From c5db6ae7fd9c209e8debc665bb1005882a401ff7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?L=C3=A9o?=
Date: Wed, 30 Sep 2026 09:26:34 +0200
Subject: [PATCH] refactor(email): split the email library into seven modules
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`emailLibrary/index.js` goes from 11,375 lines to 6,187. What came out:
- `settingsPage.js` (879) — the Email Settings view, its form and controls,
away/auto-reply including the calendar-event sync, and the display
preferences. The inline-image preference lives here rather than with the
renderer because the settings form owns writing it.
- `unsubscribe.js` (1,224) — the bulk-unsubscribe review flow. One exported
entry point, its own localStorage keys, its own agent-tool-output listeners.
- `reader.js` (676) — opening an email as a docked tab or a floating window,
plus the AI summary panel both of them share.
- `menus.js` (855) — the reader More menu, the card kebab menu, the bulk
Actions menu and `_bulkAction`.
- `bodyRender.js` (871) — plain and threaded body rendering, inline MIME
images, quote folding.
- `attachments.js` (491) — attachment chips and the deferred load for messages
whose attachment list was not in the list response.
- `aiReply.js` (449) — AI-reply entry points, the per-message context draft,
the translate and remind submenus.
The extracted modules import back from `index.js`, so the graph has cycles.
That is safe for hoisted function declarations and unsafe for a value read
during evaluation, so nothing crosses a module boundary except functions:
`API_BASE` is re-declared per module, the way `emailInbox.js` and
`emailShared.js` already do it, and `_autoReplyRefreshSeq` moves into
`state.js` because the settings page and the unread-badge refresh both write
it and an imported binding is read-only. The module-graph test enters the
package at each module in turn, which is the order that would expose a
dead-zone read.
Two test helpers grew while doing this. `js_function_source` replaces three
marker-pair slices ("from this signature down to that comment") whose end
marker had moved into another module — the slice ran past the function and
kept passing against the wrong text. Its first implementation balanced braces
by walking characters and ended `_toggleCardPreview` 18 lines early, because
the apostrophe in `// that's a scroll, not a nav` opened a string that ate the
braces after it. It now keys on 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.
---
specs/frontend.md | 4 +-
static/js/MODULE_SUMMARY.md | 2 +-
static/js/emailLibrary/aiReply.js | 449 ++
static/js/emailLibrary/attachments.js | 491 ++
static/js/emailLibrary/bodyRender.js | 871 +++
static/js/emailLibrary/index.js | 5371 +------------------
static/js/emailLibrary/menus.js | 855 +++
static/js/emailLibrary/reader.js | 676 +++
static/js/emailLibrary/settingsPage.js | 879 +++
static/js/emailLibrary/state.js | 4 +
static/js/emailLibrary/unsubscribe.js | 1224 +++++
static/sw.js | 7 +
tests/helpers/js_modules.py | 33 +
tests/test_email_library_bulk_actions.py | 15 +-
tests/test_email_library_module_graph_js.py | 68 +-
tests/test_email_library_prewarm.py | 62 +-
tests/test_email_open_dedup_js.py | 6 +-
17 files changed, 5646 insertions(+), 5371 deletions(-)
create mode 100644 static/js/emailLibrary/aiReply.js
create mode 100644 static/js/emailLibrary/attachments.js
create mode 100644 static/js/emailLibrary/bodyRender.js
create mode 100644 static/js/emailLibrary/menus.js
create mode 100644 static/js/emailLibrary/reader.js
create mode 100644 static/js/emailLibrary/settingsPage.js
create mode 100644 static/js/emailLibrary/unsubscribe.js
diff --git a/specs/frontend.md b/specs/frontend.md
index cd3eea540..73ad727bb 100644
--- a/specs/frontend.md
+++ b/specs/frontend.md
@@ -97,6 +97,8 @@ Current major frontend areas include:
- image editor integration in `static/js/galleryEditor.js` plus leaves under `static/js/editor/`;
- gallery, email inbox/library, calendar, research panel/jobs/synapse, notes/tasks, assistant, memory/skills, Cookbook/HW Fit, workspace picker, provider device flow, composer ArrowUp recall, theme, modal/window utilities, storage, and accessibility helpers.
+`static/js/emailLibrary/` is the one JS package with module-graph coverage: `tests/test_email_library_module_graph_js.py` pins the wrapper's public surface against the entry module's exports, evaluates every module on its own in a browser so an import cycle cannot hide a temporal-dead-zone read, and requires each module to be in the `sw.js` precache. Assertions about its source go through `tests/helpers/js_modules.py`, which reads the whole package, for the same reason `tests/helpers/stylesheets.py` reads the whole cascade.
+
Coordinator ownership:
- `static/app.js` owns late orchestration, global fetch 401 redirects, sidebar/tool route wiring, and many `window.*` compatibility bridges;
@@ -153,7 +155,7 @@ Missing coverage includes:
## Current Gaps
- `static/style.css` and large coordinators remain high-risk owners: `static/js/document.js`, `static/js/settings.js`, `static/js/chat.js`, and `static/app.js`.
-- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets only.
+- There is no build-time type checking, module graph validation, or script-order validation. Service-worker precache validation exists for stylesheets, and for the `static/js/emailLibrary/` package only.
- Frontend state is mostly module/global/localStorage driven, so cross-session and cross-user behavior needs explicit care.
- `window.*` compatibility bridges remain widespread.
- PWA/static-serving behavior may deserve a separate spec if service worker, manifests, route-specific icons, and cache policy keep growing.
diff --git a/static/js/MODULE_SUMMARY.md b/static/js/MODULE_SUMMARY.md
index c0f88c824..a9b525e0a 100644
--- a/static/js/MODULE_SUMMARY.md
+++ b/static/js/MODULE_SUMMARY.md
@@ -137,7 +137,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
| Module | Responsibility |
|---|---|
| **`gallery.js`** / **`galleryEditor.js`** | Gallery/image library and canvas editor entry points. |
-| **`emailInbox.js`** / **`emailLibrary.js`** | Email inbox reader and library modal. Sub-modules handle signatures, reply recipients, state, and signature folding. |
+| **`emailInbox.js`** / **`emailLibrary/`** | Email inbox reader and library modal. `emailLibrary.js` is a re-export wrapper; `emailLibrary/index.js` owns the panel, list and cards, with `settingsPage.js`, `unsubscribe.js`, `reader.js`, `menus.js`, `bodyRender.js`, `attachments.js` and `aiReply.js` alongside it plus the `utils.js` / `signatureFold.js` / `replyRecipients.js` / `state.js` leaves. |
| **`calendar.js`** / **`calendar/utils.js`** / **`calendar/reminders.js`** | Calendar views, event forms, reminders. |
| **`tasks.js`** | Scheduled task/recurring LLM job UI. |
| **`notes.js`** | Notes and todo panel, reminders, pinboard. |
diff --git a/static/js/emailLibrary/aiReply.js b/static/js/emailLibrary/aiReply.js
new file mode 100644
index 000000000..bbbcb0e86
--- /dev/null
+++ b/static/js/emailLibrary/aiReply.js
@@ -0,0 +1,449 @@
+// static/js/emailLibrary/aiReply.js
+//
+// The AI-reply entry points and the reader submenus that sit next to them:
+// reply-mode choice, the per-message context note (drafted into localStorage so
+// a half-typed instruction survives a reload), the translate submenu, and the
+// remind-me submenu that writes a todo note.
+//
+// The context-draft key is versioned (`…:v2:`) and scoped by account, folder
+// and uid, so a draft cannot leak from one message to another.
+
+import { topPortalZ } from '../toolWindowZOrder.js';
+import { showToast } from '../ui.js?v=20260916largetoolscroll1';
+import { state } from './state.js';
+import { _esc, _extractName } from './utils.js';
+import { _snapEmailModalToLeftSidebar, _translateEmail } from './index.js';
+
+const API_BASE = window.location.origin;
+
+export function _aiReplyIcon(data) {
+ return '';
+}
+
+function _summaryIcon(data) {
+ const fill = data?.cached_summary ? 'var(--accent-primary, var(--red))' : 'currentColor';
+ return ``;
+}
+
+async function _emailTranslateLanguage() {
+ try {
+ const res = await fetch(`${API_BASE}/api/email/config`);
+ const cfg = await res.json();
+ return cfg?.email_translate_language || 'English';
+ } catch (_) {
+ return 'English';
+ }
+}
+
+async function _runAiReplyFromButton(btn, em, data, mode, noteHint = '') {
+ _snapEmailModalToLeftSidebar(btn.closest('.modal'));
+ btn.disabled = true;
+ const orig = btn.innerHTML;
+ let wp = null;
+ try {
+ wp = spinnerModule.createWhirlpool(14);
+ wp.element.style.cssText = 'width:14px;height:14px;display:inline-block;vertical-align:middle;position:relative;top:-2px;';
+ btn.innerHTML = '';
+ btn.appendChild(wp.element);
+ } catch (_) {}
+ try {
+ if (state._onEmailClick) {
+ return await state._onEmailClick({ email: em, emailData: data, mode, noteHint });
+ }
+ return false;
+ } finally {
+ try { wp && wp.stop(); } catch (_) {}
+ btn.disabled = false;
+ btn.innerHTML = orig;
+ }
+}
+
+const _AI_REPLY_CONTEXT_DRAFT_PREFIX = 'odysseus:email-ai-reply-context:v2:';
+let _aiReplyChoiceOutsideClose = null;
+
+function _aiReplyContextDraftKey(em, data) {
+ const accountId = data?.account_id || em?.account_id || state._libAccountId || '';
+ const folder = data?.folder || em?.folder || state._libFolder || 'INBOX';
+ const uid = data?.uid || em?.uid || data?.message_id || em?.message_id || '';
+ if (!uid) return '';
+ return _AI_REPLY_CONTEXT_DRAFT_PREFIX
+ + [accountId, folder, uid].map(value => encodeURIComponent(String(value || ''))).join(':');
+}
+
+function _loadAiReplyContextDraft(key) {
+ if (!key) return '';
+ try { return localStorage.getItem(key) || ''; } catch (_) { return ''; }
+}
+
+function _saveAiReplyContextDraft(key, value) {
+ if (!key) return;
+ try {
+ const text = String(value || '');
+ if (text.trim()) localStorage.setItem(key, text);
+ else localStorage.removeItem(key);
+ } catch (_) {}
+}
+
+function _clearAiReplyContextDraft(key) {
+ if (!key) return;
+ try { localStorage.removeItem(key); } catch (_) {}
+}
+
+function _closeAiReplyChoice() {
+ if (_aiReplyChoiceOutsideClose) {
+ document.removeEventListener('click', _aiReplyChoiceOutsideClose, true);
+ _aiReplyChoiceOutsideClose = null;
+ }
+ document.querySelectorAll('.email-ai-reply-choice').forEach(el => {
+ const input = el.querySelector('[data-note-input]');
+ _saveAiReplyContextDraft(el.dataset.contextDraftKey || '', input?.value || '');
+ el.remove();
+ });
+}
+
+function _showAiReplyChoice(btn, em, data, positionAnchor = btn) {
+ _closeAiReplyChoice();
+ const rect = positionAnchor.getBoundingClientRect();
+ const menu = document.createElement('div');
+ menu.className = 'email-ai-reply-choice';
+ /* Clamp width to viewport minus 16px margin so the menu never spills off
+ the right edge on narrow mobile screens. */
+ const menuMaxW = Math.min(220, window.innerWidth - 16);
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8));
+ /* Vertical placement: prefer below the button, but flip above if
+ there's not enough room (e.g. button near bottom of viewport).
+ Estimated menu height is ~150px (textarea + buttons + padding). */
+ const estHeight = 150;
+ const spaceBelow = window.innerHeight - rect.bottom - 8;
+ const spaceAbove = rect.top - 8;
+ let top;
+ if (spaceBelow >= estHeight || spaceBelow >= spaceAbove) {
+ top = Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8));
+ } else {
+ top = Math.max(8, rect.top - estHeight - 6);
+ }
+ menu.style.cssText = [
+ 'position:fixed',
+ `left:${left}px`,
+ `top:${top}px`,
+ `max-width:${menuMaxW}px`,
+ `max-height:${window.innerHeight - 16}px`,
+ 'overflow:auto',
+ 'box-sizing:border-box',
+ `z-index:${topPortalZ()}`,
+ 'display:flex',
+ 'gap:6px',
+ 'padding:6px',
+ 'background:var(--bg,#111)',
+ 'border:1px solid var(--border,#333)',
+ 'border-radius:7px',
+ 'box-shadow:0 8px 24px rgba(0,0,0,.28)',
+ ].join(';');
+ menu.innerHTML = `
+
+
+
+
+
+
+ `;
+ const noteInput = menu.querySelector('[data-note-input]');
+ const contextDraftKey = _aiReplyContextDraftKey(em, data);
+ menu.dataset.contextDraftKey = contextDraftKey;
+ noteInput.value = _loadAiReplyContextDraft(contextDraftKey);
+ noteInput.addEventListener('input', () => {
+ _saveAiReplyContextDraft(contextDraftKey, noteInput.value || '');
+ });
+ setTimeout(() => noteInput.focus(), 0);
+ menu.addEventListener('click', async (ev) => {
+ const choice = ev.target.closest('[data-mode]');
+ if (!choice) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
+ const noteHint = (noteInput.value || '').trim();
+ _saveAiReplyContextDraft(contextDraftKey, noteInput.value || '');
+ _closeAiReplyChoice();
+ const draftOpened = await _runAiReplyFromButton(btn, em, data, mode, noteHint);
+ if (draftOpened === true) _clearAiReplyContextDraft(contextDraftKey);
+ });
+ // Esc closes the popover; ignore plain clicks inside the menu so the
+ // textarea stays focused.
+ menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
+ document.body.appendChild(menu);
+ // Outside-click closer: only fires when the click target is OUTSIDE
+ // the menu. The original handler closed on any click which made
+ // focusing the textarea immediately dismiss the popover.
+ const outsideClose = (ev) => {
+ if (menu.contains(ev.target)) return;
+ document.removeEventListener('click', outsideClose, true);
+ if (_aiReplyChoiceOutsideClose === outsideClose) _aiReplyChoiceOutsideClose = null;
+ _closeAiReplyChoice();
+ };
+ _aiReplyChoiceOutsideClose = outsideClose;
+ setTimeout(() => {
+ if (_aiReplyChoiceOutsideClose === outsideClose) {
+ document.addEventListener('click', outsideClose, true);
+ }
+ }, 0);
+}
+
+export function _handleAiReplyButton(ev, em, data, positionAnchor = ev.currentTarget) {
+ ev.stopPropagation();
+ const btn = ev.currentTarget;
+ // First click on a cached email surfaces the cached draft. Second
+ // click clears the cache and opens the Fast/Full + context menu so
+ // the user can ask for a fresh draft (with new steering).
+ if (data?.cached_ai_reply && !btn.dataset.shownOnce) {
+ btn.dataset.shownOnce = '1';
+ _runAiReplyFromButton(btn, em, data, 'ai-reply');
+ return;
+ }
+ if (data?.cached_ai_reply) {
+ data.cached_ai_reply = null;
+ btn.dataset.shownOnce = '';
+ }
+ _showAiReplyChoice(btn, em, data, positionAnchor);
+}
+
+export function _hasMultipleRecipients(data) {
+ // Count distinct addresses in To + Cc (minus the current user). Empty
+ // fallback when the user's address isn't yet known — no exclusion.
+ const myAddress = (window._myEmailAddress || '').toLowerCase();
+ const extractEmails = (str) => {
+ if (!str) return [];
+ return str.split(',')
+ .map(s => {
+ const m = s.match(/<([^>]+)>/);
+ return (m ? m[1] : s).trim().toLowerCase();
+ })
+ .filter(e => e && e !== myAddress);
+ };
+ const recipients = new Set([
+ ...extractEmails(data.to),
+ ...extractEmails(data.cc),
+ ]);
+ // Sender counts as one other person too
+ if (data.from_address && data.from_address.toLowerCase() !== myAddress) {
+ recipients.add(data.from_address.toLowerCase());
+ }
+ return recipients.size > 1;
+}
+
+// _esc lives in ./emailLibrary/utils.js
+
+export function _showEmailTranslateSubmenu(reader, parentDropdown) {
+ parentDropdown.innerHTML = '';
+ const header = document.createElement('div');
+ header.className = 'dropdown-item-compact';
+ header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
+ header.innerHTML = 'Translate to';
+ parentDropdown.appendChild(header);
+
+ const customRow = document.createElement('div');
+ customRow.className = 'dropdown-item-compact email-translate-custom-row';
+ customRow.style.cssText = 'display:flex;gap:5px;align-items:center;padding:5px 7px;cursor:default;';
+ customRow.innerHTML = `
+
+
+ `;
+ parentDropdown.appendChild(customRow);
+ const input = customRow.querySelector('.email-translate-custom-input');
+ const go = customRow.querySelector('.email-translate-custom-go');
+ const runCustom = async () => {
+ const language = (input?.value || '').trim();
+ if (!language) {
+ input?.focus();
+ return;
+ }
+ parentDropdown.remove();
+ await _translateEmail(reader, language);
+ };
+ customRow.addEventListener('click', e => e.stopPropagation());
+ go?.addEventListener('click', async e => {
+ e.stopPropagation();
+ await runCustom();
+ });
+ input?.addEventListener('keydown', async e => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ await runCustom();
+ }
+ });
+ _emailTranslateLanguage().then(language => {
+ if (input && !input.value) input.value = language || 'English';
+ }).catch(() => {});
+
+ const languages = ['English', 'Swedish', 'Japanese', 'Spanish', 'French', 'German'];
+ for (const language of languages) {
+ const item = document.createElement('div');
+ item.className = 'dropdown-item-compact';
+ item.innerHTML = `${language}`;
+ item.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ parentDropdown.remove();
+ await _translateEmail(reader, language);
+ });
+ parentDropdown.appendChild(item);
+ }
+}
+
+// ---- Reminder submenu (used by both email menus) ----
+export function _showLibRemindSubmenu(em, parentDropdown) {
+ parentDropdown.innerHTML = '';
+ const header = document.createElement('div');
+ header.className = 'dropdown-item-compact';
+ header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
+ header.innerHTML = 'Remind me';
+ parentDropdown.appendChild(header);
+
+ const now = new Date();
+ const laterToday = new Date(now);
+ const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0);
+ if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3*60*60*1000);
+ else laterToday.setTime(sixPm.getTime());
+ const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate()+1); tomorrow.setHours(8,0,0,0);
+ const daysUntilMon = (8 - now.getDay()) % 7 || 7;
+ const nextWeek = new Date(now); nextWeek.setDate(now.getDate()+daysUntilMon); nextWeek.setHours(8,0,0,0);
+
+ const presets = [
+ { label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: laterToday },
+ { label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: tomorrow },
+ { label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday:'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: nextWeek },
+ ];
+ for (const p of presets) {
+ const item = document.createElement('div');
+ item.className = 'dropdown-item-compact';
+ item.innerHTML = `${p.label}${p.sub}`;
+ item.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ parentDropdown.remove();
+ await _createEmailReplyReminder(em, p.date);
+ });
+ parentDropdown.appendChild(item);
+ }
+ const customItem = document.createElement('div');
+ customItem.className = 'dropdown-item-compact';
+ customItem.innerHTML = 'Pick date and time…';
+ customItem.addEventListener('click', (e) => {
+ e.stopPropagation();
+ parentDropdown.remove();
+ const tmp = document.createElement('input');
+ tmp.type = 'datetime-local';
+ const def = new Date(tomorrow);
+ const pad = n => String(n).padStart(2,'0');
+ tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`;
+ tmp.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:13px;';
+ document.body.appendChild(tmp);
+ tmp.focus();
+ if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} }
+ tmp.addEventListener('change', async () => {
+ if (tmp.value) await _createEmailReplyReminder(em, new Date(tmp.value));
+ tmp.remove();
+ });
+ tmp.addEventListener('blur', () => setTimeout(() => tmp.remove(), 200));
+ });
+ parentDropdown.appendChild(customItem);
+ // "Note" — prompts for free-text and saves it as a note without a
+ // due_date, so no timer/reminder fires.
+ const noteItem = document.createElement('div');
+ noteItem.className = 'dropdown-item-compact';
+ noteItem.innerHTML = 'Note';
+ noteItem.addEventListener('click', (e) => {
+ e.stopPropagation();
+ parentDropdown.remove();
+ _promptEmailNote(em);
+ });
+ parentDropdown.appendChild(noteItem);
+}
+
+function _promptEmailNote(em) {
+ const overlay = document.createElement('div');
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;padding:16px;';
+ const card = document.createElement('div');
+ card.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:280px;max-width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;box-shadow:0 12px 32px rgba(0,0,0,0.4);';
+ const subject = em.subject || '(no subject)';
+ card.innerHTML = `
+
Note about ${_esc(subject)}
+
+
+
+
+
+ `;
+ overlay.appendChild(card);
+ document.body.appendChild(overlay);
+ const ta = card.querySelector('[data-note]');
+ setTimeout(() => ta.focus(), 0);
+ const close = () => overlay.remove();
+ overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
+ card.querySelector('[data-act="cancel"]').addEventListener('click', close);
+ card.querySelector('[data-act="save"]').addEventListener('click', async () => {
+ const text = (ta.value || '').trim();
+ if (!text) { ta.focus(); return; }
+ close();
+ await _createEmailReplyReminder(em, null, text);
+ });
+ ta.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Escape') close();
+ else if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') card.querySelector('[data-act="save"]').click();
+ });
+}
+
+async function _createEmailReplyReminder(em, dueDate, customText = '') {
+ const pad = n => String(n).padStart(2,'0');
+ const iso = dueDate
+ ? `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}`
+ : null;
+ const fullFrom = em.from || em.sender || '';
+ // Extract just the first name from "First Last " or fall back to email local part
+ let from = 'someone';
+ if (fullFrom) {
+ const fullName = _extractName(fullFrom);
+ if (fullName) {
+ // Strip quotes, take the first whitespace-separated word, capitalize
+ const first = fullName.replace(/^["']|["']$/g, '').trim().split(/[\s,]+/)[0] || '';
+ if (first) from = first.charAt(0).toUpperCase() + first.slice(1);
+ }
+ }
+ const subject = em.subject || '(no subject)';
+ const folder = state._libFolder || 'INBOX';
+ const deepLink = `${window.location.origin}/#email=${encodeURIComponent(folder)}:${em.uid}`;
+ const itemText = customText || `Reply to ${from}: ${subject}`;
+ const payload = {
+ title: `Reply: ${subject}`,
+ note_type: 'todo',
+ items: [
+ { text: itemText, checked: false },
+ ],
+ content: `Open email: ${deepLink}`,
+ label: 'email reminder',
+ source: 'email',
+ };
+ if (iso) payload.due_date = iso;
+ try {
+ const res = await fetch(`${API_BASE}/api/notes`, {
+ method: 'POST', credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) throw new Error('Failed');
+ const { showToast } = await import('../ui.js?v=20260916largetoolscroll1');
+ if (dueDate) {
+ const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+ showToast(`Todo reminder set for ${fmt}`);
+ } else {
+ showToast('Reply note saved');
+ }
+ if ('Notification' in window && Notification.permission === 'default') {
+ try { Notification.requestPermission(); } catch {}
+ }
+ } catch (e) {
+ const { showError } = await import('../ui.js?v=20260916largetoolscroll1');
+ showError('Failed to create reminder');
+ }
+}
diff --git a/static/js/emailLibrary/attachments.js b/static/js/emailLibrary/attachments.js
new file mode 100644
index 000000000..deab2981d
--- /dev/null
+++ b/static/js/emailLibrary/attachments.js
@@ -0,0 +1,491 @@
+// static/js/emailLibrary/attachments.js
+//
+// Attachment chips in a reader: building the markup, wiring open/download/
+// "open in editor" per type, and the deferred load for messages whose
+// attachment list was not in the list response.
+//
+// The deferred path exists because `/api/email/list` does not carry attachment
+// metadata: a card can know it *has* attachments without knowing what they
+// are, so the chips are rendered late and the card icon repaired afterwards.
+
+import * as Modals from '../modalManager.js';
+import { state } from './state.js';
+import { _esc } from './utils.js';
+import { _acct, _openCalendarEventFromEmail, _prepareEmailWindowForDocument } from './index.js';
+import { _openEmailAsTab, _openEmailWindow } from './reader.js';
+
+const API_BASE = window.location.origin;
+
+// Wire click handlers for attachment chips + "open in editor" sub-buttons
+// inside a reader. Safe to call multiple times — uses dataset.wired flag to
+// skip nodes that already have listeners.
+function _wireAttachmentHandlers(reader, folder) {
+ const useFolder = folder || state._libFolder;
+ // Detect mobile here so the attachment-chip handler doesn't blow up with
+ // a ReferenceError when this fn is called from contexts that don't have
+ // _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow).
+ const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
+ reader.querySelectorAll('.email-attachments-download-all').forEach(btn => {
+ if (btn.dataset.wired === '1') return;
+ btn.dataset.wired = '1';
+ btn.addEventListener('click', async (ev) => {
+ ev.stopPropagation();
+ ev.preventDefault();
+ if (btn.dataset.downloading === '1') return;
+ const uid = btn.dataset.attUid;
+ const sourceFolder = btn.dataset.attFolder || useFolder;
+ const count = Number(btn.dataset.attCount || 0);
+ if (!uid) return;
+ const originalHtml = btn.innerHTML;
+ const originalTitle = btn.title;
+ btn.dataset.downloading = '1';
+ btn.classList.add('is-loading');
+ try {
+ const sp = window.spinnerModule || (await import('../spinner.js')).default;
+ const wp = sp.createWhirlpool(12);
+ wp.element.style.margin = '0';
+ btn.textContent = '';
+ btn.appendChild(wp.element);
+ const label = document.createElement('span');
+ label.textContent = 'All';
+ btn.appendChild(label);
+ } catch (_) {
+ btn.textContent = 'All...';
+ }
+ try {
+ const url = `${API_BASE}/api/email/attachments-download/${encodeURIComponent(uid)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
+ const res = await fetch(url, { credentials: 'same-origin' });
+ if (!res.ok) {
+ const msg = await res.text().catch(() => '');
+ console.error('attachments zip download failed', res.status, msg);
+ location.href = url;
+ return;
+ }
+ const blob = await res.blob();
+ const blobUrl = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = blobUrl;
+ a.download = `email-${uid}-attachments.zip`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
+ try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {}
+ } catch (e) {
+ console.error('attachments zip download error', e);
+ try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError('Could not download attachments'); } catch (_) {}
+ } finally {
+ delete btn.dataset.downloading;
+ btn.classList.remove('is-loading');
+ btn.title = originalTitle;
+ btn.innerHTML = originalHtml;
+ }
+ });
+ });
+ reader.querySelectorAll('.email-attachment-calendar-open').forEach(openBtn => {
+ if (openBtn.dataset.wired === '1') return;
+ openBtn.dataset.wired = '1';
+ openBtn.addEventListener('click', async (ev) => {
+ ev.stopPropagation();
+ ev.preventDefault();
+ if (openBtn.dataset.opening === '1') return;
+ const uid = openBtn.dataset.openUid;
+ const index = openBtn.dataset.openIndex;
+ const name = openBtn.dataset.openName || `calendar-${index}`;
+ const sourceFolder = openBtn.dataset.openFolder || useFolder;
+ if (!uid || index == null) return;
+ openBtn.dataset.opening = '1';
+ openBtn.classList.add('is-loading');
+ const originalHtml = openBtn.innerHTML;
+ try {
+ const wp = spinnerModule.createWhirlpool(12);
+ wp.element.style.margin = '0';
+ openBtn.textContent = '';
+ openBtn.appendChild(wp.element);
+ const folderQs = encodeURIComponent(sourceFolder);
+ const attachmentUrl = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`;
+ const attachmentRes = await fetch(attachmentUrl, { credentials: 'same-origin' });
+ if (!attachmentRes.ok) throw new Error(`HTTP ${attachmentRes.status}`);
+ const blob = await attachmentRes.blob();
+ const fd = new FormData();
+ fd.append('file', blob, name);
+ const importRes = await fetch(`${API_BASE}/api/calendar/import`, {
+ method: 'POST', body: fd, credentials: 'same-origin',
+ });
+ const result = await importRes.json().catch(() => ({}));
+ if (!importRes.ok || !result.ok) {
+ throw new Error(result.detail || result.error || `HTTP ${importRes.status}`);
+ }
+ const existingEventUid = Array.isArray(result.event_uids) ? String(result.event_uids[0] || '').trim() : '';
+ if (existingEventUid && Number(result.imported || 0) === 0 && Number(result.skipped || 0) > 0) {
+ _openCalendarEventFromEmail(existingEventUid);
+ }
+ try { uiModule.showToast && uiModule.showToast(`${result.imported || 0} event${result.imported === 1 ? '' : 's'} added to ${result.calendar || 'calendar'}`); } catch (_) {}
+ window.dispatchEvent(new CustomEvent('calendar-refresh'));
+ } catch (e) {
+ console.error('calendar attachment import failed', e);
+ try {
+ const { showError } = await import('../ui.js?v=20260916largetoolscroll1');
+ showError(`Couldn't add ${name} to calendar: ${e?.message || 'Import failed'}`);
+ } catch (_) {}
+ } finally {
+ delete openBtn.dataset.opening;
+ openBtn.classList.remove('is-loading');
+ openBtn.innerHTML = originalHtml;
+ }
+ });
+ });
+ reader.querySelectorAll('.email-attachment-open').forEach(openBtn => {
+ if (openBtn.dataset.wired === '1') return;
+ openBtn.dataset.wired = '1';
+ openBtn.addEventListener('click', async (ev) => {
+ ev.stopPropagation();
+ ev.preventDefault();
+ if (openBtn.dataset.opening === '1') return;
+ const uid = openBtn.dataset.openUid;
+ const index = openBtn.dataset.openIndex;
+ const name = openBtn.dataset.openName || `attachment-${index}`;
+ const sourceFolder = openBtn.dataset.openFolder || useFolder;
+ if (!uid || index == null) return;
+ openBtn.dataset.opening = '1';
+ openBtn.classList.add('is-loading');
+ const origHtml = openBtn.innerHTML;
+ const wp = spinnerModule.createWhirlpool(12);
+ wp.element.style.margin = '0';
+ openBtn.textContent = '';
+ openBtn.appendChild(wp.element);
+ try {
+ const folderQs = encodeURIComponent(sourceFolder);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 55000);
+ let res;
+ try {
+ res = await fetch(
+ `${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`,
+ { method: 'POST', credentials: 'same-origin', signal: controller.signal }
+ );
+ } finally {
+ clearTimeout(timeout);
+ }
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok || !json.doc_id) {
+ const msg = (json && json.error) || `HTTP ${res.status}`;
+ try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError(`Couldn't open ${name}: ${msg}`); } catch (_) { alert(`Couldn't open ${name}: ${msg}`); }
+ return;
+ }
+ try {
+ // Tab the email modal down only when the viewport cannot fit both
+ // Email and the document pane. Desktop keeps a side-by-side layout
+ // when there is room; mobile still gives the document the screen.
+ const ownerModal = openBtn.closest('.modal');
+ if (ownerModal && ownerModal.id && _prepareEmailWindowForDocument(ownerModal)) {
+ try {
+ const ok = Modals.minimize(ownerModal.id);
+ if (!ok) ownerModal.classList.add('hidden');
+ } catch (_) {
+ ownerModal.classList.add('hidden');
+ }
+ }
+ const docMod = await import('../document.js?v=20260916docctx2');
+ const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
+ if (typeof load === 'function') {
+ await load(json.doc_id);
+ } else {
+ location.href = `/?doc=${encodeURIComponent(json.doc_id)}`;
+ }
+ } catch (e) {
+ console.error('Open document failed:', e);
+ try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError('Document opened but panel could not mount'); } catch (_) {}
+ }
+ } catch (e) {
+ console.error('attachment-as-doc error', e);
+ const msg = e && e.name === 'AbortError'
+ ? `Opening ${name} timed out. Try downloading it instead.`
+ : `Couldn't open ${name}`;
+ try { const { showError } = await import('../ui.js?v=20260916largetoolscroll1'); showError(msg); } catch (_) {}
+ } finally {
+ delete openBtn.dataset.opening;
+ openBtn.classList.remove('is-loading');
+ openBtn.innerHTML = origHtml;
+ }
+ });
+ });
+
+ reader.querySelectorAll('.email-attachment-download').forEach(downloadBtn => {
+ if (downloadBtn.dataset.wired === '1') return;
+ downloadBtn.dataset.wired = '1';
+ downloadBtn.addEventListener('click', (ev) => {
+ ev.stopPropagation();
+ ev.preventDefault();
+ const chip = downloadBtn.closest('.email-attachment-chip');
+ if (!chip) return;
+ // Reuse the established attachment download handler below. Marking the
+ // chip expanded bypasses its old first-click reveal behavior.
+ chip.classList.add('is-expanded');
+ chip.dataset.downloadTrigger = 'button';
+ chip.click();
+ });
+ downloadBtn.addEventListener('keydown', (ev) => {
+ if (ev.key !== 'Enter' && ev.key !== ' ') return;
+ ev.preventDefault();
+ downloadBtn.click();
+ });
+ });
+
+ reader.querySelectorAll('.email-attachment-chip').forEach(chip => {
+ if (chip.dataset.wired === '1') return;
+ chip.dataset.wired = '1';
+ chip.addEventListener('click', async (ev) => {
+ if (ev.target.closest('.email-attachment-open, .email-attachment-calendar-open, .email-attachment-download')) return;
+ ev.stopPropagation();
+ ev.preventDefault();
+ const uid = chip.dataset.attUid;
+ const index = chip.dataset.attIndex;
+ const name = chip.dataset.attName || `attachment-${index}`;
+ const sourceFolder = chip.dataset.attFolder || useFolder;
+ if (!uid || index == null) return;
+ if (!chip.classList.contains('is-expanded')) {
+ reader.querySelectorAll('.email-attachment-chip.is-expanded').forEach(other => {
+ if (other !== chip) other.classList.remove('is-expanded');
+ });
+ chip.classList.add('is-expanded');
+ return;
+ }
+ const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
+ if (_isMobileUA) {
+ delete chip.dataset.downloadTrigger;
+ window.open(url, '_blank');
+ return;
+ }
+ // Swap the paperclip icon for a whirlpool spinner while the
+ // download is in flight, so large attachments give a clear cue
+ // they're loading. Restore on completion.
+ const buttonTriggered = chip.dataset.downloadTrigger === 'button';
+ const iconSvg = buttonTriggered
+ ? chip.querySelector('.email-attachment-download > svg')
+ : chip.querySelector(':scope > svg');
+ const origIconHtml = iconSvg ? iconSvg.outerHTML : '';
+ let _wp = null;
+ let _spinnerHost = null;
+ try {
+ const sp = window.spinnerModule || (await import('../spinner.js')).default;
+ _wp = sp.createWhirlpool(12);
+ _spinnerHost = document.createElement('span');
+ _spinnerHost.className = 'email-attachment-spinner';
+ _spinnerHost.style.cssText = 'display:inline-flex;width:12px;height:12px;align-items:center;justify-content:center;flex-shrink:0;position:relative;top:-2px;';
+ _spinnerHost.appendChild(_wp.element);
+ if (iconSvg) iconSvg.replaceWith(_spinnerHost);
+ } catch (_) {}
+ const origOpacity = chip.style.opacity;
+ chip.style.opacity = '0.85';
+ try {
+ const res = await fetch(url, { credentials: 'same-origin' });
+ if (!res.ok) {
+ console.error('attachment download failed', res.status, await res.text().catch(() => ''));
+ location.href = url;
+ return;
+ }
+ const blob = await res.blob();
+ const blobUrl = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = blobUrl;
+ a.download = name;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
+ } catch (e) {
+ console.error('attachment download error', e);
+ location.href = url;
+ } finally {
+ delete chip.dataset.downloadTrigger;
+ chip.style.opacity = origOpacity;
+ if (_spinnerHost && _spinnerHost.parentNode && origIconHtml) {
+ const tmp = document.createElement('div');
+ tmp.innerHTML = origIconHtml;
+ const restored = tmp.firstChild;
+ if (restored) _spinnerHost.replaceWith(restored);
+ }
+ if (_wp) { try { _wp.destroy(); } catch (_) {} }
+ }
+ });
+ });
+}
+
+// Heuristic: skip "attachments" that are clearly inline images used by
+// signatures / quoted-reply headers (small image files, Outlook-style
+// image001.png placeholders, logo*.png, etc.). They aren't real user-
+// shared attachments and adding them to the chips makes every email look
+// like it has content the user needs to act on.
+function _isLikelySignatureImage(a) {
+ if (!a || !a.filename) return false;
+ const name = String(a.filename).toLowerCase();
+ const isImage = /\.(png|jpe?g|gif|bmp|svg|webp)$/i.test(name);
+ if (!isImage) return false;
+ const size = Number(a.size) || 0;
+ // Outlook / Gmail inline image placeholders always look like this.
+ if (/^image\d{3,}\.(png|jpe?g|gif)$/i.test(name)) return true;
+ if (/^(signature|logo|sig|footer|banner)[-_\d]*\.(png|jpe?g|gif|svg)$/i.test(name)) return true;
+ // Most signature logos / inline thumbnails are < 30 KB. Real user-
+ // shared images (screenshots, photos) are typically 50 KB+.
+ if (size > 0 && size < 30 * 1024) return true;
+ return false;
+}
+
+// Build the attachments header+chips HTML for an email read response. Pulled
+// out so both the initial-open and the swap-reader paths can render it.
+export function _buildAttsHtmlFor(uid, data) {
+ if (!data) return '';
+ const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown|eml)$/i;
+ const _CALENDAR_RE = /\.(calendar|ics|ical)$/i;
+ const currentAttachments = Array.isArray(data.attachments) ? data.attachments : [];
+ const relatedAttachments = Array.isArray(data.related_attachments) ? data.related_attachments : [];
+ if (!currentAttachments.length && !relatedAttachments.length) return '';
+ const visible = currentAttachments.filter(a => !_isLikelySignatureImage(a));
+ const hidden = currentAttachments.filter(a => _isLikelySignatureImage(a));
+ const related = relatedAttachments.filter(a => !_isLikelySignatureImage(a));
+ if (!visible.length && !related.length && state._libViewInlineImages === false) return '';
+ const renderChip = (a, extraClass = '') => {
+ const calendarAttachment = _CALENDAR_RE.test(a.filename || '');
+ const openable = _OPENABLE_RE.test(a.filename || '');
+ const chipUid = a.source_uid || a.uid || uid;
+ const chipFolder = a.source_folder || data.folder || state._libFolder || 'INBOX';
+ const openBtn = calendarAttachment
+ ? ``
+ : openable
+ ? ``
+ : '';
+ const downloadBtn = ``;
+ return ``;
+ };
+ const chips = visible.map(a => renderChip(a)).join('');
+ const hiddenChips = hidden.map(a => renderChip(a, ' email-attachment-chip-muted')).join('');
+ const relatedChips = related.map(a => renderChip(a, ' email-attachment-chip-related')).join('');
+ const visibleSection = visible.length
+ ? '
`;
+}
+
+/**
+ * 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 || '')
+ + `
${t.html}
`
+ + '';
+
+ 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('= 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)
+ + `
${innerHtml}
`
+ + ''
+ );
+ 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(
+ ``
+ + _foldSummary('Earlier reply', _QUOTE_ICON, meta || '')
+ + `
${bodyHtml}
`
+ + ''
+ );
+ // 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 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 || '')
+ + `
${t.html}
`
+ + '';
+
+ 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 += ' ' + 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(
+ `( |
`;
-}
-
-/**
- * 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 || '')
- + `
${t.html}
`
- + '';
-
- 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('= 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)
- + `
${innerHtml}
`
- + ''
- );
- 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(
- ``
- + _foldSummary('Earlier reply', _QUOTE_ICON, meta || '')
- + `
${bodyHtml}
`
- + ''
- );
- // 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 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 || '')
- + `
${t.html}
`
- + '';
-
- 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 += ' ' + 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
- ? '