/** * emailLibrary.js — Email library popup modal. * Similar pattern to documentLibrary.js. Shows emails in a grid with search/filter. */ import spinnerModule from './spinner.js'; import { styledConfirm, showToast, emptyStateIcon } from './ui.js'; import { folderDisplayName, sortedFolders } from './emailInbox.js'; import settingsModule from './settings.js'; import * as Modals from './modalManager.js'; import { makeWindowDraggable } from './windowDrag.js'; import { _esc, _escLinkify, _extractName, _parseTurnMeta, _formatBubbleDate, _formatRecipients, _senderColor, _initials, _sanitizeHtml, _TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO, _TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS, } from './emailLibrary/utils.js'; import { _looksLikeSignature, _harvestAttribution, _extractTurnMetaFromBlockquote, _foldSummary, _extractQuoteMeta, _peelSigNameLine, _isBloatedSig, _tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON, } from './emailLibrary/signatureFold.js'; import { state } from './emailLibrary/state.js'; const API_BASE = window.location.origin; let _emailUnreadChipClickWired = false; let _libLoadSeq = 0; let _libFolderSeq = 0; function _syncEmailReadState(uid, isRead = true) { if (uid == null) return; const uidStr = String(uid); const read = !!isRead; const match = (state._libEmails || []).find(x => String(x.uid) === uidStr); if (match) match.is_read = read; document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => { card.classList.toggle('email-card-unread', !read); const titleRow = card.querySelector('.email-card-titlerow'); if (read) { card.querySelectorAll('.email-card-unread-dot, [data-unread-dot]').forEach(n => n.remove()); if (titleRow) { titleRow.querySelectorAll('span').forEach(s => { const st = s.getAttribute('style') || ''; if (/width:\s*6px/.test(st) && /border-radius:\s*50%/.test(st)) s.remove(); }); } return; } if (!titleRow || titleRow.querySelector('.email-card-unread-dot, [data-unread-dot]')) return; const isSentFolder = /sent/i.test(state._libFolder || ''); if (isSentFolder) return; const senderName = match ? (match.from_name || match.from_address || '') : ''; const dot = document.createElement('span'); dot.className = 'email-card-unread-dot'; dot.style.cssText = `width:6px;height:6px;border-radius:50%;background:${_senderColor(senderName)};flex-shrink:0;margin-left:2px;`; const done = titleRow.querySelector('.email-card-done'); const rightCluster = titleRow.querySelector('.email-card-header-menu')?.parentElement; if (done) done.insertAdjacentElement('afterend', dot); else if (rightCluster) titleRow.insertBefore(dot, rightCluster); else titleRow.appendChild(dot); }); } // When a reply is sent (from the doc editor), the source email is marked // \Answered server-side and an `email-answered` event fires. Reflect that live // so the email shows as done without waiting for a manual refresh. window.addEventListener('email-answered', (e) => { const uid = e.detail && e.detail.uid; if (uid == null) return; const em = (state._libEmails || []).find(x => String(x.uid) === String(uid)); if (em) { em.is_answered = true; em.is_read = true; } _syncEmailReadState(uid, true); document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(String(uid)) + '"]').forEach(card => { card.classList.add('email-card-answered'); card.classList.remove('email-card-unread'); const check = card.querySelector('.email-card-done'); if (check) check.classList.add('active'); }); }); function _toggleUnreadEmails() { if (state._libFolder === '__scheduled__') state._libFolder = 'INBOX'; state._libFilter = state._libFilter === 'unread' ? 'all' : 'unread'; _syncUnreadWindowGlow(); const folderEl = document.getElementById('email-lib-folder'); const filterEl = document.getElementById('email-lib-filter'); if (folderEl) folderEl.value = state._libFolder || 'INBOX'; if (filterEl) filterEl.value = state._libFilter; document.getElementById('email-undone-btn')?.classList.remove('active'); document.getElementById('email-reminder-btn')?.classList.remove('active'); _loadEmailsFresh(); } function _syncUnreadTabBadge(count) { const label = count > 999 ? '999+ unread' : `${count} unread`; document.querySelectorAll('.minimized-dock-chip[data-modal-id="email-lib-modal"]').forEach(chip => { if (count > 0) { chip.dataset.emailUnreadLabel = label; chip.title = `Open ${label}`; } else { delete chip.dataset.emailUnreadLabel; chip.title = 'Restore Email'; } }); } function _syncUnreadWindowGlow() { document.getElementById('email-lib-modal')?.classList.toggle('email-lib-unread-active', state._libFilter === 'unread'); } function _syncReminderClearButton() { document.getElementById('email-reminders-clear-btn')?.classList.toggle('hidden', state._libFilter !== 'reminders'); } function _syncEmailReminderBellVisibility(enabled) { const btn = document.getElementById('email-reminder-btn'); const wrap = document.querySelector('#email-lib-modal .email-search-wrap'); btn?.classList.toggle('hidden', !enabled); wrap?.classList.toggle('email-reminder-bell-hidden', !enabled); } async function _loadEmailReminderBellVisibility() { try { const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const settings = await res.json(); _syncEmailReminderBellVisibility(settings.reminder_channel === 'email'); } catch (_) { _syncEmailReminderBellVisibility(false); } } function _readCssPx(name) { const v = getComputedStyle(document.documentElement).getPropertyValue(name); const n = parseFloat(v); return Number.isFinite(n) ? n : 0; } function _emailSplitLeftEdge() { return _readCssPx('--icon-rail-w') + _readCssPx('--sidebar-w'); } function _setEmailDocumentSplit(leftEdge, emailWidth) { if (window.innerWidth <= 768) return; // Zero gap so the doc-pane sits flush against the email's right edge. // modalSnap.js's left-dock path publishes the same vars with 0 gap — both // systems agree on flush so handoffs between them don't cause the doc to // "jump" sideways. The 1px modal border on each side is the visual seam. const splitGap = 0; const left = Math.max(0, Math.round(leftEdge || 0)); const width = Math.max(320, Math.round(emailWidth || 420)); const x = left + width + splitGap; document.body.classList.add('email-doc-split-active'); document.documentElement.style.setProperty('--email-doc-split-left-x', `${left}px`); document.documentElement.style.setProperty('--email-doc-split-email-w', `${width}px`); document.documentElement.style.setProperty('--email-doc-split-right-x', `${x}px`); } function _measureEmailDocumentSplit(modal) { if (window.innerWidth <= 768 || !document.body.classList.contains('email-doc-split-active')) return; const content = modal?.querySelector?.('.modal-content'); const rect = content?.getBoundingClientRect?.(); if (!rect || !rect.width) return; const splitGap = 0; document.documentElement.style.setProperty('--email-doc-split-right-x', `${Math.ceil(rect.right + splitGap)}px`); try { modal.style.setProperty('z-index', '150', 'important'); if (content) { content.style.setProperty('position', 'absolute', 'important'); content.style.setProperty('left', '0px', 'important'); content.style.setProperty('right', 'auto', 'important'); content.style.setProperty('width', `${Math.ceil(rect.width)}px`, 'important'); content.style.setProperty('max-width', `${Math.ceil(rect.width)}px`, 'important'); } const docPane = document.getElementById('doc-editor-pane'); if (docPane) { docPane.style.setProperty('position', 'fixed', 'important'); docPane.style.setProperty('left', `${Math.ceil(rect.right + splitGap)}px`, 'important'); docPane.style.setProperty('right', '0px', 'important'); docPane.style.setProperty('top', '0px', 'important'); docPane.style.setProperty('bottom', '0px', 'important'); docPane.style.setProperty('width', 'auto', 'important'); docPane.style.setProperty('max-width', 'none', 'important'); docPane.style.setProperty('height', '100vh', 'important'); docPane.style.setProperty('z-index', '260', 'important'); } } catch (_) {} } function _scheduleEmailDocumentSplitMeasure(modal) { requestAnimationFrame(() => { _measureEmailDocumentSplit(modal); requestAnimationFrame(() => _measureEmailDocumentSplit(modal)); }); setTimeout(() => _measureEmailDocumentSplit(modal), 260); setTimeout(() => _measureEmailDocumentSplit(modal), 700); } function _clearEmailDocumentSplit() { document.body.classList.remove('email-doc-split-active'); document.documentElement.style.removeProperty('--email-doc-split-left-x'); document.documentElement.style.removeProperty('--email-doc-split-email-w'); document.documentElement.style.removeProperty('--email-doc-split-right-x'); const docPane = document.getElementById('doc-editor-pane'); if (!docPane) return; [ 'position', 'left', 'right', 'top', 'bottom', 'width', 'max-width', 'height', 'z-index', 'transform', ].forEach(prop => docPane.style.removeProperty(prop)); } function _hasDesktopRoomForEmailAndDocument(modal) { if (window.innerWidth <= 768) return false; if (window.innerWidth >= 1100) return true; const content = modal?.querySelector?.('.modal-content'); const rect = content?.getBoundingClientRect?.(); const isFullscreen = modal?.classList?.contains('email-lib-fullscreen') || modal?.classList?.contains('email-window-fullscreen'); const emailWidth = isFullscreen ? Math.min(440, Math.max(360, Math.round(window.innerWidth * 0.30))) : Math.max(360, Math.round(rect?.width || 440)); const docMinWidth = 560; const breathingRoom = 72; const leftEdge = isFullscreen ? _emailSplitLeftEdge() : Math.max(0, Math.round(rect?.left || _emailSplitLeftEdge())); return (window.innerWidth - leftEdge - emailWidth) >= (docMinWidth + breathingRoom); } function _prepareEmailWindowForDocument(modal) { if (window.innerWidth <= 768) return true; if (!modal) return false; if (!_hasDesktopRoomForEmailAndDocument(modal)) { _clearEmailDocumentSplit(); return true; } if (modal.classList.contains('modal-left-docked')) { const content = modal.querySelector('.modal-content'); const rect = content?.getBoundingClientRect?.(); if (content?._leftDockNavObs) { try { content._leftDockNavObs.navObs.disconnect(); } catch (_) {} try { content._leftDockNavObs.bodyObs && content._leftDockNavObs.bodyObs.disconnect(); } catch (_) {} try { content._leftDockNavObs.disconnectDocObs && content._leftDockNavObs.disconnectDocObs(); } catch (_) {} try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {} delete content._leftDockNavObs; } modal.classList.remove('modal-left-docked'); modal.classList.add('email-snap-left'); document.body.classList.remove('left-dock-active'); document.documentElement.style.removeProperty('--left-dock-w'); if (content) { delete content._dockSide; content.style.position = 'fixed'; content.style.left = Math.round(rect?.left || _emailSplitLeftEdge()) + 'px'; content.style.top = '0'; content.style.right = 'auto'; content.style.bottom = '0'; content.style.width = Math.round(rect?.width || 440) + 'px'; content.style.maxWidth = Math.round(rect?.width || 440) + 'px'; content.style.height = '100vh'; content.style.maxHeight = '100vh'; content.style.borderRadius = '0'; content.style.transform = 'none'; content.style.margin = '0'; } } if (modal.classList.contains('email-snap-left') || modal.classList.contains('modal-left-docked')) { const rect = modal.querySelector('.modal-content')?.getBoundingClientRect?.(); _setEmailDocumentSplit(rect?.left || _emailSplitLeftEdge(), rect?.width || 420); _scheduleEmailDocumentSplitMeasure(modal); return false; } // If Email is fullscreen and there is room, park it left instead of // minimizing so the document/compose pane can open beside it. _snapEmailModalToLeftSidebar(modal); return false; } function _wireUnreadTabClick() { if (_emailUnreadChipClickWired) return; _emailUnreadChipClickWired = true; document.addEventListener('click', (e) => { const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id="email-lib-modal"][data-email-unread-label]'); if (!chip || e.target?.classList?.contains('minimized-dock-x')) return; setTimeout(_toggleUnreadEmails, 0); }); } async function _deleteEmailAndAdvance(em, card, opts = {}) { if (!em || em.uid == null) return; if (opts.confirm !== false) { const subject = em.subject || '(no subject)'; const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true }); if (!ok) return; } const wasExpanded = !!card?.classList?.contains('doclib-card-expanded'); const sibling = wasExpanded ? (_findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1)) : null; const nextUid = sibling ? sibling.dataset.uid : null; try { await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); } catch (err) { console.error('Failed to delete email:', err); showToast('Failed to delete email'); return; } await _animateEmailCardRemoval([em.uid]); state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); state._selectedUids.delete(em.uid); _updateBulkBar(); _renderGrid(); _libCacheWriteBack(); showToast('Moved to Trash'); if (!wasExpanded || !nextUid) return; const grid = document.getElementById('email-lib-grid'); const nextCard = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(nextUid))}"]`); const nextEm = state._libEmails.find(e => String(e.uid) === String(nextUid)); if (nextCard && nextEm) { await _toggleCardPreview(nextCard, nextEm); nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } else { document.getElementById('email-lib-modal')?.classList.remove('email-reading'); } } function _animateEmailCardRemoval(uids, opts = {}) { const uidSet = new Set((uids || []).map(uid => String(uid))); if (!uidSet.size) return Promise.resolve(); const grid = document.getElementById('email-lib-grid'); if (!grid) return Promise.resolve(); const cards = Array.from(grid.querySelectorAll('.doclib-card[data-uid]')) .filter(card => uidSet.has(String(card.dataset.uid))); if (!cards.length) return Promise.resolve(); const duration = Number(opts.duration || 230); for (const card of cards) { const rect = card.getBoundingClientRect(); card.style.setProperty('--email-remove-h', `${Math.max(rect.height, card.scrollHeight)}px`); card.style.maxHeight = 'var(--email-remove-h)'; card.style.overflow = 'hidden'; card.classList.add('email-card-removing'); } return new Promise(resolve => { window.setTimeout(resolve, duration + 35); }); } // URL-suffix helper — appends &account_id=... when an account is actively selected. // Every email route call in this file goes through here so switching accounts // is a single-variable flip. // Open the Settings modal and activate a specific tab. Used by empty-state // "Set up at: Settings › X" links across email/calendar/etc. function _openSettingsTab(tab) { if (tab === 'integrations' && window.adminModule && typeof window.adminModule.open === 'function') { window.adminModule.open('integrations'); return; } if (settingsModule && typeof settingsModule.open === 'function') { settingsModule.open(tab || 'services'); return; } const modal = document.getElementById('settings-modal'); if (!modal) return; modal.classList.remove('hidden'); const tabBtn = modal.querySelector(`[data-settings-tab="${tab || 'services'}"]`); if (tabBtn) tabBtn.click(); } function _emailSetupHintHtml() { return '
// from making the signature appear as a phantom prior email. if (!meta && _looksLikeSignature(innerHtml)) { turnsHtml.push( '' + _foldSummary('Signature', _SIG_ICON) + `' ); attribution = null; continue; } // Recursively render the inside of this blockquote (which may contain // its own nested blockquotes representing earlier replies). const nested = _renderThreadStructure(innerHtml); const bodyHtml = nested || innerHtml; const isLast = i === tops.length - 1; turnsHtml.push( `${innerHtml}` + '` + _foldSummary('Earlier reply', _QUOTE_ICON, meta || '') + `' ); // Only the first turn uses the harvested attribution; deeper turns // get their own from inside the blockquote. attribution = null; } return head.innerHTML + turnsHtml.join(''); } // Looks like a signature / corporate disclaimer rather than a quoted email. // Used to demote attribution-less blockquotes that some senders wrap their // sig+disclaimer in (Outlook, EY, big firms) from "Earlier reply" to a // proper Signature fold. Conservative — only fires when there's no quoted // reply markers AND it matches strong corporate-noise phrases. // _looksLikeSignature / _harvestAttribution / _extractTurnMetaFromBlockquote // live in ./emailLibrary/signatureFold.js /** * Wrap any quoted reply chain in a collapsed${bodyHtml}` + 'so deep email threads * don't dominate the reader. Detects: * -tags (Gmail / native quoted replies) * - Outlook-style "From: ... Sent: ... To: ... Subject: ..." headers * Each gets its own "Earlier thread" toggle. */ /** * Parse a plaintext email body into stacked turn-cards by walking * `> ` quote-prefix levels and Outlook-style "On X wrote:" / Original-Message * boundaries. Returns rendered HTML, or null when there's no quoted content * (caller falls back to flat rendering). * * Mirrors talon's `extract_from_plain` and email-reply-parser fragments: * 1. Lines starting with one or more `>` chars are quoted (level = count of >). * 2. Increasing the level opens a deeper turn (nested reply). * 3. `-----Original Message-----` and `On, wrote:` start a * new turn even without `>`. * 4. The leading non-quoted segment is the current message. */ function _renderPlaintextThread(text) { if (!text || typeof text !== 'string' || text.length > 200000) return null; const lines = text.split(/\r?\n/); const levels = lines.map(l => { const m = l.match(/^((?:>\s?)+)/); return m ? (m[1].match(/>/g) || []).length : 0; }); const hasQuotes = levels.some(l => l > 0); const attribLineRe = new RegExp(`(?:^|\\n)\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'im'); const hasAttrib = attribLineRe.test(text) || _TALON_ORIG_RE.test(text); if (!hasQuotes && !hasAttrib) return null; const turns = []; let buf = []; let curLevel = 0; let pendingMeta = null; const flush = () => { if (!buf.length) return; const t = buf.join('\n').trimEnd(); if (t || curLevel > 0) turns.push({ level: curLevel, text: t, meta: pendingMeta }); buf = []; pendingMeta = null; }; for (let i = 0; i < lines.length; i++) { const lvl = levels[i]; const raw = lines[i]; const stripped = lvl > 0 ? raw.replace(/^(?:>\s?)+/, '') : raw; const isSeparatorLine = lvl === 0 && /^-{5,}\s*Previous message\s*-{5,}$/i.test(raw.trim()); const isAttribLine = lvl === 0 && (new RegExp(`^\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'i').test(raw) || _TALON_ORIG_RE.test('\n' + raw)); if (isSeparatorLine || isAttribLine) { flush(); pendingMeta = isSeparatorLine ? null : (_extractQuoteMeta(raw) || raw.trim()); curLevel = 1; continue; } if (lvl !== curLevel) { flush(); curLevel = lvl; } buf.push(stripped); } flush(); if (!turns.length || (turns.length === 1 && turns[0].level === 0)) return null; const fmt = s => _escLinkify(s).replace(/\n/g, '
'); let out = ''; const stack = []; const wrapTurn = (t) => `` + _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '') + `'; for (const t of turns) { if (t.level === 0) { while (stack.length) { const top = stack.pop(); const wrapped = wrapTurn(top); if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped; } out += fmt(t.text); } else { while (stack.length && stack[stack.length - 1].level > t.level) { const top = stack.pop(); const wrapped = wrapTurn(top); if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped; } if (!stack.length || stack[stack.length - 1].level < t.level) { stack.push({ level: t.level, meta: t.meta, html: fmt(t.text) }); } else { stack[stack.length - 1].html += '${t.html}` + '
' + fmt(t.text); if (t.meta && !stack[stack.length - 1].meta) stack[stack.length - 1].meta = t.meta; } } } while (stack.length) { const top = stack.pop(); const wrapped = wrapTurn(top); if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped; } const lastIdx = out.lastIndexOf('= 0) { out = out.slice(0, lastIdx) + out.slice(lastIdx).replace( 'email-thread-turn email-quote-fold"', 'email-thread-turn email-quote-fold last-fold"' ); } return out; } // _foldSummary / _extractQuoteMeta / _SIG_ICON / _QUOTE_ICON // live in ./emailLibrary/signatureFold.js function _foldQuotedReplies(html) { if (!html || typeof html !== 'string') return html; if (html.length > 200000) return html; const before = html; // Use DOMParser for proper nested-blockquote handling. Regex against HTML // mishandles nesting and leaves orphan close tags that the browser // re-balances, producing two visually inconsistent fold styles. try { const doc = new DOMParser().parseFromString(`${html}`, 'text/html'); const root = doc.getElementById('__r'); if (root) { // Only fold TOP-LEVEL blockquotes (children of the root that are not // already inside another blockquote). The inner blockquote chain stays // intact inside the fold and renders with the existing // .email-quote-fold blockquote styles, so everything matches. const tops = Array.from(root.querySelectorAll('blockquote')).filter(b => !b.parentElement.closest('blockquote') ); if (tops.length) { for (const bq of tops) { const det = doc.createElement('details'); det.className = 'email-quote-fold'; // Build the summary as raw HTML — easier than building DOM by hand. const summary = _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(bq.innerHTML)); det.innerHTML = summary; bq.parentNode.insertBefore(det, bq); det.appendChild(bq); // move the original blockquote (and any nested ones) into the details } // Tag only the last fold so CSS can give it rounded bottom corners. const allFolds = root.querySelectorAll('.email-quote-fold'); if (allFolds.length) allFolds[allFolds.length - 1].classList.add('last-fold'); return root.innerHTML; } } } catch (e) { // Fall through to the legacy regex path below if DOMParser fails } // If DOM-pass already wrapped something, we returned above. Otherwise no // blockquotes were found — try the Outlook-header heuristic. if (html !== before) return html; // Outlook-style quoted-reply header — multilingual. Fold from the first // "From: ... Sent: ... Subject: ..." block through end-of-body so all // prior thread levels collapse together. const FROM = '(?:From|Från|Von|De|De\\s|Da|От|Od|Van)'; const SENT = '(?:Sent|Skickat|Gesendet|Envoyé|Inviato|Enviado|Verzonden|Отправлено|Wysłane)'; const SUBJ = '(?:Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat)'; const outlookRe = new RegExp( `(
|||]*>|
]*>|\\n)\\s*((?:<[^>]+>\\s*)*${FROM}\\s*:\\s*[^<\\n]+(?:<[^>]+>\\s*|\\s)*${SENT}\\s*:[\\s\\S]+?${SUBJ}\\s*:[\\s\\S]+)$`, 'i' ); const m = html.match(outlookRe); if (m) { const idx = html.lastIndexOf(m[0]); // Outlook fallback only ever produces ONE fold, so tag it as last. html = html.slice(0, idx) + m[1] + '' + _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2])) + m[2] + ''; } return html; } // Global preference: AI summary panels stay collapsed across every email // once the user folds one, and stay expanded once they unfold. Stored in // localStorage so the choice survives reloads. const _SUMMARY_COLLAPSED_KEY = 'odysseus.email.summaryCollapsed'; function _summaryCollapsedPref() { try { return localStorage.getItem(_SUMMARY_COLLAPSED_KEY) === '1'; } catch { return false; } } function _setSummaryCollapsedPref(v) { try { localStorage.setItem(_SUMMARY_COLLAPSED_KEY, v ? '1' : '0'); } catch {} } function _showCachedSummary(reader, summary, btn) { const body = reader.querySelector('.email-reader-body'); if (!body) return; if (body.querySelector('.email-summary-panel')) return; const panel = document.createElement('div'); panel.className = 'email-summary-panel'; if (_summaryCollapsedPref()) panel.classList.add('collapsed'); panel.innerHTML = '' + '' + 'Summary' + '' + '' + ''; panel.querySelector('.email-summary-content').textContent = summary; body.insertBefore(panel, body.firstChild); const toggle = panel.querySelector('.email-summary-toggle'); // Header click folds/unfolds. Persists so the next email opens in the // same state. const _flip = () => { panel.classList.toggle('collapsed'); _setSummaryCollapsedPref(panel.classList.contains('collapsed')); }; if (toggle) { toggle.addEventListener('click', (ev) => { ev.stopPropagation(); _flip(); }); toggle.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _flip(); } }); } if (btn) { btn.classList.add('active'); const label = btn.querySelector('.btn-label'); if (label) label.textContent = 'Summary'; } } // "Other from this sender" — slide-out panel inside the reader listing // recent emails from the same address. Click an item to load it in place. async function _toggleFromSenderPanel(reader, data, btn) { const body = reader.querySelector('.email-reader-body'); if (!body) return; // Recenter the modal after its size changes (CSS widens + heightens the // modal-content when the from-sender panel is mounted/unmounted). Without // this the modal grows only to the right/down and can overflow the // viewport on narrow / short windows. const _recenterModal = () => { const modal = document.getElementById('email-lib-modal'); const content = modal?.querySelector('.modal-content'); if (!content) return; requestAnimationFrame(() => { const w = content.offsetWidth; const h = content.offsetHeight; const newLeft = Math.max(20, (window.innerWidth - w) / 2); const newTop = Math.max(20, (window.innerHeight - h) / 2); content.style.left = newLeft + 'px'; content.style.top = newTop + 'px'; }); }; // Already open? Close it. const existing = reader.querySelector('.from-sender-panel'); if (existing) { existing.remove(); reader.classList.remove('from-sender-open'); if (btn) btn.classList.remove('active'); _recenterModal(); return; } const fromAddr = String(data.from_address || '').trim(); if (!fromAddr) { if (typeof showError === 'function') showError('No sender address available'); return; } const panel = document.createElement('div'); panel.className = 'from-sender-panel'; const displayName = (data.from_name && data.from_name.trim()) || fromAddr; const firstName = displayName.split(' ')[0] || displayName; panel.innerHTML = `All senders`; reader.appendChild(panel); reader.classList.add('from-sender-open'); if (btn) btn.classList.add('active'); _recenterModal(); // Header close — same as the toolbar funnel button so the close path // stays single-sourced (panel removal + active class drop). const headerClose = panel.querySelector('.from-sender-close'); if (headerClose) { headerClose.addEventListener('click', (ev) => { ev.stopPropagation(); const toolbarBtn = reader.querySelector('[data-act="from-sender"]'); if (toolbarBtn) toolbarBtn.click(); else { panel.remove(); reader.classList.remove('from-sender-open'); } }); } const listEl = panel.querySelector('.from-sender-list'); // Hoisted so panel._originalEmails (assigned later, outside the try) can see it. let emails = []; // Multi-tag model — the header is now a list of {name, address} chips. // Filter logic: an email matches when EVERY tag's address appears in // from/to/cc (case-insensitive substring on the joined header strings). panel._tags = [{ name: displayName, address: fromAddr }]; panel._attachmentsOnly = false; const searchEl = panel.querySelector('.from-sender-search'); const chipsContainer = panel.querySelector('.from-sender-chips'); const emptyLabel = panel.querySelector('.from-sender-header-empty'); const suggestEl = panel.querySelector('.from-sender-suggest'); const attToggle = panel.querySelector('[data-toggle="attachments"]'); const _renderChips = () => { chipsContainer.innerHTML = panel._tags.map((t, i) => ` ${_esc(t.name || t.address)} `).join(''); if (emptyLabel) emptyLabel.hidden = panel._tags.length > 0; chipsContainer.querySelectorAll('.from-sender-chip-x').forEach(btn => { btn.addEventListener('click', (ev) => { ev.stopPropagation(); const idx = Number(btn.closest('.from-sender-chip')?.dataset.tagIndex || -1); if (idx < 0) return; panel._tags.splice(idx, 1); _renderChips(); _refreshList(); }); }); }; // Filter loaded emails (or recents) by every active tag. const _matchesTags = (em) => { if (!panel._tags.length) return true; const haystack = [ String(em.from_address || ''), String(em.to || ''), String(em.cc || ''), ].join(' ').toLowerCase(); return panel._tags.every(t => haystack.includes(String(t.address || '').toLowerCase())); }; const _applyToggles = () => { const base = panel._lastResults || []; let view = base.filter(_matchesTags); if (panel._attachmentsOnly) view = view.filter(e => e.has_attachments); if (!view.length) { const why = panel._attachmentsOnly ? 'No emails with attachments in this view.' : (panel._tags.length > 1 ? 'No emails involve all those people.' : 'No matches.'); listEl.innerHTML = `${why}`; } else { _renderFromSenderRows(view, listEl, reader, { showFolder: !!panel._lastShowFolder }); } }; panel._setResults = (rows, opts = {}) => { panel._lastResults = rows || []; panel._lastShowFolder = !!opts.showFolder; _applyToggles(); }; // Re-runs the appropriate fetch path for the current tag set / query. // Declared early so chip-removal handlers above can call it. let _refreshList = () => {}; if (attToggle) { attToggle.addEventListener('click', (ev) => { ev.stopPropagation(); panel._attachmentsOnly = !panel._attachmentsOnly; attToggle.classList.toggle('is-active', panel._attachmentsOnly); attToggle.setAttribute('aria-pressed', panel._attachmentsOnly ? 'true' : 'false'); _applyToggles(); }); } try { const sp = spinnerModule.createWhirlpool(20); const loading = panel.querySelector('.from-sender-loading'); loading.appendChild(sp.element); const params = new URLSearchParams({ q: fromAddr, folder: state._libFolder || 'INBOX', limit: '25', }); const acct = _acct(); const acctSuffix = acct ? acct.replace(/^&?/, '&') : ''; const res = await fetch(`${API_BASE}/api/email/search?${params.toString()}${acctSuffix}`); const j = await res.json(); let raw = Array.isArray(j.emails) ? j.emails : []; const target = fromAddr.toLowerCase(); raw = raw.filter(e => String(e.from_address || '').toLowerCase() === target); raw = raw.filter(e => String(e.uid) !== String(data.uid)); emails = raw; if (!emails.length) { listEl.innerHTML = `No other emails from this sender in ${_esc(state._libFolder || 'INBOX')}.`; } else { panel._setResults(emails, { showFolder: false }); } } catch (err) { listEl.innerHTML = `Failed to load: ${_esc(String(err))}`; } const updatePlaceholder = () => { if (!searchEl) return; searchEl.placeholder = panel._tags.length ? 'Add another person…' : 'Search people or emails…'; }; updatePlaceholder(); _renderChips(); // Used both when chips change AND when the user clears their query. // Pulls the most-recent emails across the common folders so the user // lands on something useful, then _applyToggles narrows by tags. let _recentToken = 0; const _loadRecentAcross = async () => { const myToken = ++_recentToken; const folders = _crossFolderCandidates(); const acct = _acct(); const acctSuffix = acct ? acct.replace(/^&?/, '&') : ''; listEl.innerHTML = ``; try { const sp = spinnerModule.createWhirlpool(18); listEl.querySelector('.from-sender-loading')?.appendChild(sp.element); const results = await Promise.all(folders.map(async (f) => { const params = new URLSearchParams({ folder: f, limit: '40', offset: '0', filter: 'all' }); const res = await fetch(`${API_BASE}/api/email/list?${params.toString()}${acctSuffix}`); const j = await res.json(); return (j.emails || []).map(em => ({ ...em, _folder: f })); })); if (myToken !== _recentToken) return; let merged = [].concat(...results); merged.sort((a, b) => { const da = a.date ? Date.parse(a.date) : 0; const db = b.date ? Date.parse(b.date) : 0; return db - da; }); // Take a wider slice up front; tag/attachment filters trim it. merged = merged.slice(0, 80); panel._setResults(merged, { showFolder: true }); updatePlaceholder(); } catch (err) { if (myToken !== _recentToken) return; listEl.innerHTML = `Failed to load: ${_esc(String(err))}`; } }; // Adds a contact as a tag, clears input, refreshes the list. const _addTag = (contact) => { if (!contact || !contact.address) return; const addr = String(contact.address).toLowerCase(); if (panel._tags.some(t => String(t.address).toLowerCase() === addr)) return; panel._tags.push({ name: contact.name || contact.address, address: contact.address }); _renderChips(); if (searchEl) { searchEl.value = ''; } if (suggestEl) { suggestEl.hidden = true; suggestEl.innerHTML = ''; } updatePlaceholder(); _refreshList(); }; // Cross-folder search — when the user types, also honor the sender chip if // it's still active. Empty input with chip active restores the original // "from this sender" view; empty input with chip removed shows the prompt. if (searchEl) { let searchToken = 0; let debounceTimer = null; let suggestToken = 0; let highlightedIdx = -1; // Free-text email search across folders. Tag filter is applied via // _applyToggles inside panel._setResults. const runSearch = async (q) => { const myToken = ++searchToken; const folders = _crossFolderCandidates(); const acct = _acct(); const acctSuffix = acct ? acct.replace(/^&?/, '&') : ''; try { const results = await Promise.all(folders.map(async (f) => { const params = new URLSearchParams({ q, folder: f, limit: '15' }); const res = await fetch(`${API_BASE}/api/email/search?${params.toString()}${acctSuffix}`); const j = await res.json(); return (j.emails || []).map(em => ({ ...em, _folder: f })); })); if (myToken !== searchToken) return; let merged = [].concat(...results); merged.sort((a, b) => { const da = a.date ? Date.parse(a.date) : 0; const db = b.date ? Date.parse(b.date) : 0; return db - da; }); if (!merged.length) { listEl.innerHTML = `No matches for "${_esc(q)}".`; return; } panel._setResults(merged, { showFolder: true }); } catch (err) { if (myToken !== searchToken) return; listEl.innerHTML = `Search failed: ${_esc(String(err))}`; } }; // Hook up _refreshList so chip removal / tag add can rerun whichever // path matches the current input state. _refreshList = () => { const q = (searchEl.value || '').trim(); if (q.length >= 2) runSearch(q); else _loadRecentAcross(); }; // Contact suggestions — fetched from /api/email/contacts. Renders a // small absolutely-positioned dropdown under the input. Up/Down/Enter/ // Esc handled in the keydown listener below. const _renderSuggestions = (items) => { if (!suggestEl) return; if (!items || !items.length) { suggestEl.hidden = true; suggestEl.innerHTML = ''; highlightedIdx = -1; return; } highlightedIdx = 0; suggestEl.innerHTML = items.map((c, i) => `${_esc(c.name || c.address)} ${_esc(c.address)}`).join(''); suggestEl.hidden = false; suggestEl.querySelectorAll('.from-sender-suggest-item').forEach(item => { item.addEventListener('mouseenter', () => { suggestEl.querySelectorAll('.from-sender-suggest-item').forEach(n => n.classList.remove('active')); item.classList.add('active'); highlightedIdx = Number(item.dataset.idx); }); item.addEventListener('mousedown', (ev) => { // mousedown so we add the chip BEFORE blur takes the focus away ev.preventDefault(); _addTag({ name: item.dataset.name, address: item.dataset.addr }); }); }); }; const _fetchSuggestions = async (q) => { const myToken = ++suggestToken; try { // Use the same contact source as the email composer's To/Cc fields // (/api/contacts/search → {results: [{name, emails:[...]}]}). Flatten // to {name, address} pairs and drop any already-tagged address. const res = await fetch(`${API_BASE}/api/contacts/search?q=${encodeURIComponent(q)}`); const j = await res.json(); if (myToken !== suggestToken) return; const tagged = new Set(panel._tags.map(t => String(t.address).toLowerCase())); const items = []; for (const c of (j.results || [])) { for (const addr of (c.emails || [])) { if (tagged.has(String(addr).toLowerCase())) continue; items.push({ name: c.name || addr, address: addr }); if (items.length >= 8) break; } if (items.length >= 8) break; } _renderSuggestions(items); } catch {} }; searchEl.addEventListener('input', () => { clearTimeout(debounceTimer); const q = searchEl.value.trim(); if (q.length < 2) { searchToken++; suggestToken++; if (suggestEl) { suggestEl.hidden = true; suggestEl.innerHTML = ''; } _loadRecentAcross(); return; } // Fire suggestions immediately (cheap SQL) and defer the email search. _fetchSuggestions(q); debounceTimer = setTimeout(() => runSearch(q), 220); }); searchEl.addEventListener('keydown', (ev) => { const items = suggestEl && !suggestEl.hidden ? [...suggestEl.querySelectorAll('.from-sender-suggest-item')] : []; if (ev.key === 'ArrowDown' && items.length) { ev.preventDefault(); highlightedIdx = (highlightedIdx + 1) % items.length; items.forEach((n, i) => n.classList.toggle('active', i === highlightedIdx)); } else if (ev.key === 'ArrowUp' && items.length) { ev.preventDefault(); highlightedIdx = (highlightedIdx - 1 + items.length) % items.length; items.forEach((n, i) => n.classList.toggle('active', i === highlightedIdx)); } else if (ev.key === 'Enter') { if (items.length && highlightedIdx >= 0) { ev.preventDefault(); const item = items[highlightedIdx]; _addTag({ name: item.dataset.name, address: item.dataset.addr }); } } else if (ev.key === 'Escape') { if (suggestEl && !suggestEl.hidden) { ev.preventDefault(); suggestEl.hidden = true; } } else if (ev.key === 'Backspace' && searchEl.value === '' && panel._tags.length) { // Empty input + Backspace pops the rightmost chip — common chip-input idiom. ev.preventDefault(); panel._tags.pop(); _renderChips(); _refreshList(); } }); searchEl.addEventListener('blur', () => { // Hide suggestions on blur, with a tiny delay so click-on-suggestion // gets a chance to fire (mousedown-add covers most cases anyway). setTimeout(() => { if (suggestEl) suggestEl.hidden = true; }, 120); }); } // Stash the sender's emails for restoring after a search is cleared. panel._originalEmails = (typeof emails !== 'undefined') ? emails : []; } const _ATT_ICON = ''; function _renderFromSenderRows(emails, listEl, reader, opts = {}) { const { showFolder = false } = opts; listEl.innerHTML = emails.map(em => { const subj = em.subject || '(no subject)'; const date = em.date ? new Date(em.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : (em.date_display || ''); const unread = em.is_read ? '' : ' from-sender-unread'; const att = em.has_attachments ? _ATT_ICON : ''; const folder = em._folder || state._libFolder || 'INBOX'; const folderChip = showFolder ? `${_esc(folder)}` : ''; return ``; }).join(''); listEl.querySelectorAll('.from-sender-row').forEach(row => { const main = row.querySelector('.from-sender-row-main'); const more = row.querySelector('.from-sender-row-more'); main?.addEventListener('click', async () => { const uid = row.dataset.uid; const folder = row.dataset.folder || state._libFolder; if (!uid) return; await _swapReaderToUid(reader, uid, folder); }); more?.addEventListener('click', async (ev) => { ev.stopPropagation(); const uid = row.dataset.uid; const folder = row.dataset.folder || state._libFolder; if (!uid) return; // Look up the row's email in any cache we know about; the menu just // needs uid + subject + folder for its actions. const em = (typeof emails !== 'undefined' ? emails : []).find(e => String(e.uid) === String(uid)) || state._libEmails.find(e => String(e.uid) === String(uid)) || { uid, subject: row.querySelector('.from-sender-subj')?.textContent || '' }; const card = reader.closest('.doclib-card'); if (card) _showReaderMoreMenu(em, card, reader, more); }); }); } // Wire click handlers for attachment chips + "open in editor" sub-buttons // inside a reader. Safe to call multiple times — uses dataset.wired flag to // skip nodes that already have listeners. function _wireAttachmentHandlers(reader, folder) { const useFolder = folder || state._libFolder; // Detect mobile here so the attachment-chip handler doesn't blow up with // a ReferenceError when this fn is called from contexts that don't have // _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow). const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); reader.querySelectorAll('.email-attachment-open').forEach(openBtn => { if (openBtn.dataset.wired === '1') return; openBtn.dataset.wired = '1'; openBtn.addEventListener('click', async (ev) => { ev.stopPropagation(); ev.preventDefault(); const uid = openBtn.dataset.openUid; const index = openBtn.dataset.openIndex; const name = openBtn.dataset.openName || `attachment-${index}`; if (!uid || index == null) return; const orig = openBtn.style.opacity; openBtn.style.opacity = '0.4'; try { const folderQs = encodeURIComponent(useFolder); const res = await fetch( `${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`, { method: 'POST', credentials: 'same-origin' } ); const json = await res.json().catch(() => ({})); if (!res.ok || !json.doc_id) { const msg = (json && json.error) || `HTTP ${res.status}`; try { const { showError } = await import('./ui.js'); showError(`Couldn't open ${name}: ${msg}`); } catch (_) { alert(`Couldn't open ${name}: ${msg}`); } return; } try { // Tab the email modal down only when the viewport cannot fit both // Email and the document pane. Desktop keeps a side-by-side layout // when there is room; mobile still gives the document the screen. const ownerModal = openBtn.closest('.modal'); if (ownerModal && ownerModal.id && _prepareEmailWindowForDocument(ownerModal)) { try { const ok = Modals.minimize(ownerModal.id); if (!ok) ownerModal.classList.add('hidden'); } catch (_) { ownerModal.classList.add('hidden'); } } const docMod = await import('./document.js'); const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument); if (typeof load === 'function') { await load(json.doc_id); } else { location.href = `/?doc=${encodeURIComponent(json.doc_id)}`; } } catch (e) { console.error('Open document failed:', e); try { const { showError } = await import('./ui.js'); showError('Document opened but panel could not mount'); } catch (_) {} } } catch (e) { console.error('attachment-as-doc error', e); try { const { showError } = await import('./ui.js'); showError(`Couldn't open ${name}`); } catch (_) {} } finally { openBtn.style.opacity = orig; } }); }); reader.querySelectorAll('.email-attachment-chip').forEach(chip => { if (chip.dataset.wired === '1') return; chip.dataset.wired = '1'; chip.addEventListener('click', async (ev) => { if (ev.target.closest('.email-attachment-open')) return; ev.stopPropagation(); ev.preventDefault(); const uid = chip.dataset.attUid; const index = chip.dataset.attIndex; const name = chip.dataset.attName || `attachment-${index}`; if (!uid || index == null) return; const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(useFolder)}${_acct()}`; if (_isMobileUA) { window.open(url, '_blank'); return; } const orig = chip.style.opacity; chip.style.opacity = '0.6'; try { const res = await fetch(url, { credentials: 'same-origin' }); if (!res.ok) { console.error('attachment download failed', res.status, await res.text().catch(() => '')); location.href = url; return; } const blob = await res.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); } catch (e) { console.error('attachment download error', e); location.href = url; } finally { chip.style.opacity = orig; } }); }); } // Heuristic: skip "attachments" that are clearly inline images used by // signatures / quoted-reply headers (small image files, Outlook-style // image001.png placeholders, logo*.png, etc.). They aren't real user- // shared attachments and adding them to the chips makes every email look // like it has content the user needs to act on. function _isLikelySignatureImage(a) { if (!a || !a.filename) return false; const name = String(a.filename).toLowerCase(); const isImage = /\.(png|jpe?g|gif|bmp|svg|webp)$/i.test(name); if (!isImage) return false; const size = Number(a.size) || 0; // Outlook / Gmail inline image placeholders always look like this. if (/^image\d{3,}\.(png|jpe?g|gif)$/i.test(name)) return true; if (/^(signature|logo|sig|footer|banner)[-_\d]*\.(png|jpe?g|gif|svg)$/i.test(name)) return true; // Most signature logos / inline thumbnails are < 30 KB. Real user- // shared images (screenshots, photos) are typically 50 KB+. if (size > 0 && size < 30 * 1024) return true; return false; } // Build the attachments header+chips HTML for an email read response. Pulled // out so both the initial-open and the swap-reader paths can render it. function _buildAttsHtmlFor(uid, data) { if (!data || !data.attachments || !data.attachments.length) return ''; const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown)$/i; const visible = data.attachments.filter(a => !_isLikelySignatureImage(a)); if (!visible.length) return ''; const chips = visible.map(a => { const openable = _OPENABLE_RE.test(a.filename || ''); const openBtn = openable ? `` : ''; return ``; }).join(''); return ( '' + '' ); } // "Open in new tab" — the email opens in the library (expanded inline) // AND a separate floating "email viewer" overlay modal is created. The // overlay starts minimized as a chip in the dock; tapping the chip // brings the viewer up over the library. Multiple tabs = multiple // overlay modals + chips, each independent. const _EMAIL_ICON_PATH = 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7'; let _emailTabSeq = 0; // Persistent slot numbers per reader modalId. Once a reader is "tab 2" // it stays "tab 2" until it's closed — even if tab 1 closes first, the // remaining reader doesn't renumber down to 1. New tabs claim the // lowest unused slot. const _emailReaderSlots = new Map(); // modalId -> slot (1, 2, 3, ...) function _allocReaderSlot(modalId) { if (_emailReaderSlots.has(modalId)) return _emailReaderSlots.get(modalId); const used = new Set(_emailReaderSlots.values()); let n = 1; while (used.has(n)) n++; _emailReaderSlots.set(modalId, n); return n; } function _freeReaderSlot(modalId) { _emailReaderSlots.delete(modalId); } // JS-driven gate: sets [data-email-tabs="N"] on so CSS can show // the per-chip number badge only when 2+ tabs exist. function _syncEmailTabsCount() { const tabs = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-view-"]'); document.body.dataset.emailTabs = String(tabs.length); } // Recompute the email menu chip's tab-count whenever the dock contents // change. Counts "email-view-*" chips both inside #minimized-dock and // at body level (free-positioned chips on mobile). Result is written to // the email-lib-modal chip's data-tab-count attribute; CSS reads it via // attr() to render the badge. function _syncEmailTabBadge() { const readers = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-reader-"]'); document.body.dataset.emailReaders = String(readers.length); // Stamp each chip with its persistent slot number. CSS reads // data-tab-num via attr() instead of using a counter so the number // stays stable when other tabs close. readers.forEach(chip => { const slot = _emailReaderSlots.get(chip.dataset.modalId); if (slot) chip.dataset.tabNum = String(slot); }); } let _emailTabObserverWired = false; let _badgeSyncScheduled = false; function _ensureEmailTabObserver() { if (_emailTabObserverWired) return; _emailTabObserverWired = true; // Debounce so a burst of mutations (e.g. _renderDock rebuilding the // whole dock in one pass) collapses to a single sync per animation // frame. Without this the chip badge could flicker as the observer // fires repeatedly during dock rerenders. const handler = () => { if (_badgeSyncScheduled) return; _badgeSyncScheduled = true; requestAnimationFrame(() => { _badgeSyncScheduled = false; _syncEmailTabBadge(); }); }; const tryWire = () => { const dock = document.getElementById('minimized-dock'); if (!dock) { setTimeout(tryWire, 200); return; } // Only watch what we care about: chip add/remove in the dock. const obs = new MutationObserver(handler); obs.observe(dock, { childList: true }); // Watch the library grid so toggling a card expanded/collapsed // updates the lib chip's "has-expanded" badge in real time. const wireGridObs = () => { const grid = document.getElementById('email-lib-grid'); if (!grid) { setTimeout(wireGridObs, 500); return; } const gridObs = new MutationObserver(handler); gridObs.observe(grid, { subtree: true, attributes: true, attributeFilter: ['class'] }); }; wireGridObs(); handler(); }; tryWire(); } // Hybrid model: // - email-lib-modal (the inbox library) is unique. Its chip just // restores it. // - Each "Open in new tab" creates a separate per-email reader modal // (id "email-reader-{uid}-{seq}") with the SAME structure & classes // as the library's inline reader, so they look identical. Each // reader registers its own dock chip with a number badge. async function _openEmailAsTab(em, folder) { const useFolder = folder || state._libFolder || 'INBOX'; _emailTabSeq += 1; const modalId = `email-reader-${em.uid}-${_emailTabSeq}`; _allocReaderSlot(modalId); // Build the modal shell. Uses the same doclib-modal-content sizing // as the email library so it feels like a sibling window. The reader // body inside uses the exact same email-card-reader / email-reader-* // classes the inline reader uses → identical styling. const modal = document.createElement('div'); modal.className = 'modal email-reader-tab-modal'; modal.id = modalId; modal.innerHTML = ` `; document.body.appendChild(modal); // Inherit display from .modal (flex-center). z-index above the library // (which uses default .modal z-index 250) so the new tab sits on top. modal.style.zIndex = '270'; // Opened last → email windows in front of any open doc (alternation flag). document.body.classList.add('email-front'); Modals.register(modalId, { label: 'Email', icon: _EMAIL_ICON_PATH, closeFn: () => { modal.remove(); _freeReaderSlot(modalId); Promise.resolve().then(_syncEmailTabBadge); }, restoreFn: () => { // Reopened last → bring the email windows in front of any open doc. document.body.classList.add('email-front'); // Mobile: only one email window visible at a time. Tapping this // chip chips down the library + any other reader, so the user // toggles between them via the dock instead of stacking. if (window.innerWidth <= 768) { try { if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) { Modals.minimize('email-lib-modal'); } } catch {} document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => { if (other.id === modalId) return; try { if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) { Modals.minimize(other.id); } } catch {} }); } }, }); // Wire the `_` minimize button via modalManager (it sees our .minimize-btn // already exists and just binds the click handler). try { Modals.injectMinimizeButton(modal, modalId); } catch {} // X button fully closes the tab (tears down and unregisters). modal.querySelector('.close-btn')?.addEventListener('click', (ev) => { ev.stopPropagation(); Modals.close(modalId); }); // Wire dragging on the header (desktop only). Matches the global pattern // in app.js initUIVisibility, but that runs once at boot and doesn't see // dynamically-created modals — so we replicate it here. const content = modal.querySelector('.modal-content'); const mh = modal.querySelector('.modal-header'); if (mh && content) { let dragX = 0, dragY = 0, startLeft = 0, startTop = 0, dragging = false; const startDrag = (clientX, clientY) => { dragging = true; const rect = content.getBoundingClientRect(); dragX = clientX; dragY = clientY; startLeft = rect.left; startTop = rect.top; content.style.position = 'fixed'; content.style.left = startLeft + 'px'; content.style.top = startTop + 'px'; content.style.margin = '0'; }; const onDrag = (e) => { if (!dragging) return; content.style.left = (startLeft + e.clientX - dragX) + 'px'; content.style.top = (startTop + e.clientY - dragY) + 'px'; }; const stopDrag = () => { dragging = false; document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', stopDrag); }; mh.addEventListener('mousedown', (e) => { if (e.target.closest('.close-btn, .minimize-btn, .modal-minimize-btn')) return; e.preventDefault(); startDrag(e.clientX, e.clientY); document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag); }); } // Open the new tab in front, on top of the email library. The user // can tap `_` to tab it down to a chip when they're done reading. // // Mobile: bottom-sheet windows fill the viewport, so stacking multiple // readers on top of each other is confusing — only one window can be // meaningfully visible at a time. So when the new tab opens, chip down // the library AND any other email-reader-* tab that's currently up. // The user gets a stack of mini chips to toggle between them. if (window.innerWidth <= 768) { try { if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) { Modals.minimize('email-lib-modal'); } } catch {} document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => { if (other.id === modalId) return; try { if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) { Modals.minimize(other.id); } } catch {} }); } _ensureEmailTabObserver(); _syncEmailTabBadge(); // Fetch + render the email body using the exact same template as // _toggleCardPreview so the visuals match perfectly. const reader = modal.querySelector('.email-card-reader'); const sp = spinnerModule.createWhirlpool(28); const loading = modal.querySelector('.email-reader-tab-loading'); if (loading) loading.appendChild(sp.element); try { const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`); const data = await res.json(); if (data.error) { reader.innerHTML = `' + '' + `Attachments (${data.attachments.length})` + '' + '' + '' + chips + '' + 'Error: ${_esc(data.error)}`; return; } _syncEmailReadState(em.uid, true); const buildChips = (str) => { if (!str) return ''; return str.split(',').map(s => s.trim()).filter(Boolean).map(a => { const name = _extractName(a); return `${_esc(name)}`; }).join(''); }; const fromChip = `${_esc(data.from_name || data.from_address)}`; let attsHtml = ''; try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {} reader.innerHTML = `${attsHtml}${_hasMultipleRecipients(data) ? `` : ''}${_safeRenderEmailBody(data)}`; try { _wireAttachmentHandlers(reader, useFolder); } catch {} const attsWrap = reader.querySelector('.email-reader-atts-wrap'); if (attsWrap) { const attsToggle = attsWrap.querySelector('.email-reader-atts-header'); if (attsToggle) attsToggle.addEventListener('click', (ev) => { ev.stopPropagation(); attsWrap.classList.toggle('collapsed'); }); } reader.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal')); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' }); }); reader.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal')); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' }); }); reader.querySelector('[data-act="ai-reply"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal')); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'ai-reply' }); }); reader.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' }); }); reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); try { await _summarizeEmail(reader, data, ev.currentTarget); } catch {} }); reader.querySelector('[data-act="from-sender"]')?.remove(); reader.querySelector('[data-act="from-sender"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); try { await _toggleFromSenderPanel(reader, data, ev.currentTarget); } catch {} }); reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => { ev.stopPropagation(); try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget); } catch {} }); } catch (err) { reader.innerHTML = `Failed to load: ${_esc(String(err))}`; } } // "Open in new window" — spawns a floating draggable modal that shows just // the email content. Multiple windows can stack; each has its own DOM id // and close button. Uses `_makeDraggable` so dragging the header pans the // window around. Renders the body via _renderEmailBody for parity with the // expanded reader. let _emailWindowSeq = 0; async function _openEmailWindow(em, folder) { const useFolder = folder || state._libFolder || 'INBOX'; _emailWindowSeq += 1; const winId = `email-window-${em.uid}-${_emailWindowSeq}`; const modal = document.createElement('div'); modal.className = 'modal email-window-modal'; modal.id = winId; modal.style.cssText = 'pointer-events:none;background:transparent;'; modal.innerHTML = ` `; document.body.appendChild(modal); modal.style.display = 'block'; const content = modal.querySelector('.modal-content'); // Position offset from screen center so successive windows cascade. const isMobile = window.innerWidth <= 768; if (isMobile) { content.style.position = 'fixed'; content.style.pointerEvents = 'auto'; content.style.left = '0'; content.style.right = '0'; content.style.bottom = '0'; content.style.top = 'auto'; } else { content.style.position = 'fixed'; content.style.pointerEvents = 'auto'; requestAnimationFrame(() => { const w = content.offsetWidth, h = content.offsetHeight; const off = (_emailWindowSeq % 6) * 28; content.style.left = Math.max(20, (window.innerWidth - w) / 2 + off) + 'px'; content.style.top = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px'; }); } modal.querySelector('.close-btn')?.addEventListener('click', () => modal.remove()); try { _makeDraggable(content, modal, 'email-window-fullscreen'); } catch {} // Load + render const bodyEl = modal.querySelector('.email-window-body'); const loading = modal.querySelector('.email-window-loading'); try { const sp = spinnerModule.createWhirlpool(24); loading.appendChild(sp.element); const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`); const data = await res.json(); if (data.error) { bodyEl.innerHTML = `${_esc(data.error)}`; return; } _syncEmailReadState(em.uid, true); const subjEl = modal.querySelector('.email-window-subject'); if (subjEl && data.subject) subjEl.textContent = data.subject; // Build recipient chips the same way the inline reader does so the // standalone viewer looks/feels exactly like a real email view. const _chipsFor = (addrs) => { if (!addrs) return ''; const list = addrs.split(',').map(s => s.trim()).filter(Boolean); return list.map(a => { const name = _extractName(a); return `${_esc(name)}`; }).join(''); }; const fromChip = `${_esc(data.from_name || data.from_address)}`; let attsHtml = ''; try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {} // Repurpose bodyEl as a full email-card-reader so the inline reader's // CSS applies (sized header, action buttons in two rows, etc.). bodyEl.classList.add('email-card-reader'); bodyEl.style.padding = '0'; bodyEl.innerHTML = `${attsHtml}${_hasMultipleRecipients(data) ? `` : ''}${_safeRenderEmailBody(data)}`; // Wire all the same action handlers the inline reader has. try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {} const attsWrap = bodyEl.querySelector('.email-reader-atts-wrap'); if (attsWrap) { const attsToggle = attsWrap.querySelector('.email-reader-atts-header'); if (attsToggle) attsToggle.addEventListener('click', (ev) => { ev.stopPropagation(); attsWrap.classList.toggle('collapsed'); }); } bodyEl.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal')); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' }); }); bodyEl.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal')); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' }); }); bodyEl.querySelector('[data-act="ai-reply"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal')); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'ai-reply' }); }); bodyEl.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' }); }); bodyEl.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); try { await _summarizeEmail(bodyEl, data, ev.currentTarget); } catch {} }); bodyEl.querySelector('[data-act="from-sender"]')?.remove(); bodyEl.querySelector('[data-act="from-sender"]')?.addEventListener('click', async (ev) => { ev.stopPropagation(); try { await _toggleFromSenderPanel(bodyEl, data, ev.currentTarget); } catch {} }); bodyEl.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => { ev.stopPropagation(); // Use a synthetic "card" — the more-menu only needs the anchor // element and the email data. The card param is mostly used to find // the next sibling; the standalone window has none so we just pass // bodyEl as a stand-in. try { _showReaderMoreMenu(em, modal, bodyEl, ev.currentTarget); } catch {} }); } catch (err) { bodyEl.innerHTML = `Failed to load: ${_esc(String(err))}`; } } // Fetch a new email's content and replace the current reader body with it // (preserving the from-sender panel). Used for in-place navigation between // emails of the same sender — `folder` defaults to the library's current // folder but is overridable so cross-folder search results can open the // correct one. async function _swapReaderToUid(reader, uid, folder) { const body = reader.querySelector('.email-reader-body'); if (!body) return; body.innerHTML = ''; const sp = spinnerModule.createWhirlpool(24); const wrap = document.createElement('div'); wrap.style.cssText = 'padding:20px;display:flex;justify-content:center'; wrap.appendChild(sp.element); body.appendChild(wrap); const useFolder = folder || state._libFolder; try { const res = await fetch(`${API_BASE}/api/email/read/${uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`); const data = await res.json(); if (data.error) { body.innerHTML = `${_esc(data.error)}`; return; } _syncEmailReadState(uid, true); // Update the header meta (From/To/Subject) so it matches the new email. const headerMeta = reader.querySelector('.email-reader-meta'); if (headerMeta) { const subj = data.subject || '(no subject)'; const date = data.date ? new Date(data.date).toLocaleString() : ''; headerMeta.innerHTML = ` ${date ? `` : ''} `; } // Refresh the attachments block to match the new email. Build fresh HTML // and either replace the existing block, remove it (if the new email has // none), or insert one before the body (if the previous email had none // but the new one does). const newAttsHtml = _buildAttsHtmlFor(uid, data); const oldAtts = reader.querySelector('.email-reader-atts-wrap'); if (newAttsHtml) { if (oldAtts) { const tmp = document.createElement('div'); tmp.innerHTML = newAttsHtml; oldAtts.replaceWith(tmp.firstChild); } else { body.insertAdjacentHTML('beforebegin', newAttsHtml); } const newWrap = reader.querySelector('.email-reader-atts-wrap'); if (newWrap) { const hdr = newWrap.querySelector('.email-reader-atts-header'); if (hdr) { hdr.addEventListener('click', (ev) => { ev.stopPropagation(); newWrap.classList.toggle('collapsed'); }); hdr.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); newWrap.classList.toggle('collapsed'); } }); } } } else if (oldAtts) { oldAtts.remove(); } body.innerHTML = _safeRenderEmailBody(data); body.classList.toggle('html-body', !!data.body_html); // Wire click handlers for the newly-rendered attachment chips. Without // this, after swapping to a different email via the sidebar, clicking // an attachment chip would do nothing. _wireAttachmentHandlers(reader, useFolder); } catch (err) { body.innerHTML = `${_esc(String(err))}`; } } async function _summarizeEmail(reader, data, btn) { const body = reader.querySelector('.email-reader-body'); if (!body) return; // If a summary panel already exists, toggle: hide/show const existing = body.querySelector('.email-summary-panel'); if (existing) { if (existing.style.display === 'none') { existing.style.display = ''; if (btn) { btn.classList.add('active'); btn.querySelector('.btn-label').textContent = 'Summary'; } } else { existing.style.display = 'none'; if (btn) { btn.classList.remove('active'); btn.querySelector('.btn-label').textContent = 'Summary'; } } return; } // No panel yet. If the email has no cached AI summary, show a placeholder // "not generated — create now?" prompt instead of firing the LLM immediately. // This avoids accidental LLM spend and makes the state explicit to the user. if (!data.cached_summary) { const prompt = document.createElement('div'); prompt.className = 'email-summary-panel'; prompt.innerHTML = `SummaryNo AI summary generated.`; body.insertBefore(prompt, body.firstChild); if (btn) { btn.classList.add('active'); const label = btn.querySelector('.btn-label'); if (label) label.textContent = 'Summary'; } // No Cancel button — toggling the Summary button again hides this panel // (handled by the existing-panel branch above), so it'd be redundant. prompt.querySelector('[data-act="summary-generate"]').addEventListener('click', async (ev) => { ev.stopPropagation(); prompt.remove(); await _generateSummary(reader, data, btn); }); return; } // Cached summary exists — show it immediately. await _generateSummary(reader, data, btn); } async function _generateSummary(reader, data, btn) { const body = reader.querySelector('.email-reader-body'); if (!body) return; const panel = document.createElement('div'); panel.className = 'email-summary-panel'; panel.innerHTML = '' + '' + 'Summary' + '' + '' + ''; if (_summaryCollapsedPref()) panel.classList.add('collapsed'); body.insertBefore(panel, body.firstChild); const _genToggle = panel.querySelector('.email-summary-toggle'); if (_genToggle) { const _genFlip = () => { panel.classList.toggle('collapsed'); _setSummaryCollapsedPref(panel.classList.contains('collapsed')); }; _genToggle.addEventListener('click', (ev) => { ev.stopPropagation(); _genFlip(); }); _genToggle.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _genFlip(); } }); } const sp = spinnerModule.createWhirlpool(18); const content = panel.querySelector('.email-summary-content'); content.appendChild(sp.element); if (btn) btn.disabled = true; try { const res = await fetch(`${API_BASE}/api/email/summarize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: data.body, subject: data.subject, from: `${data.from_name} <${data.from_address}>`, // Send identifiers so the backend can fetch the raw message and // pull attachment text for the summary (PDFs, invoices, etc.). uid: data.uid || '', folder: state._libFolder || 'INBOX', message_id: data.message_id || '', account_id: data.account_id || '', }), }); const result = await res.json(); sp.destroy(); content.innerHTML = ''; if (result.success && result.summary) { content.textContent = result.summary; if (btn) { btn.classList.add('active'); const label = btn.querySelector('.btn-label'); if (label) label.textContent = 'Summary'; } } else { content.innerHTML = `${_esc(result.error || 'Failed to summarize')}`; panel.remove(); } } catch (e) { sp.destroy(); panel.remove(); if (uiModule) uiModule.showError?.('Failed to summarize'); } finally { if (btn) btn.disabled = false; } } // Keep an email ⋮ dropdown inside the viewport: when it would spill past the // bottom (e.g. an email low on a phone screen), flip it above the anchor if // there's more room up there, and cap height + scroll if it still overflows. function _fitEmailDropdown(dropdown, rect) { requestAnimationFrame(() => { const margin = 8; // Horizontal clamp — keep the dropdown inside the viewport regardless of // whether it was anchored via left or right. Needed now that some // triggers (e.g. the right-aligned bulk "Actions" button) sit close to // the right edge, where a left-anchored menu would spill off-screen. const dw = dropdown.offsetWidth; const curLeft = dropdown.getBoundingClientRect().left; if (curLeft + dw > window.innerWidth - margin) { dropdown.style.left = Math.max(margin, window.innerWidth - margin - dw) + 'px'; dropdown.style.right = 'auto'; } else if (curLeft < margin) { dropdown.style.left = margin + 'px'; dropdown.style.right = 'auto'; } // Vertical fit — flip up or cap+scroll if it doesn't fit below. const dh = dropdown.offsetHeight; const below = window.innerHeight - rect.bottom - margin; const above = rect.top - margin; if (dh <= below) return; // fits below as-is if (above > below) { // flip upward dropdown.style.top = 'auto'; dropdown.style.bottom = (window.innerHeight - rect.top + 4) + 'px'; if (dh > above) { dropdown.style.maxHeight = above + 'px'; dropdown.style.overflowY = 'auto'; } } else { // keep below, cap + scroll dropdown.style.maxHeight = below + 'px'; dropdown.style.overflowY = 'auto'; } }); } function _showReaderMoreMenu(em, card, reader, anchor) { // Toggle: if a dropdown for THIS anchor is already open, close it. const existing = document.querySelector('.email-card-dropdown'); if (existing && existing._anchor === anchor) { existing.remove(); anchor.classList.remove('reader-more-active'); return; } // Otherwise close any other open dropdown (and clear its anchor's active // state) before opening a fresh one. document.querySelectorAll('.email-card-dropdown').forEach(d => { if (d._anchor) d._anchor.classList.remove('reader-more-active'); d.remove(); }); const dropdown = document.createElement('div'); dropdown.className = 'email-card-dropdown'; dropdown._anchor = anchor; anchor.classList.add('reader-more-active'); const rect = anchor.getBoundingClientRect(); dropdown.style.cssText = `position:fixed;z-index: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;`; const _icon = (svg) => ``; const _unreadIcon = ''; const _archIcon = ''; const _spamIcon = ''; const _trashIcon = ''; const _deleteForeverIcon = ''; const _bellIcon = ''; const _newTabIcon = ''; const closeAndRemove = async () => { // Pick the next neighbour BEFORE we re-render so we know which email to // jump to. Prefer the next card; fall back to the previous one if this // was the last card. const sibling = _findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1); const nextUid = sibling ? sibling.dataset.uid : null; await _animateEmailCardRemoval([em.uid]); state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); _renderGrid(); _libCacheWriteBack(); if (!nextUid) return; // After _renderGrid, the card nodes are fresh — re-resolve and expand. const grid = document.getElementById('email-lib-grid'); const nextCard = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(nextUid))}"]`); const nextEm = state._libEmails.find(e => String(e.uid) === String(nextUid)); if (nextCard && nextEm) { _toggleCardPreview(nextCard, nextEm); nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } }; const _bubblesIcon = ''; const _contactIcon = ''; const actions = [ { label: 'Open in new tab', icon: _newTabIcon, action: async () => { const folder = state._libFolder || 'INBOX'; await _openEmailAsTab(em, folder); }, }, { // Save the sender to CardDAV contacts. Pulls name + address off the // list-item (em); falls back to splitting the local-part for a name. label: 'Save sender to contacts', icon: _contactIcon, action: async () => { const email = (em.from_address || em.from || '').trim(); if (!email) { import('./ui.js').then(m => m.showError && m.showError('No sender address')).catch(() => {}); return; } const name = (em.from_name || '').trim() || email.split('@')[0]; try { const r = await fetch(`${API_BASE}/api/contacts/add`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email }), }); const d = await r.json(); import('./ui.js').then(m => { if (!m.showToast) return; if (d.success && d.message === 'Already exists') m.showToast('Already in contacts'); else if (d.success) m.showToast('Saved to contacts'); else m.showError && m.showError('Failed to save contact'); }).catch(() => {}); } catch (_) { import('./ui.js').then(m => m.showError && m.showError('Failed to save contact')).catch(() => {}); } }, }, // Threaded ⇄ Plain-text view toggle removed — threaded view disabled // for now (too buggy). Emails always render plain text. Restore this // menu item + _bubblesDisabled() localStorage logic to bring it back. { label: em.is_read ? 'Mark Unread' : 'Mark Read', icon: _unreadIcon, action: async () => { const newRead = !em.is_read; _syncEmailReadState(em.uid, newRead); try { if (newRead) { await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); } else { await fetch(`${API_BASE}/api/email/mark-unread/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); } } catch (e) { console.error(e); } _renderGrid(); }, }, { label: 'Archive', icon: _archIcon, action: async () => { try { await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); } catch (e) { console.error(e); } await closeAndRemove(); }, }, { label: 'Remind to reply', icon: _bellIcon, submenu: 'remind', }, { label: 'Move to Spam', icon: _spamIcon, action: async () => { try { await fetch(`${API_BASE}/api/email/move/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&dest=Junk`, { method: 'POST' }); } catch (e) { console.error(e); } await closeAndRemove(); }, }, { label: 'Move to Trash', icon: _trashIcon, action: async () => { try { await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); } catch (e) { console.error(e); } await closeAndRemove(); }, }, { label: 'Delete Permanently', icon: _deleteForeverIcon, danger: true, action: async () => { const subject = em.subject || '(no subject)'; const ok = await styledConfirm( `Permanently delete "${subject}"? This cannot be undone.`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true } ); if (!ok) return; try { await fetch(`${API_BASE}/api/email/delete-permanent/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); } catch (e) { console.error(e); } await closeAndRemove(); }, }, ]; for (const a of actions) { const item = document.createElement('div'); item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); const arrow = a.submenu ? '›' : ''; item.innerHTML = _icon(a.icon) + `${a.label}${arrow}`; item.addEventListener('click', (e) => { e.stopPropagation(); if (a.submenu === 'remind') { _showLibRemindSubmenu(em, dropdown); return; } dropdown.remove(); anchor.classList.remove('reader-more-active'); a.action(); }); dropdown.appendChild(item); } // Mobile-only Cancel item — explicit close for touch users. CSS hides it // on desktop where outside-click already dismisses cleanly. const _cancelIco = ''; const cancelItem = document.createElement('div'); cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile'; cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel'; cancelItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); anchor.classList.remove('reader-more-active'); }); dropdown.appendChild(cancelItem); document.body.appendChild(dropdown); _fitEmailDropdown(dropdown, rect); const close = (ev) => { if (!dropdown.contains(ev.target) && ev.target !== anchor) { dropdown.remove(); anchor.classList.remove('reader-more-active'); document.removeEventListener('click', close, true); } }; setTimeout(() => document.addEventListener('click', close, true), 10); } function _showCardMenu(em, anchor) { document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove()); const dropdown = document.createElement('div'); dropdown.className = 'email-card-dropdown'; const rect = anchor.getBoundingClientRect(); 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;`; const _icon = (svg) => ``; const _replyIcon = ''; const _archIcon = ''; const _delIcon = ''; const _unreadIcon = ''; const _checkIcon = ''; const _cardBellIcon = ''; const isSentFolder = /sent/i.test(state._libFolder); const _newTabIcon = ''; const actions = [ { label: 'Open', icon: _replyIcon, action: async () => { // Just expand inline (same as tapping the row). const card = anchor.closest('.doclib-card'); if (card && !card.classList.contains('doclib-card-expanded')) { await _toggleCardPreview(card, em); } }}, { label: 'Open in new tab', icon: _newTabIcon, action: async () => { // Open this email as its own in-app modal that registers a dock // chip — multiple emails can be opened simultaneously, each gets // its own chip in the minimized dock. const folder = state._libFolder || 'INBOX'; await _openEmailAsTab(em, folder); }}, { label: 'Remind to reply', icon: _cardBellIcon, submenu: 'remind' }, ]; if (!isSentFolder) { // Source of truth = the visible "active" class on the card's done // check, so the menu label and the actual toggle behaviour can't // disagree with what the user sees. const _cardForLabel = anchor.closest('.doclib-card'); const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null; const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered; actions.push({ label: _currentlyDone ? 'Mark Not Done' : 'Mark Done', icon: _checkIcon, action: async () => { const card = anchor.closest('.doclib-card'); const check = card ? card.querySelector('.email-card-done') : null; const wasActive = check ? check.classList.contains('active') : !!em.is_answered; const newState = !wasActive; em.is_answered = newState; if (newState) _syncEmailReadState(em.uid, true); // mark-done implies mark-read try { if (newState) { await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); } else { await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); } } catch (e) { console.error('Failed to toggle done:', e); } if (card) { if (check) check.classList.toggle('active', newState); if (newState) _syncEmailReadState(em.uid, true); } }, }); actions.push({ label: 'Archive', icon: _archIcon, action: async () => { await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); await _animateEmailCardRemoval([em.uid]); state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); _renderGrid(); _libCacheWriteBack(); }, }); } else { actions.push({ label: 'Archive', icon: _archIcon, action: async () => { await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); await _animateEmailCardRemoval([em.uid]); state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); _renderGrid(); _libCacheWriteBack(); }, }); } // "Select" — switch to multi-select mode with THIS email pre-selected so // the user can quickly fan-out to neighbours with the bulk bar. // Match the chat-sidebar Select icon — a thick bullet character reads // much heavier than a small SVG circle. Nudged up 2px so its visual // center lines up with the SVG icons above (which sit a bit higher). const _selectIcon = '●'; actions.push({ label: 'Select', icon: _selectIcon, action: () => { state._selectMode = true; state._selectedUids.add(em.uid); _updateBulkBar(); _renderGrid(); }, }); actions.push( { label: 'Delete', icon: _delIcon, danger: true, action: async () => { const subject = em.subject || '(no subject)'; const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true }); if (!ok) return; await fetch(`${API_BASE}/api/email/delete/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); await _animateEmailCardRemoval([em.uid]); state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); _renderGrid(); _libCacheWriteBack(); }}, ); for (const a of actions) { const item = document.createElement('div'); item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); const arrow = a.submenu ? '›' : ''; item.innerHTML = _icon(a.icon) + `${a.label}${arrow}`; item.addEventListener('click', (e) => { e.stopPropagation(); if (a.submenu === 'remind') { _showLibRemindSubmenu(em, dropdown); return; } dropdown.remove(); anchor.classList.remove('reader-more-active'); a.action(); }); dropdown.appendChild(item); } // Mobile-only Cancel item — explicit close for touch users. CSS hides it // on desktop where outside-click already dismisses cleanly. const _cancelIco = ''; const cancelItem = document.createElement('div'); cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile'; cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel'; cancelItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); anchor.classList.remove('reader-more-active'); }); dropdown.appendChild(cancelItem); document.body.appendChild(dropdown); _fitEmailDropdown(dropdown, rect); const close = (ev) => { if (!dropdown.contains(ev.target) && ev.target !== anchor) { dropdown.remove(); anchor.classList.remove('reader-more-active'); document.removeEventListener('click', close, true); } }; setTimeout(() => document.addEventListener('click', close, true), 10); } // Bulk "Actions" dropdown for select mode — Delete is a separate visible button. function _showBulkActionsMenu(anchor) { document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove()); const dropdown = document.createElement('div'); dropdown.className = 'email-card-dropdown email-bulk-menu'; const rect = anchor.getBoundingClientRect(); 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;`; const _readIco = ''; const _unreadIco = ''; const items = [ { label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') }, { label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') }, ]; for (const a of items) { const it = document.createElement('div'); it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : ''); it.innerHTML = `${a.label}`; it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); }); dropdown.appendChild(it); } // Mobile-only Cancel — matches the per-card and sidebar dropdowns. const _cancelIco2 = ''; const cancelIt = document.createElement('div'); cancelIt.className = 'dropdown-item-compact dropdown-cancel-mobile'; cancelIt.innerHTML = `Cancel`; cancelIt.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); // Cancel inside the bulk-Actions menu also exits select mode — matches the // documents bulk dropdown. state._selectMode = false; state._selectedUids.clear(); _updateBulkBar(); _renderGrid(); }); dropdown.appendChild(cancelIt); document.body.appendChild(dropdown); _fitEmailDropdown(dropdown, rect); const close = (ev) => { if (!dropdown.contains(ev.target) && ev.target !== anchor) { dropdown.remove(); document.removeEventListener('click', close, true); } }; setTimeout(() => document.addEventListener('click', close, true), 10); } function _updateBulkBar() { const bar = document.getElementById('email-lib-bulk'); const selectBtn = document.getElementById('email-lib-select-btn'); if (bar) bar.classList.toggle('hidden', !state._selectMode); if (selectBtn) { selectBtn.textContent = state._selectMode ? 'Cancel' : 'Select'; selectBtn.classList.toggle('active', state._selectMode); } const count = document.getElementById('email-lib-selected-count'); if (count) count.textContent = `${state._selectedUids.size} Selected`; const all = document.getElementById('email-lib-select-all'); if (all) all.checked = state._libEmails.length > 0 && state._libEmails.every(e => state._selectedUids.has(e.uid)); // When something's selected, brighten Actions to the same full --fg color as // the "N Selected" count (the button is a dimmer 60% --fg by default). const actions = document.getElementById('email-lib-bulk-actions'); if (actions) actions.style.color = state._selectedUids.size > 0 ? 'var(--fg)' : ''; const deleteBtn = document.getElementById('email-lib-bulk-delete'); if (deleteBtn) deleteBtn.style.color = state._selectedUids.size > 0 ? 'var(--red)' : ''; } async function _bulkAction(action) { const uids = Array.from(state._selectedUids); if (uids.length === 0) return; if (action === 'delete') { const ok = await styledConfirm( `Delete ${uids.length} selected email${uids.length === 1 ? '' : 's'}?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true }, ); if (!ok) return; } for (const uid of uids) { try { if (action === 'archive') { await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); } else if (action === 'delete') { await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); } else if (action === 'read' || action === 'unread') { // Local toggle for now (no backend endpoint yet) const em = state._libEmails.find(e => e.uid === uid); if (em) em.is_read = (action === 'read'); } } catch (e) { console.error(`Failed to ${action} ${uid}:`, e); } } if (action === 'archive' || action === 'delete') { await _animateEmailCardRemoval(uids); const removed = new Set(uids.map(uid => String(uid))); state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); } state._selectedUids.clear(); state._selectMode = false; _updateBulkBar(); _renderGrid(); // Sync the local mutation (delete/archive, or in-place read/unread // flag flips on email objects) into the SWR cache so reopen doesn't // briefly show the pre-bulk state. _libCacheWriteBack(); } // _extractName lives in ./emailLibrary/utils.js function _hasMultipleRecipients(data) { // Count distinct addresses in To + Cc (minus the current user). Empty // fallback when the user's address isn't yet known — no exclusion. const myAddress = (window._myEmailAddress || '').toLowerCase(); const extractEmails = (str) => { if (!str) return []; return str.split(',') .map(s => { const m = s.match(/<([^>]+)>/); return (m ? m[1] : s).trim().toLowerCase(); }) .filter(e => e && e !== myAddress); }; const recipients = new Set([ ...extractEmails(data.to), ...extractEmails(data.cc), ]); // Sender counts as one other person too if (data.from_address && data.from_address.toLowerCase() !== myAddress) { recipients.add(data.from_address.toLowerCase()); } return recipients.size > 1; } // _esc lives in ./emailLibrary/utils.js // ---- Reminder submenu (used by both email menus) ---- function _showLibRemindSubmenu(em, parentDropdown) { parentDropdown.innerHTML = ''; const header = document.createElement('div'); header.className = 'dropdown-item-compact'; header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;'; header.innerHTML = 'Remind me'; parentDropdown.appendChild(header); const now = new Date(); const laterToday = new Date(now); const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3*60*60*1000); else laterToday.setTime(sixPm.getTime()); const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate()+1); tomorrow.setHours(8,0,0,0); const daysUntilMon = (8 - now.getDay()) % 7 || 7; const nextWeek = new Date(now); nextWeek.setDate(now.getDate()+daysUntilMon); nextWeek.setHours(8,0,0,0); const presets = [ { label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: laterToday }, { label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: tomorrow }, { label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday:'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: nextWeek }, ]; for (const p of presets) { const item = document.createElement('div'); item.className = 'dropdown-item-compact'; item.innerHTML = `${p.label}${p.sub}`; item.addEventListener('click', async (e) => { e.stopPropagation(); parentDropdown.remove(); await _createEmailReplyReminder(em, p.date); }); parentDropdown.appendChild(item); } const customItem = document.createElement('div'); customItem.className = 'dropdown-item-compact'; customItem.innerHTML = 'Pick date and time…'; customItem.addEventListener('click', (e) => { e.stopPropagation(); parentDropdown.remove(); const tmp = document.createElement('input'); tmp.type = 'datetime-local'; const def = new Date(tomorrow); const pad = n => String(n).padStart(2,'0'); tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`; tmp.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:13px;'; document.body.appendChild(tmp); tmp.focus(); if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} } tmp.addEventListener('change', async () => { if (tmp.value) await _createEmailReplyReminder(em, new Date(tmp.value)); tmp.remove(); }); tmp.addEventListener('blur', () => setTimeout(() => tmp.remove(), 200)); }); parentDropdown.appendChild(customItem); } async function _createEmailReplyReminder(em, dueDate) { const pad = n => String(n).padStart(2,'0'); const iso = `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}`; const fullFrom = em.from || em.sender || ''; // Extract just the first name from "First Last" or fall back to email local part let from = 'someone'; if (fullFrom) { const fullName = _extractName(fullFrom); if (fullName) { // Strip quotes, take the first whitespace-separated word, capitalize const first = fullName.replace(/^["']|["']$/g, '').trim().split(/[\s,]+/)[0] || ''; if (first) from = first.charAt(0).toUpperCase() + first.slice(1); } } const subject = em.subject || '(no subject)'; const folder = state._libFolder || 'INBOX'; const deepLink = `${window.location.origin}/#email=${encodeURIComponent(folder)}:${em.uid}`; const payload = { title: `Reply: ${subject}`, note_type: 'todo', items: [ { text: `Reply to ${from}: ${subject}`, checked: false }, ], content: `Open email: ${deepLink}`, label: 'email reminder', due_date: iso, source: 'email', }; try { const res = await fetch(`${API_BASE}/api/notes`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok) throw new Error('Failed'); const { showToast } = await import('./ui.js'); const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }); showToast(`Todo reminder set for ${fmt}`); if ('Notification' in window && Notification.permission === 'default') { try { Notification.requestPermission(); } catch {} } } catch (e) { const { showError } = await import('./ui.js'); showError('Failed to create reminder'); } } // Sanitize untrusted HTML email bodies before injecting via innerHTML. // // Denylist sanitizer — has to block every well-known XSS sink: // -