Merge pull request #33 from o3LL/refactor/email-library-package

refactor(email): decompose emailLibrary.js into a package behind a re-export wrapper
This commit is contained in:
Alexandre Teixeira
2026-10-01 02:23:56 +01:00
committed by GitHub
31 changed files with 12165 additions and 11520 deletions
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -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. |
+24 -11373
View File
File diff suppressed because it is too large Load Diff
+450
View File
@@ -0,0 +1,450 @@
// 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 spinnerModule from '../spinner.js';
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 '<svg class="email-ai-reply-icon" width="14" height="14" viewBox="0 0 24 24" fill="var(--accent-primary, var(--red))" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>';
}
function _summaryIcon(data) {
const fill = data?.cached_summary ? 'var(--accent-primary, var(--red))' : 'currentColor';
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="${fill}"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>`;
}
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 = `
<div class="email-ai-reply-row" style="display:flex;flex-direction:column;gap:6px;min-width:180px;">
<textarea data-note-input rows="2" placeholder="Context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
<div style="display:flex;align-items:center;gap:4px;">
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Draft reply" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
Submit
</button>
</div>
</div>
`;
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 = '<span>Translate to</span>';
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 = `
<input type="text" class="email-translate-custom-input" placeholder="Write language..." style="min-width:0;width:136px;height:26px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--fg);font:inherit;font-size:11px;padding:0 7px;">
<button type="button" class="email-translate-custom-go" style="height:26px;padding:0 8px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb, var(--fg) 5%, transparent);color:inherit;font:inherit;font-size:11px;cursor:pointer;">Go</button>
`;
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 = `<span>${language}</span>`;
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 = '<span>Remind me</span>';
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 = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
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 = '<span>Pick date and time…</span>';
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 = '<span>Note</span>';
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 = `
<div style="font-size:11px;opacity:0.6;">Note about ${_esc(subject)}</div>
<textarea data-note placeholder="Write your note…" rows="4" style="resize:vertical;min-height:80px;font-family:inherit;font-size:12px;padding:7px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-elev,#1a1a1a);color:var(--fg);box-sizing:border-box;width:100%;"></textarea>
<div class="email-note-actions" style="display:flex;gap:6px;justify-content:flex-end;position:relative;top:4px;">
<button class="memory-toolbar-btn" data-act="cancel"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button>
<button class="memory-toolbar-btn active" data-act="save"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><path d="M4 4h16v16H4z"/><path d="M8 8h8M8 12h8M8 16h5"/></svg>Save</button>
</div>
`;
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 <email@x>" 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');
}
}
+492
View File
@@ -0,0 +1,492 @@
// 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 spinnerModule from '../spinner.js';
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
? `<span class="email-attachment-calendar-open" role="button" tabindex="0" aria-label="Add to calendar" title="Add to calendar" data-open-uid="${_esc(chipUid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}" data-open-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="m8 15 2 2 5-5"/></svg></span>`
: openable
? `<span class="email-attachment-open" role="button" tabindex="0" aria-label="Open in document editor" title="Open in document editor" data-open-uid="${_esc(chipUid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}" data-open-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg></span>`
: '';
const downloadBtn = `<span class="email-attachment-download" role="button" tabindex="0" aria-label="Download attachment" title="Download attachment"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>`;
return `<button type="button" class="email-attachment-chip${extraClass}" data-att-uid="${_esc(chipUid)}" data-att-index="${a.index}" data-att-name="${_esc(a.filename)}" data-att-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>${_esc(a.filename)}</span><span class="att-size">${Math.round((a.size||0)/1024)} KB</span>${openBtn}${downloadBtn}</button>`;
};
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
? '<div class="email-reader-atts">' + chips + '</div>'
: '';
const relatedSection = related.length
? '<div class="email-reader-atts-hidden-note">From earlier in this thread</div><div class="email-reader-atts email-reader-atts-related">' + relatedChips + '</div>'
: '';
const hiddenSection = hidden.length && state._libViewInlineImages !== false
? '<div class="email-reader-atts-hidden-note">Filtered inline images / signature files</div><div class="email-reader-atts email-reader-atts-hidden">' + hiddenChips + '</div>'
: '';
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
? `<button type="button" class="email-attachments-download-all" title="Download all attachments" data-att-uid="${_esc(uid)}" data-att-folder="${_esc(data.folder || state._libFolder || 'INBOX')}" data-att-count="${visible.length}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg><span>All</span></button>`
: '';
return (
`<div class="email-reader-atts-wrap${startCollapsed ? ' collapsed' : ''}">`
+ '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
+ `<span>${label}</span>`
+ downloadAllBtn
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</div>'
+ visibleSection
+ relatedSection
+ hiddenSection
+ '</div>'
);
}
async function _ensureEmailAttachmentData(uid, folder, data, knownHasAttachments = false) {
if (!data) return data;
const current = Array.isArray(data.attachments) ? data.attachments : [];
const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
const shouldFetch = data.attachments_deferred || (knownHasAttachments && !current.length && !related.length);
if (!shouldFetch) return data;
try {
const metaRes = await fetch(`${API_BASE}/api/email/attachments/${encodeURIComponent(uid)}?folder=${encodeURIComponent(folder || 'INBOX')}${_acct()}`);
const meta = await metaRes.json().catch(() => ({}));
if (metaRes.ok && Array.isArray(meta.attachments)) {
return {
...data,
attachments: meta.attachments,
related_attachments: related,
attachments_deferred: false,
};
}
} catch (_) {}
// Do not full-fetch the message during ordinary open. That can download and
// parse large MIME bodies immediately after the reader renders, keeping IMAP
// busy and making the UI feel stuck. Full attachment lookup is still available
// from explicit attachment/download/forward actions.
return data;
}
export function _wireEmailAttachmentWrap(reader, folder) {
if (!reader) return;
const attsWrap = reader.querySelector('.email-reader-atts-wrap');
if (attsWrap && !attsWrap.dataset.wired) {
attsWrap.dataset.wired = '1';
const attsToggle = attsWrap.querySelector('.email-reader-atts-header');
if (attsToggle) {
attsToggle.addEventListener('click', (ev) => {
ev.stopPropagation();
attsWrap.classList.toggle('collapsed');
});
attsToggle.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
attsWrap.classList.toggle('collapsed');
}
});
}
}
try { _wireAttachmentHandlers(reader, folder); } catch {}
}
export function _loadDeferredAttachmentsIntoReader(reader, uid, folder, data, knownHasAttachments = false) {
if (!reader || !uid || !data) return;
const current = Array.isArray(data.attachments) ? data.attachments : [];
const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
if (!data.attachments_deferred && !(knownHasAttachments && !current.length && !related.length)) return;
const body = reader.querySelector('.email-reader-body');
if (!body) return;
const loading = document.createElement('div');
loading.className = 'email-reader-atts-wrap email-reader-atts-loading';
loading.style.cssText = 'min-height:34px;display:flex;flex-direction:row;align-items:center;gap:7px;padding:6px 14px;color:var(--fg);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;opacity:.7;';
const loadingSpinner = spinnerModule.createWhirlpool(12);
loadingSpinner.element.style.margin = '0';
loading.appendChild(loadingSpinner.element);
loading.appendChild(document.createTextNode('Loading attachments…'));
const existingWrap = reader.querySelector('.email-reader-atts-wrap');
if (existingWrap) existingWrap.replaceWith(loading);
else body.insertAdjacentElement('beforebegin', loading);
_ensureEmailAttachmentData(uid, folder, data, knownHasAttachments).then(fullData => {
if (!reader.isConnected || !fullData) return;
loading.remove();
try { loadingSpinner.destroy(); } catch (_) {}
const visibleCurrent = (fullData.attachments || []).filter(a => !_isLikelySignatureImage(a));
const matchingEmail = (state._libEmails || []).find(em => (
String(em.uid) === String(uid)
&& String(em.account_id || state._libAccountId || '') === String(reader.closest('[data-email-account]')?.dataset.emailAccount || state._libAccountId || '')
));
if (matchingEmail) matchingEmail.has_attachments = visibleCurrent.length > 0;
if (!visibleCurrent.length) {
reader.closest('.doclib-card')?.querySelector('.email-card-attachment')?.remove();
}
const attsHtml = _buildAttsHtmlFor(uid, fullData);
if (!attsHtml) return;
const oldWrap = reader.querySelector('.email-reader-atts-wrap');
if (oldWrap) {
const tmp = document.createElement('div');
tmp.innerHTML = attsHtml;
oldWrap.replaceWith(tmp.firstElementChild);
} else {
body.insertAdjacentHTML('beforebegin', attsHtml);
}
_wireEmailAttachmentWrap(reader, folder);
}).catch(() => {
loading.remove();
try { loadingSpinner.destroy(); } catch (_) {}
});
}
+871
View File
@@ -0,0 +1,871 @@
// static/js/emailLibrary/bodyRender.js
//
// Turning a fetched message into reader HTML: the plain and threaded body
// paths, inline MIME image handling, and the quote/thread folding that sits on
// top of the detectors in ./signatureFold.js.
//
// Everything here writes into a reader element via innerHTML, so every branch
// that touches server-supplied HTML goes through `_sanitizeHtml` from
// ./utils.js first. `tests/test_security_regressions.py` counts those call
// sites against the raw per-turn HTML reads, package-wide — so do not name that
// property in a comment, or the count stops matching.
import {
_QUOTE_ICON,
_SIG_ICON,
_extractQuoteMeta,
_extractTurnMetaFromBlockquote,
_foldSignature,
_foldSummary,
_harvestAttribution,
_isBloatedSig,
_looksLikeSignature,
} from './signatureFold.js';
import { state } from './state.js';
import {
_SIG_BLOAT_MIN_CHARS,
_TALON_ORIG_RE,
_TALON_WROTE,
_esc,
_escLinkify,
_formatBubbleDate,
_formatRecipients,
_initials,
_parseTurnMeta,
_sanitizeHtml,
_senderColor,
} from './utils.js';
import { _disableInlineImages } from './settingsPage.js';
const API_BASE = window.location.origin;
/**
* Wrap a probable signature block in a collapsed <details> so it stops
* eating the whole reader. We try, in priority order:
* 1. Mail-client signature wrappers — Gmail's `gmail_signature` div is
* explicit, no guessing required. Same for Apple Mail's data-smartmail.
* 2. The standard "-- " RFC 3676 sig delimiter.
* 3. A common closing phrase ("Best regards", "Cheers", etc.) on its own
* line — fuzzier, but catches sigs without the dash marker.
* 4. "Sent from my iPhone/Android" / "Get Outlook for ..." mobile-client
* boilerplate.
* Anything matched gets wrapped from the marker through end-of-body.
*/
/**
* Render the email body with sig/quote folds. If the backend has cached
* LLM-detected boundary offsets (data.boundaries), use those for an exact
* fold based on plain-text positions. Otherwise fall back to the regex
* detectors. The plain-body branch is always preferred when boundaries
* exist because the offsets are computed against plain text.
*/
// Global escape hatch — when the server's thread parser misfires (it
// occasionally splits a single reply into two bogus "turns" by treating a
// signature/disclaimer as its own message), the user can flip this off to
// fall back to plain rendering. Survives reloads.
const _BUBBLES_DISABLED_KEY = 'odysseus.email.bubblesDisabled';
// Threaded chat-bubble email view is DISABLED for now — too buggy to
// ship. Force plain-text rendering everywhere by always returning true.
// Re-enable by restoring the localStorage-backed body + the toggle
// menu item in the reader's More menu.
function _bubblesDisabled() {
return true;
}
function _setBubblesDisabled(v) {
try { localStorage.setItem(_BUBBLES_DISABLED_KEY, v ? '1' : '0'); } catch {}
}
export function _renderEmailBody(data) {
const plain = (typeof data?.body === 'string' && data.body.length) ? data.body : '';
const folder = String(data?.folder || '').toLowerCase();
const isSentFolder = folder.includes('sent');
const fromAddr = String(data?.from_address || '').toLowerCase().trim();
const isMine = !!fromAddr && _meEmailAddrs().has(fromAddr);
// Messages authored by the user (Sent folder or self-sent copies in INBOX)
// are current authored text. Do not let cached boundaries or HTML
// blockquote parsing hide the whole thing behind "Earlier reply".
if ((isSentFolder || isMine) && plain) {
const plainTurns = _renderPlaintextThread(plain);
if (plainTurns && !/^\s*<details\b/i.test(plainTurns.trim())) {
return _foldSignature(plainTurns, null);
}
return _foldSignature(_escLinkify(plain).replace(/\n/g, '<br>'), 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, '<br>');
let out = fmt(head);
if (quoteSection) {
out += '<details class="email-quote-fold">'
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(quoteSection))
+ fmt(quoteSection) + '</details>';
}
if (sigSection) {
const sigHtml = fmt(sigSection);
if (_isBloatedSig(sigHtml)) {
out += '<details class="email-sig-fold">' + _foldSummary('Signature', _SIG_ICON)
+ sigHtml + '</details>';
} 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, '<br>');
}
const threaded = _renderThreadStructure(rendered);
if (threaded) return _prepareEmailInlineImages(_foldSignature(threaded, hintSig));
return _prepareEmailInlineImages(_foldSignature(_foldQuotedReplies(rendered), hintSig));
}
export function _safeRenderEmailBody(data) {
try {
return _prepareEmailInlineImages(_renderEmailBody(data));
} catch (e) {
console.error('email body render failed:', e);
const plain = (typeof data?.body === 'string') ? data.body : '';
if (plain) return _escLinkify(plain).replace(/\n/g, '<br>');
if (data?.body_html) return _prepareEmailInlineImages(_sanitizeHtml(data.body_html));
return '<span style="opacity:.65">No body</span>';
}
}
function _prepareEmailInlineImages(html) {
const raw = String(html || '');
if (!/<img[\s>]/i.test(raw)) return raw;
const doc = new DOMParser().parseFromString(`<div>${raw}</div>`, '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 = `
<span class="email-inline-image-skeleton">
<span class="email-inline-image-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
</span>
</span>
<span class="email-inline-image-info">
<span class="email-inline-image-title">${_esc(label)}</span>
<span class="email-inline-image-sub">${isHttp ? 'Remote image blocked' : isCid ? 'Inline image hidden' : 'Image unavailable'}</span>
</span>
<span class="email-inline-image-actions">
${isHttp ? `<a class="email-inline-image-btn email-inline-image-download-btn" href="${_esc(src)}" target="_blank" rel="noopener noreferrer" download title="Download" aria-label="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></a>` : ''}
${isCid ? `<button type="button" class="email-inline-image-btn email-inline-image-download-btn" data-email-img-download="${idx}" title="Download" aria-label="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>` : ''}
</span>
`;
img.replaceWith(ph);
});
return root.innerHTML;
}
export function _wireEmailInlineImages(reader) {
if (!reader) return;
const placeholders = Array.from(reader.querySelectorAll('.email-inline-image-placeholder'));
const visiblePlaceholders = placeholders.filter(ph => (
ph.isConnected &&
(ph.dataset.emailImgSrc || ph.dataset.emailImgCid) &&
getComputedStyle(ph).display !== 'none'
));
const autoLoadInlineImages = state._libViewInlineImages !== false;
const manualPlaceholders = autoLoadInlineImages
? visiblePlaceholders.filter(ph => !ph.dataset.emailImgCid)
: visiblePlaceholders;
const existingBulkBar = reader.querySelector('.email-inline-image-load-all');
if (!manualPlaceholders.length) existingBulkBar?.remove();
const loadPlaceholder = (ph) => {
if (!ph || !ph.isConnected) return;
const src = (ph.dataset.emailImgSrc || '').trim();
const cid = (ph.dataset.emailImgCid || '').trim();
const ctxRoot = reader.dataset?.emailUid
? reader
: ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]');
const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX';
const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || '';
const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || '';
const inlineUrl = () => {
if (!uid || !cid) return '';
const params = new URLSearchParams({ cid, folder });
if (account) params.set('account_id', account);
return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`;
};
const isRemoteImage = /^https?:\/\//i.test(src);
const isCidImage = !!cid && !isRemoteImage;
const loadSrc = isRemoteImage ? src : inlineUrl();
if (!loadSrc || ph.classList.contains('is-loading')) return;
ph.classList.remove('is-error');
ph.classList.add('is-loading');
const sub = ph.querySelector('.email-inline-image-sub');
if (sub) sub.textContent = 'Loading image...';
const actions = ph.querySelector('.email-inline-image-actions');
if (actions) actions.style.display = 'none';
const img = document.createElement('img');
img.className = 'email-inline-image-loaded';
img.alt = ph.querySelector('.email-inline-image-title')?.textContent || 'Loaded email image';
img.loading = 'eager';
img.referrerPolicy = 'no-referrer';
let settled = false;
let objectUrl = '';
let frame = null;
let directFallbackUsed = false;
const ensureFrame = () => {
if (frame) return frame;
frame = document.createElement('span');
frame.className = 'email-inline-image-frame is-loading';
frame.appendChild(img);
if (ph.isConnected) ph.replaceWith(frame);
return frame;
};
const showLoaded = () => {
if (settled) return;
settled = true;
if (frame) frame.classList.remove('is-loading');
img.classList.add('is-visible');
};
const showError = (err) => {
if (settled) return;
settled = true;
if (frame?.isConnected) frame.replaceWith(ph);
ph.classList.remove('is-loading');
ph.classList.add('is-error');
if (sub) sub.textContent = err?.message ? `Image failed: ${err.message}` : 'Image failed to load';
if (actions) actions.style.display = '';
if (objectUrl) {
try { URL.revokeObjectURL(objectUrl); } catch {}
}
};
const loadDirect = () => {
directFallbackUsed = true;
ensureFrame();
img.src = loadSrc;
if (img.complete && img.naturalWidth > 0) showLoaded();
};
img.addEventListener('load', showLoaded, { once: true });
img.addEventListener('error', () => {
if (!directFallbackUsed && objectUrl && isCidImage) {
try { URL.revokeObjectURL(objectUrl); } catch {}
objectUrl = '';
loadDirect();
return;
}
showError();
}, { once: true });
if (isCidImage || isRemoteImage) {
loadDirect();
setTimeout(() => {
if (!settled && frame) {
frame.classList.add('is-loading');
}
}, 12000);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
fetch(loadSrc, { credentials: 'same-origin', signal: controller.signal })
.then(async res => {
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
if (!blob || !blob.size) throw new Error('Empty image');
const type = blob.type && blob.type.startsWith('image/') ? blob.type : 'image/png';
objectUrl = URL.createObjectURL(blob.type === type ? blob : new Blob([blob], { type }));
ensureFrame();
img.src = objectUrl;
if (img.complete && img.naturalWidth > 0) showLoaded();
if (!settled && typeof img.decode === 'function') {
img.decode().then(showLoaded).catch(() => {
if (img.complete && img.naturalWidth === 0) showError();
});
}
})
.catch(err => {
clearTimeout(timeoutId);
showError(err?.name === 'AbortError' ? new Error('request timed out') : err);
});
setTimeout(() => {
if (!settled && !frame) {
ph.classList.remove('is-loading');
if (sub) sub.textContent = 'Still loading...';
if (actions) actions.style.display = '';
}
}, 12000);
};
if (manualPlaceholders.length && !reader.querySelector('.email-inline-image-load-all')) {
const first = manualPlaceholders[0];
const bar = document.createElement('div');
bar.className = 'email-inline-image-load-all';
const label = autoLoadInlineImages
? `${manualPlaceholders.length} external image${manualPlaceholders.length === 1 ? '' : 's'} blocked`
: `${manualPlaceholders.length} hidden inline image${manualPlaceholders.length === 1 ? '' : 's'}`;
bar.innerHTML = `
<span>${label}</span>
<button type="button" data-email-img-dismiss-all>Dismiss</button>
<button type="button" data-email-img-load-all>Load all</button>
`;
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 =
`<div class="email-bubble-head">`
+ `<span class="email-bubble-author" style="color:${color}">${_esc(author)}</span>`
+ (date ? `<span class="email-bubble-date">${_esc(date)}</span>` : '')
+ `</div>`;
const avatar = `<div class="email-bubble-avatar" aria-hidden="true" style="background:${color}">${_esc(initials)}</div>`;
return (
`<div class="email-bubble-row email-bubble-${side}" style="--bubble-accent:${color}">`
+ (isMine ? '' : avatar)
+ `<div class="email-bubble">`
+ head
+ `<div class="email-bubble-body">${_sanitizeHtml(t.body_html || '')}</div>`
+ `</div>`
+ (isMine ? avatar : '')
+ `</div>`
);
});
return `<div class="email-bubbles">${rows.join('')}</div>`;
}
/**
* 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) =>
`<details class="email-thread-turn email-quote-fold">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
+ `<div class="email-thread-turn-body">${t.html}</div>`
+ '</details>';
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('<details class="email-thread-turn email-quote-fold"');
if (lastIdx >= 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(`<div id="__t">${html}</div>`, '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 <date>, <author> 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 <date>, <name> 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 <date>... 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 <blockquote>
// from making the signature appear as a phantom prior email.
if (!meta && _looksLikeSignature(innerHtml)) {
turnsHtml.push(
'<details class="email-sig-fold">'
+ _foldSummary('Signature', _SIG_ICON)
+ `<div class="email-sig-body">${innerHtml}</div>`
+ '</details>'
);
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(
`<details class="email-thread-turn email-quote-fold${isLast ? ' last-fold' : ''}">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, meta || '')
+ `<div class="email-thread-turn-body">${bodyHtml}</div>`
+ '</details>'
);
// 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 <details> so deep email threads
* don't dominate the reader. Detects:
* - <blockquote> 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 <date>, <name> 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, '<br>');
let out = '';
const stack = [];
const wrapTurn = (t) =>
`<details class="email-thread-turn email-quote-fold">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
+ `<div class="email-thread-turn-body">${t.html}</div>`
+ '</details>';
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 += '<br>' + 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('<details class="email-thread-turn email-quote-fold"');
if (lastIdx >= 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(`<div id="__r">${html}</div>`, '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(
`(<br\\s*/?>|</p>|</div>|<p[^>]*>|<div[^>]*>|\\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]
+ '<details class="email-quote-fold last-fold">'
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2]))
+ m[2] + '</details>';
}
return html;
}
File diff suppressed because it is too large Load Diff
+856
View File
@@ -0,0 +1,856 @@
// 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 spinnerModule from '../spinner.js';
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) => `<span class="dropdown-icon">${svg}</span>`;
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _archIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
const _spamIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
const _trashIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
const _deleteForeverIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="14" y2="15"/><line x1="14" y1="11" x2="10" y2="15"/></svg>';
const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _translateIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent-primary, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>';
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const _contactIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>';
// 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: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
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 ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const cancelItem = document.createElement('div');
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
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) => `<span class="dropdown-icon">${svg}</span>`;
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
const _archIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
const _delIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _cardBellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
const isSentFolder = /sent/i.test(state._libFolder);
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
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: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
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: '<svg width="14" height="14" viewBox="0 0 24 24" fill="' + (em.is_flagged ? 'currentColor' : 'none') + '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>',
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 ? '<span style="margin-left:auto;opacity:0.5;">›</span>' : '';
item.innerHTML = _icon(a.icon) + `<span>${a.label}</span>${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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const cancelItem = document.createElement('div');
cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const _exportIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
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 = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
dropdown.appendChild(it);
}
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
const _cancelIco2 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
const cancelIt = document.createElement('div');
cancelIt.className = 'dropdown-item-compact dropdown-cancel-mobile';
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
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 = `<span class="email-bulk-loading-label">${verbing}</span>`;
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();
}
+677
View File
@@ -0,0 +1,677 @@
// 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 spinnerModule from '../spinner.js';
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 <body> 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 = `
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);display:flex;flex-direction:column;">
<div class="modal-header">
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
</h4>
<button class="minimize-btn" type="button" title="Minimize">_</button>
<button class="close-btn" type="button" title="Close">&#x2716;</button>
</div>
<div class="modal-body email-reader-tab-body" style="display:flex;flex-direction:column;overflow:hidden;flex:1;min-height:0;padding:0;">
<div class="email-card-reader email-card-expanded" style="flex:1;min-height:0;display:flex;flex-direction:column;">
<div class="email-reader-tab-loading" style="padding:24px;display:flex;justify-content:center;"></div>
</div>
</div>
</div>
`;
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 = `
<div class="email-reader-header">
<div class="email-reader-meta">
<div class="email-reader-meta-row email-reader-meta-from">
<strong>From:</strong>
<span class="recipient-chips">${fromChip}${_recipientMetaToggleHtml(data)}</span>
</div>
${(data.to || data.cc) ? `<div class="email-reader-meta-details" hidden>
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${buildChips(data.to)}</span></div>` : ''}
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${buildChips(data.cc)}</span></div>` : ''}
</div>` : ''}
<div class="email-reader-actions-inline">
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
<div class="email-reader-more-wrap" style="position:relative">
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
</div>
</div>
</div>
</div>
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
_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 = `
<div class="modal-content email-window-content" style="width:min(640px, 92vw);display:flex;flex-direction:column;background:var(--bg);">
<div class="modal-header">
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
<span class="email-window-subject" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(em.subject || '(no subject)')}</span>
</h4>
<button class="close-btn" type="button" title="Close">&#x2716;</button>
</div>
<div class="modal-body email-window-body" style="overflow:auto;padding:14px 16px;flex:1;min-height:0;">
<div class="email-window-loading" style="display:flex;justify-content:center;padding:24px;"></div>
</div>
</div>
`;
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 = `<div style="color:var(--red,#e55);padding:16px;">${_esc(data.error)}</div>`;
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 = `
<div class="email-reader-header">
<div class="email-reader-meta">
<div class="email-reader-meta-row email-reader-meta-from">
<strong>From:</strong>
<span class="recipient-chips">${fromChip}${_recipientMetaToggleHtml(data)}</span>
</div>
${(data.to || data.cc) ? `<div class="email-reader-meta-details" hidden>
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${_chipsFor(data.to)}</span></div>` : ''}
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${_chipsFor(data.cc)}</span></div>` : ''}
</div>` : ''}
<div class="email-reader-actions-inline">
<button class="memory-toolbar-btn reader-icon-btn" data-act="ai-reply" title="${data.cached_ai_reply ? 'AI Reply (cached draft ready)' : 'AI Reply (suggest a draft)'}">${_aiReplyIcon(data)}<span class="reader-btn-label">AI reply</span></button>
<button class="memory-toolbar-btn reader-icon-btn" data-act="reply" title="Reply"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span class="reader-btn-label">Reply</span></button>
${_hasMultipleRecipients(data) ? `<button class="memory-toolbar-btn reader-icon-btn" data-act="reply-all" title="Reply All"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 17 2 12 7 7"/><polyline points="12 17 7 12 12 7"/><path d="M22 18v-2a4 4 0 0 0-4-4H7"/></svg><span class="reader-btn-label">Reply all</span></button>` : ''}
<button class="memory-toolbar-btn reader-icon-btn" data-act="forward" title="Forward"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 17 20 12 15 7"/><path d="M4 18v-2a4 4 0 0 1 4-4h12"/></svg><span class="reader-btn-label">Forward</span></button>
<div class="email-reader-more-wrap" style="position:relative">
<button class="memory-toolbar-btn reader-icon-btn" data-act="more" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg><span class="reader-btn-label">More</span></button>
</div>
</div>
</div>
</div>
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
_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 = `<div style="color:var(--red,#e55);padding:16px;">Failed to load: ${_esc(String(err))}</div>`;
}
}
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 = `
<div class="email-summary-header">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
<span>Summary</span>
</div>
<div class="email-summary-content" style="white-space:normal;display:flex;align-items:center;flex-wrap:wrap;gap:6px;"><span style="opacity:0.65">No AI summary generated.</span><button class="memory-toolbar-btn" data-act="summary-generate" style="font-size:10px;margin-left:auto;">Generate now</button></div>`;
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 =
'<div class="email-summary-header email-summary-toggle" role="button" tabindex="0">'
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>'
+ '<span>Summary</span>'
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</div>'
+ '<div class="email-summary-content"></div>';
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();
}
+880
View File
@@ -0,0 +1,880 @@
// 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 spinnerModule from '../spinner.js';
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 = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>`;
const _EMAIL_SAVE_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>';
const _EMAIL_SAVED_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
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
? ` <span class="email-settings-account-scope">(${_esc(accountAddress)})</span>`
: '';
return `
<div class="admin-card email-settings-section email-settings-auto-reply-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-auto-reply-title-row">
<div class="email-settings-section-title"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg><span>Auto Reply${accountScope}</span></div>
<div class="email-settings-enabled-control">
<span class="email-settings-enabled-state"></span>
<label class="email-settings-toggle admin-switch">
<input type="checkbox" id="email-auto-reply-enabled" ${cfg.enabled ? 'checked' : ''}>
<span class="admin-slider"></span>
</label>
</div>
</div>
<div class="email-settings-section-desc">Holiday or away replies for incoming mail.</div>
</div>
</div>
<div id="email-settings-auto-reply-body" class="email-settings-section-body">
<div class="email-settings-grid">
<label><span>Start date</span><input type="date" id="email-auto-reply-start" value="${_esc(_autoReplyDateValue(cfg.start))}"></label>
<label><span>End date</span><input type="date" id="email-auto-reply-end" value="${_esc(_autoReplyDateValue(cfg.end))}"></label>
</div>
<label class="email-settings-field">
<span>Subject</span>
<input type="text" id="email-auto-reply-subject" value="${_esc(cfg.subject || _DEFAULT_AUTO_REPLY_SUBJECT)}" placeholder="${_esc(_DEFAULT_AUTO_REPLY_SUBJECT)}">
</label>
<label class="email-settings-field">
<span>Message</span>
<textarea id="email-auto-reply-message" rows="4">${_esc(cfg.message || _DEFAULT_AUTO_REPLY_MESSAGE)}</textarea>
</label>
<label class="email-settings-field">
<span>Send to same sender</span>
<select id="email-auto-reply-cooldown">
<option value="period" ${cfg.cooldown === 'period' ? 'selected' : ''}>Once while active</option>
<option value="1d" ${cfg.cooldown === '1d' ? 'selected' : ''}>Every day</option>
<option value="3d" ${cfg.cooldown === '3d' ? 'selected' : ''}>Every 3 days</option>
<option value="7d" ${cfg.cooldown === '7d' ? 'selected' : ''}>Every 7 days</option>
</select>
</label>
<label class="email-settings-check">
<span>Pause email notifications while active</span>
<span class="email-settings-check-spacer"></span>
<span class="admin-switch"><input type="checkbox" id="email-auto-reply-pause" ${cfg.pauseNotifications ? 'checked' : ''}><span class="admin-slider"></span></span>
</label>
</div>
</div>
`;
}
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
? ` <span class="email-settings-account-scope">(${_esc(accountAddress)})</span>`
: '';
return `
<div class="admin-card email-settings-section email-settings-cleanup-section">
<div class="email-settings-section-head">
<div>
<span class="email-settings-section-title"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg><span>Newsletter Unsubscribe${accountScope}</span></span>
<div class="email-settings-section-desc">Find newsletter and ad emails with unsubscribe options.</div>
</div>
</div>
<div id="email-settings-cleanup-body" class="email-settings-section-body">
<div class="email-settings-clean-actions">
<span class="email-unsub-status email-settings-clean-status" style="font-size:12px;color:color-mix(in srgb,var(--fg) 68%,transparent);"></span>
<button type="button" class="memory-toolbar-btn email-settings-clean-btn">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="vertical-align:-1px;margin-right:2px;color:var(--accent,var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
<span>Clean</span>
</button>
<button type="button" class="memory-toolbar-btn email-settings-save">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
Save
</button>
</div>
</div>
</div>
`;
}
export function _emailDisplaySettingsHtml(cfg = {}) {
return `
<div class="admin-card email-settings-section email-settings-display-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-display-title-row">
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.59 3H4a1 1 0 0 0-1 1v5.59A2 2 0 0 0 3.59 11l9.59 9.59a2 2 0 0 0 2.82 0l4.59-4.59a2 2 0 0 0 0-2.82Z"/><path d="M7 7h.01"/></svg><span>Show Email Tags</span></div>
<span class="email-settings-enabled-control">
<span class="email-settings-enabled-state email-settings-display-enabled-state"></span>
<label class="email-settings-toggle admin-switch">
<input type="checkbox" id="email-settings-show-tags" ${state._libShowTags ? 'checked' : ''}>
<span class="admin-slider"></span>
</label>
</span>
</div>
<div class="email-settings-section-desc email-settings-support-copy">Choose which labels appear on email cards. <span class="email-settings-display-task-note">Turn off tagging in <button type="button" class="email-settings-inline-link" data-open-email-tasks>Tasks</button></span></div>
</div>
</div>
<div class="email-settings-display-spacer" aria-hidden="true"></div>
</div>
<div class="admin-card email-settings-section email-settings-inline-images-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-display-title-row">
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg><span>Hide Inline Images</span></div>
<span class="email-settings-enabled-control">
<span class="email-settings-enabled-state email-settings-inline-images-enabled-state"></span>
<label class="email-settings-toggle admin-switch">
<input type="checkbox" id="email-settings-view-inline-images" ${cfg.viewInlineImages === false ? 'checked' : ''}>
<span class="admin-slider"></span>
</label>
</span>
</div>
<div class="email-settings-section-desc email-settings-support-copy">Keep images embedded in email bodies hidden until you load them.</div>
</div>
</div>
<div class="email-settings-inline-images-spacer" aria-hidden="true"></div>
</div>
`;
}
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 `<option value="${_esc(a.id || '')}" ${state._libAccountId === a.id ? 'selected' : ''}>${_esc(label + suffix)}</option>`;
}).join('');
const selected = accounts.find(a => a.id === state._libAccountId) || accounts[0];
const active = !!selected?.is_default;
const icon = active
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>'
: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
const defaultTitle = active ? 'Default account' : 'Make this the default account';
return `<div class="email-settings-account-picker" style="margin-left:auto;display:inline-flex;align-items:center;gap:8px;">`
+ `<span class="cookbook-srv-default email-settings-default${active ? ' active' : ''}" role="button" tabindex="0" aria-pressed="${active ? 'true' : 'false'}" data-email-default-id="${_esc(selected?.id || '')}" title="${defaultTitle}"><span class="cookbook-srv-default-icon" aria-hidden="true">${icon}</span><span class="cookbook-srv-default-label">default</span></span>`
+ `<select id="email-settings-account-select" class="memory-toolbar-btn" title="Active email for all email settings" style="height:28px;max-width:240px;position:relative;top:-2px;">${opts}</select>`
+ `</div>`;
}
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
? '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>'
: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
}
export function _emailWritingStyleHtml(style) {
return `
<div class="admin-card email-settings-section email-settings-style-section">
<div class="email-settings-section-head">
<div>
<div class="email-settings-section-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg><span>Email Reply Writing Style</span></div>
<div class="email-settings-section-desc email-settings-support-copy">Used when Odysseus drafts replies for you.</div>
</div>
</div>
<div id="email-settings-style-body" class="email-settings-section-body">
<label class="email-settings-field">
<span>Writing style</span>
<textarea id="email-writing-style-text" rows="6">${_esc(style)}</textarea>
</label>
<div class="email-settings-actions">
<button type="button" class="memory-toolbar-btn email-style-settings-extract">
<svg class="email-unsub-accent-icon" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
Extract
</button>
<button class="email-style-extract-help" aria-label="What does Extract do?" title="Extract analyzes sent emails from the selected account and saves a writing-style prompt for AI replies.">?</button>
<button type="button" class="memory-toolbar-btn email-style-settings-save">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
Save
</button>
</div>
</div>
</div>
`;
}
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 = `
<div class="email-settings-page-head">
<h2 class="email-settings-title">${_EMAIL_SETTINGS_ICON}<span>Email Settings</span></h2>
</div>
<div class="email-settings-body">
${_emailSettingsLoadingHtml()}
</div>
`;
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 = `<div class="email-settings-error">Could not load settings.</div>`;
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 = `<div class="email-settings-error">Could not load settings.</div>`;
}
});
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');
}
+4
View File
@@ -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,
};
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v654-css-split-3';
const CACHE_NAME = 'odysseus-v655-email-library-split';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -101,9 +101,19 @@ const PRECACHE = [
'/static/js/slashCommands.js?v=20260921chatgptusage1',
'/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',
// emailInbox.js has always imported this one; it was never precached.
'/static/js/emailLibrary/replyRecipients.js',
'/static/js/notes.js?v=20260911notesselectioncancel1',
'/static/js/tasks.js?v=20260914taskmodel1',
'/static/js/calendar.js?v=20260914emailsource11',
+83
View File
@@ -0,0 +1,83 @@
"""Read a split JS subpackage the way the module graph does.
``static/js/emailLibrary.js`` is a re-export wrapper; the implementation lives
in ``static/js/emailLibrary/``. A test that asserts on email-library behaviour
has to look at every module in that package, because reading one file ties the
test to whichever module a function happens to sit in today — it goes red the
next time something moves without any behaviour changing.
That is the mistake the stylesheet split made, which is why
``tests/helpers/stylesheets.py`` exists. This is the same helper for JS.
Order is deterministic: the entry module first, then the rest alphabetically.
Tests that assert "A appears before B" are asserting about one module's source,
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"
EMAIL_LIBRARY_WRAPPER = _STATIC_JS / "emailLibrary.js"
EMAIL_LIBRARY_PACKAGE = _STATIC_JS / "emailLibrary"
EMAIL_LIBRARY_ENTRY = EMAIL_LIBRARY_PACKAGE / "index.js"
def _package_paths(package: Path, entry: Path) -> list[Path]:
if not entry.is_file():
raise AssertionError(f"missing package entry module: {entry}")
rest = sorted(p for p in package.glob("*.js") if p != entry)
return [entry, *rest]
def email_library_paths(include_wrapper: bool = False) -> list[Path]:
"""Every module of the email-library package, entry module first.
``include_wrapper`` adds the compatibility file at the old top-level path.
Leave it off for assertions about implementation code: the wrapper holds
only an ``export … from`` list.
"""
paths = _package_paths(EMAIL_LIBRARY_PACKAGE, EMAIL_LIBRARY_ENTRY)
return [EMAIL_LIBRARY_WRAPPER, *paths] if include_wrapper else paths
def email_library_source(include_wrapper: bool = False) -> str:
"""The whole email-library package as one string."""
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()]
+28 -23
View File
@@ -2,6 +2,7 @@ from pathlib import Path
import subprocess
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parents[1]
@@ -13,13 +14,17 @@ def test_shared_action_menu_order_is_used_by_item_menus() -> None:
"static/js/tasks.js": "orderActionMenuItems",
"static/js/sessions.js": "orderActionMenuItems",
"static/js/research/panel.js": "orderActionMenuItems",
"static/js/emailLibrary.js": "orderActionMenuItems",
"static/js/memory.js": "orderActionMenuItems",
}
for relative_path, helper in expected_imports.items():
source = (ROOT / relative_path).read_text(encoding="utf-8")
assert "actionMenuOrder.js" in source
assert helper in source
# The email library is a package, so the import and the call can sit in
# different modules of it.
email = email_library_source()
assert "actionMenuOrder.js" in email
assert "orderActionMenuItems" in email
def test_common_action_order_matches_product_convention() -> None:
@@ -68,20 +73,20 @@ def test_dropdown_select_actions_use_the_canonical_icon() -> None:
"static/js/sessions.js",
"static/js/skills.js",
"static/js/tasks.js",
"static/js/emailLibrary.js",
"static/js/research/panel.js",
):
module = (ROOT / relative_path).read_text(encoding="utf-8")
assert "SELECT_MENU_ICON" in module
assert "SELECT_MENU_ICON" in email_library_source()
def test_email_filter_menu_has_context_title() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert 'email-filter-menu-title">Filter by...</div>' in source
def test_email_setting_toggles_render_neutral_disabled_state() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
style = app_css()
assert 'email-settings-auto-reply-section' in source
assert 'email-settings-display-enabled-state' in source
@@ -90,14 +95,14 @@ def test_email_setting_toggles_render_neutral_disabled_state() -> None:
def test_email_search_options_menu_has_context_title() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
menu_start = source.index('id="email-search-options-menu"')
menu_end = source.index("</div>", menu_start) + len("</div>")
assert 'email-search-options-title">Filter by...</div>' in source[menu_start:menu_end]
def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert "function _emailTimelineGapThreshold(items)" in source
assert "email-date-gap-break" in source
assert "gapDays > 90 && gapDays > timelineGapThreshold" in source
@@ -107,7 +112,7 @@ def test_email_date_headers_mark_unexpected_timeline_gaps() -> None:
def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert '<option value="tag:action-needed">' not in source
assert "filter:tag:action-needed" not in source
assert "email-card-favorite" in source
@@ -132,7 +137,7 @@ def test_email_filters_and_card_favorite_toggle_are_wired() -> None:
def test_email_auto_reply_start_date_seeds_today_when_picker_opens() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert "function _todayDateInputValue()" in source
assert "if (autoReplyStart && !autoReplyStart.value) autoReplyStart.value = _todayDateInputValue();" in source
assert "autoReplyStart?.addEventListener('pointerdown', seedAutoReplyStartDate);" in source
@@ -140,7 +145,7 @@ def test_email_auto_reply_start_date_seeds_today_when_picker_opens() -> None:
def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert "function _syncAutoReplyCalendarEvent(cfg)" in source
assert "summary: 'Email Auto Reply (away)'" in source
assert "function _findAutoReplyCalendarEventUids(cfg, accountId)" in source
@@ -155,7 +160,7 @@ def test_email_auto_reply_syncs_one_calendar_event_per_account() -> None:
def test_email_settings_show_away_account_and_compact_display_controls() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
style = app_css()
assert 'email-account-away-label">(AWAY)</span>' in source
assert 'id="email-lib-auto-reply-badge"' in source
@@ -175,14 +180,14 @@ def test_email_settings_show_away_account_and_compact_display_controls() -> None
def test_email_cleanup_uses_the_memory_tidy_star_icon() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
cleanup = source[source.index("function _emailCleanupSettingsHtml"):source.index("function _emailDisplaySettingsHtml")]
assert "email-settings-clean-btn" in cleanup
assert "M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z" in cleanup
def test_email_settings_escape_returns_to_email_list() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
settings_guard = "if (modal.classList.contains('email-settings-mode'))"
assert settings_guard in source
assert source.index(settings_guard) < source.index("closeEmailLibrary();", source.index(settings_guard))
@@ -190,7 +195,7 @@ def test_email_settings_escape_returns_to_email_list() -> None:
def test_email_select_escape_cancels_selection_without_closing_library() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
select_guard = "if (state._selectMode) {"
select_start = source.index(select_guard, source.index("if (e.key === 'Escape')"))
assert "_setSelectBtnState(false);" in source[select_start:select_start + 260]
@@ -206,7 +211,7 @@ def test_chat_delete_actions_use_the_shared_trash_bin_icon() -> None:
def test_agent_unsubscribe_uses_the_reviewed_target_without_rescanning() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
start = source.index("function _askAgentToUnsubscribe")
end = source.index("function _unsubscribeCandidateUids", start)
prompt = source[start:end]
@@ -219,7 +224,7 @@ def test_agent_unsubscribe_uses_the_reviewed_target_without_rescanning() -> None
def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
start = source.index("function _bindEmailSettingsPageControls")
end = source.index("function _setUnsubButtonBusy", start)
controls = source[start:end]
@@ -228,7 +233,7 @@ def test_email_clean_always_forces_a_fresh_unsubscribe_scan() -> None:
def test_unsubscribe_duplicate_badge_is_lowered() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
frontend = email_library_source()
stylesheet = app_css()
assert "email-unsub-duplicate-badge" in frontend
start = stylesheet.index(".email-unsub-duplicate-badge {")
@@ -236,7 +241,7 @@ def test_unsubscribe_duplicate_badge_is_lowered() -> None:
def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
frontend = email_library_source()
stylesheet = app_css()
start = frontend.index("function _emailCleanupSettingsHtml")
end = frontend.index("function _emailDisplaySettingsHtml", start)
@@ -257,7 +262,7 @@ def test_unsubscribe_scan_status_sits_before_clean_action() -> None:
def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
frontend = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
frontend = email_library_source()
backend = (ROOT / "routes/email/email_routes.py").read_text(encoding="utf-8")
mcp = (ROOT / "mcp_servers/email_server.py").read_text(encoding="utf-8")
assert "async function _deleteAfterUnsubscribe" in frontend
@@ -271,7 +276,7 @@ def test_unsubscribe_success_removes_messages_before_the_next_scan() -> None:
def test_agent_email_mutations_reconcile_bulk_single_and_mailto_results() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
start = source.index("function _agentDeletedEmailUids")
end = source.index("function _handleAgentEmailToolOutput", start)
resolver = source[start:end]
@@ -282,7 +287,7 @@ def test_agent_email_mutations_reconcile_bulk_single_and_mailto_results() -> Non
def test_browser_agent_unsubscribe_cleans_sender_after_positive_confirmation() -> None:
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
start = source.index("function _agentBrowserUnsubscribeSucceeded")
end = source.index("function _agentDeletedEmailUids", start)
browser_flow = source[start:end]
@@ -321,7 +326,7 @@ def test_unsubscribe_cleanup_can_remove_same_sender_unsubscribe_messages() -> No
def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
source = email_library_source()
start = source.index("function _markUnsubscribeCardDone")
end = source.index("async function _runUnsubscribeCleanup", start)
card = source[start:end]
@@ -331,7 +336,7 @@ def test_unsubscribe_review_marks_handled_cards_and_offers_scan_further() -> Non
def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
source = email_library_source()
styles = app_css()
assert "email-unsub-ignore-btn" in source
assert "_rememberUnsubscribeIgnored(c)" in source
@@ -340,7 +345,7 @@ def test_unsubscribe_review_can_ignore_a_candidate_without_deleting_it() -> None
def test_email_settings_sections_use_static_headers() -> None:
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
source = email_library_source()
styles = app_css()
assert 'class="email-unsub-accent-icon"' in source
assert 'M12 0L14.59 8.41' in source
@@ -1,6 +1,7 @@
from pathlib import Path
import re
from tests.helpers.document_source import document_source, function_body
from tests.helpers.js_modules import email_library_paths
ROOT = Path(__file__).resolve().parents[1]
@@ -46,8 +47,8 @@ def test_all_runtime_document_imports_share_one_module_url():
ROOT / "static/js/chat.js",
ROOT / "static/js/chatStream.js",
ROOT / "static/js/chatRenderer.js",
ROOT / "static/js/emailLibrary.js",
ROOT / "static/js/slashCommands.js",
*email_library_paths(include_wrapper=True),
]
versions = {
match
+2 -1
View File
@@ -2,6 +2,7 @@ from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parents[1]
@@ -32,7 +33,7 @@ def test_calendar_chat_event_links_fetch_uid_and_show_title_time():
app_src = (ROOT / "static/app.js").read_text()
renderer_src = (ROOT / "static/js/chatRenderer.js").read_text()
inbox_src = (ROOT / "static/js/emailInbox.js").read_text()
library_src = (ROOT / "static/js/emailLibrary.js").read_text()
library_src = email_library_source()
assert '@router.get("/events/{uid}")' in routes_src
assert "async function _fetchEventByUid" in calendar_src
+5 -7
View File
@@ -2,6 +2,7 @@ from pathlib import Path
import re
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parents[1]
@@ -285,13 +286,10 @@ def test_calendar_tool_guidance_preserves_manual_tags_on_unrelated_updates():
def test_calendar_visual_asset_versions_are_bumped():
versions = []
for rel in (
"static/app.js",
"static/js/chatRenderer.js",
"static/js/emailInbox.js",
"static/js/emailLibrary.js",
):
src = (ROOT / rel).read_text()
for src in [
(ROOT / rel).read_text()
for rel in ("static/app.js", "static/js/chatRenderer.js", "static/js/emailInbox.js")
] + [email_library_source()]:
match = re.search(r"calendar\.js\?v=([A-Za-z0-9_-]+)", src)
assert match
versions.append(match.group(1))
+15 -11
View File
@@ -1,5 +1,6 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_paths
ROOT = Path(__file__).resolve().parents[1]
@@ -32,14 +33,17 @@ def test_library_chat_card_menu_uses_standard_anchor_gap():
def test_card_menus_use_the_same_anchor_gap():
for relative_path in (
"static/js/sessions.js",
"static/js/documentLibrary.js",
"static/js/emailLibrary.js",
"static/js/memory.js",
"static/js/tasks.js",
"static/js/skills.js",
):
source = (ROOT / relative_path).read_text(encoding="utf-8")
assert "rect.bottom + 2" not in source, relative_path
assert "r.bottom + 2" not in source, relative_path
modules = [
ROOT / relative_path
for relative_path in (
"static/js/sessions.js",
"static/js/documentLibrary.js",
"static/js/memory.js",
"static/js/tasks.js",
"static/js/skills.js",
)
] + email_library_paths(include_wrapper=True)
for module in modules:
source = module.read_text(encoding="utf-8")
assert "rect.bottom + 2" not in source, module
assert "r.bottom + 2" not in source, module
@@ -1,12 +1,13 @@
from pathlib import Path
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parent.parent
def test_email_ai_reply_context_is_saved_and_restored_per_message():
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert "_AI_REPLY_CONTEXT_DRAFT_PREFIX" in source
assert "data?.account_id || em?.account_id || state._libAccountId" in source
@@ -18,7 +19,7 @@ def test_email_ai_reply_context_is_saved_and_restored_per_message():
def test_email_ai_reply_context_only_clears_after_draft_opens():
library = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
library = email_library_source()
inbox = (ROOT / "static/js/emailInbox.js").read_text(encoding="utf-8")
assert "const draftOpened = await _runAiReplyFromButton" in library
+6 -5
View File
@@ -1,6 +1,7 @@
import sqlite3
from email.message import EmailMessage
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_source
def test_attachment_filename_is_part_of_ui_index_search(tmp_path, monkeypatch):
@@ -90,7 +91,7 @@ def test_forwarding_filters_signature_assets_and_mobile_export_stops_bubbling():
def test_attachment_open_spins_icon_only():
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
library = email_library_source()
start = library.index("reader.querySelectorAll('.email-attachment-open')")
end = library.index("reader.querySelectorAll('.email-attachment-download')", start)
handler = library[start:end]
@@ -110,7 +111,7 @@ def test_move_document_creates_destination_before_adopting_it():
def test_deferred_attachment_check_shows_feedback_and_repairs_stale_card_icon():
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
library = email_library_source()
start = library.index("function _loadDeferredAttachmentsIntoReader")
end = library.index('\n// "Open in new tab"', start)
loader = library[start:end]
@@ -160,7 +161,7 @@ def test_attachment_cache_backfill_preserves_message_id(tmp_path, monkeypatch):
def test_single_email_tag_has_no_more_control():
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
library = email_library_source()
group = library[library.index("function _emailTagGroupHtml("):library.index("function _fitEmailCardTags(")]
assert "if (visible.length === 1) return visible[0];" in group
assert "if (visible.length === 2) return visible.join('');" in group
@@ -168,7 +169,7 @@ def test_single_email_tag_has_no_more_control():
def test_email_folder_and_filter_pickers_treat_their_buttons_as_inside_clicks():
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
library = email_library_source()
assert library.count(
"bindMenuDismiss(menu, finishClose, e => !picker.contains(e.target))"
@@ -178,7 +179,7 @@ def test_email_folder_and_filter_pickers_treat_their_buttons_as_inside_clicks():
def test_empty_reply_has_two_editable_rows_and_reply_survives_compact_toolbar():
inbox = open("static/js/emailInbox.js", encoding="utf-8").read()
library = open("static/js/emailLibrary.js", encoding="utf-8").read()
library = email_library_source()
assert "<p><br></p><p><br></p>\\n" in inbox
fit_start = library.index("function _fitReaderActions")
+2 -1
View File
@@ -1,12 +1,13 @@
from pathlib import Path
from tests.helpers.stylesheets import app_css
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parents[1]
def test_folder_chip_stays_with_date_and_moves_down():
source = (ROOT / "static" / "js" / "emailLibrary.js").read_text()
source = email_library_source()
css = app_css()
assert 'class="email-meta-date-group"' in source
+7 -16
View File
@@ -1,29 +1,20 @@
from pathlib import Path
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_source, js_function_source
_REPO = Path(__file__).resolve().parents[1]
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
_EMAIL_ROUTES = _REPO / "routes" / "email" / "email_routes.py"
_EMAIL_MCP_SERVER = _REPO / "mcp_servers" / "email_server.py"
_EMAIL_FIXTURE_HELPER = _REPO / "scripts" / "ody_eval_email_fixture.py"
def _bulk_action_source() -> str:
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
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.read_text(encoding="utf-8")
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():
@@ -51,7 +42,7 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
def test_email_bulk_export_attachments_is_ui_only_selected_context():
frontend = _EMAIL_LIBRARY.read_text(encoding="utf-8")
frontend = email_library_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
export_src = frontend[
frontend.index("async function _exportSelectedAttachments()"):
@@ -87,7 +78,7 @@ def test_email_context_changes_clear_bulk_selection_state():
Folder, account, filter, quick-filter, attachment, and search basis changes
must exit select mode before the next list/search view can run bulk actions.
"""
text = _EMAIL_LIBRARY.read_text(encoding="utf-8")
text = email_library_source()
reset_src = _function_source("_resetBulkSelectionForContextChange")
fresh_src = _function_source("_resetEmailListForFreshLoad")
add_pill_src = _function_source("_addSearchPill")
@@ -119,7 +110,7 @@ def test_email_refresh_uses_explicit_server_refresh_contract():
refresh button should keep the old rows visible while asking the server to
evict those fast paths and refetch the visible mailbox slice.
"""
frontend = _EMAIL_LIBRARY.read_text(encoding="utf-8")
frontend = email_library_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "refresh=1&_=${Date.now()}" in frontend
@@ -151,7 +142,7 @@ def test_fixture_email_requires_explicit_eval_flag():
def test_email_client_cache_drops_fixture_rows():
"""Old fixture rows in browser storage must not keep rendering."""
frontend = _EMAIL_LIBRARY.read_text(encoding="utf-8")
frontend = email_library_source()
assert "function _looksLikeFixtureEmailRow(row)" in frontend
assert "function _libCacheHasFixtureRows(value)" in frontend
+315
View File
@@ -0,0 +1,315 @@
"""Module-graph coverage for the split email library.
``specs/frontend.md`` records that this frontend has "no build-time type
checking, module graph validation, or script-order validation". That is
tolerable for a single 11k-line file and not tolerable for a package: splitting
``emailLibrary.js`` into modules that import each other adds three failure modes
nothing else here would catch.
1. The compatibility wrapper at ``static/js/emailLibrary.js`` drops an export.
Five call sites import that path; a missing name is ``undefined`` at call
time, not an error at load time, so the panel just stops responding.
2. A module in the package fails to evaluate — a stale relative specifier after
a move, or a temporal-dead-zone read across an import cycle. The package has
cycles by construction: extracted modules call back into ``index.js`` from
event handlers. That is safe for hoisted function declarations and *not* safe
for a ``const`` read while the graph is still evaluating, and which one you
wrote is invisible in a diff.
3. A new module is missing from the ``sw.js`` precache, so the panel that works
online cannot open offline.
The first and third are read off the source. The second needs a real module
loader, so it runs in a browser: each module is imported *on its own*, in a
fresh page, because entering the cycle at a submodule rather than at the entry
module is the order that exposes a dead-zone read.
"""
import json
import re
import subprocess
from pathlib import Path
from tests.helpers.js_modules import (
EMAIL_LIBRARY_ENTRY,
EMAIL_LIBRARY_PACKAGE,
EMAIL_LIBRARY_WRAPPER,
email_library_paths,
)
ROOT = Path(__file__).resolve().parents[1]
_SW = ROOT / "static" / "sw.js"
# `export function foo`, `export async function foo`, `export const foo`.
_EXPORT_DECL = re.compile(
r"^export\s+(?:async\s+)?(?:function|const|let|class)\s+([A-Za-z_$][\w$]*)",
re.M,
)
# `export { a, b } from '...'` and `export { a, b }`.
_EXPORT_LIST = re.compile(r"export\s*\{([^}]*)\}", re.S)
def _declared_exports(path: Path) -> set[str]:
return set(_EXPORT_DECL.findall(path.read_text(encoding="utf-8")))
def _listed_exports(path: Path) -> set[str]:
names: set[str] = set()
for block in _EXPORT_LIST.findall(path.read_text(encoding="utf-8")):
for raw in block.split(","):
name = raw.strip().split(" as ")[-1].strip()
if name:
names.add(name)
return names
# 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",
}
_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 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"
_IMPORT_STATEMENT = re.compile(
r"^import\s+(\{[^}]*\}|\*\s+as\s+[\w$]+|[\w$]+)\s+from\s+'[^']+';", re.M | re.S
)
_TOP_LEVEL_DECL = re.compile(
r"^(?:export\s+)?(?:async\s+)?(?:function|const|let|var)\s+([A-Za-z_$][\w$]*)", re.M
)
def _bound_names(source: str) -> set[str]:
"""Local names a module binds: imports plus top-level declarations."""
names = set(_TOP_LEVEL_DECL.findall(source))
for clause in _IMPORT_STATEMENT.findall(source):
clause = clause.strip()
if clause.startswith("{"):
names |= {
part.strip().split(" as ")[-1].strip()
for part in clause.strip("{}").split(",")
if part.strip()
}
else:
names.add(clause.split(" as ")[-1].strip())
return names
_REEXPORT = re.compile(r"^export\s*\{[^}]*\}\s*from\s*'[^']+';", re.M | re.S)
_WHOLE_LINE_COMMENT = re.compile(r"^\s*(?://|/\*|\*/|\*(?!/)).*$", re.M)
def _executable_body(source: str) -> str:
"""The part of a module that actually runs a name.
Imports are dropped because a name in an import clause is the binding, not a
use. Re-export lists go for the same reason. Whole-line comments go because
prose says things like "no shared state" and `state` is a real binding here;
only full lines are stripped, so a `//` inside a URL literal is left alone.
"""
ends = [m.end() for m in _IMPORT_STATEMENT.finditer(source)]
body = source[max(ends):] if ends else source
body = _REEXPORT.sub("", body)
return _WHOLE_LINE_COMMENT.sub("", body)
def test_no_module_uses_a_package_name_it_never_bound():
"""A name used but never imported is a runtime ReferenceError, nothing less.
It is invisible to `node --check`, which parses without resolving scope, and
invisible to loading the module, because the throw happens inside a function
body the loader never calls. It is also the single most likely mistake when
code moves between modules — the declaration stays behind and the use comes
along.
The vocabulary checked is the package's own: every name any module here
binds. That is narrow on purpose. It is not a general no-undef pass, so it
never has to model the browser's globals and never produces a false
positive; it catches exactly the shape a split produces.
"""
sources = {p.name: p.read_text(encoding="utf-8") for p in email_library_paths(True)}
vocabulary: set[str] = set()
for source in sources.values():
vocabulary |= _bound_names(source)
unbound: dict[str, list[str]] = {}
for name, source in sources.items():
bound = _bound_names(source)
body = _executable_body(source)
missing = sorted(
word
for word in vocabulary - bound
if re.search(r"(?<![\w$.])" + re.escape(word) + r"(?![\w$])", body)
)
if missing:
unbound[name] = missing
assert not unbound, (
"modules use names they neither declare nor import, which throws at call "
f"time and nowhere earlier: {unbound}"
)
def test_every_package_module_is_precached():
sw = _SW.read_text(encoding="utf-8")
for path in email_library_paths():
url = "/static/js/emailLibrary/" + path.name
assert f"'{url}'" in sw, (
f"{url} is not in the static/sw.js precache, so a panel that works "
"online will not open offline"
)
def test_every_package_module_evaluates_on_its_own_in_a_browser():
"""Import each module first, alone, and require it to evaluate.
Entering the package at a submodule is what turns an import cycle from
harmless into a `ReferenceError: cannot access '…' before initialization`.
Importing the entry module first would hide exactly that.
"""
urls = ["/static/js/emailLibrary.js"] + [
"/static/js/emailLibrary/" + p.name for p in email_library_paths()
]
script = r'''
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const urls = JSON.parse(process.env.ODYSSEUS_EMAIL_MODULE_URLS);
const browser = await chromium.launch({ headless: true });
const results = {};
for (const url of urls) {
const page = await browser.newPage();
// Same synthetic shell the other email browser tests use: these
// modules touch #toast and #sidebar while evaluating.
await page.goto(`${origin}/static/js/documentStats.js`);
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
results[url] = await page.evaluate(async (target) => {
try {
const mod = await import(target);
return { ok: true, exports: Object.keys(mod).sort() };
} catch (err) {
return { ok: false, error: String(err && err.message || err) };
}
}, url);
await page.close();
}
console.log(JSON.stringify(results));
await browser.close();
'''
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
env={
**__import__("os").environ,
"ODYSSEUS_EMAIL_MODULE_URLS": json.dumps(urls),
},
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
broken = {url: info["error"] for url, info in data.items() if not info["ok"]}
assert not broken, f"modules that failed to evaluate on their own: {broken}"
assert set(data) == set(urls)
def test_wrapper_and_entry_module_hand_out_the_same_functions():
"""Importing either path must give one live module instance.
Two instances would mean two copies of the panel's module state, and the
panel is a singleton keyed on DOM ids — the second copy would fight the
first over `#email-lib-modal`.
"""
script = r'''
import { chromium } from 'playwright';
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(`${origin}/static/js/documentStats.js`);
await page.setContent('<div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
const out = await page.evaluate(async () => {
const wrapper = await import('/static/js/emailLibrary.js');
const entry = await import('/static/js/emailLibrary/index.js');
const names = Object.keys(wrapper).sort();
return {
names,
identical: names.filter((n) => wrapper[n] === entry[n]),
callable: names.filter((n) => typeof wrapper[n] === 'function'),
};
});
console.log(JSON.stringify(out));
await browser.close();
'''
result = subprocess.run(
["node", "--input-type=module", "-e", script],
cwd=ROOT,
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["names"], "the wrapper exported nothing at runtime"
assert data["identical"] == data["names"], (
"the wrapper and the entry module handed out different objects for "
f"{sorted(set(data['names']) - set(data['identical']))}"
)
assert data["callable"] == data["names"], (
"not every export is callable: "
f"{sorted(set(data['names']) - set(data['callable']))}"
)
+3 -62
View File
@@ -4,76 +4,17 @@ import shutil
import subprocess
import pytest
from tests.helpers.js_modules import email_library_source, js_function_source
_REPO = Path(__file__).resolve().parents[1]
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
def _source() -> str:
return _EMAIL_LIBRARY.read_text(encoding="utf-8")
return email_library_source()
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):
+6 -6
View File
@@ -7,11 +7,11 @@ from pathlib import Path
import pytest
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_source, js_function_source
_REPO = Path(__file__).resolve().parent.parent
_INBOX_JS = _REPO / "static" / "js" / "emailInbox.js"
_LIBRARY_JS = _REPO / "static" / "js" / "emailLibrary.js"
_HAS_NODE = shutil.which("node") is not None
@@ -22,8 +22,8 @@ def _extract_between(source: str, signature: str, next_marker: str) -> str:
def test_library_unread_preview_has_one_authoritative_request_and_rollback():
source = _LIBRARY_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
source = email_library_source()
function = js_function_source("_toggleCardPreview", source)
assert function.count("/api/email/read/") == 1
assert "/api/email/mark-read/" not in function
@@ -39,8 +39,8 @@ 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 = _LIBRARY_JS.read_text(encoding="utf-8")
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
source = email_library_source()
function = js_function_source("_toggleCardPreview", source)
settlements = _extract_between(
function,
" const restoreUnreadState = () => {",
@@ -102,7 +102,7 @@ console.log(JSON.stringify({{
def test_library_reply_open_carries_immutable_mailbox_context():
library_source = _LIBRARY_JS.read_text(encoding="utf-8")
library_source = email_library_source()
inbox_source = _INBOX_JS.read_text(encoding="utf-8")
assert "const mailboxGeneration = _emailMailboxGeneration;" in library_source
@@ -1,12 +1,13 @@
from pathlib import Path
import re
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parent.parent
def test_email_folder_and_filter_pickers_are_exclusive_and_escape_safe():
source = (ROOT / "static/js/emailLibrary.js").read_text(encoding="utf-8")
source = email_library_source()
assert source.count("const filterMenu = document.getElementById('email-filter-menu');") == 1
assert source.count("const folderMenu = document.getElementById('email-folder-menu');") == 1
+2 -1
View File
@@ -1,12 +1,13 @@
from pathlib import Path
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parents[1]
def test_card_delete_waits_for_durable_success_and_uses_email_identity():
source = (ROOT / "static/js/emailLibrary.js").read_text()
source = email_library_source()
assert "function _emailMutationQuery(em" in source
assert "em?.folder || fallbackFolder" in source
assert "em?.account_id || state._libAccountId" in source
+7 -4
View File
@@ -8,6 +8,8 @@ from pathlib import Path
import pytest
from tests.helpers.document_source import document_source
from tests.helpers.js_modules import email_library_paths
import src.tool_capabilities as tool_capabilities
from src.tool_capabilities import (
KNOWN_CAPABILITY_TOOLS,
@@ -19,7 +21,6 @@ from src.tool_capabilities import (
messages_contain_external_untrusted_context,
tool_result_should_arm_gate,
)
from tests.helpers.document_source import document_source
ToolBlock = namedtuple("ToolBlock", ["tool_type", "content"])
@@ -1405,14 +1406,16 @@ def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
"static/js/chatRenderer.js",
"static/js/chatStream.js",
"static/js/emailInbox.js",
"static/js/emailLibrary.js",
"static/js/settings.js",
"static/js/slashCommands.js",
)
]
# The document editor is a module set, not one file: a stale version string
# must not be able to hide in a module extracted out of document.js.
# Both the document editor and email library are module sets.
approval_module_sources.append(document_source())
approval_module_sources.extend(
p.read_text()
for p in email_library_paths(include_wrapper=True)
)
assert all(
"20260722emailfastindex1" not in source
for source in approval_module_sources
@@ -2,6 +2,7 @@ from pathlib import Path
import re
from tests.helpers.stylesheets import app_css, stylesheet_cache_version
from tests.helpers.js_modules import email_library_source
ROOT = Path(__file__).resolve().parent.parent
@@ -71,7 +72,7 @@ def test_drawing_edits_mark_notes_dirty_and_keep_one_gallery_image():
def test_calendar_email_attachments_have_a_calendar_import_action():
email = (ROOT / "static" / "js" / "emailLibrary.js").read_text(encoding="utf-8")
email = email_library_source()
calendar = (ROOT / "static" / "js" / "calendar.js").read_text(encoding="utf-8")
assert "email-attachment-calendar-open" in email
+2 -2
View File
@@ -18,6 +18,7 @@ import importlib
from pathlib import Path
import pytest
from tests.helpers.js_modules import email_library_source
# ── prompt-injection context wrapper ────────────────────────────
@@ -1009,8 +1010,7 @@ def test_diagnostics_routes_are_admin_gated():
def test_email_thread_rendering_sanitizes_body_html():
"""Both threaded render paths must run server-parsed body_html through the
allowlist sanitizer (the flat path already did)."""
src = Path(__file__).resolve().parents[1] / "static" / "js" / "emailLibrary.js"
text = src.read_text()
text = email_library_source()
# every `t.body_html` reference is wrapped by _sanitizeHtml(...)
assert text.count("t.body_html") == text.count("_sanitizeHtml(t.body_html")
assert "t.body_html" in text # guard against the file being refactored away