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 = ` ++ // 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] + + '`; } -function _showEmailReaderLoadError(reader, message, onRetry) { +export function _showEmailReaderLoadError(reader, message, onRetry) { if (!reader) return; reader.classList.remove('email-card-reader-loading'); reader.classList.add('email-card-reader-error'); @@ -997,7 +178,7 @@ function _showEmailReaderLoadError(reader, message, onRetry) { _markEmailReaderActive(reader); } -function _openCalendarEventFromEmail(uid) { +export function _openCalendarEventFromEmail(uid) { const target = String(uid || '').trim(); if (!target) return; import('../calendar.js?v=20260914emailsource11').then(mod => { @@ -1085,18 +266,18 @@ function _visibleEmailTagsForRender(em) { return tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-'))); } -function _clearDoneResponseTagsLocal(em) { +export function _clearDoneResponseTagsLocal(em) { if (!em || !Array.isArray(em.tags)) return; em.tags = em.tags.filter(t => !_DONE_RESPONSE_TAGS.has(String(t || '').trim().toLowerCase().replace(/_/g, '-'))); } -function _loadedEmailsHaveVisibleTags() { +export function _loadedEmailsHaveVisibleTags() { return (state._libEmails || []).some(em => _visibleEmailTagsForRender(em).length > 0 || !!em?.is_spam_verdict ); } -async function _openTasksForEmailTags() { +export async function _openTasksForEmailTags() { try { const mod = await import('../tasks.js?v=20260914taskmodel1'); const openTasks = mod.openTasks || mod.default?.openTasks; @@ -1108,7 +289,7 @@ async function _openTasksForEmailTags() { document.getElementById('tool-tasks-btn')?.click(); } -function _notifyNoLoadedEmailTags() { +export function _notifyNoLoadedEmailTags() { const count = (state._libEmails || []).length; const msg = count ? `No tags found in the ${count} loaded emails.` @@ -1123,7 +304,7 @@ function _notifyNoLoadedEmailTags() { // Stash the email identity (uid + folder + account) on the reader element // so chat submits and other code paths can ask "what email is the user // currently looking at?" without re-deriving from the DOM hierarchy. -function _stampReaderContext(reader, em, folder, account) { +export function _stampReaderContext(reader, em, folder, account) { if (!reader || !em) return; reader.dataset.emailUid = String(em.uid || ''); reader.dataset.emailFolder = String(folder || state._libFolder || 'INBOX'); @@ -1182,7 +363,7 @@ function _emailAddressFromRecipientText(text) { return any ? any[0].trim() : raw; } -function _splitRecipientList(raw) { +export function _splitRecipientList(raw) { const out = []; let cur = ''; let quote = false; @@ -1233,7 +414,7 @@ async function _copyTextToClipboard(text) { } } -function _wireMetaToggle(root) { +export function _wireMetaToggle(root) { const toggle = root && root.querySelector('.email-reader-meta-toggle'); const details = root && root.querySelector('.email-reader-meta-details'); if (!toggle || !details) return; @@ -1249,7 +430,7 @@ function _wireMetaToggle(root) { }); } -function _recipientMetaToggleHtml(data = {}) { +export function _recipientMetaToggleHtml(data = {}) { const ccCount = _splitRecipientList(data.cc || '').length; if (ccCount) { return ``; @@ -1258,7 +439,7 @@ function _recipientMetaToggleHtml(data = {}) { return ``; } -function _recipientChipHtml(full, label, extraClass = '') { +export function _recipientChipHtml(full, label, extraClass = '') { const fullText = String(full || '').trim(); const addr = _emailAddressFromRecipientText(fullText); const labelText = String(label || addr || fullText || '').trim(); @@ -1339,7 +520,7 @@ function _showRecipientChipPopover(chip) { return true; } -function _wireRecipientChips(root) { +export function _wireRecipientChips(root) { if (!root || root.dataset.recipientChipsWired === '1') return; root.dataset.recipientChipsWired = '1'; root.addEventListener('click', async (ev) => { @@ -1429,7 +610,7 @@ function _emailMatchesReadContext(email, context) { return accountId === context.accountId && folder === context.folder; } -function _syncEmailReadState(uid, isRead = true, context = null) { +export function _syncEmailReadState(uid, isRead = true, context = null) { if (uid == null) return; const uidStr = String(uid); const read = !!isRead; @@ -1472,7 +653,7 @@ function _syncEmailReadState(uid, isRead = true, context = null) { }); } -function _syncEmailDoneState(uid, isDone = true, context = null) { +export function _syncEmailDoneState(uid, isDone = true, context = null) { if (uid == null) return; const uidStr = String(uid); const done = !!isDone; @@ -1740,7 +921,7 @@ function _hasDesktopRoomForEmailAndDocument(modal, opts = {}) { return (window.innerWidth - leftEdge - emailWidth) >= (docMinWidth + breathingRoom); } -function _prepareEmailWindowForDocument(modal) { +export function _prepareEmailWindowForDocument(modal) { if (window.innerWidth <= 768) return true; if (!modal) return false; // Try to make breathing room by collapsing the wide sidebar to the rail @@ -1852,7 +1033,7 @@ async function _deleteEmailAndAdvance(em, card, opts = {}) { } } -function _showEmailDeleteOverlay(target) { +export function _showEmailDeleteOverlay(target) { if (!target) return null; const wp = spinnerModule.createWhirlpool(18); const overlay = document.createElement('div'); @@ -1877,7 +1058,7 @@ function _showEmailDeleteOverlay(target) { }; } -function _animateEmailCardRemoval(uids, opts = {}) { +export 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'); @@ -1941,7 +1122,7 @@ function _filenameFromContentDisposition(header) { return bare ? bare[1].trim() : ''; } -async function _exportSelectedAttachments() { +export async function _exportSelectedAttachments() { const messages = _selectedEmailExportPayload(); if (!messages.length) { showToast('Select emails first'); @@ -2061,11 +1242,11 @@ function _wireEmailSetupHint(root) { }); } -function _acct() { +export function _acct() { return state._libAccountId ? `&account_id=${encodeURIComponent(state._libAccountId)}` : ''; } -function _emailMutationQuery(em, fallbackFolder = state._libFolder) { +export function _emailMutationQuery(em, fallbackFolder = state._libFolder) { const folder = String(em?.folder || fallbackFolder || 'INBOX'); const accountId = em?.account_id || state._libAccountId || ''; const params = new URLSearchParams({ folder }); @@ -2074,7 +1255,7 @@ function _emailMutationQuery(em, fallbackFolder = state._libFolder) { return params.toString(); } -async function _requireSuccessfulEmailMutation(response, fallback = 'Email operation failed') { +export async function _requireSuccessfulEmailMutation(response, fallback = 'Email operation failed') { const data = await response.json().catch(() => null); if (!response.ok || data?.success !== true) { throw new Error(data?.error || `${fallback} (${response.status})`); @@ -2082,1212 +1263,6 @@ async function _requireSuccessfulEmailMutation(response, fallback = 'Email opera return data; } -function _unsubscribeMethodLabel(method) { - if (!method) return 'No unsubscribe method'; - if (method.kind === 'mailto') return 'Request Unsubscribe'; - if (method.kind === 'url') return 'Link Unsubscribe'; - return method.target || method.kind || 'Unsubscribe'; -} - -function _unsubscribeFolderLabel(folder) { - const value = String(folder || 'INBOX').trim(); - return value.toUpperCase() === 'INBOX' ? 'Inbox' : value; -} - -function _setUnsubButtonBusy(btn, label) { - if (!btn) return null; - const previous = btn.innerHTML; - const previousDisplay = btn.style.display; - const previousAlignItems = btn.style.alignItems; - const previousJustifyContent = btn.style.justifyContent; - const previousGap = btn.style.gap; - const previousWhiteSpace = btn.style.whiteSpace; - btn.disabled = true; - btn.style.display = 'inline-flex'; - btn.style.alignItems = 'center'; - btn.style.justifyContent = 'center'; - btn.style.gap = '5px'; - btn.style.whiteSpace = 'nowrap'; - btn.innerHTML = ''; - const sp = spinnerModule.createWhirlpool(14); - sp.element.style.position = 'relative'; - sp.element.style.top = '-2px'; - sp.element.style.flexShrink = '0'; - sp.element.style.margin = '0'; - sp.element.style.display = 'inline-flex'; - btn.appendChild(sp.element); - const text = document.createElement('span'); - text.textContent = label || 'Working'; - text.className = 'email-unsub-busy-label'; - text.style.whiteSpace = 'nowrap'; - text.style.display = 'inline-block'; - btn.appendChild(text); - const restore = () => { - btn.disabled = false; - btn.innerHTML = previous; - btn.style.display = previousDisplay; - btn.style.alignItems = previousAlignItems; - btn.style.justifyContent = previousJustifyContent; - btn.style.gap = previousGap; - btn.style.whiteSpace = previousWhiteSpace; - }; - restore.setLabel = (next) => { text.textContent = next || label || 'Working'; }; - return restore; -} - -const _UNSUB_EMAIL_ICON = ''; -const _UNSUB_CHECK_ICON = ''; -const _UNSUB_CLOSE_ICON = ''; -const _UNSUB_AGENT_ICON = ''; -const _UNSUB_TRASH_ICON = ''; -const _UNSUB_REFRESH_ICON = ''; -const _UNSUB_EXTERNAL_ICON = ''; - -function _setUnsubStatusBusy(statusEl, label) { - if (!statusEl) return null; - statusEl.classList.remove('is-error'); - statusEl.classList.add('is-busy'); - statusEl.innerHTML = ''; - statusEl.style.display = 'inline-flex'; - statusEl.style.alignItems = 'center'; - statusEl.style.gap = '6px'; - statusEl.style.justifyContent = 'flex-end'; - statusEl.style.width = '100%'; - const text = document.createElement('span'); - text.textContent = label || 'Scanning…'; - statusEl.appendChild(text); - const sp = spinnerModule.createWhirlpool(14); - sp.element.style.position = 'relative'; - sp.element.style.top = '0'; - sp.element.style.flexShrink = '0'; - sp.element.style.margin = '0'; - sp.element.style.display = 'inline-flex'; - statusEl.appendChild(sp.element); - return (next) => { - statusEl.style.display = ''; - statusEl.style.alignItems = ''; - statusEl.style.gap = ''; - statusEl.style.justifyContent = ''; - statusEl.style.width = ''; - statusEl.classList.remove('is-busy'); - statusEl.textContent = next || ''; - }; -} - -function _askAgentToUnsubscribe(candidate) { - const methods = Array.isArray(candidate?.methods) ? candidate.methods : []; - const recommended = candidate?.recommended_method; - const method = (recommended && typeof recommended === 'object') - ? recommended - : methods.find(m => m.kind === 'url') || methods[0] || null; - const url = method?.kind === 'url' ? method.target : ''; - const methodIndex = Math.max(0, methods.findIndex(m => ( - m === method || (m?.kind === method?.kind && m?.target === method?.target) - ))); - const uid = candidate?.uid || ''; - const reviewedUids = _unsubscribeCandidateUids(candidate); - const folder = candidate?.folder || state._libFolder || 'INBOX'; - const account = state._libAccountId || ''; - const prompt = url - ? `The user explicitly clicked Agent Unsubscribe for this reviewed email. Use the private_browser tool to open this exact unsubscribe URL and complete only the unsubscribe flow. Do not use web_search, web_fetch, bash, or scan_email_unsubscribes. After the page confirms a successful unsubscribe, use bulk_email action=delete on these exact reviewed UID(s) so they are not rediscovered. If the page is ambiguous or unsubscribe fails, stop and report that clearly so the user can choose Spam + delete; do not delete a different message.\n\nEmail UID(s): ${reviewedUids.join(', ') || uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nExact unsubscribe URL: ${url}` - : `The user explicitly clicked Agent Unsubscribe for this reviewed email. Use unsubscribe_email directly for the already-reviewed candidate below. Do not call scan_email_unsubscribes again.\n\nEmail UID: ${uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nReviewed method_index: ${methodIndex}`; - return _submitAgentUnsubscribePrompt(prompt); -} - -function _submitAgentUnsubscribePrompt(prompt) { - const input = document.getElementById('message') || document.getElementById('message-input'); - const form = document.getElementById('chat-form'); - if (!input || !form) { - showToast?.('Chat composer not found'); - return false; - } - input.value = prompt; - input.dispatchEvent(new Event('input', { bubbles: true })); - input.focus(); - // Match the working slash-command path. The app-level submit guard can - // still be settling when this is called from a modal button. - setTimeout(() => { - if (!document.body.contains(input) || !document.body.contains(form)) return; - try { - form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); - } catch (err) { - console.error('agent unsubscribe submit failed', err); - showToast?.('Could not start agent unsubscribe'); - } - }, 350); - return true; -} - -function _agentUnsubscribeGroups(candidates) { - const groups = new Map(); - (candidates || []).forEach(candidate => { - const urlMethod = (candidate?.methods || []).find(method => method?.kind === 'url'); - const sender = String(candidate?.from_address || '').trim(); - const key = sender ? `sender:${sender.toLowerCase()}` : `uid:${_unsubscribeCandidateUids(candidate).join(',')}`; - let group = groups.get(key); - if (!group) { - group = { - subject: String(candidate?.subject || '(no subject)'), - sender, - uids: [], - url: String(urlMethod?.target || ''), - subjects: [], - }; - groups.set(key, group); - } - _unsubscribeCandidateUids(candidate).forEach(uid => { - if (!group.uids.includes(uid)) group.uids.push(uid); - }); - const subject = String(candidate?.subject || '').trim(); - if (subject && !group.subjects.includes(subject)) group.subjects.push(subject); - if (!group.url && urlMethod?.target) group.url = String(urlMethod.target); - }); - return Array.from(groups.values()); -} - -function _askAgentToUnsubscribeAll(candidates) { - const reviewed = _agentUnsubscribeGroups(candidates) - .filter(item => item.uids.length || item.url); - if (!reviewed.length) { - showToast?.('No remaining unsubscribe candidates'); - return false; - } - const account = state._libAccountId || ''; - const lines = reviewed.map((item, index) => ( - `${index + 1}. ${item.sender || '(unknown sender)'} | ${item.subjects.slice(0, 3).join(' / ') || item.subject} | UID(s): ${item.uids.join(', ') || '(none)'} | URL: ${item.url || '(none)'}` - )).join('\n'); - const prompt = `The user explicitly chose Agent Unsubscribe All for these already-reviewed unsubscribe candidates. They are grouped by sender: open at most one unsubscribe flow per sender and never repeat an unsubscribe attempt for the same sender. Use the private_browser tool for each exact unsubscribe URL. Do not use web_search, web_fetch, bash, or scan_email_unsubscribes. If a page is ambiguous or unsubscribe fails, skip that sender and report it. After a successful unsubscribe, use bulk_email action=delete only for that sender group's exact reviewed UID(s), so those messages are not rediscovered. Do not delete any other messages. Report successful, skipped, and failed senders clearly.\n\nFolder: ${state._libFolder || 'INBOX'}\nAccount: ${account || '(default)'}\n\nReviewed sender groups (${reviewed.length}):\n${lines}`; - return _submitAgentUnsubscribePrompt(prompt); -} - -function _unsubscribeCandidateUids(candidate) { - const out = []; - const seen = new Set(); - const add = (uid) => { - const val = String(uid || '').trim(); - if (!val || seen.has(val)) return; - seen.add(val); - out.push(val); - }; - add(candidate?.uid); - (candidate?.duplicate_uids || []).forEach(add); - return out; -} - -function _unsubscribeHandledStorageKey() { - return `odysseus.email.unsubscribeHandled.${String(state._libAccountId || 'default')}`; -} - -function _unsubscribeScanStorageKey() { - const account = String(state._libAccountId || 'default'); - const folder = String(state._libFolder || 'INBOX'); - return `odysseus.email.unsubscribeScan.v2.${account}.${folder}`; -} - -function _readUnsubscribeScanCache() { - try { - const parsed = JSON.parse(localStorage.getItem(_unsubscribeScanStorageKey()) || 'null'); - return parsed && Array.isArray(parsed.candidates) ? parsed : null; - } catch (_) { - return null; - } -} - -function _writeUnsubscribeScanCache(data, scanLimit) { - if (!data || !Array.isArray(data.candidates)) return; - try { - localStorage.setItem(_unsubscribeScanStorageKey(), JSON.stringify({ - candidates: data.candidates, - scanned: Number(data.scanned || 0), - raw_total: Number(data.raw_total || data.candidates.length || 0), - scanLimit: scanLimit == null ? 180 : Number(scanLimit), - scanMode: Number(scanLimit || 0) > 0 ? 'limited' : 'whole-folder', - savedAt: Date.now(), - })); - } catch (_) {} -} - -function _readHandledUnsubscribeState() { - try { - const parsed = JSON.parse(localStorage.getItem(_unsubscribeHandledStorageKey()) || '{}'); - return { - uids: new Set(Array.isArray(parsed.uids) ? parsed.uids.map(String) : []), - senders: new Set(Array.isArray(parsed.senders) ? parsed.senders.map(v => String(v).toLowerCase()) : []), - ignoredUids: new Set(Array.isArray(parsed.ignoredUids) ? parsed.ignoredUids.map(String) : []), - ignoredSenders: new Set(Array.isArray(parsed.ignoredSenders) ? parsed.ignoredSenders.map(v => String(v).toLowerCase()) : []), - }; - } catch (_) { - return { uids: new Set(), senders: new Set(), ignoredUids: new Set(), ignoredSenders: new Set() }; - } -} - -function _rememberUnsubscribeHandled(candidates) { - const current = _readHandledUnsubscribeState(); - (candidates || []).forEach(candidate => { - _unsubscribeCandidateUids(candidate).forEach(uid => current.uids.add(String(uid))); - const sender = String(candidate?.from_address || '').trim().toLowerCase(); - if (sender) current.senders.add(sender); - }); - try { - localStorage.setItem(_unsubscribeHandledStorageKey(), JSON.stringify({ - uids: Array.from(current.uids).slice(-2000), - senders: Array.from(current.senders).slice(-500), - ignoredUids: Array.from(current.ignoredUids).slice(-2000), - ignoredSenders: Array.from(current.ignoredSenders).slice(-500), - })); - } catch (_) {} -} - -function _rememberUnsubscribeIgnored(candidate) { - const current = _readHandledUnsubscribeState(); - _unsubscribeCandidateUids(candidate).forEach(uid => current.ignoredUids.add(String(uid))); - const sender = String(candidate?.from_address || '').trim().toLowerCase(); - if (sender) current.ignoredSenders.add(sender); - try { - localStorage.setItem(_unsubscribeHandledStorageKey(), JSON.stringify({ - uids: Array.from(current.uids).slice(-2000), - senders: Array.from(current.senders).slice(-500), - ignoredUids: Array.from(current.ignoredUids).slice(-2000), - ignoredSenders: Array.from(current.ignoredSenders).slice(-500), - })); - } catch (_) {} -} - -function _filterHandledUnsubscribeCandidates(candidates) { - const handled = _readHandledUnsubscribeState(); - return (candidates || []).flatMap(candidate => { - const sender = String(candidate?.from_address || '').trim().toLowerCase(); - if (sender && (handled.senders.has(sender) || handled.ignoredSenders.has(sender))) return []; - const remainingUids = _unsubscribeCandidateUids(candidate) - .filter(uid => !handled.uids.has(String(uid)) && !handled.ignoredUids.has(String(uid))); - if (!remainingUids.length) return []; - if (remainingUids.length === _unsubscribeCandidateUids(candidate).length) return [candidate]; - return [{ - ...candidate, - uid: remainingUids[0], - duplicate_uids: remainingUids.slice(1), - duplicate_count: remainingUids.length, - }]; - }); -} - -function _dedupeUnsubscribeCandidatesForDisplay(candidates) { - const out = []; - const seen = new Map(); - (candidates || []).forEach(c => { - const method = c?.recommended_method || {}; - const urlMethod = (c?.methods || []).find(m => m?.kind === 'url'); - // One sender address should produce one review action, even when every - // message carries a different tokenized unsubscribe URL. - const sender = String(c?.from_address || '').trim().toLowerCase(); - const key = sender || String(c?.list_id || urlMethod?.target || method.target || c?.uid || '').trim().toLowerCase(); - if (!key || !seen.has(key)) { - if (key) seen.set(key, c); - out.push(c); - return; - } - const existing = seen.get(key); - existing.duplicate_count = Number(existing.duplicate_count || 1) + Number(c.duplicate_count || 1); - const uidSet = new Set(_unsubscribeCandidateUids(existing)); - _unsubscribeCandidateUids(c).forEach(uid => uidSet.add(uid)); - existing.duplicate_uids = Array.from(uidSet); - }); - return out; -} - -function _markUnsubscribeCardDone(modal, idx, label = 'Unsubscribed') { - const card = modal?.querySelector?.(`.email-unsub-card[data-idx="${idx}"]`); - if (!card) return; - card.classList.add('is-unsubscribed'); - if (!card.querySelector('.email-unsub-done-badge')) { - const badgeHost = card.querySelector('.email-unsub-subject-line') || card.querySelector('.email-unsub-card-top'); - badgeHost?.insertAdjacentHTML('beforeend', `${_UNSUB_CHECK_ICON}${_esc(label)}`); - } - card.querySelectorAll('.email-unsub-link-btn, .email-unsub-agent-btn, .email-unsub-send-btn, .email-unsub-done-btn, .email-unsub-ignore-btn').forEach(el => { - if (el.tagName === 'A') { - el.setAttribute('aria-disabled', 'true'); - el.style.pointerEvents = 'none'; - } else { - el.disabled = true; - } - el.style.opacity = '0.55'; - }); -} - -function _agentToolArgs(data) { - const candidates = [data?.args, data?.tool_args, data?.command]; - for (const value of candidates) { - if (value && typeof value === 'object' && !Array.isArray(value)) return value; - if (typeof value !== 'string' || !value.trim()) continue; - try { - const parsed = JSON.parse(value); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; - } catch (_) { - // Some tool cards prefix the JSON with a display label. Recover the - // object so email mutations still reconcile from the streamed event. - const start = value.indexOf('{'); - const end = value.lastIndexOf('}'); - if (start >= 0 && end > start) { - try { - const parsed = JSON.parse(value.slice(start, end + 1)); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; - } catch (_) {} - } - } - } - return {}; -} - -function _agentBrowserUnsubscribeSucceeded(data) { - const tool = String(data?.tool || '').toLowerCase(); - if (!tool.includes('private_browser')) return false; - const output = String(data?.output || ''); - if (/\b(?:not|never|failed|unable|could not|cannot|can't)\b[^.!?\n]{0,50}\bunsubscrib/i.test(output)) { - return false; - } - return /\b(?:already\s+unsubscribed|you(?:'re|\s+are)\s+(?:now\s+)?unsubscribed|successfully\s+unsubscribed|unsubscribed\s+(?:successfully|from)|unsubscription\s+(?:successful|complete)|removed\s+from\s+(?:the\s+)?(?:mailing|email)\s+list)\b/i.test(output); -} - -function _agentBrowserCandidate(run, args) { - const url = String(args?.url || '').trim(); - if (url && Array.isArray(run?.candidates)) { - const matched = run.candidates.find(candidate => ( - (candidate?.methods || []).some(method => method?.kind === 'url' && String(method.target || '') === url) - )); - if (matched) return matched; - } - return run?.activeCandidate || null; -} - -function _trackAgentBrowserUnsubscribe(data) { - const modal = document.getElementById('email-unsubscribe-review-modal'); - const run = modal?._agentUnsubscribeRun; - if (!run) return; - const args = _agentToolArgs(data); - const action = String(args.action || data?.action || '').toLowerCase(); - const candidate = _agentBrowserCandidate(run, args); - if (action === 'open' && candidate) run.activeCandidate = candidate; - if (!candidate || !_agentBrowserUnsubscribeSucceeded(data)) return; - run.cleanupInFlight ||= new Set(); - if (run.cleanupInFlight.has(candidate) || run.completed.has(candidate)) return; - run.cleanupInFlight.add(candidate); - _deleteAfterUnsubscribe([candidate]).then(result => { - if (Number(result?.failed || 0) > 0 || Number(result?.changed || 0) <= 0) { - run.failed ||= new Set(); - run.failed.add(candidate); - run.restoreBusy?.setLabel?.(`Unsubscribed, delete failed (${run.completed.size} / ${run.total})`); - modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( - 'Unsubscribe succeeded, but the matching email could not be moved to Trash. Review it below.', - )); - return; - } - const idx = run.candidates.indexOf(candidate); - run.completed.add(candidate); - _markUnsubscribeCardDone(modal, idx, 'Unsubscribed'); - run.restoreBusy?.setLabel?.(`Unsubscribed ${run.completed.size} / ${run.total}`); - }).catch(error => { - run.failed ||= new Set(); - run.failed.add(candidate); - run.restoreBusy?.setLabel?.(`Unsubscribed, delete failed (${run.completed.size} / ${run.total})`); - modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( - `Unsubscribe succeeded, but delete failed: ${error?.message || 'mail server error'}`, - )); - }); -} - -function _agentDeletedEmailUids(data) { - const tool = String(data?.tool || '').toLowerCase(); - const args = _agentToolArgs(data); - const action = String(args.action || data.action || '').toLowerCase(); - const output = String(data?.output || '').toLowerCase(); - const failed = /\b(?:failed|error|could not|unable|not found)\b/.test(output); - const isBulkDelete = tool.includes('bulk_email') - && ['delete', 'trash', 'move_to_trash'].includes(action); - const isSingleDelete = tool.endsWith('delete_email') - && !tool.includes('bulk_email'); - const isSuccessfulMailtoUnsubscribe = tool.includes('unsubscribe_email') - && !failed - && /(?:source email moved to trash|deleted|unsubscribe email sent)/.test(output); - if (!isBulkDelete && !isSingleDelete && !isSuccessfulMailtoUnsubscribe) return []; - if ((isBulkDelete || isSingleDelete) && failed) return []; - const values = [ - ...(Array.isArray(args.uids) ? args.uids : []), - ...(Array.isArray(data.deleted_uids) ? data.deleted_uids : []), - ...(Array.isArray(data.cleaned_uids) ? data.cleaned_uids : []), - ]; - if (args.uid != null) values.push(args.uid); - if (data.uid != null) values.push(data.uid); - return Array.from(new Set(values.map(uid => String(uid || '').trim()).filter(Boolean))); -} - -function _handleAgentEmailToolOutput(event) { - const data = event?.detail || {}; - _trackAgentBrowserUnsubscribe(data); - const ok = data.exit_code == null ? data.success !== false : data.exit_code === 0; - if (!ok) return; - const deletedUids = new Set(_agentDeletedEmailUids(data)); - if (!deletedUids.size) return; - - state._libEmails = (state._libEmails || []).filter(email => !deletedUids.has(String(email?.uid || ''))); - try { _libCacheWriteBack(); } catch (_) {} - try { _renderGrid(); } catch (_) {} - - const modal = document.getElementById('email-unsubscribe-review-modal'); - const completed = []; - (modal?._unsubscribeCandidates || []).forEach((candidate, idx) => { - const remaining = _unsubscribeCandidateUids(candidate) - .filter(uid => !deletedUids.has(String(uid))); - if (remaining.length === _unsubscribeCandidateUids(candidate).length) return; - if (!remaining.length) { - completed.push(candidate); - _markUnsubscribeCardDone(modal, idx, 'Agent done'); - return; - } - candidate.uid = remaining[0]; - candidate.duplicate_uids = remaining.slice(1); - candidate.duplicate_count = remaining.length; - }); - const run = modal?._agentUnsubscribeRun; - if (run && completed.length) { - completed.forEach(candidate => run.completed.add(candidate)); - run.restoreBusy?.setLabel?.(`Unsubscribed ${run.completed.size} / ${run.total}`); - } - if (completed.length) _rememberUnsubscribeHandled(completed); - const visible = (modal?._unsubscribeCandidates || []).filter(candidate => ( - _unsubscribeCandidateUids(candidate).some(uid => !deletedUids.has(String(uid))) - )); - if (modal && !visible.length) { - if (run) { - run.restoreBusy?.setLabel?.(`Unsubscribed ${run.total} / ${run.total}`); - run.restoreBusy = null; - modal._agentUnsubscribeRun = null; - } - modal.querySelector('.email-unsub-list')?.style.setProperty('display', 'none'); - modal.querySelector('.email-unsub-followup')?.remove(); - modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode('Agent unsubscribe cleanup completed.')); - } -} - -window.addEventListener('odysseus:agent-tool-output', _handleAgentEmailToolOutput); - -function _handleAgentUnsubscribeTerminal(event) { - const modal = document.getElementById('email-unsubscribe-review-modal'); - const run = modal?._agentUnsubscribeRun; - if (!run) return; - const detail = event?.detail?.data || {}; - (Array.isArray(detail.tool_events) ? detail.tool_events : []).forEach(_trackAgentBrowserUnsubscribe); - const completed = run.completed.size; - const remaining = Math.max(0, run.total - completed); - const failure = detail.failure || (detail.failed ? { message: 'The agent run failed before all unsubscribe flows completed.' } : null); - run.restoreBusy?.(); - modal._agentUnsubscribeRun = null; - modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( - failure - ? `Agent unsubscribe failed: ${completed} / ${run.total} completed. ${remaining} still need review. ${failure.message || ''}`.trim() - : remaining - ? `Agent unsubscribe finished: ${completed} / ${run.total} completed. ${remaining} still need review.` - : `Agent unsubscribe finished: ${completed} / ${run.total} completed.`, - )); - // A tool result can arrive just after the final browser step. Re-fetch once - // the run is over so an old mailbox page or server-side list cache cannot - // leave deleted messages visible until the next manual refresh. - if (!failure && typeof refreshEmailLibrary === 'function') { - setTimeout(() => refreshEmailLibrary().catch(() => {}), 0); - } -} - -window.addEventListener('odysseus:agent-terminal', _handleAgentUnsubscribeTerminal); - -async function _runUnsubscribeCleanup(modal, candidates, action, btn) { - const uids = []; - const seen = new Set(); - (candidates || []).forEach(c => { - _unsubscribeCandidateUids(c).forEach(uid => { - if (seen.has(uid)) return; - seen.add(uid); - uids.push(uid); - }); - }); - if (!uids.length) { - showToast?.('No emails to update'); - return; - } - const busyLabel = action === 'junk' - ? 'Marking spam' - : action === 'junk_delete' ? 'Spam + delete' : 'Deleting'; - const restoreBusy = _setUnsubButtonBusy(btn, busyLabel); - try { - const r = await fetch(emailApiUrl('/api/email/unsubscribe/cleanup', { - account_id: state._libAccountId || undefined, - }), { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - action, - uids, - folder: state._libFolder || 'INBOX', - account_id: state._libAccountId || '', - }), - }); - const d = await r.json().catch(() => ({})); - if (!d.success) throw new Error(d.error || 'Cleanup failed'); - const removed = new Set(uids.map(uid => String(uid))); - state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); - try { _libCacheWriteBack(); } catch (_) {} - try { _renderGrid(); } catch (_) {} - _rememberUnsubscribeHandled(candidates); - modal.querySelector('.email-unsub-followup')?.remove(); - showToast?.(action === 'junk' - ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam` - : action === 'junk_delete' - ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam and deleted ${d.changed || 0}` - : `Deleted ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'}`); - } catch (err) { - console.error(err); - showToast?.(err?.message || 'Cleanup failed'); - } finally { - restoreBusy?.(); - } -} - -async function _deleteAfterUnsubscribe(candidates) { - const groups = new Map(); - (candidates || []).forEach(c => { - const sender = String(c?.from_address || '').trim(); - const key = sender.toLowerCase() || `uids:${_unsubscribeCandidateUids(c).join(',')}`; - if (!groups.has(key)) groups.set(key, { sender, uids: [] }); - const group = groups.get(key); - const seen = new Set(group.uids); - _unsubscribeCandidateUids(c).forEach(uid => { - if (!seen.has(uid)) { - seen.add(uid); - group.uids.push(uid); - } - }); - }); - if (!groups.size) return { success: true, changed: 0, failed: 0, cleaned_uids: [] }; - const result = { success: true, changed: 0, failed: 0, cleaned_uids: [] }; - for (const group of groups.values()) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30000); - let r; - try { - r = await fetch(emailApiUrl('/api/email/unsubscribe/cleanup', { - account_id: state._libAccountId || undefined, - }), { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - signal: controller.signal, - body: JSON.stringify({ - action: 'delete', - scope: group.sender ? 'sender_unsubscribe' : '', - sender: group.sender, - uids: group.sender ? [] : group.uids, - folder: state._libFolder || 'INBOX', - account_id: state._libAccountId || '', - }), - }); - } catch (err) { - if (err?.name === 'AbortError') throw new Error('Deleting timed out; the mail server did not respond'); - throw err; - } finally { - clearTimeout(timeout); - } - const d = await r.json().catch(() => ({})); - if (!d.success) throw new Error(d.error || 'Could not move unsubscribed emails to Trash'); - result.changed += Number(d.changed || 0); - result.failed += Number(d.failed || 0); - result.cleaned_uids.push(...(d.cleaned_uids || [])); - } - const removed = new Set(result.cleaned_uids.map(uid => String(uid))); - state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); - try { _libCacheWriteBack(); } catch (_) {} - try { _renderGrid(); } catch (_) {} - _rememberUnsubscribeHandled(candidates); - return result; -} - -function _showUnsubscribeCleanupPrompt(modal, candidates, options = {}) { - if (!modal || !Array.isArray(candidates) || !candidates.length) return; - modal.querySelector('.email-unsub-followup')?.remove(); - const count = candidates.reduce((sum, c) => sum + _unsubscribeCandidateUids(c).length, 0); - const heading = options.heading || `Done. Mark all ${count} of these email${count === 1 ? '' : 's'} as spam?`; - const rows = candidates.map(c => { - const uids = _unsubscribeCandidateUids(c); - return ` -' + + _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2])) + + m[2] + ''; + } + return html; +} diff --git a/static/js/emailLibrary/index.js b/static/js/emailLibrary/index.js index 196241e88..7e48b29ea 100644 --- a/static/js/emailLibrary/index.js +++ b/static/js/emailLibrary/index.js @@ -11,32 +11,59 @@ import { styledConfirm, showToast, emptyStateIcon } from '../ui.js?v=20260916lar import { folderDisplayName, sortedFolders } from '../emailInbox.js?v=20260914aireply4'; import settingsModule from '../settings.js?v=20260912writingstyle3'; import * as Modals from '../modalManager.js'; -import { topPortalZ } from '../toolWindowZOrder.js'; import { makeWindowDraggable } from '../windowDrag.js'; -import { orderActionMenuItems, actionMenuRank, SELECT_MENU_ICON } from '../actionMenuOrder.js'; import { - _esc, _escLinkify, _extractName, _parseTurnMeta, - _formatBubbleDate, _formatRecipients, _senderColor, _initials, - _sanitizeHtml, _renderEmailSummaryError, - _TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO, - _TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS, + _esc, + _escLinkify, + _extractName, + _formatRecipients, + _senderColor, + _sanitizeHtml, } from './utils.js'; -import { - _looksLikeSignature, _harvestAttribution, _extractTurnMetaFromBlockquote, - _foldSummary, _extractQuoteMeta, _peelSigNameLine, _isBloatedSig, - _tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON, -} from './signatureFold.js'; import { state } from './state.js'; import { getSettings } from '../appConfig.js'; import { collapseSidebarToRail } from '../modalSnap.js'; import { emailApiUrl } from '../emailShared.js'; import { bindMenuDismiss, - dismissOrRemove, dismissTopMenu, bindExpandedCardDismiss, unbindExpandedCardDismiss, } from '../escMenuStack.js'; +import { _aiReplyIcon, _handleAiReplyButton, _hasMultipleRecipients } from './aiReply.js'; +import { + _buildAttsHtmlFor, + _loadDeferredAttachmentsIntoReader, + _wireEmailAttachmentWrap, +} from './attachments.js'; +import { _safeRenderEmailBody, _wireEmailInlineImages } from './bodyRender.js'; +import { + _bulkAction, + _showBulkActionsMenu, + _showCardMenu, + _showReaderMoreMenu, + _updateBulkBar, +} from './menus.js'; +import { _emailBodyTextForTranslate, _openEmailAsTab, _summarizeEmail } from './reader.js'; +import { + _EMAIL_SETTINGS_ICON, + _bindEmailSettingsPageControls, + _emailCleanupSettingsHtml, + _emailDisplaySettingsHtml, + _emailSettingsAccountSelectHtml, + _emailSettingsFormHtml, + _emailSettingsLoadingHtml, + _emailWritingStyleHtml, + _fetchEmailSettingsConfig, + _fetchEmailWritingStyle, + _hideEmailSettingsPage, + _isAutoReplyActiveForCurrentAccount, + _readEmailInlineImagesPreference, + _showEmailSettingsPage, + _syncAutoReplyCalendarEvent, + _syncEmailAutoReplyTitle, + _syncEmailSettingsAccountPicker, +} from './settingsPage.js'; const API_BASE = window.location.origin; let _emailUnreadChipClickWired = false; @@ -54,7 +81,6 @@ let _libAccountsLoadedAt = 0; const _LIB_ACCOUNTS_TTL_MS = 5 * 60 * 1000; let _accountUnreadSeq = 0; let _accountUnreadState = new Map(); // account_id -> { unreadCount, maxUid } -let _autoReplyRefreshSeq = 0; function _hasActiveEmailSearchResults() { if (!_libSearchHadResults) return false; @@ -62,61 +88,8 @@ function _hasActiveEmailSearchResults() { return (state._libSearchPills || []).some(p => p?.type === 'text' || p?.type === 'contact'); } -function _emailInlineImagesStorageKey(accountId = state._libAccountId) { - return `odysseus.email.viewInlineImages.${String(accountId || 'default')}`; -} -function _readEmailInlineImagesPreference(accountId = state._libAccountId) { - try { - const stored = localStorage.getItem(_emailInlineImagesStorageKey(accountId)); - return stored === null ? true : stored !== '0'; - } catch (_) { - return true; - } -} - -function _writeEmailInlineImagesPreference(enabled, accountId = state._libAccountId) { - try { - localStorage.setItem(_emailInlineImagesStorageKey(accountId), enabled ? '1' : '0'); - } catch (_) {} -} - -async function _disableInlineImages(accountId = state._libAccountId) { - state._libViewInlineImages = false; - _writeEmailInlineImagesPreference(false, accountId); - document.querySelectorAll('#email-settings-view-inline-images').forEach(toggle => { - toggle.checked = true; - toggle.dispatchEvent(new Event('change', { bubbles: true })); - }); - try { - const res = await fetch(emailApiUrl('/api/email/config', { - account_id: accountId || undefined, - }), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ email_view_inline_images: false }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - } catch (err) { - console.error('Failed to disable inline images:', err); - } -} - -const _EMAIL_SETTINGS_ICON = ``; -const _EMAIL_SAVE_ICON = ''; -const _EMAIL_SAVED_ICON = ''; - -function _setEmailSaveIcon(button, saved) { - if (!button) return; - const icon = button.querySelector('svg'); - if (icon) icon.outerHTML = saved ? _EMAIL_SAVED_ICON : _EMAIL_SAVE_ICON; - button.classList.toggle('is-saved', !!saved); -} -const _DEFAULT_AUTO_REPLY_SUBJECT = '(Away) {subject}'; -const _DEFAULT_AUTO_REPLY_MESSAGE = "Thanks for your email. I'm away and may be slower to reply."; - -function _normalizeEmailStateFlags(em) { +export function _normalizeEmailStateFlags(em) { if (!em || typeof em !== 'object') return em; const flags = String(em.flags || ''); const answered = !!( @@ -151,798 +124,6 @@ function _normalizeEmailStateFlags(em) { return em; } -function _normalizeAutoReplyConfig(cfg = {}) { - const normalized = Object.prototype.hasOwnProperty.call(cfg, 'enabled'); - return { - enabled: normalized ? !!cfg.enabled : !!cfg.email_auto_reply, - start: String(normalized ? (cfg.start || '') : (cfg.email_auto_reply_start || '')), - end: String(normalized ? (cfg.end || '') : (cfg.email_auto_reply_end || '')), - subject: String(normalized ? (cfg.subject || _DEFAULT_AUTO_REPLY_SUBJECT) : (cfg.email_auto_reply_subject || _DEFAULT_AUTO_REPLY_SUBJECT)), - message: String(normalized ? (cfg.message || '') : (cfg.email_auto_reply_message || '')), - cooldown: String(normalized ? (cfg.cooldown || 'period') : (cfg.email_auto_reply_cooldown || 'period')), - scope: String(normalized ? (cfg.scope || 'all') : (cfg.email_auto_reply_scope || 'all')), - accountId: String(normalized ? (cfg.accountId || '') : (cfg.email_auto_reply_account_id || '')), - excludeAutomated: normalized ? cfg.excludeAutomated !== false : cfg.email_auto_reply_exclude_automated !== false, - pauseNotifications: normalized ? !!cfg.pauseNotifications : !!cfg.email_auto_reply_pause_notifications, - viewInlineImages: normalized ? cfg.viewInlineImages !== false : cfg.email_view_inline_images !== false, - }; -} - -function _autoReplyDateValue(value) { - const s = String(value || '').trim(); - const m = s.match(/^(\d{4}-\d{2}-\d{2})/); - return m ? m[1] : ''; -} - -function _todayDateInputValue() { - const now = new Date(); - const pad = value => String(value).padStart(2, '0'); - return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; -} - -const _AUTO_REPLY_CALENDAR_KEY_PREFIX = 'odysseus.email.autoReplyCalendarEvent.'; -const _autoReplyCalendarSyncs = new Map(); - -function _autoReplyCalendarKey(accountId) { - return `${_AUTO_REPLY_CALENDAR_KEY_PREFIX}${String(accountId || '').trim()}`; -} - -function _readAutoReplyCalendarUid(accountId) { - if (!accountId) return ''; - try { return String(localStorage.getItem(_autoReplyCalendarKey(accountId)) || ''); } catch (_) { return ''; } -} - -function _writeAutoReplyCalendarUid(accountId, uid) { - if (!accountId) return; - try { - if (uid) localStorage.setItem(_autoReplyCalendarKey(accountId), uid); - else localStorage.removeItem(_autoReplyCalendarKey(accountId)); - } catch (_) {} -} - -function _addLocalCalendarDays(value, days) { - const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!match) return ''; - const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days); - const pad = n => String(n).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; -} - -function _autoReplyCalendarPayload(cfg, accountId = state._libAccountId) { - const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue(); - let end = _autoReplyDateValue(cfg?.end) || start; - if (end < start) end = start; - return { - summary: 'Email Auto Reply (away)', - dtstart: start, - dtend: _addLocalCalendarDays(end, 1), - all_day: true, - description: `Odysseus email auto reply - account:${String(accountId || '')}`, - }; -} - -async function _findAutoReplyCalendarEventUids(cfg, accountId) { - const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue(); - const end = _autoReplyDateValue(cfg?.end) || start; - const startYear = Number(start.slice(0, 4)) || new Date().getFullYear(); - const endYear = Math.max(startYear, Number(end.slice(0, 4)) || startYear); - try { - const response = await fetch(`${API_BASE}/api/calendar/events?start=${startYear - 1}-01-01&end=${endYear + 2}-01-01`, { - credentials: 'same-origin', - }); - if (!response.ok) return []; - const data = await response.json().catch(() => ({})); - const marker = `Odysseus email auto reply - account:${String(accountId || '')}`; - return (data.events || []) - .filter(ev => String(ev?.description || '').startsWith(marker)) - .map(ev => String(ev?.uid || '').trim()) - .filter(Boolean); - } catch (_) { - return []; - } -} - -async function _syncAutoReplyCalendarEventNow(cfg, accountId) { - accountId = String(accountId || '').trim(); - if (!accountId) return false; - let uid = _readAutoReplyCalendarUid(accountId); - const headers = { 'Content-Type': 'application/json' }; - const request = (url, options = {}) => fetch(url, { - credentials: 'same-origin', - ...options, - headers: { ...headers, ...(options.headers || {}) }, - }); - - if (!cfg?.enabled) { - const knownUids = new Set(uid ? [uid] : []); - (await _findAutoReplyCalendarEventUids(cfg, accountId)).forEach(foundUid => knownUids.add(foundUid)); - for (const eventUid of knownUids) { - const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(eventUid)}`, { method: 'DELETE' }); - if (!response.ok && response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`); - } - _writeAutoReplyCalendarUid(accountId, ''); - return true; - } - - const payload = _autoReplyCalendarPayload(cfg, accountId); - if (!uid) { - uid = (await _findAutoReplyCalendarEventUids(cfg, accountId))[0] || ''; - if (uid) _writeAutoReplyCalendarUid(accountId, uid); - } - if (uid) { - const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, { - method: 'PUT', body: JSON.stringify(payload), - }); - if (response.ok) return true; - if (response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`); - _writeAutoReplyCalendarUid(accountId, ''); - } - - const response = await request(`${API_BASE}/api/calendar/events`, { - method: 'POST', body: JSON.stringify(payload), - }); - const data = await response.json().catch(() => ({})); - if (!response.ok || !data.uid) throw new Error(`Calendar HTTP ${response.status}`); - _writeAutoReplyCalendarUid(accountId, data.uid); - return true; -} - -function _syncAutoReplyCalendarEvent(cfg) { - const accountId = String(state._libAccountId || '').trim(); - if (!accountId) return Promise.resolve(false); - const previous = _autoReplyCalendarSyncs.get(accountId) || Promise.resolve(); - const next = previous.catch(() => {}).then(() => _syncAutoReplyCalendarEventNow(cfg, accountId)); - _autoReplyCalendarSyncs.set(accountId, next); - return next.finally(() => { - if (_autoReplyCalendarSyncs.get(accountId) === next) _autoReplyCalendarSyncs.delete(accountId); - }); -} - -function _isAutoReplyActiveForCurrentAccount(cfg) { - cfg = _normalizeAutoReplyConfig(cfg || {}); - if (!cfg.enabled) return false; - if (cfg.scope === 'account' && cfg.accountId && cfg.accountId !== String(state._libAccountId || '')) return false; - const today = new Date().toISOString().slice(0, 10); - const start = _autoReplyDateValue(cfg.start); - const end = _autoReplyDateValue(cfg.end); - if (start && today < start) return false; - if (end && today > end) return false; - return true; -} - -function _syncEmailAutoReplyTitle(active) { - const titleText = document.getElementById('email-lib-title-text'); - const statusDot = document.getElementById('email-lib-auto-reply-status-dot'); - const badge = document.getElementById('email-lib-auto-reply-badge'); - if (titleText) titleText.textContent = active ? 'Auto Reply - Active' : 'Email'; - if (statusDot) statusDot.style.display = active ? 'inline-block' : 'none'; - if (badge) { - badge.style.display = 'none'; - badge.textContent = 'Auto Reply'; - } -} - -async function _fetchEmailSettingsConfig() { - const res = await fetch(emailApiUrl('/api/email/config', { - account_id: state._libAccountId || undefined, - }), { credentials: 'include' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const normalized = _normalizeAutoReplyConfig(await res.json()); - state._libViewInlineImages = normalized.viewInlineImages; - _writeEmailInlineImagesPreference(state._libViewInlineImages); - return normalized; -} - -async function _fetchEmailWritingStyle() { - const res = await fetch(emailApiUrl('/api/email/style', { - account_id: state._libAccountId || undefined, - }), { credentials: 'include' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json().catch(() => ({})); - return String(data.style || ''); -} - -function _emailSettingsLoadingHtml(label = 'Loading') { - const wp = spinnerModule.createWhirlpool(16); - const host = document.createElement('span'); - host.className = 'email-settings-loading-spinner'; - host.appendChild(wp.element); - const wrap = document.createElement('div'); - wrap.className = 'email-settings-loading'; - wrap.appendChild(host); - const text = document.createElement('span'); - text.textContent = label; - wrap.appendChild(text); - return wrap.outerHTML; -} - -function _emailSettingsFormHtml(cfg) { - const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId); - const accountAddress = activeAccount - ? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim() - : ''; - const accountScope = accountAddress - ? ` (${_esc(accountAddress)})` - : ''; - return ` - - `; -} - -function _emailCleanupSettingsHtml() { - const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId); - const accountAddress = activeAccount - ? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim() - : ''; - const accountScope = accountAddress - ? ` (${_esc(accountAddress)})` - : ''; - return ` --- `; -} - -function _emailDisplaySettingsHtml(cfg = {}) { - return ` ---- Newsletter Unsubscribe${accountScope} --Find newsletter and ad emails with unsubscribe options.---- - - ------ -----Show Email Tags- - - - -Choose which labels appear on email cards. Turn off tagging in--- `; -} - -function _emailSettingsAccountSelectHtml() { - const accounts = Array.isArray(state._libAccounts) - ? state._libAccounts - .filter(a => a && a.enabled !== false) - .slice() - .sort((a, b) => Number(!!b.is_default) - Number(!!a.is_default)) - : []; - if (!accounts.length) return ''; - const nameCounts = new Map(); - accounts.forEach(a => { - const name = String(a.name || '').trim(); - if (name) nameCounts.set(name, (nameCounts.get(name) || 0) + 1); - }); - const opts = accounts.map(a => { - const name = String(a.name || '').trim(); - const address = String(a.from_address || a.imap_user || '').trim(); - const label = name && nameCounts.get(name) > 1 && address - ? `${name} · ${address}` - : (name || address || 'account'); - const suffix = a.is_default ? ' · (default)' : ''; - return ``; - }).join(''); - const selected = accounts.find(a => a.id === state._libAccountId) || accounts[0]; - const active = !!selected?.is_default; - const icon = active - ? '' - : ''; - const defaultTitle = active ? 'Default account' : 'Make this the default account'; - return `-- -----Hide Inline Images- - - - -Keep images embedded in email bodies hidden until you load them.-` - + `default` - + `` - + ``; -} - -function _syncEmailSettingsAccountPicker(page) { - const select = page?.querySelector('#email-settings-account-select'); - const selected = (state._libAccounts || []).find(a => a && a.id === state._libAccountId); - if (select && selected) { - [...select.options] - .sort((a, b) => { - const aAccount = (state._libAccounts || []).find(account => account && account.id === a.value); - const bAccount = (state._libAccounts || []).find(account => account && account.id === b.value); - return Number(!!bAccount?.is_default) - Number(!!aAccount?.is_default); - }) - .forEach(option => select.appendChild(option)); - select.value = selected.id; - [...select.options].forEach(option => { - const account = (state._libAccounts || []).find(a => a && a.id === option.value); - if (!account) return; - const label = account.name || account.from_address || account.imap_user || 'account'; - option.textContent = label + (account.is_default ? ' · (default)' : ''); - }); - } - const toggle = page?.querySelector('.email-settings-default'); - if (!toggle || !selected) return; - const active = !!selected.is_default; - toggle.classList.toggle('active', active); - toggle.setAttribute('aria-pressed', active ? 'true' : 'false'); - toggle.dataset.emailDefaultId = selected.id || ''; - toggle.title = active ? 'Default account' : 'Make this the default account'; - toggle.querySelector('.cookbook-srv-default-icon').innerHTML = active - ? '' - : ''; -} - -function _emailWritingStyleHtml(style) { - return ` --- `; -} - -async function _showEmailSettingsPage() { - const modal = document.getElementById('email-lib-modal'); - const page = document.getElementById('email-lib-settings-page'); - const btn = document.getElementById('email-lib-settings-btn'); - if (!modal || !page) return; - modal.classList.add('email-settings-mode'); - page.hidden = false; - page.scrollTop = 0; - page.querySelector('.email-settings-body')?.scrollTo(0, 0); - const headerBackBtn = document.getElementById('email-settings-header-back'); - if (headerBackBtn) headerBackBtn.style.display = 'inline-flex'; - btn?.classList.add('active'); - btn?.setAttribute('aria-expanded', 'true'); - page.innerHTML = ` -----Email Reply Writing Style-Used when Odysseus drafts replies for you.-- --- - - ----${_EMAIL_SETTINGS_ICON}Email Settings
-- ${_emailSettingsLoadingHtml()} -- `; - let cfg; - let writingStyle = ''; - try { - await _loadAccounts().catch(() => {}); - const head = page.querySelector('.email-settings-page-head'); - const accountSelectHtml = _emailSettingsAccountSelectHtml(); - const oldPicker = page.querySelector('.email-settings-account-picker'); - if (oldPicker) oldPicker.remove(); - const title = head?.querySelector('.email-settings-title'); - if (title && accountSelectHtml) title.insertAdjacentHTML('beforeend', accountSelectHtml); - [cfg, writingStyle] = await Promise.all([ - _fetchEmailSettingsConfig(), - _fetchEmailWritingStyle().catch(() => ''), - ]); - } catch (err) { - page.querySelector('.email-settings-body').innerHTML = `Could not load settings.`; - return; - } - page.querySelector('.email-settings-body').innerHTML = _emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml(cfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(cfg); - page.querySelector('.email-settings-body')?.scrollTo(0, 0); - state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(cfg); - state._libAutoReplyDraftActive = null; - _syncEmailAutoReplyTitle(state._libAutoReplyActive); - _renderAccountsStrip(); - _syncAutoReplyCalendarEvent(cfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err)); - page.querySelector('#email-settings-account-select')?.addEventListener('change', async (ev) => { - state._libAccountId = ev.currentTarget.value || null; - state._libAutoReplyDraftActive = null; - _publishActiveAccount(); - _syncEmailSettingsAccountPicker(page); - _renderAccountsStrip(); - _resetEmailListForFreshLoad(); - _loadEmails({ useCache: true }); - _loadFolders({ resetMissing: true }).catch(() => {}); - page.querySelector('.email-unsubscribe-inline-panel')?.remove(); - const body = page.querySelector('.email-settings-body'); - if (body) body.innerHTML = _emailSettingsLoadingHtml(); - try { - const [nextCfg, nextStyle] = await Promise.all([ - _fetchEmailSettingsConfig(), - _fetchEmailWritingStyle().catch(() => ''), - ]); - if (body) body.innerHTML = _emailWritingStyleHtml(nextStyle) + _emailDisplaySettingsHtml(nextCfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(nextCfg); - state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(nextCfg); - state._libAutoReplyDraftActive = null; - _syncEmailAutoReplyTitle(state._libAutoReplyActive); - _renderAccountsStrip(); - _syncAutoReplyCalendarEvent(nextCfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err)); - _bindEmailSettingsPageControls(page); - } catch (_) { - if (body) body.innerHTML = `Could not load settings.`; - } - }); - page.querySelector('.email-settings-default')?.addEventListener('click', async (ev) => { - ev.stopPropagation(); - const acctId = ev.currentTarget.dataset.emailDefaultId; - const account = (state._libAccounts || []).find(a => a && a.id === acctId); - if (!acctId || account?.is_default) return; - try { - const response = await fetch(`${API_BASE}/api/email/accounts/${encodeURIComponent(acctId)}/set-default`, { - method: 'POST', credentials: 'same-origin', - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - for (const a of state._libAccounts) a.is_default = a.id === acctId; - _syncEmailSettingsAccountPicker(page); - _renderAccountsStrip(); - } catch (err) { - console.error('Set default account failed:', err); - } - }); - page.querySelector('.email-settings-default')?.addEventListener('keydown', (ev) => { - if (ev.key !== 'Enter' && ev.key !== ' ') return; - ev.preventDefault(); - ev.currentTarget.click(); - }); - _bindEmailSettingsPageControls(page); -} - -function _bindEmailSettingsPageControls(page) { - page.querySelector('.email-settings-clean-btn')?.addEventListener('click', (ev) => { - // Clean is an explicit fresh scan action; do not reopen stale cached - // candidates when the user asks to clean again. - _openUnsubscribeReviewModal(ev.currentTarget, { forceRescan: true }); - }); - const showTagsToggle = page.querySelector('#email-settings-show-tags'); - const syncDisplayTagsState = () => { - const section = page.querySelector('.email-settings-display-section'); - const stateLabel = page.querySelector('.email-settings-display-enabled-state'); - const enabled = !!showTagsToggle?.checked; - section?.classList.toggle('is-disabled', !enabled); - section?.classList.toggle('is-enabled', enabled); - if (stateLabel) stateLabel.textContent = enabled ? 'Show' : 'Hide'; - }; - showTagsToggle?.addEventListener('change', (ev) => { - state._libShowTags = !!ev.target.checked; - localStorage.setItem('odysseus.email.showTags', state._libShowTags ? '1' : '0'); - _renderGrid(); - document.dispatchEvent(new CustomEvent('odysseus:email-tags-toggle', { detail: { show: state._libShowTags } })); - if (state._libShowTags && !_loadedEmailsHaveVisibleTags()) _notifyNoLoadedEmailTags(); - syncDisplayTagsState(); - }); - syncDisplayTagsState(); - const inlineImagesToggle = page.querySelector('#email-settings-view-inline-images'); - const syncInlineImagesState = () => { - const section = page.querySelector('.email-settings-inline-images-section'); - const stateLabel = page.querySelector('.email-settings-inline-images-enabled-state'); - const hidden = !!inlineImagesToggle?.checked; - section?.classList.toggle('is-disabled', !hidden); - section?.classList.toggle('is-enabled', hidden); - if (stateLabel) stateLabel.textContent = hidden ? 'Hide' : 'Show'; - }; - inlineImagesToggle?.addEventListener('change', async (ev) => { - const hidden = !!ev.target.checked; - const enabled = !hidden; - const previous = state._libViewInlineImages; - state._libViewInlineImages = enabled; - _writeEmailInlineImagesPreference(enabled); - syncInlineImagesState(); - try { - const res = await fetch(emailApiUrl('/api/email/config', { - account_id: state._libAccountId || undefined, - }), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ email_view_inline_images: enabled }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - document.querySelectorAll('.email-card-reader').forEach(reader => _wireEmailInlineImages(reader)); - } catch (err) { - state._libViewInlineImages = previous; - _writeEmailInlineImagesPreference(previous); - ev.target.checked = previous === false; - syncInlineImagesState(); - showToast?.('Failed to save inline image setting'); - console.error('Failed to save inline image setting:', err); - } - }); - syncInlineImagesState(); - page.querySelector('[data-open-email-tasks]')?.addEventListener('click', _openTasksForEmailTags); - const autoReplyStart = page.querySelector('#email-auto-reply-start'); - const seedAutoReplyStartDate = () => { - if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue(); - }; - // Empty native date inputs can open on an arbitrary browser baseline date. - // Seed only the draft field when the picker is opened, leaving saved settings - // unchanged until the user explicitly presses Save. - autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate); - autoReplyStart?.addEventListener('focus', seedAutoReplyStartDate); - const enabledToggle = page.querySelector('#email-auto-reply-enabled'); - const syncEnabledState = () => { - const section = page.querySelector('.email-settings-auto-reply-section'); - const stateLabel = section?.querySelector('.email-settings-enabled-state'); - const enabled = !!enabledToggle?.checked; - section?.classList.toggle('is-disabled', !enabled); - section?.classList.toggle('is-enabled', enabled); - if (stateLabel) stateLabel.textContent = enabled ? 'Enabled' : 'Disabled'; - }; - const saveAutoReplySettings = async ({ quiet = false } = {}) => { - const saveBtn = page.querySelector('.email-settings-save'); - const status = page.querySelector('.email-settings-status'); - if (saveBtn && !quiet) saveBtn.disabled = true; - _setEmailSaveIcon(saveBtn, false); - if (status) { - status.classList.remove('is-success', 'is-error'); - status.textContent = 'Saving…'; - } - const payload = { - email_auto_reply: !!page.querySelector('#email-auto-reply-enabled')?.checked, - email_auto_reply_start: page.querySelector('#email-auto-reply-start')?.value || '', - email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '', - email_auto_reply_subject: page.querySelector('#email-auto-reply-subject')?.value || '', - email_auto_reply_message: page.querySelector('#email-auto-reply-message')?.value || '', - email_auto_reply_cooldown: page.querySelector('#email-auto-reply-cooldown')?.value || 'period', - email_auto_reply_scope: 'account', - email_auto_reply_account_id: state._libAccountId || '', - email_auto_reply_pause_notifications: !!page.querySelector('#email-auto-reply-pause')?.checked, - }; - try { - const res = await fetch(emailApiUrl('/api/email/config', { - account_id: state._libAccountId || undefined, - }), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(payload), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const savedCfg = { - enabled: !!payload.email_auto_reply, - start: payload.email_auto_reply_start, - end: payload.email_auto_reply_end, - }; - let calendarSynced = true; - try { - await _syncAutoReplyCalendarEvent(savedCfg); - } catch (calendarErr) { - calendarSynced = false; - console.warn('Failed to sync auto-reply calendar event:', calendarErr); - } - if (status) { - status.classList.add('is-success'); - status.textContent = 'Saved'; - } - _setEmailSaveIcon(saveBtn, true); - if (!quiet) showToast?.('Email settings saved'); - if (!calendarSynced) showToast?.('Saved, but the calendar event could not be updated'); - state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(savedCfg); - state._libAutoReplyDraftActive = null; - _syncEmailAutoReplyTitle(state._libAutoReplyActive); - _renderAccountsStrip(); - _refreshUnreadBadge({ preserveAutoReplyTitle: true }).catch(() => {}); - setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, quiet ? 900 : 1400); - return true; - } catch (err) { - if (status) { - status.classList.add('is-error'); - status.textContent = 'Save failed'; - } - showToast?.('Failed to save email settings'); - return false; - } finally { - if (saveBtn) saveBtn.disabled = false; - } - }; - enabledToggle?.addEventListener('change', () => { - syncEnabledState(); - // Invalidate any config read that started before this toggle. Its stale - // result must not overwrite the title we are about to render. - _autoReplyRefreshSeq += 1; - const active = _isAutoReplyActiveForCurrentAccount({ - email_auto_reply: !!enabledToggle.checked, - email_auto_reply_start: autoReplyStart?.value || '', - email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '', - email_auto_reply_scope: 'account', - email_auto_reply_account_id: state._libAccountId || '', - }); - state._libAutoReplyActive = active; - state._libAutoReplyDraftActive = active; - _syncEmailAutoReplyTitle(active); - _renderAccountsStrip(); - saveAutoReplySettings({ quiet: true }); - }); - syncEnabledState(); - page.querySelector('.email-settings-save')?.addEventListener('click', async () => { - await saveAutoReplySettings(); - }); - page.querySelector('.email-style-settings-save')?.addEventListener('click', async () => { - const saveBtn = page.querySelector('.email-style-settings-save'); - const status = page.querySelector('.email-style-settings-status'); - saveBtn.disabled = true; - _setEmailSaveIcon(saveBtn, false); - if (status) { - status.classList.remove('is-success', 'is-error'); - status.textContent = 'Saving…'; - } - try { - const res = await fetch(emailApiUrl('/api/email/style', { - account_id: state._libAccountId || undefined, - }), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ style: page.querySelector('#email-writing-style-text')?.value || '' }), - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - if (status) { - status.classList.add('is-success'); - status.textContent = 'Saved'; - } - _setEmailSaveIcon(saveBtn, true); - showToast?.('Email writing style saved'); - setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, 1400); - } catch (err) { - if (status) { - status.classList.add('is-error'); - status.textContent = 'Save failed'; - } - showToast?.('Failed to save email writing style'); - } finally { - saveBtn.disabled = false; - } - }); - page.querySelector('.email-style-settings-extract')?.addEventListener('click', async () => { - const extractBtn = page.querySelector('.email-style-settings-extract'); - const saveBtn = page.querySelector('.email-style-settings-save'); - const status = page.querySelector('.email-style-settings-status'); - const styleEl = page.querySelector('#email-writing-style-text'); - extractBtn.disabled = true; - if (saveBtn) saveBtn.disabled = true; - let wp = null; - if (status) { - status.innerHTML = ''; - try { - wp = spinnerModule.createWhirlpool(14); - wp.element.style.cssText = 'display:inline-block;vertical-align:-3px;margin-right:6px;'; - status.appendChild(wp.element); - status.appendChild(document.createTextNode('Analyzing sent emails…')); - } catch (_) { - status.textContent = 'Analyzing sent emails…'; - } - } - try { - const res = await fetch(emailApiUrl('/api/email/extract-style', { - account_id: state._libAccountId || undefined, - }), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ sample_count: 15 }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok || !data.success || !data.style) throw new Error(data.error || `HTTP ${res.status}`); - if (styleEl) styleEl.value = data.style; - if (status) status.textContent = 'Extracted'; - showToast?.('Email writing style extracted'); - setTimeout(() => { if (status) status.textContent = ''; }, 1800); - } catch (err) { - if (status) status.textContent = err?.message || 'Extract failed'; - showToast?.('Failed to extract email writing style'); - } finally { - if (wp && wp.destroy) { try { wp.destroy(); } catch (_) {} } - extractBtn.disabled = false; - if (saveBtn) saveBtn.disabled = false; - } - }); -} - -function _hideEmailSettingsPage() { - const modal = document.getElementById('email-lib-modal'); - const page = document.getElementById('email-lib-settings-page'); - const btn = document.getElementById('email-lib-settings-btn'); - const headerBackBtn = document.getElementById('email-settings-header-back'); - modal?.classList.remove('email-settings-mode'); - if (page) page.hidden = true; - if (headerBackBtn) headerBackBtn.style.display = 'none'; - btn?.classList.remove('active'); - btn?.setAttribute('aria-expanded', 'false'); -} - function _isEmailTypingTarget(t) { return !!(t && ( t.tagName === 'INPUT' || @@ -964,7 +145,7 @@ function _selectEmailReaderContents(reader) { return true; } -function _markEmailReaderActive(reader) { +export function _markEmailReaderActive(reader) { if (!reader) return; _activeEmailReaderForSelectAll = reader; if (reader.dataset.selectAllWired === '1') return; @@ -982,7 +163,7 @@ function _emailReaderLoadErrorHtml(message) {-`; - }).join(''); - const box = document.createElement('div'); - box.className = 'email-unsub-followup'; - box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;flex-direction:column;gap:8px;'; - box.innerHTML = ` -- - -- ${uids.length > 1 ? `x${uids.length}` : ''} ---${_esc(heading)}-- - - - - ${options.scanFurther ? '' : ''} --${rows}`; - const body = modal.querySelector('.modal-body'); - const list = modal.querySelector('.email-unsub-list'); - body?.insertBefore(box, list || null); - box.querySelector('.email-unsub-clean-spam')?.addEventListener('click', (e) => { - _runUnsubscribeCleanup(modal, candidates, 'junk', e.currentTarget); - }); - box.querySelector('.email-unsub-clean-junk-delete')?.addEventListener('click', async (e) => { - const ok = await styledConfirm(`Mark ${count} reviewed email${count === 1 ? '' : 's'} as spam and delete ${count === 1 ? 'it' : 'them'}?`, { - confirmText: 'Spam + delete', - cancelText: 'Cancel', - danger: true, - }); - if (ok) _runUnsubscribeCleanup(modal, candidates, 'junk_delete', e.currentTarget); - }); - box.querySelector('.email-unsub-clean-delete')?.addEventListener('click', async (e) => { - const ok = await styledConfirm(`Delete ${count} reviewed email${count === 1 ? '' : 's'}?`, { - confirmText: 'Delete', - cancelText: 'Cancel', - danger: true, - }); - if (ok) _runUnsubscribeCleanup(modal, candidates, 'delete', e.currentTarget); - }); - box.querySelector('.email-unsub-clean-keep')?.addEventListener('click', () => box.remove()); - box.querySelector('.email-unsub-scan-further')?.addEventListener('click', () => { - const nextAnchor = options.scanAnchor; - modal.remove(); - if (nextAnchor) setTimeout(() => _openUnsubscribeReviewModal(nextAnchor), 0); - }); -} - -function _showUnsubscribeScanFurtherPrompt(modal, anchor, heading) { - if (!modal || !anchor) return; - modal.querySelector('.email-unsub-followup')?.remove(); - const box = document.createElement('div'); - box.className = 'email-unsub-followup'; - box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;'; - box.innerHTML = `${_esc(heading)}`; - const body = modal.querySelector('.modal-body'); - const list = modal.querySelector('.email-unsub-list'); - body?.insertBefore(box, list || null); - box.querySelector('.email-unsub-scan-further')?.addEventListener('click', () => { - modal.remove(); - setTimeout(() => _openUnsubscribeReviewModal(anchor), 0); - }); -} - -function _showUnsubscribeRemainingPrompt(modal, candidates) { - if (!modal || !Array.isArray(candidates) || !candidates.length) return; - modal.querySelector('.email-unsub-followup')?.remove(); - const remaining = new Set(candidates); - modal.querySelectorAll('.email-unsub-card').forEach(card => { - const idx = Number(card.dataset.idx || -1); - card.hidden = !remaining.has(modal._unsubscribeCandidates?.[idx]); - }); - const box = document.createElement('div'); - box.className = 'email-unsub-followup email-unsub-remaining-followup'; - box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;'; - box.innerHTML = ` - ${candidates.length} unsubscribe${candidates.length === 1 ? '' : 's'} still need review - - - - `; - const body = modal.querySelector('.modal-body'); - const list = modal.querySelector('.email-unsub-list'); - body?.insertBefore(box, list || null); - box.querySelector('.email-unsub-clean-remaining-btn')?.addEventListener('click', async (event) => { - const count = candidates.reduce((sum, candidate) => sum + _unsubscribeCandidateUids(candidate).length, 0); - const ok = await styledConfirm( - `Mark ${count} remaining email${count === 1 ? '' : 's'} as spam and delete ${count === 1 ? 'it' : 'them'}?`, - { confirmText: 'Spam + delete', cancelText: 'Cancel', danger: true }, - ); - if (ok) _runUnsubscribeCleanup(modal, candidates, 'junk_delete', event.currentTarget); - }); - box.querySelector('.email-unsub-agent-all-btn')?.addEventListener('click', (event) => { - const button = event.currentTarget; - if (modal._agentUnsubscribeRun) return; - const total = candidates.length; - const restoreBusy = _setUnsubButtonBusy(button, `Completed 0 / ${total}`); - modal._agentUnsubscribeRun = { - total, - candidates: candidates.slice(), - completed: new Set(), - cleanupInFlight: new Set(), - restoreBusy, - }; - if (!_askAgentToUnsubscribeAll(candidates)) { - restoreBusy?.(); - modal._agentUnsubscribeRun = null; - } - }); -} - -async function _openUnsubscribeReviewModal(anchor, options = {}) { - const existing = document.getElementById('email-unsubscribe-review-modal'); - if (existing) { - existing.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); - existing.querySelector('.email-unsub-status')?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); - return; - } - const modal = document.createElement('div'); - const scanFolderLabel = _unsubscribeFolderLabel(state._libFolder); - modal.className = 'modal'; - modal.id = 'email-unsubscribe-review-modal'; - const settingsPage = anchor?.closest?.('#email-lib-settings-page, .email-settings-page-host'); - const inlineHost = settingsPage?.querySelector?.('.email-settings-cleanup-section'); - const inlineMode = !!inlineHost; - if (inlineMode) { - modal.className = 'email-unsubscribe-inline-panel'; - modal.style.cssText = 'display:none;margin-top:10px;'; - modal.innerHTML = ` -- - -`; - inlineHost.appendChild(modal); - } else { - modal.style.display = 'block'; - modal.innerHTML = ` - `; - document.body.appendChild(modal); - } - const close = () => modal.remove(); - if (!inlineMode) modal.addEventListener('click', (e) => { if (e.target === modal) close(); }); - const statusEl = inlineMode - ? inlineHost.querySelector('.email-settings-clean-status') - : modal.querySelector('.email-unsub-status'); - const panelStatusEl = modal.querySelector('.email-unsub-panel-status'); - const listEl = modal.querySelector('.email-unsub-list'); - const cachedScan = options.forceRescan ? null : _readUnsubscribeScanCache(); - const usingCachedScan = !!cachedScan; - let finishScanStatus = _setUnsubStatusBusy( - statusEl, - usingCachedScan ? 'Loading last unsubscribe scan…' : `Scanning ${scanFolderLabel} headers…`, - ); - const showFinalStatus = (message, isError = false) => { - const actionsEl = modal.querySelector('.email-unsub-actions'); - if (actionsEl) actionsEl.style.display = 'flex'; - if (inlineMode && panelStatusEl) { - finishScanStatus?.(''); - panelStatusEl.classList.toggle('is-error', isError); - panelStatusEl.textContent = message || ''; - modal.style.display = 'block'; - return; - } - statusEl?.classList.toggle('is-error', isError); - finishScanStatus?.(message); - }; - const finishScanError = (message) => { - showFinalStatus(message || 'Failed to scan email headers', true); - }; - try { - const scanUnsubscribeHeaders = (maxScan) => fetch(emailApiUrl('/api/email/unsubscribe/scan', { - folder: state._libFolder || 'INBOX', - limit: 500, - max_scan: maxScan, - account_id: state._libAccountId || undefined, - }), { credentials: 'same-origin' }); - const readScanResponse = async (response) => { - const payload = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error( - payload?.error || payload?.detail || `Email scan failed (HTTP ${response.status})`, - ); - } - return payload; - }; - // Keep the review request below the app's 45s hard deadline. The old - // whole-folder request made Gmail scans time out before any cards loaded. - let scanLimit = 500; - let res; - let data = cachedScan; - if (!data) { - res = await scanUnsubscribeHeaders(scanLimit); - data = await readScanResponse(res); - } else { - scanLimit = Number(data.scanLimit || scanLimit); - } - if (!data || (!data.success && !usingCachedScan)) { - finishScanError(data?.error || 'Failed to scan email headers'); - return; - } - let candidates = _filterHandledUnsubscribeCandidates( - _dedupeUnsubscribeCandidatesForDisplay(data.candidates || []), - ); - if (!usingCachedScan && !candidates.length && scanLimit > 0 && Number(data.scanned || 0) >= scanLimit) { - const searchMore = await styledConfirm( - `No unsubscribe candidates found in ${scanLimit} recent emails. Search more?`, - { title: 'Search more?', confirmText: 'Search more', cancelText: 'No' }, - ); - if (searchMore) { - scanLimit = 0; - finishScanStatus = _setUnsubStatusBusy(statusEl, `Scanning ${scanLimit} recent ${scanFolderLabel} headers…`); - res = await scanUnsubscribeHeaders(scanLimit); - data = await readScanResponse(res); - if (!data.success) { - finishScanError(data.error || 'Failed to scan email headers'); - return; - } - candidates = _filterHandledUnsubscribeCandidates( - _dedupeUnsubscribeCandidatesForDisplay(data.candidates || []), - ); - } - } - if (!usingCachedScan) _writeUnsubscribeScanCache(data, scanLimit); - const rawTotal = Number(data.raw_total || candidates.length || 0); - const scanPrefix = usingCachedScan ? 'Last scan: ' : ''; - const scanSuffix = usingCachedScan ? ' Rescan for fresh results.' : ''; - const scanScope = scanLimit > 0 ? `${data.scanned || 0} recent emails` : `all ${data.scanned || 0} emails`; - const finalStatus = candidates.length - ? `${scanPrefix}Found ${candidates.length} unsubscribe target${candidates.length === 1 ? '' : 's'} from ${scanScope}${rawTotal > candidates.length ? `, collapsed from ${rawTotal} matching emails` : ''}.${scanSuffix} Review before sending unsubscribe.` - : `${scanPrefix}No unsubscribe candidates found in ${scanScope}.${scanSuffix}`; - showFinalStatus(finalStatus); - modal.querySelector('.email-unsub-actions').style.display = 'flex'; - modal.querySelector('.email-unsub-delete-all-btn').style.display = candidates.length ? 'inline-flex' : 'none'; - modal.querySelector('.email-unsub-auto-safe-btn').style.display = candidates.length ? 'inline-flex' : 'none'; - listEl.style.display = candidates.length ? 'flex' : 'none'; - listEl.innerHTML = candidates.map((c, idx) => { - const method = c.recommended_method || null; - const reasons = (c.reasons || []).map(r => `${_esc(r)}`).join(''); - const urlMethod = (c.methods || []).find(m => m.kind === 'url'); - const duplicateCount = Number(c.duplicate_count || 1); - const duplicateBadge = duplicateCount > 1 ? `x${duplicateCount}` : ''; - const deleteLabel = duplicateCount > 1 ? 'Delete all' : 'Delete'; - const deleteTitle = duplicateCount > 1 - ? 'Delete all unsubscribe emails from this sender' - : 'Delete matching unsubscribe emails from this sender'; - return ` --`; - }).join(''); - modal._unsubscribeCandidates = candidates; - listEl.querySelectorAll('.email-unsub-card-toggle').forEach(btn => { - btn.addEventListener('click', () => { - const card = btn.closest('.email-unsub-card'); - const details = card?.querySelector('.email-unsub-card-details'); - if (!details) return; - const isOpen = !details.hidden; - details.hidden = isOpen; - card.classList.toggle('is-expanded', !isOpen); - btn.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); - btn.setAttribute('aria-label', isOpen ? 'Show unsubscribe details' : 'Hide unsubscribe details'); - btn.title = isOpen ? 'Show unsubscribe details' : 'Hide unsubscribe details'; - }); - }); - modal.querySelector('.email-unsub-rescan-btn')?.addEventListener('click', () => { - modal.remove(); - setTimeout(() => _openUnsubscribeReviewModal(anchor, { forceRescan: true }), 0); - }); - modal.querySelector('.email-unsub-delete-all-btn')?.addEventListener('click', async (event) => { - const allCandidates = modal._unsubscribeCandidates || []; - const count = allCandidates.reduce((sum, candidate) => sum + _unsubscribeCandidateUids(candidate).length, 0); - if (!count) { - finishScanStatus?.('No unsubscribe emails to delete.'); - return; - } - const ok = await styledConfirm( - `Delete all ${count} reviewed unsubscribe email${count === 1 ? '' : 's'}?`, - { confirmText: 'Delete all', cancelText: 'Cancel', danger: true }, - ); - if (!ok) return; - const restoreBusy = _setUnsubButtonBusy(event.currentTarget, 'Deleting all'); - try { - const result = await _deleteAfterUnsubscribe(allCandidates); - if (Number(result?.failed || 0) > 0 || Number(result?.changed || 0) <= 0) { - throw new Error('Could not move the reviewed emails to Trash'); - } - allCandidates.forEach((candidate, idx) => _markUnsubscribeCardDone(modal, idx, 'Deleted')); - modal.querySelector('.email-unsub-list')?.style.setProperty('display', 'none'); - finishScanStatus?.(`Deleted ${result.changed} reviewed email${result.changed === 1 ? '' : 's'}`); - } catch (error) { - finishScanStatus?.(error?.message || 'Delete all failed'); - } finally { - restoreBusy?.(); - } - }); - modal.querySelector('.email-unsub-auto-safe-btn')?.addEventListener('click', async () => { - const safeCandidates = (modal._unsubscribeCandidates || []) - .map((c, idx) => ({ c, idx })) - .filter(item => item.c && item.c.can_execute); - if (!safeCandidates.length) { - finishScanStatus?.(`No automatic unsubscribe actions found. ${modal._unsubscribeCandidates?.length || 0} link${(modal._unsubscribeCandidates?.length || 0) === 1 ? '' : 's'} still need review.`); - _showUnsubscribeRemainingPrompt(modal, modal._unsubscribeCandidates || []); - return; - } - const ok = await styledConfirm(`Send unsubscribe emails for ${safeCandidates.length} target${safeCandidates.length === 1 ? '' : 's'}?`, { - confirmText: 'Auto Unsubscribe All', - cancelText: 'Cancel', - }); - if (!ok) return; - const btn = modal.querySelector('.email-unsub-auto-safe-btn'); - const restoreBusy = _setUnsubButtonBusy(btn, `Unsubscribing 0/${safeCandidates.length}`); - let sent = 0; - let failed = 0; - const sentCandidates = []; - const failedCandidates = []; - try { - for (const item of safeCandidates) { - const c = item.c; - try { - restoreBusy?.setLabel?.(`Unsubscribing ${sent + failed + 1}/${safeCandidates.length}`); - const r = await fetch(emailApiUrl('/api/email/unsubscribe/execute', { - account_id: state._libAccountId || undefined, - }), { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - uid: c.uid, - folder: c.folder || state._libFolder || 'INBOX', - account_id: state._libAccountId || '', - method_index: 0, - move_to_spam: false, - }), - }); - const d = await r.json().catch(() => ({})); - if (!d.success) throw new Error(d.error || 'Unsubscribe failed'); - sent += 1; - sentCandidates.push(c); - const rowBtn = modal.querySelector(`.email-unsub-send-btn[data-idx="${item.idx}"]`); - if (rowBtn) { - rowBtn.disabled = true; - rowBtn.innerHTML = `${_UNSUB_CHECK_ICON}Sent`; - rowBtn.style.display = 'inline-flex'; - rowBtn.style.alignItems = 'center'; - rowBtn.style.gap = '4px'; - } - _markUnsubscribeCardDone(modal, item.idx); - } catch (err) { - failed += 1; - failedCandidates.push(c); - console.error(err); - } - } - } finally { - restoreBusy?.(); - } - let deleteFailed = false; - let cleanupResult = { success: true, changed: 0, failed: 0, cleaned_uids: [] }; - if (sentCandidates.length) { - try { - cleanupResult = await _deleteAfterUnsubscribe(sentCandidates); - deleteFailed = Number(cleanupResult.failed || 0) > 0; - const cleaned = new Set((cleanupResult.cleaned_uids || []).map(uid => String(uid))); - (modal._unsubscribeCandidates || []).forEach((candidate, candidateIdx) => { - if (_unsubscribeCandidateUids(candidate).some(uid => cleaned.has(String(uid)))) { - _markUnsubscribeCardDone(modal, candidateIdx); - } - }); - } catch (err) { - deleteFailed = true; - console.error(err); - } - } - const handled = new Set(); - sentCandidates.forEach(c => _unsubscribeCandidateUids(c).forEach(uid => handled.add(String(uid)))); - (cleanupResult.cleaned_uids || []).forEach(uid => handled.add(String(uid))); - const remainingCandidates = (modal._unsubscribeCandidates || []).filter(c => ( - !_unsubscribeCandidateUids(c).some(uid => handled.has(String(uid))) - )); - finishScanStatus?.( - `Auto unsubscribe finished: ${sent} unsubscribed${failed ? `, ${failed} failed` : ''}${remainingCandidates.length ? `, ${remainingCandidates.length} still need review` : ''}.`, - ); - if (sent && !failed && !deleteFailed) { - showToast?.(`Unsubscribed and deleted ${sent} email${sent === 1 ? '' : 's'}`); - } else if (sent || failed) { - showToast?.(deleteFailed - ? `Unsubscribed ${sent}, but some emails could not be deleted` - : `Sent ${sent}, failed ${failed}`); - } - if (remainingCandidates.length) { - _showUnsubscribeRemainingPrompt(modal, remainingCandidates); - } else { - _showUnsubscribeScanFurtherPrompt( - modal, - anchor, - `Finished this batch: ${sent} unsubscribed. Search older emails?`, - ); - } - }); - listEl.querySelectorAll('.email-unsub-done-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const idx = Number(btn.dataset.idx || 0); - const c = modal._unsubscribeCandidates?.[idx]; - if (!c) return; - const sender = c.from_name || c.from_address || 'this sender'; - const ok = await styledConfirm( - `Delete matching unsubscribe emails from ${sender}?`, - { confirmText: 'Delete', cancelText: 'Cancel', danger: true }, - ); - if (!ok) return; - const restoreBusy = _setUnsubButtonBusy(btn, 'Deleting'); - try { - await _deleteAfterUnsubscribe([c]); - restoreBusy?.(); - _markUnsubscribeCardDone(modal, idx, 'Deleted'); - showToast?.('Matching emails deleted'); - } catch (err) { - restoreBusy?.(); - showToast?.(err?.message || 'Could not delete matching emails'); - } - }); - }); - listEl.querySelectorAll('.email-unsub-ignore-btn').forEach(btn => { - btn.addEventListener('click', () => { - const idx = Number(btn.dataset.idx || 0); - const c = modal._unsubscribeCandidates?.[idx]; - if (!c) return; - _rememberUnsubscribeIgnored(c); - btn.closest('.email-unsub-card')?.remove(); - const remaining = listEl.querySelectorAll('.email-unsub-card:not([hidden])').length; - if (!remaining) { - listEl.style.display = 'none'; - modal.querySelector('.email-unsub-auto-safe-btn').style.display = 'none'; - finishScanStatus?.('No unsubscribe candidates left in this review.'); - } else { - finishScanStatus?.(`${remaining} unsubscribe candidate${remaining === 1 ? '' : 's'} still need review.`); - } - }); - }); - listEl.querySelectorAll('.email-unsub-agent-btn').forEach(btn => { - btn.addEventListener('click', () => { - const idx = Number(btn.dataset.idx || 0); - const c = modal._unsubscribeCandidates?.[idx]; - if (!c) return; - if (modal._agentUnsubscribeRun) return; - const restoreBusy = _setUnsubButtonBusy(btn, 'Starting'); - modal._agentUnsubscribeRun = { - total: 1, - candidates: [c], - completed: new Set(), - cleanupInFlight: new Set(), - restoreBusy, - }; - if (!_askAgentToUnsubscribe(c)) { - restoreBusy?.(); - modal._agentUnsubscribeRun = null; - } - }); - }); - listEl.querySelectorAll('.email-unsub-send-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const idx = Number(btn.dataset.idx || 0); - const c = modal._unsubscribeCandidates?.[idx]; - if (!c) return; - const ok = await styledConfirm(`Send unsubscribe email to ${_unsubscribeMethodLabel(c.recommended_method)}?`, { - confirmText: 'Unsubscribe', - cancelText: 'Cancel', - }); - if (!ok) return; - btn.disabled = true; - btn.textContent = 'Sending…'; - try { - const r = await fetch(emailApiUrl('/api/email/unsubscribe/execute', { - account_id: state._libAccountId || undefined, - }), { - method: 'POST', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - uid: c.uid, - folder: c.folder || state._libFolder || 'INBOX', - account_id: state._libAccountId || '', - method_index: 0, - move_to_spam: false, - }), - }); - const d = await r.json().catch(() => ({})); - if (!d.success) throw new Error(d.error || 'Unsubscribe failed'); - btn.innerHTML = `${_UNSUB_CHECK_ICON}Sent`; - btn.style.display = 'inline-flex'; - btn.style.alignItems = 'center'; - btn.style.gap = '4px'; - _markUnsubscribeCardDone(modal, idx); - try { - await _deleteAfterUnsubscribe([c]); - showToast('Unsubscribed and deleted email'); - } catch (deleteErr) { - console.error(deleteErr); - showToast('Unsubscribed, but email could not be deleted'); - _showUnsubscribeCleanupPrompt(modal, [c], { - heading: 'The unsubscribe request was sent. Mark this email as spam and delete it?', - }); - } - } catch (err) { - console.error(err); - btn.disabled = false; - btn.innerHTML = `${_UNSUB_EMAIL_ICON}${_esc(_unsubscribeMethodLabel(c.recommended_method))}`; - btn.style.display = 'inline-flex'; - btn.style.alignItems = 'center'; - btn.style.gap = '4px'; - showToast(err?.message || 'Unsubscribe failed'); - } - }); - }); - } catch (err) { - console.error(err); - finishScanError('Failed to scan email headers'); - } -} function _rememberedEmailAccountId() { try { @@ -3537,7 +1512,7 @@ function _resetBulkSelectionForContextChange({ rerender = false } = {}) { } } -function _resetEmailListForFreshLoad({ useCache = true, refreshAfterCache = false } = {}) { +export function _resetEmailListForFreshLoad({ useCache = true, refreshAfterCache = false } = {}) { _exitEmailReaderModeForList(); _resetBulkSelectionForContextChange(); state._libOffset = 0; @@ -3585,7 +1560,7 @@ function _exitEmailReaderModeForList() { }); } -function _loadEmailsFresh({ force = true, useCache = true, refreshAfterCache = true, showRefreshSpinner = false } = {}) { +export function _loadEmailsFresh({ force = true, useCache = true, refreshAfterCache = true, showRefreshSpinner = false } = {}) { const refreshBtn = showRefreshSpinner ? document.getElementById('email-lib-refresh-btn') : null; refreshBtn?.classList.add('email-lib-refreshing'); const resolvedFolder = _resolveEmailFolderAlias(state._libFolder); @@ -4048,7 +2023,7 @@ async function _prewarmEmailViews({ signal, generation } = {}) { _setEmailSyncStatus({ warming: false }); } } -function _libCacheWriteBack() { +export function _libCacheWriteBack() { // After a local mutation that already updated state._libEmails // (delete / archive / bulk), sync the change into the cache so the // next reopen doesn't briefly show the pre-mutation state before the @@ -4071,7 +2046,7 @@ function _libCacheWriteBack() { // Expose the active account id to other modules (document.js uses this when sending). // Simple global rather than cross-module import to keep coupling minimal. -function _publishActiveAccount() { +export function _publishActiveAccount() { try { window.__odysseusActiveEmailAccount = state._libAccountId || null; } catch (_) {} try { if (state._libAccountId) localStorage.setItem(_LIB_LAST_ACCOUNT_KEY, state._libAccountId); @@ -4890,7 +2865,7 @@ export function openEmailLibrary(opts = {}) { })(); } -async function _loadAccounts({ force = false } = {}) { +export async function _loadAccounts({ force = false } = {}) { const hasCachedAccounts = Array.isArray(state._libAccounts) && state._libAccounts.length; const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS; if (!force && hasCachedAccounts && accountsFresh) { @@ -4929,7 +2904,7 @@ async function _loadAccounts({ force = false } = {}) { _refreshAccountUnreadHighlights().catch(() => {}); } -function _renderAccountsStrip() { +export function _renderAccountsStrip() { const strip = document.getElementById('email-lib-accounts'); if (!strip) return; strip.style.display = 'flex'; @@ -5183,7 +3158,7 @@ export function closeEmailLibrary() { // Make a modal draggable by its header. If `modal` and `fsClass` are // provided, dragging to the top edge of the viewport snaps to fullscreen // (Aero Snap). Dragging away from the top while fullscreen unsnaps. -function _makeDraggable(content, modal, fsClass) { +export function _makeDraggable(content, modal, fsClass) { if (!content) return; const header = content.querySelector('.modal-header'); if (!header) return; @@ -5253,7 +3228,7 @@ function _makeDraggable(content, modal, fsClass) { // the right side of the chat area) is visible side-by-side. Only triggers // when the viewport is wide enough to make a true split worthwhile. Returns // true if the snap was applied, false otherwise. -function _snapEmailModalToLeftSidebar(modal) { +export function _snapEmailModalToLeftSidebar(modal) { if (!modal) return false; if (window.innerWidth < 900) return false; // "Open in new tab" reader modals (id="email-view-…") are explicitly @@ -5291,7 +3266,7 @@ function _snapEmailModalToLeftSidebar(modal) { return true; } -async function _loadFolders({ resetMissing = false, live = false } = {}) { +export async function _loadFolders({ resetMissing = false, live = false } = {}) { const seq = ++_libFolderSeq; const accountAtStart = state._libAccountId || ''; try { @@ -6682,7 +4657,7 @@ function _renderEmailLoading(grid) { return null; } -function _emailReaderSkeletonHtml() { +export function _emailReaderSkeletonHtml() { return `---- -- -- ---- score ${_esc(c.score || 0)} - ${duplicateBadge} - - - --${reasons}-- ${c.can_execute ? `` : ''} - ${urlMethod ? `` : ''} - ${urlMethod ? `${_UNSUB_EXTERNAL_ICON}Link Unsubscribe` : ''} --@@ -6732,10 +4707,10 @@ function _appendEmailSearchProgressRow(grid) { // it would toggle the filter off — so the label needs to advertise the // action, not the now-current view. Uses the cheap unread-state endpoint for // the normal badge; silent on failure. -async function _refreshUnreadBadge({ unreadCountOverride = null, preserveAutoReplyTitle = false, syncAutoReplyTitle = false } = {}) { +export async function _refreshUnreadBadge({ unreadCountOverride = null, preserveAutoReplyTitle = false, syncAutoReplyTitle = false } = {}) { const badge = document.getElementById('email-lib-unread-badge'); if (!badge) return; - const refreshSeq = ++_autoReplyRefreshSeq; + const refreshSeq = ++state._autoReplyRefreshSeq; const accountAtStart = String(state._libAccountId || ''); try { const folder = state._libFolder || 'INBOX'; @@ -6749,7 +4724,7 @@ async function _refreshUnreadBadge({ unreadCountOverride = null, preserveAutoRep // Several mailbox/account refreshes can overlap while the settings page // is saving. Do not let an older response restore the previous account's // Auto Reply state after the current account has already changed. - if (refreshSeq !== _autoReplyRefreshSeq || accountAtStart !== String(state._libAccountId || '')) return; + if (refreshSeq !== state._autoReplyRefreshSeq || accountAtStart !== String(state._libAccountId || '')) return; if (!cfg) return; const awayActive = _isAutoReplyActiveForCurrentAccount(cfg); if (!preserveAutoReplyTitle) { @@ -6805,7 +4780,7 @@ async function _refreshUnreadBadge({ unreadCountOverride = null, preserveAutoRep } catch (_) { _syncUnreadTabBadge(0); } } -async function _loadEmails({ force = false, useCache = true } = {}) { +export async function _loadEmails({ force = false, useCache = true } = {}) { const seq = ++_libLoadSeq; state._libLoading = true; const accountAtStart = state._libAccountId || ''; @@ -7102,7 +5077,7 @@ function _dateGroupEmailsWithPinned(items, mode = 'recent') { return ordered; } -function _renderGrid() { +export function _renderGrid() { const grid = document.getElementById('email-lib-grid'); if (!grid) return; const modal = document.getElementById('email-lib-modal'); @@ -7580,7 +5555,7 @@ function _createCard(em) { return card; } -function _findSiblingEmailCard(card, dir) { +export function _findSiblingEmailCard(card, dir) { const grid = card.closest('.doclib-grid'); if (!grid) return null; const cards = [...grid.querySelectorAll('.doclib-card[data-uid]')]; @@ -7647,7 +5622,7 @@ function _prefetchAdjacentEmails(card, count = 1) { }, 2500); } -async function _toggleCardPreview(card, em) { +export async function _toggleCardPreview(card, em) { _normalizeEmailStateFlags(em); const accountAtStart = state._libAccountId || ''; const libraryFolderAtStart = state._libFolder || 'INBOX'; @@ -7994,846 +5969,15 @@ async function _toggleCardPreview(card, em) { } } -/** - * 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 {} -} - -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 += '' - + _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(quoteSection)) - + fmt(quoteSection) + ''; - } - if (sigSection) { - const sigHtml = fmt(sigSection); - if (_isBloatedSig(sigHtml)) { - out += '' + _foldSummary('Signature', _SIG_ICON) - + sigHtml + ''; - } 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)); -} - -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 = ` - - - - - ${_esc(label)} - ${isHttp ? 'Remote image blocked' : isCid ? 'Inline image hidden' : 'Image unavailable'} - - - ${isHttp ? `` : ''} - ${isCid ? `` : ''} - - `; - img.replaceWith(ph); - }); - return root.innerHTML; -} - -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 = - `` - + `` - + (date ? `${_esc(date)}` : '') - + ``; - const avatar = ``; - return ( - `` - + (isMine ? '' : avatar) - + `` - ); - }); - return `` - + head - + `` - + (isMine ? avatar : '') - + `${_sanitizeHtml(t.body_html || '')}` - + `${rows.join('')}`; -} - -/** - * 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) => - `` - + _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '') - + `'; - - 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('${t.html}` - + '= 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; -} - -/** - * Parse an email body's reply chain into a stack of turn-cards. - * Each turn = { author, date, bodyHtml, nested[] } where the body is - * everything UP TO the next quote boundary, and `nested` is the sub-thread - * inside (recursively parsed). Returns null if the email has no quoted - * thread to parse (single message, no folds needed). - */ -// ── Talon-inspired multilingual quote-detection patterns ── -// Sources: -// github.com/mailgun/talon (HTML/text quote detection) -// github.com/crisp-oss/email-reply-parser (locale list) -// -// _TALON_* / _SIG_BLOAT_MIN_CHARS live in ./emailLibrary/utils.js -// _SIG_ICON / _QUOTE_ICON live in ./emailLibrary/signatureFold.js - -function _renderThreadStructure(html) { - if (!html || typeof html !== 'string' || html.length > 200000) return null; - let doc; - try { doc = new DOMParser().parseFromString(`${html}`, 'text/html'); } - catch { return null; } - const root = doc.getElementById('__t'); - if (!root) return null; - - // Find top-level blockquotes (not nested inside another blockquote). - const tops = Array.from(root.querySelectorAll('blockquote')).filter(b => - !b.parentElement.closest('blockquote') - ); - if (!tops.length) return null; - - // Build the current-message body: everything in root up to the first - // top-level blockquote, minus the "On, wrote:" attribution - // line that introduces it. - const head = doc.createElement('div'); - let cursor = root.firstChild; - while (cursor && cursor !== tops[0]) { - const next = cursor.nextSibling; - head.appendChild(cursor); - cursor = next; - } - // Strip trailing "On , wrote:" / Outlook-style attribution - // from `head` since the same info will appear in the turn header. - let attribution = _harvestAttribution(head); - - // Recursively parse each top-level blockquote into a turn (and its nested chain). - const turnsHtml = []; - for (let i = 0; i < tops.length; i++) { - const bq = tops[i]; - // The blockquote may have an Outlook-style "From: / Sent: / Subject:" - // header inside as the first text. Extract that as the turn meta. - const meta = _extractTurnMetaFromBlockquote(bq) || attribution || _extractQuoteMeta(bq.innerHTML); - const innerHtml = bq.innerHTML; - - // Heuristic: if a blockquote has no detectable attribution (no "From:", - // no "On ... wrote:") AND its content matches signature-style - // patterns (corporate disclaimer, "registered in", legal notices, just - // a name + title), treat it as a Signature fold instead of an Earlier - // Reply. This stops mail clients that wrap signatures in - // 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() { +export function _summaryCollapsedPref() { try { return localStorage.getItem(_SUMMARY_COLLAPSED_KEY) === '1'; } catch { return false; } } -function _setSummaryCollapsedPref(v) { +export function _setSummaryCollapsedPref(v) { try { localStorage.setItem(_SUMMARY_COLLAPSED_KEY, v ? '1' : '0'); } catch {} } @@ -8873,1106 +6017,8 @@ function _showCachedSummary(reader, summary, btn) { } } -// 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. -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 ( - `` - + '' - ); -} - -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; -} - -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 {} -} - -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 (_) {} - }); -} - -// "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 showFailedTab = (message) => { - try { showToast(message || 'Failed to load email'); } catch (_) {} - _showEmailReaderLoadError(reader, message, () => { - try { Modals.close(modalId); } catch (_) { try { modal.remove(); } catch (_) {} } - setTimeout(() => { _openEmailAsTab(em, useFolder); }, 0); - }); - }; - _markEmailReaderActive(reader); - const loading = modal.querySelector('.email-reader-tab-loading'); - if (loading) loading.remove(); - if (reader) { - reader.classList.add('email-card-reader-loading'); - reader.innerHTML = _emailReaderSkeletonHtml(); - } - try { - const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - let data = await res.json(); - if (data.error) { - showFailedTab(`Failed to load email: ${data.error}`); - return; - } - data = _normalizeEmailStateFlags({ ...em, ...data }); - Object.assign(em, data); - _syncEmailDoneState(em.uid, data.is_answered); - _syncEmailReadState(em.uid, true); - _stampReaderContext(reader, data, useFolder, state._libAccountId); - const buildChips = (str) => { - if (!str) return ''; - return _splitRecipientList(str).map(a => { - const name = _extractName(a); - return _recipientChipHtml(a, name); - }).join(''); - }; - const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip'); - let attsHtml = ''; - try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {} - reader.innerHTML = ` -' - + '' - + `${label}` - + downloadAllBtn - + '' - + '' - + visibleSection - + relatedSection - + hiddenSection - + '- -- ${attsHtml} -${_safeRenderEmailBody(data)}- `; - _markEmailReaderActive(reader); - reader.classList.remove('email-card-reader-loading'); - _wireRecipientChips(reader); - _wireEmailAttachmentWrap(reader, useFolder); - _wireEmailInlineImages(reader); - _loadDeferredAttachmentsIntoReader(reader, em.uid, useFolder, data, !!em.has_attachments); - _maybeAutoTranslateEmail(reader); - 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', (ev) => _handleAiReplyButton(ev, em, data)); - 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 {} - }); - _wireMetaToggle(reader); - _wireReaderActionOverflow(reader); - reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => { - ev.stopPropagation(); - try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget, data); } catch {} - }); - } catch (err) { - showFailedTab(err?.message ? `Failed to load email: ${err.message}` : 'Failed to load email'); - } -} - - -// "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 { - if (loading) loading.remove(); - if (bodyEl) { - bodyEl.classList.add('email-card-reader', 'email-card-reader-loading'); - bodyEl.style.padding = '0'; - bodyEl.innerHTML = _emailReaderSkeletonHtml(); - } - const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`); - let data = await res.json(); - if (data.error) { - bodyEl.innerHTML = `${_esc(data.error)}`; - return; - } - data = _normalizeEmailStateFlags({ ...em, ...data }); - Object.assign(em, data); - _syncEmailDoneState(em.uid, data.is_answered); - _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 = _splitRecipientList(addrs); - return list.map(a => { - const name = _extractName(a); - return _recipientChipHtml(a, name); - }).join(''); - }; - const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip'); - 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.classList.remove('email-card-reader-loading'); - _stampReaderContext(bodyEl, { ...em, ...data }, useFolder, state._libAccountId); - _markEmailReaderActive(bodyEl); - bodyEl.style.padding = '0'; - bodyEl.innerHTML = ` -- -- ${attsHtml} -${_safeRenderEmailBody(data)}- `; - _markEmailReaderActive(bodyEl); - _wireRecipientChips(bodyEl); - // Wire all the same action handlers the inline reader has. - _wireEmailAttachmentWrap(bodyEl, useFolder); - _wireEmailInlineImages(bodyEl); - _loadDeferredAttachmentsIntoReader(bodyEl, em.uid, useFolder, data, !!em.has_attachments); - _maybeAutoTranslateEmail(bodyEl); - 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', (ev) => _handleAiReplyButton(ev, em, data)); - 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 {} - }); - _wireMetaToggle(bodyEl); - _wireReaderActionOverflow(bodyEl); - 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, data); } catch {} - }); - } catch (err) { - bodyEl.innerHTML = `Failed to load: ${_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 = ` -- - Summary --No 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 { - _renderEmailSummaryError(content, result); - } - } catch (e) { - sp.destroy(); - _renderEmailSummaryError(content, null); - if (uiModule) uiModule.showError?.('Failed to summarize'); - } finally { - if (btn) btn.disabled = false; - } -} - -function _emailBodyTextForTranslate(reader) { - const body = reader?.querySelector?.('.email-reader-body'); - if (!body) return ''; - const clone = body.cloneNode(true); - clone.querySelectorAll('.email-summary-panel, details.email-quote-fold, details.email-sig-fold').forEach(n => n.remove()); - return (clone.innerText || clone.textContent || '').trim(); -} - -async function _translateEmail(reader, language, opts = {}) { +export async function _translateEmail(reader, language, opts = {}) { const body = reader?.querySelector?.('.email-reader-body'); if (!body) return; const existing = body.querySelector('.email-translation-panel'); @@ -10043,14 +6089,14 @@ async function _translateEmail(reader, language, opts = {}) { } } -async function _maybeAutoTranslateEmail(reader) { +export async function _maybeAutoTranslateEmail(reader) { if (reader) reader.dataset.autoTranslateChecked = '1'; } // 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) { +export function _fitEmailDropdown(dropdown, rect) { requestAnimationFrame(() => { const margin = 8; // Horizontal clamp — keep the dropdown inside the viewport regardless of @@ -10106,1261 +6152,16 @@ const _readerActionFitObserver = typeof ResizeObserver === 'function' }) : null; -function _wireReaderActionOverflow(reader) { +export function _wireReaderActionOverflow(reader) { const meta = reader?.querySelector('.email-reader-meta'); if (!meta) return; requestAnimationFrame(() => _fitReaderActions(meta)); _readerActionFitObserver?.observe(meta); } -function _showReaderMoreMenu(em, card, reader, anchor, data) { - // Toggle: if a dropdown for THIS anchor is already open, close it. - const existing = document.querySelector('.email-card-dropdown'); - if (existing && existing._anchor === anchor) { - dismissOrRemove(existing); - return; - } - // Otherwise close any other open dropdown (its own teardown clears its - // anchor's active state) before opening a fresh one. - document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove); - - 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:${topPortalZ()};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 _checkIcon = ''; - const _translateIcon = ''; - - 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 = ''; - // Three groups separated by dividers: - // 1. Open / Mark Unread / Remind — the per-email view actions - // 2. Save sender / Not Done / Archive — non-destructive state changes - // 3. Move to Spam / Move to Trash / Delete — destructive - const overflowActions = Array.from(reader.querySelectorAll('.reader-action-overflowed')).map(button => ({ - label: button.querySelector('.reader-btn-label')?.textContent?.trim() || button.title || 'Action', - icon: button.querySelector('svg')?.outerHTML || '', - button, - action: (positionAnchor) => { - if (button.dataset.act === 'ai-reply') { - _handleAiReplyButton({ - currentTarget: button, - stopPropagation() {}, - }, em, data, positionAnchor || button); - } else { - button.click(); - } - }, - })); - const actions = [ - ...overflowActions, - ...(overflowActions.length ? [{ separator: true }] : []), - { - label: 'Open in new tab', - icon: _newTabIcon, - action: async () => { - const folder = state._libFolder || 'INBOX'; - await _openEmailAsTab(em, folder); - }, - }, - { - label: 'Remind to reply', - icon: _bellIcon, - submenu: 'remind', - }, - { - label: 'Translate', - icon: _translateIcon, - submenu: 'translate', - }, - { separator: true }, - { - label: em.is_read ? 'Mark as Unread' : 'Mark as 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(); - }, - }, - { - // Favorite (pin to top). Same bookmark glyph we use for the - // sidebar-pin / favorites filter so the visual language stays - // consistent. Toggling updates em.is_flagged and re-sorts via - // _renderGrid (favorited rows are always pinned at the top). - label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)', - icon: '', - action: async () => { - const next = !em.is_flagged; - em.is_flagged = next; - _renderGrid(); - try { - await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' }); - } catch (e) { - // Roll back the optimistic flip if the server didn't take it. - em.is_flagged = !next; - _renderGrid(); - console.error('Failed to toggle favorite:', e); - } - }, - }, - { - label: em.is_answered ? 'Mark as Not Done' : 'Mark as Done', - icon: _checkIcon, - action: async () => { - const newState = !em.is_answered; - em.is_answered = newState; - if (newState) { - _clearDoneResponseTagsLocal(em); - _syncEmailReadState(em.uid, true); - } - 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); } - _renderGrid(); - }, - }, - { - label: 'Move to 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(); - }, - }, - { - // 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?v=20260916largetoolscroll1').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?v=20260916largetoolscroll1').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?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Failed to save contact')).catch(() => {}); - } - }, - }, - { separator: true }, - { - 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 () => { - const busy = _showEmailDeleteOverlay(card); - await busy?.ready; - try { - const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' }); - await _requireSuccessfulEmailMutation(response, 'Failed to delete email'); - } catch (e) { - console.error(e); - busy?.remove?.(); - showToast('Failed to delete email'); - return; - } - busy?.remove?.(); - 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; - const busy = _showEmailDeleteOverlay(card); - await busy?.ready; - try { - const response = await fetch(`${API_BASE}/api/email/delete-permanent/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' }); - await _requireSuccessfulEmailMutation(response, 'Failed to delete email'); - } catch (e) { - console.error(e); - busy?.remove?.(); - showToast('Failed to delete email'); - return; - } - busy?.remove?.(); - await closeAndRemove(); - }, - }, - ]; - - for (const a of actions) { - if (a.separator) { - const sep = document.createElement('div'); - sep.className = 'dropdown-divider'; - dropdown.appendChild(sep); - continue; - } - 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; - } - if (a.submenu === 'translate') { - _showEmailTranslateSubmenu(reader, dropdown); - return; - } - // A hidden overflowed button has a zero-sized client rect. Invoke the - // AI chooser while the visible More item is still mounted, then close - // the action menu after the chooser has captured its position. - if (a.button?.dataset.act === 'ai-reply') { - a.action(item); - close(); - return; - } - close(); - 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(); - close(); - }); - dropdown.appendChild(cancelItem); - - document.body.appendChild(dropdown); - _fitEmailDropdown(dropdown, rect); - const close = bindMenuDismiss(dropdown, () => { - dropdown.remove(); - anchor.classList.remove('reader-more-active'); - }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor); -} - -function _showCardMenu(em, anchor) { - const openMenu = document.querySelector('.email-card-dropdown:not(.email-bulk-menu)'); - if (openMenu && openMenu._emailMenuAnchor === anchor) { - dismissOrRemove(openMenu); - return; - } - document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove); - - const dropdown = document.createElement('div'); - dropdown.className = 'email-card-dropdown'; - dropdown._emailMenuAnchor = anchor; - const rect = anchor.getBoundingClientRect(); - dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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 ? 'Not Done' : '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) { - _clearDoneResponseTagsLocal(em); - _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); - card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove()); - } - } - }, - }); - actions.push({ - label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)', - icon: '', - action: async () => { - const next = !em.is_flagged; - em.is_flagged = next; - _renderGrid(); - try { - await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' }); - } catch (e) { - em.is_flagged = !next; - _renderGrid(); - console.error('Failed to toggle favorite:', e); - } - }, - }); - 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: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)', - icon: '', - action: async () => { - const next = !em.is_flagged; - em.is_flagged = next; - _renderGrid(); - try { - await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' }); - } catch (e) { - em.is_flagged = !next; - _renderGrid(); - console.error('Failed to toggle favorite:', e); - } - }, - }); - 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 = SELECT_MENU_ICON; - 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; - const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(em.uid))}"]`); - const busy = _showEmailDeleteOverlay(card); - await busy?.ready; - try { - const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' }); - await _requireSuccessfulEmailMutation(response, 'Failed to delete email'); - } catch (e) { - busy?.remove?.(); - showToast(e?.message || 'Could not move email to Trash'); - return; - } - busy?.remove?.(); - await _animateEmailCardRemoval([em.uid]); - state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); - _renderGrid(); - _libCacheWriteBack(); - }}, - ); - - const orderedActions = orderActionMenuItems(actions); - let addedActionDivider = false; - for (const [index, a] of orderedActions.entries()) { - if (!addedActionDivider && index > 0 && actionMenuRank(a) >= 700) { - const divider = document.createElement('div'); - divider.className = 'dropdown-divider'; - dropdown.appendChild(divider); - addedActionDivider = true; - } - 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; - } - close(); - 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(); - close(); - }); - dropdown.appendChild(cancelItem); - - document.body.appendChild(dropdown); - _fitEmailDropdown(dropdown, rect); - const close = bindMenuDismiss(dropdown, () => { - dropdown.remove(); - anchor.classList.remove('reader-more-active'); - }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor); -} - -// Bulk "Actions" dropdown for select mode — Delete is a separate visible button. -function _showBulkActionsMenu(anchor) { - document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove); - const dropdown = document.createElement('div'); - dropdown.className = 'email-card-dropdown email-bulk-menu'; - const rect = anchor.getBoundingClientRect(); - dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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 _doneIco = ''; - const _exportIco = ''; - const items = [ - { label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() }, - { label: 'Done', icon: _doneIco, action: () => _bulkAction('done') }, - { 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(); close(); 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(); - close(); - // 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 = bindMenuDismiss(dropdown, () => { - dropdown.remove(); - }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor); -} - -function _updateBulkBar() { - const bar = document.getElementById('email-lib-bulk'); - const grid = document.getElementById('email-lib-grid'); - if (grid) grid.classList.toggle('email-selecting', !!state._selectMode); - 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; - let failedReadSync = 0; - const failedUids = new Set(); - 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; - // Deletion is intentionally optimistic. The user should not have to keep - // the mail window open while IMAP moves a batch; refresh and restore the - // cards only if the provider rejects the background operation. - 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(); - _libCacheWriteBack(); - showToast(`Moved ${uids.length} email${uids.length === 1 ? '' : 's'} to Trash`); - fetch(`${API_BASE}/api/email/delete-bulk`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }), - }).then(async (res) => { - const data = await res.json().catch(() => null); - if (!res.ok || data?.success === false || (data?.failed_uids || []).length) { - throw new Error(data?.error || `${data?.failed_uids?.length || 0} emails could not be deleted`); - } - }).catch((error) => { - console.error('Background bulk email delete failed:', error); - showToast('Delete failed; restoring mailbox'); - _loadEmailsFresh({ force: true, useCache: false, refreshAfterCache: false }).catch(() => {}); - }); - return; - } - - const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null; - const actionsBtn = document.getElementById('email-lib-bulk-actions'); - const cancelBtn = document.getElementById('email-lib-bulk-cancel'); - const selectAll = document.getElementById('email-lib-select-all'); - const countEl = document.getElementById('email-lib-selected-count'); - const originalDeleteHtml = deleteBtn?.innerHTML || ''; - const originalCountText = countEl?.textContent || ''; - let busySpinner = null; - // Loading state for every bulk action, not just delete — large - // selections (e.g. 90+ Dones) used to silently hammer the server - // with sequential requests and the user got zero feedback. Now the - // Actions button (or Delete button) shows a whirlpool + verb-ing - // label, and the count surfaces progress. - const verbing = { - delete: 'Deleting', - archive: 'Archiving', - done: 'Marking done', - read: 'Marking read', - unread: 'Marking unread', - }[action] || 'Updating'; - const targetBtn = action === 'delete' ? deleteBtn : actionsBtn; - let originalTargetHtml = ''; - if (targetBtn) { - originalTargetHtml = targetBtn.innerHTML; - targetBtn.disabled = true; - targetBtn.classList.add('email-bulk-loading'); - targetBtn.innerHTML = `${verbing}`; - busySpinner = spinnerModule.create('', 'clean', 'whirlpool'); - const spEl = busySpinner.createElement(); - spEl.classList.add('email-bulk-whirlpool'); - targetBtn.appendChild(spEl); - busySpinner.start(); - } - if (action !== 'delete' && deleteBtn) deleteBtn.disabled = true; - if (action === 'delete' && actionsBtn) actionsBtn.disabled = true; - if (cancelBtn) cancelBtn.disabled = true; - if (selectAll) selectAll.disabled = true; - if (countEl) countEl.textContent = `${verbing} ${uids.length}…`; - const deleteOverlays = action === 'delete' - ? uids.map(uid => { - const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(uid))}"]`); - return _showEmailDeleteOverlay(card); - }).filter(Boolean) - : []; - if (deleteOverlays.length) { - await Promise.all(deleteOverlays.map(busy => busy.ready).filter(Boolean)); - } - - // Single-uid worker. - const handleOne = async (uid) => { - try { - if (action === 'archive') { - const res = await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); - const data = await res.json().catch(() => null); - if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`); - } else if (action === 'delete') { - // Bulk Delete matches the permanent-delete behavior used by the - // library and albums. Moving a batch through provider Trash folders - // is unreliable on several IMAP implementations. - const res = await fetch(`${API_BASE}/api/email/delete-permanent/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); - const data = await res.json().catch(() => null); - if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`); - } else if (action === 'done') { - // uid may come back from the Set as a string while em.uid is - // numeric (or vice versa) — coerce both sides so the in-memory - // state actually flips and the post-loop re-render shows the - // done checkmark. - const em = state._libEmails.find(e => String(e.uid) === String(uid)); - if (em) { - em.is_answered = true; - em.is_read = true; - _clearDoneResponseTagsLocal(em); - } - const ansRes = await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); - const readRes = await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); - if (!ansRes.ok || !readRes.ok) throw new Error(`mark-done HTTP ${ansRes.status}/${readRes.status}`); - } else if (action === 'read' || action === 'unread') { - const endpoint = action === 'read' ? 'mark-read' : 'mark-unread'; - const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); - let data = null; - try { data = await res.json(); } catch (_) {} - if (!res.ok || data?.success === false) { - throw new Error(data?.error || `HTTP ${res.status}`); - } - _syncEmailReadState(uid, action === 'read'); - } - } catch (e) { - if (action === 'read' || action === 'unread') failedReadSync += 1; - failedUids.add(String(uid)); - console.error(`Failed to ${action} ${uid}:`, e); - } - }; - - try { - // Run in parallel with a concurrency cap so 92 emails don't take - // 30 seconds sequentially but we also don't open 92 simultaneous - // connections. - const CONCURRENCY = 6; - const queue = uids.slice(); - let inFlight = 0; - let nextSlot = 0; - let finishedCount = 0; - let bulkDeletedUids = null; - if (action === 'delete') { - const res = await fetch(`${API_BASE}/api/email/delete-bulk`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }), - }); - const data = await res.json().catch(() => null); - if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`); - for (const uid of data?.failed_uids || []) failedUids.add(String(uid)); - bulkDeletedUids = (data?.deleted_uids || []).map(String); - if (countEl) countEl.textContent = `${verbing} ${bulkDeletedUids.length}/${uids.length}…`; - } else await new Promise((resolve) => { - const launch = () => { - while (inFlight < CONCURRENCY && nextSlot < queue.length) { - const uid = queue[nextSlot++]; - inFlight++; - handleOne(uid).finally(() => { - inFlight--; - finishedCount++; - if (countEl) countEl.textContent = `${verbing} ${finishedCount}/${queue.length}…`; - if (nextSlot >= queue.length && inFlight === 0) resolve(); - else launch(); - }); - } - if (queue.length === 0) resolve(); - }; - launch(); - }); - - if (action === 'archive' || action === 'delete') { - const successfulUids = bulkDeletedUids || uids.filter(uid => !failedUids.has(String(uid))); - if (action === 'delete') { - deleteOverlays.forEach(busy => busy.remove?.()); - } - await _animateEmailCardRemoval(successfulUids); - const removed = new Set(successfulUids.map(uid => String(uid))); - state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); - } else if (action === 'done' && state._libFilter === 'undone') { - // The undone filter is a "show only not-done" view — after marking - // selected emails done, they no longer match. Animate them out and - // drop them from the local list so the view reflects the filter - // instead of leaving freshly-done cards sitting there. - await _animateEmailCardRemoval(uids); - const removed = new Set(uids.map(uid => String(uid))); - state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); - } - } finally { - deleteOverlays.forEach(busy => busy.remove?.()); - if (busySpinner) busySpinner.destroy(); - // Restore whichever button we hijacked (delete vs actions). - if (targetBtn) { - targetBtn.disabled = false; - targetBtn.classList.remove('email-bulk-loading'); - targetBtn.innerHTML = originalTargetHtml || targetBtn.innerHTML; - } - if (deleteBtn && deleteBtn !== targetBtn) { - deleteBtn.disabled = false; - deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML; - } - if (actionsBtn && actionsBtn !== targetBtn) actionsBtn.disabled = false; - if (cancelBtn) cancelBtn.disabled = false; - if (selectAll) selectAll.disabled = false; - if (countEl) countEl.textContent = originalCountText; - } - state._selectedUids.clear(); - state._selectMode = false; - _updateBulkBar(); - _renderGrid(); - if (failedReadSync > 0) { - showToast(`Failed to update ${failedReadSync} email${failedReadSync === 1 ? '' : 's'}`); - } - if (failedUids.size > 0 && (action === 'delete' || action === 'archive')) { - showToast(`${failedUids.size} email${failedUids.size === 1 ? '' : 's'} could not be ${action === 'delete' ? 'deleted' : 'archived'}`); - } - // Sync successful local mutations into the SWR cache so reopen doesn't - // briefly show the pre-bulk state. - _libCacheWriteBack(); -} // _extractName lives in ./emailLibrary/utils.js -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); -} - -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); -} - -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 - -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) ---- -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'); - } -} // Sanitize untrusted HTML email bodies before injecting via innerHTML. // diff --git a/static/js/emailLibrary/menus.js b/static/js/emailLibrary/menus.js new file mode 100644 index 000000000..94352f43c --- /dev/null +++ b/static/js/emailLibrary/menus.js @@ -0,0 +1,855 @@ +// static/js/emailLibrary/menus.js +// +// Every action menu the email library opens: the reader's More menu, a card's +// kebab menu, the bulk-selection Actions menu, and `_bulkAction`, which runs +// the chosen bulk mutation. +// +// Item order is not local — it comes from `orderActionMenuItems` in +// ../actionMenuOrder.js, so an action keeps the same position here as in the +// document, task, session and memory menus. `tests/test_action_menu_order.py` +// pins that. + +import { SELECT_MENU_ICON, actionMenuRank, orderActionMenuItems } from '../actionMenuOrder.js'; +import { bindMenuDismiss, dismissOrRemove } from '../escMenuStack.js'; +import { topPortalZ } from '../toolWindowZOrder.js'; +import { showToast, styledConfirm } from '../ui.js?v=20260916largetoolscroll1'; +import { state } from './state.js'; +import { + _handleAiReplyButton, + _showEmailTranslateSubmenu, + _showLibRemindSubmenu, +} from './aiReply.js'; +import { + _acct, + _animateEmailCardRemoval, + _clearDoneResponseTagsLocal, + _emailMutationQuery, + _exportSelectedAttachments, + _findSiblingEmailCard, + _fitEmailDropdown, + _libCacheWriteBack, + _loadEmailsFresh, + _renderGrid, + _requireSuccessfulEmailMutation, + _showEmailDeleteOverlay, + _syncEmailReadState, + _toggleCardPreview, +} from './index.js'; +import { _openEmailAsTab } from './reader.js'; + +const API_BASE = window.location.origin; + +export function _showReaderMoreMenu(em, card, reader, anchor, data) { + // Toggle: if a dropdown for THIS anchor is already open, close it. + const existing = document.querySelector('.email-card-dropdown'); + if (existing && existing._anchor === anchor) { + dismissOrRemove(existing); + return; + } + // Otherwise close any other open dropdown (its own teardown clears its + // anchor's active state) before opening a fresh one. + document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove); + + 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:${topPortalZ()};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 _checkIcon = ''; + const _translateIcon = ''; + + 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 = ''; + // Three groups separated by dividers: + // 1. Open / Mark Unread / Remind — the per-email view actions + // 2. Save sender / Not Done / Archive — non-destructive state changes + // 3. Move to Spam / Move to Trash / Delete — destructive + const overflowActions = Array.from(reader.querySelectorAll('.reader-action-overflowed')).map(button => ({ + label: button.querySelector('.reader-btn-label')?.textContent?.trim() || button.title || 'Action', + icon: button.querySelector('svg')?.outerHTML || '', + button, + action: (positionAnchor) => { + if (button.dataset.act === 'ai-reply') { + _handleAiReplyButton({ + currentTarget: button, + stopPropagation() {}, + }, em, data, positionAnchor || button); + } else { + button.click(); + } + }, + })); + const actions = [ + ...overflowActions, + ...(overflowActions.length ? [{ separator: true }] : []), + { + label: 'Open in new tab', + icon: _newTabIcon, + action: async () => { + const folder = state._libFolder || 'INBOX'; + await _openEmailAsTab(em, folder); + }, + }, + { + label: 'Remind to reply', + icon: _bellIcon, + submenu: 'remind', + }, + { + label: 'Translate', + icon: _translateIcon, + submenu: 'translate', + }, + { separator: true }, + { + label: em.is_read ? 'Mark as Unread' : 'Mark as 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(); + }, + }, + { + // Favorite (pin to top). Same bookmark glyph we use for the + // sidebar-pin / favorites filter so the visual language stays + // consistent. Toggling updates em.is_flagged and re-sorts via + // _renderGrid (favorited rows are always pinned at the top). + label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)', + icon: '', + action: async () => { + const next = !em.is_flagged; + em.is_flagged = next; + _renderGrid(); + try { + await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' }); + } catch (e) { + // Roll back the optimistic flip if the server didn't take it. + em.is_flagged = !next; + _renderGrid(); + console.error('Failed to toggle favorite:', e); + } + }, + }, + { + label: em.is_answered ? 'Mark as Not Done' : 'Mark as Done', + icon: _checkIcon, + action: async () => { + const newState = !em.is_answered; + em.is_answered = newState; + if (newState) { + _clearDoneResponseTagsLocal(em); + _syncEmailReadState(em.uid, true); + } + 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); } + _renderGrid(); + }, + }, + { + label: 'Move to 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(); + }, + }, + { + // 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?v=20260916largetoolscroll1').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?v=20260916largetoolscroll1').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?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Failed to save contact')).catch(() => {}); + } + }, + }, + { separator: true }, + { + 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 () => { + const busy = _showEmailDeleteOverlay(card); + await busy?.ready; + try { + const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' }); + await _requireSuccessfulEmailMutation(response, 'Failed to delete email'); + } catch (e) { + console.error(e); + busy?.remove?.(); + showToast('Failed to delete email'); + return; + } + busy?.remove?.(); + 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; + const busy = _showEmailDeleteOverlay(card); + await busy?.ready; + try { + const response = await fetch(`${API_BASE}/api/email/delete-permanent/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' }); + await _requireSuccessfulEmailMutation(response, 'Failed to delete email'); + } catch (e) { + console.error(e); + busy?.remove?.(); + showToast('Failed to delete email'); + return; + } + busy?.remove?.(); + await closeAndRemove(); + }, + }, + ]; + + for (const a of actions) { + if (a.separator) { + const sep = document.createElement('div'); + sep.className = 'dropdown-divider'; + dropdown.appendChild(sep); + continue; + } + 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; + } + if (a.submenu === 'translate') { + _showEmailTranslateSubmenu(reader, dropdown); + return; + } + // A hidden overflowed button has a zero-sized client rect. Invoke the + // AI chooser while the visible More item is still mounted, then close + // the action menu after the chooser has captured its position. + if (a.button?.dataset.act === 'ai-reply') { + a.action(item); + close(); + return; + } + close(); + 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(); + close(); + }); + dropdown.appendChild(cancelItem); + + document.body.appendChild(dropdown); + _fitEmailDropdown(dropdown, rect); + const close = bindMenuDismiss(dropdown, () => { + dropdown.remove(); + anchor.classList.remove('reader-more-active'); + }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor); +} + +export function _showCardMenu(em, anchor) { + const openMenu = document.querySelector('.email-card-dropdown:not(.email-bulk-menu)'); + if (openMenu && openMenu._emailMenuAnchor === anchor) { + dismissOrRemove(openMenu); + return; + } + document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove); + + const dropdown = document.createElement('div'); + dropdown.className = 'email-card-dropdown'; + dropdown._emailMenuAnchor = anchor; + const rect = anchor.getBoundingClientRect(); + dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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 ? 'Not Done' : '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) { + _clearDoneResponseTagsLocal(em); + _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); + card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove()); + } + } + }, + }); + actions.push({ + label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)', + icon: '', + action: async () => { + const next = !em.is_flagged; + em.is_flagged = next; + _renderGrid(); + try { + await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' }); + } catch (e) { + em.is_flagged = !next; + _renderGrid(); + console.error('Failed to toggle favorite:', e); + } + }, + }); + 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: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)', + icon: '', + action: async () => { + const next = !em.is_flagged; + em.is_flagged = next; + _renderGrid(); + try { + await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' }); + } catch (e) { + em.is_flagged = !next; + _renderGrid(); + console.error('Failed to toggle favorite:', e); + } + }, + }); + 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 = SELECT_MENU_ICON; + 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; + const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(em.uid))}"]`); + const busy = _showEmailDeleteOverlay(card); + await busy?.ready; + try { + const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' }); + await _requireSuccessfulEmailMutation(response, 'Failed to delete email'); + } catch (e) { + busy?.remove?.(); + showToast(e?.message || 'Could not move email to Trash'); + return; + } + busy?.remove?.(); + await _animateEmailCardRemoval([em.uid]); + state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid)); + _renderGrid(); + _libCacheWriteBack(); + }}, + ); + + const orderedActions = orderActionMenuItems(actions); + let addedActionDivider = false; + for (const [index, a] of orderedActions.entries()) { + if (!addedActionDivider && index > 0 && actionMenuRank(a) >= 700) { + const divider = document.createElement('div'); + divider.className = 'dropdown-divider'; + dropdown.appendChild(divider); + addedActionDivider = true; + } + 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; + } + close(); + 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(); + close(); + }); + dropdown.appendChild(cancelItem); + + document.body.appendChild(dropdown); + _fitEmailDropdown(dropdown, rect); + const close = bindMenuDismiss(dropdown, () => { + dropdown.remove(); + anchor.classList.remove('reader-more-active'); + }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor); +} + +// Bulk "Actions" dropdown for select mode — Delete is a separate visible button. +export function _showBulkActionsMenu(anchor) { + document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove); + const dropdown = document.createElement('div'); + dropdown.className = 'email-card-dropdown email-bulk-menu'; + const rect = anchor.getBoundingClientRect(); + dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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 _doneIco = ''; + const _exportIco = ''; + const items = [ + { label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() }, + { label: 'Done', icon: _doneIco, action: () => _bulkAction('done') }, + { 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(); close(); 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(); + close(); + // 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 = bindMenuDismiss(dropdown, () => { + dropdown.remove(); + }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor); +} + +export function _updateBulkBar() { + const bar = document.getElementById('email-lib-bulk'); + const grid = document.getElementById('email-lib-grid'); + if (grid) grid.classList.toggle('email-selecting', !!state._selectMode); + 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)' : ''; +} + +export async function _bulkAction(action) { + const uids = Array.from(state._selectedUids); + if (uids.length === 0) return; + let failedReadSync = 0; + const failedUids = new Set(); + 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; + // Deletion is intentionally optimistic. The user should not have to keep + // the mail window open while IMAP moves a batch; refresh and restore the + // cards only if the provider rejects the background operation. + 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(); + _libCacheWriteBack(); + showToast(`Moved ${uids.length} email${uids.length === 1 ? '' : 's'} to Trash`); + fetch(`${API_BASE}/api/email/delete-bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }), + }).then(async (res) => { + const data = await res.json().catch(() => null); + if (!res.ok || data?.success === false || (data?.failed_uids || []).length) { + throw new Error(data?.error || `${data?.failed_uids?.length || 0} emails could not be deleted`); + } + }).catch((error) => { + console.error('Background bulk email delete failed:', error); + showToast('Delete failed; restoring mailbox'); + _loadEmailsFresh({ force: true, useCache: false, refreshAfterCache: false }).catch(() => {}); + }); + return; + } + + const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null; + const actionsBtn = document.getElementById('email-lib-bulk-actions'); + const cancelBtn = document.getElementById('email-lib-bulk-cancel'); + const selectAll = document.getElementById('email-lib-select-all'); + const countEl = document.getElementById('email-lib-selected-count'); + const originalDeleteHtml = deleteBtn?.innerHTML || ''; + const originalCountText = countEl?.textContent || ''; + let busySpinner = null; + // Loading state for every bulk action, not just delete — large + // selections (e.g. 90+ Dones) used to silently hammer the server + // with sequential requests and the user got zero feedback. Now the + // Actions button (or Delete button) shows a whirlpool + verb-ing + // label, and the count surfaces progress. + const verbing = { + delete: 'Deleting', + archive: 'Archiving', + done: 'Marking done', + read: 'Marking read', + unread: 'Marking unread', + }[action] || 'Updating'; + const targetBtn = action === 'delete' ? deleteBtn : actionsBtn; + let originalTargetHtml = ''; + if (targetBtn) { + originalTargetHtml = targetBtn.innerHTML; + targetBtn.disabled = true; + targetBtn.classList.add('email-bulk-loading'); + targetBtn.innerHTML = `${verbing}`; + busySpinner = spinnerModule.create('', 'clean', 'whirlpool'); + const spEl = busySpinner.createElement(); + spEl.classList.add('email-bulk-whirlpool'); + targetBtn.appendChild(spEl); + busySpinner.start(); + } + if (action !== 'delete' && deleteBtn) deleteBtn.disabled = true; + if (action === 'delete' && actionsBtn) actionsBtn.disabled = true; + if (cancelBtn) cancelBtn.disabled = true; + if (selectAll) selectAll.disabled = true; + if (countEl) countEl.textContent = `${verbing} ${uids.length}…`; + const deleteOverlays = action === 'delete' + ? uids.map(uid => { + const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(uid))}"]`); + return _showEmailDeleteOverlay(card); + }).filter(Boolean) + : []; + if (deleteOverlays.length) { + await Promise.all(deleteOverlays.map(busy => busy.ready).filter(Boolean)); + } + + // Single-uid worker. + const handleOne = async (uid) => { + try { + if (action === 'archive') { + const res = await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); + const data = await res.json().catch(() => null); + if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`); + } else if (action === 'delete') { + // Bulk Delete matches the permanent-delete behavior used by the + // library and albums. Moving a batch through provider Trash folders + // is unreliable on several IMAP implementations. + const res = await fetch(`${API_BASE}/api/email/delete-permanent/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); + const data = await res.json().catch(() => null); + if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`); + } else if (action === 'done') { + // uid may come back from the Set as a string while em.uid is + // numeric (or vice versa) — coerce both sides so the in-memory + // state actually flips and the post-loop re-render shows the + // done checkmark. + const em = state._libEmails.find(e => String(e.uid) === String(uid)); + if (em) { + em.is_answered = true; + em.is_read = true; + _clearDoneResponseTagsLocal(em); + } + const ansRes = await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); + const readRes = await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); + if (!ansRes.ok || !readRes.ok) throw new Error(`mark-done HTTP ${ansRes.status}/${readRes.status}`); + } else if (action === 'read' || action === 'unread') { + const endpoint = action === 'read' ? 'mark-read' : 'mark-unread'; + const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); + let data = null; + try { data = await res.json(); } catch (_) {} + if (!res.ok || data?.success === false) { + throw new Error(data?.error || `HTTP ${res.status}`); + } + _syncEmailReadState(uid, action === 'read'); + } + } catch (e) { + if (action === 'read' || action === 'unread') failedReadSync += 1; + failedUids.add(String(uid)); + console.error(`Failed to ${action} ${uid}:`, e); + } + }; + + try { + // Run in parallel with a concurrency cap so 92 emails don't take + // 30 seconds sequentially but we also don't open 92 simultaneous + // connections. + const CONCURRENCY = 6; + const queue = uids.slice(); + let inFlight = 0; + let nextSlot = 0; + let finishedCount = 0; + let bulkDeletedUids = null; + if (action === 'delete') { + const res = await fetch(`${API_BASE}/api/email/delete-bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }), + }); + const data = await res.json().catch(() => null); + if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`); + for (const uid of data?.failed_uids || []) failedUids.add(String(uid)); + bulkDeletedUids = (data?.deleted_uids || []).map(String); + if (countEl) countEl.textContent = `${verbing} ${bulkDeletedUids.length}/${uids.length}…`; + } else await new Promise((resolve) => { + const launch = () => { + while (inFlight < CONCURRENCY && nextSlot < queue.length) { + const uid = queue[nextSlot++]; + inFlight++; + handleOne(uid).finally(() => { + inFlight--; + finishedCount++; + if (countEl) countEl.textContent = `${verbing} ${finishedCount}/${queue.length}…`; + if (nextSlot >= queue.length && inFlight === 0) resolve(); + else launch(); + }); + } + if (queue.length === 0) resolve(); + }; + launch(); + }); + + if (action === 'archive' || action === 'delete') { + const successfulUids = bulkDeletedUids || uids.filter(uid => !failedUids.has(String(uid))); + if (action === 'delete') { + deleteOverlays.forEach(busy => busy.remove?.()); + } + await _animateEmailCardRemoval(successfulUids); + const removed = new Set(successfulUids.map(uid => String(uid))); + state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); + } else if (action === 'done' && state._libFilter === 'undone') { + // The undone filter is a "show only not-done" view — after marking + // selected emails done, they no longer match. Animate them out and + // drop them from the local list so the view reflects the filter + // instead of leaving freshly-done cards sitting there. + await _animateEmailCardRemoval(uids); + const removed = new Set(uids.map(uid => String(uid))); + state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); + } + } finally { + deleteOverlays.forEach(busy => busy.remove?.()); + if (busySpinner) busySpinner.destroy(); + // Restore whichever button we hijacked (delete vs actions). + if (targetBtn) { + targetBtn.disabled = false; + targetBtn.classList.remove('email-bulk-loading'); + targetBtn.innerHTML = originalTargetHtml || targetBtn.innerHTML; + } + if (deleteBtn && deleteBtn !== targetBtn) { + deleteBtn.disabled = false; + deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML; + } + if (actionsBtn && actionsBtn !== targetBtn) actionsBtn.disabled = false; + if (cancelBtn) cancelBtn.disabled = false; + if (selectAll) selectAll.disabled = false; + if (countEl) countEl.textContent = originalCountText; + } + state._selectedUids.clear(); + state._selectMode = false; + _updateBulkBar(); + _renderGrid(); + if (failedReadSync > 0) { + showToast(`Failed to update ${failedReadSync} email${failedReadSync === 1 ? '' : 's'}`); + } + if (failedUids.size > 0 && (action === 'delete' || action === 'archive')) { + showToast(`${failedUids.size} email${failedUids.size === 1 ? '' : 's'} could not be ${action === 'delete' ? 'deleted' : 'archived'}`); + } + // Sync successful local mutations into the SWR cache so reopen doesn't + // briefly show the pre-bulk state. + _libCacheWriteBack(); +} diff --git a/static/js/emailLibrary/reader.js b/static/js/emailLibrary/reader.js new file mode 100644 index 000000000..0f13581af --- /dev/null +++ b/static/js/emailLibrary/reader.js @@ -0,0 +1,676 @@ +// static/js/emailLibrary/reader.js +// +// The two ways an email opens outside the list: as a docked tab (a numbered +// slot in the dock, `_openEmailAsTab`) and as a floating window +// (`_openEmailWindow`). Plus the AI summary panel, which both of them and the +// inline expanded card share. +// +// Reader slot numbers are deliberately sticky: once a reader is tab 2 it stays +// tab 2 until it closes, even if tab 1 closes first. The slot map here is what +// holds that. + +import * as Modals from '../modalManager.js'; +import { showToast } from '../ui.js?v=20260916largetoolscroll1'; +import { state } from './state.js'; +import { _esc, _extractName, _renderEmailSummaryError } from './utils.js'; +import { _aiReplyIcon, _handleAiReplyButton, _hasMultipleRecipients } from './aiReply.js'; +import { + _buildAttsHtmlFor, + _loadDeferredAttachmentsIntoReader, + _wireEmailAttachmentWrap, +} from './attachments.js'; +import { + _renderEmailBody, + _safeRenderEmailBody, + _wireEmailInlineImages, +} from './bodyRender.js'; +import { + _acct, + _emailReaderSkeletonHtml, + _makeDraggable, + _markEmailReaderActive, + _maybeAutoTranslateEmail, + _normalizeEmailStateFlags, + _recipientChipHtml, + _recipientMetaToggleHtml, + _setSummaryCollapsedPref, + _showEmailReaderLoadError, + _snapEmailModalToLeftSidebar, + _splitRecipientList, + _stampReaderContext, + _summaryCollapsedPref, + _syncEmailDoneState, + _syncEmailReadState, + _toggleCardPreview, + _wireMetaToggle, + _wireReaderActionOverflow, + _wireRecipientChips, +} from './index.js'; +import { _showReaderMoreMenu } from './menus.js'; + +const API_BASE = window.location.origin; + +// "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. +export 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 showFailedTab = (message) => { + try { showToast(message || 'Failed to load email'); } catch (_) {} + _showEmailReaderLoadError(reader, message, () => { + try { Modals.close(modalId); } catch (_) { try { modal.remove(); } catch (_) {} } + setTimeout(() => { _openEmailAsTab(em, useFolder); }, 0); + }); + }; + _markEmailReaderActive(reader); + const loading = modal.querySelector('.email-reader-tab-loading'); + if (loading) loading.remove(); + if (reader) { + reader.classList.add('email-card-reader-loading'); + reader.innerHTML = _emailReaderSkeletonHtml(); + } + try { + const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + let data = await res.json(); + if (data.error) { + showFailedTab(`Failed to load email: ${data.error}`); + return; + } + data = _normalizeEmailStateFlags({ ...em, ...data }); + Object.assign(em, data); + _syncEmailDoneState(em.uid, data.is_answered); + _syncEmailReadState(em.uid, true); + _stampReaderContext(reader, data, useFolder, state._libAccountId); + const buildChips = (str) => { + if (!str) return ''; + return _splitRecipientList(str).map(a => { + const name = _extractName(a); + return _recipientChipHtml(a, name); + }).join(''); + }; + const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip'); + let attsHtml = ''; + try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {} + reader.innerHTML = ` + + ++ ${attsHtml} +${_safeRenderEmailBody(data)}+ `; + _markEmailReaderActive(reader); + reader.classList.remove('email-card-reader-loading'); + _wireRecipientChips(reader); + _wireEmailAttachmentWrap(reader, useFolder); + _wireEmailInlineImages(reader); + _loadDeferredAttachmentsIntoReader(reader, em.uid, useFolder, data, !!em.has_attachments); + _maybeAutoTranslateEmail(reader); + 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', (ev) => _handleAiReplyButton(ev, em, data)); + 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 {} + }); + _wireMetaToggle(reader); + _wireReaderActionOverflow(reader); + reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => { + ev.stopPropagation(); + try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget, data); } catch {} + }); + } catch (err) { + showFailedTab(err?.message ? `Failed to load email: ${err.message}` : 'Failed to load email'); + } +} + + +// "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; +export 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 { + if (loading) loading.remove(); + if (bodyEl) { + bodyEl.classList.add('email-card-reader', 'email-card-reader-loading'); + bodyEl.style.padding = '0'; + bodyEl.innerHTML = _emailReaderSkeletonHtml(); + } + const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`); + let data = await res.json(); + if (data.error) { + bodyEl.innerHTML = `${_esc(data.error)}`; + return; + } + data = _normalizeEmailStateFlags({ ...em, ...data }); + Object.assign(em, data); + _syncEmailDoneState(em.uid, data.is_answered); + _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 = _splitRecipientList(addrs); + return list.map(a => { + const name = _extractName(a); + return _recipientChipHtml(a, name); + }).join(''); + }; + const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip'); + 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.classList.remove('email-card-reader-loading'); + _stampReaderContext(bodyEl, { ...em, ...data }, useFolder, state._libAccountId); + _markEmailReaderActive(bodyEl); + bodyEl.style.padding = '0'; + bodyEl.innerHTML = ` ++ ++ ${attsHtml} +${_safeRenderEmailBody(data)}+ `; + _markEmailReaderActive(bodyEl); + _wireRecipientChips(bodyEl); + // Wire all the same action handlers the inline reader has. + _wireEmailAttachmentWrap(bodyEl, useFolder); + _wireEmailInlineImages(bodyEl); + _loadDeferredAttachmentsIntoReader(bodyEl, em.uid, useFolder, data, !!em.has_attachments); + _maybeAutoTranslateEmail(bodyEl); + 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', (ev) => _handleAiReplyButton(ev, em, data)); + 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 {} + }); + _wireMetaToggle(bodyEl); + _wireReaderActionOverflow(bodyEl); + 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, data); } catch {} + }); + } catch (err) { + bodyEl.innerHTML = `Failed to load: ${_esc(String(err))}`; + } +} + +export 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 = ` ++ + Summary ++No 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 { + _renderEmailSummaryError(content, result); + } + } catch (e) { + sp.destroy(); + _renderEmailSummaryError(content, null); + if (uiModule) uiModule.showError?.('Failed to summarize'); + } finally { + if (btn) btn.disabled = false; + } +} + +export function _emailBodyTextForTranslate(reader) { + const body = reader?.querySelector?.('.email-reader-body'); + if (!body) return ''; + const clone = body.cloneNode(true); + clone.querySelectorAll('.email-summary-panel, details.email-quote-fold, details.email-sig-fold').forEach(n => n.remove()); + return (clone.innerText || clone.textContent || '').trim(); +} diff --git a/static/js/emailLibrary/settingsPage.js b/static/js/emailLibrary/settingsPage.js new file mode 100644 index 000000000..406b1f166 --- /dev/null +++ b/static/js/emailLibrary/settingsPage.js @@ -0,0 +1,879 @@ +// static/js/emailLibrary/settingsPage.js +// +// The Email Settings page: the in-panel settings view, its form markup and +// controls, the away/auto-reply configuration, and the display preferences +// (inline images, tag visibility, compact rows). +// +// Two things here reach outside settings, which is why they live together. +// Turning away-replies on writes a matching calendar event, so this module owns +// the `odysseus.email.autoReplyCalendarEvent.*` keys and the create/update/ +// delete sync against /api/calendar. And the inline-image preference is read by +// ./bodyRender.js while rendering a message, not only by the settings form. + +import { emailApiUrl } from '../emailShared.js'; +import { showToast } from '../ui.js?v=20260916largetoolscroll1'; +import { state } from './state.js'; +import { _esc } from './utils.js'; +import { _wireEmailInlineImages } from './bodyRender.js'; +import { + _loadAccounts, + _loadEmails, + _loadFolders, + _loadedEmailsHaveVisibleTags, + _notifyNoLoadedEmailTags, + _openTasksForEmailTags, + _publishActiveAccount, + _refreshUnreadBadge, + _renderAccountsStrip, + _renderGrid, + _resetEmailListForFreshLoad, +} from './index.js'; +import { _openUnsubscribeReviewModal } from './unsubscribe.js'; + +const API_BASE = window.location.origin; + +function _emailInlineImagesStorageKey(accountId = state._libAccountId) { + return `odysseus.email.viewInlineImages.${String(accountId || 'default')}`; +} + +export function _readEmailInlineImagesPreference(accountId = state._libAccountId) { + try { + const stored = localStorage.getItem(_emailInlineImagesStorageKey(accountId)); + return stored === null ? true : stored !== '0'; + } catch (_) { + return true; + } +} + +function _writeEmailInlineImagesPreference(enabled, accountId = state._libAccountId) { + try { + localStorage.setItem(_emailInlineImagesStorageKey(accountId), enabled ? '1' : '0'); + } catch (_) {} +} + +export async function _disableInlineImages(accountId = state._libAccountId) { + state._libViewInlineImages = false; + _writeEmailInlineImagesPreference(false, accountId); + document.querySelectorAll('#email-settings-view-inline-images').forEach(toggle => { + toggle.checked = true; + toggle.dispatchEvent(new Event('change', { bubbles: true })); + }); + try { + const res = await fetch(emailApiUrl('/api/email/config', { + account_id: accountId || undefined, + }), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ email_view_inline_images: false }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (err) { + console.error('Failed to disable inline images:', err); + } +} + +export const _EMAIL_SETTINGS_ICON = ``; +const _EMAIL_SAVE_ICON = ''; +const _EMAIL_SAVED_ICON = ''; + +function _setEmailSaveIcon(button, saved) { + if (!button) return; + const icon = button.querySelector('svg'); + if (icon) icon.outerHTML = saved ? _EMAIL_SAVED_ICON : _EMAIL_SAVE_ICON; + button.classList.toggle('is-saved', !!saved); +} +const _DEFAULT_AUTO_REPLY_SUBJECT = '(Away) {subject}'; +const _DEFAULT_AUTO_REPLY_MESSAGE = "Thanks for your email. I'm away and may be slower to reply."; + +function _normalizeAutoReplyConfig(cfg = {}) { + const normalized = Object.prototype.hasOwnProperty.call(cfg, 'enabled'); + return { + enabled: normalized ? !!cfg.enabled : !!cfg.email_auto_reply, + start: String(normalized ? (cfg.start || '') : (cfg.email_auto_reply_start || '')), + end: String(normalized ? (cfg.end || '') : (cfg.email_auto_reply_end || '')), + subject: String(normalized ? (cfg.subject || _DEFAULT_AUTO_REPLY_SUBJECT) : (cfg.email_auto_reply_subject || _DEFAULT_AUTO_REPLY_SUBJECT)), + message: String(normalized ? (cfg.message || '') : (cfg.email_auto_reply_message || '')), + cooldown: String(normalized ? (cfg.cooldown || 'period') : (cfg.email_auto_reply_cooldown || 'period')), + scope: String(normalized ? (cfg.scope || 'all') : (cfg.email_auto_reply_scope || 'all')), + accountId: String(normalized ? (cfg.accountId || '') : (cfg.email_auto_reply_account_id || '')), + excludeAutomated: normalized ? cfg.excludeAutomated !== false : cfg.email_auto_reply_exclude_automated !== false, + pauseNotifications: normalized ? !!cfg.pauseNotifications : !!cfg.email_auto_reply_pause_notifications, + viewInlineImages: normalized ? cfg.viewInlineImages !== false : cfg.email_view_inline_images !== false, + }; +} + +function _autoReplyDateValue(value) { + const s = String(value || '').trim(); + const m = s.match(/^(\d{4}-\d{2}-\d{2})/); + return m ? m[1] : ''; +} + +function _todayDateInputValue() { + const now = new Date(); + const pad = value => String(value).padStart(2, '0'); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} + +const _AUTO_REPLY_CALENDAR_KEY_PREFIX = 'odysseus.email.autoReplyCalendarEvent.'; +const _autoReplyCalendarSyncs = new Map(); + +function _autoReplyCalendarKey(accountId) { + return `${_AUTO_REPLY_CALENDAR_KEY_PREFIX}${String(accountId || '').trim()}`; +} + +function _readAutoReplyCalendarUid(accountId) { + if (!accountId) return ''; + try { return String(localStorage.getItem(_autoReplyCalendarKey(accountId)) || ''); } catch (_) { return ''; } +} + +function _writeAutoReplyCalendarUid(accountId, uid) { + if (!accountId) return; + try { + if (uid) localStorage.setItem(_autoReplyCalendarKey(accountId), uid); + else localStorage.removeItem(_autoReplyCalendarKey(accountId)); + } catch (_) {} +} + +function _addLocalCalendarDays(value, days) { + const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) return ''; + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days); + const pad = n => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function _autoReplyCalendarPayload(cfg, accountId = state._libAccountId) { + const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue(); + let end = _autoReplyDateValue(cfg?.end) || start; + if (end < start) end = start; + return { + summary: 'Email Auto Reply (away)', + dtstart: start, + dtend: _addLocalCalendarDays(end, 1), + all_day: true, + description: `Odysseus email auto reply - account:${String(accountId || '')}`, + }; +} + +async function _findAutoReplyCalendarEventUids(cfg, accountId) { + const start = _autoReplyDateValue(cfg?.start) || _todayDateInputValue(); + const end = _autoReplyDateValue(cfg?.end) || start; + const startYear = Number(start.slice(0, 4)) || new Date().getFullYear(); + const endYear = Math.max(startYear, Number(end.slice(0, 4)) || startYear); + try { + const response = await fetch(`${API_BASE}/api/calendar/events?start=${startYear - 1}-01-01&end=${endYear + 2}-01-01`, { + credentials: 'same-origin', + }); + if (!response.ok) return []; + const data = await response.json().catch(() => ({})); + const marker = `Odysseus email auto reply - account:${String(accountId || '')}`; + return (data.events || []) + .filter(ev => String(ev?.description || '').startsWith(marker)) + .map(ev => String(ev?.uid || '').trim()) + .filter(Boolean); + } catch (_) { + return []; + } +} + +async function _syncAutoReplyCalendarEventNow(cfg, accountId) { + accountId = String(accountId || '').trim(); + if (!accountId) return false; + let uid = _readAutoReplyCalendarUid(accountId); + const headers = { 'Content-Type': 'application/json' }; + const request = (url, options = {}) => fetch(url, { + credentials: 'same-origin', + ...options, + headers: { ...headers, ...(options.headers || {}) }, + }); + + if (!cfg?.enabled) { + const knownUids = new Set(uid ? [uid] : []); + (await _findAutoReplyCalendarEventUids(cfg, accountId)).forEach(foundUid => knownUids.add(foundUid)); + for (const eventUid of knownUids) { + const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(eventUid)}`, { method: 'DELETE' }); + if (!response.ok && response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`); + } + _writeAutoReplyCalendarUid(accountId, ''); + return true; + } + + const payload = _autoReplyCalendarPayload(cfg, accountId); + if (!uid) { + uid = (await _findAutoReplyCalendarEventUids(cfg, accountId))[0] || ''; + if (uid) _writeAutoReplyCalendarUid(accountId, uid); + } + if (uid) { + const response = await request(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, { + method: 'PUT', body: JSON.stringify(payload), + }); + if (response.ok) return true; + if (response.status !== 404) throw new Error(`Calendar HTTP ${response.status}`); + _writeAutoReplyCalendarUid(accountId, ''); + } + + const response = await request(`${API_BASE}/api/calendar/events`, { + method: 'POST', body: JSON.stringify(payload), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || !data.uid) throw new Error(`Calendar HTTP ${response.status}`); + _writeAutoReplyCalendarUid(accountId, data.uid); + return true; +} + +export function _syncAutoReplyCalendarEvent(cfg) { + const accountId = String(state._libAccountId || '').trim(); + if (!accountId) return Promise.resolve(false); + const previous = _autoReplyCalendarSyncs.get(accountId) || Promise.resolve(); + const next = previous.catch(() => {}).then(() => _syncAutoReplyCalendarEventNow(cfg, accountId)); + _autoReplyCalendarSyncs.set(accountId, next); + return next.finally(() => { + if (_autoReplyCalendarSyncs.get(accountId) === next) _autoReplyCalendarSyncs.delete(accountId); + }); +} + +export function _isAutoReplyActiveForCurrentAccount(cfg) { + cfg = _normalizeAutoReplyConfig(cfg || {}); + if (!cfg.enabled) return false; + if (cfg.scope === 'account' && cfg.accountId && cfg.accountId !== String(state._libAccountId || '')) return false; + const today = new Date().toISOString().slice(0, 10); + const start = _autoReplyDateValue(cfg.start); + const end = _autoReplyDateValue(cfg.end); + if (start && today < start) return false; + if (end && today > end) return false; + return true; +} + +export function _syncEmailAutoReplyTitle(active) { + const titleText = document.getElementById('email-lib-title-text'); + const statusDot = document.getElementById('email-lib-auto-reply-status-dot'); + const badge = document.getElementById('email-lib-auto-reply-badge'); + if (titleText) titleText.textContent = active ? 'Auto Reply - Active' : 'Email'; + if (statusDot) statusDot.style.display = active ? 'inline-block' : 'none'; + if (badge) { + badge.style.display = 'none'; + badge.textContent = 'Auto Reply'; + } +} + +export async function _fetchEmailSettingsConfig() { + const res = await fetch(emailApiUrl('/api/email/config', { + account_id: state._libAccountId || undefined, + }), { credentials: 'include' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const normalized = _normalizeAutoReplyConfig(await res.json()); + state._libViewInlineImages = normalized.viewInlineImages; + _writeEmailInlineImagesPreference(state._libViewInlineImages); + return normalized; +} + +export async function _fetchEmailWritingStyle() { + const res = await fetch(emailApiUrl('/api/email/style', { + account_id: state._libAccountId || undefined, + }), { credentials: 'include' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json().catch(() => ({})); + return String(data.style || ''); +} + +export function _emailSettingsLoadingHtml(label = 'Loading') { + const wp = spinnerModule.createWhirlpool(16); + const host = document.createElement('span'); + host.className = 'email-settings-loading-spinner'; + host.appendChild(wp.element); + const wrap = document.createElement('div'); + wrap.className = 'email-settings-loading'; + wrap.appendChild(host); + const text = document.createElement('span'); + text.textContent = label; + wrap.appendChild(text); + return wrap.outerHTML; +} + +export function _emailSettingsFormHtml(cfg) { + const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId); + const accountAddress = activeAccount + ? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim() + : ''; + const accountScope = accountAddress + ? ` (${_esc(accountAddress)})` + : ''; + return ` + + `; +} + +export function _emailCleanupSettingsHtml() { + const activeAccount = state._libAccounts?.find(a => a && a.id === state._libAccountId); + const accountAddress = activeAccount + ? String(activeAccount.from_address || activeAccount.imap_user || activeAccount.email || '').trim() + : ''; + const accountScope = accountAddress + ? ` (${_esc(accountAddress)})` + : ''; + return ` +++ `; +} + +export function _emailDisplaySettingsHtml(cfg = {}) { + return ` ++++ Newsletter Unsubscribe${accountScope} ++Find newsletter and ad emails with unsubscribe options.++++ + + ++++++ +++++Show Email Tags+ + + + +Choose which labels appear on email cards. Turn off tagging in+++ `; +} + +export function _emailSettingsAccountSelectHtml() { + const accounts = Array.isArray(state._libAccounts) + ? state._libAccounts + .filter(a => a && a.enabled !== false) + .slice() + .sort((a, b) => Number(!!b.is_default) - Number(!!a.is_default)) + : []; + if (!accounts.length) return ''; + const nameCounts = new Map(); + accounts.forEach(a => { + const name = String(a.name || '').trim(); + if (name) nameCounts.set(name, (nameCounts.get(name) || 0) + 1); + }); + const opts = accounts.map(a => { + const name = String(a.name || '').trim(); + const address = String(a.from_address || a.imap_user || '').trim(); + const label = name && nameCounts.get(name) > 1 && address + ? `${name} · ${address}` + : (name || address || 'account'); + const suffix = a.is_default ? ' · (default)' : ''; + return ``; + }).join(''); + const selected = accounts.find(a => a.id === state._libAccountId) || accounts[0]; + const active = !!selected?.is_default; + const icon = active + ? '' + : ''; + const defaultTitle = active ? 'Default account' : 'Make this the default account'; + return `++ +++++Hide Inline Images+ + + + +Keep images embedded in email bodies hidden until you load them.+` + + `default` + + `` + + ``; +} + +export function _syncEmailSettingsAccountPicker(page) { + const select = page?.querySelector('#email-settings-account-select'); + const selected = (state._libAccounts || []).find(a => a && a.id === state._libAccountId); + if (select && selected) { + [...select.options] + .sort((a, b) => { + const aAccount = (state._libAccounts || []).find(account => account && account.id === a.value); + const bAccount = (state._libAccounts || []).find(account => account && account.id === b.value); + return Number(!!bAccount?.is_default) - Number(!!aAccount?.is_default); + }) + .forEach(option => select.appendChild(option)); + select.value = selected.id; + [...select.options].forEach(option => { + const account = (state._libAccounts || []).find(a => a && a.id === option.value); + if (!account) return; + const label = account.name || account.from_address || account.imap_user || 'account'; + option.textContent = label + (account.is_default ? ' · (default)' : ''); + }); + } + const toggle = page?.querySelector('.email-settings-default'); + if (!toggle || !selected) return; + const active = !!selected.is_default; + toggle.classList.toggle('active', active); + toggle.setAttribute('aria-pressed', active ? 'true' : 'false'); + toggle.dataset.emailDefaultId = selected.id || ''; + toggle.title = active ? 'Default account' : 'Make this the default account'; + toggle.querySelector('.cookbook-srv-default-icon').innerHTML = active + ? '' + : ''; +} + +export function _emailWritingStyleHtml(style) { + return ` +++ `; +} + +export async function _showEmailSettingsPage() { + const modal = document.getElementById('email-lib-modal'); + const page = document.getElementById('email-lib-settings-page'); + const btn = document.getElementById('email-lib-settings-btn'); + if (!modal || !page) return; + modal.classList.add('email-settings-mode'); + page.hidden = false; + page.scrollTop = 0; + page.querySelector('.email-settings-body')?.scrollTo(0, 0); + const headerBackBtn = document.getElementById('email-settings-header-back'); + if (headerBackBtn) headerBackBtn.style.display = 'inline-flex'; + btn?.classList.add('active'); + btn?.setAttribute('aria-expanded', 'true'); + page.innerHTML = ` +++++Email Reply Writing Style+Used when Odysseus drafts replies for you.++ +++ + + ++++${_EMAIL_SETTINGS_ICON}Email Settings
++ ${_emailSettingsLoadingHtml()} ++ `; + let cfg; + let writingStyle = ''; + try { + await _loadAccounts().catch(() => {}); + const head = page.querySelector('.email-settings-page-head'); + const accountSelectHtml = _emailSettingsAccountSelectHtml(); + const oldPicker = page.querySelector('.email-settings-account-picker'); + if (oldPicker) oldPicker.remove(); + const title = head?.querySelector('.email-settings-title'); + if (title && accountSelectHtml) title.insertAdjacentHTML('beforeend', accountSelectHtml); + [cfg, writingStyle] = await Promise.all([ + _fetchEmailSettingsConfig(), + _fetchEmailWritingStyle().catch(() => ''), + ]); + } catch (err) { + page.querySelector('.email-settings-body').innerHTML = `Could not load settings.`; + return; + } + page.querySelector('.email-settings-body').innerHTML = _emailWritingStyleHtml(writingStyle) + _emailDisplaySettingsHtml(cfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(cfg); + page.querySelector('.email-settings-body')?.scrollTo(0, 0); + state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(cfg); + state._libAutoReplyDraftActive = null; + _syncEmailAutoReplyTitle(state._libAutoReplyActive); + _renderAccountsStrip(); + _syncAutoReplyCalendarEvent(cfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err)); + page.querySelector('#email-settings-account-select')?.addEventListener('change', async (ev) => { + state._libAccountId = ev.currentTarget.value || null; + state._libAutoReplyDraftActive = null; + _publishActiveAccount(); + _syncEmailSettingsAccountPicker(page); + _renderAccountsStrip(); + _resetEmailListForFreshLoad(); + _loadEmails({ useCache: true }); + _loadFolders({ resetMissing: true }).catch(() => {}); + page.querySelector('.email-unsubscribe-inline-panel')?.remove(); + const body = page.querySelector('.email-settings-body'); + if (body) body.innerHTML = _emailSettingsLoadingHtml(); + try { + const [nextCfg, nextStyle] = await Promise.all([ + _fetchEmailSettingsConfig(), + _fetchEmailWritingStyle().catch(() => ''), + ]); + if (body) body.innerHTML = _emailWritingStyleHtml(nextStyle) + _emailDisplaySettingsHtml(nextCfg) + _emailCleanupSettingsHtml() + _emailSettingsFormHtml(nextCfg); + state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(nextCfg); + state._libAutoReplyDraftActive = null; + _syncEmailAutoReplyTitle(state._libAutoReplyActive); + _renderAccountsStrip(); + _syncAutoReplyCalendarEvent(nextCfg).catch((err) => console.warn('Failed to reconcile auto-reply calendar event:', err)); + _bindEmailSettingsPageControls(page); + } catch (_) { + if (body) body.innerHTML = `Could not load settings.`; + } + }); + page.querySelector('.email-settings-default')?.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const acctId = ev.currentTarget.dataset.emailDefaultId; + const account = (state._libAccounts || []).find(a => a && a.id === acctId); + if (!acctId || account?.is_default) return; + try { + const response = await fetch(`${API_BASE}/api/email/accounts/${encodeURIComponent(acctId)}/set-default`, { + method: 'POST', credentials: 'same-origin', + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + for (const a of state._libAccounts) a.is_default = a.id === acctId; + _syncEmailSettingsAccountPicker(page); + _renderAccountsStrip(); + } catch (err) { + console.error('Set default account failed:', err); + } + }); + page.querySelector('.email-settings-default')?.addEventListener('keydown', (ev) => { + if (ev.key !== 'Enter' && ev.key !== ' ') return; + ev.preventDefault(); + ev.currentTarget.click(); + }); + _bindEmailSettingsPageControls(page); +} + +export function _bindEmailSettingsPageControls(page) { + page.querySelector('.email-settings-clean-btn')?.addEventListener('click', (ev) => { + // Clean is an explicit fresh scan action; do not reopen stale cached + // candidates when the user asks to clean again. + _openUnsubscribeReviewModal(ev.currentTarget, { forceRescan: true }); + }); + const showTagsToggle = page.querySelector('#email-settings-show-tags'); + const syncDisplayTagsState = () => { + const section = page.querySelector('.email-settings-display-section'); + const stateLabel = page.querySelector('.email-settings-display-enabled-state'); + const enabled = !!showTagsToggle?.checked; + section?.classList.toggle('is-disabled', !enabled); + section?.classList.toggle('is-enabled', enabled); + if (stateLabel) stateLabel.textContent = enabled ? 'Show' : 'Hide'; + }; + showTagsToggle?.addEventListener('change', (ev) => { + state._libShowTags = !!ev.target.checked; + localStorage.setItem('odysseus.email.showTags', state._libShowTags ? '1' : '0'); + _renderGrid(); + document.dispatchEvent(new CustomEvent('odysseus:email-tags-toggle', { detail: { show: state._libShowTags } })); + if (state._libShowTags && !_loadedEmailsHaveVisibleTags()) _notifyNoLoadedEmailTags(); + syncDisplayTagsState(); + }); + syncDisplayTagsState(); + const inlineImagesToggle = page.querySelector('#email-settings-view-inline-images'); + const syncInlineImagesState = () => { + const section = page.querySelector('.email-settings-inline-images-section'); + const stateLabel = page.querySelector('.email-settings-inline-images-enabled-state'); + const hidden = !!inlineImagesToggle?.checked; + section?.classList.toggle('is-disabled', !hidden); + section?.classList.toggle('is-enabled', hidden); + if (stateLabel) stateLabel.textContent = hidden ? 'Hide' : 'Show'; + }; + inlineImagesToggle?.addEventListener('change', async (ev) => { + const hidden = !!ev.target.checked; + const enabled = !hidden; + const previous = state._libViewInlineImages; + state._libViewInlineImages = enabled; + _writeEmailInlineImagesPreference(enabled); + syncInlineImagesState(); + try { + const res = await fetch(emailApiUrl('/api/email/config', { + account_id: state._libAccountId || undefined, + }), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ email_view_inline_images: enabled }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + document.querySelectorAll('.email-card-reader').forEach(reader => _wireEmailInlineImages(reader)); + } catch (err) { + state._libViewInlineImages = previous; + _writeEmailInlineImagesPreference(previous); + ev.target.checked = previous === false; + syncInlineImagesState(); + showToast?.('Failed to save inline image setting'); + console.error('Failed to save inline image setting:', err); + } + }); + syncInlineImagesState(); + page.querySelector('[data-open-email-tasks]')?.addEventListener('click', _openTasksForEmailTags); + const autoReplyStart = page.querySelector('#email-auto-reply-start'); + const seedAutoReplyStartDate = () => { + if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue(); + }; + // Empty native date inputs can open on an arbitrary browser baseline date. + // Seed only the draft field when the picker is opened, leaving saved settings + // unchanged until the user explicitly presses Save. + autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate); + autoReplyStart?.addEventListener('focus', seedAutoReplyStartDate); + const enabledToggle = page.querySelector('#email-auto-reply-enabled'); + const syncEnabledState = () => { + const section = page.querySelector('.email-settings-auto-reply-section'); + const stateLabel = section?.querySelector('.email-settings-enabled-state'); + const enabled = !!enabledToggle?.checked; + section?.classList.toggle('is-disabled', !enabled); + section?.classList.toggle('is-enabled', enabled); + if (stateLabel) stateLabel.textContent = enabled ? 'Enabled' : 'Disabled'; + }; + const saveAutoReplySettings = async ({ quiet = false } = {}) => { + const saveBtn = page.querySelector('.email-settings-save'); + const status = page.querySelector('.email-settings-status'); + if (saveBtn && !quiet) saveBtn.disabled = true; + _setEmailSaveIcon(saveBtn, false); + if (status) { + status.classList.remove('is-success', 'is-error'); + status.textContent = 'Saving…'; + } + const payload = { + email_auto_reply: !!page.querySelector('#email-auto-reply-enabled')?.checked, + email_auto_reply_start: page.querySelector('#email-auto-reply-start')?.value || '', + email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '', + email_auto_reply_subject: page.querySelector('#email-auto-reply-subject')?.value || '', + email_auto_reply_message: page.querySelector('#email-auto-reply-message')?.value || '', + email_auto_reply_cooldown: page.querySelector('#email-auto-reply-cooldown')?.value || 'period', + email_auto_reply_scope: 'account', + email_auto_reply_account_id: state._libAccountId || '', + email_auto_reply_pause_notifications: !!page.querySelector('#email-auto-reply-pause')?.checked, + }; + try { + const res = await fetch(emailApiUrl('/api/email/config', { + account_id: state._libAccountId || undefined, + }), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const savedCfg = { + enabled: !!payload.email_auto_reply, + start: payload.email_auto_reply_start, + end: payload.email_auto_reply_end, + }; + let calendarSynced = true; + try { + await _syncAutoReplyCalendarEvent(savedCfg); + } catch (calendarErr) { + calendarSynced = false; + console.warn('Failed to sync auto-reply calendar event:', calendarErr); + } + if (status) { + status.classList.add('is-success'); + status.textContent = 'Saved'; + } + _setEmailSaveIcon(saveBtn, true); + if (!quiet) showToast?.('Email settings saved'); + if (!calendarSynced) showToast?.('Saved, but the calendar event could not be updated'); + state._libAutoReplyActive = _isAutoReplyActiveForCurrentAccount(savedCfg); + state._libAutoReplyDraftActive = null; + _syncEmailAutoReplyTitle(state._libAutoReplyActive); + _renderAccountsStrip(); + _refreshUnreadBadge({ preserveAutoReplyTitle: true }).catch(() => {}); + setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, quiet ? 900 : 1400); + return true; + } catch (err) { + if (status) { + status.classList.add('is-error'); + status.textContent = 'Save failed'; + } + showToast?.('Failed to save email settings'); + return false; + } finally { + if (saveBtn) saveBtn.disabled = false; + } + }; + enabledToggle?.addEventListener('change', () => { + syncEnabledState(); + // Invalidate any config read that started before this toggle. Its stale + // result must not overwrite the title we are about to render. + state._autoReplyRefreshSeq += 1; + const active = _isAutoReplyActiveForCurrentAccount({ + email_auto_reply: !!enabledToggle.checked, + email_auto_reply_start: autoReplyStart?.value || '', + email_auto_reply_end: page.querySelector('#email-auto-reply-end')?.value || '', + email_auto_reply_scope: 'account', + email_auto_reply_account_id: state._libAccountId || '', + }); + state._libAutoReplyActive = active; + state._libAutoReplyDraftActive = active; + _syncEmailAutoReplyTitle(active); + _renderAccountsStrip(); + saveAutoReplySettings({ quiet: true }); + }); + syncEnabledState(); + page.querySelector('.email-settings-save')?.addEventListener('click', async () => { + await saveAutoReplySettings(); + }); + page.querySelector('.email-style-settings-save')?.addEventListener('click', async () => { + const saveBtn = page.querySelector('.email-style-settings-save'); + const status = page.querySelector('.email-style-settings-status'); + saveBtn.disabled = true; + _setEmailSaveIcon(saveBtn, false); + if (status) { + status.classList.remove('is-success', 'is-error'); + status.textContent = 'Saving…'; + } + try { + const res = await fetch(emailApiUrl('/api/email/style', { + account_id: state._libAccountId || undefined, + }), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ style: page.querySelector('#email-writing-style-text')?.value || '' }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (status) { + status.classList.add('is-success'); + status.textContent = 'Saved'; + } + _setEmailSaveIcon(saveBtn, true); + showToast?.('Email writing style saved'); + setTimeout(() => { _setEmailSaveIcon(saveBtn, false); if (status) status.textContent = ''; }, 1400); + } catch (err) { + if (status) { + status.classList.add('is-error'); + status.textContent = 'Save failed'; + } + showToast?.('Failed to save email writing style'); + } finally { + saveBtn.disabled = false; + } + }); + page.querySelector('.email-style-settings-extract')?.addEventListener('click', async () => { + const extractBtn = page.querySelector('.email-style-settings-extract'); + const saveBtn = page.querySelector('.email-style-settings-save'); + const status = page.querySelector('.email-style-settings-status'); + const styleEl = page.querySelector('#email-writing-style-text'); + extractBtn.disabled = true; + if (saveBtn) saveBtn.disabled = true; + let wp = null; + if (status) { + status.innerHTML = ''; + try { + wp = spinnerModule.createWhirlpool(14); + wp.element.style.cssText = 'display:inline-block;vertical-align:-3px;margin-right:6px;'; + status.appendChild(wp.element); + status.appendChild(document.createTextNode('Analyzing sent emails…')); + } catch (_) { + status.textContent = 'Analyzing sent emails…'; + } + } + try { + const res = await fetch(emailApiUrl('/api/email/extract-style', { + account_id: state._libAccountId || undefined, + }), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ sample_count: 15 }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.success || !data.style) throw new Error(data.error || `HTTP ${res.status}`); + if (styleEl) styleEl.value = data.style; + if (status) status.textContent = 'Extracted'; + showToast?.('Email writing style extracted'); + setTimeout(() => { if (status) status.textContent = ''; }, 1800); + } catch (err) { + if (status) status.textContent = err?.message || 'Extract failed'; + showToast?.('Failed to extract email writing style'); + } finally { + if (wp && wp.destroy) { try { wp.destroy(); } catch (_) {} } + extractBtn.disabled = false; + if (saveBtn) saveBtn.disabled = false; + } + }); +} + +export function _hideEmailSettingsPage() { + const modal = document.getElementById('email-lib-modal'); + const page = document.getElementById('email-lib-settings-page'); + const btn = document.getElementById('email-lib-settings-btn'); + const headerBackBtn = document.getElementById('email-settings-header-back'); + modal?.classList.remove('email-settings-mode'); + if (page) page.hidden = true; + if (headerBackBtn) headerBackBtn.style.display = 'none'; + btn?.classList.remove('active'); + btn?.setAttribute('aria-expanded', 'false'); +} diff --git a/static/js/emailLibrary/state.js b/static/js/emailLibrary/state.js index 3601452da..e75f9e88e 100644 --- a/static/js/emailLibrary/state.js +++ b/static/js/emailLibrary/state.js @@ -36,4 +36,8 @@ export const state = { _libEscHandler: null, _selectMode: false, _selectedUids: new Set(), + // Generation guard for the away/auto-reply refresh. Written by the + // settings page and by the unread-badge refresh, which live in + // different modules, so it cannot be a module-level `let`. + _autoReplyRefreshSeq: 0, }; diff --git a/static/js/emailLibrary/unsubscribe.js b/static/js/emailLibrary/unsubscribe.js new file mode 100644 index 000000000..37efe3208 --- /dev/null +++ b/static/js/emailLibrary/unsubscribe.js @@ -0,0 +1,1224 @@ +// static/js/emailLibrary/unsubscribe.js +// +// The bulk-unsubscribe review flow: scan `List-Unsubscribe` headers, group +// candidates by sender, and run the mailto/URL/agent-browser unsubscribe plus +// the optional delete-after-unsubscribe cleanup. +// +// Reached from one place — the Clean action in the email-library header — and +// it owns its own localStorage keys (`odysseus.email.unsubscribe*`), its own +// icon set, and the `odysseus:agent-tool-output` listeners that watch an +// agent-browser run finish. Nothing else in the package reads any of that, +// which is why this is the one module with a single exported entry point. + +import { emailApiUrl } from '../emailShared.js'; +import { showToast, styledConfirm } from '../ui.js?v=20260916largetoolscroll1'; +import { state } from './state.js'; +import { _esc } from './utils.js'; +import { _libCacheWriteBack, _renderGrid, isOpen, refreshEmailLibrary } from './index.js'; + +function _unsubscribeMethodLabel(method) { + if (!method) return 'No unsubscribe method'; + if (method.kind === 'mailto') return 'Request Unsubscribe'; + if (method.kind === 'url') return 'Link Unsubscribe'; + return method.target || method.kind || 'Unsubscribe'; +} + +function _unsubscribeFolderLabel(folder) { + const value = String(folder || 'INBOX').trim(); + return value.toUpperCase() === 'INBOX' ? 'Inbox' : value; +} + +function _setUnsubButtonBusy(btn, label) { + if (!btn) return null; + const previous = btn.innerHTML; + const previousDisplay = btn.style.display; + const previousAlignItems = btn.style.alignItems; + const previousJustifyContent = btn.style.justifyContent; + const previousGap = btn.style.gap; + const previousWhiteSpace = btn.style.whiteSpace; + btn.disabled = true; + btn.style.display = 'inline-flex'; + btn.style.alignItems = 'center'; + btn.style.justifyContent = 'center'; + btn.style.gap = '5px'; + btn.style.whiteSpace = 'nowrap'; + btn.innerHTML = ''; + const sp = spinnerModule.createWhirlpool(14); + sp.element.style.position = 'relative'; + sp.element.style.top = '-2px'; + sp.element.style.flexShrink = '0'; + sp.element.style.margin = '0'; + sp.element.style.display = 'inline-flex'; + btn.appendChild(sp.element); + const text = document.createElement('span'); + text.textContent = label || 'Working'; + text.className = 'email-unsub-busy-label'; + text.style.whiteSpace = 'nowrap'; + text.style.display = 'inline-block'; + btn.appendChild(text); + const restore = () => { + btn.disabled = false; + btn.innerHTML = previous; + btn.style.display = previousDisplay; + btn.style.alignItems = previousAlignItems; + btn.style.justifyContent = previousJustifyContent; + btn.style.gap = previousGap; + btn.style.whiteSpace = previousWhiteSpace; + }; + restore.setLabel = (next) => { text.textContent = next || label || 'Working'; }; + return restore; +} + +const _UNSUB_EMAIL_ICON = ''; +const _UNSUB_CHECK_ICON = ''; +const _UNSUB_CLOSE_ICON = ''; +const _UNSUB_AGENT_ICON = ''; +const _UNSUB_TRASH_ICON = ''; +const _UNSUB_REFRESH_ICON = ''; +const _UNSUB_EXTERNAL_ICON = ''; + +function _setUnsubStatusBusy(statusEl, label) { + if (!statusEl) return null; + statusEl.classList.remove('is-error'); + statusEl.classList.add('is-busy'); + statusEl.innerHTML = ''; + statusEl.style.display = 'inline-flex'; + statusEl.style.alignItems = 'center'; + statusEl.style.gap = '6px'; + statusEl.style.justifyContent = 'flex-end'; + statusEl.style.width = '100%'; + const text = document.createElement('span'); + text.textContent = label || 'Scanning…'; + statusEl.appendChild(text); + const sp = spinnerModule.createWhirlpool(14); + sp.element.style.position = 'relative'; + sp.element.style.top = '0'; + sp.element.style.flexShrink = '0'; + sp.element.style.margin = '0'; + sp.element.style.display = 'inline-flex'; + statusEl.appendChild(sp.element); + return (next) => { + statusEl.style.display = ''; + statusEl.style.alignItems = ''; + statusEl.style.gap = ''; + statusEl.style.justifyContent = ''; + statusEl.style.width = ''; + statusEl.classList.remove('is-busy'); + statusEl.textContent = next || ''; + }; +} + +function _askAgentToUnsubscribe(candidate) { + const methods = Array.isArray(candidate?.methods) ? candidate.methods : []; + const recommended = candidate?.recommended_method; + const method = (recommended && typeof recommended === 'object') + ? recommended + : methods.find(m => m.kind === 'url') || methods[0] || null; + const url = method?.kind === 'url' ? method.target : ''; + const methodIndex = Math.max(0, methods.findIndex(m => ( + m === method || (m?.kind === method?.kind && m?.target === method?.target) + ))); + const uid = candidate?.uid || ''; + const reviewedUids = _unsubscribeCandidateUids(candidate); + const folder = candidate?.folder || state._libFolder || 'INBOX'; + const account = state._libAccountId || ''; + const prompt = url + ? `The user explicitly clicked Agent Unsubscribe for this reviewed email. Use the private_browser tool to open this exact unsubscribe URL and complete only the unsubscribe flow. Do not use web_search, web_fetch, bash, or scan_email_unsubscribes. After the page confirms a successful unsubscribe, use bulk_email action=delete on these exact reviewed UID(s) so they are not rediscovered. If the page is ambiguous or unsubscribe fails, stop and report that clearly so the user can choose Spam + delete; do not delete a different message.\n\nEmail UID(s): ${reviewedUids.join(', ') || uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nExact unsubscribe URL: ${url}` + : `The user explicitly clicked Agent Unsubscribe for this reviewed email. Use unsubscribe_email directly for the already-reviewed candidate below. Do not call scan_email_unsubscribes again.\n\nEmail UID: ${uid}\nFolder: ${folder}\nAccount: ${account || '(default)'}\nReviewed method_index: ${methodIndex}`; + return _submitAgentUnsubscribePrompt(prompt); +} + +function _submitAgentUnsubscribePrompt(prompt) { + const input = document.getElementById('message') || document.getElementById('message-input'); + const form = document.getElementById('chat-form'); + if (!input || !form) { + showToast?.('Chat composer not found'); + return false; + } + input.value = prompt; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.focus(); + // Match the working slash-command path. The app-level submit guard can + // still be settling when this is called from a modal button. + setTimeout(() => { + if (!document.body.contains(input) || !document.body.contains(form)) return; + try { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + } catch (err) { + console.error('agent unsubscribe submit failed', err); + showToast?.('Could not start agent unsubscribe'); + } + }, 350); + return true; +} + +function _agentUnsubscribeGroups(candidates) { + const groups = new Map(); + (candidates || []).forEach(candidate => { + const urlMethod = (candidate?.methods || []).find(method => method?.kind === 'url'); + const sender = String(candidate?.from_address || '').trim(); + const key = sender ? `sender:${sender.toLowerCase()}` : `uid:${_unsubscribeCandidateUids(candidate).join(',')}`; + let group = groups.get(key); + if (!group) { + group = { + subject: String(candidate?.subject || '(no subject)'), + sender, + uids: [], + url: String(urlMethod?.target || ''), + subjects: [], + }; + groups.set(key, group); + } + _unsubscribeCandidateUids(candidate).forEach(uid => { + if (!group.uids.includes(uid)) group.uids.push(uid); + }); + const subject = String(candidate?.subject || '').trim(); + if (subject && !group.subjects.includes(subject)) group.subjects.push(subject); + if (!group.url && urlMethod?.target) group.url = String(urlMethod.target); + }); + return Array.from(groups.values()); +} + +function _askAgentToUnsubscribeAll(candidates) { + const reviewed = _agentUnsubscribeGroups(candidates) + .filter(item => item.uids.length || item.url); + if (!reviewed.length) { + showToast?.('No remaining unsubscribe candidates'); + return false; + } + const account = state._libAccountId || ''; + const lines = reviewed.map((item, index) => ( + `${index + 1}. ${item.sender || '(unknown sender)'} | ${item.subjects.slice(0, 3).join(' / ') || item.subject} | UID(s): ${item.uids.join(', ') || '(none)'} | URL: ${item.url || '(none)'}` + )).join('\n'); + const prompt = `The user explicitly chose Agent Unsubscribe All for these already-reviewed unsubscribe candidates. They are grouped by sender: open at most one unsubscribe flow per sender and never repeat an unsubscribe attempt for the same sender. Use the private_browser tool for each exact unsubscribe URL. Do not use web_search, web_fetch, bash, or scan_email_unsubscribes. If a page is ambiguous or unsubscribe fails, skip that sender and report it. After a successful unsubscribe, use bulk_email action=delete only for that sender group's exact reviewed UID(s), so those messages are not rediscovered. Do not delete any other messages. Report successful, skipped, and failed senders clearly.\n\nFolder: ${state._libFolder || 'INBOX'}\nAccount: ${account || '(default)'}\n\nReviewed sender groups (${reviewed.length}):\n${lines}`; + return _submitAgentUnsubscribePrompt(prompt); +} + +function _unsubscribeCandidateUids(candidate) { + const out = []; + const seen = new Set(); + const add = (uid) => { + const val = String(uid || '').trim(); + if (!val || seen.has(val)) return; + seen.add(val); + out.push(val); + }; + add(candidate?.uid); + (candidate?.duplicate_uids || []).forEach(add); + return out; +} + +function _unsubscribeHandledStorageKey() { + return `odysseus.email.unsubscribeHandled.${String(state._libAccountId || 'default')}`; +} + +function _unsubscribeScanStorageKey() { + const account = String(state._libAccountId || 'default'); + const folder = String(state._libFolder || 'INBOX'); + return `odysseus.email.unsubscribeScan.v2.${account}.${folder}`; +} + +function _readUnsubscribeScanCache() { + try { + const parsed = JSON.parse(localStorage.getItem(_unsubscribeScanStorageKey()) || 'null'); + return parsed && Array.isArray(parsed.candidates) ? parsed : null; + } catch (_) { + return null; + } +} + +function _writeUnsubscribeScanCache(data, scanLimit) { + if (!data || !Array.isArray(data.candidates)) return; + try { + localStorage.setItem(_unsubscribeScanStorageKey(), JSON.stringify({ + candidates: data.candidates, + scanned: Number(data.scanned || 0), + raw_total: Number(data.raw_total || data.candidates.length || 0), + scanLimit: scanLimit == null ? 180 : Number(scanLimit), + scanMode: Number(scanLimit || 0) > 0 ? 'limited' : 'whole-folder', + savedAt: Date.now(), + })); + } catch (_) {} +} + +function _readHandledUnsubscribeState() { + try { + const parsed = JSON.parse(localStorage.getItem(_unsubscribeHandledStorageKey()) || '{}'); + return { + uids: new Set(Array.isArray(parsed.uids) ? parsed.uids.map(String) : []), + senders: new Set(Array.isArray(parsed.senders) ? parsed.senders.map(v => String(v).toLowerCase()) : []), + ignoredUids: new Set(Array.isArray(parsed.ignoredUids) ? parsed.ignoredUids.map(String) : []), + ignoredSenders: new Set(Array.isArray(parsed.ignoredSenders) ? parsed.ignoredSenders.map(v => String(v).toLowerCase()) : []), + }; + } catch (_) { + return { uids: new Set(), senders: new Set(), ignoredUids: new Set(), ignoredSenders: new Set() }; + } +} + +function _rememberUnsubscribeHandled(candidates) { + const current = _readHandledUnsubscribeState(); + (candidates || []).forEach(candidate => { + _unsubscribeCandidateUids(candidate).forEach(uid => current.uids.add(String(uid))); + const sender = String(candidate?.from_address || '').trim().toLowerCase(); + if (sender) current.senders.add(sender); + }); + try { + localStorage.setItem(_unsubscribeHandledStorageKey(), JSON.stringify({ + uids: Array.from(current.uids).slice(-2000), + senders: Array.from(current.senders).slice(-500), + ignoredUids: Array.from(current.ignoredUids).slice(-2000), + ignoredSenders: Array.from(current.ignoredSenders).slice(-500), + })); + } catch (_) {} +} + +function _rememberUnsubscribeIgnored(candidate) { + const current = _readHandledUnsubscribeState(); + _unsubscribeCandidateUids(candidate).forEach(uid => current.ignoredUids.add(String(uid))); + const sender = String(candidate?.from_address || '').trim().toLowerCase(); + if (sender) current.ignoredSenders.add(sender); + try { + localStorage.setItem(_unsubscribeHandledStorageKey(), JSON.stringify({ + uids: Array.from(current.uids).slice(-2000), + senders: Array.from(current.senders).slice(-500), + ignoredUids: Array.from(current.ignoredUids).slice(-2000), + ignoredSenders: Array.from(current.ignoredSenders).slice(-500), + })); + } catch (_) {} +} + +function _filterHandledUnsubscribeCandidates(candidates) { + const handled = _readHandledUnsubscribeState(); + return (candidates || []).flatMap(candidate => { + const sender = String(candidate?.from_address || '').trim().toLowerCase(); + if (sender && (handled.senders.has(sender) || handled.ignoredSenders.has(sender))) return []; + const remainingUids = _unsubscribeCandidateUids(candidate) + .filter(uid => !handled.uids.has(String(uid)) && !handled.ignoredUids.has(String(uid))); + if (!remainingUids.length) return []; + if (remainingUids.length === _unsubscribeCandidateUids(candidate).length) return [candidate]; + return [{ + ...candidate, + uid: remainingUids[0], + duplicate_uids: remainingUids.slice(1), + duplicate_count: remainingUids.length, + }]; + }); +} + +function _dedupeUnsubscribeCandidatesForDisplay(candidates) { + const out = []; + const seen = new Map(); + (candidates || []).forEach(c => { + const method = c?.recommended_method || {}; + const urlMethod = (c?.methods || []).find(m => m?.kind === 'url'); + // One sender address should produce one review action, even when every + // message carries a different tokenized unsubscribe URL. + const sender = String(c?.from_address || '').trim().toLowerCase(); + const key = sender || String(c?.list_id || urlMethod?.target || method.target || c?.uid || '').trim().toLowerCase(); + if (!key || !seen.has(key)) { + if (key) seen.set(key, c); + out.push(c); + return; + } + const existing = seen.get(key); + existing.duplicate_count = Number(existing.duplicate_count || 1) + Number(c.duplicate_count || 1); + const uidSet = new Set(_unsubscribeCandidateUids(existing)); + _unsubscribeCandidateUids(c).forEach(uid => uidSet.add(uid)); + existing.duplicate_uids = Array.from(uidSet); + }); + return out; +} + +function _markUnsubscribeCardDone(modal, idx, label = 'Unsubscribed') { + const card = modal?.querySelector?.(`.email-unsub-card[data-idx="${idx}"]`); + if (!card) return; + card.classList.add('is-unsubscribed'); + if (!card.querySelector('.email-unsub-done-badge')) { + const badgeHost = card.querySelector('.email-unsub-subject-line') || card.querySelector('.email-unsub-card-top'); + badgeHost?.insertAdjacentHTML('beforeend', `${_UNSUB_CHECK_ICON}${_esc(label)}`); + } + card.querySelectorAll('.email-unsub-link-btn, .email-unsub-agent-btn, .email-unsub-send-btn, .email-unsub-done-btn, .email-unsub-ignore-btn').forEach(el => { + if (el.tagName === 'A') { + el.setAttribute('aria-disabled', 'true'); + el.style.pointerEvents = 'none'; + } else { + el.disabled = true; + } + el.style.opacity = '0.55'; + }); +} + +function _agentToolArgs(data) { + const candidates = [data?.args, data?.tool_args, data?.command]; + for (const value of candidates) { + if (value && typeof value === 'object' && !Array.isArray(value)) return value; + if (typeof value !== 'string' || !value.trim()) continue; + try { + const parsed = JSON.parse(value); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch (_) { + // Some tool cards prefix the JSON with a display label. Recover the + // object so email mutations still reconcile from the streamed event. + const start = value.indexOf('{'); + const end = value.lastIndexOf('}'); + if (start >= 0 && end > start) { + try { + const parsed = JSON.parse(value.slice(start, end + 1)); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + } catch (_) {} + } + } + } + return {}; +} + +function _agentBrowserUnsubscribeSucceeded(data) { + const tool = String(data?.tool || '').toLowerCase(); + if (!tool.includes('private_browser')) return false; + const output = String(data?.output || ''); + if (/\b(?:not|never|failed|unable|could not|cannot|can't)\b[^.!?\n]{0,50}\bunsubscrib/i.test(output)) { + return false; + } + return /\b(?:already\s+unsubscribed|you(?:'re|\s+are)\s+(?:now\s+)?unsubscribed|successfully\s+unsubscribed|unsubscribed\s+(?:successfully|from)|unsubscription\s+(?:successful|complete)|removed\s+from\s+(?:the\s+)?(?:mailing|email)\s+list)\b/i.test(output); +} + +function _agentBrowserCandidate(run, args) { + const url = String(args?.url || '').trim(); + if (url && Array.isArray(run?.candidates)) { + const matched = run.candidates.find(candidate => ( + (candidate?.methods || []).some(method => method?.kind === 'url' && String(method.target || '') === url) + )); + if (matched) return matched; + } + return run?.activeCandidate || null; +} + +function _trackAgentBrowserUnsubscribe(data) { + const modal = document.getElementById('email-unsubscribe-review-modal'); + const run = modal?._agentUnsubscribeRun; + if (!run) return; + const args = _agentToolArgs(data); + const action = String(args.action || data?.action || '').toLowerCase(); + const candidate = _agentBrowserCandidate(run, args); + if (action === 'open' && candidate) run.activeCandidate = candidate; + if (!candidate || !_agentBrowserUnsubscribeSucceeded(data)) return; + run.cleanupInFlight ||= new Set(); + if (run.cleanupInFlight.has(candidate) || run.completed.has(candidate)) return; + run.cleanupInFlight.add(candidate); + _deleteAfterUnsubscribe([candidate]).then(result => { + if (Number(result?.failed || 0) > 0 || Number(result?.changed || 0) <= 0) { + run.failed ||= new Set(); + run.failed.add(candidate); + run.restoreBusy?.setLabel?.(`Unsubscribed, delete failed (${run.completed.size} / ${run.total})`); + modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( + 'Unsubscribe succeeded, but the matching email could not be moved to Trash. Review it below.', + )); + return; + } + const idx = run.candidates.indexOf(candidate); + run.completed.add(candidate); + _markUnsubscribeCardDone(modal, idx, 'Unsubscribed'); + run.restoreBusy?.setLabel?.(`Unsubscribed ${run.completed.size} / ${run.total}`); + }).catch(error => { + run.failed ||= new Set(); + run.failed.add(candidate); + run.restoreBusy?.setLabel?.(`Unsubscribed, delete failed (${run.completed.size} / ${run.total})`); + modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( + `Unsubscribe succeeded, but delete failed: ${error?.message || 'mail server error'}`, + )); + }); +} + +function _agentDeletedEmailUids(data) { + const tool = String(data?.tool || '').toLowerCase(); + const args = _agentToolArgs(data); + const action = String(args.action || data.action || '').toLowerCase(); + const output = String(data?.output || '').toLowerCase(); + const failed = /\b(?:failed|error|could not|unable|not found)\b/.test(output); + const isBulkDelete = tool.includes('bulk_email') + && ['delete', 'trash', 'move_to_trash'].includes(action); + const isSingleDelete = tool.endsWith('delete_email') + && !tool.includes('bulk_email'); + const isSuccessfulMailtoUnsubscribe = tool.includes('unsubscribe_email') + && !failed + && /(?:source email moved to trash|deleted|unsubscribe email sent)/.test(output); + if (!isBulkDelete && !isSingleDelete && !isSuccessfulMailtoUnsubscribe) return []; + if ((isBulkDelete || isSingleDelete) && failed) return []; + const values = [ + ...(Array.isArray(args.uids) ? args.uids : []), + ...(Array.isArray(data.deleted_uids) ? data.deleted_uids : []), + ...(Array.isArray(data.cleaned_uids) ? data.cleaned_uids : []), + ]; + if (args.uid != null) values.push(args.uid); + if (data.uid != null) values.push(data.uid); + return Array.from(new Set(values.map(uid => String(uid || '').trim()).filter(Boolean))); +} + +function _handleAgentEmailToolOutput(event) { + const data = event?.detail || {}; + _trackAgentBrowserUnsubscribe(data); + const ok = data.exit_code == null ? data.success !== false : data.exit_code === 0; + if (!ok) return; + const deletedUids = new Set(_agentDeletedEmailUids(data)); + if (!deletedUids.size) return; + + state._libEmails = (state._libEmails || []).filter(email => !deletedUids.has(String(email?.uid || ''))); + try { _libCacheWriteBack(); } catch (_) {} + try { _renderGrid(); } catch (_) {} + + const modal = document.getElementById('email-unsubscribe-review-modal'); + const completed = []; + (modal?._unsubscribeCandidates || []).forEach((candidate, idx) => { + const remaining = _unsubscribeCandidateUids(candidate) + .filter(uid => !deletedUids.has(String(uid))); + if (remaining.length === _unsubscribeCandidateUids(candidate).length) return; + if (!remaining.length) { + completed.push(candidate); + _markUnsubscribeCardDone(modal, idx, 'Agent done'); + return; + } + candidate.uid = remaining[0]; + candidate.duplicate_uids = remaining.slice(1); + candidate.duplicate_count = remaining.length; + }); + const run = modal?._agentUnsubscribeRun; + if (run && completed.length) { + completed.forEach(candidate => run.completed.add(candidate)); + run.restoreBusy?.setLabel?.(`Unsubscribed ${run.completed.size} / ${run.total}`); + } + if (completed.length) _rememberUnsubscribeHandled(completed); + const visible = (modal?._unsubscribeCandidates || []).filter(candidate => ( + _unsubscribeCandidateUids(candidate).some(uid => !deletedUids.has(String(uid))) + )); + if (modal && !visible.length) { + if (run) { + run.restoreBusy?.setLabel?.(`Unsubscribed ${run.total} / ${run.total}`); + run.restoreBusy = null; + modal._agentUnsubscribeRun = null; + } + modal.querySelector('.email-unsub-list')?.style.setProperty('display', 'none'); + modal.querySelector('.email-unsub-followup')?.remove(); + modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode('Agent unsubscribe cleanup completed.')); + } +} + +window.addEventListener('odysseus:agent-tool-output', _handleAgentEmailToolOutput); + +function _handleAgentUnsubscribeTerminal(event) { + const modal = document.getElementById('email-unsubscribe-review-modal'); + const run = modal?._agentUnsubscribeRun; + if (!run) return; + const detail = event?.detail?.data || {}; + (Array.isArray(detail.tool_events) ? detail.tool_events : []).forEach(_trackAgentBrowserUnsubscribe); + const completed = run.completed.size; + const remaining = Math.max(0, run.total - completed); + const failure = detail.failure || (detail.failed ? { message: 'The agent run failed before all unsubscribe flows completed.' } : null); + run.restoreBusy?.(); + modal._agentUnsubscribeRun = null; + modal.querySelector('.email-unsub-status')?.replaceChildren(document.createTextNode( + failure + ? `Agent unsubscribe failed: ${completed} / ${run.total} completed. ${remaining} still need review. ${failure.message || ''}`.trim() + : remaining + ? `Agent unsubscribe finished: ${completed} / ${run.total} completed. ${remaining} still need review.` + : `Agent unsubscribe finished: ${completed} / ${run.total} completed.`, + )); + // A tool result can arrive just after the final browser step. Re-fetch once + // the run is over so an old mailbox page or server-side list cache cannot + // leave deleted messages visible until the next manual refresh. + if (!failure && typeof refreshEmailLibrary === 'function') { + setTimeout(() => refreshEmailLibrary().catch(() => {}), 0); + } +} + +window.addEventListener('odysseus:agent-terminal', _handleAgentUnsubscribeTerminal); + +async function _runUnsubscribeCleanup(modal, candidates, action, btn) { + const uids = []; + const seen = new Set(); + (candidates || []).forEach(c => { + _unsubscribeCandidateUids(c).forEach(uid => { + if (seen.has(uid)) return; + seen.add(uid); + uids.push(uid); + }); + }); + if (!uids.length) { + showToast?.('No emails to update'); + return; + } + const busyLabel = action === 'junk' + ? 'Marking spam' + : action === 'junk_delete' ? 'Spam + delete' : 'Deleting'; + const restoreBusy = _setUnsubButtonBusy(btn, busyLabel); + try { + const r = await fetch(emailApiUrl('/api/email/unsubscribe/cleanup', { + account_id: state._libAccountId || undefined, + }), { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action, + uids, + folder: state._libFolder || 'INBOX', + account_id: state._libAccountId || '', + }), + }); + const d = await r.json().catch(() => ({})); + if (!d.success) throw new Error(d.error || 'Cleanup failed'); + const removed = new Set(uids.map(uid => String(uid))); + state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); + try { _libCacheWriteBack(); } catch (_) {} + try { _renderGrid(); } catch (_) {} + _rememberUnsubscribeHandled(candidates); + modal.querySelector('.email-unsub-followup')?.remove(); + showToast?.(action === 'junk' + ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam` + : action === 'junk_delete' + ? `Marked ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'} as spam and deleted ${d.changed || 0}` + : `Deleted ${d.changed || 0} email${Number(d.changed || 0) === 1 ? '' : 's'}`); + } catch (err) { + console.error(err); + showToast?.(err?.message || 'Cleanup failed'); + } finally { + restoreBusy?.(); + } +} + +async function _deleteAfterUnsubscribe(candidates) { + const groups = new Map(); + (candidates || []).forEach(c => { + const sender = String(c?.from_address || '').trim(); + const key = sender.toLowerCase() || `uids:${_unsubscribeCandidateUids(c).join(',')}`; + if (!groups.has(key)) groups.set(key, { sender, uids: [] }); + const group = groups.get(key); + const seen = new Set(group.uids); + _unsubscribeCandidateUids(c).forEach(uid => { + if (!seen.has(uid)) { + seen.add(uid); + group.uids.push(uid); + } + }); + }); + if (!groups.size) return { success: true, changed: 0, failed: 0, cleaned_uids: [] }; + const result = { success: true, changed: 0, failed: 0, cleaned_uids: [] }; + for (const group of groups.values()) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + let r; + try { + r = await fetch(emailApiUrl('/api/email/unsubscribe/cleanup', { + account_id: state._libAccountId || undefined, + }), { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + signal: controller.signal, + body: JSON.stringify({ + action: 'delete', + scope: group.sender ? 'sender_unsubscribe' : '', + sender: group.sender, + uids: group.sender ? [] : group.uids, + folder: state._libFolder || 'INBOX', + account_id: state._libAccountId || '', + }), + }); + } catch (err) { + if (err?.name === 'AbortError') throw new Error('Deleting timed out; the mail server did not respond'); + throw err; + } finally { + clearTimeout(timeout); + } + const d = await r.json().catch(() => ({})); + if (!d.success) throw new Error(d.error || 'Could not move unsubscribed emails to Trash'); + result.changed += Number(d.changed || 0); + result.failed += Number(d.failed || 0); + result.cleaned_uids.push(...(d.cleaned_uids || [])); + } + const removed = new Set(result.cleaned_uids.map(uid => String(uid))); + state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid))); + try { _libCacheWriteBack(); } catch (_) {} + try { _renderGrid(); } catch (_) {} + _rememberUnsubscribeHandled(candidates); + return result; +} + +function _showUnsubscribeCleanupPrompt(modal, candidates, options = {}) { + if (!modal || !Array.isArray(candidates) || !candidates.length) return; + modal.querySelector('.email-unsub-followup')?.remove(); + const count = candidates.reduce((sum, c) => sum + _unsubscribeCandidateUids(c).length, 0); + const heading = options.heading || `Done. Mark all ${count} of these email${count === 1 ? '' : 's'} as spam?`; + const rows = candidates.map(c => { + const uids = _unsubscribeCandidateUids(c); + return ` ++`; + }).join(''); + const box = document.createElement('div'); + box.className = 'email-unsub-followup'; + box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;flex-direction:column;gap:8px;'; + box.innerHTML = ` ++ + ++ ${uids.length > 1 ? `x${uids.length}` : ''} +++${_esc(heading)}++ + + + + ${options.scanFurther ? '' : ''} ++${rows}`; + const body = modal.querySelector('.modal-body'); + const list = modal.querySelector('.email-unsub-list'); + body?.insertBefore(box, list || null); + box.querySelector('.email-unsub-clean-spam')?.addEventListener('click', (e) => { + _runUnsubscribeCleanup(modal, candidates, 'junk', e.currentTarget); + }); + box.querySelector('.email-unsub-clean-junk-delete')?.addEventListener('click', async (e) => { + const ok = await styledConfirm(`Mark ${count} reviewed email${count === 1 ? '' : 's'} as spam and delete ${count === 1 ? 'it' : 'them'}?`, { + confirmText: 'Spam + delete', + cancelText: 'Cancel', + danger: true, + }); + if (ok) _runUnsubscribeCleanup(modal, candidates, 'junk_delete', e.currentTarget); + }); + box.querySelector('.email-unsub-clean-delete')?.addEventListener('click', async (e) => { + const ok = await styledConfirm(`Delete ${count} reviewed email${count === 1 ? '' : 's'}?`, { + confirmText: 'Delete', + cancelText: 'Cancel', + danger: true, + }); + if (ok) _runUnsubscribeCleanup(modal, candidates, 'delete', e.currentTarget); + }); + box.querySelector('.email-unsub-clean-keep')?.addEventListener('click', () => box.remove()); + box.querySelector('.email-unsub-scan-further')?.addEventListener('click', () => { + const nextAnchor = options.scanAnchor; + modal.remove(); + if (nextAnchor) setTimeout(() => _openUnsubscribeReviewModal(nextAnchor), 0); + }); +} + +function _showUnsubscribeScanFurtherPrompt(modal, anchor, heading) { + if (!modal || !anchor) return; + modal.querySelector('.email-unsub-followup')?.remove(); + const box = document.createElement('div'); + box.className = 'email-unsub-followup'; + box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;'; + box.innerHTML = `${_esc(heading)}`; + const body = modal.querySelector('.modal-body'); + const list = modal.querySelector('.email-unsub-list'); + body?.insertBefore(box, list || null); + box.querySelector('.email-unsub-scan-further')?.addEventListener('click', () => { + modal.remove(); + setTimeout(() => _openUnsubscribeReviewModal(anchor), 0); + }); +} + +function _showUnsubscribeRemainingPrompt(modal, candidates) { + if (!modal || !Array.isArray(candidates) || !candidates.length) return; + modal.querySelector('.email-unsub-followup')?.remove(); + const remaining = new Set(candidates); + modal.querySelectorAll('.email-unsub-card').forEach(card => { + const idx = Number(card.dataset.idx || -1); + card.hidden = !remaining.has(modal._unsubscribeCandidates?.[idx]); + }); + const box = document.createElement('div'); + box.className = 'email-unsub-followup email-unsub-remaining-followup'; + box.style.cssText = 'border:1px solid color-mix(in srgb,var(--accent,var(--red)) 35%,var(--border));border-radius:8px;padding:10px;background:color-mix(in srgb,var(--accent,var(--red)) 8%,transparent);display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;'; + box.innerHTML = ` + ${candidates.length} unsubscribe${candidates.length === 1 ? '' : 's'} still need review + + + + `; + const body = modal.querySelector('.modal-body'); + const list = modal.querySelector('.email-unsub-list'); + body?.insertBefore(box, list || null); + box.querySelector('.email-unsub-clean-remaining-btn')?.addEventListener('click', async (event) => { + const count = candidates.reduce((sum, candidate) => sum + _unsubscribeCandidateUids(candidate).length, 0); + const ok = await styledConfirm( + `Mark ${count} remaining email${count === 1 ? '' : 's'} as spam and delete ${count === 1 ? 'it' : 'them'}?`, + { confirmText: 'Spam + delete', cancelText: 'Cancel', danger: true }, + ); + if (ok) _runUnsubscribeCleanup(modal, candidates, 'junk_delete', event.currentTarget); + }); + box.querySelector('.email-unsub-agent-all-btn')?.addEventListener('click', (event) => { + const button = event.currentTarget; + if (modal._agentUnsubscribeRun) return; + const total = candidates.length; + const restoreBusy = _setUnsubButtonBusy(button, `Completed 0 / ${total}`); + modal._agentUnsubscribeRun = { + total, + candidates: candidates.slice(), + completed: new Set(), + cleanupInFlight: new Set(), + restoreBusy, + }; + if (!_askAgentToUnsubscribeAll(candidates)) { + restoreBusy?.(); + modal._agentUnsubscribeRun = null; + } + }); +} + +export async function _openUnsubscribeReviewModal(anchor, options = {}) { + const existing = document.getElementById('email-unsubscribe-review-modal'); + if (existing) { + existing.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); + existing.querySelector('.email-unsub-status')?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); + return; + } + const modal = document.createElement('div'); + const scanFolderLabel = _unsubscribeFolderLabel(state._libFolder); + modal.className = 'modal'; + modal.id = 'email-unsubscribe-review-modal'; + const settingsPage = anchor?.closest?.('#email-lib-settings-page, .email-settings-page-host'); + const inlineHost = settingsPage?.querySelector?.('.email-settings-cleanup-section'); + const inlineMode = !!inlineHost; + if (inlineMode) { + modal.className = 'email-unsubscribe-inline-panel'; + modal.style.cssText = 'display:none;margin-top:10px;'; + modal.innerHTML = ` ++ + +`; + inlineHost.appendChild(modal); + } else { + modal.style.display = 'block'; + modal.innerHTML = ` + `; + document.body.appendChild(modal); + } + const close = () => modal.remove(); + if (!inlineMode) modal.addEventListener('click', (e) => { if (e.target === modal) close(); }); + const statusEl = inlineMode + ? inlineHost.querySelector('.email-settings-clean-status') + : modal.querySelector('.email-unsub-status'); + const panelStatusEl = modal.querySelector('.email-unsub-panel-status'); + const listEl = modal.querySelector('.email-unsub-list'); + const cachedScan = options.forceRescan ? null : _readUnsubscribeScanCache(); + const usingCachedScan = !!cachedScan; + let finishScanStatus = _setUnsubStatusBusy( + statusEl, + usingCachedScan ? 'Loading last unsubscribe scan…' : `Scanning ${scanFolderLabel} headers…`, + ); + const showFinalStatus = (message, isError = false) => { + const actionsEl = modal.querySelector('.email-unsub-actions'); + if (actionsEl) actionsEl.style.display = 'flex'; + if (inlineMode && panelStatusEl) { + finishScanStatus?.(''); + panelStatusEl.classList.toggle('is-error', isError); + panelStatusEl.textContent = message || ''; + modal.style.display = 'block'; + return; + } + statusEl?.classList.toggle('is-error', isError); + finishScanStatus?.(message); + }; + const finishScanError = (message) => { + showFinalStatus(message || 'Failed to scan email headers', true); + }; + try { + const scanUnsubscribeHeaders = (maxScan) => fetch(emailApiUrl('/api/email/unsubscribe/scan', { + folder: state._libFolder || 'INBOX', + limit: 500, + max_scan: maxScan, + account_id: state._libAccountId || undefined, + }), { credentials: 'same-origin' }); + const readScanResponse = async (response) => { + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + payload?.error || payload?.detail || `Email scan failed (HTTP ${response.status})`, + ); + } + return payload; + }; + // Keep the review request below the app's 45s hard deadline. The old + // whole-folder request made Gmail scans time out before any cards loaded. + let scanLimit = 500; + let res; + let data = cachedScan; + if (!data) { + res = await scanUnsubscribeHeaders(scanLimit); + data = await readScanResponse(res); + } else { + scanLimit = Number(data.scanLimit || scanLimit); + } + if (!data || (!data.success && !usingCachedScan)) { + finishScanError(data?.error || 'Failed to scan email headers'); + return; + } + let candidates = _filterHandledUnsubscribeCandidates( + _dedupeUnsubscribeCandidatesForDisplay(data.candidates || []), + ); + if (!usingCachedScan && !candidates.length && scanLimit > 0 && Number(data.scanned || 0) >= scanLimit) { + const searchMore = await styledConfirm( + `No unsubscribe candidates found in ${scanLimit} recent emails. Search more?`, + { title: 'Search more?', confirmText: 'Search more', cancelText: 'No' }, + ); + if (searchMore) { + scanLimit = 0; + finishScanStatus = _setUnsubStatusBusy(statusEl, `Scanning ${scanLimit} recent ${scanFolderLabel} headers…`); + res = await scanUnsubscribeHeaders(scanLimit); + data = await readScanResponse(res); + if (!data.success) { + finishScanError(data.error || 'Failed to scan email headers'); + return; + } + candidates = _filterHandledUnsubscribeCandidates( + _dedupeUnsubscribeCandidatesForDisplay(data.candidates || []), + ); + } + } + if (!usingCachedScan) _writeUnsubscribeScanCache(data, scanLimit); + const rawTotal = Number(data.raw_total || candidates.length || 0); + const scanPrefix = usingCachedScan ? 'Last scan: ' : ''; + const scanSuffix = usingCachedScan ? ' Rescan for fresh results.' : ''; + const scanScope = scanLimit > 0 ? `${data.scanned || 0} recent emails` : `all ${data.scanned || 0} emails`; + const finalStatus = candidates.length + ? `${scanPrefix}Found ${candidates.length} unsubscribe target${candidates.length === 1 ? '' : 's'} from ${scanScope}${rawTotal > candidates.length ? `, collapsed from ${rawTotal} matching emails` : ''}.${scanSuffix} Review before sending unsubscribe.` + : `${scanPrefix}No unsubscribe candidates found in ${scanScope}.${scanSuffix}`; + showFinalStatus(finalStatus); + modal.querySelector('.email-unsub-actions').style.display = 'flex'; + modal.querySelector('.email-unsub-delete-all-btn').style.display = candidates.length ? 'inline-flex' : 'none'; + modal.querySelector('.email-unsub-auto-safe-btn').style.display = candidates.length ? 'inline-flex' : 'none'; + listEl.style.display = candidates.length ? 'flex' : 'none'; + listEl.innerHTML = candidates.map((c, idx) => { + const method = c.recommended_method || null; + const reasons = (c.reasons || []).map(r => `${_esc(r)}`).join(''); + const urlMethod = (c.methods || []).find(m => m.kind === 'url'); + const duplicateCount = Number(c.duplicate_count || 1); + const duplicateBadge = duplicateCount > 1 ? `x${duplicateCount}` : ''; + const deleteLabel = duplicateCount > 1 ? 'Delete all' : 'Delete'; + const deleteTitle = duplicateCount > 1 + ? 'Delete all unsubscribe emails from this sender' + : 'Delete matching unsubscribe emails from this sender'; + return ` ++`; + }).join(''); + modal._unsubscribeCandidates = candidates; + listEl.querySelectorAll('.email-unsub-card-toggle').forEach(btn => { + btn.addEventListener('click', () => { + const card = btn.closest('.email-unsub-card'); + const details = card?.querySelector('.email-unsub-card-details'); + if (!details) return; + const isOpen = !details.hidden; + details.hidden = isOpen; + card.classList.toggle('is-expanded', !isOpen); + btn.setAttribute('aria-expanded', isOpen ? 'false' : 'true'); + btn.setAttribute('aria-label', isOpen ? 'Show unsubscribe details' : 'Hide unsubscribe details'); + btn.title = isOpen ? 'Show unsubscribe details' : 'Hide unsubscribe details'; + }); + }); + modal.querySelector('.email-unsub-rescan-btn')?.addEventListener('click', () => { + modal.remove(); + setTimeout(() => _openUnsubscribeReviewModal(anchor, { forceRescan: true }), 0); + }); + modal.querySelector('.email-unsub-delete-all-btn')?.addEventListener('click', async (event) => { + const allCandidates = modal._unsubscribeCandidates || []; + const count = allCandidates.reduce((sum, candidate) => sum + _unsubscribeCandidateUids(candidate).length, 0); + if (!count) { + finishScanStatus?.('No unsubscribe emails to delete.'); + return; + } + const ok = await styledConfirm( + `Delete all ${count} reviewed unsubscribe email${count === 1 ? '' : 's'}?`, + { confirmText: 'Delete all', cancelText: 'Cancel', danger: true }, + ); + if (!ok) return; + const restoreBusy = _setUnsubButtonBusy(event.currentTarget, 'Deleting all'); + try { + const result = await _deleteAfterUnsubscribe(allCandidates); + if (Number(result?.failed || 0) > 0 || Number(result?.changed || 0) <= 0) { + throw new Error('Could not move the reviewed emails to Trash'); + } + allCandidates.forEach((candidate, idx) => _markUnsubscribeCardDone(modal, idx, 'Deleted')); + modal.querySelector('.email-unsub-list')?.style.setProperty('display', 'none'); + finishScanStatus?.(`Deleted ${result.changed} reviewed email${result.changed === 1 ? '' : 's'}`); + } catch (error) { + finishScanStatus?.(error?.message || 'Delete all failed'); + } finally { + restoreBusy?.(); + } + }); + modal.querySelector('.email-unsub-auto-safe-btn')?.addEventListener('click', async () => { + const safeCandidates = (modal._unsubscribeCandidates || []) + .map((c, idx) => ({ c, idx })) + .filter(item => item.c && item.c.can_execute); + if (!safeCandidates.length) { + finishScanStatus?.(`No automatic unsubscribe actions found. ${modal._unsubscribeCandidates?.length || 0} link${(modal._unsubscribeCandidates?.length || 0) === 1 ? '' : 's'} still need review.`); + _showUnsubscribeRemainingPrompt(modal, modal._unsubscribeCandidates || []); + return; + } + const ok = await styledConfirm(`Send unsubscribe emails for ${safeCandidates.length} target${safeCandidates.length === 1 ? '' : 's'}?`, { + confirmText: 'Auto Unsubscribe All', + cancelText: 'Cancel', + }); + if (!ok) return; + const btn = modal.querySelector('.email-unsub-auto-safe-btn'); + const restoreBusy = _setUnsubButtonBusy(btn, `Unsubscribing 0/${safeCandidates.length}`); + let sent = 0; + let failed = 0; + const sentCandidates = []; + const failedCandidates = []; + try { + for (const item of safeCandidates) { + const c = item.c; + try { + restoreBusy?.setLabel?.(`Unsubscribing ${sent + failed + 1}/${safeCandidates.length}`); + const r = await fetch(emailApiUrl('/api/email/unsubscribe/execute', { + account_id: state._libAccountId || undefined, + }), { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uid: c.uid, + folder: c.folder || state._libFolder || 'INBOX', + account_id: state._libAccountId || '', + method_index: 0, + move_to_spam: false, + }), + }); + const d = await r.json().catch(() => ({})); + if (!d.success) throw new Error(d.error || 'Unsubscribe failed'); + sent += 1; + sentCandidates.push(c); + const rowBtn = modal.querySelector(`.email-unsub-send-btn[data-idx="${item.idx}"]`); + if (rowBtn) { + rowBtn.disabled = true; + rowBtn.innerHTML = `${_UNSUB_CHECK_ICON}Sent`; + rowBtn.style.display = 'inline-flex'; + rowBtn.style.alignItems = 'center'; + rowBtn.style.gap = '4px'; + } + _markUnsubscribeCardDone(modal, item.idx); + } catch (err) { + failed += 1; + failedCandidates.push(c); + console.error(err); + } + } + } finally { + restoreBusy?.(); + } + let deleteFailed = false; + let cleanupResult = { success: true, changed: 0, failed: 0, cleaned_uids: [] }; + if (sentCandidates.length) { + try { + cleanupResult = await _deleteAfterUnsubscribe(sentCandidates); + deleteFailed = Number(cleanupResult.failed || 0) > 0; + const cleaned = new Set((cleanupResult.cleaned_uids || []).map(uid => String(uid))); + (modal._unsubscribeCandidates || []).forEach((candidate, candidateIdx) => { + if (_unsubscribeCandidateUids(candidate).some(uid => cleaned.has(String(uid)))) { + _markUnsubscribeCardDone(modal, candidateIdx); + } + }); + } catch (err) { + deleteFailed = true; + console.error(err); + } + } + const handled = new Set(); + sentCandidates.forEach(c => _unsubscribeCandidateUids(c).forEach(uid => handled.add(String(uid)))); + (cleanupResult.cleaned_uids || []).forEach(uid => handled.add(String(uid))); + const remainingCandidates = (modal._unsubscribeCandidates || []).filter(c => ( + !_unsubscribeCandidateUids(c).some(uid => handled.has(String(uid))) + )); + finishScanStatus?.( + `Auto unsubscribe finished: ${sent} unsubscribed${failed ? `, ${failed} failed` : ''}${remainingCandidates.length ? `, ${remainingCandidates.length} still need review` : ''}.`, + ); + if (sent && !failed && !deleteFailed) { + showToast?.(`Unsubscribed and deleted ${sent} email${sent === 1 ? '' : 's'}`); + } else if (sent || failed) { + showToast?.(deleteFailed + ? `Unsubscribed ${sent}, but some emails could not be deleted` + : `Sent ${sent}, failed ${failed}`); + } + if (remainingCandidates.length) { + _showUnsubscribeRemainingPrompt(modal, remainingCandidates); + } else { + _showUnsubscribeScanFurtherPrompt( + modal, + anchor, + `Finished this batch: ${sent} unsubscribed. Search older emails?`, + ); + } + }); + listEl.querySelectorAll('.email-unsub-done-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const idx = Number(btn.dataset.idx || 0); + const c = modal._unsubscribeCandidates?.[idx]; + if (!c) return; + const sender = c.from_name || c.from_address || 'this sender'; + const ok = await styledConfirm( + `Delete matching unsubscribe emails from ${sender}?`, + { confirmText: 'Delete', cancelText: 'Cancel', danger: true }, + ); + if (!ok) return; + const restoreBusy = _setUnsubButtonBusy(btn, 'Deleting'); + try { + await _deleteAfterUnsubscribe([c]); + restoreBusy?.(); + _markUnsubscribeCardDone(modal, idx, 'Deleted'); + showToast?.('Matching emails deleted'); + } catch (err) { + restoreBusy?.(); + showToast?.(err?.message || 'Could not delete matching emails'); + } + }); + }); + listEl.querySelectorAll('.email-unsub-ignore-btn').forEach(btn => { + btn.addEventListener('click', () => { + const idx = Number(btn.dataset.idx || 0); + const c = modal._unsubscribeCandidates?.[idx]; + if (!c) return; + _rememberUnsubscribeIgnored(c); + btn.closest('.email-unsub-card')?.remove(); + const remaining = listEl.querySelectorAll('.email-unsub-card:not([hidden])').length; + if (!remaining) { + listEl.style.display = 'none'; + modal.querySelector('.email-unsub-auto-safe-btn').style.display = 'none'; + finishScanStatus?.('No unsubscribe candidates left in this review.'); + } else { + finishScanStatus?.(`${remaining} unsubscribe candidate${remaining === 1 ? '' : 's'} still need review.`); + } + }); + }); + listEl.querySelectorAll('.email-unsub-agent-btn').forEach(btn => { + btn.addEventListener('click', () => { + const idx = Number(btn.dataset.idx || 0); + const c = modal._unsubscribeCandidates?.[idx]; + if (!c) return; + if (modal._agentUnsubscribeRun) return; + const restoreBusy = _setUnsubButtonBusy(btn, 'Starting'); + modal._agentUnsubscribeRun = { + total: 1, + candidates: [c], + completed: new Set(), + cleanupInFlight: new Set(), + restoreBusy, + }; + if (!_askAgentToUnsubscribe(c)) { + restoreBusy?.(); + modal._agentUnsubscribeRun = null; + } + }); + }); + listEl.querySelectorAll('.email-unsub-send-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const idx = Number(btn.dataset.idx || 0); + const c = modal._unsubscribeCandidates?.[idx]; + if (!c) return; + const ok = await styledConfirm(`Send unsubscribe email to ${_unsubscribeMethodLabel(c.recommended_method)}?`, { + confirmText: 'Unsubscribe', + cancelText: 'Cancel', + }); + if (!ok) return; + btn.disabled = true; + btn.textContent = 'Sending…'; + try { + const r = await fetch(emailApiUrl('/api/email/unsubscribe/execute', { + account_id: state._libAccountId || undefined, + }), { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uid: c.uid, + folder: c.folder || state._libFolder || 'INBOX', + account_id: state._libAccountId || '', + method_index: 0, + move_to_spam: false, + }), + }); + const d = await r.json().catch(() => ({})); + if (!d.success) throw new Error(d.error || 'Unsubscribe failed'); + btn.innerHTML = `${_UNSUB_CHECK_ICON}Sent`; + btn.style.display = 'inline-flex'; + btn.style.alignItems = 'center'; + btn.style.gap = '4px'; + _markUnsubscribeCardDone(modal, idx); + try { + await _deleteAfterUnsubscribe([c]); + showToast('Unsubscribed and deleted email'); + } catch (deleteErr) { + console.error(deleteErr); + showToast('Unsubscribed, but email could not be deleted'); + _showUnsubscribeCleanupPrompt(modal, [c], { + heading: 'The unsubscribe request was sent. Mark this email as spam and delete it?', + }); + } + } catch (err) { + console.error(err); + btn.disabled = false; + btn.innerHTML = `${_UNSUB_EMAIL_ICON}${_esc(_unsubscribeMethodLabel(c.recommended_method))}`; + btn.style.display = 'inline-flex'; + btn.style.alignItems = 'center'; + btn.style.gap = '4px'; + showToast(err?.message || 'Unsubscribe failed'); + } + }); + }); + } catch (err) { + console.error(err); + finishScanError('Failed to scan email headers'); + } +} diff --git a/static/sw.js b/static/sw.js index a7660e7b6..fa74bcc1e 100644 --- a/static/sw.js +++ b/static/sw.js @@ -85,6 +85,13 @@ const PRECACHE = [ '/static/js/research/jobs.js?v=20260910researcherrorpersist1', '/static/js/emailInbox.js?v=20260914aireply4', '/static/js/emailLibrary/index.js', + '/static/js/emailLibrary/aiReply.js', + '/static/js/emailLibrary/attachments.js', + '/static/js/emailLibrary/bodyRender.js', + '/static/js/emailLibrary/menus.js', + '/static/js/emailLibrary/reader.js', + '/static/js/emailLibrary/settingsPage.js', + '/static/js/emailLibrary/unsubscribe.js', '/static/js/emailLibrary/utils.js', '/static/js/emailLibrary/signatureFold.js', '/static/js/emailLibrary/state.js', diff --git a/tests/helpers/js_modules.py b/tests/helpers/js_modules.py index 96760dd86..f76016937 100644 --- a/tests/helpers/js_modules.py +++ b/tests/helpers/js_modules.py @@ -16,6 +16,7 @@ not about the package, so the concatenation order only has to be stable. from __future__ import annotations +import re from pathlib import Path _STATIC_JS = Path(__file__).resolve().parents[2] / "static" / "js" @@ -48,3 +49,35 @@ def email_library_source(include_wrapper: bool = False) -> str: return "\n".join( p.read_text(encoding="utf-8") for p in email_library_paths(include_wrapper) ) + + +def js_function_source(name: str, source: str | None = None) -> str: + """One top-level JS function, from its signature to its closing brace. + + Two things this does not do, on purpose. + + It does not slice between a signature and a marker further down ("from + ``_toggleCardPreview`` to the ``Wrap a probable signature`` comment"). That + is what a split breaks: the marker ends up in another module, the slice runs + past the end of the function without failing, and the assertions keep + passing against the wrong text. + + It does not balance braces by walking characters either. The obvious version + of that walker treats the apostrophe in a ``// that's a scroll`` comment as + an open quote and swallows every brace until the next one, which ends the + function early — silently, again. + + Instead it uses the invariant the file actually holds: a top-level + declaration starts at column 0, so its closing brace is the next lone ``}`` + at column 0. + """ + text = email_library_source() if source is None else source + signature = re.compile( + r"^(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(", + re.M, + ) + match = signature.search(text) + assert match, f"no top-level declaration of {name}" + closing = re.compile(r"^\}", re.M).search(text, match.end()) + assert closing, f"unterminated function {name}" + return text[match.start():closing.end()] diff --git a/tests/test_email_library_bulk_actions.py b/tests/test_email_library_bulk_actions.py index 068e4a658..570258a29 100644 --- a/tests/test_email_library_bulk_actions.py +++ b/tests/test_email_library_bulk_actions.py @@ -1,5 +1,5 @@ from pathlib import Path -from tests.helpers.js_modules import email_library_source +from tests.helpers.js_modules import email_library_source, js_function_source _REPO = Path(__file__).resolve().parents[1] @@ -9,20 +9,11 @@ _EMAIL_FIXTURE_HELPER = _REPO / "scripts" / "ody_eval_email_fixture.py" def _bulk_action_source() -> str: - text = email_library_source() - start = text.index("async function _bulkAction(action)") - end = text.index("\n}\n\n// _extractName", start) + 3 - return text[start:end] + return js_function_source("_bulkAction") def _function_source(name: str) -> str: - text = email_library_source() - start = text.index(f"function {name}") - next_function = text.find("\nfunction ", start + 1) - next_async = text.find("\nasync function ", start + 1) - candidates = [idx for idx in (next_function, next_async) if idx != -1] - end = min(candidates) if candidates else len(text) - return text[start:end] + return js_function_source(name) def test_email_bulk_read_unread_calls_provider_write_routes(): diff --git a/tests/test_email_library_module_graph_js.py b/tests/test_email_library_module_graph_js.py index d7f7df82b..aa2c7411b 100644 --- a/tests/test_email_library_module_graph_js.py +++ b/tests/test_email_library_module_graph_js.py @@ -31,6 +31,7 @@ from pathlib import Path from tests.helpers.js_modules import ( EMAIL_LIBRARY_ENTRY, + EMAIL_LIBRARY_PACKAGE, EMAIL_LIBRARY_WRAPPER, email_library_paths, ) @@ -61,22 +62,71 @@ def _listed_exports(path: Path) -> set[str]: return names -def test_wrapper_re_exports_the_entry_module_surface_exactly(): - """The old path must expose the same names as the package entry module. +# The email library's public surface. The entry module exports more than this — +# siblings in the package import helpers back out of it — so the wrapper is what +# declares which names are API and which are package-internal. +# +# Written out rather than derived because three of the five callers reach these +# through a dynamic import and a property read (`mod.openEmailLibrary` in +# chatStream.js and chatRenderer.js, `mod.refreshEmailLibrary` and +# `mod.openEmailLibrary` in document.js, `mod.mountEmailSettings` in +# settings.js), which no import scan can see. Only emailInbox.js imports names +# statically, and `test_wrapper_exposes_every_statically_imported_name` covers +# that half exactly. +_PUBLIC_SURFACE = { + "closeEmailLibrary", + "initEmailLibrary", + "isOpen", + "mountEmailSettings", + "openEmailLibrary", + "openEmailLibrarySettings", + "prewarmEmailLibrary", + "prewarmUnreadEmails", + "refreshEmailLibrary", +} - Not a subset and not a superset: a missing name breaks a caller silently, - and a name the entry module no longer exports is a load-time error. - """ +_STATIC_JS = ROOT / "static" / "js" +_WRAPPER_IMPORT = re.compile( + r"import\s*\{([^}]*)\}\s*from\s*'\./emailLibrary\.js(?:\?[^']*)?'", re.S +) + + +def test_wrapper_declares_the_public_surface(): + assert _listed_exports(EMAIL_LIBRARY_WRAPPER) == _PUBLIC_SURFACE + + +def test_wrapper_re_exports_only_names_the_entry_module_has(): + """A name in the wrapper that the entry module does not export is a + SyntaxError at load time, and it takes the whole email panel with it.""" entry = _declared_exports(EMAIL_LIBRARY_ENTRY) | _listed_exports(EMAIL_LIBRARY_ENTRY) wrapper = _listed_exports(EMAIL_LIBRARY_WRAPPER) assert wrapper, f"{EMAIL_LIBRARY_WRAPPER} re-exports nothing" - assert wrapper == entry, ( - "static/js/emailLibrary.js and static/js/emailLibrary/index.js disagree " - f"on the public surface; only in the wrapper: {sorted(wrapper - entry)}; " - f"only in the entry module: {sorted(entry - wrapper)}" + assert wrapper <= entry, ( + "static/js/emailLibrary.js re-exports names static/js/emailLibrary/" + f"index.js does not export: {sorted(wrapper - entry)}" ) +def test_wrapper_exposes_every_statically_imported_name(): + """Whatever a module outside the package imports by name must be there.""" + wrapper = _listed_exports(EMAIL_LIBRARY_WRAPPER) + checked = 0 + for path in sorted(_STATIC_JS.rglob("*.js")): + if path == EMAIL_LIBRARY_WRAPPER or EMAIL_LIBRARY_PACKAGE in path.parents: + continue + for block in _WRAPPER_IMPORT.findall(path.read_text(encoding="utf-8")): + for raw in block.split(","): + name = raw.strip().split(" as ")[0].strip() + if not name: + continue + checked += 1 + assert name in wrapper, ( + f"{path.relative_to(ROOT)} imports {name} from " + "static/js/emailLibrary.js, which does not export it" + ) + assert checked, "no module imports names from static/js/emailLibrary.js" + + def test_every_package_module_is_precached(): sw = _SW.read_text(encoding="utf-8") for path in email_library_paths(): diff --git a/tests/test_email_library_prewarm.py b/tests/test_email_library_prewarm.py index 81de84c89..2585c9c09 100644 --- a/tests/test_email_library_prewarm.py +++ b/tests/test_email_library_prewarm.py @@ -4,7 +4,7 @@ import shutil import subprocess import pytest -from tests.helpers.js_modules import email_library_source +from tests.helpers.js_modules import email_library_source, js_function_source _REPO = Path(__file__).resolve().parents[1] @@ -14,65 +14,7 @@ def _source() -> str: def _function_source(name: str) -> str: - """Return one top-level JS function using balanced braces.""" - text = _source() - markers = (f"function {name}", f"async function {name}", f"export function {name}", f"export async function {name}") - starts = [text.find(marker) for marker in markers] - starts = [start for start in starts if start >= 0] - assert starts, f"missing function {name}" - start = min(starts) - paren = text.index("(", start) - paren_depth = 0 - quote = None - escaped = False - for index in range(paren, len(text)): - char = text[index] - if quote: - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == quote: - quote = None - continue - if char in ("'", '"', "`"): - quote = char - elif char == "(": - paren_depth += 1 - elif char == ")": - paren_depth -= 1 - if paren_depth == 0: - brace = text.index("{", index) - break - else: - raise AssertionError(f"unterminated signature {name}") - depth = 0 - quote = None - escaped = False - template_depth = 0 - for index in range(brace, len(text)): - char = text[index] - if quote: - if escaped: - escaped = False - elif char == "\\": - escaped = True - elif char == quote and template_depth == 0: - quote = None - elif quote == "`" and char == "$" and index + 1 < len(text) and text[index + 1] == "{": - template_depth += 1 - elif quote == "`" and char == "}" and template_depth: - template_depth -= 1 - continue - if char in ("'", '"', "`"): - quote = char - elif char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return text[start:index + 1] - raise AssertionError(f"unterminated function {name}") + return js_function_source(name, _source()) def _run_scheduler_scenario(scenario: str): diff --git a/tests/test_email_open_dedup_js.py b/tests/test_email_open_dedup_js.py index 9a35450f5..1a624a83c 100644 --- a/tests/test_email_open_dedup_js.py +++ b/tests/test_email_open_dedup_js.py @@ -6,7 +6,7 @@ import subprocess from pathlib import Path import pytest -from tests.helpers.js_modules import email_library_source +from tests.helpers.js_modules import email_library_source, js_function_source _REPO = Path(__file__).resolve().parent.parent @@ -22,7 +22,7 @@ def _extract_between(source: str, signature: str, next_marker: str) -> str: def test_library_unread_preview_has_one_authoritative_request_and_rollback(): source = email_library_source() - function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block") + function = js_function_source("_toggleCardPreview", source) assert function.count("/api/email/read/") == 1 assert "/api/email/mark-read/" not in function @@ -39,7 +39,7 @@ def test_library_unread_preview_has_one_authoritative_request_and_rollback(): @pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") def test_library_authoritative_success_defeats_newer_rollback_in_either_order(): source = email_library_source() - function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block") + function = js_function_source("_toggleCardPreview", source) settlements = _extract_between( function, " const restoreUnreadState = () => {",++++ ++ ++ ++++ score ${_esc(c.score || 0)} + ${duplicateBadge} + + + ++${reasons}++ ${c.can_execute ? `` : ''} + ${urlMethod ? `` : ''} + ${urlMethod ? `${_UNSUB_EXTERNAL_ICON}Link Unsubscribe` : ''} ++