PageSourceSearch

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

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 17:00:03 UTC 53,381 bytes, 1,245 lines download raw bytes

1/**
2 * emailInbox.js — Email inbox list in sidebar.
3 * Follows the session list pattern: list items, click to open as document, archive, etc.
4 */
5
6import spinnerModule from './spinner.js';
7import sessionModule from './sessions.js';
8import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary } from './emailLibrary.js';
9import * as Modals from './modalManager.js';
10import { applyEdgeDock } from './modalSnap.js';
11import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
12
13const API_BASE = window.location.origin;
14const _acct = () => window.__odysseusActiveEmailAccount
15  ? `&account_id=${encodeURIComponent(window.__odysseusActiveEmailAccount)}`
16  : '';
17
18const _emailSetupHint = () => '<div style="margin-top:6px;opacity:0.72;font-size:11px;">Setup: <span style="color:var(--accent,var(--red));">Settings &rsaquo; Integrations</span></div>';
19
20// SVG icons matching sessions.js dropdown style
21const _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>';
22const _archiveIcon = '<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>';
23const _deleteIcon = '<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>';
24const _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>';
25const _starIcon = '<svg width="14" height="14" 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>';
26const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" 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>';
27const _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>';
28const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
29const _replySeparator = '---------- Previous message ----------';
30
31function _cleanAiReplyText(text) {
32  if (!text) return '';
33  let t = String(text);
34  const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i;
35  const close = /<<<\s*END\s*>>+/i;
36  const m = open.exec(t);
37  if (m) {
38    const rest = t.slice(m.index + m[0].length);
39    const c = close.exec(rest);
40    t = c ? rest.slice(0, c.index) : rest;
41  }
42  return t
43    .replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
44    .replace(/<<<\s*END\s*>>+/gi, '')
45    .trim();
46}
47
48function _shouldUseFastAiReply(data) {
49  const body = String(data?.body || data?.body_html || '');
50  const subject = String(data?.subject || '');
51  const atts = Array.isArray(data?.attachments) ? data.attachments : [];
52  if (atts.length > 0) return false;
53  const text = `${subject}\n${body}`.toLowerCase();
54  if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
55    return false;
56  }
57  return body.length < 2500;
58}
59
60let _emails = [];
61let _currentFolder = 'INBOX';
62let _offset = 0;
63let _total = 0;
64
65// Replying to an email marks the source \Answered server-side and fires
66// `email-answered`. Reflect it live in the inbox list so it shows as done
67// immediately (no manual refresh needed).
68window.addEventListener('email-answered', (e) => {
69  const uid = e.detail && e.detail.uid;
70  if (uid == null) return;
71  const em = _emails.find(x => String(x.uid) === String(uid));
72  if (em) { em.is_answered = true; em.is_read = true; }
73  document.querySelectorAll('.email-item[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(item => {
74    item.classList.remove('email-unread');
75    const check = item.querySelector('.email-done-check');
76    if (check) check.classList.add('active');
77  });
78});
79let _loading = false;
80let _expanded = false;
81let _docModule = null;
82let _listSpinner = null;
83let _senderFilter = null;       // email address (lowercased) to filter by, or null
84let _senderFilterLabel = null;  // display label for the active filter chip
85
86export function init(documentModule) {
87  _docModule = documentModule;
88  _bindEvents();
89  // Init the library popup with a callback to open emails
90  initEmailLibrary({
91    documentModule,
92    onEmailClick: async (opts) => {
93      // Reply / AI Reply / Compose open a draft in the doc editor.
94      //  - Desktop: dock the email to the LEFT so it stays visible beside the
95      //    reply draft (which opens on the right) — read-while-you-reply.
96      //  - Mobile: there's no room for a split, so minimize the email modal;
97      //    the draft comes to the front and the inbox stays a tap away as a
98      //    minimized chip.
99      // Never call closeEmailLibrary() here — that destroys state.
100      try {
101        if (Modals.isRegistered('email-lib-modal')) {
102          const emailModal = document.getElementById('email-lib-modal');
103          if (window.innerWidth > 768 && emailModal && !emailModal.classList.contains('hidden')) {
104            applyEdgeDock(emailModal, 'left');
105          }
106          // Mobile: do NOT pre-mount the pane here. The load path (open/inject)
107          // mounts it exactly once when the doc is ready; the doc-view z-index
108          // rule slides it up OVER the email (which stays behind). Pre-mounting
109          // here caused a double-mount — the early pane was torn down by the
110          // compose session-switch, then remounted, which looked like a doc
111          // flashing before the smooth slide.
112        }
113      } catch (_) {}
114      if (opts.compose) { _composeNew(); return; }
115      if (opts.email) {
116        await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply');
117      }
118    },
119  });
120  _watchDocOpenToReDockEmail();
121}
122
123export async function openReplyDraft(uid, folder = 'INBOX', mode = 'reply') {
124  if (!uid) return;
125  const previousFolder = _currentFolder;
126  _currentFolder = folder || 'INBOX';
127  try {
128    await _openEmail({ uid: String(uid), subject: '' }, null, null, mode || 'reply');
129  } finally {
130    _currentFolder = previousFolder || _currentFolder;
131  }
132}
133
134// When the document editor pane opens (body.doc-view turns on), make sure the
135// email modal is on the LEFT — even if it was previously docked RIGHT or
136// floating — so the email and the doc always end up side-by-side. The actual
137// width math lives in modalSnap.js (`_anchorLeftDock` shrinks the email when
138// the doc is rendered to the right).
139let _docOpenObs = null;
140function _watchDocOpenToReDockEmail() {
141  if (_docOpenObs) return;
142  if (typeof MutationObserver === 'undefined') return;
143  let last = document.body.classList.contains('doc-view');
144  _docOpenObs = new MutationObserver(() => {
145    const cur = document.body.classList.contains('doc-view');
146    if (cur && !last) {
147      if (window.innerWidth > 768) {
148        const emailModal = document.getElementById('email-lib-modal');
149        if (emailModal && !emailModal.classList.contains('hidden')) {
150          // Already left-docked → nothing to do (modalSnap re-anchors on its own).
151          if (!emailModal.classList.contains('modal-left-docked')) {
152            try { applyEdgeDock(emailModal, 'left'); } catch (_) {}
153          }
154        }
155        // Same treatment for an open email-reader modal (one specific email
156        // open standalone — typical "click email, click doc" flow).
157        document.querySelectorAll('.modal[id^="email-reader-"]').forEach(m => {
158          if (m.classList.contains('hidden')) return;
159          if (m.classList.contains('modal-left-docked')) return;
160          try { applyEdgeDock(m, 'left'); } catch (_) {}
161        });
162      }
163    }
164    last = cur;
165  });
166  _docOpenObs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
167}
168
169function _bindEvents() {
170  // Clicking anywhere in the email section header opens the popup
171  // (except the compose button which has its own handler)
172  const section = document.getElementById('email-section');
173  const header = section?.querySelector('.section-header-flex');
174  if (header) {
175    header.style.cursor = 'pointer';
176    header.addEventListener('click', (e) => {
177      if (e.target.closest('#email-compose-btn')) return;
178      openEmailLibrary();
179      markInboxAsSeen();
180    });
181  }
182
183  // Compose button creates a new email document
184  const composeBtn = document.getElementById('email-compose-btn');
185  if (composeBtn) {
186    composeBtn.addEventListener('click', (e) => {
187      e.stopPropagation();
188      _composeNew();
189    });
190  }
191
192  // Initial unread count check, refresh every 60s
193  _refreshUnreadCount();
194  setInterval(_refreshUnreadCount, 60000);
195  prewarmEmailLibrary({ delay: 3000 });
196
197  // Deep-link: #email=<folder>:<uid> opens the library and expands that card
198  _maybeOpenFromHash();
199  window.addEventListener('hashchange', _maybeOpenFromHash);
200}
201
202function _maybeOpenFromHash() {
203  const h = window.location.hash || '';
204  const m = h.match(/^#email=([^:]+):(\d+)/);
205  if (!m) return;
206  const folder = decodeURIComponent(m[1]);
207  const uid = m[2];
208  try { openEmailLibrary({ folder, uid }); } catch (e) { console.error(e); }
209  // Clear the hash so reloads don't reopen
210  try { history.replaceState(null, '', window.location.pathname + window.location.search); } catch (_) {}
211}
212
213// Tint helper — turns the urgent-email-scanner's max_score into a dot color.
214// Falls back to the default (blue / unset) when scanner is off or no urgent.
215function _urgencyColor(score) {
216  if (score >= 3) return 'var(--color-error, #e06c75)';   // red — urgent now
217  if (score === 2) return '#f0ad4e';                       // orange — reply soon
218  return '';                                                // default (blue / theme)
219}
220
221async function _refreshUnreadCount() {
222  // Default the dot to hidden — only the verified "new mail above threshold"
223  // path below should turn it on. Without this, a fetch error or a backend
224  // returning malformed data left a stale dot from a previous account/session.
225  const dot = document.getElementById('email-unread-dot');
226  if (dot && !dot._stickyState) dot.style.display = 'none';
227  try {
228    // Parallel: unread list + urgency state.
229    const [listRes, urgRes] = await Promise.all([
230      fetch(`${API_BASE}/api/email/list?folder=INBOX&limit=50&filter=unread${_acct()}`),
231      fetch(`${API_BASE}/api/email/urgency-state`, { credentials: 'same-origin' }).catch(() => null),
232    ]);
233    if (!listRes || !listRes.ok) return;
234    const data = await listRes.json();
235    if (!dot) return;
236
237    const emails = data.emails || [];
238    if (emails.length === 0) {
239      dot.style.display = 'none';
240      return;
241    }
242
243    // Compare highest unread UID to the last-seen threshold in localStorage
244    const lastSeen = parseInt(localStorage.getItem('odysseus-email-last-seen-uid') || '0', 10);
245    const maxUid = Math.max(...emails.map(e => parseInt(e.uid, 10) || 0));
246
247    // Only show dot if there's a new email above the threshold
248    dot.style.display = maxUid > lastSeen ? '' : 'none';
249
250    // Color the dot by urgency tier. Cache the per-uid map so the per-row
251    // renderer can reuse it without a second fetch.
252    if (dot.style.display !== 'none' && urgRes && urgRes.ok) {
253      try {
254        const ud = await urgRes.json();
255        window._emailUrgencyState = ud;
256        const tint = _urgencyColor(ud.max_score || 0);
257        if (tint) dot.style.backgroundColor = tint;
258        else dot.style.backgroundColor = '';
259      } catch (_) {}
260    } else if (dot.style.display !== 'none') {
261      dot.style.backgroundColor = '';
262    }
263  } catch (e) {
264    // Network/parse error — keep the dot hidden (default at the top).
265    if (dot) dot.style.display = 'none';
266  }
267}
268
269export function markInboxAsSeen() {
270  // Called when the user opens the inbox popup — clears the notif dot
271  try {
272    // Find current max UID so subsequent arrivals trigger the dot
273    fetch(`${API_BASE}/api/email/list?folder=INBOX&limit=1${_acct()}`)
274      .then(r => r.json())
275      .then(data => {
276        const emails = data.emails || [];
277        if (emails.length > 0) {
278          const maxUid = Math.max(...emails.map(e => parseInt(e.uid, 10) || 0));
279          localStorage.setItem('odysseus-email-last-seen-uid', String(maxUid));
280        }
281        const dot = document.getElementById('email-unread-dot');
282        if (dot) dot.style.display = 'none';
283      })
284      .catch(() => {});
285  } catch (e) {}
286}
287
288export async function loadEmails(append = false) {
289  if (_loading) return;
290  _loading = true;
291
292  const list = document.getElementById('email-list');
293  if (!list) { _loading = false; return; }
294
295  if (!append) {
296    list.innerHTML = '';
297    // Show whirlpool spinner
298    if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
299    const sp = spinnerModule.createWhirlpool(20);
300    _listSpinner = sp;
301    list.appendChild(sp.element);
302  }
303
304  try {
305    const fromQS = _senderFilter ? `&from=${encodeURIComponent(_senderFilter)}` : '';
306    const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}&_=${Date.now()}`);
307    const data = await res.json();
308    if (data.error) throw new Error(data.error);
309
310    if (!append) _emails = [];
311    _emails.push(...(data.emails || []));
312    _total = data.total || 0;
313
314    // Remove spinner
315    if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
316
317    _renderList();
318
319    const unreadCount = _emails.filter(e => !e.is_read).length;
320    const dot = document.getElementById('email-unread-dot');
321    if (dot) dot.style.display = unreadCount > 0 ? '' : 'none';
322  } catch (e) {
323    console.error('Failed to load emails:', e);
324    if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
325    if (!append && list) {
326      const msg = e && e.message ? `Failed to load: ${e.message}` : 'Failed to load';
327      list.innerHTML = `<div class="email-loading">${msg.replace(/&/g, '&amp;').replace(/</g, '&lt;')}${_emailSetupHint()}</div>`;
328    }
329  } finally {
330    _loading = false;
331  }
332}
333
334async function loadFolders() {
335  try {
336    const res = await fetch(`${API_BASE}/api/email/folders?_=1${_acct()}`);
337    const data = await res.json();
338    const select = document.getElementById('email-folder-select');
339    if (!select || !data.folders) return;
340    _populateFolderSelect(select, data.folders);
341  } catch (e) {
342    console.error('Failed to load folders:', e);
343  }
344}
345
346export function sortedFolders(folders) {
347  const roleOf = (folder) => {
348    const f = String(folder || '').toLowerCase();
349    if (f === 'inbox') return 'inbox';
350    if (f.includes('sent')) return 'sent';
351    if (f.includes('starred') || f.includes('flagged')) return 'starred';
352    if (f.includes('draft')) return 'drafts';
353    if (f.includes('all mail') || f.includes('archive')) return 'archive';
354    if (f.includes('spam') || f.includes('junk')) return 'junk';
355    if (f.includes('trash') || f.includes('bin') || f.includes('deleted')) return 'trash';
356    return '';
357  };
358  const roleOrder = ['inbox', 'sent', 'starred', 'archive', 'junk', 'trash', 'drafts'];
359  const found = new Map();
360  const others = [];
361  for (const f of folders) {
362    const role = roleOf(f);
363    if (role && !found.has(role)) found.set(role, f);
364    else others.push(f);
365  }
366  return { priority: roleOrder.map(role =>
366 found.get(role)).filter(Boolean), others };
367}
368
369export function folderDisplayName(folder) {
370  const raw = String(folder || '');
371  const f = raw.toLowerCase();
372  if (f === 'inbox') return 'INBOX';
373  if (f.includes('all mail')) return 'Archive / All Mail';
374  if (f.includes('archive')) return 'Archive';
375  if (f.includes('spam')) return 'Spam';
376  if (f.includes('junk')) return 'Junk';
377  if (f.includes('trash') || f.includes('bin') || f.includes('deleted')) return 'Trash';
378  if (f.includes('sent')) return 'Sent';
379  if (f.includes('draft')) return 'Drafts';
380  return raw;
381}
382
383function _populateFolderSelect(select, folders) {
384  select.innerHTML = '';
385  const { priority, others } = sortedFolders(folders);
386
387  for (const folder of priority) {
388    const opt = document.createElement('option');
389    opt.value = folder;
390    opt.textContent = folderDisplayName(folder);
391    if (folder === _currentFolder) opt.selected = true;
392    select.appendChild(opt);
393  }
394
395  if (priority.length > 0 && others.length > 0) {
396    const sep = document.createElement('option');
397    sep.disabled = true;
398    sep.textContent = '─────────';
399    select.appendChild(sep);
400  }
401
402  for (const folder of others) {
403    const opt = document.createElement('option');
404    opt.value = folder;
405    opt.textContent = folderDisplayName(folder);
406    if (folder === _currentFolder) opt.selected = true;
407    select.appendChild(opt);
408  }
409}
410
411function _renderList() {
412  const list = document.getElementById('email-list');
413  if (!list) return;
414  list.innerHTML = '';
415
416  if (_senderFilter) {
417    const chip = document.createElement('div');
418    chip.className = 'email-filter-chip';
419    chip.innerHTML = `<span class="email-filter-chip-label">From: ${_esc(_senderFilterLabel || _senderFilter)}</span><button class="email-filter-chip-clear" title="Clear filter">&times;</button>`;
420    chip.querySelector('.email-filter-chip-clear').addEventListener('click', () => _clearSenderFilter());
421    list.appendChild(chip);
422  }
423
424  if (_emails.length === 0) {
425    const empty = document.createElement('div');
426    empty.className = 'email-loading';
427    empty.textContent = _senderFilter ? `No emails from ${_senderFilterLabel || _senderFilter}` : 'No emails';
428    list.appendChild(empty);
429    return;
430  }
431
432  for (const em of _emails) {
433    list.appendChild(_createEmailItem(em));
434  }
435
436  const loadMore = document.getElementById('email-load-more');
437  if (loadMore) {
438    loadMore.style.display = (_emails.length < _total) ? '' : 'none';
439  }
440}
441
442function _setSenderFilter(addr, label) {
443  _senderFilter = addr;
444  _senderFilterLabel = label || addr;
445  _offset = 0;
446  loadEmails(false);
447}
448
449function _clearSenderFilter() {
450  _senderFilter = null;
451  _senderFilterLabel = null;
452  _offset = 0;
453  loadEmails(false);
454}
455
456function _createEmailItem(em) {
457  const item = document.createElement('div');
458  item.className = 'list-item email-item' + (em.is_spam_verdict ? ' email-item-spam' : '');
459  item.setAttribute('role', 'option');
460  item.setAttribute('data-uid', em.uid);
461
462  let dateStr = '';
463  if (em.date) {
464    try {
465      const d = new Date(em.date);
466      const now = new Date();
467      const isToday = d.toDateString() === now.toDateString();
468      if (isToday) {
469        dateStr = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
470      } else {
471        dateStr = d.toLocaleDateString([], { month: 'short', day: 'numeric' });
472      }
473    } catch (_) {}
474  }
475
476  const senderName = em.from_name || em.from_address;
477  const initial = (senderName || '?')[0].toUpperCase();
478  const color = _senderColor(senderName);
479
480  const attachIcon = em.has_attachments
481    ? '<span title="Has attachments" style="opacity:0.6;display:inline-flex;flex-shrink:0;margin-left:4px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span>'
482    : '';
483
484  // Per-row dot tint: if the urgency scanner flagged this UID, override the
485  // per-sender pastel with red (3) / orange (2). Look up by any cached key
486  // ending in `:<uid>` since the per_uid map is keyed `<account_id>:<uid>`
487  // and the inbox list doesn't surface the account id per row.
488  let _unreadColor = color;
489  let _unreadTitle = 'Unread';
490  try {
491    const us = window._emailUrgencyState;
492    if (us && us.per_uid && em.uid != null) {
493      const suffix = ':' + String(em.uid);
494      for (const k of Object.keys(us.per_uid)) {
495        if (k.endsWith(suffix)) {
496          const v = us.per_uid[k] || {};
497          const score = v.score || 0;
498          if (score >= 3) { _unreadColor = 'var(--color-error, #e06c75)'; _unreadTitle = 'Urgent — ' + (v.reason || 'needs reply now'); }
499          else if (score === 2) { _unreadColor = '#f0ad4e'; _unreadTitle = 'Reply soon — ' + (v.reason || ''); }
500          break;
501        }
502      }
503    }
504  } catch (_) {}
505  const unreadIcon = (!em.is_read && !em.is_answered)
506    ? `<span class="email-unread-dot-inline" title="${_esc(_unreadTitle)}" style="display:inline-flex;align-items:center;flex-shrink:0;margin-left:4px;color:${_unreadColor}"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="6"/></svg></span>`
507    : '';
508
509  const tags = Array.isArray(em.tags) ? em.tags : [];
510  const tagPills = tags.length
511    ? `<span class="email-tags">${tags.map(t => `<span class="email-tag email-tag-${_esc(t)}">${_esc(t)}</span>`).join('')}</span>`
512    : '';
513
514  const spamTag = em.is_spam_verdict
515    ? `<span class="email-tag email-tag-spam" title="AI flagged as spam — click ✓ to unflag">spam <button class="email-spam-unflag" data-uid="${em.uid}" title="Not spam">\u2713</button></span>`
516    : '';
517
518  const senderAddr = (em.from_address || '').toLowerCase();
519  item.innerHTML = `
520    <span class="email-avatar" style="background:${color}">${initial}</span>
521    <div class="email-item-content">
522      <div class="email-item-top">
523        <span class="email-sender email-sender-clickable" style="color:${color}" data-from-addr="${_esc(senderAddr)}" data-from-name="${_esc(senderName)}" title="Show all emails from ${_esc(senderName)}">${_esc(senderName)}</span>
524        <span class="email-date">${_esc(dateStr)}</span>
525      </div>
526      <div class="email-subject">${_esc(em.subject)}${unreadIcon}${attachIcon}${tagPills}${spamTag}</div>
527    </div>
528    <div class="email-menu-wrap">
529      <button class="hamburger email-menu-btn" title="Actions">
530        <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>
531      </button>
532    </div>
533  `;
534
535  // Click sender name → filter list to that sender
536  const senderEl = item.querySelector('.email-sender-clickable');
537  if (senderEl) {
538    senderEl.addEventListener('click', (e) => {
539      e.stopPropagation();
540      const addr = senderEl.dataset.fromAddr || '';
541      const name = senderEl.dataset.fromName || addr;
542      if (addr) _setSenderFilter(addr, name);
543    });
544  }
545
546  // Wire the "not spam" button
547  const unflagBtn = item.querySelector('.email-spam-unflag');
548  if (unflagBtn) {
549    unflagBtn.addEventListener('click', async (e) => {
550      e.stopPropagation();
551      try {
552        await fetch(`${API_BASE}/api/email/${em.uid}/unflag-spam`, {
553          method: 'POST', credentials: 'same-origin',
554        });
555        em.is_spam_verdict = false;
556        item.classList.remove('email-item-spam');
557        const tag = item.querySelector('.email-tag-spam');
558        if (tag) tag.remove();
559      } catch (_) {}
560    });
561  }
562
563  // Click to open — do NOT close sidebar
564  item.addEventListener('click', (e) => {
565    if (e.target.closest('.email-menu-wrap')) return;
566    if (item.dataset.swipeBlock === '1') return;
567    _openEmail(em, item);
568  });
569
570  const menuWrap = item.querySelector('.email-menu-wrap');
571  menuWrap.addEventListener('click', (e) => {
572    e.stopPropagation();
573    _showEmailMenu(em, menuWrap, item);
574  });
575
576  // Swipe left to archive (mobile). Mirrors sidebar-layout.js swipe pattern.
577  if ('ontouchstart' in window) {
578    let startX = 0, startY = 0, dx = 0, dy = 0, swiping = false, swiped = false;
579    const HORIZ_THRESHOLD = 70; // px to trigger archive
580    const VERT_CANCEL = 30;     // px vertical motion cancels swipe (treat as scroll)
581
582    item.addEventListener('touchstart', (e) => {
583      if (e.target.closest('.email-menu-wrap')) return;
584      const t = e.touches[0];
585      startX = t.clientX; startY = t.clientY;
586      dx = 0; dy = 0; swiping = true; swiped = false;
587      item.style.transition = 'none';
588    }, { passive: true });
589
590    item.addEventListener('touchmove', (e) => {
591      if (!swiping) return;
592      const t = e.touches[0];
593      dx = t.clientX - startX;
594      dy = t.clientY - startY;
595      if (Math.abs(dy) > VERT_CANCEL) {
596        // Vertical scroll — cancel swipe
597        swiping = false;
598        item.style.transform = '';
599        return;
600      }
601      if (dx < 0) {
602        // Only swipe-left for archive; clamp at -160 so it doesn't fly off
603        const offset = Math.max(dx, -160);
604        item.style.transform = `translateX(${offset}px)`;
605        item.style.background = `linear-gradient(to right, transparent, transparent ${100 + offset/1.6}%, var(--red) ${100 + offset/1.6}%)`;
606      }
607    }, { passive: true });
608
609    item.addEventListener('touchend', () => {
610      if (!swiping) return;
611      swiping = false;
612      item.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
613      if (dx <= -HORIZ_THRESHOLD) {
614        // Trigger archive — animate off-screen, suppress next click
615        swiped = true;
616        item.dataset.swipeBlock = '1';
617        item.style.transform = 'translateX(-100%)';
618        item.style.opacity = '0';
619        setTimeout(() => {
620          _archiveEmail(em);
621          delete item.dataset.swipeBlock;
622        }, 200);
623      } else {
624        // Snap back
625        item.style.transform = '';
626        item.style.background = '';
627      }
628    });
629
630    item.addEventListener('touchcancel', () => {
631      swiping = false;
632      item.style.transition = 'transform 0.2s ease';
633      item.style.transform = '';
634      item.style.background = '';
635    });
636  }
637
638  return item;
639}
640
641async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
642  const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : (mode === 'ai-reply-full' ? 'full' : '');
643  const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
644  let aiSuggestedBody = null;
645  if (wantsAiReply) {
646    // Fall through to reply-all (not plain reply) so the generated AI
647    // draft addresses everyone on the original thread. On single-
648    // recipient emails this collapses to a regular reply since there's
649    // no one else to CC.
650    mode = 'reply-all';
651  }
652  // Show whirlpool spinner on the right side of the item (only if from sidebar)
653  let spinner = null;
654  if (itemEl) {
655    const sp = spinnerModule.createWhirlpool(16);
656    spinner = sp;
657    sp.element.style.cssText = 'margin:0;flex-shrink:0;';
658    const menuWrap = itemEl.querySelector('.email-menu-wrap');
659    if (menuWrap) menuWrap.style.display = 'none';
660    itemEl.appendChild(sp.element);
661  }
662
663  try {
664    let data = preloadedData;
665    if (!data) {
666      const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`);
667      data = await res.json();
668    }
669    if (data.error) {
670      console.error('Failed to read email:', data.error);
671      return;
672    }
673    if (wantsAiReply) {
674      if (data.cached_ai_reply) {
675        aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
676      } else {
677        let draftToastTimer = null;
678        draftToastTimer = setTimeout(() => {
679          import('./ui.js').then(m => m.showToast && m.showToast('Drafting AI reply', { duration: 3000, leadingIcon: 'spinner' })).catch(() => {});
680        }, 450);
681        try {
682          let currentModel = '';
683          let currentSessionId = '';
684          try {
685            currentModel = sessionModule?.getCurrentModel() || '';
686            currentSessionId = sessionModule?.getCurrentSessionId() || '';
687          } catch (_) {}
688          const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
689            method: 'POST',
690            headers: { 'Content-Type': 'application/json' },
691            body: JSON.stringify({
692              to: data.from_address,
693              subject: `Re: ${data.subject}`,
694              original_body: data.body,
695              model: currentModel,
696              session_id: currentSessionId,
697              message_id: data.message_id || '',
698              uid: String(em.uid || ''),
699              folder: _currentFolder,
700              fast: aiReplyMode ? aiReplyMode === 'fast' : _shouldUseFastAiReply(data),
701            }),
702          });
703          const result = await res.json();
704          if (draftToastTimer) clearTimeout(draftToastTimer);
705          if (result.success && result.reply) {
706            aiSuggestedBody = _cleanAiReplyText(result.reply);
707          } else {
708            const _msg = result.error || 'AI reply could not be generated';
709            console.error('AI reply generation failed:', _msg);
710            import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {});
711            return;
712          }
713        } catch (e) {
714          if (draftToastTimer) clearTimeout(draftToastTimer);
715          console.error('AI reply generation failed:', e);
716          import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
717          return;
718        }
719      }
720    }
721
722    em.is_read = true;
723    if (itemEl) itemEl.classList.remove('email-unread');
724
725    // Addresses to exclude from Reply All. Prefer the full set of configured
726    // accounts (so a multi-account user's other mailboxes are excluded too),
727    // falling back to the single active address. Empty ⇒ no exclusion.
728    const myAddresses = (Array.isArray(window._myEmailAddresses) && window._myEmailAddresses.length)
729      ? window._myEmailAddresses
730      : (window._myEmailAddress ? [window._myEmailAddress] : []);
731
732    let toAddress = data.from_address;
733    let ccAddresses = '';
734    let subjectPrefix = 'Re: ';
735
736    if (mode === 'reply-all') {
737      // Build reply-all: TO = original sender, CC = everyone else (To + Cc minus me)
738      ccAddresses = buildReplyAllCc(data, myAddresses);
739    } else if (mode === 'forward') {
740      toAddress = '';
741      subjectPrefix = 'Fwd: ';
742    }
743
744    // Don't double-prefix `Re:` / `Fwd:` when the subject already starts with one.
745    // Replies to replies were producing `Re: Re: Re: …` which can also break
746    // some IMAP servers' header parsing on very long subject lines.
747    let _baseSubject = (data.subject || '').trim();
748    if (subjectPrefix === 'Re: ' && /^re\s*:/i.test(_baseSubject)) subjectPrefix = '';
749    else if (subjectPrefix === 'Fwd: ' && /^fwd?\s*:/i.test(_baseSubject)) subjectPrefix = '';
750    let content = `To: ${toAddress}\nSubject: ${subjectPrefix}${_baseSubject}`;
751    if (ccAddresses) content += `\nCc: ${ccAddresses}`;
752    if (mode !== 'forward' && data.message_id) content += `\nIn-Reply-To: ${data.message_id}`;
753    if (mode !== 'forward' && data.message_id) content += `\nReferences: ${data.references ? data.references + ' ' + data.message_id : data.message_id}`;
754    content += `\nX-Source-UID: ${em.uid}`;
755    content += `\nX-Source-Folder: ${_currentFolder}`;
756    if (data.attachments && data.attachments.length > 0) {
757      const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
758      content += `\nX-Attachments: ${attStr}`;
759    }
760    content += '\n---\n';
761
762    // Format the original date in a human-readable way for the quote header
763    let niceDate = data.date || '';
764    try {
765      if (data.date) {
766        const d = new Date(data.date);
767        if (!isNaN(d.getTime())) {
768          niceDate = d.toLocaleString([], {
769            weekday: 'short', month: 'short', day: 'numeric', year: 'numeric',
770            hour: '2-digit', minute: '2-digit',
771          });
772        }
773      }
774    } catch (_) {}
775
776    // Plain-text body, with HTML fallback stripped if no text part exists.
777    // Without this, an HTML-only email gives data.body === null/undefined
778    // and the reply doc opens empty (data.body.split throws).
779    let _origBody = (typeof data.body === 'string' && data.body.length) ? data.body : '';
780    if (!_origBody && typeof data.body_html === 'string' && data.body_html) {
781      _origBody = data.body_html
782        .replace(/<style[\s\S]*?<\/style>/gi, '')
783        .replace(/<script[\s\S]*?<\/script>/gi, '')
784        .replace(/<br\s*\/?>/gi, '\n')
785        .replace(/<\/p>/gi, '\n\n')
786        .replace(/<[^>]+>/g, '')
787        .replace(/&nbsp;/g, ' ')
788        .replace(/&amp;/g, '&')
789        .replace(/&lt;/g, '<')
790        .replace(/&gt;/g, '>')
791        .replace(/&quot;/g, '"')
792        .replace(/\n{3,}/g, '\n\n')
793        .trim();
794    }
795
796    if (mode === 'forward') {
797      content += `\n\n---------- Forwarded message ----------\n`;
798      content += `From: ${data.from_name} <${data.from_address}>\n`;
799      content += `Date: ${niceDate}\n`;
800      content += `Subject: ${data.subject}\n`;
801      if (data.to) content += `To: ${data.to}\n`;
802      content += `\n${_origBody}`;
803    } else {
804      const quotedBody = _origBody.split('\n').map(l => '> ' + l).join('\n');
805      // Inject AI-suggested body if present. No leading newline — the header
806      // block already ends with "---\n", so the reply must start on the very
807      // first body line, not one row down.
808      if (aiSuggestedBody) {
809        content += `${aiSuggestedBody}\n\n`;
810      } else {
811        content += '\n\n';
812      }
813      content += `${_replySeparator}\nOn ${niceDate}, ${data.from_name} <${data.from_address}> wrote:\n${quotedBody}`;
814    }
815
816    if (_docModule) {
817      // Only reuse an existing doc tab if the user really just wants to "view"
818      // the email again. For reply/reply-all/forward/ai-reply, always create
819      // a fresh draft — otherwise a previously-emptied doc (sent reply, AI
820      // reply that came back blank, etc.) keeps coming back instead of a
821      // proper pre-filled reply.
822      const reuseExisting = (mode === 'view' || mode === 'open');
823      const existingDocId = (reuseExisting && _docModule.findEmailDocId)
824        ? _docModule.findEmailDocId(em.uid, _currentFolder)
825        : null;
826      if (existingDocId) {
827        if (!_docModule.isPanelOpen()) _docModule.openPanel();
828        await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
829        await _docModule.loadDocument(existingDocId);
830      } else {
831        // If the user already has a chat session open, reuse it instead of
832        // spawning a new one. They asked for this explicitly — opening reply
833        // mid-conversation shouldn't whip them out of context.
834        let activeSid = '';
835        try { activeSid = sessionModule?.getCurrentSessionId?.() || ''; } catch {}
836        if (!activeSid) {
837          // No chat in flight — keep the old behavior of creating a scoped
838          // email-thread chat, then RE-READ the now-current session id. The
839          // POST below requires a session_id (backend 400s without one), and
840          // the freshly-created chat is what should own the reply draft.
841          await _createEmailChat(data);
842          try { activeSid = sessionModule?.getCurrentSessionId?.() || ''; } catch {}
843        }
844        // Guarantee a session — _createEmailChat can't make one when there's
845        // no enabled default-chat endpoint, which left the reply POSTing a
846        // null session_id → 400. Create a bare session so the draft always
847        // has a home regardless of chat/endpoint config.
848        if (!activeSid) {
849          try {
850            const _fd = new FormData();
851            _fd.append('name', `Email: ${(data.subject || '').slice(0, 60)}`);
852            _fd.append('skip_validation', 'true');
853            const _sres = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
854            if (_sres.ok) {
855              const _sdata = await _sres.json();
856              if (_sdata && _sdata.id) {
857                activeSid = _sdata.id;
858                if (sessionModule?.loadSessions) await sessionModule.loadSessions();
859                if (sessionModule?.selectSession) await sessionModule.selectSession(activeSid);
860              }
861            }
862          } catch (e) { console.error('reply: bare session create failed', e); }
863        }
864
865        const docRes = await fetch(`${API_BASE}/api/document`, {
866          method: 'POST',
867          headers: { 'Content-Type': 'application/json' },
868          body: JSON.stringify({
869            // Reuse the user's current chat session if there is one (so the
870            // reply draft lives in the chat they were just in); otherwise
871            // null and the new email-chat (created above) takes over.
872            session_id: activeSid || null,
873            title: data.subject,
874            content: content,
875            language: 'email',
876          }),
877        });
878        if (!docRes.ok) {
879          const errText = await docRes.text();
880          console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
881          // uiModule isn't statically imported here — use the dynamic
882          // import pattern the rest of this file uses. (Previously this
883          // referenced a bare `uiModule`, throwing a ReferenceError that
884          // the outer catch swallowed → reply silently did nothing.)
885          import('./ui.js').then(m => m.showError && m.showError('Failed to create reply draft (' + docRes.status + ')')).catch(() => {});
886          return;
887        }
888        const doc = await docRes.json();
889        if (doc.id) {
890          const wasOpen = _docModule.isPanelOpen();
891          if (!wasOpen) _docModule.openPanel();
892          await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
893          // Use the doc dict from the POST directly — avoids a 404 race
894          // when the GET fires before the new row is visible to the read
895          // connection (or when caching is interfering). loadDocument's
896          // GET path can still be used as a fallback.
897          if (_docModule.injectFreshDoc) {
898            _docModule.injectFreshDoc(doc);
899          } else {
900            await _docModule.loadDocument(doc.id);
901          }
902        }
903      }
904    }
905  } catch (e) {
906    console.error('Failed to open email:', e);
907    // Surface the failure so a silent throw in the reply flow doesn't
908    // look like "nothing happened". Dynamic import — uiModule isn't a
909    // static import in this file.
910    const msg = e && e.message ? e.message : String(e);
911    import('./ui.js').then(m => m.showError && m.showError('Reply failed: ' + msg)).catch(() => {});
912  } finally {
913    if (spinner) { spinner.destroy(); spinner.element.remove(); }
914    if (itemEl) {
915      const menuWrap = itemEl.querySelector('.email-menu-wrap');
916      if (menuWrap) menuWrap.style.display = '';
917    }
918  }
919}
920
921function _showEmailMenu(em, anchor, itemEl) {
922  document.querySelectorAll('.email-dropdown').forEach(d => d.remove());
923
924  const dropdown = document.createElement('div');
925  dropdown.className = 'dropdown email-dropdown show';
926
927  const actions = [
928    { label: 'Open', icon: _replyIcon, action: () => _openEmail(em, itemEl) },
929    { label: 'Remind to reply', icon: _bellIcon, submenu: 'remind' },
930    { label: 'Archive', icon: _archiveIcon, action: () => _archiveEmail(em) },
931    { label: 'Delete', icon: _deleteIcon, danger: true, action: () => _deleteEmail(em) },
932  ];
933
934  for (const a of actions) {
935    const menuItem = document.createElement('div');
936    menuItem.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
937    const arrow = a.submenu ? ' <span style="margin-left:auto;opacity:0.5;">›</span>' : '';
938    menuItem.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${arrow}`;
939    menuItem.addEventListener('click', (e) => {
940      e.stopPropagation();
941      if (a.submenu === 'remind') {
942        _showRemindSubmenu(em, dropdown);
943        return;
944      }
945      dropdown.remove();
946      a.action();
947    });
948    dropdown.appendChild(menuItem);
949  }
950
951  anchor.appendChild(dropdown);
952
953  const close = (e) => {
954    if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
955      dropdown.remove();
956      document.removeEventListener('click', close, true);
957    }
958  };
959  setTimeout(() => document.addEventListener('click', close, true), 10);
960}
961
962// ---- Reminder submenu (creates a Note with a reminder for this email) ----
963
964function _showRemindSubmenu(em, parentDropdown) {
965  // Replace content of parent dropdown with time presets
966  parentDropdown.innerHTML = '';
967  const header = document.createElement('div');
968  header.className = 'dropdown-item-compact';
969  header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
970  header.innerHTML = '<span>Remind me</span>';
971  parentDropdown.appendChild(header);
972
973  const now = new Date();
974  const laterToday = new Date(now);
975  const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0);
976  if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3 * 60 * 60 * 1000);
977  else laterToday.setTime(sixPm.getTime());
978
979  const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(8, 0, 0, 0);
980  const daysUntilMon = (8 - now.getDay()) % 7 || 7;
981  const nextWeek = new Date(now); nextWeek.setDate(now.getDate() + daysUntilMon); nextWeek.setHours(8, 0, 0, 0);
982
983  const presets = [
984    { label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), date: laterToday },
985    { label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), date: tomorrow },
986    { label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday: 'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }), date: nextWeek },
987  ];
988  for (const p of presets) {
989    const item = document.createElement('div');
990    item.className = 'dropdown-item-compact';
991    item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
992    item.addEventListener('click', async (e) => {
993      e.stopPropagation();
994      parentDropdown.remove();
995      await _createReplyReminder(em, p.date);
996    });
997    parentDropdown.appendChild(item);
998  }
999  const customItem = document.createElement('div');
1000  customItem.className = 'dropdown-item-compact';
1001  customItem.innerHTML = '<span>Pick date and time…</span>';
1002  customItem.addEventListener('click', async (e) => {
1003    e.stopPropagation();
1004    parentDropdown.remove();
1005    const tmp = document.createElement('input');
1006    tmp.type = 'datetime-local';
1007    const def = new Date(tomorrow);
1008    const pad = n => String(n).padStart(2, '0');
1009    tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`;
1010    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;';
1011    document.body.appendChild(tmp);
1012    tmp.focus();
1013    if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} }
1014    // Cleanup helper — also unwires the global listeners so they don't
1015    // linger after dismiss.
1016    const _cleanup = () => {
1017      tmp.remove();
1018      document.removeEventListener('keydown', _onKey);
1019      document.removeEventListener('mousedown', _onDocClick, true);
1020    };
1021    const _onKey = (ev) => { if (ev.key === 'Escape') _cleanup(); };
1022    // Click-outside dismiss. Replaces the old blur-based auto-remove —
1023    // blur fires whenever the native datetime popup steals focus, so
1024    // the input vanished before the user could click any date. Now we
1025    // only dismiss when the user clicks something that is NOT the
1026    // input itself (the native picker popup is a browser-owned overlay
1027    // OUTSIDE the document, so its clicks don't fire here at all — no
1028    // false dismissals).
1029    const _onDocClick = (ev) => { if (ev.target !== tmp) _cleanup(); };
1030    tmp.addEventListener('change', async () => {
1031      if (tmp.value) {
1032        await _createReplyReminder(em, new Date(tmp.value));
1033      }
1034      _cleanup();
1035    });
1036    document.addEventListener('keydown', _onKey);
1037    // Defer the click-outside listener so the click that opened this
1038    // input doesn't immediately close it.
1039    setTimeout(() => document.addEventListener('mousedown', _onDocClick, true), 50);
1040  });
1041  parentDropdown.appendChild(customItem);
1042}
1043
1044async function _createReplyReminder(em, dueDate) {
1045  const pad = n => String(n).padStart(2, '0');
1046  const iso = `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}`;
1047  const from = em.from || em.sender || 'someone';
1048  const payload = {
1049    title: `Reply: ${em.subject || '(no subject)'}`,
1050    content: `From: ${from}\n\nRemember to reply to this email.`,
1051    note_type: 'note',
1052    label: 'email',
1053    due_date: iso,
1054    source: 'email',
1055  };
1056  try {
1057    const res = await fetch(`${API_BASE}/api/notes`, {
1058      method: 'POST', credentials: 'same-origin',
1059      headers: { 'Content-Type': 'application/json' },
1060      body: JSON.stringify(payload),
1061    });
1062    if (!res.ok) throw new Error('Failed');
1063    const { showToast } = await import('./ui.js');
1064    const fmt = dueDate.toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
1065    showToast(`Reminder set for ${fmt}`);
1066    // Request notification permission if needed
1067    if ('Notification' in window && Notification.permission === 'default') {
1068      try { Notification.requestPermission(); } catch {}
1069    }
1070  } catch (e) {
1071    const { showError } = await import('./ui.js');
1072    showError('Failed to create reminder');
1073  }
1074}
1075
1076async function _archiveEmail(em) {
1077  try {
1078    await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' });
1079    _emails = _emails.filter(e => e.uid !== em.uid);
1080    _renderList();
1081  } catch (e) {
1082    console.error('Failed to archive:', e);
1083  }
1084}
1085
1086async function _deleteEmail(em) {
1087  const subject = em.subject || '(no subject)';
1088  const { styledConfirm } = await import('./ui.js');
1089  const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true });
1090  if (!ok) return;
1091  try {
1092    await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'DELETE' });
1093    _emails = _emails.filter(e => e.uid !== em.uid);
1094    _renderList();
1095  } catch (e) {
1096    console.error('Failed to delete:', e);
1097  }
1098}
1099
1100async function _toggleDone(em, itemEl) {
1101  const newState = !em.is_answered;
1102  em.is_answered = newState;
1103  if (newState) em.is_read = true; // mark-done implies mark-read
1104  if (itemEl) {
1105    if (newState) {
1106      itemEl.classList.remove('email-unread');
1107      // Also drop any inline unread indicator dots the renderer may have added
1108      itemEl.querySelectorAll('.email-unread-dot, [data-unread-dot]').forEach(n => n.remove());
1109    }
1110    const check = itemEl.querySelector('.email-done-check');
1111    if (check) check.classList.toggle('active', newState);
1112  }
1113  try {
1114    if (newState) {
1115      await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' });
1116      await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' });
1117    } else {
1118      await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}`, { method: 'POST' });
1119    }
1120  } catch (e) {
1121    console.error('Failed to toggle done:', e);
1122  }
1123}
1124
1125async function _createEmailChat(emailData) {
1126  try {
1127    // Try current session's endpoint first
1128    const current = sessionModule.getSessions?.().find(s => s.id === sessionModule.getCurrentSessionId?.());
1129    let url, model, endpointId;
1130    if (current && current.endpoint_url && current.model) {
1131      url = current.endpoint_url;
1132      model = current.model;
1133      endpointId = current.endpoint_id;
1134    } else {
1135      // Fall back to default chat config
1136      const dcRes = await fetch(`${API_BASE}/api/default-chat`);
1137      const dc = await dcRes.json();
1138      url = dc.endpoint_url;
1139      model = dc.model;
1140      endpointId = dc.endpoint_id;
1141    }
1142
1143    if (url && model) {
1144      await sessionModule.createDirectChat(url, model, endpointId);
1145      // Set a helpful title in the chat meta
1146      const meta = document.getElementById('current-meta');
1147      if (meta) meta.textContent = `Email: ${(emailData.subject || '').slice(0, 60)}`;
1148    }
1149  } catch (e) {
1150    console.error('Failed to create email chat:', e);
1151  }
1152}
1153
1154async function _composeNew() {
1155  if (!_docModule) return;
1156  // NOTE: don't open the panel here. Creating the email-scoped chat below can
1157  // switch sessions, which tears the panel down — so an early open w
1157ould mount
1158  // the pane, get closed, then injectFreshDoc remounts it: a visible flash
1159  // (doc shows for a frame, then slides up again). Mount once, at injectFreshDoc,
1160  // after the session + doc exist.
1161  try {
1162    // /api/document requires a session_id (returns 400 if null), so reuse
1163    // the active chat if there is one — otherwise spin up an email-scoped
1164    // chat first, same pattern the reply path uses.
1165    let sid = '';
1166    try { sid = sessionModule?.getCurrentSessionId?.() || ''; } catch (_) {}
1167    if (!sid) {
1168      await _createEmailChat({ subject: 'New Email' });
1169      try { sid = sessionModule?.getCurrentSessionId?.() || ''; } catch (_) {}
1170    }
1171    // Guarantee a session — _createEmailChat can't make one when there's no
1172    // enabled default-chat endpoint, which left compose POSTing a null
1173    // session_id → 400 (the draft silently never appeared). Same bare-session
1174    // fallback the reply flow uses.
1175    if (!sid) {
1176      try {
1177        const _fd = new FormData();
1178        _fd.append('name', 'New Email');
1179        _fd.append('skip_validation', 'true');
1180        const _sres = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
1181        if (_sres.ok) {
1182          const _sdata = await _sres.json();
1183          if (_sdata && _sdata.id) {
1184            sid = _sdata.id;
1185            // NOTE: intentionally do NOT loadSessions()/selectSession() here.
1186            // Re-selecting the (empty) session re-renders the chat and flashes
1187            // the welcome splash for a frame before the draft opens — the
1188            // "splash flickers like crazy then email opens" bug. The doc only
1189            // needs the session_id; the draft opens in the doc panel regardless.
1190          }
1191        }
1192      } catch (e) { console.error('compose: bare session create failed', e); }
1193    }
1194    if (!sid) {
1195      console.error('compose: could not obtain a session_id');
1196      import('./ui.js').then(m => m.showError && m.showError('Could not start a new email (no session).')).catch(() => {});
1197      return;
1198    }
1199    const res = await fetch(`${API_BASE}/api/document`, {
1200      method: 'POST',
1201      headers: { 'Content-Type': 'application/json' },
1202      body: JSON.stringify({
1203        session_id: sid,
1204        title: 'New Email',
1205        content: 'To: \nSubject: \n---\n',
1206        language: 'email',
1207      }),
1208    });
1209    if (!res.ok) {
1210      console.error('compose POST failed', res.status, await res.text().catch(() => ''));
1211      import('./ui.js').then(m => m.showError && m.showError('Failed to create new email (' + res.status + ')')).catch(() => {});
1212      return;
1213    }
1214    const doc = await res.json();
1215    if (doc.id) {
1216      await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
1217      // Use the doc dict from POST directly to avoid the GET 404 race that
1218      // hits a freshly-created doc on a separate read connection.
1219      if (_docModule.injectFreshDoc) {
1220        _docModule.injectFreshDoc(doc);
1221      } else {
1222        _docModule.loadDocument(doc.id);
1223      }
1224    }
1225  } catch (e) {
1226    console.error('Failed to create email:', e);
1227  }
1228}
1229
1230function _esc(text) {
1231  const div = document.createElement('div');
1232  div.textContent = text || '';
1233  return div.innerHTML;
1234}
1235
1236function _senderColor(name) {
1237  if (!name) return 'hsl(220, 55%, 65%)';
1238  const key = name.toLowerCase();
1239  let hash = 0;
1240  for (let i = 0; i < key.length; i++) {
1241    hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0;
1242  }
1243  const hue = ((hash % 360) + 360) % 360;
1244  return `hsl(${hue}, 55%, 65%)`;
1245}

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.