PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/emailLibrary.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:49 UTC 245,170 bytes, 5,217 lines download raw bytes

1/**
2 * emailLibrary.js — Email library popup modal.
3 * Similar pattern to documentLibrary.js. Shows emails in a grid with search/filter.
4 */
5
6import spinnerModule from './spinner.js';
7import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
8import { folderDisplayName, sortedFolders } from './emailInbox.js';
9import settingsModule from './settings.js';
10import * as Modals from './modalManager.js';
11import { makeWindowDraggable } from './windowDrag.js';
12import {
13  _esc, _escLinkify, _extractName, _parseTurnMeta,
14  _formatBubbleDate, _formatRecipients, _senderColor, _initials,
15  _sanitizeHtml,
16  _TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
17  _TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
18} from './emailLibrary/utils.js';
19import {
20  _looksLikeSignature, _harvestAttribution, _extractTurnMetaFromBlockquote,
21  _foldSummary, _extractQuoteMeta, _peelSigNameLine, _isBloatedSig,
22  _tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON,
23} from './emailLibrary/signatureFold.js';
24import { state } from './emailLibrary/state.js';
25
26const API_BASE = window.location.origin;
27let _emailUnreadChipClickWired = false;
28let _libLoadSeq = 0;
29let _libFolderSeq = 0;
30let _libSearchSeq = 0;
31let _libSearchHadResults = false;
32let _activeEmailReaderForSelectAll = null;
33
34function _isEmailTypingTarget(t) {
35  return !!(t && (
36    t.tagName === 'INPUT' ||
37    t.tagName === 'TEXTAREA' ||
38    t.tagName === 'SELECT' ||
39    t.isContentEditable
40  ));
41}
42
43function _selectEmailReaderContents(reader) {
44  if (!reader || !reader.isConnected) return false;
45  const hiddenModal = reader.closest('.modal.hidden');
46  if (hiddenModal) return false;
47  const range = document.createRange();
48  range.selectNodeContents(reader);
49  const sel = window.getSelection();
50  sel?.removeAllRanges();
51  sel?.addRange(range);
52  return true;
53}
54
55function _markEmailReaderActive(reader) {
56  if (!reader) return;
57  _activeEmailReaderForSelectAll = reader;
58  if (reader.dataset.selectAllWired === '1') return;
59  reader.dataset.selectAllWired = '1';
60  reader.addEventListener('pointerdown', () => { _activeEmailReaderForSelectAll = reader; }, true);
61  reader.addEventListener('focusin', () => { _activeEmailReaderForSelectAll = reader; }, true);
62}
63
64const _COPY_EMAIL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
65
66function _decodeAttrValue(v) {
67  const tmp = document.createElement('textarea');
68  tmp.innerHTML = v || '';
69  return tmp.value;
70}
71
72function _emailAddressFromRecipientText(text) {
73  const raw = String(text || '').trim();
74  const angle = raw.match(/<\s*([^<>@\s]+@[^<>\s]+)\s*>/);
75  if (angle) return angle[1].trim();
76  const any = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
77  return any ? any[0].trim() : raw;
78}
79
80function _splitRecipientList(raw) {
81  const out = [];
82  let cur = '';
83  let quote = false;
84  let angle = false;
85  const s = String(raw || '');
86  for (let i = 0; i < s.length; i += 1) {
87    const ch = s[i];
88    if (ch === '"' && s[i - 1] !== '\\') quote = !quote;
89    else if (ch === '<' && !quote) angle = true;
90    else if (ch === '>' && !quote) angle = false;
91
92    if (ch === ',' && !quote && !angle) {
93      const part = cur.trim();
94      if (part) out.push(part);
95      cur = '';
96      continue;
97    }
98    cur += ch;
99  }
100  const tail = cur.trim();
101  if (tail) out.push(tail);
102  return out;
103}
104
105async function _copyTextToClipboard(text) {
106  const value = String(text || '');
107  if (!value) return false;
108  try {
109    if (navigator.clipboard?.writeText) {
110      await navigator.clipboard.writeText(value);
111      return true;
112    }
113  } catch (_) {}
114  try {
115    const ta = document.createElement('textarea');
116    ta.value = value;
117    ta.setAttribute('readonly', '');
118    ta.style.position = 'fixed';
119    ta.style.left = '-9999px';
120    ta.style.top = '0';
121    document.body.appendChild(ta);
122    ta.select();
123    const ok = document.execCommand('copy');
124    ta.remove();
125    return !!ok;
126  } catch (_) {
127    return false;
128  }
129}
130
131function _recipientChipHtml(full, label, extraClass = '') {
132  const fullText = String(full || '').trim();
133  const addr = _emailAddressFromRecipientText(fullText);
134  const labelText = String(label || addr || fullText || '').trim();
135  const cls = `recipient-chip${extraClass ? ` ${extraClass}` : ''}`;
136  return `<span class="${cls}" data-full="${_esc(fullText || labelText)}" data-email="${_esc(addr)}" title="Click for details"><span class="recipient-chip-label">${_esc(labelText)}</span><button type="button" class="recipient-chip-copy" title="Copy email" aria-label="Copy email" hidden>${_COPY_EMAIL_ICON}</button></span>`;
137}
138
139function _wireRecipientChips(root) {
140  if (!root || root.dataset.recipientChipsWired === '1') return;
141  root.dataset.recipientChipsWired = '1';
142  root.addEventListener('click', async (ev) => {
143    const copyBtn = ev.target.closest?.('.recipient-chip-copy');
144    if (copyBtn && root.contains(copyBtn)) {
145      ev.stopPropagation();
146      ev.preventDefault();
147      const chip = copyBtn.closest('.recipient-chip');
148      const email = chip?.dataset.email || _emailAddressFromRecipientText(_decodeAttrValue(chip?.dataset.full || ''));
149      if (!email) return;
150      try {
151        const copied = await _copyTextToClipboard(email);
152        if (!copied) throw new Error('copy failed');
153        copyBtn.classList.add('copied');
154        copyBtn.title = 'Copied';
155        showToast?.('Email copied');
156        setTimeout(() => {
157          copyBtn.classList.remove('copied');
158          copyBtn.title = 'Copy email';
159        }, 900);
160      } catch (_) {
161        showToast?.('Copy failed');
162      }
163      return;
164    }
165
166    const chip = ev.target.closest?.('.recipient-chip');
167    if (!chip || !root.contains(chip)) return;
168    ev.stopPropagation();
169    ev.preventDefault();
170    const label = chip.querySelector('.recipient-chip-label');
171    const copy = chip.querySelector('.recipient-chip-copy');
172    if (chip.classList.contains('expanded')) {
173      chip.classList.remove('expanded');
174      if (label) label.textContent = chip.dataset.name || label.textContent;
175      if (copy) copy.hidden = true;
176    } else {
177      if (!chip.dataset.name && label) chip.dataset.name = label.textContent.trim();
178      chip.classList.add('expanded');
179      const expandedText = _decodeAttrValue(chip.dataset.full || '').trim()
180        || chip.dataset.name
181        || chip.dataset.email
182        || label?.textContent?.trim()
183        || '';
184      if (label && expandedText) label.textContent = expandedText;
185      if (copy) copy.hidden = false;
186    }
187  });
188}
189
190function _emailReaderForSelectAllTarget(target) {
191  if (_isEmailTypingTarget(target)) return null;
192  const direct = target?.closest?.('.email-card-reader, #email-lib-modal .doclib-card.doclib-card-expanded');
193  if (direct) return direct.querySelector?.('.email-card-reader') || direct;
194  const expanded = document.querySelector('#email-lib-modal:not(.hidden) .doclib-card.doclib-card-expanded .email-card-reader');
195  if (expanded) return expanded;
196  return _activeEmailReaderForSelectAll;
197}
198
199document.addEventListener('keydown', (e) => {
200  if (!(e.ctrlKey || e.metaKey) || String(e.key || '').toLowerCase() !== 'a') return;
201  const reader = _emailReaderForSelectAllTarget(e.target);
202  if (!_selectEmailReaderContents(reader)) return;
203  e.preventDefault();
204  e.stopPropagation();
205  e.stopImmediatePropagation?.();
206}, true);
207
208function _syncEmailReadState(uid, isRead = true) {
209  if (uid == null) return;
210  const uidStr = String(uid);
211  const read = !!isRead;
212  const match = (state._libEmails || []).find(x => String(x.uid) === uidStr);
213  if (match) match.is_read = read;
214
215  document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => {
216    card.classList.toggle('email-card-unread', !read);
217    const titleRow = card.querySelector('.email-card-titlerow');
218    if (read) {
219      card.querySelectorAll('.email-card-unread-dot, [data-unread-dot]').forEach(n => n.remove());
220      if (titleRow) {
221        titleRow.querySelectorAll('span').forEach(s => {
222          const st = s.getAttribute('style') || '';
223          if (/width:\s*6px/.test(st) && /border-radius:\s*50%/.test(st)) s.remove();
224        });
225      }
226      return;
227    }
228
229    if (!titleRow || titleRow.querySelector('.email-card-unread-dot, [data-unread-dot]')) return;
230    const isSentFolder = /sent/i.test(state._libFolder || '');
231    if (isSentFolder) return;
232    const senderName = match ? (match.from_name || match.from_address || '') : '';
233    const dot = document.createElement('span');
234    dot.className = 'email-card-unread-dot';
235    dot.style.cssText = `width:6px;height:6px;border-radius:50%;background:${_senderColor(senderName)};flex-shrink:0;margin-left:2px;`;
236    const done = titleRow.querySelector('.email-card-done');
237    const rightCluster = titleRow.querySelector('.email-card-header-menu')?.parentElement;
238    if (done) done.insertAdjacentElement('afterend', dot);
239    else if (rightCluster) titleRow.insertBefore(dot, rightCluster);
240    else titleRow.appendChild(dot);
241  });
242}
243
244// When a reply is sent (from the doc editor), the source email is marked
245// \Answered server-side and an `email-answered` event fires. Reflect that live
246// so the email shows as done without waiting for a manual refresh.
247window.addEventListener('email-answered', (e) => {
248  const uid = e.detail && e.detail.uid;
249  if (uid == null) return;
250  const em = (state._libEmails || []).find(x => String(x.uid) === String(uid));
251  if (em) { em.is_answered = true; em.is_read = true; }
252  _syncEmailReadState(uid, true);
253  document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(card => {
254    card.classList.add('email-card-answered');
255    card.classList.remove('email-card-unread');
256    const check = card.querySelector('.email-card-done');
257    if (check) check.classList.add('active');
258  });
259});
260
261function _toggleUnreadEmails() {
262  if (state._libFolder === '__scheduled__') state._libFolder = 'INBOX';
263  state._libFilter = state._libFilter === 'unread' ? 'all' : 'unread';
264  _syncUnreadWindowGlow();
265  const folderEl = document.getElementById('email-lib-folder');
266  const filterEl = document.getElementById('email-lib-filter');
267  if (folderEl) folderEl.value = state._libFolder || 'INBOX';
268  if (filterEl) filterEl.value = state._libFilter;
269  document.getElementById('email-undone-btn')?.classList.remove('active');
270  document.getElementById('email-reminder-btn')?.classList.remove('active');
271  _loadEmailsFresh();
272}
273
274function _syncUnreadTabBadge(count) {
275  const label = count > 999 ? '999+ unread' : `${count} unread`;
276  document.querySelectorAll('.minimized-dock-chip[data-modal-id="email-lib-modal"]').forEach(chip => {
277    if (count > 0) {
278      chip.dataset.emailUnreadLabel = label;
279      chip.title = `Open ${label}`;
280    } else {
281      delete chip.dataset.emailUnreadLabel;
282      chip.title = 'Restore Email';
283    }
284  });
285}
286
287function _syncUnreadWindowGlow() {
288  document.getElementById('email-lib-modal')?.classList.toggle('email-lib-unread-active', state._libFilter === 'unread');
289}
290
291function _syncReminderClearButton() {
292  document.getElementById('email-reminders-clear-btn')?.classList.toggle('hidden', state._libFilter !== 'reminders');
293}
294
295function _renderAccountsLoading() {
296  const strip = document.getElementById('email-lib-accounts');
297  if (!strip) return;
298  strip.style.display = 'flex';
299  strip.innerHTML = '';
300  try {
301    const wp = spinnerModule.createWhirlpool(14);
302    wp.element.classList.add('email-accounts-loading-whirlpool');
303    const label = document.createElement('span');
304    label.className = 'email-accounts-loading-label';
305    label.textContent = 'Accounts';
306    strip.appendChild(wp.element);
307    strip.appendChild(label);
308  } catch (_) {
309    strip.textContent = 'Accounts...';
310  }
311}
312
313function _syncEmailReminderBellVisibility(enabled) {
314  const btn = document.getElementById('email-reminder-btn');
315  const wrap = document.querySelector('#email-lib-modal .email-search-wrap');
316  btn?.classList.toggle('hidden', !enabled);
317  wrap?.classList.toggle('email-reminder-bell-hidden', !enabled);
318}
319
320async function _loadEmailReminderBellVisibility() {
321  try {
322    const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
323    const settings = await res.json();
324    _syncEmailReminderBellVisibility(settings.reminder_channel === 'email');
325  } catch (_) {
326    _syncEmailReminderBellVisibility(false);
327  }
328}
329
330function _readCssPx(name) {
331  const v = getComputedStyle(document.documentElement).getPropertyValue(name);
332  const n = parseFloat(v);
333  return Number.isFinite(n) ? n : 0;
334}
335
336function _emailSplitLeftEdge() {
337  return _readCssPx('--icon-rail-w') + _readCssPx('--sidebar-w');
338}
339
340function _setEmailDocumentSplit(leftEdge, emailWidth) {
341  if (window.innerWidth <= 768) return;
342  // Zero gap so the doc-pane sits flush against the email's right edge.
343  // modalSnap.js's left-dock path publishes the same vars with 0 gap — both
344  // systems agree on flush so handoffs between them don't cause the doc to
345  // "jump" sideways. The 1px modal border on each side is the visual seam.
346  const splitGap = 0;
347  const left = Math.max(0, Math.round(leftEdge || 0));
348  const width = Math.max(320, Math.round(emailWidth || 420));
349  const x = left + width + splitGap;
350  document.body.classList.add('email-doc-split-active');
351  document.documentElement.style.setProperty('--email-doc-split-left-x', `${left}px`);
352  document.documentElement.style.setProperty('--email-doc-split-email-w', `${width}px`);
353  document.documentElement.style.setProperty('--email-doc-split-right-x', `${x}px`);
354}
355
356function _measureEmailDocumentSplit(modal) {
357  if (window.innerWidth <= 768 || !document.body.classList.contains('email-doc-split-active')) return;
358  const content = modal?.querySelector?.('.modal-content');
359  const rect = content?.getBoundingClientRect?.();
360  if (!rect || !rect.width) return;
361  const splitGap = 0;
362  document.documentElement.style.setProperty('--email-doc-split-right-x', `${Math.ceil(rect.right + splitGap)}px`);
363  try {
364    modal.style.setProperty('z-index', '150', 'important');
365    if (content) {
366      content.style.setProperty('position', 'absolute', 'important');
367      content.style.setProperty('left', '0px', 'important');
368      content.style.setProperty('right', 'auto', 'important');
369      content.style.setProperty('width', `${Math.ceil(rect.width)}px`, 'important');
370      content.style.setProperty('max-width', `${Math.ceil(rect.width)}px`, 'important');
371    }
372    const docPane = document.getElementById('doc-editor-pane');
373    if (docPane) {
374      docPane.style.setProperty('position', 'fixed', 'important');
375      docPane.style.setProperty('left', `${Math.ceil(rect.right + splitGap)}px`, 'important');
376      docPane.style.setProperty('right', '0px', 'important');
377      docPane.style.setProperty('top', '0px', 'important');
378      docPane.style.setProperty('bottom', '0px', 'important');
379      docPane.style.setProperty('width', 'auto', 'important');
380      docPane.style.setProperty('max-width', 'none', 'important');
381      docPane.style.setProperty('height', '100vh', 'important');
382      docPane.style.setProperty('z-index', '260', 'important');
383    }
384  } catch (_) {}
385}
386
387function _scheduleEmailDocumentSplitMeasure(modal) {
388  requestAnimationFrame(() => {
389    _measureEmailDocumentSplit(modal);
390    requestAnimationFrame(() => _measureEmailDocumentSplit(modal));
391  });
392  setTimeout(() => _measureEmailDocumentSplit(modal), 260);
393  setTimeout(() => _measureEmailDocumentSplit(modal), 700);
394}
395
396function _clearEmailDocumentSplit() {
397  document.body.classList.remove('email-doc-split-active');
398  document.documentElement.style.removeProperty('--email-doc-split-left-x');
399  document.documentElement.style.removeProperty('--email-doc-split-email-w');
400  document.documentElement.style.removeProperty('--email-doc-split-right-x');
401  const docPane = document.getElementById('doc-editor-pane');
402  if (!docPane) return;
403  [
404    'position', 'left', 'right', 'top', 'bottom', 'width', 'max-width',
405    'height', 'z-index', 'transform',
406  ].forEach(prop => docPane.style.removeProperty(prop));
407}
408
409function _hasDesktopRoomForEmailAndDocument(modal) {
410  if (window.innerWidth <= 768) return false;
411  if (window.innerWidth >= 1100) return true;
412  const content = modal?.querySelector?.('.modal-content');
413  const rect = content?.getBoundingClientRect?.();
414  const isFullscreen = modal?.classList?.contains('email-lib-fullscreen')
415    || modal?.classList?.contains('email-window-fullscreen');
416  const emailWidth = isFullscreen
417    ? Math.min(440, Math.max(360, Math.round(window.innerWidth * 0.30)))
418    : Math.max(360, Math.round(rect?.width || 440));
419  const docMinWidth = 560;
420  const breathingRoom = 72;
421  const leftEdge = isFullscreen ? _emailSplitLeftEdge() : Math.max(0, Math.round(rect?.left || _emailSplitLeftEdge()));
422  return (window.innerWidth - leftEdge - emailWidth) >= (docMinWidth + breathingRoom);
423}
424
425function _prepareEmailWindowForDocument(modal) {
426  if (window.innerWidth <= 768) return true;
427  if (!modal) return false;
428  if (!_hasDesktopRoomForEmailAndDocument(modal)) {
429    _clearEmailDocumentSplit();
430    return true;
431  }
432  if (modal.classList.contains('modal-left-docked')) {
433    const content = modal.querySelector('.modal-content');
434    const rect = content?.getBoundingClientRect?.();
435    if (content?._leftDockNavObs) {
436      try { content._leftDockNavObs.navObs.disconnect(); } catch (_) {}
437      try { content._leftDockNavObs.bodyObs && content._leftDockNavObs.bodyObs.disconnect(); } catch (_) {}
438      try { content._leftDockNavObs.disconnectDocObs && content._leftDockNavObs.disconnectDocObs(); } catch (_) {}
439      try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {}
440      delete content._leftDockNavObs;
441    }
442    modal.classList.remove('modal-left-docked');
443    modal.classList.add('email-snap-left');
444    document.body.classList.remove('left-dock-active');
445    document.documentElement.style.removeProperty('--left-dock-w');
446    if (content) {
447      delete content._dockSide;
448      content.style.position = 'fixed';
449      content.style.left = Math.round(rect?.left || _emailSplitLeftEdge()) + 'px';
450      content.style.top = '0';
451      content.style.right = 'auto';
452      content.style.bottom = '0';
453      content.style.width = Math.round(rect?.width || 440) + 'px';
454      content.style.maxWidth = Math.round(rect?.width || 440) + 'px';
455      content.style.height = '100vh';
456      content.style.maxHeight = '100vh';
457      content.style.borderRadius = '0';
458      content.style.transform = 'none';
459      content.style.margin = '0';
460    }
461  }
462  if (modal.classList.contains('email-snap-left') || modal.classList.contains('modal-left-docked')) {
463    const rect = modal.querySelector('.modal-content')?.getBoundingClientRect?.();
464    _setEmailDocumentSplit(rect?.left || _emailSplitLeftEdge(), rect?.width || 420);
465    _scheduleEmailDocumentSplitMeasure(modal);
466    return false;
467  }
468  // If Email is fullscreen and there is room, park it left instead of
469  // minimizing so the document/compose pane can open beside it.
470  _snapEmailModalToLeftSidebar(modal);
471  return false;
472}
473
474function _wireUnreadTabClick() {
475  if (_emailUnreadChipClickWired) return;
476  _emailUnreadChipClickWired = true;
477  document.addEventListener('click', (e) => {
478    const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id="email-lib-modal"][data-email-unread-label]');
479    if (!chip || e.target?.classList?.contains('minimized-dock-x')) return;
480    setTimeout(_toggleUnreadEmails, 0);
481  });
482}
483
484async function _deleteEmailAndAdvance(em, card, opts = {}) {
485  if (!em || em.uid == null) return;
486  if (opts.confirm !== false) {
487    const subject = em.subject || '(no subject)';
488    const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true });
489    if (!ok) return;
490  }
491  const wasExpanded = !!card?.classList?.contains('doclib-card-expanded');
492  const sibling = wasExpanded
493    ? (_findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1))
494    : null;
495  const nextUid = sibling ? sibling.dataset.uid : null;
496  try {
497    await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
498  } catch (err) {
499    console.error('Failed to delete email:', err);
500    showToast('Failed to delete email');
501    return;
502  }
503  await _animateEmailCardRemoval([em.uid]);
504  state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
505  state._selectedUids.delete(em.uid);
506  _updateBulkBar();
507  _renderGrid();
508  _libCacheWriteBack();
509  showToast('Moved to Trash');
510  if (!wasExpanded || !nextUid) return;
511  const grid = document.getElementById('email-lib-grid');
512  const nextCard = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(nextUid))}"]`);
513  const nextEm = state._libEmails.find(e => String(e.uid) === String(nextUid));
514  if (nextCard && nextEm) {
515    await _toggleCardPreview(nextCard, nextEm);
516    nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
517  } else {
518    document.getElementById('email-lib-modal')?.classList.remove('email-reading');
519  }
520}
521
522function _animateEmailCardRemoval(uids, opts = {}) {
523  const uidSet = new Set((uids || []).map(uid => String(uid)));
524  if (!uidSet.size) return Promise.resolve();
525  const grid = document.getElementById('email-lib-grid');
526  if (!grid) return Promise.resolve();
527  const cards = Array.from(grid.querySelectorAll('.doclib-card[data-uid]'))
528    .filter(card => uidSet.has(String(card.dataset.uid)));
529  if (!cards.length) return Promise.resolve();
530  const duration = Number(opts.duration || 230);
531
532  for (const card of cards) {
533    const rect = card.getBoundingClientRect();
534    card.style.setProperty('--email-remove-h', `${Math.max(rect.height, card.scrollHeight)}px`);
535    card.style.maxHeight = 'var(--email-remove-h)';
536    card.style.overflow = 'hidden';
537    card.classList.add('email-card-removing');
538  }
539
540  return new Promise(resolve => {
541    window.setTimeout(resolve, duration + 35);
542  });
543}
544
545
546// URL-suffix helper — appends &account_id=... when an account is actively selected.
547// Every email route call in this file goes through here so switching accounts
548// is a single-variable flip.
549// Open the Settings modal and activate a specific tab. Used by empty-state
550// "Set up at: Settings › X" links across email/calendar/etc.
551function _openSettingsTab(tab) {
552  if (tab === 'integrations' && window.adminModule && typeof window.adminModule.open === 'function') {
553    window.adminModule.open('integrations');
554    return;
555  }
556  if (settingsModule && typeof settingsModule.open === 'function') {
557    settingsModule.open(tab || 'services');
558    return;
559  }
560  const modal = document.getElementById('settings-modal');
561  if (!modal) return;
562  modal.classList.remove('hidden');
563  const tabBtn = modal.querySelector(`[data-settings-tab="${tab || 'services'}"]`);
564  if (tabBtn) tabBtn.click();
565}
566
567function _emailSetupHintHtml() {
568  return '<div style="margin-top:6px;opacity:0.72;font-size:11px;">' +
569    'Setup: <a href="#" data-open-settings="integrations" style="color:var(--accent,var(--red));text-decoration:underline;">Settings &rsaquo; Integrations</a>' +
570    '</div>';
571}
572
573function _wireEmailSetupHint(root) {
574  root?.querySelectorAll?.('[data-open-settings]').forEach(link => {
575    if (link.dataset.emailSetupBound === '1') return;
576    link.dataset.emailSetupBound = '1';
577    link.addEventListener('click', (e) => {
578      e.preventDefault();
579      _openSettingsTab(link.dataset.openSettings || 'integrations');
580    });
581  });
582}
583
584function _acct() {
585  return state._libAccountId ? `&account_id=${encodeURIComponent(state._libAccountId)}` : '';
586}
587
588// Per-(account, folder, filter, attachments) cache of the most recent
589// first-page list response. Lets reopen-after-close paint the previous
590// list instantly while the network refresh runs behind it — the modal
591// used to wipe its DOM and spinner-from-empty on every open, even when
592// the same view was just visible a second ago.
593//
594// Session-only (lives in module scope, cleared on hard reload). Search
595// results and __scheduled__ are deliberately not cached.
596const _libListCache = new Map();
597const _LIB_CACHE_MAX = 24;
598let _libPrewarmTimer = null;
599let _libPrewarmPromise = null;
600let _libLastPrewarmAt = 0;
601
602function _libCacheKeyFor(accountId, folder, filter, hasAttachments) {
603  return [
604    accountId || '',
605    folder || '',
606    filter || '',
607    hasAttachments ? 1 : 0,
608  ].join('|');
609}
610function _libCacheKey() {
611  return _libCacheKeyFor(
612    state._libAccountId || '',
613    state._libFolder || '',
614    state._libFilter || '',
615    state._libHasAttachments
616  );
617}
618function _libCacheGet(key) { return _libListCache.get(key) || null; }
619function _libCachePut(key, value) {
620  // Re-insert to bump LRU recency.
621  _libListCache.delete(key);
622  _libListCache.set(key, value);
623  if (_libListCache.size > _LIB_CACHE_MAX) {
624    const oldest = _libListCache.keys().next().value;
625    _libListCache.delete(oldest);
626  }
627}
628
629function _resetEmailListForFreshLoad() {
630  state._libOffset = 0;
631  state._libEmails = [];
632  state._libTotal = 0;
633  _libLoadSeq += 1;
634  const grid = document.getElementById('email-lib-grid');
635  if (grid) _renderEmailLoading(grid);
636  const stats = document.getElementById('email-lib-stats');
637  if (stats) stats.textContent = 'Loading...';
638}
639
640function _loadEmailsFresh() {
641  _resetEmailListForFreshLoad();
642  return _loadEmails({ force: true, useCache: false });
643}
644
645export function prewarmEmailLibrary({ delay = 2500 } = {}) {
646  if (_libPrewarmTimer || _libPrewarmPromise) return;
647  const elapsed = Date.now() - _libLastPrewarmAt;
648  if (elapsed >= 0 && elapsed < 60000) return;
649  _libPrewarmTimer = setTimeout(() => {
650    _libPrewarmTimer = null;
651    _libPrewarmPromise = _prewarmDefaultEmailView()
652      .catch(() => {})
653      .finally(() => { _libPrewarmPromise = null; });
654  }, Math.max(0, Number(delay) || 0));
655}
656
657async function _prewarmDefaultEmailView() {
658  if (state._libOpen) return;
659  _libLastPrewarmAt = Date.now();
660  const folder = 'INBOX';
661  const filter = 'all';
662  const accountId = state._libAccountId || '';
663  const ck = _libCacheKeyFor(accountId, folder, filter, false);
664  if (_libCacheGet(ck)) return;
665
666  // The accounts request is cheap and warms the account strip for first open.
667  // Then the list request warms both the client cache and the backend IMAP/read
668  // cache. Failure stays silent: no configured mail should not nag on app boot.
669  try {
670    const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
671    if (accountsRes.ok) {
672      const accountsData = await accountsRes.json().catch(() => ({}));
673      if (Array.isArray(accountsData.accounts)) state._libAccounts = accountsData.accounts;
674    }
675  } catch (_) {}
676
677  const accountQS = accountId ? `&account_id=${encodeURIComponent(accountId)}` : '';
678  const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${accountQS}&limit=100&offset=0&filter=${filter}`, {
679    credentials: 'same-origin',
680  });
681  if (!res.ok) return;
682  const data = await res.json().catch(() => null);
683  if (!data || data.error) return;
684  _libCachePut(ck, { emails: data.emails || [], total: data.total || 0 });
685}
686function _libCacheWriteBack() {
687  // After a local mutation that already updated state._libEmails
688  // (delete / archive / bulk), sync the change into the cache so the
689  // next reopen doesn't briefly show the pre-mutation state before the
690  // refetch wins. Skipped during search (results aren't the real list)
691  // and on the scheduled virtual folder.
692  if (state._libSearch) return;
693  if (state._libFolder === '__scheduled__') return;
694  const ck = _libCacheKey();
695  if (_libListCache.has(ck)) {
696    _libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal });
697  }
698}
699
700// Expose the active account id to other modules (document.js uses this when sending).
701// Simple global rather than cross-module import to keep coupling minimal.
702function _publishActiveAccount() {
703  try { window.__odysseusActiveEmailAccount = state._libAccountId || null; } catch (_) {}
704  // Publish the active account's own address so reply-all can exclude us from
705  // the recipient list. This global was read in emailInbox.js but never set.
706  try {
707    const accts = state._libAccounts || [];
708    const active = accts.find(a => a && a.id === state._libAccountId)
709      || accts.find(a => a && a.is_default)
710      || accts[0];
711    window._myEmailAddress = (active && (active.from_address || active.imap_user)) || '';
712    // Also publish every configured address so reply-all can exclude all of
713    // the user's own mailboxes, not just the active one (multi-account users
714    // were getting their other addresses added to Cc).
715    const all = [];
716    for (const a of accts) {
717      if (a && a.from_address) all.push(a.from_address);
718      if (a && a.imap_user) all.push(a.imap_user);
719    }
720    window._myEmailAddresses = all;
721  } catch (_) {}
722}
723
724export function initEmailLibrary(config) {
725  state._docModule = config.documentModule;
726  state._onEmailClick = config.onEmailClick;
727}
728
729export function isOpen() { return state._libOpen; }
730
731export function openEmailLibrary(opts = {}) {
732  // Force-clean any stale state from previous attempts
733  const existing = document.getElementById('email-lib-modal');
734  if (existing) existing.remove();
735  if (state._libEscHandler) {
736    document.removeEventListener('keydown', state._libEscHandler, true);
737    state._libEscHandler = null;
738  }
739  state._libOpen = true;
740  // On mobile the sidebar overlays content — close it so the email view isn't
741  // opened behind it (same pattern as session-switch/delete).
742  if (window.innerWidth <= 768) {
743    const _sb = document.getElementById('sidebar');
744    if (_sb) _sb.classList.add('hidden');
745    const _bd = document.getElementById('sidebar-backdrop');
746    if (_bd) _bd.classList.remove('visible');
747    // Email was opened last → bring the email windows IN FRONT of any open doc
748    // (they alternate: whichever was opened last wins). The doc stays open
749    // behind it; reopening the doc flips it back on top.
750    document.body.classList.add('email-front');
751  }
752  state._libEmails = [];
753  state._libOffset = 0;
754  state._libSearch = '';
755  state._libFilter = 'all';
756  state._libHasAttachments = false;
757  // Animate the very first card render with a domino cascade (same as the
758  // sidebar section-domino-in keyframe). Reset by _renderGrid after the
759  // animation is queued so subsequent filter/sort re-renders are instant.
760  state._libJustOpened = true;
761  if (Object.prototype.hasOwnProperty.call(opts, 'account_id')) {
762    state._libAccountId = opts.account_id || null;
763    _publishActiveAccount();
764  }
765  if (opts.folder) state._libFolder = opts.folder;
766  state._libPendingExpandUid = opts.uid || null;
767
768  const modal = document.createElement('div');
769  modal.className = 'modal';
770  modal.id = 'email-lib-modal';
771  modal.innerHTML = `
772    <div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);max-height:85vh;background:var(--bg);">
773      <div class="modal-header">
774        <h4>
775          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;">
776            <rect x="2" y="4" width="20" height="16" rx="2"/>
777            <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
778          </svg>
779          Email
780          <span id="email-lib-unread-badge" class="email-lib-unread-badge" role="button" tabindex="0" title="Show unread emails" style="display:none"></span>
781          <span id="email-lib-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal;margin-left:8px;position:relative;top:-2px"></span>
782        </h4>
783        <div class="email-lib-header-actions" style="display:flex;align-items:center;gap:8px;">
784          <button class="close-btn" id="email-lib-close">\u2716</button>
785        </div>
786      </div>
787      <div class="modal-body" style="display:flex;flex-direction:column;gap:10px;overflow:hidden;">
788        <div class="admin-card" style="flex:1;flex-direction:column;display:flex;overflow:hidden;">
789          <p class="memory-desc doclib-desc">All emails. Click to open as a document.</p>
790          <div class="email-accounts-row">
791            <div id="email-lib-accounts" style="display:flex;gap:4px;flex-wrap:wrap;flex:1;"></div>
792            <button class="memory-toolbar-btn email-compose-jiggle" id="email-lib-compose-btn">
793              <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
794              New
795            </button>
796          </div>
797          <div class="memory-toolbar">
798            <div class="memory-category-filters">
799              <select class="memory-sort-select" id="email-lib-folder" style="flex:1;min-width:0;text-overflow:ellipsis;">
800                <option value="INBOX">Inbox</option>
801              </select>
802              <select class="memory-sort-select" id="email-lib-filter" style="flex:1;min-width:0;">
803                <option value="all">All</option>
804                <option value="unread">Unread</option>
805                <option value="favorites">Favorites</option>
806                <option value="undone">Undone</option>
807                <option value="reminders">Reminders</option>
808                <option value="unanswered">Unanswered</option>
809                <option value="pending_30d">Pending · 30d</option>
810                <option value="stale_30d">Stale · &gt;30d</option>
811                <optgroup label="Tags">
812                  <option value="tag:urgent">Urgent</option>
813                  <option value="tag:reply-soon">Reply soon</option>
814                  <option value="tag:spam">Spam</option>
815                  <option value="tag:newsletter">Newsletter</option>
816                  <option value="tag:marketing">Marketing</option>
817                </optgroup>
818              </select>
819              <button class="memory-toolbar-btn email-filter-select-btn" id="email-lib-select-btn">Select</button>
820              <button class="memory-toolbar-btn email-filter-refresh-btn" id="email-lib-refresh-btn" title="Refresh">
821                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg>
822              </button>
823              <button class="memory-toolbar-btn email-reminders-clear-btn hidden" id="email-reminders-clear-btn" title="Permanently delete Odysseus reminder emails">
824                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/></svg>
825                Clear
826              </button>
827            </div>
828            <div class="email-search-row" style="display:flex;gap:6px;align-items:flex-start;">
829            <div class="email-search-wrap" style="position:relative;flex:1;min-width:140px;">
830              <input type="text" id="email-lib-search" placeholder="Search emails\u2026" class="memory-search-input" style="width:100%;padding-right:96px;" />
831              <button class="memory-toolbar-btn email-undone-toggle email-undone-toggle-inline" id="email-undone-btn" title="Show only emails not marked as done (undone)">
832                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
833              </button>
834              <button class="memory-toolbar-btn email-reminder-toggle-inline hidden" id="email-reminder-btn" title="Show Odysseus reminder emails">
835                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></svg>
836              </button>
837              <button class="memory-toolbar-btn email-attach-toggle email-attach-toggle-inline" id="email-attach-btn" title="Show only emails with attachments">
838                <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
839              </button>
840            </div>
841            </div>
842          </div>
843          <div id="email-lib-bulk" class="memory-bulk-bar hidden" style="margin-bottom:5px;">
844            <label class="memory-bulk-check-all" style="position:relative;top:2px;"><input type="checkbox" id="email-lib-select-all"> All</label>
845            <span id="email-lib-selected-count" style="position:relative;top:1px;">0 Selected</span>
846            <button class="memory-toolbar-btn" id="email-lib-bulk-actions" style="position:relative;top:-2px;">
846<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Actions <span style="opacity:0.55;font-size:9px;">▼</span></button>
847            <button class="memory-toolbar-btn" id="email-lib-bulk-delete" style="position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>
848            <button class="memory-toolbar-btn" id="email-lib-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-2px;">
848<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
849          </div>
850          <div id="email-lib-grid" class="doclib-grid"></div>
851          <button class="email-lib-fab" id="email-lib-fab" type="button" aria-label="New email">
852            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="4.5" width="19" height="15" rx="2.5"/><path d="M3 6.5l9 6 9-6"/></svg>
853            <span class="email-lib-fab-label">New</span>
854          </button>
855        </div>
856      </div>
857    </div>
858  `;
859
860  document.body.appendChild(modal);
861  modal.style.display = 'block';
862  // Make modal background non-blocking so user can interact with rest of the app
863  modal.style.cssText += 'pointer-events:none;background:transparent;';
864
865  // Register so the chip carries the right label/icon. restoreFn left
866  // empty — just unminimizing the modal is enough; whatever email was
867  // expanded inside stays expanded.
868  try {
869    Modals.register('email-lib-modal', {
870      label: 'Email',
871      icon: 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7',
872      closeFn: () => {
873        const m = document.getElementById('email-lib-modal');
874        if (m) m.classList.add('hidden');
875      },
876      restoreFn: () => {
877        // Reopened last → bring the email windows in front of any open doc.
878        document.body.classList.add('email-front');
879        // Mobile: tapping the library chip chips down any open email
880        // reader so the library is the only visible window. Pairs with
881        // the per-reader restoreFn that chips the library down when a
882        // reader is brought up.
883        if (window.innerWidth <= 768) {
884          document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
885            try {
886              if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
887                Modals.minimize(other.id);
888              }
889            } catch {}
890          });
891        }
892      },
893    });
894  } catch (_) {}
895  _wireUnreadTabClick();
896  const unreadBadge = document.getElementById('email-lib-unread-badge');
897  unreadBadge?.addEventListener('click', (e) => {
898    e.stopPropagation();
899    _toggleUnreadEmails();
900  });
901  unreadBadge?.addEventListener('keydown', (e) => {
902    if (e.key !== 'Enter' && e.key !== ' ') return;
903    e.preventDefault();
904    _toggleUnreadEmails();
905  });
906  const content = modal.querySelector('.modal-content');
907  if (content) {
908    const isMobile = window.innerWidth <= 768;
909    if (isMobile) {
910      // Bottom-anchored sheet on mobile
911      content.style.position = 'fixed';
912      content.style.pointerEvents = 'auto';
913      content.style.left = '0';
914      content.style.right = '0';
915      content.style.bottom = '0';
916      content.style.top = 'auto';
917      content.style.transform = 'none';
918    } else {
919      // Center on screen using fixed positioning + computed offsets
920      content.style.position = 'fixed';
921      content.style.pointerEvents = 'auto';
922      // Wait a frame for size to stabilize, then center. Center against the
923      // modal's max-height (85vh) — NOT the live offsetHeight, which is tiny
924      // while the email list is still loading and put the window ~1/3 down
925      // (then it grew off the bottom as the list filled in).
926      requestAnimationFrame(() => {
927        const w = content.offsetWidth;
928        const refH = window.innerHeight * 0.85;
929        content.style.left = Math.max(20, (window.innerWidth - w) / 2) + 'px';
930        content.style.top = Math.max(20, (window.innerHeight - refH) / 2) + 'px';
931        content.style.transform = 'none';
932      });
933    }
934  }
935
936  // Wire events
937  document.getElementById('email-lib-close').addEventListener('click', closeEmailLibrary);
938
939  // Clicking the modal header (anywhere except buttons/inputs) collapses
940  // any currently-expanded email card and returns to the inbox list view.
941  // Acts as a "back to email menu" gesture.
942  const libHeader = modal.querySelector('.modal-header');
943  if (libHeader) {
944    libHeader.style.cursor = 'pointer';
945    libHeader.addEventListener('click', (ev) => {
946      if (ev.target.closest('button, input, select, a')) return;
947      const g = document.getElementById('email-lib-grid');
948      if (!g) return;
949      g.querySelectorAll('.doclib-card.doclib-card-expanded').forEach(c => {
950        const uid = c.dataset.uid;
951        const liveEm = state._libEmails.find(e => String(e.uid) === String(uid));
952        if (liveEm) _toggleCardPreview(c, liveEm);
953      });
954    });
955  }
956
957  // Drag-to-top edge → snap to fullscreen (Aero Snap). Dragging away from
958  // the top edge while fullscreen unsnaps back to a centered window.
959  _makeDraggable(content, modal, 'email-lib-fullscreen');
960
961  document.getElementById('email-lib-folder').addEventListener('change', (e) => {
962    state._libFolder = e.target.value;
963    _loadEmailsFresh();
964  });
965  document.getElementById('email-lib-filter').addEventListener('change', (e) => {
966    state._libFilter = e.target.value;
967    _syncUnreadWindowGlow();
968    _syncReminderClearButton();
969    _loadEmailsFresh();
970    // Sync quick-toggle active states so they mirror the dropdown.
971    document.getElementById('email-undone-btn')?.classList.toggle('active', state._libFilter === 'undone');
972    document.getElementById('email-reminder-btn')?.classList.toggle('active', state._libFilter === 'reminders');
973  });
974  document.getElementById('email-attach-btn')?.addEventListener('click', () => {
975    const btn = document.getElementById('email-attach-btn');
976    state._libHasAttachments = !state._libHasAttachments;
977    btn?.classList.toggle('active', state._libHasAttachments);
978    _syncReminderClearButton();
979    _loadEmailsFresh();
980  });
981  document.getElementById('email-reminders-clear-btn')?.addEventListener('click', async () => {
982    const ok = await styledConfirm('Permanently delete all Odysseus reminder emails?', {
983      confirmText: 'Delete',
984      cancelText: 'Cancel',
985      danger: true,
986    });
987    if (!ok) return;
988    try {
989      const res = await fetch(`${API_BASE}/api/email/odysseus/reminders?permanent=1${_acct()}`, {
990        method: 'DELETE',
991        credentials: 'same-origin',
992      });
993      const data = await res.json().catch(() => ({}));
994      showToast(`Deleted ${data.deleted || 0} reminder email${(data.deleted || 0) === 1 ? '' : 's'}`);
995      if ((data.deleted || 0) > 0) {
996        const visibleUids = Array.from(document.querySelectorAll('#email-lib-grid .doclib-card[data-uid]'))
997          .map(card => card.dataset.uid)
998          .filter(Boolean);
999        await _animateEmailCardRemoval(visibleUids);
1000      }
1001      state._libFilter = 'all';
1002      const filterEl = document.getElementById('email-lib-filter');
1003      if (filterEl) filterEl.value = 'all';
1004      document.getElementById('email-reminder-btn')?.classList.remove('active');
1005      _syncReminderClearButton();
1006      _loadEmailsFresh();
1007    } catch (err) {
1008      console.error(err);
1009      showToast('Failed to clear reminder emails');
1010    }
1011  });
1012  document.getElementById('email-undone-btn')?.addEventListener('click', () => {
1013    const btn = document.getElementById('email-undone-btn');
1014    const filterEl = document.getElementById('email-lib-filter');
1015    if (state._libFilter === 'undone') {
1016      state._libFilter = 'all';
1017      filterEl.value = 'all';
1018      btn.classList.remove('active');
1019    } else {
1020      state._libFilter = 'undone';
1021      filterEl.value = 'undone';
1022      btn.classList.add('active');
1023      document.getElementById('email-reminder-btn')?.classList.remove('active');
1024    }
1025    _syncUnreadWindowGlow();
1026    _syncReminderClearButton();
1027    _loadEmailsFresh();
1028  });
1029  document.getElementById('email-reminder-btn')?.addEventListener('click', () => {
1030    const btn = document.getElementById('email-reminder-btn');
1031    const filterEl = document.getElementById('email-lib-filter');
1032    if (state._libFilter === 'reminders') {
1033      state._libFilter = 'all';
1034      filterEl.value = 'all';
1035      btn.classList.remove('active');
1036    } else {
1037      state._libFilter = 'reminders';
1038      filterEl.value = 'reminders';
1039      btn.classList.add('active');
1040      document.getElementById('email-undone-btn')?.classList.remove('active');
1041    }
1042    _syncUnreadWindowGlow();
1043    _syncReminderClearButton();
1044    _loadEmailsFresh();
1045  });
1046  // The old "sort" dropdown (Latest / Unread first / Favorites first) was merged
1047  // into the filter dropdown above — "Favorites" is now a filter (server-side
1048  // \Flagged search). _libSort stays at its 'recent' default so the grid keeps
1049  // the API's newest-first order.
1050
1051  let searchTimer = null;
1052  document.getElementById('email-lib-search').addEventListener('input', (e) => {
1053    state._libSearch = e.target.value;
1054    if (searchTimer) clearTimeout(searchTimer);
1055    searchTimer = setTimeout(_doSearch, 350);
1056  });
1057
1058  document.getElementById('email-lib-refresh-btn').addEventListener('click', async () => {
1059    const btn = document.getElementById('email-lib-refresh-btn');
1060    btn?.classList.add('email-lib-refreshing');
1061    state._libOffset = 0;
1062    // Don't wipe state._libEmails — _loadEmails will paint the cached
1063    // list while the forced refetch runs, so the grid doesn't blank out
1064    // mid-refresh. `force: true` adds the cache-buster so the server's
1065    // 8s list cache is bypassed for an actually-fresh result.
1066    try {
1067      await _loadEmails({ force: true });
1068    } finally {
1069      btn?.classList.remove('email-lib-refreshing');
1070      // Flash a checkmark for ~900ms so the user gets a clear "done" cue.
1071      if (btn) {
1072        const orig = btn.innerHTML;
1073        btn.classList.add('email-lib-refresh-done');
1074        btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><polyline points="20 6 9 17 4 12"/></svg>';
1075        setTimeout(() => {
1076          if (btn.classList.contains('email-lib-refresh-done')) {
1077            btn.classList.remove('email-lib-refresh-done');
1078            btn.innerHTML = orig;
1079          }
1080        }, 900);
1081      }
1082    }
1083  });
1084
1085
1086  const _composeNew = () => {
1087    // Desktop: keep Email open when there is enough room for it plus the
1088    // compose/document pane. Mobile still tabs down so the doc owns the screen.
1089    if (_prepareEmailWindowForDocument(document.getElementById('email-lib-modal'))) {
1090      if (!Modals.minimize('email-lib-modal')) closeEmailLibrary();
1091    }
1092    if (state._onEmailClick) state._onEmailClick({ compose: true });
1093    if (document.body.classList.contains('email-doc-split-active')) {
1094      _scheduleEmailDocumentSplitMeasure(document.getElementById('email-lib-modal'));
1095    }
1096  };
1097  document.getElementById('email-lib-compose-btn').addEventListener('click', _composeNew);
1098
1099  // Mobile FAB: same action as the (desktop) New button, plus collapse-to-icon
1100  // while the list scrolls and spring back out to "New" when scrolling stops.
1101  const _fab = document.getElementById('email-lib-fab');
1102  if (_fab) {
1103    _fab.addEventListener('click', _composeNew);
1104    const _grid = document.getElementById('email-lib-grid');
1105    if (_grid) {
1106      let _fabIdle = null;
1107      _grid.addEventListener('scroll', () => {
1108        _fab.classList.add('collapsed');
1109        clearTimeout(_fabIdle);
1110        _fabIdle = setTimeout(() => _fab.classList.remove('collapsed'), 280);
1111        _positionFab();   // Firefox's toolbar shows/hides on scroll
1112      }, { passive: true });
1113    }
1114
1115    // Keep the FAB above the browser's bottom toolbar. env(safe-area-inset)
1116    // doesn't cover Firefox-for-Android's URL bar, and its 100dvh handling is
1117    // unreliable, so measure how far the panel extends below the *visible*
1118    // (visualViewport) area and lift the button by that much.
1119    function _positionFab() {
1120      if (!_fab.isConnected) {       // modal was rebuilt/closed — stop listening
1121        window.visualViewport?.removeEventListener('resize', _positionFab);
1122        window.visualViewport?.removeEventListener('scroll', _positionFab);
1123        window.removeEventListener('resize', _positionFab);
1124        return;
1125      }
1126      const card = _fab.parentElement;            // .admin-card (positioned)
1127      const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
1128      const overflowBelow = card ? Math.max(0, Math.round(card.getBoundingClientRect().bottom - vh)) : 0;
1129      _fab.style.bottom = `calc(18px + env(safe-area-inset-bottom, 0px) + ${overflowBelow}px)`;
1130    }
1131    if (window.visualViewport) {
1132      window.visualViewport.addEventListener('resize', _positionFab);
1133      window.visualViewport.addEventListener('scroll', _positionFab);
1134    }
1135    window.addEventListener('resize', _positionFab);
1136    // Run after layout settles (modal opens with an animation).
1137    requestAnimationFrame(() => requestAnimationFrame(_positionFab));
1138    setTimeout(_positionFab, 300);
1139
1140    // Reveal the FAB with a scale-from-center pop only AFTER the email list has
1141    // rendered (the window is "fully loaded") — position it first while it's
1142    // still invisible so it never flashes at the top and slides down.
1143    let _revealed = false;
1144    const _revealFab = () => {
1145      if (_revealed || !_fab.isConnected) return;
1146      _revealed = true;
1147      _positionFab();
1148      // The FAB is an absolute child of .modal-content, which slides up on open
1149      // (sheet-enter). Wait until that entrance finishes before popping the FAB
1150      // in, otherwise it rides the slide ("swipes down with the window").
1151      const content = _fab.closest('.modal-content');
1152      const pop = () => { _positionFab(); requestAnimationFrame(() => _fab.classList.add('fab-revealed')); };
1153      if (!content || content.classList.contains('sheet-ready')) {
1154        pop();
1155      } else {
1156        let done = false;
1157        const onEnd = () => {
1158          if (done) return; done = true;
1159          content.removeEventListener('animationend', onEnd);
1160          pop();
1161        };
1162        content.addEventListener('animationend', onEnd);
1163        setTimeout(onEnd, 450);  // fallback if animationend doesn't fire
1164      }
1165    };
1166    if (_grid) {
1167      if (_grid.children.length) {
1168        _revealFab();
1169      } else {
1170        const _gobs = new MutationObserver(() => {
1171          if (_grid.children.length) { _gobs.disconnect(); _revealFab(); }
1172        });
1173        _gobs.observe(_grid, { childList: true });
1174        // Safety net — never leave the FAB hidden if the list stays empty.
1175        setTimeout(() => { _gobs.disconnect(); _revealFab(); }, 1600);
1176      }
1177    } else {
1178      setTimeout(_revealFab, 400);
1179    }
1180  }
1181
1182  // Select mode toggle
1183  document.getElementById('email-lib-select-btn').addEventListener('click', () => {
1184    state._selectMode = !state._selectMode;
1185    state._selectedUids.clear();
1186    _updateBulkBar();
1187    _renderGrid();
1188  });
1189  document.getElementById('email-lib-select-all').addEventListener('change', (e) => {
1190    if (e.target.checked) {
1191      state._libEmails.forEach(em => state._selectedUids.add(em.uid));
1192    } else {
1193      state._selectedUids.clear();
1194    }
1195    _updateBulkBar();
1196    _renderGrid();
1197  });
1198
1199  // Bulk cancel — wired with the same teardown a fresh Cancel-via-toggle does.
1200  // Lets the global Esc handler (keyboard-shortcuts.js) close select mode by
1201  // clicking the visible [id$="-bulk-cancel"] button.
1202  document.getElementById('email-lib-bulk-cancel')?.addEventListener('click', () => {
1203    state._selectMode = false;
1204    state._selectedUids.clear();
1205    _updateBulkBar();
1206    _renderGrid();
1207  });
1208
1209  // Bulk actions
1210  document.getElementById('email-lib-bulk-actions').addEventListener('click', (e) => {
1211    e.stopPropagation();
1212    if (state._selectedUids.size === 0) {
1213      showToast('Select emails first');
1214      return;
1215    }
1216    _showBulkActionsMenu(e.currentTarget);
1217  });
1218  document.getElementById('email-lib-bulk-delete')?.addEventListener('click', (e) => {
1219    e.stopPropagation();
1220    if (state._selectedUids.size === 0) {
1221      showToast('Select emails first');
1222      return;
1223    }
1224    _bulkAction('delete');
1225  });
1226
1227  const selectExpandedEmailText = () => {
1228    const expanded = document.querySelector('#email-lib-modal .doclib-card.doclib-card-expanded');
1229    const reader = expanded?.querySelector('.email-card-reader') || expanded;
1230    return _selectEmailReaderContents(reader);
1231  };
1232
1233  // ESC to close + Arrow nav + Delete on the selected / currently-expanded email.
1234  state._libEscHandler = (e) => {
1235    const modal = document.getElementById('email-lib-modal');
1236    if (!modal || modal.classList.contains('hidden')) return;
1237    if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'a') {
1238      const t = e.target;
1239      if (_isEmailTypingTarget(t)) return;
1240      if (selectExpandedEmailText()) {
1241        e.preventDefault();
1242        e.stopPropagation();
1243        e.stopImmediatePropagation?.();
1244      }
1245      return;
1246    }
1247    if (e.key === 'Escape') {
1248      e.preventDefault();
1249      e.stopPropagation();
1250      e.stopImmediatePropagation?.();
1251      if (state._selectMode) {
1252        state._selectMode = false;
1253        state._selectedUids.clear();
1254        _updateBulkBar();
1255        _renderGrid();
1256        return;
1257      }
1258      closeEmailLibrary();
1259      return;
1260    }
1261    // Don't hijack arrows / delete while the user is typing somewhere.
1262    const t = e.target;
1263    if (_isEmailTypingTarget(t)) return;
1264    const isDeleteKey = e.key === 'Delete' || e.key === 'Backspace';
1265    if (isDeleteKey && state._selectMode && state._selectedUids.size > 0) {
1266      e.preventDefault();
1267      _bulkAction('delete');
1268      return;
1269    }
1270    const expanded = document.querySelector('#email-lib-modal .doclib-card.doclib-card-expanded');
1271    if (!expanded) return;
1272    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
1273      const dir = e.key === 'ArrowLeft' ? '-1' : '1';
1274      const btn = expanded.querySelector(`.email-card-nav-btn[data-nav-dir="${dir}"]`);
1275      if (btn) { e.preventDefault(); btn.click(); }
1276    } else if (isDeleteKey) {
1277      const em = state._libEmails.find(x => String(x.uid) === String(expanded.dataset.uid));
1278      if (em) {
1279        e.preventDefault();
1280        _deleteEmailAndAdvance(em, expanded);
1281      }
1282    }
1283  };
1284  document.addEventListener('keydown', state._libEscHandler, true);
1285
1286  _renderAccountsLoading();
1287  _loadAccounts();
1288  _loadFolders();
1289  _loadEmailReminderBellVisibility();
1290  _loadEmails();
1291}
1292
1293async function _loadAccounts() {
1294  try {
1295    const r = await fetch(`${API_BASE}/api/email/accounts`);
1296    if (!r.ok) return;
1297    const d = await r.json();
1298    state._libAccounts = d.accounts || [];
1299  } catch (_) { state._libAccounts = []; }
1300  _renderAccountsStrip();
1301}
1302
1303function _renderAccountsStrip() {
1304  const strip = document.getElementById('email-lib-accounts');
1305  if (!strip) return;
1306  strip.style.display = 'flex';
1307  const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
1308  const allActive = !state._libAccountId ? ' active' : '';
1309  let html = `<button class="memory-toolbar-btn gallery-chip${allActive}" data-acc-id="">All (default)</button>`;
1310  for (const a of state._libAccounts) {
1311    const active = state._libAccountId === a.id ? ' active' : '';
1312    const label = a.name || a.from_address || a.imap_user || 'account';
1313    html += `<button class="memory-toolbar-btn gallery-chip${active}" data-acc-id="${esc(a.id)}" title="${esc(a.from_address || a.imap_user || '')}${a.is_default ? ' (default)' : ''}">${esc(label)}</button>`;
1314  }
1315  strip.innerHTML = html;
1316  strip.querySelectorAll('button[data-acc-id]').forEach(btn => {
1317    btn.addEventListener('click', async () => {
1318      state._libAccountId = btn.dataset.accId || null;
1319      _publishActiveAccount();
1320      _resetEmailListForFreshLoad();
1321      _renderAccountsStrip();
1322      await _loadFolders({ resetMissing: true });
1323      _loadEmails({ force: true, useCache: false });
1324    });
1325  });
1326  _publishActiveAccount();
1327}
1328
1329export function closeEmailLibrary() {
1330  const modal = document.getElementById('email-lib-modal');
1331  if (modal) modal.remove();
1332  _clearEmailDocumentSplit();
1333  if (state._libEscHandler) {
1334    document.removeEventListener('keydown', state._libEscHandler, true);
1335    state._libEscHandler = null;
1336  }
1337  state._libOpen = false;
1338  // If the /email route collapsed the wide sidebar to make room for
1339  // the fullscreen modal, re-expand it now that the modal is gone.
1340  try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {}
1341}
1342
1343// Make a modal draggable by its header. If `modal` and `fsClass` are
1344// provided, dragging to the top edge of the viewport snaps to fullscreen
1345// (Aero Snap). Dragging away from the top while fullscreen unsnaps.
1346function _makeDraggable(content, modal, fsClass) {
1347  if (!content) return;
1348  const header = content.querySelector('.modal-header');
1349  if (!header) return;
1350  // Per-modal fullscreen behavior — caller supplies fsClass, we apply
1351  // the same inline-style fullscreen pattern email-lib + email-window
1352  // both use. exitFullscreen restores the default windowed size
1353  // (min(720px, 92vw) × 85vh) and centers around the cursor.
1354  const enterFullscreen = () => {
1355    if (!fsClass || modal.classList.contains(fsClass)) return;
1356    modal.classList.add(fsClass);
1357    content.style.position = 'fixed';
1358    content.style.left = '0';
1359    content.style.top = '0';
1360    content.style.right = '0';
1361    content.style.bottom = '0';
1362    content.style.width = '100vw';
1363    content.style.maxWidth = '100vw';
1364    content.style.height = '100vh';
1365    content.style.maxHeight = '100vh';
1366    content.style.borderRadius = '0';
1367    content.style.transform = 'none';
1368  };
1369  const exitFullscreen = (cx, cy) => {
1370    if (!fsClass || !modal.classList.contains(fsClass)) return;
1371    modal.classList.remove(fsClass);
1372    content.style.width = 'min(720px, 92vw)';
1373    content.style.maxWidth = '';
1374    content.style.height = '';
1375    content.style.maxHeight = '85vh';
1376    content.style.borderRadius = '';
1377    content.style.right = '';
1378    content.style.bottom = '';
1379    const w = Math.min(720, window.innerWidth * 0.92);
1380    content.style.left = Math.max(8, cx - w / 2) + 'px';
1381    content.style.top = Math.max(8, cy - 20) + 'px';
1382  };
1383  makeWindowDraggable(modal, {
1384    content,
1385    header,
1386    fsClass,
1387    skipSelector: '.close-btn, .modal-close',
1388    enableLeftDock: true,  // park the email on the left while replying on the right
1389    onDragStart: ({ rect }) => {
1390      if (!modal.classList.contains('email-snap-left')) return;
1391      modal.classList.remove('email-snap-left');
1392      _clearEmailDocumentSplit();
1393      content.style.position = 'fixed';
1394      content.style.left = `${Math.round(rect.left)}px`;
1395      content.style.top = `${Math.round(rect.top)}px`;
1396      content.style.right = '';
1397      content.style.bottom = '';
1398      content.style.width = `${Math.max(420, Math.round(rect.width || 560))}px`;
1399      content.style.maxWidth = '';
1400      content.style.height = `${Math.max(320, Math.round(rect.height || 620))}px`;
1401      content.style.maxHeight = '85vh';
1402      content.style.borderRadius = '';
1403      content.style.transform = 'none';
1404      content.style.margin = '0';
1405    },
1406    onEnterFullscreen: fsClass ? enterFullscreen : null,
1407    onExitFullscreen: fsClass ? exitFullscreen : null,
1408  });
1409}
1410
1411// When the user clicks Reply on a fullscreened email view, dock the email
1412// modal to the left as a narrow sidebar so the doc panel (which opens on
1413// the right side of the chat area) is visible side-by-side. Only triggers
1414// when the viewport is wide enough to make a true split worthwhile. Returns
1415// true if the snap was applied, false otherwise.
1416function _snapEmailModalToLeftSidebar(modal) {
1417  if (!modal) return false;
1418  if (window.innerWidth < 900) return false;
1419  const content = modal.querySelector('.modal-content');
1420  if (!content) return false;
1421  // Only dock if currently fullscreen — for a manually-sized window the
1422  // user already chose its layout; don't surprise them by snapping it.
1423  const wasLibFs = modal.classList.contains('email-lib-fullscreen');
1424  const wasWinFs = modal.classList.contains('email-window-fullscreen');
1425  if (!wasLibFs && !wasWinFs) return false;
1426  modal.classList.remove('email-lib-fullscreen');
1427  modal.classList.remove('email-window-fullscreen');
1428  modal.classList.add('email-snap-left');
1429  const W = Math.min(440, Math.max(360, Math.round(window.innerWidth * 0.30)));
1430  const left = _emailSplitLeftEdge();
1431  content.style.position = 'fixed';
1432  content.style.left = '0';
1433  content.style.top = '0';
1434  content.style.right = '';
1435  content.style.bottom = '0';
1436  content.style.width = W + 'px';
1437  content.style.maxWidth = W + 'px';
1438  content.style.height = '100vh';
1439  content.style.maxHeight = '100vh';
1440  content.style.borderRadius = '0';
1441  content.style.transform = 'none';
1442  content.style.margin = '0';
1443  _setEmailDocumentSplit(left, W);
1444  _scheduleEmailDocumentSplitMeasure(modal);
1445  return true;
1446}
1447
1448async function _loadFolders({ resetMissing = false } = {}) {
1449  const seq = ++_libFolderSeq;
1450  const accountAtStart = state._libAccountId || '';
1451  try {
1452    const res = await fetch(`${API_BASE}/api/email/folders?_=${Date.now()}${_acct()}`);
1453    const data = await res.json();
1454    if (seq !== _libFolderSeq || accountAtStart !== (state._libAccountId || '')) return;
1455    const sel = document.getElementById('email-lib-folder');
1456    if (!sel || !data.folders) return;
1457    state._libFolders = data.folders;
1458    if (resetMissing && state._libFolder !== '__scheduled__' && !data.folders.includes(state._libFolder)) {
1459      state._libFolder = data.folders.includes('INBOX') ? 'INBOX' : (data.folders[0] || 'INBOX');
1460      state._libFilter = 'all';
1461      state._libSearch = '';
1462      state._libHasAttachments = false;
1463      _libListCache.clear();
1464      const searchEl = document.getElementById('email-lib-search');
1465      const filterEl = document.getElementById('email-lib-filter');
1466      const attachEl = document.getElementById('email-attachments-btn');
1467      if (searchEl) searchEl.value = '';
1468      if (filterEl) filterEl.value = 'all';
1469      if (attachEl) attachEl.classList.remove('active');
1470      _syncUnreadWindowGlow();
1471      _syncReminderClearButton();
1472    }
1473    sel.innerHTML = '';
1474    const { priority, others } = sortedFolders(data.folders);
1475    for (const f of priority) {
1476      const opt = document.createElement('option');
1477      opt.value = f;
1478      opt.textContent = folderDisplayName(f);
1479      if (f === state._libFolder) opt.selected = true;
1480      sel.appendChild(opt);
1481    }
1482    if (priority.length > 0 && others.length > 0) {
1483      const sep = document.createElement('option');
1484      sep.disabled = true;
1485      sep.textContent = '─────────';
1486      sel.appendChild(sep);
1487    }
1488    for (const f of others) {
1489      const opt = document.createElement('option');
1490      opt.value = f;
1491      opt.textContent = folderDisplayName(f);
1492      if (f === state._libFolder) opt.selected = true;
1493      sel.appendChild(opt);
1494    }
1495    // Scheduled (special virtual folder)
1496    const sep2 = document.createElement('option');
1497    sep2.disabled = true;
1498    sep2.textContent = '─────────';
1499    sel.appendChild(sep2);
1500    const schedOpt = document.createElement('option');
1501    schedOpt.value = '__scheduled__';
1502    schedOpt.textContent = 'Scheduled';
1503    if (state._libFolder === '__scheduled__') schedOpt.selected = true;
1504    sel.appendChild(schedOpt);
1505    sel.value = state._libFolder;
1506  } catch (e) {}
1507}
1508
1509function _crossFolderCandidates() {
1510  const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : [];
1511  const lower = new Map(available.map(f => [String(f).toLowerCase(), f]));
1512  const pick = (patterns, fallback) => {
1513    for (const p of patterns) {
1514      const direct = lower.get(String(p).toLowerCase());
1515      if (direct) return direct;
1516    }
1517    const match = available.find(f => patterns.some(p => String(f).toLowerCase().includes(String(p).toLowerCase())));
1518    return match || fallback;
1519  };
1520  const candidates = [
1521    pick(['INBOX'], 'INBOX'),
1522    pick(['[Gmail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], '[Gmail]/Sent Mail'),
1523    pick(['Archive', '[Gmail]/All Mail', 'All Mail'], '[Gmail]/All Mail'),
1524  ];
1525  return Array.from(new Set(candidates.filter(Boolean)));
1526}
1527
1528async function _doSearch() {
1529  const seq = ++_libSearchSeq;
1530  const q = state._libSearch.trim();
1531  if (q.length < 2) {
1532    // Empty or too short — restore the normal folder if a previous search
1533    // had replaced the grid contents.
1534    if (_libSearchHadResults) {
1535      _libSearchHadResults = false;
1536      state._libOffset = 0;
1537      await _loadEmails({ useCache: true });
1538      return;
1539    }
1540    _renderGrid();
1541    return;
1542  }
1543  const grid = document.getElementById('email-lib-grid');
1544  if (!grid) return;
1545  const sp = _renderEmailLoading(grid);
1546  const accountAtStart = state._libAccountId || '';
1547  const folderAtStart = state._libFolder || 'INBOX';
1548
1549  try {
1550    const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
1551    const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(folderAtStart)}${accountQS}&q=${encodeURIComponent(q)}&limit=100`);
1552    const data = await res.json();
1553    sp.destroy();
1554    if (
1555      seq !== _libSearchSeq ||
1556      q !== state._libSearch.trim() ||
1557      accountAtStart !== (state._libAccountId || '') ||
1558      folderAtStart !== (state._libFolder || 'INBOX')
1559    ) {
1560      return;
1561    }
1562    if (data.error) throw new Error(data.error);
1563
1564    const results = data.emails || [];
1565    _libSearchHadResults = true;
1566    state._libEmails = results;  // temporarily replace with search results
1567    _renderGrid();
1568
1569    const stats = document.getElementById('email-lib-stats');
1570    if (stats) stats.textContent = `${data.total || results.length} match${(data.total || results.length) === 1 ? '' : 'es'}`;
1571  } catch (e) {
1572    sp.destroy();
1573    grid.innerHTML = '<div class="email-loading">Search failed</div>';
1574  }
1575}
1576
1577function _renderEmailLoading(grid) {
1578  if (!grid) return null;
1579  grid.innerHTML = '';
1580  const wrap = document.createElement('div');
1581  wrap.className = 'email-loading email-loading-with-label';
1582  let sp = null;
1583  try {
1584    sp = spinnerModule.createWhirlpool(28);
1585    wrap.appendChild(sp.element);
1586  } catch (_) {}
1587  const label = document.createElement('div');
1588  label.className = 'email-loading-label';
1589  label.textContent = 'Loading emails';
1590  wrap.appendChild(label);
1591  grid.appendChild(wrap);
1592  return sp;
1593}
1594
1595// Refreshes the small accent-pill in the modal title with the unread count
1596// for the current folder. When the inbox is currently filtered to unread, the
1597// pill flips to show the total-emails count + "all" label, because clicking
1598// it would toggle the filter off — so the label needs to advertise the
1599// action, not the now-current view. Two tiny side-fetches (limit=1, total
1600// only); silent on failure — the badge just stays hidden if the request errors.
1601async function _refreshUnreadBadge() {
1602  const badge = document.getElementById('email-lib-unread-badge');
1603  if (!badge) return;
1604  try {
1605    const folder = state._libFolder || 'INBOX';
1606    if (folder === '__scheduled__') { badge.style.display = 'none'; return; }
1607    const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${_acct()}&limit=1&filter=unread`);
1608    const data = await res.json();
1609    const n = data.total || 0;
1610    _syncUnreadTabBadge(n);
1611    if (state._libFilter === 'unread') {
1612      // Currently viewing unread — show what the click will take you to.
1613      try {
1614        const allRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folder)}${_acct()}&limit=1&filter=all`);
1615        const allData = await allRes.json();
1616        const t = allData.total || 0;
1617        badge.textContent = `${t} all`;
1618        badge.title = 'Show all emails';
1619        badge.style.display = '';
1620      } catch (_) {
1621        badge.textContent = 'Show all';
1622        badge.title = 'Show all emails';
1623        badge.style.display = '';
1624      }
1625    } else if (n > 0) {
1626      badge.textContent = n > 999 ? '999+ unread' : `${n} unread`;
1627      badge.title = 'Show unread emails';
1628      badge.style.display = '';
1629    } else {
1630      badge.style.display = 'none';
1631    }
1632  } catch (_) { _syncUnreadTabBadge(0); }
1633}
1634
1635async function _loadEmails({ force = false, useCache = true } = {}) {
1636  const seq = ++_libLoadSeq;
1637  state._libLoading = true;
1638  const accountAtStart = state._libAccountId || '';
1639  const folderAtStart = state._libFolder;
1640  const filterAtStart = state._libFilter;
1641  const offsetAtStart = state._libOffset;
1642  const searchAtStart = state._libSearch;
1643  const hasAttachmentsAtStart = state._libHasAttachments;
1644
1645  const grid = document.getElementById('email-lib-grid');
1646  if (!grid) { if (seq === _libLoadSeq) state._libLoading = false; return; }
1647
1648  // SWR: when loading the first page of a real folder with no search,
1649  // paint the cached list immediately (no spinner, no blank grid) and
1650  // then quietly refetch behind it. Pagination, search, and the
1651  // scheduled virtual folder skip the cache and use the old spinner
1652  // path. `force` (Refresh button) can still consult the cache for
1653  // perceptual continuity, but adds a cache-buster so the server's 8s
1654  // list cache is bypassed too. Account/folder/filter changes pass
1655  // `useCache: false` so stale rows from the previous view never flash.
1656  const cacheable =
1657    offsetAtStart === 0 &&
1658    !searchAtStart &&
1659    folderAtStart !== '__scheduled__';
1660  const ck = cacheable ? _libCacheKey() : null;
1661  const cached = (useCache && cacheable) ? _libCacheGet(ck) : null;
1662
1663  let sp = null;
1664  if (cached) {
1665    state._libEmails = cached.emails || [];
1666    state._libTotal = cached.total || 0;
1667    // Suppress the open-cascade animation when we're painting from
1668    // cache — the data was already on screen a moment ago, so sliding
1669    // each card in fresh feels janky. Also prevents the cascade from
1670    // re-firing when the bg refetch lands within the 900ms cleanup
1671    // window and appends new card nodes into the still-classed grid.
1672    state._libJustOpened = false;
1673    const grid2 = document.getElementById('email-lib-grid');
1674    if (grid2) grid2.classList.remove('email-lib-just-opened');
1675    _renderGrid();
1676    const stats = document.getElementById('email-lib-stats');
1677    if (stats) stats.textContent = `${state._libTotal} emails`;
1678  } else {
1679    sp = _renderEmailLoading(grid);
1680  }
1681
1682  try {
1683    _syncUnreadWindowGlow();
1684    if (folderAtStart === '__scheduled__') {
1685      await _loadScheduled(grid, sp);
1686    } else {
1687      const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
1688      const attQS = hasAttachmentsAtStart ? '&has_attachments=1' : '';
1689      // `&_=Date.now()` bypasses the server's 8s list cache. Default
1690      // opens omit it so rapid close/reopen returns instantly; the
1691      // Refresh button passes `force: true` to add it back.
1692      const buster = force ? `&_=${Date.now()}` : '';
1693      const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
1694      const data = await res.json();
1695      if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
1696      if (data.error) throw new Error(data.error);
1697      state._libEmails = data.emails || [];
1698      state._libTotal = data.total || 0;
1699      if (sp) sp.destroy();
1700      _renderGrid();
1701      const stats = document.getElementById('email-lib-stats');
1702      if (stats) stats.textContent = `${state._libTotal} emails`;
1703      _refreshUnreadBadge();
1704      if (cacheable) _libCachePut(ck, { emails: state._libEmails.slice(), total: state._libTotal });
1705    }
1706  } catch (e) {
1707    if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
1708    if (sp) sp.destroy();
1709    // If we already painted the cached list, leave it on screen — beats
1710    // wiping it for "Failed to load" when there's still readable content.
1711    if (!cached) {
1712      const msg = e && e.message ? `Failed to load: ${e.message}` : 'Failed to load';
1713      grid.innerHTML = `<div class="email-loading">${_esc(msg)}${_emailSetupHintHtml()}</div>`;
1714      _wireEmailSetupHint(grid);
1715    }
1716  } finally {
1717    if (seq === _libLoadSeq) state._libLoading = false;
1718  }
1719}
1720
1721async function _loadScheduled(grid, sp) {
1722  const res = await fetch(`${API_BASE}/api/email/scheduled`);
1723  const data = await res.json();
1724  if (sp) sp.destroy();
1725  const items = data.scheduled || [];
1726  grid.innerHTML = '';
1727  const stats = document.getElementById('email-lib-stats');
1728  if (stats) stats.textContent = `${items.length} scheduled`;
1729
1730  if (items.length === 0) {
1731    grid.innerHTML = '<div class="email-loading">No scheduled emails</div>';
1732    return;
1733  }
1734
1735  for (const it of items) {
1736    const card = document.createElement('div');
1737    card.className = 'doclib-card memory-item';
1738
1739    const sendDate = new Date(it.send_at);
1740    const dateStr = sendDate.toLocaleString([], {
1741      month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
1742    });
1743
1744    const content = document.createElement('div');
1745    content.style.cssText = 'flex:1;min-width:0;';
1746    const subject = it.subject || '(no subject)';
1747    const toDisplay = it.to || '(no recipient)';
1748
1749    content.innerHTML = `
1750      <div style="display:flex;align-items:center;gap:6px;">
1751        <span class="memory-item-title">${_esc(subject)}</span>
1752        ${it.status === 'failed' ? '<span style="font-size:9px;color:var(--red);border:1px solid var(--red);padding:1px 4px;border-radius:4px;">FAILED</span>' : '<span style="font-size:9px;opacity:0.6;border:1px solid var(--border);padding:1px 4px;border-radius:4px;">PENDING</span>'}
1753      </div>
1754      <div style="font-size:10px;opacity:0.7;margin-top:2px;">
1755        To: ${_esc(toDisplay)} · Sends ${_esc(dateStr)}
1756      </div>
1757      ${it.error ? `<div style="font-size:10px;color:var(--red);margin-top:2px;">${_esc(it.error)}</div>` : ''}
1758    `;
1759    card.appendChild(content);
1760
1761    // Cancel button
1762    const cancelBtn = document.createElement('button');
1763    cancelBtn.className = 'memory-item-btn';
1764    cancelBtn.title = 'Cancel scheduled send';
1765    cancelBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
1766    cancelBtn.addEventListener('click', async (e) => {
1767      e.stopPropagation();
1768      const { styledConfirm } = await import('./ui.js');
1769      const ok = await styledConfirm(`Cancel scheduled email "${subject}"?`, { confirmText: 'Cancel Send', cancelText: 'Keep', danger: true });
1770      if (!ok) return;
1771      try {
1772        await fetch(`${API_BASE}/api/email/scheduled/${it.id}`, { method: 'DELETE' });
1773        _loadEmails();
1774      } catch (err) { console.error(err); }
1775    });
1776    const actionsWrap = document.createElement('div');
1777    actionsWrap.className = 'memory-item-actions';
1778    actionsWrap.appendChild(cancelBtn);
1779    card.appendChild(actionsWrap);
1780
1781    grid.appendChild(card);
1782  }
1783}
1784
1785function _renderGrid() {
1786  const grid = document.getElementById('email-lib-grid');
1787  if (!grid) return;
1788  grid.innerHTML = '';
1789
1790  let filtered = state._libEmails;
1791
1792  // Apply sort
1793  if (state._libSort === 'unread') {
1794    filtered = [...filtered].sort((a, b) => Number(a.is_read) - Number(b.is_read));
1795  } else if (state._libSort === 'favorites') {
1796    filtered = [...filtered].sort((a, b) => Number(b.is_flagged) - Number(a.is_flagged));
1797  }
1798  // 'recent' is the default order from the API
1799
1800  if (filtered.length === 0) {
1801    // Inbox-zero is a win — pair the message with a small smiley so the
1802    // empty state reads as "all caught up", not "something's broken".
1803    const _smileyIco = '<span style="vertical-align:-3px;margin-left:6px;">' + emptyStateIcon('smiley') + '</span>';
1804    // Only show the "Set up at Settings › Integrations" hint when the inbox
1805    // is TRULY empty — no filter, no search, no source emails. A sub-filter
1806    // (reminders, unread, etc.) that happens to be empty isn't a setup
1807    // problem; the link there reads as nonsense.
1808    const _isTrulyEmpty = (
1809      state._libEmails.length === 0
1810      && (!state._libFilter || state._libFilter === 'all')
1811      && !(state._libSearch || '').trim()
1812    );
1813    if (_isTrulyEmpty) {
1814      grid.innerHTML =
1815        '<div class="email-loading" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;text-align:center;">' +
1816          '<span>No emails' + _smileyIco + '</span>' +
1817          '<span style="opacity:0.7;font-size:11px;">' +
1818            'Set up at: <a href="#" data-open-settings="integrations" style="color:var(--accent,var(--red));text-decoration:underline;">Settings &rsaquo; Integrations</a>' +
1819          '</span>' +
1820        '</div>';
1821      const _link = grid.querySelector('[data-open-settings]');
1822      if (_link) _link.addEventListener('click', (e) => {
1823        e.preventDefault();
1824        _openSettingsTab(_link.dataset.openSettings || 'integrations');
1825      });
1826    } else {
1827      grid.innerHTML =
1828        '<div class="email-loading" style="display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap;">' +
1829          '<span>No emails' + _smileyIco + '</span>' +
1830        '</div>';
1831    }
1832    return;
1833  }
1834
1835  // Cascade-on-open: fire the same domino-in animation the sidebar
1836   // section uses. Only on the FIRST grid render after the library is
1837   // opened — subsequent re-renders (filter/sort/search) need to be
1838   // instant.
1839  if (state._libJustOpened) {
1840    grid.classList.add('email-lib-just-opened');
1841    state._libJustOpened = false;
1842    // Strip the class after the cascade so it doesn't restrict later
1843    // animations (e.g. the FLIP reflow when archiving). Worst-case
1844    // duration matches the longest delay in the keyframe set below.
1845    setTimeout(() => grid.classList.remove('email-lib-just-opened'), 900);
1846  }
1847  for (const em of filtered) {
1848    grid.appendChild(_createCard(em));
1849  }
1850
1851  // If a deep-link asked us to expand a specific email, do it now and clear.
1852  if (state._libPendingExpandUid) {
1853    const target = filtered.find(e => String(e.uid) === String(state._libPendingExpandUid));
1854    const wantUid = state._libPendingExpandUid;
1855    state._libPendingExpandUid = null;
1856    if (target) {
1857      const cards = grid.querySelectorAll('.doclib-card');
1858      const targetCard = Array.from(cards).find(c => c.dataset.uid === String(wantUid));
1859      if (targetCard) {
1860        requestAnimationFrame(() => _toggleCardPreview(targetCard, target));
1861      }
1862    }
1863  }
1864}
1865
1866function _createCard(em) {
1867  const card = document.createElement('div');
1868  let cls = 'doclib-card memory-item';
1869  if (em.is_answered) cls += ' email-card-answered';
1870  else if (!em.is_read) cls += ' email-card-unread';
1871  card.className = cls;
1872  card.dataset.uid = String(em.uid);
1873  if (state._selectMode && state._selectedUids.has(em.uid)) card.classList.add('selected');
1874
1875  // Checkbox in select mode
1876  if (state._selectMode) {
1877    const cb = document.createElement('input');
1878    cb.type = 'checkbox';
1879    cb.className = 'memory-select-cb';
1880    cb.checked = state._selectedUids.has(em.uid);
1881    cb.addEventListener('click', e => e.stopPropagation());
1882    cb.addEventListener('change', () => {
1883      if (cb.checked) state._selectedUids.add(em.uid);
1884      else state._selectedUids.delete(em.uid);
1885      card.classList.toggle('selected', cb.checked);
1886      _updateBulkBar();
1887    });
1888    card.appendChild(cb);
1889  }
1890
1891  // In Sent folder, show the recipient(s) — the sender is always you and
1892  // hides the actually useful info. Outside Sent, show the sender as before.
1893  const isSentFolderEarly = /sent/i.test(state._libFolder);
1894  let senderName;
1895  if (isSentFolderEarly) {
1896    senderName = _formatRecipients(em.to) || em.to || '(no recipient)';
1897  } else {
1898    senderName = em.from_name || em.from_address;
1899  }
1900  const color = _senderColor(senderName);
1901
1902  let dateStr = '';
1903  if (em.date) {
1904    try {
1905      const d = new Date(em.date);
1906      const now = new Date();
1907      const sameYear = d.getFullYear() === now.getFullYear();
1908      const dateOpts = sameYear
1909        ? { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }
1910        : { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
1911      dateStr = d.toLocaleString([], dateOpts);
1912    } catch (_) {}
1913  }
1914
1915  const content = document.createElement('div');
1916  content.style.cssText = 'flex:1;min-width:0;';
1917
1918  const titleRow = document.createElement('div');
1919  titleRow.className = 'email-card-titlerow';
1920  titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;';
1921
1922  const titleEl = document.createElement('span');
1923  titleEl.className = 'memory-item-title';
1924  titleEl.textContent = em.subject || '(no subject)';
1925  // Hover preview: surface the cached AI summary directly on the title via
1926  // a native browser tooltip — no need to open the email to skim it.
1927  if (em.cached_summary) {
1928    titleEl.title = em.cached_summary;
1929    titleEl.classList.add('email-card-has-summary');
1930  }
1931  titleRow.appendChild(titleEl);
1932
1933  if (em.has_attachments) {
1934    const att = document.createElement('span');
1935    att.title = 'Has attachments';
1936    att.style.cssText = 'opacity:0.6;flex-shrink:0;display:inline-flex;';
1937    att.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
1938    titleRow.appendChild(att);
1939  }
1940
1941  // Done check + unread dot stay next to the subject on the left.
1942  const isSentFolder = /sent/i.test(state._libFolder);
1943  if (!isSentFolder) {
1944    const doneCheck = document.createElement('span');
1945    doneCheck.className = 'email-card-done' + (em.is_answered ? ' active' : '');
1946    doneCheck.title = em.is_answered ? 'Mark not done' : 'Mark done';
1947    doneCheck.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
1948    const _toggleDone = async (e) => {
1949      if (e) e.stopPropagation();
1950      // Use the visible class as source of truth — em.is_answered could
1951      // be stale from a background sync, which would leave the user
1952      // clicking and seeing no UI change.
1953      const wasActive = doneCheck.classList.contains('active');
1954      const newState = !wasActive;
1955      em.is_answered = newState;
1956      doneCheck.classList.toggle('active', newState);
1957      doneCheck.title = newState ? 'Mark not done' : 'Mark done';
1958      // Animate in both directions so the user gets explicit feedback when
1959      // un-checking too — without this the hover state and the active state
1960      // look identical, so the click felt like a no-op.
1961      doneCheck.classList.remove('just-checked', 'just-unchecked');
1962      void doneCheck.offsetWidth; // restart animation
1963      doneCheck.classList.add(newState ? 'just-checked' : 'just-unchecked');
1964      setTimeout(() => doneCheck.classList.remove('just-checked', 'just-unchecked'), 500);
1965      if (newState) {
1966        _syncEmailReadState(em.uid, true);
1967      }
1968      try {
1969        if (newState) {
1970          await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
1971          await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
1972        } else {
1973          await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
1974        }
1975      } catch (err) { console.error(err); }
1976    };
1977    doneCheck.addEventListener('click', _toggleDone);
1978    titleRow.appendChild(doneCheck);
1979    if (!em.is_read) {
1980      const dot = document.createElement('span');
1981      dot.className = 'email-card-unread-dot';
1982      dot.style.cssText = `width:6px;height:6px;border-radius:50%;background:${color};flex-shrink:0;margin-left:2px;`;
1983      titleRow.appendChild(dot);
1984    }
1985  }
1986
1987  if (em.is_flagged) {
1988    const star = document.createElement('span');
1989    star.title = 'Favorited';
1990    star.style.cssText = 'color:var(--accent, var(--red));opacity:0.85;flex-shrink:0;display:inline-flex;';
1991    star.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
1992    titleRow.appendChild(star);
1993  }
1994
1995  // Prev/next arrows — visible only when this card is the expanded one
1996  // (CSS-gated so collapsed cards stay clean). Click navigates by collapsing
1997  // this card and expanding the neighbour.
1998  const navArrows = document.createElement('span');
1999  navArrows.className = 'email-card-nav-arrows';
2000  navArrows.innerHTML = `
2001    <button type="button" class="email-card-nav-btn" data-nav-dir="-1" title="Previous email"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
2002    <button type="button" class="email-card-nav-btn" data-nav-dir="1" title="Next email"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg></button>
2003  `;
2004  navArrows.addEventListener('click', async (ev) => {
2005    const btn = ev.target.closest('.email-card-nav-btn');
2006    if (!btn || btn.disabled) return;
2007    ev.stopPropagation();
2008    const card = navArrows.closest('.doclib-card');
2009    if (!card) return;
2010    const dir = parseInt(btn.dataset.navDir, 10);
2011    const sibling = _findSiblingEmailCard(card, dir);
2012    if (!sibling) return;
2013    const nextEm = state._libEmails.find(e => String(e.uid) === String(sibling.dataset.uid));
2014    if (!nextEm) return;
2015    await _toggleCardPreview(card, em);
2016    await _toggleCardPreview(sibling, nextEm);
2017    sibling.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2018  });
2019  // Right cluster: expanded-only actions menu + nav arrows. The normal
2020  // `.memory-item-actions` menu is hidden while expanded, so this keeps
2021  // the same email actions available beside the previous/next controls.
2022  const rightCluster = document.createElement('span');
2023  rightCluster.style.cssText = 'margin-left:auto;display:inline-flex;align-items:center;gap:6px;';
2024  const headerMenuBtn = document.createElement('button');
2025  headerMenuBtn.type = 'button';
2026  headerMenuBtn.className = 'email-card-header-menu';
2027  headerMenuBtn.title = 'Actions';
2028  headerMenuBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
2029  headerMenuBtn.addEventListener('click', (e) => {
2030    e.stopPropagation();
2031    _showCardMenu(em, headerMenuBtn);
2032  });
2033  // The CSS rule on .email-card-nav-arrows still sets margin-left:auto
2034  // (needed when the arrows live alone in the title row). Inside this
2035  // wrapper, we want the cluster's gap to apply, so cancel that auto.
2036  navArrows.style.marginLeft = '0';
2037  rightCluster.appendChild(headerMenuBtn);
2038  rightCluster.appendChild(navArrows);
2039  titleRow.appendChild(rightCluster);
2040
2041  content.appendChild(titleRow);
2042
2043  const meta = document.createElement('div');
2044  meta.className = 'memory-item-meta';
2045  meta.style.cssText = 'font-size:10px;opacity:0.7;margin-top:2px;';
2046  const senderPrefix = isSentFolderEarly ? 'to ' : '';
2047  meta.innerHTML = `<span class="email-meta-sender"><span style="opacity:0.55">${senderPrefix}</span><span style="color:${color};font-weight:600">${_esc(senderName)}</span></span><span class="email-meta-sep"> · </span><span class="email-meta-date">${_esc(dateStr)}</span>`;
2048  content.appendChild(meta);
2049
2050  card.appendChild(content);
2051
2052  // Per-card menu button (... menu)
2053  if (!state._selectMode) {
2054    const actionsWrap = document.createElement('div');
2055    actionsWrap.className = 'memory-item-actions';
2056    const menuBtn = document.createElement('button');
2057    menuBtn.className = 'memory-item-btn';
2058    menuBtn.title = 'Actions';
2059    menuBtn.style.position = 'relative';
2060    menuBtn.style.top = '-1px';
2061    menuBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg>';
2062    menuBtn.addEventListener('click', (e) => {
2063      e.stopPropagation();
2064      _showCardMenu(em, menuBtn);
2065    });
2066    actionsWrap.appendChild(menuBtn);
2067    card.appendChild(actionsWrap);
2068
2069    // Long-press anywhere on the row opens the same actions menu — matches
2070    // the chats / archive / research / documents tabs' long-press UX.
2071    let _hold = null, _holdStart = null;
2072    const _cancelHold = () => { if (_hold) { clearTimeout(_hold); _hold = null; } _holdStart = null; };
2073    card.addEventListener('pointerdown', (e) => {
2074      if (card.classList.contains('email-card-expanded') || card.classList.contains('doclib-card-expanded')) return;
2075      if (e.target.closest('button, .email-card-done, .recipient-chip, .memory-select-cb, .email-card-nav-btn')) return;
2076      _holdStart = { x: e.clientX, y: e.clientY };
2077      _hold = setTimeout(() => {
2078        _hold = null;
2079        if (card.classList.contains('email-card-expanded') || card.classList.contains('doclib-card-expanded')) return;
2080        card._suppressNextClick = true;
2081        setTimeout(() => { card._suppressNextClick = false; }, 400);
2082        if (navigator.vibrate) try { navigator.vibrate(15); } catch {}
2083        _showCardMenu(em, menuBtn);
2084      }, 500);
2085    });
2086    card.addEventListener('pointermove', (e) => {
2087      if (!_holdStart) return;
2088      if (Math.hypot(e.clientX - _holdStart.x, e.clientY - _holdStart.y) > 10) _cancelHold();
2089    });
2090    card.addEventListener('pointerup', _cancelHold);
2091    card.addEventListener('pointercancel', _cancelHold);
2092  }
2093
2094  // Click handler — toggle preview expansion
2095  card.addEventListener('click', async (e) => {
2096    if (card._suppressNextClick) { card._suppressNextClick = false; return; }
2097    if (state._selectMode) {
2098      if (state._selectedUids.has(em.uid)) state._selectedUids.delete(em.uid);
2099      else state._selectedUids.add(em.uid);
2100      card.classList.toggle('selected', state._selectedUids.has(em.uid));
2101      const cb = card.querySelector('.memory-select-cb');
2102      if (cb) cb.checked = state._selectedUids.has(em.uid);
2103      _updateBulkBar();
2104      return;
2105    }
2106    await _toggleCardPreview(card, em);
2107  });
2108
2109  return card;
2110}
2111
2112function _findSiblingEmailCard(card, dir) {
2113  const grid = card.closest('.doclib-grid');
2114  if (!grid) return null;
2115  const cards = [...grid.querySelectorAll('.doclib-card[data-uid]')];
2116  const idx = cards.indexOf(card);
2117  if (idx === -1) return null;
2118  return cards[idx + dir] || null;
2119}
2120
2121function _syncCardNavArrows(card) {
2122  const prev = card.querySelector('.email-card-nav-btn[data-nav-dir="-1"]');
2123  const next = card.querySelector('.email-card-nav-btn[data-nav-dir="1"]');
2124  if (prev) prev.disabled = !_findSiblingEmailCard(card, -1);
2125  if (next) next.disabled = !_findSiblingEmailCard(card, 1);
2126}
2127
2128const _emailReadPrefetching = new Set();
2129let _emailReadPrefetchTimer = null;
2130
2131function _prefetchAdjacentEmails(card, count = 1) {
2132  if (!card || state._libFolder === '__scheduled__') return;
2133  const grid = card.closest('.doclib-grid');
2134  if (!grid) return;
2135  const cards = [...grid.querySelectorAll('.doclib-card[data-uid]')];
2136  const idx = cards.indexOf(card);
2137  if (idx === -1) return;
2138  const targets = [];
2139  for (let i = 1; i <= count; i++) {
2140    if (cards[idx + i]) targets.push(cards[idx + i]);
2141  }
2142  if (targets.length < count) {
2143    for (let i = 1; targets.length < count && cards[idx - i]; i++) targets.push(cards[idx - i]);
2144  }
2145  const target = targets.find(t => t?.dataset?.uid);
2146  const uid = target?.dataset?.uid;
2147  if (!uid) return;
2148  const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
2149  if (_emailReadPrefetching.has(key) || _emailReadPrefetching.size > 0) return;
2150  if (_emailReadPrefetchTimer) clearTimeout(_emailReadPrefetchTimer);
2151  _emailReadPrefetchTimer = setTimeout(() => {
2152    _emailReadPrefetchTimer = null;
2153    _emailReadPrefetching.add(key);
2154    fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&mark_seen=false`)
2155      .catch(() => {})
2156      .finally(() => _emailReadPrefetching.delete(key));
2157  }, 900);
2158}
2159
2160async function _toggleCardPreview(card, em) {
2161  const accountAtStart = state._libAccountId || '';
2162  const folderAtStart = state._libFolder || 'INBOX';
2163  const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
2164  const grid = card.closest('.doclib-grid');
2165  const gridRect = grid?.getBoundingClientRect?.();
2166  const modal = document.getElementById('email-lib-modal');
2167  const modalContent = card.closest('.modal-content');
2168  const modalRect = modalContent?.getBoundingClientRect?.();
2169  const currentRect = card.getBoundingClientRect();
2170  const stableOpenHeight = Math.max(
2171    currentRect.height || 0,
2172    (modalRect?.height || 0) - 84,
2173    Math.min(Math.max(260, window.innerHeight * 0.56), gridRect?.height || window.innerHeight)
2174  );
2175
2176  // Already expanded — collapse
2177  if (card.classList.contains('email-card-expanded')) {
2178    card.classList.remove('email-card-expanded');
2179    card.classList.remove('doclib-card-expanded');
2180    card.style.minHeight = '';
2181    modal?.classList.remove('email-reading');
2182    modal?.style.removeProperty('--email-reading-modal-min-h');
2183    const reader = card.querySelector('.email-card-reader');
2184    if (reader) reader.remove();
2185    return;
2186  }
2187
2188  // Collapse any other expanded card
2189  if (grid) {
2190    grid.querySelectorAll('.email-card-expanded').forEach(c => {
2191      c.classList.remove('email-card-expanded');
2192      c.classList.remove('doclib-card-expanded');
2193      c.style.minHeight = '';
2194      const r = c.querySelector('.email-card-reader');
2195      if (r) r.remove();
2196    });
2197  }
2198
2199  card.classList.add('email-card-expanded');
2200  card.classList.add('doclib-card-expanded');
2201  card.style.minHeight = `${Math.round(stableOpenHeight)}px`;
2202  if (!em.is_read) {
2203    _syncEmailReadState(em.uid, true);
2204    fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`, { method: 'POST' })
2205      .catch(err => console.error('Failed to mark email read:', err));
2206  }
2207  // Class hook on the modal so the header-hide / padding rules work on
2208  // browsers without :has() support (Firefox mobile) — the :has() versions
2209  // below stay as the desktop path.
2210  if (modal && modalRect?.height) {
2211    modal.style.setProperty('--email-reading-modal-min-h', `${Math.round(modalRect.height)}px`);
2212  }
2213  modal?.classList.add('email-reading');
2214
2215  // Show loading reader with whirlpool spinner
2216  const reader = document.createElement('div');
2217  reader.className = 'email-card-reader email-card-reader-loading';
2218  reader.style.minHeight = `${Math.max(180, Math.round(stableOpenHeight - 70))}px`;
2219  const loadingWrap = document.createElement('div');
2220  loadingWrap.style.cssText = 'padding:20px;display:flex;justify-content:center;align-items:center;flex:1;';
2221  const sp = spinnerModule.createWhirlpool(28);
2222  loadingWrap.appendChild(sp.element);
2223  reader.appendChild(loadingWrap);
2224  card.appendChild(reader);
2225  _markEmailReaderActive(reader);
2226
2227  try {
2228    const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
2229    const data = await res.json();
2230    if (
2231      accountAtStart !== (state._libAccountId || '') ||
2232      folderAtStart !== (state._libFolder || 'INBOX') ||
2233      uidAtStart !== String(card?.dataset?.uid || '') ||
2234      !card.isConnected ||
2235      !card.classList.contains('email-card-expanded')
2236    ) {
2237      return;
2238    }
2239    if (data.error) {
2240      reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Error: ${_esc(data.error)}</div>`;
2241      return;
2242    }
2243
2244    // Mark as read locally
2245    _syncEmailReadState(em.uid, true);
2246    _prefetchAdjacentEmails(card);
2247
2248    // Build the attachments wrap using the shared helper so the signature-
2249    // image filter (small inline PNGs/JPGs, Outlook image001 placeholders,
2250    // logo/banner files) is applied here too. Falls back to '' when every
2251    // attachment is filtered out.
2252    const attsHtml = _buildAttsHtmlFor(em.uid, data);
2253
2254    // Format date nicely (compact): "Mar 21, 2026 14:32"
2255    let dateDisplay = data.date || '';
2256    try {
2257      if (data.date) {
2258        const d = new Date(data.date);
2259        if (!isNaN(d.getTime())) {
2260          dateDisplay = d.toLocaleString([], {
2261            month: 'short', day: 'numeric', year: 'numeric',
2262            hour: '2-digit', minute: '2-digit',
2263          });
2264        }
2265      }
2266    } catch (_) {}
2267
2268    // Build recipient chip group from a comma-separated address list
2269    const buildRecipients = (str) => {
2270      if (!str) return '';
2271      const addrs = _splitRecipientList(str);
2272      if (addrs.length === 0) return '';
2273      return addrs.map(a => {
2274        const name = _extractName(a);
2275        return _recipientChipHtml(a, name);
2276      }).join('');
2277    };
2278
2279    // Build the From chip too — single chip with name, click reveals address
2280    const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
2281
2282    reader.innerHTML = `
2283      <div class="email-reader-header">
2284        <div class="email-reader-meta">
2285          <div class="email-reader-meta-row"><strong>From:</strong><span class="recipient-chips">${fromChip}</span></div>
2286          ${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${buildRecipients(data.to)}</span></div>` : ''}
2287          ${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildRecipients(data.cc)}</span></div>` : ''}
2288        </div>
2289        <div class="email-reader-actions">
2290          <div class="email-reader-actions-row email-reader-actions-row-primary">
2291            <button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
2292            ${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
2293            <button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
2294          </div>
2295          <div class="email-reader-actions-row email-reader-actions-row-secondary">
2296            <button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
2297            <button class="memory-toolbar-btn reader-icon-btn" data-act="summarize" title="Summarize">${_summaryIcon(data)}<span class="reader-btn-label">Summary</span></button>
2298            <button class="memory-toolbar-btn reader-icon-btn" data-act="from-sender" title="Search text in this thread"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><span class="reader-btn-label">Search</span></button>
2299            <div class="email-reader-more-wrap" style="position:relative">
2300              <button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
2301            </div>
2302          </div>
2303        </div>
2304      </div>
2305      ${attsHtml}
2306      <div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
2307    `;
2308    _markEmailReaderActive(reader);
2309    reader.classList.remove('email-card-reader-loading');
2310    reader.style.minHeight = '';
2311
2312    // Attachment header click toggles fold/unfold (same UX as the summary).
2313    const attsWrap = reader.querySelector('.email-reader-atts-wrap');
2314    if (attsWrap) {
2315      const attsToggle = attsWrap.querySelector('.email-reader-atts-header');
2316      if (attsToggle) {
2317        attsToggle.addEventListener('click', (ev) => {
2318          ev.stopPropagation();
2319          attsWrap.classList.toggle('collapsed');
2320        });
2321        attsToggle.addEventListener('keydown', (ev) => {
2322          if (ev.key === 'Enter' || ev.key === ' ') {
2323            ev.preventDefault();
2324            attsWrap.classList.toggle('collapsed');
2325          }
2326        });
2327      }
2328    }
2329
2330    // Attachment chip clicks: works on both mobile (iOS Safari ignores
2331    // programmatic <a download> outside an actual <a> in the DOM) and desktop.
2332    // On mobile we open the URL in a new tab so the OS picks the action; on
2333    // desktop we fetch + blob-download so the filename is preserved and no
2334    // popup-blocker fires.
2335    const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
2336
2337    _wireAttachmentHandlers(reader, state._libFolder);
2338
2339    reader.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
2340      ev.stopPropagation();
2341      _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
2342      if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
2343    });
2344    reader.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
2345      ev.stopPropagation();
2346      _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
2347      if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
2348    });
2349    reader.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
2350    reader.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
2351      ev.stopPropagation();
2352      if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
2353    });
2354    reader.querySelector('[data-act="close"]')?.addEventListener('click', (ev) => {
2355      ev.stopPropagation();
2356      _toggleCardPreview(card, em);
2357    });
2358    reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
2359      ev.stopPropagation();
2360      _showReaderMoreMenu(em, card, reader, ev.currentTarget);
2361    });
2362    reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
2363      ev.stopPropagation();
2364      await _summarizeEmail(reader, data, ev.currentTarget);
2365    });
2366    // from-sender / thread-search Search button is DISABLED for now —
2367    // the search + threaded sidebar UX is too buggy to ship. Physically
2368    // remove it from every reader render path. Re-enable by deleting
2369    // these .remove() lines + the CSS rule.
2370    reader.querySelector('[data-act="from-sender"]')?.remove();
2371    reader.querySelector('[data-act="from-sender"]')?.addEventListener('click', async (ev) => {
2372      ev.stopPropagation();
2373      await _toggleFromSenderPanel(reader, data, ev.currentTarget);
2374    });
2375
2376    // Refresh the title-row prev/next arrows for this newly-expanded card.
2377    _syncCardNavArrows(card);
2378
2379    // Horizontal swipe on the reader switches to prev/next email — but
2380    // only when the underlying content can't scroll further in the swipe
2381    // direction. If the email body is wider than the viewport (HTML emails
2382    // with tables, embedded images), normal horizontal scroll wins; nav
2383    // only fires once the user has reached an edge.
2384    {
2385      let _sx = 0, _sy = 0, _swiping = false, _intent = null;
2386      let _scrollEl = null;
2387      let _startScrollLeft = 0;
2388      const SWIPE_THRESHOLD = 60;
2389      const VERT_ABORT = 14;
2390      const findHScroller = (el) => {
2391        while (el && el !== reader) {
2392          if (el.scrollWidth - el.clientWidth > 2) return el;
2393          el = el.parentElement;
2394        }
2395        return null;
2396      };
2397      reader.addEventListener('touchstart', (ev) => {
2398        if (ev.touches.length !== 1) { _swiping = false; return; }
2399        if (ev.target.closest('button, a, .recipient-chip, .email-attachment-chip, .email-reader-more-wrap')) { _swiping = false; return; }
2400        _sx = ev.touches[0].clientX;
2401        _sy = ev.touches[0].clientY;
2402        _scrollEl = findHScroller(ev.target);
2403        _startScrollLeft = _scrollEl ? _scrollEl.scrollLeft : 0;
2404        _swiping = true;
2405        _intent = null;
2406      }, { passive: true });
2407      reader.addEventListener('touchmove', (ev) => {
2408        if (!_swiping) return;
2409        const dx = ev.touches[0].clientX - _sx;
2410        const dy = ev.touches[0].clientY - _sy;
2411        if (!_intent) {
2412          if (Math.abs(dy) > VERT_ABORT && Math.abs(dy) > Math.abs(dx)) {
2413            _intent = 'scroll';
2414            _swiping = false;
2415            return;
2416          }
2417          if (Math.abs(dx) > 12) _intent = 'swipe';
2418        }
2419      }, { passive: true });
2420      reader.addEventListener('touchend', (ev) => {
2421        if (!_swiping) return;
2422        _swiping = false;
2423        const t = (ev.changedTouches && ev.changedTouches[0]) || null;
2424        if (!t || _intent !== 'swipe') return;
2425        const dx = t.clientX - _sx;
2426        const dy = t.clientY - _sy;
2427        if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
2428        // If a horizontally-scrollable element captured the swipe, let it
2429        // scroll instead of changing email — UNLESS the user was already
2430        // at the edge (scrollLeft can't move further in that direction).
2431        if (_scrollEl) {
2432          const max = _scrollEl.scrollWidth - _scrollEl.clientWidth;
2433          const atLeftEdge = _scrollEl.scrollLeft <= 2;
2434          const atRightEdge = _scrollEl.scrollLeft >= max - 2;
2435          // Swiping LEFT (dx<0) reveals content to the right → if not at
2436          // right edge, that's a scroll, not a nav.
2437          if (dx < 0 && !atRightEdge) return;
2438          // Swiping RIGHT (dx>0) reveals content to the left → if not at
2439          // left edge, that's a scroll, not a nav.
2440          if (dx > 0 && !atLeftEdge) return;
2441          // If the browser already scrolled during this gesture, treat as
2442          // scroll regardless (the user clearly wanted to pan).
2443          if (_scrollEl.scrollLeft !== _startScrollLeft) return;
2444        }
2445        const dir = dx < 0 ? 1 : -1;
2446        const navBtn = card.querySelector(`.email-card-nav-btn[data-nav-dir="${dir}"]`);
2447        if (navBtn && !navBtn.disabled) navBtn.click();
2448      }, { passive: true });
2449    }
2450
2451    // If the email has a pre-cached summary, show it immediately. Fold
2452    // state is persisted via _summaryCollapsedPref inside the renderer.
2453    if (data.cached_summary) {
2454      const sumBtn = reader.querySelector('[data-act="summarize"]');
2455      _showCachedSummary(reader, data.cached_summary, sumBtn);
2456    }
2457
2458    _wireRecipientChips(reader);
2459    // Always stop bubbling so the card's click doesn't fire while reading.
2460    reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
2461  } catch (e) {
2462    reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Failed to load email</div>`;
2463  }
2464}
2465
2466/**
2467 * Wrap a probable signature block in a collapsed <details> so it stops
2468 * eating the whole reader. We try, in priority order:
2469 *   1. Mail-client signature wrappers — Gmail's `gmail_signature` div is
2470 *      explicit, no guessing required. Same for Apple Mail's data-smartmail.
2471 *   2. The standard "-- " RFC 3676 sig delimiter.
2472 *   3. A common closing phrase ("Best regards", "Cheers", etc.) on its own
2473 *      line — fuzzier, but catches sigs without the dash marker.
2474 *   4. "Sent from my iPhone/Android" / "Get Outlook for ..." mobile-client
2475 *      boilerplate.
2476 * Anything matched gets wrapped from the marker through end-of-body.
2477 */
2478/**
2479 * Render the email body with sig/quote folds. If the backend has cached
2480 * LLM-detected boundary offsets (data.boundaries), use those for an exact
2481 * fold based on plain-text positions. Otherwise fall back to the regex
2482 * detectors. The plain-body branch is always preferred when boundaries
2483 * exist because the offsets are computed against plain text.
2484 */
2485// Global escape hatch — when the server's thread parser misfires (it
2486// occasionally splits a single reply into two bogus "turns" by treating a
2487// signature/disclaimer as its own message), the user can flip this off to
2488// fall back to plain rendering. Survives reloads.
2489const _BUBBLES_DISABLED_KEY = 'odysseus.email.bubblesDisabled';
2490// Threaded chat-bubble email view is DISABLED for now — too buggy to
2491// ship. Force plain-text rendering everywhere by always returning true.
2492// Re-enable by restoring the localStorage-backed body + the toggle
2493// menu item in the reader's More menu.
2494function _bubblesDisabled() {
2495  return true;
2496}
2497function _setBubblesDisabled(v) {
2498  try { localStorage.setItem(_BUBBLES_DISABLED_KEY, v ? '1' : '0'); } catch {}
2499}
2500
2501function _renderEmailBody(data) {
2502  const plain = (typeof data?.body === 'string' && data.body.length) ? data.body : '';
2503  const folder = String(data?.folder || '').toLowerCase();
2504  const isSentFolder = folder.includes('sent');
2505  const fromAddr = String(data?.from_address || '').toLowerCase().trim();
2506  const isMine = !!fromAddr && _meEmailAddrs().has(fromAddr);
2507
2508  // Messages authored by the user (Sent folder or self-sent copies in INBOX)
2509  // are current authored text. Do not let cached boundaries or HTML
2510  // blockquote parsing hide the whole thing behind "Earlier reply".
2511  if ((isSentFolder || isMine) && plain) {
2512    const plainTurns = _renderPlaintextThread(plain);
2513    if (plainTurns && !/^\s*<details\b/i.test(plainTurns.trim())) {
2514      return _foldSignature(plainTurns, null);
2515    }
2516    return _foldSignature(_escLinkify(plain).replace(/\n/g, '<br>'), null);
2517  }
2518
2519  // Prefer the server-cached thread parse — that's the richest structure
2520  // and the one the chat-bubble layout is built around. Skip when the user
2521  // has manually disabled bubble rendering.
2522  if (!_bubblesDisabled() && Array.isArray(data && data.thread_turns) && data.thread_turns.length) {
2523    return _foldSignature(
2524      _renderTurnsAsBubbles(data.thread_turns, data),
2525      data && data.sender_signature || null,
2526    );
2527  }
2528  const b = data && data.boundaries;
2529  // Use cached boundaries when present AND we have plain-text body to slice
2530  if (b && plain && (b.sig_start >= 0 || b.quote_start >= 0)) {
2531    // Pick the EARLIER of the two as the cut for "everything below this is
2532    // foldable", but render sig and quote with their own labels.
2533    let sig = (typeof b.sig_start === 'number' && b.sig_start >= 0) ? b.sig_start : -1;
2534    let quote = (typeof b.quote_start === 'number' && b.quote_start >= 0) ? b.quote_start : -1;
2535    // Clamp
2536    if (sig >= plain.length) sig = -1;
2537    if (quote >= plain.length) quote = -1;
2538    let head = plain;
2539    let sigSection = '';
2540    let quoteSection = '';
2541    if (sig >= 0 && quote >= 0) {
2542      const earlier = Math.min(sig, quote);
2543      head = plain.slice(0, earlier);
2544      if (sig < quote) {
2545        sigSection = plain.slice(sig, quote);
2546        quoteSection = plain.slice(quote);
2547      } else {
2548        quoteSection = plain.slice(quote, sig);
2549        sigSection = plain.slice(sig);
2550      }
2551    } else if (sig >= 0) {
2552      head = plain.slice(0, sig);
2553      sigSection = plain.slice(sig);
2554    } else {
2555      head = plain.slice(0, quote);
2556      quoteSection = plain.slice(quote);
2557    }
2558    const fmt = (s) => _escLinkify(s).replace(/\n/g, '<br>');
2559    let out = fmt(head);
2560    if (quoteSection) {
2561      out += '<details class="email-quote-fold">'
2562           + _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(quoteSection))
2563           + fmt(quoteSection) + '</details>';
2564    }
2565    if (sigSection) {
2566      const sigHtml = fmt(sigSection);
2567      if (_isBloatedSig(sigHtml)) {
2568        out += '<details class="email-sig-fold">' + _foldSummary('Signature', _SIG_ICON)
2569             + sigHtml + '</details>';
2570      } else {
2571        // Short closing — leave inline; folding would just add chrome.
2572        out += sigHtml;
2573      }
2574    }
2575    return out;
2576  }
2577  // Fallback: client-side parse (HTML or plaintext).
2578  const hintSig = (data && data.sender_signature) || null;
2579  const isHtml = !!data.body_html;
2580  let rendered;
2581  if (isHtml) {
2582    rendered = _sanitizeHtml(data.body_html);
2583  } else {
2584    const plainTurns = _renderPlaintextThread(data.body || '');
2585    if (plainTurns) return _foldSignature(plainTurns, hintSig);
2586    rendered = _escLinkify(data.body || '').replace(/\n/g, '<br>');
2587  }
2588  const threaded = _renderThreadStructure(rendered);
2589  if (threaded) return _foldSignature(threaded, hintSig);
2590  return _foldSignature(_foldQuotedReplies(rendered), hintSig);
2591}
2592
2593function _safeRenderEmailBody(data) {
2594  try {
2595    return _renderEmailBody(data);
2596  } catch (e) {
2597    console.error('email body render failed:', e);
2598    const plain = (typeof data?.body === 'string') ? data.body : '';
2599    if (plain) return _escLinkify(plain).replace(/\n/g, '<br>');
2600    if (data?.body_html) return _sanitizeHtml(data.body_html);
2601    return '<span style="opacity:.65">No body</span>';
2602  }
2603}
2604
2605// ── Chat-bubble rendering for email threads ──
2606// Each parsed turn renders as a chat bubble. Bubbles for the active
2607// account's outgoing replies align right; everyone else aligns left.
2608// Order is reversed so the oldest message sits at the top of the
2609// conversation and the newest (the message currently being read) sits
2610// at the bottom — matches the mental model people have from chat.
2611
2612function _meEmailAddrs() {
2613  const set = new Set();
2614  for (const a of (state._libAccounts || [])) {
2615    if (a && a.from_address) set.add(String(a.from_address).toLowerCase().trim());
2616    if (a && a.imap_user) set.add(String(a.imap_user).toLowerCase().trim());
2617  }
2618  return set;
2619}
2620
2621// _parseTurnMeta / _formatBubbleDate / _formatRecipients / _senderColor /
2622// _initials live in ./emailLibrary/utils.js
2623
2624function _renderTurnsAsBubbles(turns, data) {
2625  if (!Array.isArray(turns) || !turns.length) return '';
2626  const mineSet = _meEmailAddrs();
2627  const lvl0Email = String(data && data.from_address || '').toLowerCase().trim();
2628  const lvl0Mine = !!lvl0Email && mineSet.has(lvl0Email);
2629  const lvl0Author = (data && (data.from_name || data.from_address)) || '';
2630  const lvl0Date = _formatBubbleDate(data && data.date);
2631
2632  // Newest reply on top, older history below. Turns come ordered shallow→deep
2633  // (level 0 = current reply, deeper levels = older quoted material) so we
2634  // render in source order without reversing.
2635  const ordered = turns.slice();
2636
2637  // Gather per-turn sender identity + frequency for the no-self case below.
2638  const turnIdentity = ordered.map((t) => {
2639    if (t.level === 0) {
2640      return { email: lvl0Email, author: lvl0Author };
2641    }
2642    const p = _parseTurnMeta(t.meta || '');
2643    return { email: p.email, author: p.author };
2644  });
2645  const anyMine = turnIdentity.some(x => x.email && mineSet.has(x.email));
2646  // When the user isn't a participant in this thread (forwarded chains,
2647  // historical archives, etc.), assign the two most frequent senders to
2648  // opposite sides so the conversation still reads side-to-side. Third+
2649  // parties fall back to hash mod 2.
2650  const sideForKey = (() => {
2651    if (anyMine) return null;
2652    const freq = new Map();
2653    const firstSeen = new Map();
2654    turnIdentity.forEach((x, i) => {
2655      const key = (x.email || x.author || '').toLowerCase();
2656      if (!key) return;
2657      freq.set(key, (freq.get(key) || 0) + 1);
2658      if (!firstSeen.has(key)) firstSeen.set(key, i);
2659    });
2660    const sorted = [...freq.entries()]
2661      .sort((a, b) => (b[1] - a[1]) || (firstSeen.get(a[0]) - firstSeen.get(b[0])));
2662    const leftKey  = sorted[0] && sorted[0][0];
2663    const rightKey = sorted[1] && sorted[1][0];
2664    return (key) => {
2665      if (!key) return 'theirs';
2666      if (key === leftKey)  return 'theirs';
2667      if (key === rightKey) return 'mine';
2668      // Stable hash for 3rd+ parties.
2669      let h = 0;
2670      for (let i = 0; i < key.length; i++) h = ((h << 5) - h + key.charCodeAt(i)) | 0;
2671      return (h & 1) ? 'mine' : 'theirs';
2672    };
2673  })();
2674
2675  const rows = ordered.map((t, i) => {
2676    let isMine, author, date;
2677    if (t.level === 0) {
2678      isMine = lvl0Mine;
2679      author = lvl0Author || 'Me';
2680      date = lvl0Date;
2681    } else {
2682      const p = _parseTurnMeta(t.meta || '');
2683      isMine = !!p.email && mineSet.has(p.email);
2684      author = p.author || (t.meta || 'Earlier reply');
2685      date = p.date;
2686    }
2687    // No-self fallback: route by per-sender side mapping.
2688    if (sideForKey) {
2689      const id = turnIdentity[i];
2690      const key = (id.email || id.author || '').toLowerCase();
2691      isMine = sideForKey(key) === 'mine';
2692    }
2693    const side = isMine ? 'mine' : 'theirs';
2694    const initials = _initials(author);
2695    const color = _senderColor(author || (t.level === 0 ? lvl0Email : ''));
2696    const head =
2697      `<div class="email-bubble-head">`
2698      + `<span class="email-bubble-author" style="color:${color}">${_esc(author)}</span>`
2699      + (date ? `<span class="email-bubble-date">${_esc(date)}</span>` : '')
2700      + `</div>`;
2701    const avatar = `<div class="email-bubble-avatar" aria-hidden="true" style="background:${color}">
2701${_esc(initials)}</div>`;
2702    return (
2703      `<div class="email-bubble-row email-bubble-${side}" style="--bubble-accent:${color}">`
2704      + (isMine ? '' : avatar)
2705      + `<div class="email-bubble">`
2706      +   head
2707      +   `<div class="email-bubble-body">${_sanitizeHtml(t.body_html || '')}</div>`
2708      + `</div>`
2709      + (isMine ? avatar : '')
2710      + `</div>`
2711    );
2712  });
2713  return `<div class="email-bubbles">${rows.join('')}</div>`;
2714}
2715
2716/**
2717 * Render server-cached thread turns (list of {level, body_html, meta})
2718 * into the same nested-card structure the client-side parser produces.
2719 */
2720function _renderTurnsFromServer(turns) {
2721  if (!Array.isArray(turns) || !turns.length) return '';
2722  let out = '';
2723  const stack = []; // [{ level, html }]
2724  const wrap = (t) =>
2725    `<details class="email-thread-turn email-quote-fold" open>`
2726    + _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
2727    + `<div class="email-thread-turn-body">${t.html}</div>`
2728    + '</details>';
2729
2730  for (const t of turns) {
2731    if (t.level === 0) {
2732      while (stack.length) {
2733        const top = stack.pop();
2734        const w = wrap(top);
2735        if (stack.length) stack[stack.length - 1].html += w; else out += w;
2736      }
2737      out += _sanitizeHtml(t.body_html || '');
2738    } else {
2739      while (stack.length && stack[stack.length - 1].level > t.level) {
2740        const top = stack.pop();
2741        const w = wrap(top);
2742        if (stack.length) stack[stack.length - 1].html += w; else out += w;
2743      }
2744      if (!stack.length || stack[stack.length - 1].level < t.level) {
2745        stack.push({ level: t.level, meta: t.meta, html: _sanitizeHtml(t.body_html || '') });
2746      } else {
2747        stack[stack.length - 1].html += _sanitizeHtml(t.body_html || '');
2748        if (t.meta && !stack[stack.length - 1].meta) {
2749          stack[stack.length - 1].meta = t.meta;
2750        }
2751      }
2752    }
2753  }
2754  while (stack.length) {
2755    const top = stack.pop();
2756    const w = wrap(top);
2757    if (stack.length) stack[stack.length - 1].html += w; else out += w;
2758  }
2759  // Mark the bottom-most fold for rounded corners.
2760  const lastIdx = out.lastIndexOf('<details class="email-thread-turn email-quote-fold"');
2761  if (lastIdx >= 0) {
2762    out = out.slice(0, lastIdx)
2763        + out.slice(lastIdx).replace(
2764            'email-thread-turn email-quote-fold"',
2765            'email-thread-turn email-quote-fold last-fold"'
2766          );
2767  }
2768  return out;
2769}
2770
2771/**
2772 * Parse an email body's reply chain into a stack of turn-cards.
2773 * Each turn = { author, date, bodyHtml, nested[] } where the body is
2774 * everything UP TO the next quote boundary, and `nested` is the sub-thread
2775 * inside (recursively parsed). Returns null if the email has no quoted
2776 * thread to parse (single message, no folds needed).
2777 */
2778// ── Talon-inspired multilingual quote-detection patterns ──
2779// Sources:
2780//   github.com/mailgun/talon (HTML/text quote detection)
2781//   github.com/crisp-oss/email-reply-parser (locale list)
2782//
2783// _TALON_* / _SIG_BLOAT_MIN_CHARS live in ./emailLibrary/utils.js
2784// _SIG_ICON / _QUOTE_ICON live in ./emailLibrary/signatureFold.js
2785
2786function _renderThreadStructure(html) {
2787  if (!html || typeof html !== 'string' || html.length > 200000) return null;
2788  let doc;
2789  try { doc = new DOMParser().parseFromString(`<div id="__t">${html}</div>`, 'text/html'); }
2790  catch { return null; }
2791  const root = doc.getElementById('__t');
2792  if (!root) return null;
2793
2794  // Find top-level blockquotes (not nested inside another blockquote).
2795  const tops = Array.from(root.querySelectorAll('blockquote')).filter(b =>
2796    !b.parentElement.closest('blockquote')
2797  );
2798  if (!tops.length) return null;
2799
2800  // Build the current-message body: everything in root up to the first
2801  // top-level blockquote, minus the "On <date>, <author> wrote:" attribution
2802  // line that introduces it.
2803  const head = doc.createElement('div');
2804  let cursor = root.firstChild;
2805  while (cursor && cursor !== tops[0]) {
2806    const next = cursor.nextSibling;
2807    head.appendChild(cursor);
2808    cursor = next;
2809  }
2810  // Strip trailing "On <date>, <name> wrote:" / Outlook-style attribution
2811  // from `head` since the same info will appear in the turn header.
2812  let attribution = _harvestAttribution(head);
2813
2814  // Recursively parse each top-level blockquote into a turn (and its nested chain).
2815  const turnsHtml = [];
2816  for (let i = 0; i < tops.length; i++) {
2817    const bq = tops[i];
2818    // The blockquote may have an Outlook-style "From: / Sent: / Subject:"
2819    // header inside as the first text. Extract that as the turn meta.
2820    const meta = _extractTurnMetaFromBlockquote(bq) || attribution || _extractQuoteMeta(bq.innerHTML);
2821    const innerHtml = bq.innerHTML;
2822
2823    // Heuristic: if a blockquote has no detectable attribution (no "From:",
2824    // no "On <date>... wrote:") AND its content matches signature-style
2825    // patterns (corporate disclaimer, "registered in", legal notices, just
2826    // a name + title), treat it as a Signature fold instead of an Earlier
2827    // Reply. This stops mail clients that wrap signatures in <blockquote>
2828    // from making the signature appear as a phantom prior email.
2829    if (!meta && _looksLikeSignature(innerHtml)) {
2830      turnsHtml.push(
2831        '<details class="email-sig-fold">'
2832        + _foldSummary('Signature', _SIG_ICON)
2833        + `<div class="email-sig-body">${innerHtml}</div>`
2834        + '</details>'
2835      );
2836      attribution = null;
2837      continue;
2838    }
2839
2840    // Recursively render the inside of this blockquote (which may contain
2841    // its own nested blockquotes representing earlier replies).
2842    const nested = _renderThreadStructure(innerHtml);
2843    const bodyHtml = nested || innerHtml;
2844    const isLast = i === tops.length - 1;
2845    turnsHtml.push(
2846      `<details class="email-thread-turn email-quote-fold${isLast ? ' last-fold' : ''}" ${i === 0 ? '' : 'open'}>`
2847        + _foldSummary('Earlier reply', _QUOTE_ICON, meta || '')
2848        + `<div class="email-thread-turn-body">${bodyHtml}</div>`
2849      + '</details>'
2850    );
2851    // Only the first turn uses the harvested attribution; deeper turns
2852    // get their own from inside the blockquote.
2853    attribution = null;
2854  }
2855
2856  return head.innerHTML + turnsHtml.join('');
2857}
2858
2859// Looks like a signature / corporate disclaimer rather than a quoted email.
2860// Used to demote attribution-less blockquotes that some senders wrap their
2861// sig+disclaimer in (Outlook, EY, big firms) from "Earlier reply" to a
2862// proper Signature fold. Conservative — only fires when there's no quoted
2863// reply markers AND it matches strong corporate-noise phrases.
2864// _looksLikeSignature / _harvestAttribution / _extractTurnMetaFromBlockquote
2865// live in ./emailLibrary/signatureFold.js
2866
2867/**
2868 * Wrap any quoted reply chain in a collapsed <details> so deep email threads
2869 * don't dominate the reader. Detects:
2870 *   - <blockquote> tags (Gmail / native quoted replies)
2871 *   - Outlook-style "From: ... Sent: ... To: ... Subject: ..." headers
2872 * Each gets its own "Earlier thread" toggle.
2873 */
2874/**
2875 * Parse a plaintext email body into stacked turn-cards by walking
2876 * `> ` quote-prefix levels and Outlook-style "On X wrote:" / Original-Message
2877 * boundaries. Returns rendered HTML, or null when there's no quoted content
2878 * (caller falls back to flat rendering).
2879 *
2880 * Mirrors talon's `extract_from_plain` and email-reply-parser fragments:
2881 *   1. Lines starting with one or more `>` chars are quoted (level = count of >).
2882 *   2. Increasing the level opens a deeper turn (nested reply).
2883 *   3. `-----Original Message-----` and `On <date>, <name> wrote:` start a
2884 *      new turn even without `>`.
2885 *   4. The leading non-quoted segment is the current message.
2886 */
2887function _renderPlaintextThread(text) {
2888  if (!text || typeof text !== 'string' || text.length > 200000) return null;
2889  const lines = text.split(/\r?\n/);
2890  const levels = lines.map(l => {
2891    const m = l.match(/^((?:>\s?)+)/);
2892    return m ? (m[1].match(/>/g) || []).length : 0;
2893  });
2894  const hasQuotes = levels.some(l => l > 0);
2895  const attribLineRe = new RegExp(`(?:^|\\n)\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'im');
2896  const hasAttrib = attribLineRe.test(text) || _TALON_ORIG_RE.test(text);
2897  if (!hasQuotes && !hasAttrib) return null;
2898
2899  const turns = [];
2900  let buf = [];
2901  let curLevel = 0;
2902  let pendingMeta = null;
2903  const flush = () => {
2904    if (!buf.length) return;
2905    const t = buf.join('\n').trimEnd();
2906    if (t || curLevel > 0) turns.push({ level: curLevel, text: t, meta: pendingMeta });
2907    buf = [];
2908    pendingMeta = null;
2909  };
2910  for (let i = 0; i < lines.length; i++) {
2911    const lvl = levels[i];
2912    const raw = lines[i];
2913    const stripped = lvl > 0 ? raw.replace(/^(?:>\s?)+/, '') : raw;
2914    const isSeparatorLine = lvl === 0 && /^-{5,}\s*Previous message\s*-{5,}$/i.test(raw.trim());
2915    const isAttribLine = lvl === 0
2916      && (new RegExp(`^\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'i').test(raw)
2917          || _TALON_ORIG_RE.test('\n' + raw));
2918    if (isSeparatorLine || isAttribLine) {
2919      flush();
2920      pendingMeta = isSeparatorLine ? null : (_extractQuoteMeta(raw) || raw.trim());
2921      curLevel = 1;
2922      continue;
2923    }
2924    if (lvl !== curLevel) {
2925      flush();
2926      curLevel = lvl;
2927    }
2928    buf.push(stripped);
2929  }
2930  flush();
2931
2932  if (!turns.length || (turns.length === 1 && turns[0].level === 0)) return null;
2933
2934  const fmt = s => _escLinkify(s).replace(/\n/g, '<br>');
2935  let out = '';
2936  const stack = [];
2937  const wrapTurn = (t) =>
2938    `<details class="email-thread-turn email-quote-fold" open>`
2939    + _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
2940    + `<div class="email-thread-turn-body">${t.html}</div>`
2941    + '</details>';
2942
2943  for (const t of turns) {
2944    if (t.level === 0) {
2945      while (stack.length) {
2946        const top = stack.pop();
2947        const wrapped = wrapTurn(top);
2948        if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
2949      }
2950      out += fmt(t.text);
2951    } else {
2952      while (stack.length && stack[stack.length - 1].level > t.level) {
2953        const top = stack.pop();
2954        const wrapped = wrapTurn(top);
2955        if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
2956      }
2957      if (!stack.length || stack[stack.length - 1].level < t.level) {
2958        stack.push({ level: t.level, meta: t.meta, html: fmt(t.text) });
2959      } else {
2960        stack[stack.length - 1].html += '<br>' + fmt(t.text);
2961        if (t.meta && !stack[stack.length - 1].meta) stack[stack.length - 1].meta = t.meta;
2962      }
2963    }
2964  }
2965  while (stack.length) {
2966    const top = stack.pop();
2967    const wrapped = wrapTurn(top);
2968    if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
2969  }
2970  const lastIdx = out.lastIndexOf('<details class="email-thread-turn email-quote-fold"');
2971  if (lastIdx >= 0) {
2972    out = out.slice(0, lastIdx)
2973        + out.slice(lastIdx).replace(
2974            'email-thread-turn email-quote-fold"',
2975            'email-thread-turn email-quote-fold last-fold"'
2976          );
2977  }
2978  return out;
2979}
2980
2981// _foldSummary / _extractQuoteMeta / _SIG_ICON / _QUOTE_ICON
2982// live in ./emailLibrary/signatureFold.js
2983
2984function _foldQuotedReplies(html) {
2985  if (!html || typeof html !== 'string') return html;
2986  if (html.length > 200000) return html;
2987  const before = html;
2988  // Use DOMParser for proper nested-blockquote handling. Regex against HTML
2989  // mishandles nesting and leaves orphan close tags that the browser
2990  // re-balances, producing two visually inconsistent fold styles.
2991  try {
2992    const doc = new DOMParser().parseFromString(`<div id="__r">${html}</div>`, 'text/html');
2993    const root = doc.getElementById('__r');
2994    if (root) {
2995      // Only fold TOP-LEVEL blockquotes (children of the root that are not
2996      // already inside another blockquote). The inner blockquote chain stays
2997      // intact inside the fold and renders with the existing
2998      // .email-quote-fold blockquote styles, so everything matches.
2999      const tops = Array.from(root.querySelectorAll('blockquote')).filter(b =>
3000        !b.parentElement.closest('blockquote')
3001      );
3002      if (tops.length) {
3003        for (const bq of tops) {
3004          const det = doc.createElement('details');
3005          det.className = 'email-quote-fold';
3006          // Build the summary as raw HTML — easier than building DOM by hand.
3007          const summary = _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(bq.innerHTML));
3008          det.innerHTML = summary;
3009          bq.parentNode.insertBefore(det, bq);
3010          det.appendChild(bq); // move the original blockquote (and any nested ones) into the details
3011        }
3012        // Tag only the last fold so CSS can give it rounded bottom corners.
3013        const allFolds = root.querySelectorAll('.email-quote-fold');
3014        if (allFolds.length) allFolds[allFolds.length - 1].classList.add('last-fold');
3015        return root.innerHTML;
3016      }
3017    }
3018  } catch (e) {
3019    // Fall through to the legacy regex path below if DOMParser fails
3020  }
3021  // If DOM-pass already wrapped something, we returned above. Otherwise no
3022  // blockquotes were found — try the Outlook-header heuristic.
3023  if (html !== before) return html;
3024  // Outlook-style quoted-reply header — multilingual. Fold from the first
3025  // "From: ... Sent: ... Subject: ..." block through end-of-body so all
3026  // prior thread levels collapse together.
3027  const FROM = '(?:From|Från|Von|De|De\\s|Da|От|Od|Van)';
3028  const SENT = '(?:Sent|Skickat|Gesendet|Envoyé|Inviato|Enviado|Verzonden|Отправлено|Wysłane)';
3029  const SUBJ = '(?:Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat)';
3030  const outlookRe = new RegExp(
3031    `(<br\\s*/?>|</p>|</div>|<p[^>]*>|<div[^>]*>|\\n)\\s*((?:<[^>]+>\\s*)*${FROM}\\s*:\\s*[^<\\n]+(?:<[^>]+>\\s*|\\s)*${SENT}\\s*:[\\s\\S]+?${SUBJ}\\s*:[\\s\\S]+)$`,
3032    'i'
3033  );
3034  const m = html.match(outlookRe);
3035  if (m) {
3036    const idx = html.lastIndexOf(m[0]);
3037    // Outlook fallback only ever produces ONE fold, so tag it as last.
3038    html = html.slice(0, idx) + m[1]
3039      + '<details class="email-quote-fold last-fold">'
3040      + _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2]))
3041      + m[2] + '</details>';
3042  }
3043  return html;
3044}
3045
3046
3047// Global preference: AI summary panels stay collapsed across every email
3048// once the user folds one, and stay expanded once they unfold. Stored in
3049// localStorage so the choice survives reloads.
3050const _SUMMARY_COLLAPSED_KEY = 'odysseus.email.summaryCollapsed';
3051function _summaryCollapsedPref() {
3052  try { return localStorage.getItem(_SUMMARY_COLLAPSED_KEY) === '1'; } catch { return false; }
3053}
3054function _setSummaryCollapsedPref(v) {
3055  try { localStorage.setItem(_SUMMARY_COLLAPSED_KEY, v ? '1' : '0'); } catch {}
3056}
3057
3058function _showCachedSummary(reader, summary, btn) {
3059  const body = reader.querySelector('.email-reader-body');
3060  if (!body) return;
3061  if (body.querySelector('.email-summary-panel')) return;
3062  const panel = document.createElement('div');
3063  panel.className = 'email-summary-panel';
3064  if (_summaryCollapsedPref()) panel.classList.add('collapsed');
3065  panel.innerHTML =
3066    '<div class="email-summary-header email-summary-toggle" role="button" tabindex="0">'
3067    +   '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>'
3068    +   '<span>Summary</span>'
3069    +   '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
3070    + '</div>'
3071    + '<div class="email-summary-content"></div>';
3072  panel.querySelector('.email-summary-content').textContent = summary;
3073  body.insertBefore(panel, body.firstChild);
3074  const toggle = panel.querySelector('.email-summary-toggle');
3075  // Header click folds/unfolds. Persists so the next email opens in the
3076  // same state.
3077  const _flip = () => {
3078    panel.classList.toggle('collapsed');
3079    _setSummaryCollapsedPref(panel.classList.contains('collapsed'));
3080  };
3081  if (toggle) {
3082    toggle.addEventListener('click', (ev) => { ev.stopPropagation(); _flip(); });
3083    toggle.addEventListener('keydown', (ev) => {
3084      if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _flip(); }
3085    });
3086  }
3087  if (btn) {
3088    btn.classList.add('active');
3089    const label = btn.querySelector('.btn-label');
3090    if (label) label.textContent = 'Summary';
3091  }
3092}
3093
3094// "Other from this sender" — slide-out panel inside the reader listing
3095// recent emails from the same address. Click an item to load it in place.
3096async function _toggleFromSenderPanel(reader, data, btn) {
3097  const body = reader.querySelector('.email-reader-body');
3098  if (!body) return;
3099
3100  // Recenter the modal after its size changes (CSS widens + heightens the
3101  // modal-content when the from-sender panel is mounted/unmounted). Without
3102  // this the modal grows only to the right/down and can overflow the
3103  // viewport on narrow / short windows.
3104  const _recenterModal = () => {
3105    const modal = document.getElementById('email-lib-modal');
3106    const content = modal?.querySelector('.modal-content');
3107    if (!content) return;
3108    requestAnimationFrame(() => {
3109      const w = content.offsetWidth;
3110      const h = content.offsetHeight;
3111      const newLeft = Math.max(20, (window.innerWidth - w) / 2);
3112      const newTop  = Math.max(20, (window.innerHeight - h) / 2);
3113      content.style.left = newLeft + 'px';
3114      content.style.top  = newTop + 'px';
3115    });
3116  };
3117
3118  // Already open? Close it.
3119  const existing = reader.querySelector('.from-sender-panel');
3120  if (existing) {
3121    existing.remove();
3122    reader.classList.remove('from-sender-open');
3123    if (btn) btn.classList.remove('active');
3124    _recenterModal();
3125    return;
3126  }
3127
3128  const fromAddr = String(data.from_address || '').trim();
3129  if (!fromAddr) {
3130    if (typeof showError === 'function') showError('No sender address available');
3131    return;
3132  }
3133
3134  const panel = document.createElement('div');
3135  panel.className = 'from-sender-panel';
3136  const displayName = (data.from_name && data.from_name.trim()) || fromAddr;
3137  const firstName = displayName.split(' ')[0] || displayName;
3138  panel.innerHTML = `
3139    <div class="from-sender-header">
3140      <span class="from-sender-chips"></span>
3141      <span class="from-sender-header-empty" hidden>All senders</span>
3142      <button type="button" class="from-sender-toggle" data-toggle="attachments" title="Show only emails with attachments" aria-pressed="false">
3143        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
3144      </button>
3145      <button type="button" class="from-sender-close" title="Close" aria-label="Close sender panel">&times;</button>
3146    </div>
3147    <div class="from-sender-search-wrap">
3148      <input type="text" class="from-sender-search" placeholder="Search ${_esc(firstName)}…" autocomplete="off" />
3149      <div class="from-sender-suggest" hidden></div>
3150    </div>
3151    <div class="from-sender-list">
3152      <div class="from-sender-loading"></div>
3153    </div>
3154  `;
3155  reader.appendChild(panel);
3156  reader.classList.add('from-sender-open');
3157  if (btn) btn.classList.add('active');
3158  _recenterModal();
3159
3160  // Header close — same as the toolbar funnel button so the close path
3161  // stays single-sourced (panel removal + active class drop).
3162  const headerClose = panel.querySelector('.from-sender-close');
3163  if (headerClose) {
3164    headerClose.addEventListener('click', (ev) => {
3165      ev.stopPropagation();
3166      const toolbarBtn = reader.querySelector('[data-act="from-sender"]');
3167      if (toolbarBtn) toolbarBtn.click();
3168      else { panel.remove(); reader.classList.remove('from-sender-open'); }
3169    });
3170  }
3171
3172  const listEl = panel.querySelector('.from-sender-list');
3173  // Hoisted so panel._originalEmails (assigned later, outside the try) can see it.
3174  let emails = [];
3175
3176  // Multi-tag model — the header is now a list of {name, address} chips.
3177  // Filter logic: an email matches when EVERY tag's address appears in
3178  // from/to/cc (case-insensitive substring on the joined header strings).
3179  panel._tags = [{ name: displayName, address: fromAddr }];
3180  panel._attachmentsOnly = false;
3181  const searchEl = panel.querySelector('.from-sender-search');
3182  const chipsContainer = panel.querySelector('.from-sender-chips');
3183  const emptyLabel = panel.querySelector('.from-sender-header-empty');
3184  const suggestEl = panel.querySelector('.from-sender-suggest');
3185  const attToggle = panel.querySelector('[data-toggle="attachments"]');
3186
3187  const _renderChips = () => {
3188    chipsContainer.innerHTML = panel._tags.map((t, i) => `
3189      <span class="from-sender-chip" title="${_esc(t.address)}" data-tag-index="${i}">
3190        <span class="from-sender-chip-name">${_esc(t.name || t.address)}</span>
3191        <button class="from-sender-chip-x" type="button" title="Remove" aria-label="Remove ${_esc(t.name || t.address)}">&times;</button>
3192      </span>
3193    `).join('');
3194    if (emptyLabel) emptyLabel.hidden = panel._tags.length > 0;
3195    chipsContainer.querySelectorAll('.from-sender-chip-x').forEach(btn => {
3196      btn.addEventListener('click', (ev) => {
3197        ev.stopPropagation();
3198        const idx = Number(btn.closest('.from-sender-chip')?.dataset.tagIndex || -1);
3199        if (idx < 0) return;
3200        panel._tags.splice(idx, 1);
3201        _renderChips();
3202        _refreshList();
3203      });
3204    });
3205  };
3206  // Filter loaded emails (or recents) by every active tag.
3207  const _matchesTags = (em) => {
3208    if (!panel._tags.length) return true;
3209    const haystack = [
3210      String(em.from_address || ''),
3211      String(em.to || ''),
3212      String(em.cc || ''),
3213    ].join(' ').toLowerCase();
3214    return panel._tags.every(t => haystack.includes(String(t.address || '').toLowerCase()));
3215  };
3216  const _applyToggles = () => {
3217    const base = panel._lastResults || [];
3218    let view = base.filter(_matchesTags);
3219    if (panel._attachmentsOnly) view = view.filter(e => e.has_attachments);
3220    if (!view.length) {
3221      const why = panel._attachmentsOnly
3222        ? 'No emails with attachments in this view.'
3223        : (panel._tags.length > 1 ? 'No emails involve all those people.' : 'No matches.');
3224      listEl.innerHTML = `<div class="from-sender-empty">${why}</div>`;
3225    } else {
3226      _renderFromSenderRows(view, listEl, reader, { showFolder: !!panel._lastShowFolder });
3227    }
3228  };
3229  panel._setResults = (rows, opts = {}) => {
3230    panel._lastResults = rows || [];
3231    panel._lastShowFolder = !!opts.showFolder;
3232    _applyToggles();
3233  };
3234  // Re-runs the appropriate fetch path for the current tag set / query.
3235  // Declared early so chip-removal handlers above can call it.
3236  let _refreshList = () => {};
3237  if (attToggle) {
3238    attToggle.addEventListener('click', (ev) => {
3239      ev.stopPropagation();
3240      panel._attachmentsOnly = !panel._attachmentsOnly;
3241      attToggle.classList.toggle('is-active', panel._attachmentsOnly);
3242      attToggle.setAttribute('aria-pressed', panel._attachmentsOnly ? 'true' : 'false');
3243      _applyToggles();
3244    });
3245  }
3246
3247  try {
3248    const sp = spinnerModule.createWhirlpool(20);
3249    const loading = panel.querySelector('.from-sender-loading');
3250    loading.appendChild(sp.element);
3251
3252    const params = new URLSearchParams({
3253      q: fromAddr,
3254      folder: state._libFolder || 'INBOX',
3255      limit: '25',
3256    });
3257    const acct = _acct();
3258    const acctSuffix = acct ? acct.replace(/^&?/, '&') : '';
3259    const res = await fetch(`${API_BASE}/api/email/search?${params.toString()}${acctSuffix}`);
3260    const j = await res.json();
3261    let raw = Array.isArray(j.emails) ? j.emails : [];
3262    const target = fromAddr.toLowerCase();
3263    raw = raw.filter(e => String(e.from_address || '').toLowerCase() === target);
3264    raw = raw.filter(e => String(e.uid) !== String(data.uid));
3265    emails = raw;
3266
3267    if (!emails.length) {
3268      listEl.innerHTML = `<div class="from-sender-empty">No other emails from this sender in ${_esc(state._libFolder || 'INBOX')}.</div>`;
3269    } else {
3270      panel._setResults(emails, { showFolder: false });
3271    }
3272  } catch (err) {
3273    listEl.innerHTML = `<div class="from-sender-empty" style="color:var(--red, #e55)">Failed to load: ${_esc(String(err))}</div>`;
3274  }
3275  const updatePlaceholder = () => {
3276    if (!searchEl) return;
3277    searchEl.placeholder = panel._tags.length
3278      ? 'Add another person…'
3279      : 'Search people or emails…';
3280  };
3281  updatePlaceholder();
3282  _renderChips();
3283
3284  // Used both when chips change AND when the user clears their query.
3285  // Pulls the most-recent emails across the common folders so the user
3286  // lands on something useful, then _applyToggles narrows by tags.
3287  let _recentToken = 0;
3288  const _loadRecentAcross = async () => {
3289    const myToken = ++_recentToken;
3290    const folders = _crossFolderCandidates();
3291    const acct = _acct();
3292    const acctSuffix = acct ? acct.replace(/^&?/, '&') : '';
3293    listEl.innerHTML = `<div class="from-sender-loading"></div>`;
3294    try {
3295      const sp = spinnerModule.createWhirlpool(18);
3296      listEl.querySelector('.from-sender-loading')?.appendChild(sp.element);
3297      const results = await Promise.all(folders.map(async (f) => {
3298        const params = new URLSearchParams({ folder: f, limit: '40', offset: '0', filter: 'all' });
3299        const res = await fetch(`${API_BASE}/api/email/list?${params.toString()}${acctSuffix}`);
3300        const j = await res.json();
3301        return (j.emails || []).map(em => ({ ...em, _folder: f }));
3302      }));
3303      if (myToken !== _recentToken) return;
3304      let merged = [].concat(...results);
3305      merged.sort((a, b) => {
3306        const da = a.date ? Date.parse(a.date) : 0;
3307        const db = b.date ? Date.parse(b.date) : 0;
3308        return db - da;
3309      });
3310      // Take a wider slice up front; tag/attachment filters trim it.
3311      merged = merged.slice(0, 80);
3312      panel._setResults(merged, { showFolder: true });
3313      updatePlaceholder();
3314    } catch (err) {
3315      if (myToken !== _recentToken) return;
3316      listEl.innerHTML = `<div class="from-sender-empty" style="color:var(--red, #e55)">Failed to load: ${_esc(String(err))}</div>`;
3317    }
3318  };
3319
3320  // Adds a contact as a tag, clears input, refreshes the list.
3321  const _addTag = (contact) => {
3322    if (!contact || !contact.address) return;
3323    const addr = String(contact.address).toLowerCase();
3324    if (panel._tags.some(t => String(t.address).toLowerCase() === addr)) return;
3325    panel._tags.push({ name: contact.name || contact.address, address: contact.address });
3326    _renderChips();
3327    if (searchEl) { searchEl.value = ''; }
3328    if (suggestEl) { suggestEl.hidden = true; suggestEl.innerHTML = ''; }
3329    updatePlaceholder();
3330    _refreshList();
3331  };
3332
3333  // Cross-folder search — when the user types, also honor the sender chip if
3334  // it's still active. Empty input with chip active restores the original
3335  // "from this sender" view; empty input with chip removed shows the prompt.
3336  if (searchEl) {
3337    let searchToken = 0;
3338    let debounceTimer = null;
3339    let suggestToken = 0;
3340    let highlightedIdx = -1;
3341
3342    // Free-text email search across folders. Tag filter is applied via
3343    // _applyToggles inside panel._setResults.
3344    const runSearch = async (q) => {
3345      const myToken = ++searchToken;
3346      const folders = _crossFolderCandidates();
3347      const acct = _acct();
3348      const acctSuffix = acct ? acct.replace(/^&?/, '&') : '';
3349      try {
3350        const results = await Promise.all(folders.map(async (f) => {
3351          const params = new URLSearchParams({ q, folder: f, limit: '15' });
3352          const res = await fetch(`${API_BASE}/api/email/search?${params.toString()}${acctSuffix}`);
3353          const j = await res.json();
3354          return (j.emails || []).map(em => ({ ...em, _folder: f }));
3355        }));
3356        if (myToken !== searchToken) return;
3357        let merged = [].concat(...results);
3358        merged.sort((a, b) => {
3359          const da = a.date ? Date.parse(a.date) : 0;
3360          const db = b.date ? Date.parse(b.date) : 0;
3361          return db - da;
3362        });
3363        if (!merged.length) {
3364          listEl.innerHTML = `<div class="from-sender-empty">No matches for "${_esc(q)}".</div>`;
3365          return;
3366        }
3367        panel._setResults(merged, { showFolder: true });
3368      } catch (err) {
3369        if (myToken !== searchToken) return;
3370        listEl.innerHTML = `<div class="from-sender-empty" style="color:var(--red, #e55)">Search failed: ${_esc(String(err))}</div>`;
3371      }
3372    };
3373
3374    // Hook up _refreshList so chip removal / tag add can rerun whichever
3375    // path matches the current input state.
3376    _refreshList = () => {
3377      const q = (searchEl.value || '').trim();
3378      if (q.length >= 2) runSearch(q);
3379      else _loadRecentAcross();
3380    };
3381
3382    // Contact suggestions — fetched from /api/email/contacts. Renders a
3383    // small absolutely-positioned dropdown under the input. Up/Down/Enter/
3384    // Esc handled in the keydown listener below.
3385    const _renderSuggestions = (items) => {
3386      if (!suggestEl) return;
3387      if (!items || !items.length) {
3388        suggestEl.hidden = true;
3389        suggestEl.innerHTML = '';
3390        highlightedIdx = -1;
3391        return;
3392      }
3393      highlightedIdx = 0;
3394      suggestEl.innerHTML = items.map((c, i) => `
3395        <div class="from-sender-suggest-item${i === 0 ? ' active' : ''}" data-idx="${i}" data-addr="${_esc(c.address)}" data-name="${_esc(c.name || c.address)}">
3396          <span class="suggest-name">${_esc(c.name || c.address)}</span>
3397          <span class="suggest-addr">${_esc(c.address)}</span>
3398        </div>
3399      `).join('');
3400      suggestEl.hidden = false;
3401      suggestEl.querySelectorAll('.from-sender-suggest-item').forEach(item => {
3402        item.addEventListener('mouseenter', () => {
3403          suggestEl.querySelectorAll('.from-sender-suggest-item').forEach(n => n.classList.remove('active'));
3404          item.classList.add('active');
3405          highlightedIdx = Number(item.dataset.idx);
3406        });
3407        item.addEventListener('mousedown', (ev) => {
3408          // mousedown so we add the chip BEFORE blur takes the focus away
3409          ev.preventDefault();
3410          _addTag({ name: item.dataset.name, address: item.dataset.addr });
3411        });
3412      });
3413    };
3414    const _fetchSuggestions = async (q) => {
3415      const myToken = ++suggestToken;
3416      try {
3417        // Use the same contact source as the email composer's To/Cc fields
3418        // (/api/contacts/search → {results: [{name, emails:[...]}]}). Flatten
3419        // to {name, address} pairs and drop any already-tagged address.
3420        const res = await fetch(`${API_BASE}/api/contacts/search?q=${encodeURIComponent(q)}`);
3421        const j = await res.json();
3422        if (myToken !== suggestToken) return;
3423        const tagged = new Set(panel._tags.map(t => String(t.address).toLowerCase()));
3424        const items = [];
3425        for (const c of (j.results || [])) {
3426          for (const addr of (c.emails || [])) {
3427            if (tagged.has(String(addr).toLowerCase())) continue;
3428            items.push({ name: c.name || addr, address: addr });
3429            if (items.length >= 8) break;
3430          }
3431          if (items.length >= 8) break;
3432        }
3433        _renderSuggestions(items);
3434      } catch {}
3435    };
3436
3437    searchEl.addEventListener('input', () => {
3438      clearTimeout(debounceTimer);
3439      const q = searchEl.value.trim();
3440      if (q.length < 2) {
3441        searchToken++;
3442        suggestToken++;
3443        if (suggestEl) { suggestEl.hidden = true; suggestEl.innerHTML = ''; }
3444        _loadRecentAcross();
3445        return;
3446      }
3447      // Fire suggestions immediately (cheap SQL) and defer the email search.
3448      _fetchSuggestions(q);
3449      debounceTimer = setTimeout(() => runSearch(q), 220);
3450    });
3451
3452    searchEl.addEventListener('keydown', (ev) => {
3453      const items = suggestEl && !suggestEl.hidden
3454        ? [...suggestEl.querySelectorAll('.from-sender-suggest-item')]
3455        : [];
3456      if (ev.key === 'ArrowDown' && items.length) {
3457        ev.preventDefault();
3458        highlightedIdx = (highlightedIdx + 1) % items.length;
3459        items.forEach((n, i) => n.classList.toggle('active', i === highlightedIdx));
3460      } else if (ev.key === 'ArrowUp' && items.length) {
3461        ev.preventDefault();
3462        highlightedIdx = (highlightedIdx - 1 + items.length) % items.length;
3463        items.forEach((n, i) => n.classList.toggle('active', i === highlightedIdx));
3464      } else if (ev.key === 'Enter') {
3465        if (items.length && highlightedIdx >= 0) {
3466          ev.preventDefault();
3467          const item = items[highlightedIdx];
3468          _addTag({ name: item.dataset.name, address: item.dataset.addr });
3469        }
3470      } else if (ev.key === 'Escape') {
3471        if (suggestEl && !suggestEl.hidden) {
3472          ev.preventDefault();
3473          suggestEl.hidden = true;
3474        }
3475      } else if (ev.key === 'Backspace' && searchEl.value === '' && panel._tags.length) {
3476        // Empty input + Backspace pops the rightmost chip — common chip-input idiom.
3477        ev.preventDefault();
3478        panel._tags.pop();
3479        _renderChips();
3480        _refreshList();
3481      }
3482    });
3483
3484    searchEl.addEventListener('blur', () => {
3485      // Hide suggestions on blur, with a tiny delay so click-on-suggestion
3486      // gets a chance to fire (mousedown-add covers most cases anyway).
3487      setTimeout(() => { if (suggestEl) suggestEl.hidden = true; }, 120);
3488    });
3489  }
3490  // Stash the sender's emails for restoring after a search is cleared.
3491  panel._originalEmails = (typeof emails !== 'undefined') ? emails : [];
3492}
3493
3494const _ATT_ICON = '<svg class="from-sender-att" width="11" height="11" viewB
3494ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="Has attachments"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
3495
3496function _renderFromSenderRows(emails, listEl, reader, opts = {}) {
3497  const { showFolder = false } = opts;
3498  listEl.innerHTML = emails.map(em => {
3499    const subj = em.subject || '(no subject)';
3500    const date = em.date ? new Date(em.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : (em.date_display || '');
3501    const unread = em.is_read ? '' : ' from-sender-unread';
3502    const att = em.has_attachments ? _ATT_ICON : '';
3503    const folder = em._folder || state._libFolder || 'INBOX';
3504    const folderChip = showFolder ? `<span class="from-sender-folder">${_esc(folder)}</span>` : '';
3505    return `<div class="from-sender-row${unread}" data-uid="${_esc(em.uid)}" data-folder="${_esc(folder)}">
3506      <button class="from-sender-row-main" type="button">
3507        <span class="from-sender-row-top">
3508          <span class="from-sender-subj">${_esc(subj)}</span>
3509          ${att}
3510        </span>
3511        <span class="from-sender-row-bottom">
3512          <span class="from-sender-date">${_esc(date)}</span>
3513          ${folderChip}
3514        </span>
3515      </button>
3516      <button class="from-sender-row-more" type="button" title="More actions" aria-label="More actions">
3517        <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/></svg>
3518      </button>
3519    </div>`;
3520  }).join('');
3521  listEl.querySelectorAll('.from-sender-row').forEach(row => {
3522    const main = row.querySelector('.from-sender-row-main');
3523    const more = row.querySelector('.from-sender-row-more');
3524    main?.addEventListener('click', async () => {
3525      const uid = row.dataset.uid;
3526      const folder = row.dataset.folder || state._libFolder;
3527      if (!uid) return;
3528      await _swapReaderToUid(reader, uid, folder);
3529    });
3530    more?.addEventListener('click', async (ev) => {
3531      ev.stopPropagation();
3532      const uid = row.dataset.uid;
3533      const folder = row.dataset.folder || state._libFolder;
3534      if (!uid) return;
3535      // Look up the row's email in any cache we know about; the menu just
3536      // needs uid + subject + folder for its actions.
3537      const em = (typeof emails !== 'undefined' ? emails : []).find(e => String(e.uid) === String(uid))
3538        || state._libEmails.find(e => String(e.uid) === String(uid))
3539        || { uid, subject: row.querySelector('.from-sender-subj')?.textContent || '' };
3540      const card = reader.closest('.doclib-card');
3541      if (card) _showReaderMoreMenu(em, card, reader, more);
3542    });
3543  });
3544}
3545
3546// Wire click handlers for attachment chips + "open in editor" sub-buttons
3547// inside a reader. Safe to call multiple times — uses dataset.wired flag to
3548// skip nodes that already have listeners.
3549function _wireAttachmentHandlers(reader, folder) {
3550  const useFolder = folder || state._libFolder;
3551  // Detect mobile here so the attachment-chip handler doesn't blow up with
3552  // a ReferenceError when this fn is called from contexts that don't have
3553  // _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow).
3554  const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
3555  reader.querySelectorAll('.email-attachment-open').forEach(openBtn => {
3556    if (openBtn.dataset.wired === '1') return;
3557    openBtn.dataset.wired = '1';
3558    openBtn.addEventListener('click', async (ev) => {
3559      ev.stopPropagation();
3560      ev.preventDefault();
3561      const uid = openBtn.dataset.openUid;
3562      const index = openBtn.dataset.openIndex;
3563      const name = openBtn.dataset.openName || `attachment-${index}`;
3564      if (!uid || index == null) return;
3565      const orig = openBtn.style.opacity;
3566      openBtn.style.opacity = '0.4';
3567      try {
3568        const folderQs = encodeURIComponent(useFolder);
3569        const res = await fetch(
3570          `${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`,
3571          { method: 'POST', credentials: 'same-origin' }
3572        );
3573        const json = await res.json().catch(() => ({}));
3574        if (!res.ok || !json.doc_id) {
3575          const msg = (json && json.error) || `HTTP ${res.status}`;
3576          try { const { showError } = await import('./ui.js'); showError(`Couldn't open ${name}: ${msg}`); } catch (_) { alert(`Couldn't open ${name}: ${msg}`); }
3577          return;
3578        }
3579        try {
3580          // Tab the email modal down only when the viewport cannot fit both
3581          // Email and the document pane. Desktop keeps a side-by-side layout
3582          // when there is room; mobile still gives the document the screen.
3583          const ownerModal = openBtn.closest('.modal');
3584          if (ownerModal && ownerModal.id && _prepareEmailWindowForDocument(ownerModal)) {
3585            try {
3586              const ok = Modals.minimize(ownerModal.id);
3587              if (!ok) ownerModal.classList.add('hidden');
3588            } catch (_) {
3589              ownerModal.classList.add('hidden');
3590            }
3591          }
3592          const docMod = await import('./document.js');
3593          const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
3594          if (typeof load === 'function') {
3595            await load(json.doc_id);
3596          } else {
3597            location.href = `/?doc=${encodeURIComponent(json.doc_id)}`;
3598          }
3599        } catch (e) {
3600          console.error('Open document failed:', e);
3601          try { const { showError } = await import('./ui.js'); showError('Document opened but panel could not mount'); } catch (_) {}
3602        }
3603      } catch (e) {
3604        console.error('attachment-as-doc error', e);
3605        try { const { showError } = await import('./ui.js'); showError(`Couldn't open ${name}`); } catch (_) {}
3606      } finally {
3607        openBtn.style.opacity = orig;
3608      }
3609    });
3610  });
3611
3612  reader.querySelectorAll('.email-attachment-chip').forEach(chip => {
3613    if (chip.dataset.wired === '1') return;
3614    chip.dataset.wired = '1';
3615    chip.addEventListener('click', async (ev) => {
3616      if (ev.target.closest('.email-attachment-open')) return;
3617      ev.stopPropagation();
3618      ev.preventDefault();
3619      const uid = chip.dataset.attUid;
3620      const index = chip.dataset.attIndex;
3621      const name = chip.dataset.attName || `attachment-${index}`;
3622      if (!uid || index == null) return;
3623      const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(useFolder)}${_acct()}`;
3624      if (_isMobileUA) {
3625        window.open(url, '_blank');
3626        return;
3627      }
3628      const orig = chip.style.opacity;
3629      chip.style.opacity = '0.6';
3630      try {
3631        const res = await fetch(url, { credentials: 'same-origin' });
3632        if (!res.ok) {
3633          console.error('attachment download failed', res.status, await res.text().catch(() => ''));
3634          location.href = url;
3635          return;
3636        }
3637        const blob = await res.blob();
3638        const blobUrl = URL.createObjectURL(blob);
3639        const a = document.createElement('a');
3640        a.href = blobUrl;
3641        a.download = name;
3642        document.body.appendChild(a);
3643        a.click();
3644        a.remove();
3645        setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
3646      } catch (e) {
3647        console.error('attachment download error', e);
3648        location.href = url;
3649      } finally {
3650        chip.style.opacity = orig;
3651      }
3652    });
3653  });
3654}
3655
3656// Heuristic: skip "attachments" that are clearly inline images used by
3657// signatures / quoted-reply headers (small image files, Outlook-style
3658// image001.png placeholders, logo*.png, etc.). They aren't real user-
3659// shared attachments and adding them to the chips makes every email look
3660// like it has content the user needs to act on.
3661function _isLikelySignatureImage(a) {
3662  if (!a || !a.filename) return false;
3663  const name = String(a.filename).toLowerCase();
3664  const isImage = /\.(png|jpe?g|gif|bmp|svg|webp)$/i.test(name);
3665  if (!isImage) return false;
3666  const size = Number(a.size) || 0;
3667  // Outlook / Gmail inline image placeholders always look like this.
3668  if (/^image\d{3,}\.(png|jpe?g|gif)$/i.test(name)) return true;
3669  if (/^(signature|logo|sig|footer|banner)[-_\d]*\.(png|jpe?g|gif|svg)$/i.test(name)) return true;
3670  // Most signature logos / inline thumbnails are < 30 KB. Real user-
3671  // shared images (screenshots, photos) are typically 50 KB+.
3672  if (size > 0 && size < 30 * 1024) return true;
3673  return false;
3674}
3675
3676// Build the attachments header+chips HTML for an email read response. Pulled
3677// out so both the initial-open and the swap-reader paths can render it.
3678function _buildAttsHtmlFor(uid, data) {
3679  if (!data || !data.attachments || !data.attachments.length) return '';
3680  const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown)$/i;
3681  const visible = data.attachments.filter(a => !_isLikelySignatureImage(a));
3682  if (!visible.length) return '';
3683  const chips = visible.map(a => {
3684    const openable = _OPENABLE_RE.test(a.filename || '');
3685    const openBtn = openable
3686      ? `<span class="email-attachment-open" title="Open in document editor" data-open-uid="${_esc(uid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg><span class="email-attachment-open-label">Open</span></span>`
3687      : '';
3688    return `<button type="button" class="email-attachment-chip" data-att-uid="${_esc(uid)}" data-att-index="${a.index}" data-att-name="${_esc(a.filename)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>${_esc(a.filename)}</span><span class="att-size">${Math.round((a.size||0)/1024)} KB</span>${openBtn}</button>`;
3689  }).join('');
3690  return (
3691    '<div class="email-reader-atts-wrap collapsed">'
3692    +   '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
3693    +     '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
3694    +     `<span>Attachments (${data.attachments.length})</span>`
3695    +     '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
3696    +   '</div>'
3697    +   '<div class="email-reader-atts">' + chips + '</div>'
3698    + '</div>'
3699  );
3700}
3701
3702// "Open in new tab" — the email opens in the library (expanded inline)
3703// AND a separate floating "email viewer" overlay modal is created. The
3704// overlay starts minimized as a chip in the dock; tapping the chip
3705// brings the viewer up over the library. Multiple tabs = multiple
3706// overlay modals + chips, each independent.
3707const _EMAIL_ICON_PATH = 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7';
3708let _emailTabSeq = 0;
3709// Persistent slot numbers per reader modalId. Once a reader is "tab 2"
3710// it stays "tab 2" until it's closed — even if tab 1 closes first, the
3711// remaining reader doesn't renumber down to 1. New tabs claim the
3712// lowest unused slot.
3713const _emailReaderSlots = new Map(); // modalId -> slot (1, 2, 3, ...)
3714function _allocReaderSlot(modalId) {
3715  if (_emailReaderSlots.has(modalId)) return _emailReaderSlots.get(modalId);
3716  const used = new Set(_emailReaderSlots.values());
3717  let n = 1;
3718  while (used.has(n)) n++;
3719  _emailReaderSlots.set(modalId, n);
3720  return n;
3721}
3722function _freeReaderSlot(modalId) {
3723  _emailReaderSlots.delete(modalId);
3724}
3725
3726// JS-driven gate: sets [data-email-tabs="N"] on <body> so CSS can show
3727// the per-chip number badge only when 2+ tabs exist.
3728function _syncEmailTabsCount() {
3729  const tabs = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-view-"]');
3730  document.body.dataset.emailTabs = String(tabs.length);
3731}
3732
3733// Recompute the email menu chip's tab-count whenever the dock contents
3734// change. Counts "email-view-*" chips both inside #minimized-dock and
3735// at body level (free-positioned chips on mobile). Result is written to
3736// the email-lib-modal chip's data-tab-count attribute; CSS reads it via
3737// attr() to render the badge.
3738function _syncEmailTabBadge() {
3739  const readers = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-reader-"]');
3740  document.body.dataset.emailReaders = String(readers.length);
3741  // Stamp each chip with its persistent slot number. CSS reads
3742  // data-tab-num via attr() instead of using a counter so the number
3743  // stays stable when other tabs close.
3744  readers.forEach(chip => {
3745    const slot = _emailReaderSlots.get(chip.dataset.modalId);
3746    if (slot) chip.dataset.tabNum = String(slot);
3747  });
3748}
3749let _emailTabObserverWired = false;
3750let _badgeSyncScheduled = false;
3751function _ensureEmailTabObserver() {
3752  if (_emailTabObserverWired) return;
3753  _emailTabObserverWired = true;
3754  // Debounce so a burst of mutations (e.g. _renderDock rebuilding the
3755  // whole dock in one pass) collapses to a single sync per animation
3756  // frame. Without this the chip badge could flicker as the observer
3757  // fires repeatedly during dock rerenders.
3758  const handler = () => {
3759    if (_badgeSyncScheduled) return;
3760    _badgeSyncScheduled = true;
3761    requestAnimationFrame(() => {
3762      _badgeSyncScheduled = false;
3763      _syncEmailTabBadge();
3764    });
3765  };
3766  const tryWire = () => {
3767    const dock = document.getElementById('minimized-dock');
3768    if (!dock) { setTimeout(tryWire, 200); return; }
3769    // Only watch what we care about: chip add/remove in the dock.
3770    const obs = new MutationObserver(handler);
3771    obs.observe(dock, { childList: true });
3772    // Watch the library grid so toggling a card expanded/collapsed
3773    // updates the lib chip's "has-expanded" badge in real time.
3774    const wireGridObs = () => {
3775      const grid = document.getElementById('email-lib-grid');
3776      if (!grid) { setTimeout(wireGridObs, 500); return; }
3777      const gridObs = new MutationObserver(handler);
3778      gridObs.observe(grid, { subtree: true, attributes: true, attributeFilter: ['class'] });
3779    };
3780    wireGridObs();
3781    handler();
3782  };
3783  tryWire();
3784}
3785// Hybrid model:
3786//   - email-lib-modal (the inbox library) is unique. Its chip just
3787//     restores it.
3788//   - Each "Open in new tab" creates a separate per-email reader modal
3789//     (id "email-reader-{uid}-{seq}") with the SAME structure & classes
3790//     as the library's inline reader, so they look identical. Each
3791//     reader registers its own dock chip with a number badge.
3792async function _openEmailAsTab(em, folder) {
3793  const useFolder = folder || state._libFolder || 'INBOX';
3794  _emailTabSeq += 1;
3795  const modalId = `email-reader-${em.uid}-${_emailTabSeq}`;
3796  _allocReaderSlot(modalId);
3797
3798  // Build the modal shell. Uses the same doclib-modal-content sizing
3799  // as the email library so it feels like a sibling window. The reader
3800  // body inside uses the exact same email-card-reader / email-reader-*
3801  // classes the inline reader uses → identical styling.
3802  const modal = document.createElement('div');
3803  modal.className = 'modal email-reader-tab-modal';
3804  modal.id = modalId;
3805  modal.innerHTML = `
3806    <div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);max-height:85vh;display:flex;flex-direction:column;">
3807      <div class="modal-header">
3808        <h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
3809          <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
3810        </h4>
3811        <button class="minimize-btn" type="button" title="Minimize">_</button>
3812        <button class="close-btn" type="button" title="Close">&#x2716;</button>
3813      </div>
3814      <div class="modal-body email-reader-tab-body" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;padding:0;">
3815        <div class="email-card-reader email-card-expanded" style="flex:1;min-height:0;display:flex;flex-direction:column;">
3816          <div class="email-reader-tab-loading" style="padding:24px;display:flex;justify-content:center;"></div>
3817        </div>
3818      </div>
3819    </div>
3820  `;
3821  document.body.appendChild(modal);
3822  // Inherit display from .modal (flex-center). z-index above the library
3823  // (which uses default .modal z-index 250) so the new tab sits on top.
3824  modal.style.zIndex = '270';
3825  // Opened last → email windows in front of any open doc (alternation flag).
3826  document.body.classList.add('email-front');
3827
3828  Modals.register(modalId, {
3829    label: 'Email',
3830    icon: _EMAIL_ICON_PATH,
3831    closeFn: () => {
3832      modal.remove();
3833      _freeReaderSlot(modalId);
3834      Promise.resolve().then(_syncEmailTabBadge);
3835    },
3836    restoreFn: () => {
3837      // Reopened last → bring the email windows in front of any open doc.
3838      document.body.classList.add('email-front');
3839      // Mobile: only one email window visible at a time. Tapping this
3840      // chip chips down the library + any other reader, so the user
3841      // toggles between them via the dock instead of stacking.
3842      if (window.innerWidth <= 768) {
3843        try {
3844          if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
3845            Modals.minimize('email-lib-modal');
3846          }
3847        } catch {}
3848        document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
3849          if (other.id === modalId) return;
3850          try {
3851            if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
3852              Modals.minimize(other.id);
3853            }
3854          } catch {}
3855        });
3856      }
3857    },
3858  });
3859  // Wire the `_` minimize button via modalManager (it sees our .minimize-btn
3860  // already exists and just binds the click handler).
3861  try { Modals.injectMinimizeButton(modal, modalId); } catch {}
3862  // X button fully closes the tab (tears down and unregisters).
3863  modal.querySelector('.close-btn')?.addEventListener('click', (ev) => {
3864    ev.stopPropagation();
3865    Modals.close(modalId);
3866  });
3867
3868  // Wire dragging on the header (desktop only). Matches the global pattern
3869  // in app.js initUIVisibility, but that runs once at boot and doesn't see
3870  // dynamically-created modals — so we replicate it here.
3871  const content = modal.querySelector('.modal-content');
3872  const mh = modal.querySelector('.modal-header');
3873  if (mh && content) {
3874    let dragX = 0, dragY = 0, startLeft = 0, startTop = 0, dragging = false;
3875    const startDrag = (clientX, clientY) => {
3876      dragging = true;
3877      const rect = content.getBoundingClientRect();
3878      dragX = clientX; dragY = clientY;
3879      startLeft = rect.left; startTop = rect.top;
3880      content.style.position = 'fixed';
3881      content.style.left = startLeft + 'px';
3882      content.style.top = startTop + 'px';
3883      content.style.margin = '0';
3884    };
3885    const onDrag = (e) => {
3886      if (!dragging) return;
3887      content.style.left = (startLeft + e.clientX - dragX) + 'px';
3888      content.style.top = (startTop + e.clientY - dragY) + 'px';
3889    };
3890    const stopDrag = () => {
3891      dragging = false;
3892      document.removeEventListener('mousemove', onDrag);
3893      document.removeEventListener('mouseup', stopDrag);
3894    };
3895    mh.addEventListener('mousedown', (e) => {
3896      if (e.target.closest('.close-btn, .minimize-btn, .modal-minimize-btn')) return;
3897      e.preventDefault();
3898      startDrag(e.clientX, e.clientY);
3899      document.addEventListener('mousemove', onDrag);
3900      document.addEventListener('mouseup', stopDrag);
3901    });
3902  }
3903
3904  // Open the new tab in front, on top of the email library. The user
3905  // can tap `_` to tab it down to a chip when they're done reading.
3906  //
3907  // Mobile: bottom-sheet windows fill the viewport, so stacking multiple
3908  // readers on top of each other is confusing — only one window can be
3909  // meaningfully visible at a time. So when the new tab opens, chip down
3910  // the library AND any other email-reader-* tab that's currently up.
3911  // The user gets a stack of mini chips to toggle between them.
3912  if (window.innerWidth <= 768) {
3913    try {
3914      if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
3915        Modals.minimize('email-lib-modal');
3916      }
3917    } catch {}
3918    document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
3919      if (other.id === modalId) return;
3920      try {
3921        if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
3922          Modals.minimize(other.id);
3923        }
3924      } catch {}
3925    });
3926  }
3927  _ensureEmailTabObserver();
3928  _syncEmailTabBadge();
3929
3930  // Fetch + render the email body using the exact same template as
3931  // _toggleCardPreview so the visuals match perfectly.
3932  const reader = modal.querySelector('.email-card-reader');
3933  _markEmailReaderActive(reader);
3934  const sp = spinnerModule.createWhirlpool(28);
3935  const loading = modal.querySelector('.email-reader-tab-loading');
3936  if (loading) loading.appendChild(sp.element);
3937  try {
3938    const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
3939    const data = await res.json();
3940    if (data.error) {
3941      reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Error: ${_esc(data.error)}</div>`;
3942      return;
3943    }
3944    _syncEmailReadState(em.uid, true);
3945    const buildChips = (str) => {
3946      if (!str) return '';
3947      return _splitRecipientList(str).map(a => {
3948        const name = _extractName(a);
3949        return _recipientChipHtml(a, name);
3950      }).join('');
3951    };
3952    const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
3953    let attsHtml = '';
3954    try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
3955    reader.innerHTML = `
3956      <div class="email-reader-header">
3957        <div class="email-reader-meta">
3958          <div class="email-reader-meta-row"><strong>From:</strong><span class="recipient-chips">${fromChip}</span></div>
3959          ${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${buildChips(data.to)}</span></div>` : ''}
3960          ${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildChips(data.cc)}</span></div>` : ''}
3961        </div>
3962        <div class="email-reader-actions">
3963          <div class="email-reader-actions-row email-reader-actions-row-primary">
3964            <button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
3965            ${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
3966            <button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
3967          </div>
3968          <div class="email-reader-actions-row email-reader-actions-row-secondary">
3969            <button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
3970            <button class="memory-toolbar-btn reader-icon-btn" data-act="summarize" title="Summarize">${_summaryIcon(data)}<span class="reader-btn-label">Summary</span></button>
3971            <button class="memory-toolbar-btn reader-icon-btn" data-act="from-sender" title="Search text in this thread"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><span class="reader-btn-label">Search</span></button>
3972            <div class="email-reader-more-wrap" style="position:relative">
3973              <button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
3974            </div>
3975          </div>
3976        </div>
3977      </div>
3978      ${attsHtml}
3979      <div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
3980    `;
3981    _markEmailReaderActive(reader);
3982    _wireRecipientChips(reader);
3983    try { _wireAttachmentHandlers(reader, useFolder); } catch {}
3984    const attsWrap = reader.querySelector('.email-reader-atts-wrap');
3985    if (attsWrap) {
3986      const attsToggle = attsWrap.querySelector('.email-reader-atts-header');
3987      if (attsToggle) attsToggle.addEventListener('click', (ev) => { ev.stopPropagation(); attsWrap.classList.toggle('collapsed'); });
3988    }
3989    reader.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
3990      ev.stopPropagation();
3991      _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
3992      if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
3993    });
3994    reader.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
3995      ev.stopPropagation();
3996      _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
3997      if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
3998    });
3999    reader.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
4000    reader.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
4001      ev.stopPropagation();
4002      if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
4003    });
4004    reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
4005      ev.stopPropagation();
4006      try { await _summarizeEmail(reader, data, ev.currentTarget); } catch {}
4007    });
4008    reader.querySelector('[data-act="from-sender"]')?.remove();
4009    reader.querySelector('[data-act="from-sender"]')?.addEventListener('click', async (ev) => {
4010      ev.stopPropagation();
4011      try { await _toggleFromSenderPanel(reader, data, ev.currentTarget); } catch {}
4012    });
4013    reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
4014      ev.stopPropagation();
4015      try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget); } catch {}
4016    });
4017  } catch (err) {
4018    reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Failed to load: ${_esc(String(err))}</div>`;
4019  }
4020}
4021
4022
4023// "Open in new window" — spawns a floating draggable modal that shows just
4024// the email content. Multiple windows can stack; each has its own DOM id
4025// and close button. Uses `_makeDraggable` so dragging the header pans the
4026// window around. Renders the body via _renderEmailBody for parity with the
4027// expanded reader.
4028let _emailWindowSeq = 0;
4029async function _openEmailWindow(em, folder) {
4030  const useFolder = folder || state._libFolder || 'INBOX';
4031  _emailWindowSeq += 1;
4032  const winId = `email-window-${em.uid}-${_emailWindowSeq}`;
4033  const modal = document.createElement('div');
4034  modal.className = 'modal email-window-modal';
4035  modal.id = winId;
4036  modal.style.cssText = 'pointer-events:none;background:transparent;';
4037  modal.innerHTML = `
4038    <div class="modal-content email-window-content" style="width:min(640px, 92vw);max-height:80vh;display:flex;flex-direction:column;background:var(--bg);">
4039      <div class="modal-header">
4040        <h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
4041          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
4042          <span class="email-window-subject" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(em.subject || '(no subject)')}</span>
4043        </h4>
4044        <button class="close-btn" type="button" title="Close">&#x2716;</button>
4045      </div>
4046      <div class="modal-body email-window-body" style="overflow:auto;padding:14px 16px;flex:1;min-height:0;">
4047        <div class="email-window-loading" style="display:flex;justify-content:center;padding:24px;"></div>
4048      </div>
4049    </div>
4050  `;
4051  document.body.appendChild(modal);
4052  modal.style.display = 'block';
4053  const content = modal.querySelector('.modal-content');
4054  // Position offset from screen center so successive windows cascade.
4055  const isMobile = window.innerWidth <= 768;
4056  if (isMobile) {
4057    content.style.position = 'fixed';
4058    content.style.pointerEvents = 'auto';
4059    content.style.left = '0';
4060    content.style.right = '0';
4061    content.style.bottom = '0';
4062    content.style.top = 'auto';
4063  } else {
4064    content.style.position = 'fixed';
4065    content.style.pointerEvents = 'auto';
4066    requestAnimationFrame(() => {
4067      const w = content.offsetWidth, h = content.offsetHeight;
4068      const off = (_emailWindowSeq % 6) * 28;
4069      content.style.left = Math.max(20, (window.innerWidth  - w) / 2 + off) + 'px';
4070      content.style.top  = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px';
4071    });
4072  }
4073  modal.querySelector('.close-btn')?.addEventListener('click', () => modal.remove());
4074  try { _makeDraggable(content, modal, 'email-window-fullscreen'); } catch {}
4075
4076  // Load + render
4077  const bodyEl = modal.querySelector('.email-window-body');
4078  const loading = modal.querySelector('.email-window-loading');
4079  try {
4080    const sp = spinnerModule.createWhirlpool(24);
4081    loading.appendChild(sp.element);
4082    const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
4083    const data = await res.json();
4084    if (data.error) {
4085      bodyEl.innerHTML = `<div style="color:var(--red,#e55);padding:16px;">${_esc(data.error)}</div>`;
4086      return;
4087    }
4088    _syncEmailReadState(em.uid, true);
4089    const subjEl = modal.querySelector('.email-window-subject');
4090    if (subjEl && data.subject) subjEl.textContent = data.subject;
4091    // Build recipient chips the same way the inline reader does so the
4092    // standalone viewer looks/feels exactly like a real email view.
4093    const _chipsFor = (addrs) => {
4094      if (!addrs) return '';
4095      const list = _splitRecipientList(addrs);
4096      return list.map(a => {
4097        const name = _extractName(a);
4098        return _recipientChipHtml(a, name);
4099      }).join('');
4100    };
4101    const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
4102    let attsHtml = '';
4103    try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
4104    // Repurpose bodyEl as a full email-card-reader so the inline reader's
4105    // CSS applies (sized header, action buttons in two rows, etc.).
4106    bodyEl.classList.add('email-card-reader');
4107    _markEmailReaderActive(bodyEl);
4108    bodyEl.style.padding = '0';
4109    bodyEl.innerHTML = `
4110      <div class="email-reader-header">
4111        <div class="email-reader-meta">
4112          <div class="email-reader-meta-row"><strong>From:</strong><span class="recipient-chips">${fromChip}</span></div>
4113          ${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${_chipsFor(data.to)}</span></div>` : ''}
4114          ${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${_chipsFor(data.cc)}</span></div>` : ''}
4115        </div>
4116        <div class="email-reader-actions">
4117          <div class="email-reader-actions-row email-reader-actions-row-primary">
4118            <button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
4119            ${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
4120            <button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
4121          </div>
4122          <div class="email-reader-actions-row email-reader-actions-row-secondary">
4123            <button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
4124            <button class="memory-toolbar-btn reader-icon-btn" data-act="summarize" title="Summarize">${_summaryIcon(data)}<span class="reader-btn-label">Summary</span></button>
4125            <button class="memory-toolbar-btn reader-icon-btn" data-act="from-sender" title="Search text in this thread"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><span class="reader-btn-label">Search</span></button>
4126            <div class="email-reader-more-wrap" style="position:relative">
4127              <button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
4128            </div>
4129          </div>
4130        </div>
4131      </div>
4132      ${attsHtml}
4133      <div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
4134    `;
4135    _markEmailReaderActive(bodyEl);
4136    _wireRecipientChips(bodyEl);
4137    // Wire all the same action handlers the inline reader has.
4138    try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {}
4139    const attsWrap = bodyEl.querySelector('.email-reader-atts-wrap');
4140    if (attsWrap) {
4141      const attsToggle = attsWrap.querySelector('.email-reader-atts-header');
4142      if (attsToggle) attsToggle.addEventListener('click', (ev) => { ev.stopPropagation(); attsWrap.classList.toggle('collapsed'); });
4143    }
4144    bodyEl.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
4145      ev.stopPropagation();
4146      _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
4147      if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
4148    });
4149    bodyEl.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
4150      ev.stopPropagation();
4151      _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
4152      if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
4153    });
4154    bodyEl.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
4155    bodyEl.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
4156      ev.stopPropagation();
4157      if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
4158    });
4159    bodyEl.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
4160      ev.stopPropagation();
4161      try { await _summarizeEmail(bodyEl, data, ev.currentTarget); } catch {}
4162    });
4163    bodyEl.querySelector('[data-act="from-sender"]')?.remove();
4164    bodyEl.querySelector('[data-act="from-sender"]')?.addEventListener('click', async (ev) => {
4165      ev.stopPropagation();
4166      try { await _toggleFromSenderPanel(bodyEl, data, ev.currentTarget); } catch {}
4167    });
4168    bodyEl.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
4169      ev.stopPropagation();
4170      // Use a synthetic "card" — the more-menu only needs the anchor
4171      // element and the email data. The card param is mostly used to find
4172      // the next sibling; the standalone window has none so we just pass
4173      // bodyEl as a stand-in.
4174      try { _showReaderMoreMenu(em, modal, bodyEl, ev.currentTarget); } catch {}
4175    });
4176  } catch (err) {
4177    bodyEl.innerHTML = `<div style="color:var(--red,#e55);padding:16px;">Failed to load: ${_esc(String(err))}</div>`;
4178  }
4179}
4180
4181// Fetch a new email's content and replace the current reader body with it
4182// (preserving the from-sender panel). Used for in-place navigation between
4183// emails of the same sender — `folder` defaults to the library's current
4184// folder but is overridable so cross-folder search results can open the
4185// correct one.
4186async function _swapReaderToUid(reader, uid, folder) {
4187  const body = reader.querySelector('.email-reader-body');
4188  if (!body) return;
4189  body.innerHTML = '';
4190  const sp = spinnerModule.createWhirlpool(24);
4191  const wrap = document.createElement('div');
4192  wrap.style.cssText = 'padding:20px;display:flex;justify-content:center';
4193  wrap.appendChild(sp.element);
4194  body.appendChild(wrap);
4195  const useFolder = folder || state._libFolder;
4196  try {
4197    const res = await fetch(`${API_BASE}/api/email/read/${uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
4198    const data = await res.json();
4199    if (data.error) {
4200      body.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">${_esc(data.error)}</div>`;
4201      return;
4202    }
4203    _syncEmailReadState(uid, true);
4204    // Update the header meta (From/To/Subject) so it matches the new email.
4205    const headerMeta = reader.querySelector('.email-reader-meta');
4206    if (headerMeta) {
4207      const subj = data.subject || '(no subject)';
4208      const date = data.date ? new Date(data.date).toLocaleString() : '';
4209      const chipsFor = (addrs) => {
4210        if (!addrs) return '';
4211        return _splitRecipientList(addrs).map(a => {
4212          const name = _extractName(a);
4213          return _recipientChipHtml(a, name);
4214        }).join('');
4215      };
4216      const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
4217      headerMeta.innerHTML = `
4218        <div class="email-reader-meta-row"><strong>Subject:</strong> ${_esc(subj)}</div>
4219        <div class="email-reader-meta-row"><strong>From:</strong><span class="recipient-chips">${fromChip}</span></div>
4220        ${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${chipsFor(data.to)}</span></div>` : ''}
4221        ${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${chipsFor(data.cc)}</span></div>` : ''}
4222        ${date ? `<div class="email-reader-meta-row"><strong>Date:</strong> ${_esc(date)}</div>` : ''}
4223      `;
4224      _wireRecipientChips(reader);
4225    }
4226    // Refresh the attachments block to match the new email. Build fresh HTML
4227    // and either replace the existing block, remove it (if the new email has
4228    // none), or insert one before the body (if the previous email had none
4229    // but the new one does).
4230    const newAttsHtml = _buildAttsHtmlFor(uid, data);
4231    const oldAtts = reader.querySelector('.email-reader-atts-wrap');
4232    if (newAttsHtml) {
4233      if (oldAtts) {
4234        const tmp = document.createElement('div');
4235        tmp.innerHTML = newAttsHtml;
4236        oldAtts.replaceWith(tmp.firstChild);
4237      } else {
4238        body.insertAdjacentHTML('beforebegin', newAttsHtml);
4239      }
4240      const newWrap = reader.querySelector('.email-reader-atts-wrap');
4241      if (newWrap) {
4242        const hdr = newWrap.querySelector('.email-reader-atts-header');
4243        if (hdr) {
4244          hdr.addEventListener('click', (ev) => {
4245            ev.stopPropagation();
4246            newWrap.classList.toggle('collapsed');
4247          });
4248          hdr.addEventListener('keydown', (ev) => {
4249            if (ev.key === 'Enter' || ev.key === ' ') {
4250              ev.preventDefault();
4251              newWrap.classList.toggle('collapsed');
4252            }
4253          });
4254        }
4255      }
4256    } else if (oldAtts) {
4257      oldAtts.remove();
4258    }
4259    body.innerHTML = _safeRenderEmailBody(data);
4260    body.classList.toggle('html-body', !!data.body_html);
4261    // Wire click handlers for the newly-rendered attachment chips. Without
4262    // this, after swapping to a different email via the sidebar, clicking
4263    // an attachment chip would do nothing.
4264    _wireAttachmentHandlers(reader, useFolder);
4265  } catch (err) {
4266    body.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">${_esc(String(err))}</div>`;
4267  }
4268}
4269
4270async function _summarizeEmail(reader, data, btn) {
4271  const body = reader.querySelector('.email-reader-body');
4272  if (!body) return;
4273
4274  // If a summary panel already exists, toggle: hide/show
4275  const existing = body.querySelector('.email-summary-panel');
4276  if (existing) {
4277    if (existing.style.display === 'none') {
4278      existing.style.display = '';
4279      if (btn) {
4280        btn.classList.add('active');
4281        btn.querySelector('.btn-label').textContent = 'Summary';
4282      }
4283    } else {
4284      existing.style.display = 'none';
4285      if (btn) {
4286        btn.classList.remove('active');
4287        btn.querySelector('.btn-label').textContent = 'Summary';
4288      }
4289    }
4290    return;
4291  }
4292
4293  // No panel yet. If the email has no cached AI summary, show a placeholder
4294  // "not generated — create now?" prompt instead of firing the LLM immediately.
4295  // This avoids accidental LLM spend and makes the state explicit to the user.
4296  if (!data.cached_summary) {
4297    const prompt = document.createElement('div');
4298    prompt.className = 'email-summary-panel';
4299    prompt.innerHTML = `
4300      <div class="email-summary-header">
4301        <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
4302        <span>Summary</span>
4303      </div>
4304      <div class="email-summary-content" style="white-space:normal;display:flex;align-items:center;flex-wrap:wrap;gap:6px;"><span style="opacity:0.65">No AI summary generated.</span><button class="memory-toolbar-btn" data-act="summary-generate" style="font-size:10px;margin-left:auto;">Generate now</button></div>`;
4305    body.insertBefore(prompt, body.firstChild);
4306    if (btn) {
4307      btn.classList.add('active');
4308      const label = btn.querySelector('.btn-label');
4309      if (label) label.textContent = 'Summary';
4310    }
4311    // No Cancel button — toggling the Summary button again hides this panel
4312    // (handled by the existing-panel branch above), so it'd be redundant.
4313    prompt.querySelector('[data-act="summary-generate"]').addEventListener('click', async (ev) => {
4314      ev.stopPropagation();
4315      prompt.remove();
4316      await _generateSummary(reader, data, btn);
4317    });
4318    return;
4319  }
4320
4321  // Cached summary exists — show it immediately.
4322  await _generateSummary(reader, data, btn);
4323}
4324
4325async function _generateSummary(reader, data, btn) {
4326  const body = reader.querySelector('.email-reader-body');
4327  if (!body) return;
4328
4329  const panel = document.createElement('div');
4330  panel.className = 'email-summary-panel';
4331  panel.innerHTML =
4332    '<div class="email-summary-header email-summary-toggle" role="button" tabindex="0">'
4333    +   '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>'
4334    +   '<span>Summary</span>'
4335    +   '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
4336    + '</div>'
4337    + '<div class="email-summary-content"></div>';
4338  if (_summaryCollapsedPref()) panel.classList.add('collapsed');
4339  body.insertBefore(panel, body.firstChild);
4340  const _genToggle = panel.querySelector('.email-summary-toggle');
4341  if (_genToggle) {
4342    const _genFlip = () => {
4343      panel.classList.toggle('collapsed');
4344      _setSummaryCollapsedPref(panel.classList.contains('collapsed'));
4345    };
4346    _genToggle.addEventListener('click', (ev) => { ev.stopPropagation(); _genFlip(); });
4347    _genToggle.addEventListener('keydown', (ev) => {
4348      if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _genFlip(); }
4349    });
4350  }
4351
4352  const sp = spinnerModule.createWhirlpool(18);
4353  const content = panel.querySelector('.email-summary-content');
4354  content.appendChild(sp.element);
4355
4356  if (btn) btn.disabled = true;
4357  try {
4358    const res = await fetch(`${API_BASE}/api/email/summarize`, {
4359      method: 'POST',
4360      headers: { 'Content-Type': 'application/json' },
4361      body: JSON.stringify({
4362        body: data.body,
4363        subject: data.subject,
4364        from: `${data.from_name} <${data.from_address}>`,
4365        // Send identifiers so the backend can fetch the raw message and
4366        // pull attachment text for the summary (PDFs, invoices, etc.).
4367        uid: data.uid || '',
4368        folder: state._libFolder || 'INBOX',
4369        message_id: data.message_id || '',
4370        account_id: data.account_id || '',
4371      }),
4372    });
4373    const result = await res.json();
4374    sp.destroy();
4375    content.innerHTML = '';
4376    if (result.success && result.summary) {
4377      content.textContent = result.summary;
4378      if (btn) {
4379        btn.classList.add('active');
4380        const label = btn.querySelector('.btn-label');
4381        if (label) label.textContent = 'Summary';
4382      }
4383    } else {
4384      content.innerHTML = `<span style="color:var(--red)">${_esc(result.error || 'Failed to summarize')}</span>`;
4385      panel.remove();
4386    }
4387  } catch (e) {
4388    sp.destroy();
4389    panel.remove();
4390    if (uiModule) uiModule.showError?.('Failed to summarize');
4391  } finally {
4392    if (btn) btn.disabled = false;
4393  }
4394}
4395
4396// Keep an email ⋮ dropdown inside the viewport: when it would spill past the
4397// bottom (e.g. an email low on a phone screen), flip it above the anchor if
4398// there's more room up there, and cap height + scroll if it still overflows.
4399function _fitEmailDropdown(dropdown, rect) {
4400  requestAnimationFrame(() => {
4401    const margin = 8;
4402    // Horizontal clamp — keep the dropdown inside the viewport regardless of
4403    // whether it was anchored via left or right. Needed now that some
4404    // triggers (e.g. the right-aligned bulk "Actions" button) sit close to
4405    // the right edge, where a left-anchored menu would spill off-screen.
4406    const dw = dropdown.offsetWidth;
4407    const curLeft = dropdown.getBoundingClientRect().left;
4408    if (curLeft + dw > window.innerWidth - margin) {
4409      dropdown.style.left = Math.max(margin, window.innerWidth - margin - dw) + 'px';
4410      dropdown.style.right = 'auto';
4411    } else if (curLeft < margin) {
4412      dropdown.style.left = margin + 'px';
4413      dropdown.style.right = 'auto';
4414    }
4415    // Vertical fit — flip up or cap+scroll if it doesn't fit below.
4416    const dh = dropdown.offsetHeight;
4417    const below = window.innerHeight - rect.bottom - margin;
4418    const above = rect.top - margin;
4419    if (dh <= below) return;                 // fits below as-is
4420    if (above > below) {                     // flip upward
4421      dropdown.style.top = 'auto';
4422      dropdown.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
4423      if (dh > above) { dropdown.style.maxHeight = above + 'px'; dropdown.style.overflowY = 'auto'; }
4424    } else {                                 // keep below, cap + scroll
4425      dropdown.style.maxHeight = below + 'px';
4426      dropdown.style.overflowY = 'auto';
4427    }
4428  });
4429}
4430
4431function _showReaderMoreMenu(em, card, reader, anchor) {
4432  // Toggle: if a dropdown for THIS anchor is already open, close it.
4433  const existing = document.querySelector('.email-card-dropdown');
4434  if (existing && existing._anchor === anchor) {
4435    existing.remove();
4436    anchor.classList.remove('reader-more-active');
4437    return;
4438  }
4439  // Otherwise close any other open dropdown (and clear its anchor's active
4440  // state) before opening a fresh one.
4441  document.querySelectorAll('.email-card-dropdown').forEach(d => {
4442    if (d._anchor) d._anchor.classList.remove('reader-more-active');
4443    d.remove();
4444  });
4445
4446  const dropdown = document.createElement('div');
4447  dropdown.className = 'email-card-dropdown';
4448  dropdown._anchor = anchor;
4449  anchor.classList.add('reader-more-active');
4450  const rect = anchor.getBoundingClientRect();
4451  dropdown.style.cssText = `position:fixed;z-index:10001;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;`;
4452
4453  const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
4454  const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
4455  const _archIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
4456  const _spamIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
4457  const _trashIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
4458  const _deleteForeverIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="14" y2="15"/><line x1="14" y1="11" x2="10" y2="15"/></svg>';
4459  const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
4460  const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
4461  const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
4462
4463  const closeAndRemove = async () => {
4464    // Pick the next neighbour BEFORE we re-render so we know which email to
4465    // jump to. Prefer the next card; fall back to the previous one if this
4466    // was the last card.
4467    const sibling = _findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1);
4468    const nextUid = sibling ? sibling.dataset.uid : null;
4469    await _animateEmailCardRemoval([em.uid]);
4470    state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
4471    _renderGrid();
4472    _libCacheWriteBack();
4473    if (!nextUid) return;
4474    // After _renderGrid, the card nodes are fresh — re-resolve and expand.
4475    const grid = document.getElementById('email-lib-grid');
4476    const nextCard = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(nextUid))}"]`);
4477    const nextEm = state._libEmails.find(e => String(e.uid) === String(nextUid));
4478    if (nextCard && nextEm) {
4479      _toggleCardPreview(nextCard, nextEm);
4480      nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
4481    }
4482  };
4483
4484  const _bubblesIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
4485  const _contactIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/>
4485<line x1="22" y1="11" x2="16" y2="11"/></svg>';
4486  const actions = [
4487    {
4488      label: 'Open in new tab',
4489      icon: _newTabIcon,
4490      action: async () => {
4491        const folder = state._libFolder || 'INBOX';
4492        await _openEmailAsTab(em, folder);
4493      },
4494    },
4495    {
4496      // Save the sender to CardDAV contacts. Pulls name + address off the
4497      // list-item (em); falls back to splitting the local-part for a name.
4498      label: 'Save sender to contacts',
4499      icon: _contactIcon,
4500      action: async () => {
4501        const email = (em.from_address || em.from || '').trim();
4502        if (!email) {
4503          import('./ui.js').then(m => m.showError && m.showError('No sender address')).catch(() => {});
4504          return;
4505        }
4506        const name = (em.from_name || '').trim() || email.split('@')[0];
4507        try {
4508          const r = await fetch(`${API_BASE}/api/contacts/add`, {
4509            method: 'POST', credentials: 'same-origin',
4510            headers: { 'Content-Type': 'application/json' },
4511            body: JSON.stringify({ name, email }),
4512          });
4513          const d = await r.json();
4514          import('./ui.js').then(m => {
4515            if (!m.showToast) return;
4516            if (d.success && d.message === 'Already exists') m.showToast('Already in contacts');
4517            else if (d.success) m.showToast('Saved to contacts');
4518            else m.showError && m.showError('Failed to save contact');
4519          }).catch(() => {});
4520        } catch (_) {
4521          import('./ui.js').then(m => m.showError && m.showError('Failed to save contact')).catch(() => {});
4522        }
4523      },
4524    },
4525    // Threaded ⇄ Plain-text view toggle removed — threaded view disabled
4526    // for now (too buggy). Emails always render plain text. Restore this
4527    // menu item + _bubblesDisabled() localStorage logic to bring it back.
4528    {
4529      label: em.is_read ? 'Mark Unread' : 'Mark Read',
4530      icon: _unreadIcon,
4531      action: async () => {
4532        const newRead = !em.is_read;
4533        _syncEmailReadState(em.uid, newRead);
4534        try {
4535          if (newRead) {
4536            await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4537          } else {
4538            await fetch(`${API_BASE}/api/email/mark-unread/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4539          }
4540        } catch (e) { console.error(e); }
4541        _renderGrid();
4542      },
4543    },
4544    {
4545      label: em.is_answered ? 'Not Done' : 'Done',
4546      icon: _checkIcon,
4547      action: async () => {
4548        const newState = !em.is_answered;
4549        em.is_answered = newState;
4550        if (newState) _syncEmailReadState(em.uid, true);
4551        try {
4552          if (newState) {
4553            await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4554            await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4555          } else {
4556            await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4557          }
4558        } catch (e) { console.error('Failed to toggle done:', e); }
4559        _renderGrid();
4560      },
4561    },
4562    {
4563      label: 'Archive',
4564      icon: _archIcon,
4565      action: async () => {
4566        try {
4567          await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4568        } catch (e) { console.error(e); }
4569        await closeAndRemove();
4570      },
4571    },
4572    {
4573      label: 'Remind to reply',
4574      icon: _bellIcon,
4575      submenu: 'remind',
4576    },
4577    {
4578      label: 'Move to Spam',
4579      icon: _spamIcon,
4580      action: async () => {
4581        try {
4582          await fetch(`${API_BASE}/api/email/move/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&dest=Junk`, { method: 'POST' });
4583        } catch (e) { console.error(e); }
4584        await closeAndRemove();
4585      },
4586    },
4587    {
4588      label: 'Move to Trash',
4589      icon: _trashIcon,
4590      action: async () => {
4591        try {
4592          await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
4593        } catch (e) { console.error(e); }
4594        await closeAndRemove();
4595      },
4596    },
4597    {
4598      label: 'Delete Permanently',
4599      icon: _deleteForeverIcon,
4600      danger: true,
4601      action: async () => {
4602        const subject = em.subject || '(no subject)';
4603        const ok = await styledConfirm(
4604          `Permanently delete "${subject}"? This cannot be undone.`,
4605          { confirmText: 'Delete', cancelText: 'Cancel', danger: true }
4606        );
4607        if (!ok) return;
4608        try {
4609          await fetch(`${API_BASE}/api/email/delete-permanent/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
4610        } catch (e) { console.error(e); }
4611        await closeAndRemove();
4612      },
4613    },
4614  ];
4615
4616  for (const a of actions) {
4617    const item = document.createElement('div');
4618    item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
4619    const arrow = a.submenu ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
4620    item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${arrow}`;
4621    item.addEventListener('click', (e) => {
4622      e.stopPropagation();
4623      if (a.submenu === 'remind') {
4624        _showLibRemindSubmenu(em, dropdown);
4625        return;
4626      }
4627      dropdown.remove();
4628      anchor.classList.remove('reader-more-active');
4629      a.action();
4630    });
4631    dropdown.appendChild(item);
4632  }
4633  // Mobile-only Cancel item — explicit close for touch users. CSS hides it
4634  // on desktop where outside-click already dismisses cleanly.
4635  const _cancelIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
4636  const cancelItem = document.createElement('div');
4637  cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
4638  cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
4639  cancelItem.addEventListener('click', (e) => {
4640    e.stopPropagation();
4641    dropdown.remove();
4642    anchor.classList.remove('reader-more-active');
4643  });
4644  dropdown.appendChild(cancelItem);
4645
4646  document.body.appendChild(dropdown);
4647  _fitEmailDropdown(dropdown, rect);
4648  const close = (ev) => {
4649    if (!dropdown.contains(ev.target) && ev.target !== anchor) {
4650      dropdown.remove();
4651      anchor.classList.remove('reader-more-active');
4652      document.removeEventListener('click', close, true);
4653    }
4654  };
4655  setTimeout(() => document.addEventListener('click', close, true), 10);
4656}
4657
4658function _showCardMenu(em, anchor) {
4659  document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
4660
4661  const dropdown = document.createElement('div');
4662  dropdown.className = 'email-card-dropdown';
4663  const rect = anchor.getBoundingClientRect();
4664  dropdown.style.cssText = `position:fixed;z-index:10001;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;`;
4665
4666  const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
4667  const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
4668  const _archIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
4669  const _delIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
4670  const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
4671  const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
4672  const _cardBellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
4673
4674  const isSentFolder = /sent/i.test(state._libFolder);
4675
4676  const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
4677  const actions = [
4678    { label: 'Open', icon: _replyIcon, action: async () => {
4679      // Just expand inline (same as tapping the row).
4680      const card = anchor.closest('.doclib-card');
4681      if (card && !card.classList.contains('doclib-card-expanded')) {
4682        await _toggleCardPreview(card, em);
4683      }
4684    }},
4685    { label: 'Open in new tab', icon: _newTabIcon, action: async () => {
4686      // Open this email as its own in-app modal that registers a dock
4687      // chip — multiple emails can be opened simultaneously, each gets
4688      // its own chip in the minimized dock.
4689      const folder = state._libFolder || 'INBOX';
4690      await _openEmailAsTab(em, folder);
4691    }},
4692    { label: 'Remind to reply', icon: _cardBellIcon, submenu: 'remind' },
4693  ];
4694
4695  if (!isSentFolder) {
4696    // Source of truth = the visible "active" class on the card's done
4697    // check, so the menu label and the actual toggle behaviour can't
4698    // disagree with what the user sees.
4699    const _cardForLabel = anchor.closest('.doclib-card');
4700    const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null;
4701    const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered;
4702    actions.push({
4703      label: _currentlyDone ? 'Not Done' : 'Done',
4704      icon: _checkIcon,
4705      action: async () => {
4706        const card = anchor.closest('.doclib-card');
4707        const check = card ? card.querySelector('.email-card-done') : null;
4708        const wasActive = check ? check.classList.contains('active') : !!em.is_answered;
4709        const newState = !wasActive;
4710        em.is_answered = newState;
4711        if (newState) _syncEmailReadState(em.uid, true); // mark-done implies mark-read
4712        try {
4713          if (newState) {
4714            await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4715            await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4716          } else {
4717            await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4718          }
4719        } catch (e) { console.error('Failed to toggle done:', e); }
4720        if (card) {
4721          if (check) check.classList.toggle('active', newState);
4722          if (newState) _syncEmailReadState(em.uid, true);
4723        }
4724      },
4725    });
4726    actions.push({
4727      label: 'Archive',
4728      icon: _archIcon,
4729      action: async () => {
4730        await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4731        await _animateEmailCardRemoval([em.uid]);
4732        state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
4733        _renderGrid();
4734        _libCacheWriteBack();
4735      },
4736    });
4737  } else {
4738    actions.push({
4739      label: 'Archive',
4740      icon: _archIcon,
4741      action: async () => {
4742        await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4743        await _animateEmailCardRemoval([em.uid]);
4744        state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
4745        _renderGrid();
4746        _libCacheWriteBack();
4747      },
4748    });
4749  }
4750
4751  // "Select" — switch to multi-select mode with THIS email pre-selected so
4752  // the user can quickly fan-out to neighbours with the bulk bar.
4753  // Match the chat-sidebar Select icon — a thick bullet character reads
4754  // much heavier than a small SVG circle. Nudged up 2px so its visual
4755  // center lines up with the SVG icons above (which sit a bit higher).
4756  const _selectIcon = '<span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span>';
4757  actions.push({
4758    label: 'Select',
4759    icon: _selectIcon,
4760    action: () => {
4761      state._selectMode = true;
4762      state._selectedUids.add(em.uid);
4763      _updateBulkBar();
4764      _renderGrid();
4765    },
4766  });
4767
4768  actions.push(
4769    { label: 'Delete', icon: _delIcon, danger: true, action: async () => {
4770      const subject = em.subject || '(no subject)';
4771      const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true });
4772      if (!ok) return;
4773      await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
4774      await _animateEmailCardRemoval([em.uid]);
4775      state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
4776      _renderGrid();
4777      _libCacheWriteBack();
4778    }},
4779  );
4780
4781  for (const a of actions) {
4782    const item = document.createElement('div');
4783    item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
4784    const arrow = a.submenu ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
4785    item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${arrow}`;
4786    item.addEventListener('click', (e) => {
4787      e.stopPropagation();
4788      if (a.submenu === 'remind') {
4789        _showLibRemindSubmenu(em, dropdown);
4790        return;
4791      }
4792      dropdown.remove();
4793      anchor.classList.remove('reader-more-active');
4794      a.action();
4795    });
4796    dropdown.appendChild(item);
4797  }
4798  // Mobile-only Cancel item — explicit close for touch users. CSS hides it
4799  // on desktop where outside-click already dismisses cleanly.
4800  const _cancelIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
4801  const cancelItem = document.createElement('div');
4802  cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
4803  cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
4804  cancelItem.addEventListener('click', (e) => {
4805    e.stopPropagation();
4806    dropdown.remove();
4807    anchor.classList.remove('reader-more-active');
4808  });
4809  dropdown.appendChild(cancelItem);
4810
4811  document.body.appendChild(dropdown);
4812  _fitEmailDropdown(dropdown, rect);
4813  const close = (ev) => {
4814    if (!dropdown.contains(ev.target) && ev.target !== anchor) {
4815      dropdown.remove();
4816      anchor.classList.remove('reader-more-active');
4817      document.removeEventListener('click', close, true);
4818    }
4819  };
4820  setTimeout(() => document.addEventListener('click', close, true), 10);
4821}
4822
4823// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
4824function _showBulkActionsMenu(anchor) {
4825  document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
4826  const dropdown = document.createElement('div');
4827  dropdown.className = 'email-card-dropdown email-bulk-menu';
4828  const rect = anchor.getBoundingClientRect();
4829  dropdown.style.cssText = `position:fixed;z-index:10001;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;`;
4830  const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
4831  const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
4832  const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
4833  const items = [
4834    { label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
4835    { label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
4836    { label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') },
4837  ];
4838  for (const a of items) {
4839    const it = document.createElement('div');
4840    it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
4841    it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
4842    it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
4843    dropdown.appendChild(it);
4844  }
4845  // Mobile-only Cancel — matches the per-card and sidebar dropdowns.
4846  const _cancelIco2 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
4847  const cancelIt = document.createElement('div');
4848  cancelIt.className = 'dropdown-item-compact dropdown-cancel-mobile';
4849  cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
4850  cancelIt.addEventListener('click', (e) => {
4851    e.stopPropagation();
4852    dropdown.remove();
4853    // Cancel inside the bulk-Actions menu also exits select mode — matches the
4854    // documents bulk dropdown.
4855    state._selectMode = false;
4856    state._selectedUids.clear();
4857    _updateBulkBar();
4858    _renderGrid();
4859  });
4860  dropdown.appendChild(cancelIt);
4861  document.body.appendChild(dropdown);
4862  _fitEmailDropdown(dropdown, rect);
4863  const close = (ev) => {
4864    if (!dropdown.contains(ev.target) && ev.target !== anchor) {
4865      dropdown.remove();
4866      document.removeEventListener('click', close, true);
4867    }
4868  };
4869  setTimeout(() => document.addEventListener('click', close, true), 10);
4870}
4871
4872function _updateBulkBar() {
4873  const bar = document.getElementById('email-lib-bulk');
4874  const selectBtn = document.getElementById('email-lib-select-btn');
4875  if (bar) bar.classList.toggle('hidden', !state._selectMode);
4876  if (selectBtn) {
4877    selectBtn.textContent = state._selectMode ? 'Cancel' : 'Select';
4878    selectBtn.classList.toggle('active', state._selectMode);
4879  }
4880  const count = document.getElementById('email-lib-selected-count');
4881  if (count) count.textContent = `${state._selectedUids.size} Selected`;
4882  const all = document.getElementById('email-lib-select-all');
4883  if (all) all.checked = state._libEmails.length > 0 && state._libEmails.every(e => state._selectedUids.has(e.uid));
4884  // When something's selected, brighten Actions to the same full --fg color as
4885  // the "N Selected" count (the button is a dimmer 60% --fg by default).
4886  const actions = document.getElementById('email-lib-bulk-actions');
4887  if (actions) actions.style.color = state._selectedUids.size > 0 ? 'var(--fg)' : '';
4888  const deleteBtn = document.getElementById('email-lib-bulk-delete');
4889  if (deleteBtn) deleteBtn.style.color = state._selectedUids.size > 0 ? 'var(--red)' : '';
4890}
4891
4892async function _bulkAction(action) {
4893  const uids = Array.from(state._selectedUids);
4894  if (uids.length === 0) return;
4895  let failedReadSync = 0;
4896  if (action === 'delete') {
4897    const ok = await styledConfirm(
4898      `Delete ${uids.length} selected email${uids.length === 1 ? '' : 's'}?`,
4899      { confirmText: 'Delete', cancelText: 'Cancel', danger: true },
4900    );
4901    if (!ok) return;
4902  }
4903
4904  const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null;
4905  const actionsBtn = document.getElementById('email-lib-bulk-actions');
4906  const cancelBtn = document.getElementById('email-lib-bulk-cancel');
4907  const selectAll = document.getElementById('email-lib-select-all');
4908  const countEl = document.getElementById('email-lib-selected-count');
4909  const originalDeleteHtml = deleteBtn?.innerHTML || '';
4910  const originalCountText = countEl?.textContent || '';
4911  let busySpinner = null;
4912  if (action === 'delete') {
4913    if (deleteBtn) {
4914      deleteBtn.disabled = true;
4915      deleteBtn.classList.add('email-bulk-loading');
4916      deleteBtn.innerHTML = '<span class="email-bulk-loading-label">Deleting</span>';
4917      busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
4918      const spEl = busySpinner.createElement();
4919      spEl.classList.add('email-bulk-whirlpool');
4920      deleteBtn.appendChild(spEl);
4921      busySpinner.start();
4922    }
4923    if (actionsBtn) actionsBtn.disabled = true;
4924    if (cancelBtn) cancelBtn.disabled = true;
4925    if (selectAll) selectAll.disabled = true;
4926    if (countEl) countEl.textContent = `Deleting ${uids.length}...`;
4927  }
4928
4929  try {
4930    for (const uid of uids) {
4931      try {
4932        if (action === 'archive') {
4933          await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4934        } else if (action === 'delete') {
4935          await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
4936        } else if (action === 'done') {
4937          const em = state._libEmails.find(e => e.uid === uid);
4938          if (em) {
4939            em.is_answered = true;
4940            em.is_read = true;
4941          }
4942          await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4943          await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4944        } else if (action === 'read' || action === 'unread') {
4945          const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
4946          const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
4947          let data = null;
4948          try { data = await res.json(); } catch (_) {}
4949          if (!res.ok || data?.success === false) {
4950            throw new Error(data?.error || `HTTP ${res.status}`);
4951          }
4952          _syncEmailReadState(uid, action === 'read');
4953        }
4954      } catch (e) {
4955        if (action === 'read' || action === 'unread') failedReadSync += 1;
4956        console.error(`Failed to ${action} ${uid}:`, e);
4957      }
4958    }
4959
4960    if (action === 'archive' || action === 'delete') {
4961      await _animateEmailCardRemoval(uids);
4962      const removed = new Set(uids.map(uid => String(uid)));
4963      state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
4964    }
4965  } finally {
4966    if (busySpinner) busySpinner.destroy();
4967    if (deleteBtn) {
4968      deleteBtn.disabled = false;
4969      deleteBtn.classList.remove('email-bulk-loading');
4970      deleteBtn.innerHTML = originalDeleteHtml;
4971    }
4972    if (actionsBtn) actionsBtn.disabled = false;
4973    if (cancelBtn) cancelBtn.disabled = false;
4974    if (selectAll) selectAll.disabled = false;
4975    if (countEl) countEl.textContent = originalCountText;
4976  }
4977  state._selectedUids.clear();
4978  state._selectMode = false;
4979  _updateBulkBar();
4980  _renderGrid();
4981  if (failedReadSync > 0) {
4982    showToast(`Failed to update ${failedReadSync} email${failedReadSync === 1 ? '' : 's'}`);
4983  }
4984  // Sync successful local mutations into the SWR cache so reopen doesn't
4985  // briefly show the pre-bulk state.
4986  _libCacheWriteBack();
4987}
4988
4989// _extractName lives in ./emailLibrary/utils.js
4990
4991function _aiReplyIcon(data) {
4992  const cachedSpark = data?.cached_ai_reply
4993    ? '<path d="M14 4l1 2 2 1-2 1-1 2-1-2-2-1 2-1z" fill="var(--accent-primary, var(--red))" stroke="none" transform="translate(2 0)"/>'
4994    : '';
4995  return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/>${cachedSpark}</svg>`;
4996}
4997
4998function _summaryIcon(data) {
4999  const fill = data?.cached_summary ? 'var(--accent-primary, var(--red))' : 'currentColor';
5000  return `<svg width="14" height="14" viewBox="0 0 24 24" fill="${fill}"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>`;
5001}
5002
5003async function _runAiReplyFromButton(btn, em, data, mode) {
5004  _snapEmailModalToLeftSidebar(btn.closest('.modal'));
5005  btn.disabled = true;
5006  const orig = btn.innerHTML;
5007  let wp = null;
5008  try {
5009    wp = spinnerModule.createWhirlpool(14);
5010    wp.element.style.cssText = 'width:14px;height:14px;display:inline-block;vertical-align:middle;position:relative;top:-2px;';
5011    btn.innerHTML = '';
5012    btn.appendChild(wp.element);
5013  } catch (_) {}
5014  try {
5015    if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode });
5016  } finally {
5017    try { wp && wp.stop(); } catch (_) {}
5018    btn.disabled = false;
5019    btn.innerHTML = orig;
5020  }
5021}
5022
5023function _closeAiReplyChoice() {
5024  document.querySelectorAll('.email-ai-reply-choice').forEach(el => el.remove());
5025  document.removeEventListener('click', _closeAiReplyChoice, true);
5026}
5027
5028function _showAiReplyChoice(btn, em, data) {
5029  _closeAiReplyChoice();
5030  const rect = btn.getBoundingClientRect();
5031  const menu = document.createElement('div');
5032  menu.className = 'email-ai-reply-choice';
5033  menu.style.cssText = [
5034    'position:fixed',
5035    `left:${Math.max(8, Math.min(rect.left, window.innerWidth - 190))}px`,
5036    `top:${Math.min(window.innerHeight - 96, rect.bottom + 6)}px`,
5037    'z-index:10060',
5038    'display:flex',
5039    'gap:6px',
5040    'padding:6px',
5041    'background:var(--bg,#111)',
5042    'border:1px solid var(--border,#333)',
5043    'border-radius:7px',
5044    'box-shadow:0 8px 24px rgba(0,0,0,.28)',
5045  ].join(';');
5046  menu.innerHTML = `
5047    <button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Shorter, faster draft">Fast</button>
5048    <button class="memory-toolbar-btn" data-mode="ai-reply-full" title="Uses the fuller reply context">Full</button>
5049  `;
5050  menu.addEventListener('click', async (ev) => {
5051    const choice = ev.target.closest('[data-mode]');
5052    if (!choice) return;
5053    ev.preventDefault();
5054    ev.stopPropagation();
5055    const mode = choice.getAttribute('data-mode') || 'ai-reply';
5056    _closeAiReplyChoice();
5057    await _runAiReplyFromButton(btn, em, data, mode);
5058  });
5059  document.body.appendChild(menu);
5060  setTimeout(() => document.addEventListener('click', _closeAiReplyChoice, true), 0);
5061}
5062
5063function _handleAiReplyButton(ev, em, data) {
5064  ev.stopPropagation();
5065  const btn = ev.currentTarget;
5066  if (data?.cached_ai_reply) {
5067    _runAiReplyFromButton(btn, em, data, 'ai-reply');
5068    return;
5069  }
5070  _showAiReplyChoice(btn, em, data);
5071}
5072
5073function _hasMultipleRecipients(data) {
5074  // Count distinct addresses in To + Cc (minus the current user). Empty
5075  // fallback when the user's address isn't yet known — no exclusion.
5076  const myAddress = (window._myEmailAddress || '').toLowerCase();
5077  const extractEmails = (str) => {
5078    if (!str) return [];
5079    return str.split(',')
5080      .map(s => {
5081        const m = s.match(/<([^>]+)>/);
5082        return (m ? m[1] : s).trim().toLowerCase();
5083      })
5084      .filter(e => e && e !== myAddress);
5085  };
5086  const recipients = new Set([
5087    ...extractEmails(data.to),
5088    ...extractEmails(data.cc),
5089  ]);
5090  // Sender counts as one other person too
5091  if (data.from_address && data.from_address.toLowerCase() !== myAddress) {
5092    recipients.add(data.from_address.toLowerCase());
5093  }
5094  return recipients.size > 1;
5095}
5096
5097// _esc lives in ./emailLibrary/utils.js
5098
5099// ---- Reminder submenu (used by both email menus) ----
5100function _showLibRemindSubmenu(em, parentDropdown) {
5101  parentDropdown.innerHTML = '';
5102  const header = document.createElement('div');
5103  header.className = 'dropdown-item-compact';
5104  header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
5105  header.innerHTML = '<span>Remind me</span>';
5106  parentDropdown.appendChild(header);
5107
5108  const now = new Date();
5109  const laterToday = new Date(now);
5110  const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0);
5111  if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3*60*60*1000);
5112  else laterToday.setTime(sixPm.getTime());
5113  const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate()+1); tomorrow.setHours(8,0,0,0);
5114  const daysUntilMon = (8 - now.getDay()) % 7 || 7;
5115  const nextWeek = new Date(now); nextWeek.setDate(now.getDate()+daysUntilMon); nextWeek.setHours(8,0,0,0);
5116
5117  const presets = [
5118    { label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: laterToday },
5119    { label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: tomorrow },
5120    { label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday:'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: nextWeek },
5121  ];
5122  for (const p of presets) {
5123    const item = document.createElement('div');
5124    item.className = 'dropdown-item-compact';
5125    item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
5126    item.addEventListener('click', async (e) => {
5127      e.stopPropagation();
5128      parentDropdown.remove();
5129      await _createEmailReplyReminder(em, p.date);
5130    });
5131    parentDropdown.appendChild(item);
5132  }
5133  const customItem = document.createElement('div');
5134  customItem.className = 'dropdown-item-compact';
5135  customItem.innerHTML = '<span>Pick date and time…</span>';
5136  customItem.addEventListener('click', (e) => {
5137    e.stopPropagation();
5138    parentDropdown.remove();
5139    const tmp = document.createElement('input');
5140    tmp.type = 'datetime-local';
5141    const def = new Date(tomorrow);
5142    const pad = n => String(n).padStart(2,'0');
5143    tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`;
5144    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;';
5145    document.body.appendChild(tmp);
5146    tmp.focus();
5147    if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} }
5148    tmp.addEventListener('change', async () => {
5149      if (tmp.value) await _createEmailReplyReminder(em, new Date(tmp.value));
5150      tmp.remove();
5151    });
5152    tmp.addEventListener('blur', () => setTimeout(() => tmp.remove(), 200));
5153  });
5154  parentDropdown.appendChild(customItem);
5155}
5156
5157async function _createEmailReplyReminder(em, dueDate) {
5158  const pad = n => String(n).padStart(2,'0');
5159  const iso = `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}`;
5160  const fullFrom = em.from || em.sender || '';
5161  // Extract just the first name from "First Last <email@x>" or fall back to email local part
5162  let from = 'someone';
5163  if (fullFrom) {
5164    const fullName = _extractName(fullFrom);
5165    if (fullName) {
5166      // Strip quotes, take the first whitespace-separated word, capitalize
5167      const first = fullName.replace(/^["']|["']$/g, '').trim().split(/[\s,]+/)[0] || '';
5168      if (first) from = first.charAt(0).toUpperCase() + first.slice(1);
5169    }
5170  }
5171  const subject = em.subject || '(no subject)';
5172  const folder = state._libFolder || 'INBOX';
5173  const deepLink = `${window.location.origin}/#email=${encodeURIComponent(folder)}:${em.uid}`;
5174  const payload = {
5175    title: `Reply: ${subject}`,
5176    note_type: 'todo',
5177    items: [
5178      { text: `Reply to ${from}: ${subject}`, checked: false },
5179    ],
5180    content: `Open email: ${deepLink}`,
5181    label: 'email reminder',
5182    due_date: iso,
5183    source: 'email',
5184  };
5185  try {
5186    const res = await fetch(`${API_BASE}/api/notes`, {
5187      method: 'POST', credentials: 'same-origin',
5188      headers: { 'Content-Type': 'application/json' },
5189      body: JSON.stringify(payload),
5190    });
5191    if (!res.ok) throw new Error('Failed');
5192    const { showToast } = await import('./ui.js');
5193    const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
5194    showToast(`Todo reminder set for ${fmt}`);
5195    if ('Notification' in window && Notification.permission === 'default') {
5196      try { Notification.requestPermission(); } catch {}
5197    }
5198  } catch (e) {
5199    const { showError } = await import('./ui.js');
5200    showError('Failed to create reminder');
5201  }
5202}
5203
5204// Sanitize untrusted HTML email bodies before injecting via innerHTML.
5205//
5206// Denylist sanitizer — has to block every well-known XSS sink:
5207//   - <script>, <iframe>, <object>, <embed>, <form>, <style>, <link>
5208//   - SVG entirely (event handlers, <use href="javascript:">, <foreignObject>,
5209//     <animate>, <set>, etc.). Email clients don't need SVG.
5210//   - <math> (MathML can carry handlers).
5211//   - <base href="...">, <meta http-equiv="refresh">, <noscript>, <frame>,
5212//     <frameset>, <applet>, <portal>.
5213//   - on* attributes; javascript:/vbscript:/data: URLs in href/src/srcset/
5214//     formaction/action/background/poster/data attributes.
5215//   - srcdoc (defensive — iframe is already nuked).
5216//   - inline `style` declarations containing javascript: or expression().
5217// _sanitizeHtml / _escLinkify live in ./emailLibrary/utils.js

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.