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 › 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, '&').replace(/</g, '<')}${_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">×</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(/ /g, ' ') 788 .replace(/&/g, '&') 789 .replace(/</g, '<') 790 .replace(/>/g, '>') 791 .replace(/"/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.