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