diff --git a/specs/frontend.md b/specs/frontend.md index cd3eea540..73ad727bb 100644 --- a/specs/frontend.md +++ b/specs/frontend.md @@ -97,6 +97,8 @@ Current major frontend areas include: - image editor integration in `static/js/galleryEditor.js` plus leaves under `static/js/editor/`; - gallery, email inbox/library, calendar, research panel/jobs/synapse, notes/tasks, assistant, memory/skills, Cookbook/HW Fit, workspace picker, provider device flow, composer ArrowUp recall, theme, modal/window utilities, storage, and accessibility helpers. +`static/js/emailLibrary/` is the one JS package with module-graph coverage: `tests/test_email_library_module_graph_js.py` pins the wrapper's public surface against the entry module's exports, evaluates every module on its own in a browser so an import cycle cannot hide a temporal-dead-zone read, and requires each module to be in the `sw.js` precache. Assertions about its source go through `tests/helpers/js_modules.py`, which reads the whole package, for the same reason `tests/helpers/stylesheets.py` reads the whole cascade. + Coordinator ownership: - `static/app.js` owns late orchestration, global fetch 401 redirects, sidebar/tool route wiring, and many `window.*` compatibility bridges; @@ -153,7 +155,7 @@ Missing coverage includes: ## Current Gaps - `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`. -- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets only. +- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets, and for the `static/js/emailLibrary/` package only. - Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care. - `window.*` compatibility bridges remain widespread. - PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing. diff --git a/static/js/MODULE_SUMMARY.md b/static/js/MODULE_SUMMARY.md index c0f88c824..a9b525e0a 100644 --- a/static/js/MODULE_SUMMARY.md +++ b/static/js/MODULE_SUMMARY.md @@ -137,7 +137,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog | Module | Responsibility | |---|---| | **`gallery.js`** / **`galleryEditor.js`** | Gallery/image library and canvas editor entry points. | -| **`emailInbox.js`** / **`emailLibrary.js`** | Email inbox reader and library modal. Sub-modules handle signatures, reply recipients, state, and signature folding. | +| **`emailInbox.js`** / **`emailLibrary/`** | Email inbox reader and library modal. `emailLibrary.js` is a re-export wrapper; `emailLibrary/index.js` owns the panel, list and cards, with `settingsPage.js`, `unsubscribe.js`, `reader.js`, `menus.js`, `bodyRender.js`, `attachments.js` and `aiReply.js` alongside it plus the `utils.js` / `signatureFold.js` / `replyRecipients.js` / `state.js` leaves. | | **`calendar.js`** / **`calendar/utils.js`** / **`calendar/reminders.js`** | Calendar views, event forms, reminders. | | **`tasks.js`** | Scheduled task/recurring LLM job UI. | | **`notes.js`** | Notes and todo panel, reminders, pinboard. | diff --git a/static/js/emailLibrary/aiReply.js b/static/js/emailLibrary/aiReply.js new file mode 100644 index 000000000..bbbcb0e86 --- /dev/null +++ b/static/js/emailLibrary/aiReply.js @@ -0,0 +1,449 @@ +// static/js/emailLibrary/aiReply.js +// +// The AI-reply entry points and the reader submenus that sit next to them: +// reply-mode choice, the per-message context note (drafted into localStorage so +// a half-typed instruction survives a reload), the translate submenu, and the +// remind-me submenu that writes a todo note. +// +// The context-draft key is versioned (`…:v2:`) and scoped by account, folder +// and uid, so a draft cannot leak from one message to another. + +import { topPortalZ } from '../toolWindowZOrder.js'; +import { showToast } from '../ui.js?v=20260916largetoolscroll1'; +import { state } from './state.js'; +import { _esc, _extractName } from './utils.js'; +import { _snapEmailModalToLeftSidebar, _translateEmail } from './index.js'; + +const API_BASE = window.location.origin; + +export function _aiReplyIcon(data) { + return ''; +} + +function _summaryIcon(data) { + const fill = data?.cached_summary ? 'var(--accent-primary, var(--red))' : 'currentColor'; + return ``; +} + +async function _emailTranslateLanguage() { + try { + const res = await fetch(`${API_BASE}/api/email/config`); + const cfg = await res.json(); + return cfg?.email_translate_language || 'English'; + } catch (_) { + return 'English'; + } +} + +async function _runAiReplyFromButton(btn, em, data, mode, noteHint = '') { + _snapEmailModalToLeftSidebar(btn.closest('.modal')); + btn.disabled = true; + const orig = btn.innerHTML; + let wp = null; + try { + wp = spinnerModule.createWhirlpool(14); + wp.element.style.cssText = 'width:14px;height:14px;display:inline-block;vertical-align:middle;position:relative;top:-2px;'; + btn.innerHTML = ''; + btn.appendChild(wp.element); + } catch (_) {} + try { + if (state._onEmailClick) { + return await state._onEmailClick({ email: em, emailData: data, mode, noteHint }); + } + return false; + } finally { + try { wp && wp.stop(); } catch (_) {} + btn.disabled = false; + btn.innerHTML = orig; + } +} + +const _AI_REPLY_CONTEXT_DRAFT_PREFIX = 'odysseus:email-ai-reply-context:v2:'; +let _aiReplyChoiceOutsideClose = null; + +function _aiReplyContextDraftKey(em, data) { + const accountId = data?.account_id || em?.account_id || state._libAccountId || ''; + const folder = data?.folder || em?.folder || state._libFolder || 'INBOX'; + const uid = data?.uid || em?.uid || data?.message_id || em?.message_id || ''; + if (!uid) return ''; + return _AI_REPLY_CONTEXT_DRAFT_PREFIX + + [accountId, folder, uid].map(value => encodeURIComponent(String(value || ''))).join(':'); +} + +function _loadAiReplyContextDraft(key) { + if (!key) return ''; + try { return localStorage.getItem(key) || ''; } catch (_) { return ''; } +} + +function _saveAiReplyContextDraft(key, value) { + if (!key) return; + try { + const text = String(value || ''); + if (text.trim()) localStorage.setItem(key, text); + else localStorage.removeItem(key); + } catch (_) {} +} + +function _clearAiReplyContextDraft(key) { + if (!key) return; + try { localStorage.removeItem(key); } catch (_) {} +} + +function _closeAiReplyChoice() { + if (_aiReplyChoiceOutsideClose) { + document.removeEventListener('click', _aiReplyChoiceOutsideClose, true); + _aiReplyChoiceOutsideClose = null; + } + document.querySelectorAll('.email-ai-reply-choice').forEach(el => { + const input = el.querySelector('[data-note-input]'); + _saveAiReplyContextDraft(el.dataset.contextDraftKey || '', input?.value || ''); + el.remove(); + }); +} + +function _showAiReplyChoice(btn, em, data, positionAnchor = btn) { + _closeAiReplyChoice(); + const rect = positionAnchor.getBoundingClientRect(); + const menu = document.createElement('div'); + menu.className = 'email-ai-reply-choice'; + /* Clamp width to viewport minus 16px margin so the menu never spills off + the right edge on narrow mobile screens. */ + const menuMaxW = Math.min(220, window.innerWidth - 16); + const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8)); + /* Vertical placement: prefer below the button, but flip above if + there's not enough room (e.g. button near bottom of viewport). + Estimated menu height is ~150px (textarea + buttons + padding). */ + const estHeight = 150; + const spaceBelow = window.innerHeight - rect.bottom - 8; + const spaceAbove = rect.top - 8; + let top; + if (spaceBelow >= estHeight || spaceBelow >= spaceAbove) { + top = Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8)); + } else { + top = Math.max(8, rect.top - estHeight - 6); + } + menu.style.cssText = [ + 'position:fixed', + `left:${left}px`, + `top:${top}px`, + `max-width:${menuMaxW}px`, + `max-height:${window.innerHeight - 16}px`, + 'overflow:auto', + 'box-sizing:border-box', + `z-index:${topPortalZ()}`, + 'display:flex', + 'gap:6px', + 'padding:6px', + 'background:var(--bg,#111)', + 'border:1px solid var(--border,#333)', + 'border-radius:7px', + 'box-shadow:0 8px 24px rgba(0,0,0,.28)', + ].join(';'); + menu.innerHTML = ` +
+ +
+ +
+
+ `; + const noteInput = menu.querySelector('[data-note-input]'); + const contextDraftKey = _aiReplyContextDraftKey(em, data); + menu.dataset.contextDraftKey = contextDraftKey; + noteInput.value = _loadAiReplyContextDraft(contextDraftKey); + noteInput.addEventListener('input', () => { + _saveAiReplyContextDraft(contextDraftKey, noteInput.value || ''); + }); + setTimeout(() => noteInput.focus(), 0); + menu.addEventListener('click', async (ev) => { + const choice = ev.target.closest('[data-mode]'); + if (!choice) return; + ev.preventDefault(); + ev.stopPropagation(); + const mode = choice.getAttribute('data-mode') || 'ai-reply-fast'; + const noteHint = (noteInput.value || '').trim(); + _saveAiReplyContextDraft(contextDraftKey, noteInput.value || ''); + _closeAiReplyChoice(); + const draftOpened = await _runAiReplyFromButton(btn, em, data, mode, noteHint); + if (draftOpened === true) _clearAiReplyContextDraft(contextDraftKey); + }); + // Esc closes the popover; ignore plain clicks inside the menu so the + // textarea stays focused. + menu.addEventListener('mousedown', (ev) => ev.stopPropagation()); + document.body.appendChild(menu); + // Outside-click closer: only fires when the click target is OUTSIDE + // the menu. The original handler closed on any click which made + // focusing the textarea immediately dismiss the popover. + const outsideClose = (ev) => { + if (menu.contains(ev.target)) return; + document.removeEventListener('click', outsideClose, true); + if (_aiReplyChoiceOutsideClose === outsideClose) _aiReplyChoiceOutsideClose = null; + _closeAiReplyChoice(); + }; + _aiReplyChoiceOutsideClose = outsideClose; + setTimeout(() => { + if (_aiReplyChoiceOutsideClose === outsideClose) { + document.addEventListener('click', outsideClose, true); + } + }, 0); +} + +export function _handleAiReplyButton(ev, em, data, positionAnchor = ev.currentTarget) { + ev.stopPropagation(); + const btn = ev.currentTarget; + // First click on a cached email surfaces the cached draft. Second + // click clears the cache and opens the Fast/Full + context menu so + // the user can ask for a fresh draft (with new steering). + if (data?.cached_ai_reply && !btn.dataset.shownOnce) { + btn.dataset.shownOnce = '1'; + _runAiReplyFromButton(btn, em, data, 'ai-reply'); + return; + } + if (data?.cached_ai_reply) { + data.cached_ai_reply = null; + btn.dataset.shownOnce = ''; + } + _showAiReplyChoice(btn, em, data, positionAnchor); +} + +export 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 + +export function _showEmailTranslateSubmenu(reader, 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 = 'Translate to'; + parentDropdown.appendChild(header); + + const customRow = document.createElement('div'); + customRow.className = 'dropdown-item-compact email-translate-custom-row'; + customRow.style.cssText = 'display:flex;gap:5px;align-items:center;padding:5px 7px;cursor:default;'; + customRow.innerHTML = ` + + + `; + parentDropdown.appendChild(customRow); + const input = customRow.querySelector('.email-translate-custom-input'); + const go = customRow.querySelector('.email-translate-custom-go'); + const runCustom = async () => { + const language = (input?.value || '').trim(); + if (!language) { + input?.focus(); + return; + } + parentDropdown.remove(); + await _translateEmail(reader, language); + }; + customRow.addEventListener('click', e => e.stopPropagation()); + go?.addEventListener('click', async e => { + e.stopPropagation(); + await runCustom(); + }); + input?.addEventListener('keydown', async e => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + await runCustom(); + } + }); + _emailTranslateLanguage().then(language => { + if (input && !input.value) input.value = language || 'English'; + }).catch(() => {}); + + const languages = ['English', 'Swedish', 'Japanese', 'Spanish', 'French', 'German']; + for (const language of languages) { + const item = document.createElement('div'); + item.className = 'dropdown-item-compact'; + item.innerHTML = `${language}`; + item.addEventListener('click', async (e) => { + e.stopPropagation(); + parentDropdown.remove(); + await _translateEmail(reader, language); + }); + parentDropdown.appendChild(item); + } +} + +// ---- Reminder submenu (used by both email menus) ---- +export 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); + // "Note" — prompts for free-text and saves it as a note without a + // due_date, so no timer/reminder fires. + const noteItem = document.createElement('div'); + noteItem.className = 'dropdown-item-compact'; + noteItem.innerHTML = 'Note'; + noteItem.addEventListener('click', (e) => { + e.stopPropagation(); + parentDropdown.remove(); + _promptEmailNote(em); + }); + parentDropdown.appendChild(noteItem); +} + +function _promptEmailNote(em) { + const overlay = document.createElement('div'); + overlay.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;padding:16px;'; + const card = document.createElement('div'); + card.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:280px;max-width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;box-shadow:0 12px 32px rgba(0,0,0,0.4);'; + const subject = em.subject || '(no subject)'; + card.innerHTML = ` +
Note about ${_esc(subject)}
+ +
+ + +
+ `; + overlay.appendChild(card); + document.body.appendChild(overlay); + const ta = card.querySelector('[data-note]'); + setTimeout(() => ta.focus(), 0); + const close = () => overlay.remove(); + overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); }); + card.querySelector('[data-act="cancel"]').addEventListener('click', close); + card.querySelector('[data-act="save"]').addEventListener('click', async () => { + const text = (ta.value || '').trim(); + if (!text) { ta.focus(); return; } + close(); + await _createEmailReplyReminder(em, null, text); + }); + ta.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') close(); + else if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') card.querySelector('[data-act="save"]').click(); + }); +} + +async function _createEmailReplyReminder(em, dueDate, customText = '') { + const pad = n => String(n).padStart(2,'0'); + const iso = dueDate + ? `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}` + : null; + 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 itemText = customText || `Reply to ${from}: ${subject}`; + const payload = { + title: `Reply: ${subject}`, + note_type: 'todo', + items: [ + { text: itemText, checked: false }, + ], + content: `Open email: ${deepLink}`, + label: 'email reminder', + source: 'email', + }; + if (iso) payload.due_date = iso; + 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?v=20260916largetoolscroll1'); + if (dueDate) { + const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' }); + showToast(`Todo reminder set for ${fmt}`); + } else { + showToast('Reply note saved'); + } + if ('Notification' in window && Notification.permission === 'default') { + try { Notification.requestPermission(); } catch {} + } + } catch (e) { + const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); + showError('Failed to create reminder'); + } +} diff --git a/static/js/emailLibrary/attachments.js b/static/js/emailLibrary/attachments.js new file mode 100644 index 000000000..deab2981d --- /dev/null +++ b/static/js/emailLibrary/attachments.js @@ -0,0 +1,491 @@ +// static/js/emailLibrary/attachments.js +// +// Attachment chips in a reader: building the markup, wiring open/download/ +// "open in editor" per type, and the deferred load for messages whose +// attachment list was not in the list response. +// +// The deferred path exists because `/api/email/list` does not carry attachment +// metadata: a card can know it *has* attachments without knowing what they +// are, so the chips are rendered late and the card icon repaired afterwards. + +import * as Modals from '../modalManager.js'; +import { state } from './state.js'; +import { _esc } from './utils.js'; +import { _acct, _openCalendarEventFromEmail, _prepareEmailWindowForDocument } from './index.js'; +import { _openEmailAsTab, _openEmailWindow } from './reader.js'; + +const API_BASE = window.location.origin; + +// 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-attachments-download-all').forEach(btn => { + if (btn.dataset.wired === '1') return; + btn.dataset.wired = '1'; + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + if (btn.dataset.downloading === '1') return; + const uid = btn.dataset.attUid; + const sourceFolder = btn.dataset.attFolder || useFolder; + const count = Number(btn.dataset.attCount || 0); + if (!uid) return; + const originalHtml = btn.innerHTML; + const originalTitle = btn.title; + btn.dataset.downloading = '1'; + btn.classList.add('is-loading'); + try { + const sp = window.spinnerModule || (await import('../spinner.js')).default; + const wp = sp.createWhirlpool(12); + wp.element.style.margin = '0'; + btn.textContent = ''; + btn.appendChild(wp.element); + const label = document.createElement('span'); + label.textContent = 'All'; + btn.appendChild(label); + } catch (_) { + btn.textContent = 'All...'; + } + try { + const url = `${API_BASE}/api/email/attachments-download/${encodeURIComponent(uid)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`; + const res = await fetch(url, { credentials: 'same-origin' }); + if (!res.ok) { + const msg = await res.text().catch(() => ''); + console.error('attachments zip download failed', res.status, msg); + location.href = url; + return; + } + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = `email-${uid}-attachments.zip`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); + try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {} + } catch (e) { + console.error('attachments zip download error', e); + try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError('Could not download attachments'); } catch (_) {} + } finally { + delete btn.dataset.downloading; + btn.classList.remove('is-loading'); + btn.title = originalTitle; + btn.innerHTML = originalHtml; + } + }); + }); + reader.querySelectorAll('.email-attachment-calendar-open').forEach(openBtn => { + if (openBtn.dataset.wired === '1') return; + openBtn.dataset.wired = '1'; + openBtn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + if (openBtn.dataset.opening === '1') return; + const uid = openBtn.dataset.openUid; + const index = openBtn.dataset.openIndex; + const name = openBtn.dataset.openName || `calendar-${index}`; + const sourceFolder = openBtn.dataset.openFolder || useFolder; + if (!uid || index == null) return; + openBtn.dataset.opening = '1'; + openBtn.classList.add('is-loading'); + const originalHtml = openBtn.innerHTML; + try { + const wp = spinnerModule.createWhirlpool(12); + wp.element.style.margin = '0'; + openBtn.textContent = ''; + openBtn.appendChild(wp.element); + const folderQs = encodeURIComponent(sourceFolder); + const attachmentUrl = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`; + const attachmentRes = await fetch(attachmentUrl, { credentials: 'same-origin' }); + if (!attachmentRes.ok) throw new Error(`HTTP ${attachmentRes.status}`); + const blob = await attachmentRes.blob(); + const fd = new FormData(); + fd.append('file', blob, name); + const importRes = await fetch(`${API_BASE}/api/calendar/import`, { + method: 'POST', body: fd, credentials: 'same-origin', + }); + const result = await importRes.json().catch(() => ({})); + if (!importRes.ok || !result.ok) { + throw new Error(result.detail || result.error || `HTTP ${importRes.status}`); + } + const existingEventUid = Array.isArray(result.event_uids) ? String(result.event_uids[0] || '').trim() : ''; + if (existingEventUid && Number(result.imported || 0) === 0 && Number(result.skipped || 0) > 0) { + _openCalendarEventFromEmail(existingEventUid); + } + try { uiModule.showToast && uiModule.showToast(`${result.imported || 0} event${result.imported === 1 ? '' : 's'} added to ${result.calendar || 'calendar'}`); } catch (_) {} + window.dispatchEvent(new CustomEvent('calendar-refresh')); + } catch (e) { + console.error('calendar attachment import failed', e); + try { + const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); + showError(`Couldn't add ${name} to calendar: ${e?.message || 'Import failed'}`); + } catch (_) {} + } finally { + delete openBtn.dataset.opening; + openBtn.classList.remove('is-loading'); + openBtn.innerHTML = originalHtml; + } + }); + }); + 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(); + if (openBtn.dataset.opening === '1') return; + const uid = openBtn.dataset.openUid; + const index = openBtn.dataset.openIndex; + const name = openBtn.dataset.openName || `attachment-${index}`; + const sourceFolder = openBtn.dataset.openFolder || useFolder; + if (!uid || index == null) return; + openBtn.dataset.opening = '1'; + openBtn.classList.add('is-loading'); + const origHtml = openBtn.innerHTML; + const wp = spinnerModule.createWhirlpool(12); + wp.element.style.margin = '0'; + openBtn.textContent = ''; + openBtn.appendChild(wp.element); + try { + const folderQs = encodeURIComponent(sourceFolder); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 55000); + let res; + try { + res = await fetch( + `${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`, + { method: 'POST', credentials: 'same-origin', signal: controller.signal } + ); + } finally { + clearTimeout(timeout); + } + 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?v=20260916largetoolscroll1'); 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?v=20260916docctx2'); + 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?v=20260916largetoolscroll1'); showError('Document opened but panel could not mount'); } catch (_) {} + } + } catch (e) { + console.error('attachment-as-doc error', e); + const msg = e && e.name === 'AbortError' + ? `Opening ${name} timed out. Try downloading it instead.` + : `Couldn't open ${name}`; + try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError(msg); } catch (_) {} + } finally { + delete openBtn.dataset.opening; + openBtn.classList.remove('is-loading'); + openBtn.innerHTML = origHtml; + } + }); + }); + + reader.querySelectorAll('.email-attachment-download').forEach(downloadBtn => { + if (downloadBtn.dataset.wired === '1') return; + downloadBtn.dataset.wired = '1'; + downloadBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + const chip = downloadBtn.closest('.email-attachment-chip'); + if (!chip) return; + // Reuse the established attachment download handler below. Marking the + // chip expanded bypasses its old first-click reveal behavior. + chip.classList.add('is-expanded'); + chip.dataset.downloadTrigger = 'button'; + chip.click(); + }); + downloadBtn.addEventListener('keydown', (ev) => { + if (ev.key !== 'Enter' && ev.key !== ' ') return; + ev.preventDefault(); + downloadBtn.click(); + }); + }); + + 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, .email-attachment-calendar-open, .email-attachment-download')) return; + ev.stopPropagation(); + ev.preventDefault(); + const uid = chip.dataset.attUid; + const index = chip.dataset.attIndex; + const name = chip.dataset.attName || `attachment-${index}`; + const sourceFolder = chip.dataset.attFolder || useFolder; + if (!uid || index == null) return; + if (!chip.classList.contains('is-expanded')) { + reader.querySelectorAll('.email-attachment-chip.is-expanded').forEach(other => { + if (other !== chip) other.classList.remove('is-expanded'); + }); + chip.classList.add('is-expanded'); + return; + } + const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`; + if (_isMobileUA) { + delete chip.dataset.downloadTrigger; + window.open(url, '_blank'); + return; + } + // Swap the paperclip icon for a whirlpool spinner while the + // download is in flight, so large attachments give a clear cue + // they're loading. Restore on completion. + const buttonTriggered = chip.dataset.downloadTrigger === 'button'; + const iconSvg = buttonTriggered + ? chip.querySelector('.email-attachment-download > svg') + : chip.querySelector(':scope > svg'); + const origIconHtml = iconSvg ? iconSvg.outerHTML : ''; + let _wp = null; + let _spinnerHost = null; + try { + const sp = window.spinnerModule || (await import('../spinner.js')).default; + _wp = sp.createWhirlpool(12); + _spinnerHost = document.createElement('span'); + _spinnerHost.className = 'email-attachment-spinner'; + _spinnerHost.style.cssText = 'display:inline-flex;width:12px;height:12px;align-items:center;justify-content:center;flex-shrink:0;position:relative;top:-2px;'; + _spinnerHost.appendChild(_wp.element); + if (iconSvg) iconSvg.replaceWith(_spinnerHost); + } catch (_) {} + const origOpacity = chip.style.opacity; + chip.style.opacity = '0.85'; + 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 { + delete chip.dataset.downloadTrigger; + chip.style.opacity = origOpacity; + if (_spinnerHost && _spinnerHost.parentNode && origIconHtml) { + const tmp = document.createElement('div'); + tmp.innerHTML = origIconHtml; + const restored = tmp.firstChild; + if (restored) _spinnerHost.replaceWith(restored); + } + if (_wp) { try { _wp.destroy(); } catch (_) {} } + } + }); + }); +} + +// 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. +export function _buildAttsHtmlFor(uid, data) { + if (!data) return ''; + const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown|eml)$/i; + const _CALENDAR_RE = /\.(calendar|ics|ical)$/i; + const currentAttachments = Array.isArray(data.attachments) ? data.attachments : []; + const relatedAttachments = Array.isArray(data.related_attachments) ? data.related_attachments : []; + if (!currentAttachments.length && !relatedAttachments.length) return ''; + const visible = currentAttachments.filter(a => !_isLikelySignatureImage(a)); + const hidden = currentAttachments.filter(a => _isLikelySignatureImage(a)); + const related = relatedAttachments.filter(a => !_isLikelySignatureImage(a)); + if (!visible.length && !related.length && state._libViewInlineImages === false) return ''; + const renderChip = (a, extraClass = '') => { + const calendarAttachment = _CALENDAR_RE.test(a.filename || ''); + const openable = _OPENABLE_RE.test(a.filename || ''); + const chipUid = a.source_uid || a.uid || uid; + const chipFolder = a.source_folder || data.folder || state._libFolder || 'INBOX'; + const openBtn = calendarAttachment + ? `` + : openable + ? `` + : ''; + const downloadBtn = ``; + return ``; + }; + const chips = visible.map(a => renderChip(a)).join(''); + const hiddenChips = hidden.map(a => renderChip(a, ' email-attachment-chip-muted')).join(''); + const relatedChips = related.map(a => renderChip(a, ' email-attachment-chip-related')).join(''); + const visibleSection = visible.length + ? '
' + chips + '
' + : ''; + const relatedSection = related.length + ? '
From earlier in this thread
' + : ''; + const hiddenSection = hidden.length && state._libViewInlineImages !== false + ? '
Filtered inline images / signature files
' + hiddenChips + '
' + : ''; + const label = visible.length + ? `Attachments (${visible.length + related.length})` + : related.length + ? `Thread attachments (${related.length})` + : state._libViewInlineImages === false + ? 'Attachments' + : `Hidden inline attachments (${hidden.length})`; + const startCollapsed = !visible.length && !related.length; + const downloadAllBtn = visible.length > 4 + ? `` + : ''; + return ( + `
` + + '' + + visibleSection + + relatedSection + + hiddenSection + + '
' + ); +} + +async function _ensureEmailAttachmentData(uid, folder, data, knownHasAttachments = false) { + if (!data) return data; + const current = Array.isArray(data.attachments) ? data.attachments : []; + const related = Array.isArray(data.related_attachments) ? data.related_attachments : []; + const shouldFetch = data.attachments_deferred || (knownHasAttachments && !current.length && !related.length); + if (!shouldFetch) return data; + try { + const metaRes = await fetch(`${API_BASE}/api/email/attachments/${encodeURIComponent(uid)}?folder=${encodeURIComponent(folder || 'INBOX')}${_acct()}`); + const meta = await metaRes.json().catch(() => ({})); + if (metaRes.ok && Array.isArray(meta.attachments)) { + return { + ...data, + attachments: meta.attachments, + related_attachments: related, + attachments_deferred: false, + }; + } + } catch (_) {} + // Do not full-fetch the message during ordinary open. That can download and + // parse large MIME bodies immediately after the reader renders, keeping IMAP + // busy and making the UI feel stuck. Full attachment lookup is still available + // from explicit attachment/download/forward actions. + return data; +} + +export function _wireEmailAttachmentWrap(reader, folder) { + if (!reader) return; + const attsWrap = reader.querySelector('.email-reader-atts-wrap'); + if (attsWrap && !attsWrap.dataset.wired) { + attsWrap.dataset.wired = '1'; + const attsToggle = attsWrap.querySelector('.email-reader-atts-header'); + if (attsToggle) { + attsToggle.addEventListener('click', (ev) => { + ev.stopPropagation(); + attsWrap.classList.toggle('collapsed'); + }); + attsToggle.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + attsWrap.classList.toggle('collapsed'); + } + }); + } + } + try { _wireAttachmentHandlers(reader, folder); } catch {} +} + +export function _loadDeferredAttachmentsIntoReader(reader, uid, folder, data, knownHasAttachments = false) { + if (!reader || !uid || !data) return; + const current = Array.isArray(data.attachments) ? data.attachments : []; + const related = Array.isArray(data.related_attachments) ? data.related_attachments : []; + if (!data.attachments_deferred && !(knownHasAttachments && !current.length && !related.length)) return; + const body = reader.querySelector('.email-reader-body'); + if (!body) return; + const loading = document.createElement('div'); + loading.className = 'email-reader-atts-wrap email-reader-atts-loading'; + loading.style.cssText = 'min-height:34px;display:flex;flex-direction:row;align-items:center;gap:7px;padding:6px 14px;color:var(--fg);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;opacity:.7;'; + const loadingSpinner = spinnerModule.createWhirlpool(12); + loadingSpinner.element.style.margin = '0'; + loading.appendChild(loadingSpinner.element); + loading.appendChild(document.createTextNode('Loading attachments…')); + const existingWrap = reader.querySelector('.email-reader-atts-wrap'); + if (existingWrap) existingWrap.replaceWith(loading); + else body.insertAdjacentElement('beforebegin', loading); + _ensureEmailAttachmentData(uid, folder, data, knownHasAttachments).then(fullData => { + if (!reader.isConnected || !fullData) return; + loading.remove(); + try { loadingSpinner.destroy(); } catch (_) {} + const visibleCurrent = (fullData.attachments || []).filter(a => !_isLikelySignatureImage(a)); + const matchingEmail = (state._libEmails || []).find(em => ( + String(em.uid) === String(uid) + && String(em.account_id || state._libAccountId || '') === String(reader.closest('[data-email-account]')?.dataset.emailAccount || state._libAccountId || '') + )); + if (matchingEmail) matchingEmail.has_attachments = visibleCurrent.length > 0; + if (!visibleCurrent.length) { + reader.closest('.doclib-card')?.querySelector('.email-card-attachment')?.remove(); + } + const attsHtml = _buildAttsHtmlFor(uid, fullData); + if (!attsHtml) return; + const oldWrap = reader.querySelector('.email-reader-atts-wrap'); + if (oldWrap) { + const tmp = document.createElement('div'); + tmp.innerHTML = attsHtml; + oldWrap.replaceWith(tmp.firstElementChild); + } else { + body.insertAdjacentHTML('beforebegin', attsHtml); + } + _wireEmailAttachmentWrap(reader, folder); + }).catch(() => { + loading.remove(); + try { loadingSpinner.destroy(); } catch (_) {} + }); +} diff --git a/static/js/emailLibrary/bodyRender.js b/static/js/emailLibrary/bodyRender.js new file mode 100644 index 000000000..827b7b8e5 --- /dev/null +++ b/static/js/emailLibrary/bodyRender.js @@ -0,0 +1,871 @@ +// static/js/emailLibrary/bodyRender.js +// +// Turning a fetched message into reader HTML: the plain and threaded body +// paths, inline MIME image handling, and the quote/thread folding that sits on +// top of the detectors in ./signatureFold.js. +// +// Everything here writes into a reader element via innerHTML, so every branch +// that touches server-supplied HTML goes through `_sanitizeHtml` from +// ./utils.js first. `tests/test_security_regressions.py` counts those call +// sites against the raw per-turn HTML reads, package-wide — so do not name that +// property in a comment, or the count stops matching. + +import { + _QUOTE_ICON, + _SIG_ICON, + _extractQuoteMeta, + _extractTurnMetaFromBlockquote, + _foldSignature, + _foldSummary, + _harvestAttribution, + _isBloatedSig, + _looksLikeSignature, +} from './signatureFold.js'; +import { state } from './state.js'; +import { + _SIG_BLOAT_MIN_CHARS, + _TALON_ORIG_RE, + _TALON_WROTE, + _esc, + _escLinkify, + _formatBubbleDate, + _formatRecipients, + _initials, + _parseTurnMeta, + _sanitizeHtml, + _senderColor, +} from './utils.js'; +import { _disableInlineImages } from './settingsPage.js'; + +const API_BASE = window.location.origin; + +/** + * Wrap a probable signature block in a collapsed
so it stops + * eating the whole reader. We try, in priority order: + * 1. Mail-client signature wrappers — Gmail's `gmail_signature` div is + * explicit, no guessing required. Same for Apple Mail's data-smartmail. + * 2. The standard "-- " RFC 3676 sig delimiter. + * 3. A common closing phrase ("Best regards", "Cheers", etc.) on its own + * line — fuzzier, but catches sigs without the dash marker. + * 4. "Sent from my iPhone/Android" / "Get Outlook for ..." mobile-client + * boilerplate. + * Anything matched gets wrapped from the marker through end-of-body. + */ +/** + * Render the email body with sig/quote folds. If the backend has cached + * LLM-detected boundary offsets (data.boundaries), use those for an exact + * fold based on plain-text positions. Otherwise fall back to the regex + * detectors. The plain-body branch is always preferred when boundaries + * exist because the offsets are computed against plain text. + */ +// Global escape hatch — when the server's thread parser misfires (it +// occasionally splits a single reply into two bogus "turns" by treating a +// signature/disclaimer as its own message), the user can flip this off to +// fall back to plain rendering. Survives reloads. +const _BUBBLES_DISABLED_KEY = 'odysseus.email.bubblesDisabled'; +// Threaded chat-bubble email view is DISABLED for now — too buggy to +// ship. Force plain-text rendering everywhere by always returning true. +// Re-enable by restoring the localStorage-backed body + the toggle +// menu item in the reader's More menu. +function _bubblesDisabled() { + return true; +} +function _setBubblesDisabled(v) { + try { localStorage.setItem(_BUBBLES_DISABLED_KEY, v ? '1' : '0'); } catch {} +} + +export function _renderEmailBody(data) { + const plain = (typeof data?.body === 'string' && data.body.length) ? data.body : ''; + const folder = String(data?.folder || '').toLowerCase(); + const isSentFolder = folder.includes('sent'); + const fromAddr = String(data?.from_address || '').toLowerCase().trim(); + const isMine = !!fromAddr && _meEmailAddrs().has(fromAddr); + + // Messages authored by the user (Sent folder or self-sent copies in INBOX) + // are current authored text. Do not let cached boundaries or HTML + // blockquote parsing hide the whole thing behind "Earlier reply". + if ((isSentFolder || isMine) && plain) { + const plainTurns = _renderPlaintextThread(plain); + if (plainTurns && !/^\s*'), null); + } + + // Prefer the normalized plain-text thread when the message contains + // explicit reply markers. HTML mail from Outlook/Gmail commonly wraps the + // same quoted message in several nested blockquotes; feeding that markup + // to the HTML walker creates phantom chains such as "Earlier reply > + // Earlier reply > sender". The plain representation has the actual quote + // levels and gives us one stable fold per real quoted message. + const hasPlainThreadMarkers = plain && ( + /^\s*>/m.test(plain) + || /^\s*-{5,}\s*(?:Previous message|Original message)\s*-{5,}\s*$/im.test(plain) + || /^\s*On\s.+?\s(?:wrote|skrev|schrieb|écrit|escribió)\s*:\s*$/im.test(plain) + ); + if (hasPlainThreadMarkers) { + const plainThread = _renderPlaintextThread(plain); + if (plainThread) return _foldSignature(plainThread, data && data.sender_signature || null); + } + + // Prefer the server-cached thread parse — that's the richest structure + // and the one the chat-bubble layout is built around. Skip when the user + // has manually disabled bubble rendering. + if (!_bubblesDisabled() && Array.isArray(data && data.thread_turns) && data.thread_turns.length) { + return _foldSignature( + _renderTurnsAsBubbles(data.thread_turns, data), + data && data.sender_signature || null, + ); + } + const b = data && data.boundaries; + // Use cached boundaries when present AND we have plain-text body to slice + if (b && plain && (b.sig_start >= 0 || b.quote_start >= 0)) { + // Pick the EARLIER of the two as the cut for "everything below this is + // foldable", but render sig and quote with their own labels. + let sig = (typeof b.sig_start === 'number' && b.sig_start >= 0) ? b.sig_start : -1; + let quote = (typeof b.quote_start === 'number' && b.quote_start >= 0) ? b.quote_start : -1; + // Clamp + if (sig >= plain.length) sig = -1; + if (quote >= plain.length) quote = -1; + let head = plain; + let sigSection = ''; + let quoteSection = ''; + if (sig >= 0 && quote >= 0) { + const earlier = Math.min(sig, quote); + head = plain.slice(0, earlier); + if (sig < quote) { + sigSection = plain.slice(sig, quote); + quoteSection = plain.slice(quote); + } else { + quoteSection = plain.slice(quote, sig); + sigSection = plain.slice(sig); + } + } else if (sig >= 0) { + head = plain.slice(0, sig); + sigSection = plain.slice(sig); + } else { + head = plain.slice(0, quote); + quoteSection = plain.slice(quote); + } + const fmt = (s) => _escLinkify(s).replace(/\n/g, '
'); + let out = fmt(head); + if (quoteSection) { + out += ''; + } + if (sigSection) { + const sigHtml = fmt(sigSection); + if (_isBloatedSig(sigHtml)) { + out += ''; + } else { + // Short closing — leave inline; folding would just add chrome. + out += sigHtml; + } + } + return out; + } + // Fallback: client-side parse (HTML or plaintext). + const hintSig = (data && data.sender_signature) || null; + const isHtml = !!data.body_html; + let rendered; + if (isHtml) { + rendered = _sanitizeHtml(data.body_html); + } else { + const plainTurns = _renderPlaintextThread(data.body || ''); + if (plainTurns) return _foldSignature(plainTurns, hintSig); + rendered = _escLinkify(data.body || '').replace(/\n/g, '
'); + } + const threaded = _renderThreadStructure(rendered); + if (threaded) return _prepareEmailInlineImages(_foldSignature(threaded, hintSig)); + return _prepareEmailInlineImages(_foldSignature(_foldQuotedReplies(rendered), hintSig)); +} + +export function _safeRenderEmailBody(data) { + try { + return _prepareEmailInlineImages(_renderEmailBody(data)); + } catch (e) { + console.error('email body render failed:', e); + const plain = (typeof data?.body === 'string') ? data.body : ''; + if (plain) return _escLinkify(plain).replace(/\n/g, '
'); + if (data?.body_html) return _prepareEmailInlineImages(_sanitizeHtml(data.body_html)); + return 'No body'; + } +} + +function _prepareEmailInlineImages(html) { + const raw = String(html || ''); + if (!/]/i.test(raw)) return raw; + const doc = new DOMParser().parseFromString(`
${raw}
`, 'text/html'); + const root = doc.body.firstElementChild; + if (!root) return raw; + root.querySelectorAll('img').forEach((img, idx) => { + const src = (img.getAttribute('src') || '').trim(); + const alt = (img.getAttribute('alt') || img.getAttribute('title') || '').trim(); + const isHttp = /^https?:\/\//i.test(src); + const isCid = /^cid:/i.test(src); + const cid = isCid ? src.replace(/^cid:/i, '').replace(/^<|>$/g, '').trim() : ''; + const label = alt || (isCid ? 'Inline image' : 'Remote image'); + const ph = doc.createElement('span'); + ph.className = 'email-inline-image-placeholder'; + ph.setAttribute('role', 'group'); + ph.setAttribute('aria-label', label); + if (src) ph.dataset.emailImgSrc = src; + if (cid) ph.dataset.emailImgCid = cid; + ph.innerHTML = ` + + + + `; + img.replaceWith(ph); + }); + return root.innerHTML; +} + +export function _wireEmailInlineImages(reader) { + if (!reader) return; + const placeholders = Array.from(reader.querySelectorAll('.email-inline-image-placeholder')); + const visiblePlaceholders = placeholders.filter(ph => ( + ph.isConnected && + (ph.dataset.emailImgSrc || ph.dataset.emailImgCid) && + getComputedStyle(ph).display !== 'none' + )); + const autoLoadInlineImages = state._libViewInlineImages !== false; + const manualPlaceholders = autoLoadInlineImages + ? visiblePlaceholders.filter(ph => !ph.dataset.emailImgCid) + : visiblePlaceholders; + const existingBulkBar = reader.querySelector('.email-inline-image-load-all'); + if (!manualPlaceholders.length) existingBulkBar?.remove(); + const loadPlaceholder = (ph) => { + if (!ph || !ph.isConnected) return; + const src = (ph.dataset.emailImgSrc || '').trim(); + const cid = (ph.dataset.emailImgCid || '').trim(); + const ctxRoot = reader.dataset?.emailUid + ? reader + : ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]'); + const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX'; + const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || ''; + const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || ''; + const inlineUrl = () => { + if (!uid || !cid) return ''; + const params = new URLSearchParams({ cid, folder }); + if (account) params.set('account_id', account); + return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`; + }; + const isRemoteImage = /^https?:\/\//i.test(src); + const isCidImage = !!cid && !isRemoteImage; + const loadSrc = isRemoteImage ? src : inlineUrl(); + if (!loadSrc || ph.classList.contains('is-loading')) return; + ph.classList.remove('is-error'); + ph.classList.add('is-loading'); + const sub = ph.querySelector('.email-inline-image-sub'); + if (sub) sub.textContent = 'Loading image...'; + const actions = ph.querySelector('.email-inline-image-actions'); + if (actions) actions.style.display = 'none'; + const img = document.createElement('img'); + img.className = 'email-inline-image-loaded'; + img.alt = ph.querySelector('.email-inline-image-title')?.textContent || 'Loaded email image'; + img.loading = 'eager'; + img.referrerPolicy = 'no-referrer'; + let settled = false; + let objectUrl = ''; + let frame = null; + let directFallbackUsed = false; + const ensureFrame = () => { + if (frame) return frame; + frame = document.createElement('span'); + frame.className = 'email-inline-image-frame is-loading'; + frame.appendChild(img); + if (ph.isConnected) ph.replaceWith(frame); + return frame; + }; + const showLoaded = () => { + if (settled) return; + settled = true; + if (frame) frame.classList.remove('is-loading'); + img.classList.add('is-visible'); + }; + const showError = (err) => { + if (settled) return; + settled = true; + if (frame?.isConnected) frame.replaceWith(ph); + ph.classList.remove('is-loading'); + ph.classList.add('is-error'); + if (sub) sub.textContent = err?.message ? `Image failed: ${err.message}` : 'Image failed to load'; + if (actions) actions.style.display = ''; + if (objectUrl) { + try { URL.revokeObjectURL(objectUrl); } catch {} + } + }; + const loadDirect = () => { + directFallbackUsed = true; + ensureFrame(); + img.src = loadSrc; + if (img.complete && img.naturalWidth > 0) showLoaded(); + }; + img.addEventListener('load', showLoaded, { once: true }); + img.addEventListener('error', () => { + if (!directFallbackUsed && objectUrl && isCidImage) { + try { URL.revokeObjectURL(objectUrl); } catch {} + objectUrl = ''; + loadDirect(); + return; + } + showError(); + }, { once: true }); + if (isCidImage || isRemoteImage) { + loadDirect(); + setTimeout(() => { + if (!settled && frame) { + frame.classList.add('is-loading'); + } + }, 12000); + return; + } + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 20000); + fetch(loadSrc, { credentials: 'same-origin', signal: controller.signal }) + .then(async res => { + clearTimeout(timeoutId); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + if (!blob || !blob.size) throw new Error('Empty image'); + const type = blob.type && blob.type.startsWith('image/') ? blob.type : 'image/png'; + objectUrl = URL.createObjectURL(blob.type === type ? blob : new Blob([blob], { type })); + ensureFrame(); + img.src = objectUrl; + if (img.complete && img.naturalWidth > 0) showLoaded(); + if (!settled && typeof img.decode === 'function') { + img.decode().then(showLoaded).catch(() => { + if (img.complete && img.naturalWidth === 0) showError(); + }); + } + }) + .catch(err => { + clearTimeout(timeoutId); + showError(err?.name === 'AbortError' ? new Error('request timed out') : err); + }); + setTimeout(() => { + if (!settled && !frame) { + ph.classList.remove('is-loading'); + if (sub) sub.textContent = 'Still loading...'; + if (actions) actions.style.display = ''; + } + }, 12000); + }; + if (manualPlaceholders.length && !reader.querySelector('.email-inline-image-load-all')) { + const first = manualPlaceholders[0]; + const bar = document.createElement('div'); + bar.className = 'email-inline-image-load-all'; + const label = autoLoadInlineImages + ? `${manualPlaceholders.length} external image${manualPlaceholders.length === 1 ? '' : 's'} blocked` + : `${manualPlaceholders.length} hidden inline image${manualPlaceholders.length === 1 ? '' : 's'}`; + bar.innerHTML = ` + ${label} + + + `; + bar.querySelector('[data-email-img-load-all]')?.addEventListener('click', ev => { + ev.preventDefault(); + ev.stopPropagation(); + bar.remove(); + manualPlaceholders.forEach(loadPlaceholder); + }); + bar.querySelector('[data-email-img-dismiss-all]')?.addEventListener('click', ev => { + ev.preventDefault(); + ev.stopPropagation(); + bar.remove(); + manualPlaceholders.forEach(ph => ph.remove()); + _disableInlineImages().catch(() => {}); + }); + first.parentNode?.insertBefore(bar, first); + } + placeholders.forEach(ph => { + if (ph.dataset.wired !== '1') { + ph.dataset.wired = '1'; + const src = (ph.dataset.emailImgSrc || '').trim(); + const cid = (ph.dataset.emailImgCid || '').trim(); + const ctxRoot = reader.dataset?.emailUid + ? reader + : ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]'); + const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX'; + const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || ''; + const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || ''; + const inlineUrl = () => { + if (!uid || !cid) return ''; + const params = new URLSearchParams({ cid, folder }); + if (account) params.set('account_id', account); + return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`; + }; + ph.querySelector('[data-email-img-download]')?.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + const url = inlineUrl(); + if (!url) return; + const a = document.createElement('a'); + a.href = url; + a.download = 'inline-image'; + document.body.appendChild(a); + a.click(); + a.remove(); + }); + } + const cid = (ph.dataset.emailImgCid || '').trim(); + if (autoLoadInlineImages && cid && ph.isConnected && ph.dataset.emailImgAutoRequested !== '1') { + ph.dataset.emailImgAutoRequested = '1'; + loadPlaceholder(ph); + } + }); +} + +// ── Chat-bubble rendering for email threads ── +// Each parsed turn renders as a chat bubble. Bubbles for the active +// account's outgoing replies align right; everyone else aligns left. +// Order is reversed so the oldest message sits at the top of the +// conversation and the newest (the message currently being read) sits +// at the bottom — matches the mental model people have from chat. + +function _meEmailAddrs() { + const set = new Set(); + for (const a of (state._libAccounts || [])) { + if (a && a.from_address) set.add(String(a.from_address).toLowerCase().trim()); + if (a && a.imap_user) set.add(String(a.imap_user).toLowerCase().trim()); + } + return set; +} + +// _parseTurnMeta / _formatBubbleDate / _formatRecipients / _senderColor / +// _initials live in ./emailLibrary/utils.js + +function _renderTurnsAsBubbles(turns, data) { + if (!Array.isArray(turns) || !turns.length) return ''; + const mineSet = _meEmailAddrs(); + const lvl0Email = String(data && data.from_address || '').toLowerCase().trim(); + const lvl0Mine = !!lvl0Email && mineSet.has(lvl0Email); + const lvl0Author = (data && (data.from_name || data.from_address)) || ''; + const lvl0Date = _formatBubbleDate(data && data.date); + + // Newest reply on top, older history below. Turns come ordered shallow→deep + // (level 0 = current reply, deeper levels = older quoted material) so we + // render in source order without reversing. + const ordered = turns.slice(); + + // Gather per-turn sender identity + frequency for the no-self case below. + const turnIdentity = ordered.map((t) => { + if (t.level === 0) { + return { email: lvl0Email, author: lvl0Author }; + } + const p = _parseTurnMeta(t.meta || ''); + return { email: p.email, author: p.author }; + }); + const anyMine = turnIdentity.some(x => x.email && mineSet.has(x.email)); + // When the user isn't a participant in this thread (forwarded chains, + // historical archives, etc.), assign the two most frequent senders to + // opposite sides so the conversation still reads side-to-side. Third+ + // parties fall back to hash mod 2. + const sideForKey = (() => { + if (anyMine) return null; + const freq = new Map(); + const firstSeen = new Map(); + turnIdentity.forEach((x, i) => { + const key = (x.email || x.author || '').toLowerCase(); + if (!key) return; + freq.set(key, (freq.get(key) || 0) + 1); + if (!firstSeen.has(key)) firstSeen.set(key, i); + }); + const sorted = [...freq.entries()] + .sort((a, b) => (b[1] - a[1]) || (firstSeen.get(a[0]) - firstSeen.get(b[0]))); + const leftKey = sorted[0] && sorted[0][0]; + const rightKey = sorted[1] && sorted[1][0]; + return (key) => { + if (!key) return 'theirs'; + if (key === leftKey) return 'theirs'; + if (key === rightKey) return 'mine'; + // Stable hash for 3rd+ parties. + let h = 0; + for (let i = 0; i < key.length; i++) h = ((h << 5) - h + key.charCodeAt(i)) | 0; + return (h & 1) ? 'mine' : 'theirs'; + }; + })(); + + const rows = ordered.map((t, i) => { + let isMine, author, date; + if (t.level === 0) { + isMine = lvl0Mine; + author = lvl0Author || 'Me'; + date = lvl0Date; + } else { + const p = _parseTurnMeta(t.meta || ''); + isMine = !!p.email && mineSet.has(p.email); + author = p.author || (t.meta || 'Earlier reply'); + date = p.date; + } + // No-self fallback: route by per-sender side mapping. + if (sideForKey) { + const id = turnIdentity[i]; + const key = (id.email || id.author || '').toLowerCase(); + isMine = sideForKey(key) === 'mine'; + } + const side = isMine ? 'mine' : 'theirs'; + const initials = _initials(author); + const color = _senderColor(author || (t.level === 0 ? lvl0Email : '')); + const head = + ``; + const avatar = ``; + return ( + `` + ); + }); + return ``; +} + +/** + * Render server-cached thread turns (list of {level, body_html, meta}) + * into the same nested-card structure the client-side parser produces. + */ +function _renderTurnsFromServer(turns) { + if (!Array.isArray(turns) || !turns.length) return ''; + let out = ''; + const stack = []; // [{ level, html }] + const wrap = (t) => + `'; + + for (const t of turns) { + if (t.level === 0) { + while (stack.length) { + const top = stack.pop(); + const w = wrap(top); + if (stack.length) stack[stack.length - 1].html += w; else out += w; + } + out += _sanitizeHtml(t.body_html || ''); + } else { + while (stack.length && stack[stack.length - 1].level > t.level) { + const top = stack.pop(); + const w = wrap(top); + if (stack.length) stack[stack.length - 1].html += w; else out += w; + } + if (!stack.length || stack[stack.length - 1].level < t.level) { + stack.push({ level: t.level, meta: t.meta, html: _sanitizeHtml(t.body_html || '') }); + } else { + stack[stack.length - 1].html += _sanitizeHtml(t.body_html || ''); + if (t.meta && !stack[stack.length - 1].meta) { + stack[stack.length - 1].meta = t.meta; + } + } + } + } + while (stack.length) { + const top = stack.pop(); + const w = wrap(top); + if (stack.length) stack[stack.length - 1].html += w; else out += w; + } + // Mark the bottom-most fold for rounded corners. + const lastIdx = out.lastIndexOf('