mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 15:02:20 +02:00
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:
+3
-1
@@ -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.
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 (_) {}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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">✖</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">✖</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();
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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()]
|
||||
@@ -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,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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']))}"
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user