]*>|\\n)\\s*((?:<[^>]+>\\s*)*${FROM}\\s*:\\s*[^<\\n]+(?:<[^>]+>\\s*|\\s)*${SENT}\\s*:[\\s\\S]+?${SUBJ}\\s*:[\\s\\S]+)$`,
- 'i'
- );
- const m = html.match(outlookRe);
- if (m) {
- const idx = html.lastIndexOf(m[0]);
- // Outlook fallback only ever produces ONE fold, so tag it as last.
- html = html.slice(0, idx) + m[1]
- + '
'
- + _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2]))
- + m[2] + ' ';
- }
- return html;
-}
-
-
-// Global preference: AI summary panels stay collapsed across every email
-// once the user folds one, and stay expanded once they unfold. Stored in
-// localStorage so the choice survives reloads.
-const _SUMMARY_COLLAPSED_KEY = 'odysseus.email.summaryCollapsed';
-function _summaryCollapsedPref() {
- try { return localStorage.getItem(_SUMMARY_COLLAPSED_KEY) === '1'; } catch { return false; }
-}
-function _setSummaryCollapsedPref(v) {
- try { localStorage.setItem(_SUMMARY_COLLAPSED_KEY, v ? '1' : '0'); } catch {}
-}
-
-function _showCachedSummary(reader, summary, btn) {
- const body = reader.querySelector('.email-reader-body');
- if (!body) return;
- if (body.querySelector('.email-summary-panel')) return;
- const panel = document.createElement('div');
- panel.className = 'email-summary-panel';
- if (_summaryCollapsedPref()) panel.classList.add('collapsed');
- panel.innerHTML =
- ''
- + '
';
- panel.querySelector('.email-summary-content').textContent = summary;
- body.insertBefore(panel, body.firstChild);
- const toggle = panel.querySelector('.email-summary-toggle');
- // Header click folds/unfolds. Persists so the next email opens in the
- // same state.
- const _flip = () => {
- panel.classList.toggle('collapsed');
- _setSummaryCollapsedPref(panel.classList.contains('collapsed'));
- };
- if (toggle) {
- toggle.addEventListener('click', (ev) => { ev.stopPropagation(); _flip(); });
- toggle.addEventListener('keydown', (ev) => {
- if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _flip(); }
- });
- }
- if (btn) {
- btn.classList.add('active');
- const label = btn.querySelector('.btn-label');
- if (label) label.textContent = 'Summary';
- }
-}
-
-// Wire click handlers for attachment chips + "open in editor" sub-buttons
-// inside a reader. Safe to call multiple times — uses dataset.wired flag to
-// skip nodes that already have listeners.
-function _wireAttachmentHandlers(reader, folder) {
- const useFolder = folder || state._libFolder;
- // Detect mobile here so the attachment-chip handler doesn't blow up with
- // a ReferenceError when this fn is called from contexts that don't have
- // _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow).
- const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
- reader.querySelectorAll('.email-attachments-download-all').forEach(btn => {
- if (btn.dataset.wired === '1') return;
- btn.dataset.wired = '1';
- btn.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- ev.preventDefault();
- if (btn.dataset.downloading === '1') return;
- const uid = btn.dataset.attUid;
- const sourceFolder = btn.dataset.attFolder || useFolder;
- const count = Number(btn.dataset.attCount || 0);
- if (!uid) return;
- const originalHtml = btn.innerHTML;
- const originalTitle = btn.title;
- btn.dataset.downloading = '1';
- btn.classList.add('is-loading');
- try {
- const sp = window.spinnerModule || (await import('./spinner.js')).default;
- const wp = sp.createWhirlpool(12);
- wp.element.style.margin = '0';
- btn.textContent = '';
- btn.appendChild(wp.element);
- const label = document.createElement('span');
- label.textContent = 'All';
- btn.appendChild(label);
- } catch (_) {
- btn.textContent = 'All...';
- }
- try {
- const url = `${API_BASE}/api/email/attachments-download/${encodeURIComponent(uid)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
- const res = await fetch(url, { credentials: 'same-origin' });
- if (!res.ok) {
- const msg = await res.text().catch(() => '');
- console.error('attachments zip download failed', res.status, msg);
- location.href = url;
- return;
- }
- const blob = await res.blob();
- const blobUrl = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = blobUrl;
- a.download = `email-${uid}-attachments.zip`;
- document.body.appendChild(a);
- a.click();
- a.remove();
- setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
- try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {}
- } catch (e) {
- console.error('attachments zip download error', e);
- try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError('Could not download attachments'); } catch (_) {}
- } finally {
- delete btn.dataset.downloading;
- btn.classList.remove('is-loading');
- btn.title = originalTitle;
- btn.innerHTML = originalHtml;
- }
- });
- });
- reader.querySelectorAll('.email-attachment-calendar-open').forEach(openBtn => {
- if (openBtn.dataset.wired === '1') return;
- openBtn.dataset.wired = '1';
- openBtn.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- ev.preventDefault();
- if (openBtn.dataset.opening === '1') return;
- const uid = openBtn.dataset.openUid;
- const index = openBtn.dataset.openIndex;
- const name = openBtn.dataset.openName || `calendar-${index}`;
- const sourceFolder = openBtn.dataset.openFolder || useFolder;
- if (!uid || index == null) return;
- openBtn.dataset.opening = '1';
- openBtn.classList.add('is-loading');
- const originalHtml = openBtn.innerHTML;
- try {
- const wp = spinnerModule.createWhirlpool(12);
- wp.element.style.margin = '0';
- openBtn.textContent = '';
- openBtn.appendChild(wp.element);
- const folderQs = encodeURIComponent(sourceFolder);
- const attachmentUrl = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`;
- const attachmentRes = await fetch(attachmentUrl, { credentials: 'same-origin' });
- if (!attachmentRes.ok) throw new Error(`HTTP ${attachmentRes.status}`);
- const blob = await attachmentRes.blob();
- const fd = new FormData();
- fd.append('file', blob, name);
- const importRes = await fetch(`${API_BASE}/api/calendar/import`, {
- method: 'POST', body: fd, credentials: 'same-origin',
- });
- const result = await importRes.json().catch(() => ({}));
- if (!importRes.ok || !result.ok) {
- throw new Error(result.detail || result.error || `HTTP ${importRes.status}`);
- }
- const existingEventUid = Array.isArray(result.event_uids) ? String(result.event_uids[0] || '').trim() : '';
- if (existingEventUid && Number(result.imported || 0) === 0 && Number(result.skipped || 0) > 0) {
- _openCalendarEventFromEmail(existingEventUid);
- }
- try { uiModule.showToast && uiModule.showToast(`${result.imported || 0} event${result.imported === 1 ? '' : 's'} added to ${result.calendar || 'calendar'}`); } catch (_) {}
- window.dispatchEvent(new CustomEvent('calendar-refresh'));
- } catch (e) {
- console.error('calendar attachment import failed', e);
- try {
- const { showError } = await import('./ui.js?v=20260916largetoolscroll1');
- showError(`Couldn't add ${name} to calendar: ${e?.message || 'Import failed'}`);
- } catch (_) {}
- } finally {
- delete openBtn.dataset.opening;
- openBtn.classList.remove('is-loading');
- openBtn.innerHTML = originalHtml;
- }
- });
- });
- reader.querySelectorAll('.email-attachment-open').forEach(openBtn => {
- if (openBtn.dataset.wired === '1') return;
- openBtn.dataset.wired = '1';
- openBtn.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- ev.preventDefault();
- if (openBtn.dataset.opening === '1') return;
- const uid = openBtn.dataset.openUid;
- const index = openBtn.dataset.openIndex;
- const name = openBtn.dataset.openName || `attachment-${index}`;
- const sourceFolder = openBtn.dataset.openFolder || useFolder;
- if (!uid || index == null) return;
- openBtn.dataset.opening = '1';
- openBtn.classList.add('is-loading');
- const origHtml = openBtn.innerHTML;
- const wp = spinnerModule.createWhirlpool(12);
- wp.element.style.margin = '0';
- openBtn.textContent = '';
- openBtn.appendChild(wp.element);
- try {
- const folderQs = encodeURIComponent(sourceFolder);
- const controller = new AbortController();
- const timeout = setTimeout(() => controller.abort(), 55000);
- let res;
- try {
- res = await fetch(
- `${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`,
- { method: 'POST', credentials: 'same-origin', signal: controller.signal }
- );
- } finally {
- clearTimeout(timeout);
- }
- const json = await res.json().catch(() => ({}));
- if (!res.ok || !json.doc_id) {
- const msg = (json && json.error) || `HTTP ${res.status}`;
- try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError(`Couldn't open ${name}: ${msg}`); } catch (_) { alert(`Couldn't open ${name}: ${msg}`); }
- return;
- }
- try {
- // Tab the email modal down only when the viewport cannot fit both
- // Email and the document pane. Desktop keeps a side-by-side layout
- // when there is room; mobile still gives the document the screen.
- const ownerModal = openBtn.closest('.modal');
- if (ownerModal && ownerModal.id && _prepareEmailWindowForDocument(ownerModal)) {
- try {
- const ok = Modals.minimize(ownerModal.id);
- if (!ok) ownerModal.classList.add('hidden');
- } catch (_) {
- ownerModal.classList.add('hidden');
- }
- }
- const docMod = await import('./document.js?v=20260916docctx2');
- const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
- if (typeof load === 'function') {
- await load(json.doc_id);
- } else {
- location.href = `/?doc=${encodeURIComponent(json.doc_id)}`;
- }
- } catch (e) {
- console.error('Open document failed:', e);
- try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError('Document opened but panel could not mount'); } catch (_) {}
- }
- } catch (e) {
- console.error('attachment-as-doc error', e);
- const msg = e && e.name === 'AbortError'
- ? `Opening ${name} timed out. Try downloading it instead.`
- : `Couldn't open ${name}`;
- try { const { showError } = await import('./ui.js?v=20260916largetoolscroll1'); showError(msg); } catch (_) {}
- } finally {
- delete openBtn.dataset.opening;
- openBtn.classList.remove('is-loading');
- openBtn.innerHTML = origHtml;
- }
- });
- });
-
- reader.querySelectorAll('.email-attachment-download').forEach(downloadBtn => {
- if (downloadBtn.dataset.wired === '1') return;
- downloadBtn.dataset.wired = '1';
- downloadBtn.addEventListener('click', (ev) => {
- ev.stopPropagation();
- ev.preventDefault();
- const chip = downloadBtn.closest('.email-attachment-chip');
- if (!chip) return;
- // Reuse the established attachment download handler below. Marking the
- // chip expanded bypasses its old first-click reveal behavior.
- chip.classList.add('is-expanded');
- chip.dataset.downloadTrigger = 'button';
- chip.click();
- });
- downloadBtn.addEventListener('keydown', (ev) => {
- if (ev.key !== 'Enter' && ev.key !== ' ') return;
- ev.preventDefault();
- downloadBtn.click();
- });
- });
-
- reader.querySelectorAll('.email-attachment-chip').forEach(chip => {
- if (chip.dataset.wired === '1') return;
- chip.dataset.wired = '1';
- chip.addEventListener('click', async (ev) => {
- if (ev.target.closest('.email-attachment-open, .email-attachment-calendar-open, .email-attachment-download')) return;
- ev.stopPropagation();
- ev.preventDefault();
- const uid = chip.dataset.attUid;
- const index = chip.dataset.attIndex;
- const name = chip.dataset.attName || `attachment-${index}`;
- const sourceFolder = chip.dataset.attFolder || useFolder;
- if (!uid || index == null) return;
- if (!chip.classList.contains('is-expanded')) {
- reader.querySelectorAll('.email-attachment-chip.is-expanded').forEach(other => {
- if (other !== chip) other.classList.remove('is-expanded');
- });
- chip.classList.add('is-expanded');
- return;
- }
- const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
- if (_isMobileUA) {
- delete chip.dataset.downloadTrigger;
- window.open(url, '_blank');
- return;
- }
- // Swap the paperclip icon for a whirlpool spinner while the
- // download is in flight, so large attachments give a clear cue
- // they're loading. Restore on completion.
- const buttonTriggered = chip.dataset.downloadTrigger === 'button';
- const iconSvg = buttonTriggered
- ? chip.querySelector('.email-attachment-download > svg')
- : chip.querySelector(':scope > svg');
- const origIconHtml = iconSvg ? iconSvg.outerHTML : '';
- let _wp = null;
- let _spinnerHost = null;
- try {
- const sp = window.spinnerModule || (await import('./spinner.js')).default;
- _wp = sp.createWhirlpool(12);
- _spinnerHost = document.createElement('span');
- _spinnerHost.className = 'email-attachment-spinner';
- _spinnerHost.style.cssText = 'display:inline-flex;width:12px;height:12px;align-items:center;justify-content:center;flex-shrink:0;position:relative;top:-2px;';
- _spinnerHost.appendChild(_wp.element);
- if (iconSvg) iconSvg.replaceWith(_spinnerHost);
- } catch (_) {}
- const origOpacity = chip.style.opacity;
- chip.style.opacity = '0.85';
- try {
- const res = await fetch(url, { credentials: 'same-origin' });
- if (!res.ok) {
- console.error('attachment download failed', res.status, await res.text().catch(() => ''));
- location.href = url;
- return;
- }
- const blob = await res.blob();
- const blobUrl = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = blobUrl;
- a.download = name;
- document.body.appendChild(a);
- a.click();
- a.remove();
- setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
- } catch (e) {
- console.error('attachment download error', e);
- location.href = url;
- } finally {
- delete chip.dataset.downloadTrigger;
- chip.style.opacity = origOpacity;
- if (_spinnerHost && _spinnerHost.parentNode && origIconHtml) {
- const tmp = document.createElement('div');
- tmp.innerHTML = origIconHtml;
- const restored = tmp.firstChild;
- if (restored) _spinnerHost.replaceWith(restored);
- }
- if (_wp) { try { _wp.destroy(); } catch (_) {} }
- }
- });
- });
-}
-
-// Heuristic: skip "attachments" that are clearly inline images used by
-// signatures / quoted-reply headers (small image files, Outlook-style
-// image001.png placeholders, logo*.png, etc.). They aren't real user-
-// shared attachments and adding them to the chips makes every email look
-// like it has content the user needs to act on.
-function _isLikelySignatureImage(a) {
- if (!a || !a.filename) return false;
- const name = String(a.filename).toLowerCase();
- const isImage = /\.(png|jpe?g|gif|bmp|svg|webp)$/i.test(name);
- if (!isImage) return false;
- const size = Number(a.size) || 0;
- // Outlook / Gmail inline image placeholders always look like this.
- if (/^image\d{3,}\.(png|jpe?g|gif)$/i.test(name)) return true;
- if (/^(signature|logo|sig|footer|banner)[-_\d]*\.(png|jpe?g|gif|svg)$/i.test(name)) return true;
- // Most signature logos / inline thumbnails are < 30 KB. Real user-
- // shared images (screenshots, photos) are typically 50 KB+.
- if (size > 0 && size < 30 * 1024) return true;
- return false;
-}
-
-// Build the attachments header+chips HTML for an email read response. Pulled
-// out so both the initial-open and the swap-reader paths can render it.
-function _buildAttsHtmlFor(uid, data) {
- if (!data) return '';
- const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown|eml)$/i;
- const _CALENDAR_RE = /\.(calendar|ics|ical)$/i;
- const currentAttachments = Array.isArray(data.attachments) ? data.attachments : [];
- const relatedAttachments = Array.isArray(data.related_attachments) ? data.related_attachments : [];
- if (!currentAttachments.length && !relatedAttachments.length) return '';
- const visible = currentAttachments.filter(a => !_isLikelySignatureImage(a));
- const hidden = currentAttachments.filter(a => _isLikelySignatureImage(a));
- const related = relatedAttachments.filter(a => !_isLikelySignatureImage(a));
- if (!visible.length && !related.length && state._libViewInlineImages === false) return '';
- const renderChip = (a, extraClass = '') => {
- const calendarAttachment = _CALENDAR_RE.test(a.filename || '');
- const openable = _OPENABLE_RE.test(a.filename || '');
- const chipUid = a.source_uid || a.uid || uid;
- const chipFolder = a.source_folder || data.folder || state._libFolder || 'INBOX';
- const openBtn = calendarAttachment
- ? `
`
- : openable
- ? `
`
- : '';
- const downloadBtn = `
`;
- return ``;
- };
- const chips = visible.map(a => renderChip(a)).join('');
- const hiddenChips = hidden.map(a => renderChip(a, ' email-attachment-chip-muted')).join('');
- const relatedChips = related.map(a => renderChip(a, ' email-attachment-chip-related')).join('');
- const visibleSection = visible.length
- ? '
' + chips + '
'
- : '';
- const relatedSection = related.length
- ? '
From earlier in this thread
' + relatedChips + '
'
- : '';
- const hiddenSection = hidden.length && state._libViewInlineImages !== false
- ? '
Filtered inline images / signature files
' + hiddenChips + '
'
- : '';
- const label = visible.length
- ? `Attachments (${visible.length + related.length})`
- : related.length
- ? `Thread attachments (${related.length})`
- : state._libViewInlineImages === false
- ? 'Attachments'
- : `Hidden inline attachments (${hidden.length})`;
- const startCollapsed = !visible.length && !related.length;
- const downloadAllBtn = visible.length > 4
- ? `
`
- : '';
- return (
- `
`
- + ''
- + visibleSection
- + relatedSection
- + hiddenSection
- + '
'
- );
-}
-
-async function _ensureEmailAttachmentData(uid, folder, data, knownHasAttachments = false) {
- if (!data) return data;
- const current = Array.isArray(data.attachments) ? data.attachments : [];
- const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
- const shouldFetch = data.attachments_deferred || (knownHasAttachments && !current.length && !related.length);
- if (!shouldFetch) return data;
- try {
- const metaRes = await fetch(`${API_BASE}/api/email/attachments/${encodeURIComponent(uid)}?folder=${encodeURIComponent(folder || 'INBOX')}${_acct()}`);
- const meta = await metaRes.json().catch(() => ({}));
- if (metaRes.ok && Array.isArray(meta.attachments)) {
- return {
- ...data,
- attachments: meta.attachments,
- related_attachments: related,
- attachments_deferred: false,
- };
- }
- } catch (_) {}
- // Do not full-fetch the message during ordinary open. That can download and
- // parse large MIME bodies immediately after the reader renders, keeping IMAP
- // busy and making the UI feel stuck. Full attachment lookup is still available
- // from explicit attachment/download/forward actions.
- return data;
-}
-
-function _wireEmailAttachmentWrap(reader, folder) {
- if (!reader) return;
- const attsWrap = reader.querySelector('.email-reader-atts-wrap');
- if (attsWrap && !attsWrap.dataset.wired) {
- attsWrap.dataset.wired = '1';
- const attsToggle = attsWrap.querySelector('.email-reader-atts-header');
- if (attsToggle) {
- attsToggle.addEventListener('click', (ev) => {
- ev.stopPropagation();
- attsWrap.classList.toggle('collapsed');
- });
- attsToggle.addEventListener('keydown', (ev) => {
- if (ev.key === 'Enter' || ev.key === ' ') {
- ev.preventDefault();
- attsWrap.classList.toggle('collapsed');
- }
- });
- }
- }
- try { _wireAttachmentHandlers(reader, folder); } catch {}
-}
-
-function _loadDeferredAttachmentsIntoReader(reader, uid, folder, data, knownHasAttachments = false) {
- if (!reader || !uid || !data) return;
- const current = Array.isArray(data.attachments) ? data.attachments : [];
- const related = Array.isArray(data.related_attachments) ? data.related_attachments : [];
- if (!data.attachments_deferred && !(knownHasAttachments && !current.length && !related.length)) return;
- const body = reader.querySelector('.email-reader-body');
- if (!body) return;
- const loading = document.createElement('div');
- loading.className = 'email-reader-atts-wrap email-reader-atts-loading';
- loading.style.cssText = 'min-height:34px;display:flex;flex-direction:row;align-items:center;gap:7px;padding:6px 14px;color:var(--fg);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;opacity:.7;';
- const loadingSpinner = spinnerModule.createWhirlpool(12);
- loadingSpinner.element.style.margin = '0';
- loading.appendChild(loadingSpinner.element);
- loading.appendChild(document.createTextNode('Loading attachments…'));
- const existingWrap = reader.querySelector('.email-reader-atts-wrap');
- if (existingWrap) existingWrap.replaceWith(loading);
- else body.insertAdjacentElement('beforebegin', loading);
- _ensureEmailAttachmentData(uid, folder, data, knownHasAttachments).then(fullData => {
- if (!reader.isConnected || !fullData) return;
- loading.remove();
- try { loadingSpinner.destroy(); } catch (_) {}
- const visibleCurrent = (fullData.attachments || []).filter(a => !_isLikelySignatureImage(a));
- const matchingEmail = (state._libEmails || []).find(em => (
- String(em.uid) === String(uid)
- && String(em.account_id || state._libAccountId || '') === String(reader.closest('[data-email-account]')?.dataset.emailAccount || state._libAccountId || '')
- ));
- if (matchingEmail) matchingEmail.has_attachments = visibleCurrent.length > 0;
- if (!visibleCurrent.length) {
- reader.closest('.doclib-card')?.querySelector('.email-card-attachment')?.remove();
- }
- const attsHtml = _buildAttsHtmlFor(uid, fullData);
- if (!attsHtml) return;
- const oldWrap = reader.querySelector('.email-reader-atts-wrap');
- if (oldWrap) {
- const tmp = document.createElement('div');
- tmp.innerHTML = attsHtml;
- oldWrap.replaceWith(tmp.firstElementChild);
- } else {
- body.insertAdjacentHTML('beforebegin', attsHtml);
- }
- _wireEmailAttachmentWrap(reader, folder);
- }).catch(() => {
- loading.remove();
- try { loadingSpinner.destroy(); } catch (_) {}
- });
-}
-
-// "Open in new tab" — the email opens in the library (expanded inline)
-// AND a separate floating "email viewer" overlay modal is created. The
-// overlay starts minimized as a chip in the dock; tapping the chip
-// brings the viewer up over the library. Multiple tabs = multiple
-// overlay modals + chips, each independent.
-const _EMAIL_ICON_PATH = 'M2 4h20v16H2zM22 7l-9.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7';
-let _emailTabSeq = 0;
-// Persistent slot numbers per reader modalId. Once a reader is "tab 2"
-// it stays "tab 2" until it's closed — even if tab 1 closes first, the
-// remaining reader doesn't renumber down to 1. New tabs claim the
-// lowest unused slot.
-const _emailReaderSlots = new Map(); // modalId -> slot (1, 2, 3, ...)
-function _allocReaderSlot(modalId) {
- if (_emailReaderSlots.has(modalId)) return _emailReaderSlots.get(modalId);
- const used = new Set(_emailReaderSlots.values());
- let n = 1;
- while (used.has(n)) n++;
- _emailReaderSlots.set(modalId, n);
- return n;
-}
-function _freeReaderSlot(modalId) {
- _emailReaderSlots.delete(modalId);
-}
-
-// JS-driven gate: sets [data-email-tabs="N"] on so CSS can show
-// the per-chip number badge only when 2+ tabs exist.
-function _syncEmailTabsCount() {
- const tabs = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-view-"]');
- document.body.dataset.emailTabs = String(tabs.length);
-}
-
-// Recompute the email menu chip's tab-count whenever the dock contents
-// change. Counts "email-view-*" chips both inside #minimized-dock and
-// at body level (free-positioned chips on mobile). Result is written to
-// the email-lib-modal chip's data-tab-count attribute; CSS reads it via
-// attr() to render the badge.
-function _syncEmailTabBadge() {
- const readers = document.querySelectorAll('.minimized-dock-chip[data-modal-id^="email-reader-"]');
- document.body.dataset.emailReaders = String(readers.length);
- // Stamp each chip with its persistent slot number. CSS reads
- // data-tab-num via attr() instead of using a counter so the number
- // stays stable when other tabs close.
- readers.forEach(chip => {
- const slot = _emailReaderSlots.get(chip.dataset.modalId);
- if (slot) chip.dataset.tabNum = String(slot);
- });
-}
-let _emailTabObserverWired = false;
-let _badgeSyncScheduled = false;
-function _ensureEmailTabObserver() {
- if (_emailTabObserverWired) return;
- _emailTabObserverWired = true;
- // Debounce so a burst of mutations (e.g. _renderDock rebuilding the
- // whole dock in one pass) collapses to a single sync per animation
- // frame. Without this the chip badge could flicker as the observer
- // fires repeatedly during dock rerenders.
- const handler = () => {
- if (_badgeSyncScheduled) return;
- _badgeSyncScheduled = true;
- requestAnimationFrame(() => {
- _badgeSyncScheduled = false;
- _syncEmailTabBadge();
- });
- };
- const tryWire = () => {
- const dock = document.getElementById('minimized-dock');
- if (!dock) { setTimeout(tryWire, 200); return; }
- // Only watch what we care about: chip add/remove in the dock.
- const obs = new MutationObserver(handler);
- obs.observe(dock, { childList: true });
- // Watch the library grid so toggling a card expanded/collapsed
- // updates the lib chip's "has-expanded" badge in real time.
- const wireGridObs = () => {
- const grid = document.getElementById('email-lib-grid');
- if (!grid) { setTimeout(wireGridObs, 500); return; }
- const gridObs = new MutationObserver(handler);
- gridObs.observe(grid, { subtree: true, attributes: true, attributeFilter: ['class'] });
- };
- wireGridObs();
- handler();
- };
- tryWire();
-}
-// Hybrid model:
-// - email-lib-modal (the inbox library) is unique. Its chip just
-// restores it.
-// - Each "Open in new tab" creates a separate per-email reader modal
-// (id "email-reader-{uid}-{seq}") with the SAME structure & classes
-// as the library's inline reader, so they look identical. Each
-// reader registers its own dock chip with a number badge.
-async function _openEmailAsTab(em, folder) {
- const useFolder = folder || state._libFolder || 'INBOX';
- _emailTabSeq += 1;
- const modalId = `email-reader-${em.uid}-${_emailTabSeq}`;
- _allocReaderSlot(modalId);
-
- // Build the modal shell. Uses the same doclib-modal-content sizing
- // as the email library so it feels like a sibling window. The reader
- // body inside uses the exact same email-card-reader / email-reader-*
- // classes the inline reader uses → identical styling.
- const modal = document.createElement('div');
- modal.className = 'modal email-reader-tab-modal';
- modal.id = modalId;
- modal.innerHTML = `
-
- `;
- document.body.appendChild(modal);
- // Inherit display from .modal (flex-center). z-index above the library
- // (which uses default .modal z-index 250) so the new tab sits on top.
- modal.style.zIndex = '270';
- // Opened last → email windows in front of any open doc (alternation flag).
- document.body.classList.add('email-front');
-
- Modals.register(modalId, {
- label: 'Email',
- icon: _EMAIL_ICON_PATH,
- closeFn: () => {
- modal.remove();
- _freeReaderSlot(modalId);
- Promise.resolve().then(_syncEmailTabBadge);
- },
- restoreFn: () => {
- // Reopened last → bring the email windows in front of any open doc.
- document.body.classList.add('email-front');
- // Mobile: only one email window visible at a time. Tapping this
- // chip chips down the library + any other reader, so the user
- // toggles between them via the dock instead of stacking.
- if (window.innerWidth <= 768) {
- try {
- if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
- Modals.minimize('email-lib-modal');
- }
- } catch {}
- document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
- if (other.id === modalId) return;
- try {
- if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
- Modals.minimize(other.id);
- }
- } catch {}
- });
- }
- },
- });
- // Wire the `_` minimize button via modalManager (it sees our .minimize-btn
- // already exists and just binds the click handler).
- try { Modals.injectMinimizeButton(modal, modalId); } catch {}
- // X button fully closes the tab (tears down and unregisters).
- modal.querySelector('.close-btn')?.addEventListener('click', (ev) => {
- ev.stopPropagation();
- Modals.close(modalId);
- });
-
- // Wire dragging on the header (desktop only). Matches the global pattern
- // in app.js initUIVisibility, but that runs once at boot and doesn't see
- // dynamically-created modals — so we replicate it here.
- const content = modal.querySelector('.modal-content');
- const mh = modal.querySelector('.modal-header');
- if (mh && content) {
- let dragX = 0, dragY = 0, startLeft = 0, startTop = 0, dragging = false;
- const startDrag = (clientX, clientY) => {
- dragging = true;
- const rect = content.getBoundingClientRect();
- dragX = clientX; dragY = clientY;
- startLeft = rect.left; startTop = rect.top;
- content.style.position = 'fixed';
- content.style.left = startLeft + 'px';
- content.style.top = startTop + 'px';
- content.style.margin = '0';
- };
- const onDrag = (e) => {
- if (!dragging) return;
- content.style.left = (startLeft + e.clientX - dragX) + 'px';
- content.style.top = (startTop + e.clientY - dragY) + 'px';
- };
- const stopDrag = () => {
- dragging = false;
- document.removeEventListener('mousemove', onDrag);
- document.removeEventListener('mouseup', stopDrag);
- };
- mh.addEventListener('mousedown', (e) => {
- if (e.target.closest('.close-btn, .minimize-btn, .modal-minimize-btn')) return;
- e.preventDefault();
- startDrag(e.clientX, e.clientY);
- document.addEventListener('mousemove', onDrag);
- document.addEventListener('mouseup', stopDrag);
- });
- }
-
- // Open the new tab in front, on top of the email library. The user
- // can tap `_` to tab it down to a chip when they're done reading.
- //
- // Mobile: bottom-sheet windows fill the viewport, so stacking multiple
- // readers on top of each other is confusing — only one window can be
- // meaningfully visible at a time. So when the new tab opens, chip down
- // the library AND any other email-reader-* tab that's currently up.
- // The user gets a stack of mini chips to toggle between them.
- if (window.innerWidth <= 768) {
- try {
- if (Modals.isRegistered('email-lib-modal') && !Modals.isMinimized('email-lib-modal')) {
- Modals.minimize('email-lib-modal');
- }
- } catch {}
- document.querySelectorAll('.modal[id^="email-reader-"]').forEach(other => {
- if (other.id === modalId) return;
- try {
- if (Modals.isRegistered(other.id) && !Modals.isMinimized(other.id)) {
- Modals.minimize(other.id);
- }
- } catch {}
- });
- }
- _ensureEmailTabObserver();
- _syncEmailTabBadge();
-
- // Fetch + render the email body using the exact same template as
- // _toggleCardPreview so the visuals match perfectly.
- const reader = modal.querySelector('.email-card-reader');
- const showFailedTab = (message) => {
- try { showToast(message || 'Failed to load email'); } catch (_) {}
- _showEmailReaderLoadError(reader, message, () => {
- try { Modals.close(modalId); } catch (_) { try { modal.remove(); } catch (_) {} }
- setTimeout(() => { _openEmailAsTab(em, useFolder); }, 0);
- });
- };
- _markEmailReaderActive(reader);
- const loading = modal.querySelector('.email-reader-tab-loading');
- if (loading) loading.remove();
- if (reader) {
- reader.classList.add('email-card-reader-loading');
- reader.innerHTML = _emailReaderSkeletonHtml();
- }
- try {
- const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- let data = await res.json();
- if (data.error) {
- showFailedTab(`Failed to load email: ${data.error}`);
- return;
- }
- data = _normalizeEmailStateFlags({ ...em, ...data });
- Object.assign(em, data);
- _syncEmailDoneState(em.uid, data.is_answered);
- _syncEmailReadState(em.uid, true);
- _stampReaderContext(reader, data, useFolder, state._libAccountId);
- const buildChips = (str) => {
- if (!str) return '';
- return _splitRecipientList(str).map(a => {
- const name = _extractName(a);
- return _recipientChipHtml(a, name);
- }).join('');
- };
- const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
- let attsHtml = '';
- try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
- reader.innerHTML = `
-
- ${attsHtml}
-
${_safeRenderEmailBody(data)}
- `;
- _markEmailReaderActive(reader);
- reader.classList.remove('email-card-reader-loading');
- _wireRecipientChips(reader);
- _wireEmailAttachmentWrap(reader, useFolder);
- _wireEmailInlineImages(reader);
- _loadDeferredAttachmentsIntoReader(reader, em.uid, useFolder, data, !!em.has_attachments);
- _maybeAutoTranslateEmail(reader);
- reader.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
- });
- reader.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
- });
- reader.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
- reader.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
- });
- reader.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- try { await _summarizeEmail(reader, data, ev.currentTarget); } catch {}
- });
- _wireMetaToggle(reader);
- _wireReaderActionOverflow(reader);
- reader.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
- ev.stopPropagation();
- try { _showReaderMoreMenu(em, modal, reader, ev.currentTarget, data); } catch {}
- });
- } catch (err) {
- showFailedTab(err?.message ? `Failed to load email: ${err.message}` : 'Failed to load email');
- }
-}
-
-
-// "Open in new window" — spawns a floating draggable modal that shows just
-// the email content. Multiple windows can stack; each has its own DOM id
-// and close button. Uses `_makeDraggable` so dragging the header pans the
-// window around. Renders the body via _renderEmailBody for parity with the
-// expanded reader.
-let _emailWindowSeq = 0;
-async function _openEmailWindow(em, folder) {
- const useFolder = folder || state._libFolder || 'INBOX';
- _emailWindowSeq += 1;
- const winId = `email-window-${em.uid}-${_emailWindowSeq}`;
- const modal = document.createElement('div');
- modal.className = 'modal email-window-modal';
- modal.id = winId;
- modal.style.cssText = 'pointer-events:none;background:transparent;';
- modal.innerHTML = `
-
- `;
- document.body.appendChild(modal);
- modal.style.display = 'block';
- const content = modal.querySelector('.modal-content');
- // Position offset from screen center so successive windows cascade.
- const isMobile = window.innerWidth <= 768;
- if (isMobile) {
- content.style.position = 'fixed';
- content.style.pointerEvents = 'auto';
- content.style.left = '0';
- content.style.right = '0';
- content.style.bottom = '0';
- content.style.top = 'auto';
- } else {
- content.style.position = 'fixed';
- content.style.pointerEvents = 'auto';
- requestAnimationFrame(() => {
- const w = content.offsetWidth, h = content.offsetHeight;
- const off = (_emailWindowSeq % 6) * 28;
- content.style.left = Math.max(20, (window.innerWidth - w) / 2 + off) + 'px';
- content.style.top = Math.max(20, (window.innerHeight - h) / 3 + off) + 'px';
- });
- }
- modal.querySelector('.close-btn')?.addEventListener('click', () => modal.remove());
- try { _makeDraggable(content, modal, 'email-window-fullscreen'); } catch {}
-
- // Load + render
- const bodyEl = modal.querySelector('.email-window-body');
- const loading = modal.querySelector('.email-window-loading');
- try {
- if (loading) loading.remove();
- if (bodyEl) {
- bodyEl.classList.add('email-card-reader', 'email-card-reader-loading');
- bodyEl.style.padding = '0';
- bodyEl.innerHTML = _emailReaderSkeletonHtml();
- }
- const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(useFolder)}${_acct()}`);
- let data = await res.json();
- if (data.error) {
- bodyEl.innerHTML = `
${_esc(data.error)}
`;
- return;
- }
- data = _normalizeEmailStateFlags({ ...em, ...data });
- Object.assign(em, data);
- _syncEmailDoneState(em.uid, data.is_answered);
- _syncEmailReadState(em.uid, true);
- const subjEl = modal.querySelector('.email-window-subject');
- if (subjEl && data.subject) subjEl.textContent = data.subject;
- // Build recipient chips the same way the inline reader does so the
- // standalone viewer looks/feels exactly like a real email view.
- const _chipsFor = (addrs) => {
- if (!addrs) return '';
- const list = _splitRecipientList(addrs);
- return list.map(a => {
- const name = _extractName(a);
- return _recipientChipHtml(a, name);
- }).join('');
- };
- const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
- let attsHtml = '';
- try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
- // Repurpose bodyEl as a full email-card-reader so the inline reader's
- // CSS applies (sized header, action buttons in two rows, etc.).
- bodyEl.classList.add('email-card-reader');
- bodyEl.classList.remove('email-card-reader-loading');
- _stampReaderContext(bodyEl, { ...em, ...data }, useFolder, state._libAccountId);
- _markEmailReaderActive(bodyEl);
- bodyEl.style.padding = '0';
- bodyEl.innerHTML = `
-
- ${attsHtml}
-
${_safeRenderEmailBody(data)}
- `;
- _markEmailReaderActive(bodyEl);
- _wireRecipientChips(bodyEl);
- // Wire all the same action handlers the inline reader has.
- _wireEmailAttachmentWrap(bodyEl, useFolder);
- _wireEmailInlineImages(bodyEl);
- _loadDeferredAttachmentsIntoReader(bodyEl, em.uid, useFolder, data, !!em.has_attachments);
- _maybeAutoTranslateEmail(bodyEl);
- bodyEl.querySelector('[data-act="reply"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply' });
- });
- bodyEl.querySelector('[data-act="reply-all"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- _snapEmailModalToLeftSidebar(ev.currentTarget.closest('.modal'));
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'reply-all' });
- });
- bodyEl.querySelector('[data-act="ai-reply"]')?.addEventListener('click', (ev) => _handleAiReplyButton(ev, em, data));
- bodyEl.querySelector('[data-act="forward"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- if (state._onEmailClick) await state._onEmailClick({ email: em, emailData: data, mode: 'forward' });
- });
- bodyEl.querySelector('[data-act="summarize"]')?.addEventListener('click', async (ev) => {
- ev.stopPropagation();
- try { await _summarizeEmail(bodyEl, data, ev.currentTarget); } catch {}
- });
- _wireMetaToggle(bodyEl);
- _wireReaderActionOverflow(bodyEl);
- bodyEl.querySelector('[data-act="more"]')?.addEventListener('click', (ev) => {
- ev.stopPropagation();
- // Use a synthetic "card" — the more-menu only needs the anchor
- // element and the email data. The card param is mostly used to find
- // the next sibling; the standalone window has none so we just pass
- // bodyEl as a stand-in.
- try { _showReaderMoreMenu(em, modal, bodyEl, ev.currentTarget, data); } catch {}
- });
- } catch (err) {
- bodyEl.innerHTML = `
Failed to load: ${_esc(String(err))}
`;
- }
-}
-
-async function _summarizeEmail(reader, data, btn) {
- const body = reader.querySelector('.email-reader-body');
- if (!body) return;
-
- // If a summary panel already exists, toggle: hide/show
- const existing = body.querySelector('.email-summary-panel');
- if (existing) {
- if (existing.style.display === 'none') {
- existing.style.display = '';
- if (btn) {
- btn.classList.add('active');
- btn.querySelector('.btn-label').textContent = 'Summary';
- }
- } else {
- existing.style.display = 'none';
- if (btn) {
- btn.classList.remove('active');
- btn.querySelector('.btn-label').textContent = 'Summary';
- }
- }
- return;
- }
-
- // No panel yet. If the email has no cached AI summary, show a placeholder
- // "not generated — create now?" prompt instead of firing the LLM immediately.
- // This avoids accidental LLM spend and makes the state explicit to the user.
- if (!data.cached_summary) {
- const prompt = document.createElement('div');
- prompt.className = 'email-summary-panel';
- prompt.innerHTML = `
-
-
No AI summary generated.
`;
- body.insertBefore(prompt, body.firstChild);
- if (btn) {
- btn.classList.add('active');
- const label = btn.querySelector('.btn-label');
- if (label) label.textContent = 'Summary';
- }
- // No Cancel button — toggling the Summary button again hides this panel
- // (handled by the existing-panel branch above), so it'd be redundant.
- prompt.querySelector('[data-act="summary-generate"]').addEventListener('click', async (ev) => {
- ev.stopPropagation();
- prompt.remove();
- await _generateSummary(reader, data, btn);
- });
- return;
- }
-
- // Cached summary exists — show it immediately.
- await _generateSummary(reader, data, btn);
-}
-
-async function _generateSummary(reader, data, btn) {
- const body = reader.querySelector('.email-reader-body');
- if (!body) return;
-
- const panel = document.createElement('div');
- panel.className = 'email-summary-panel';
- panel.innerHTML =
- ''
- + '
';
- if (_summaryCollapsedPref()) panel.classList.add('collapsed');
- body.insertBefore(panel, body.firstChild);
- const _genToggle = panel.querySelector('.email-summary-toggle');
- if (_genToggle) {
- const _genFlip = () => {
- panel.classList.toggle('collapsed');
- _setSummaryCollapsedPref(panel.classList.contains('collapsed'));
- };
- _genToggle.addEventListener('click', (ev) => { ev.stopPropagation(); _genFlip(); });
- _genToggle.addEventListener('keydown', (ev) => {
- if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); _genFlip(); }
- });
- }
-
- const sp = spinnerModule.createWhirlpool(18);
- const content = panel.querySelector('.email-summary-content');
- content.appendChild(sp.element);
-
- if (btn) btn.disabled = true;
- try {
- const res = await fetch(`${API_BASE}/api/email/summarize`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- body: data.body,
- subject: data.subject,
- from: `${data.from_name} <${data.from_address}>`,
- // Send identifiers so the backend can fetch the raw message and
- // pull attachment text for the summary (PDFs, invoices, etc.).
- uid: data.uid || '',
- folder: state._libFolder || 'INBOX',
- message_id: data.message_id || '',
- account_id: data.account_id || '',
- }),
- });
- const result = await res.json();
- sp.destroy();
- content.innerHTML = '';
- if (result.success && result.summary) {
- content.textContent = result.summary;
- if (btn) {
- btn.classList.add('active');
- const label = btn.querySelector('.btn-label');
- if (label) label.textContent = 'Summary';
- }
- } else {
- _renderEmailSummaryError(content, result);
- }
- } catch (e) {
- sp.destroy();
- _renderEmailSummaryError(content, null);
- if (uiModule) uiModule.showError?.('Failed to summarize');
- } finally {
- if (btn) btn.disabled = false;
- }
-}
-
-function _emailBodyTextForTranslate(reader) {
- const body = reader?.querySelector?.('.email-reader-body');
- if (!body) return '';
- const clone = body.cloneNode(true);
- clone.querySelectorAll('.email-summary-panel, details.email-quote-fold, details.email-sig-fold').forEach(n => n.remove());
- return (clone.innerText || clone.textContent || '').trim();
-}
-
-async function _translateEmail(reader, language, opts = {}) {
- const body = reader?.querySelector?.('.email-reader-body');
- if (!body) return;
- const existing = body.querySelector('.email-translation-panel');
- if (existing) {
- if (opts.auto && !opts.force) return;
- existing.remove();
- }
- const targetLanguage = language || 'English';
- const sourceText = _emailBodyTextForTranslate(reader);
- if (!sourceText) {
- try { uiModule?.showError?.('No email body to translate'); } catch {}
- return;
- }
-
- body.querySelectorAll('.email-translation-panel').forEach(p => p.remove());
- const panel = document.createElement('div');
- panel.className = 'email-summary-panel email-translation-panel';
- panel.innerHTML =
- ''
- + '
Translating...
';
- body.insertBefore(panel, body.firstChild);
- const translationToggle = panel.querySelector('.email-summary-toggle');
- if (translationToggle) {
- const flip = () => panel.classList.toggle('collapsed');
- translationToggle.addEventListener('click', (ev) => { ev.stopPropagation(); flip(); });
- translationToggle.addEventListener('keydown', (ev) => {
- if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); flip(); }
- });
- }
-
- const content = panel.querySelector('.email-summary-content');
- const sp = spinnerModule.createWhirlpool(18);
- content.querySelector('.email-translation-spinner')?.appendChild(sp.element);
- try {
- const res = await fetch(`${API_BASE}/api/email/translate`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- body: sourceText,
- subject: reader.dataset.emailSubject || '',
- from: reader.dataset.emailFrom || '',
- target_language: targetLanguage,
- auto: !!opts.auto,
- }),
- });
- const result = await res.json().catch(() => ({}));
- sp.destroy();
- content.innerHTML = '';
- if (res.ok && result.success && result.same_language) {
- panel.remove();
- } else if (res.ok && result.success && result.translation) {
- content.textContent = String(result.translation || '')
- .replace(/^\s*<<
>>\s*/i, '')
- .replace(/\s*<<>>\s*$/i, '')
- .trim();
- } else {
- panel.remove();
- try { uiModule?.showError?.(result.error || 'Failed to translate'); } catch {}
- }
- } catch (_) {
- sp.destroy();
- panel.remove();
- try { uiModule?.showError?.('Failed to translate'); } catch {}
- }
-}
-
-async function _maybeAutoTranslateEmail(reader) {
- if (reader) reader.dataset.autoTranslateChecked = '1';
-}
-
-// Keep an email ⋮ dropdown inside the viewport: when it would spill past the
-// bottom (e.g. an email low on a phone screen), flip it above the anchor if
-// there's more room up there, and cap height + scroll if it still overflows.
-function _fitEmailDropdown(dropdown, rect) {
- requestAnimationFrame(() => {
- const margin = 8;
- // Horizontal clamp — keep the dropdown inside the viewport regardless of
- // whether it was anchored via left or right. Needed now that some
- // triggers (e.g. the right-aligned bulk "Actions" button) sit close to
- // the right edge, where a left-anchored menu would spill off-screen.
- const dw = dropdown.offsetWidth;
- const curLeft = dropdown.getBoundingClientRect().left;
- if (curLeft + dw > window.innerWidth - margin) {
- dropdown.style.left = Math.max(margin, window.innerWidth - margin - dw) + 'px';
- dropdown.style.right = 'auto';
- } else if (curLeft < margin) {
- dropdown.style.left = margin + 'px';
- dropdown.style.right = 'auto';
- }
- // Vertical fit — flip up or cap+scroll if it doesn't fit below.
- const dh = dropdown.offsetHeight;
- const below = window.innerHeight - rect.bottom - margin;
- const above = rect.top - margin;
- if (dh <= below) return; // fits below as-is
- if (above > below) { // flip upward
- dropdown.style.top = 'auto';
- dropdown.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
- if (dh > above) { dropdown.style.maxHeight = above + 'px'; dropdown.style.overflowY = 'auto'; }
- } else { // keep below, cap + scroll
- dropdown.style.maxHeight = below + 'px';
- dropdown.style.overflowY = 'auto';
- }
- });
-}
-
-function _fitReaderActions(meta) {
- const row = meta?.querySelector(':scope > .email-reader-actions-inline');
- if (!row) return;
- const candidates = row.querySelectorAll(':scope > .reader-icon-btn:not([data-act="more"]), :scope > :not(.email-reader-more-wrap) > .reader-icon-btn');
- candidates.forEach(button => button.classList.remove('reader-action-overflowed'));
- row.classList.remove('email-reader-actions-compact');
- const buttonsWidth = Array.from(row.children).reduce((sum, child) => sum + child.getBoundingClientRect().width, 0)
- + Math.max(0, row.children.length - 1) * 4;
- const compact = buttonsWidth > Math.max(190, meta.clientWidth - 150);
- row.classList.toggle('email-reader-actions-compact', compact);
- if (compact) {
- // Keep the two common drafting actions visible on narrow screens. Put
- // the less frequent recipient variants in More first.
- row.querySelectorAll('[data-act="reply-all"], [data-act="forward"]')
- .forEach(button => button.classList.add('reader-action-overflowed'));
- }
-}
-
-const _readerActionFitObserver = typeof ResizeObserver === 'function'
- ? new ResizeObserver(entries => {
- for (const entry of entries) _fitReaderActions(entry.target);
- })
- : null;
-
-function _wireReaderActionOverflow(reader) {
- const meta = reader?.querySelector('.email-reader-meta');
- if (!meta) return;
- requestAnimationFrame(() => _fitReaderActions(meta));
- _readerActionFitObserver?.observe(meta);
-}
-
-function _showReaderMoreMenu(em, card, reader, anchor, data) {
- // Toggle: if a dropdown for THIS anchor is already open, close it.
- const existing = document.querySelector('.email-card-dropdown');
- if (existing && existing._anchor === anchor) {
- dismissOrRemove(existing);
- return;
- }
- // Otherwise close any other open dropdown (its own teardown clears its
- // anchor's active state) before opening a fresh one.
- document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
-
- const dropdown = document.createElement('div');
- dropdown.className = 'email-card-dropdown';
- dropdown._anchor = anchor;
- anchor.classList.add('reader-more-active');
- const rect = anchor.getBoundingClientRect();
- dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
-
- const _icon = (svg) => `${svg}`;
- const _unreadIcon = '';
- const _archIcon = '';
- const _spamIcon = '';
- const _trashIcon = '';
- const _deleteForeverIcon = '';
- const _bellIcon = '';
- const _newTabIcon = '';
- const _checkIcon = '';
- const _translateIcon = '';
-
- const closeAndRemove = async () => {
- // Pick the next neighbour BEFORE we re-render so we know which email to
- // jump to. Prefer the next card; fall back to the previous one if this
- // was the last card.
- const sibling = _findSiblingEmailCard(card, +1) || _findSiblingEmailCard(card, -1);
- const nextUid = sibling ? sibling.dataset.uid : null;
- await _animateEmailCardRemoval([em.uid]);
- state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
- _renderGrid();
- _libCacheWriteBack();
- if (!nextUid) return;
- // After _renderGrid, the card nodes are fresh — re-resolve and expand.
- const grid = document.getElementById('email-lib-grid');
- const nextCard = grid?.querySelector(`.doclib-card[data-uid="${CSS.escape(String(nextUid))}"]`);
- const nextEm = state._libEmails.find(e => String(e.uid) === String(nextUid));
- if (nextCard && nextEm) {
- _toggleCardPreview(nextCard, nextEm);
- nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- }
- };
-
- const _bubblesIcon = '';
- const _contactIcon = '';
- // Three groups separated by dividers:
- // 1. Open / Mark Unread / Remind — the per-email view actions
- // 2. Save sender / Not Done / Archive — non-destructive state changes
- // 3. Move to Spam / Move to Trash / Delete — destructive
- const overflowActions = Array.from(reader.querySelectorAll('.reader-action-overflowed')).map(button => ({
- label: button.querySelector('.reader-btn-label')?.textContent?.trim() || button.title || 'Action',
- icon: button.querySelector('svg')?.outerHTML || '',
- button,
- action: (positionAnchor) => {
- if (button.dataset.act === 'ai-reply') {
- _handleAiReplyButton({
- currentTarget: button,
- stopPropagation() {},
- }, em, data, positionAnchor || button);
- } else {
- button.click();
- }
- },
- }));
- const actions = [
- ...overflowActions,
- ...(overflowActions.length ? [{ separator: true }] : []),
- {
- label: 'Open in new tab',
- icon: _newTabIcon,
- action: async () => {
- const folder = state._libFolder || 'INBOX';
- await _openEmailAsTab(em, folder);
- },
- },
- {
- label: 'Remind to reply',
- icon: _bellIcon,
- submenu: 'remind',
- },
- {
- label: 'Translate',
- icon: _translateIcon,
- submenu: 'translate',
- },
- { separator: true },
- {
- label: em.is_read ? 'Mark as Unread' : 'Mark as Read',
- icon: _unreadIcon,
- action: async () => {
- const newRead = !em.is_read;
- _syncEmailReadState(em.uid, newRead);
- try {
- if (newRead) {
- await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- } else {
- await fetch(`${API_BASE}/api/email/mark-unread/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- }
- } catch (e) { console.error(e); }
- _renderGrid();
- },
- },
- {
- // Favorite (pin to top). Same bookmark glyph we use for the
- // sidebar-pin / favorites filter so the visual language stays
- // consistent. Toggling updates em.is_flagged and re-sorts via
- // _renderGrid (favorited rows are always pinned at the top).
- label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
- icon: '',
- action: async () => {
- const next = !em.is_flagged;
- em.is_flagged = next;
- _renderGrid();
- try {
- await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
- } catch (e) {
- // Roll back the optimistic flip if the server didn't take it.
- em.is_flagged = !next;
- _renderGrid();
- console.error('Failed to toggle favorite:', e);
- }
- },
- },
- {
- label: em.is_answered ? 'Mark as Not Done' : 'Mark as Done',
- icon: _checkIcon,
- action: async () => {
- const newState = !em.is_answered;
- em.is_answered = newState;
- if (newState) {
- _clearDoneResponseTagsLocal(em);
- _syncEmailReadState(em.uid, true);
- }
- try {
- if (newState) {
- await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- } else {
- await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- }
- } catch (e) { console.error('Failed to toggle done:', e); }
- _renderGrid();
- },
- },
- {
- label: 'Move to Archive',
- icon: _archIcon,
- action: async () => {
- try {
- await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- } catch (e) { console.error(e); }
- await closeAndRemove();
- },
- },
- {
- // Save the sender to CardDAV contacts. Pulls name + address off the
- // list-item (em); falls back to splitting the local-part for a name.
- label: 'Save sender to contacts',
- icon: _contactIcon,
- action: async () => {
- const email = (em.from_address || em.from || '').trim();
- if (!email) {
- import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('No sender address')).catch(() => {});
- return;
- }
- const name = (em.from_name || '').trim() || email.split('@')[0];
- try {
- const r = await fetch(`${API_BASE}/api/contacts/add`, {
- method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name, email }),
- });
- const d = await r.json();
- import('./ui.js?v=20260916largetoolscroll1').then(m => {
- if (!m.showToast) return;
- if (d.success && d.message === 'Already exists') m.showToast('Already in contacts');
- else if (d.success) m.showToast('Saved to contacts');
- else m.showError && m.showError('Failed to save contact');
- }).catch(() => {});
- } catch (_) {
- import('./ui.js?v=20260916largetoolscroll1').then(m => m.showError && m.showError('Failed to save contact')).catch(() => {});
- }
- },
- },
- { separator: true },
- {
- label: 'Move to Spam',
- icon: _spamIcon,
- action: async () => {
- try {
- await fetch(`${API_BASE}/api/email/move/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&dest=Junk`, { method: 'POST' });
- } catch (e) { console.error(e); }
- await closeAndRemove();
- },
- },
- {
- label: 'Move to Trash',
- icon: _trashIcon,
- action: async () => {
- const busy = _showEmailDeleteOverlay(card);
- await busy?.ready;
- try {
- const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
- await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
- } catch (e) {
- console.error(e);
- busy?.remove?.();
- showToast('Failed to delete email');
- return;
- }
- busy?.remove?.();
- await closeAndRemove();
- },
- },
- {
- label: 'Delete Permanently',
- icon: _deleteForeverIcon,
- danger: true,
- action: async () => {
- const subject = em.subject || '(no subject)';
- const ok = await styledConfirm(
- `Permanently delete "${subject}"? This cannot be undone.`,
- { confirmText: 'Delete', cancelText: 'Cancel', danger: true }
- );
- if (!ok) return;
- const busy = _showEmailDeleteOverlay(card);
- await busy?.ready;
- try {
- const response = await fetch(`${API_BASE}/api/email/delete-permanent/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
- await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
- } catch (e) {
- console.error(e);
- busy?.remove?.();
- showToast('Failed to delete email');
- return;
- }
- busy?.remove?.();
- await closeAndRemove();
- },
- },
- ];
-
- for (const a of actions) {
- if (a.separator) {
- const sep = document.createElement('div');
- sep.className = 'dropdown-divider';
- dropdown.appendChild(sep);
- continue;
- }
- const item = document.createElement('div');
- item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
- const arrow = a.submenu ? '›' : '';
- item.innerHTML = _icon(a.icon) + `${a.label}${arrow}`;
- item.addEventListener('click', (e) => {
- e.stopPropagation();
- if (a.submenu === 'remind') {
- _showLibRemindSubmenu(em, dropdown);
- return;
- }
- if (a.submenu === 'translate') {
- _showEmailTranslateSubmenu(reader, dropdown);
- return;
- }
- // A hidden overflowed button has a zero-sized client rect. Invoke the
- // AI chooser while the visible More item is still mounted, then close
- // the action menu after the chooser has captured its position.
- if (a.button?.dataset.act === 'ai-reply') {
- a.action(item);
- close();
- return;
- }
- close();
- a.action();
- });
- dropdown.appendChild(item);
- }
- // Mobile-only Cancel item — explicit close for touch users. CSS hides it
- // on desktop where outside-click already dismisses cleanly.
- const _cancelIco = '';
- const cancelItem = document.createElement('div');
- cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
- cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel';
- cancelItem.addEventListener('click', (e) => {
- e.stopPropagation();
- close();
- });
- dropdown.appendChild(cancelItem);
-
- document.body.appendChild(dropdown);
- _fitEmailDropdown(dropdown, rect);
- const close = bindMenuDismiss(dropdown, () => {
- dropdown.remove();
- anchor.classList.remove('reader-more-active');
- }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
-}
-
-function _showCardMenu(em, anchor) {
- const openMenu = document.querySelector('.email-card-dropdown:not(.email-bulk-menu)');
- if (openMenu && openMenu._emailMenuAnchor === anchor) {
- dismissOrRemove(openMenu);
- return;
- }
- document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
-
- const dropdown = document.createElement('div');
- dropdown.className = 'email-card-dropdown';
- dropdown._emailMenuAnchor = anchor;
- const rect = anchor.getBoundingClientRect();
- dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;right:${window.innerWidth - rect.right}px;`;
-
- const _icon = (svg) => `${svg}`;
- const _replyIcon = '';
- const _archIcon = '';
- const _delIcon = '';
- const _unreadIcon = '';
- const _checkIcon = '';
- const _cardBellIcon = '';
-
- const isSentFolder = /sent/i.test(state._libFolder);
-
- const _newTabIcon = '';
- const actions = [
- { label: 'Open', icon: _replyIcon, action: async () => {
- // Just expand inline (same as tapping the row).
- const card = anchor.closest('.doclib-card');
- if (card && !card.classList.contains('doclib-card-expanded')) {
- await _toggleCardPreview(card, em);
- }
- }},
- { label: 'Open in new tab', icon: _newTabIcon, action: async () => {
- // Open this email as its own in-app modal that registers a dock
- // chip — multiple emails can be opened simultaneously, each gets
- // its own chip in the minimized dock.
- const folder = state._libFolder || 'INBOX';
- await _openEmailAsTab(em, folder);
- }},
- { label: 'Remind to reply', icon: _cardBellIcon, submenu: 'remind' },
- ];
-
- if (!isSentFolder) {
- // Source of truth = the visible "active" class on the card's done
- // check, so the menu label and the actual toggle behaviour can't
- // disagree with what the user sees.
- const _cardForLabel = anchor.closest('.doclib-card');
- const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null;
- const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered;
- actions.push({
- label: _currentlyDone ? 'Not Done' : 'Done',
- icon: _checkIcon,
- action: async () => {
- const card = anchor.closest('.doclib-card');
- const check = card ? card.querySelector('.email-card-done') : null;
- const wasActive = check ? check.classList.contains('active') : !!em.is_answered;
- const newState = !wasActive;
- em.is_answered = newState;
- if (newState) {
- _clearDoneResponseTagsLocal(em);
- _syncEmailReadState(em.uid, true); // mark-done implies mark-read
- }
- try {
- if (newState) {
- await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- } else {
- await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- }
- } catch (e) { console.error('Failed to toggle done:', e); }
- if (card) {
- if (check) check.classList.toggle('active', newState);
- if (newState) {
- _syncEmailReadState(em.uid, true);
- card.querySelectorAll('.email-tag-urgent, .email-tag-reply-soon, .email-tag-action-needed').forEach(n => n.remove());
- }
- }
- },
- });
- actions.push({
- label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
- icon: '',
- action: async () => {
- const next = !em.is_flagged;
- em.is_flagged = next;
- _renderGrid();
- try {
- await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
- } catch (e) {
- em.is_flagged = !next;
- _renderGrid();
- console.error('Failed to toggle favorite:', e);
- }
- },
- });
- actions.push({
- label: 'Archive',
- icon: _archIcon,
- action: async () => {
- await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- await _animateEmailCardRemoval([em.uid]);
- state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
- _renderGrid();
- _libCacheWriteBack();
- },
- });
- } else {
- actions.push({
- label: em.is_flagged ? 'Unfavorite' : 'Favorite (pin to top)',
- icon: '',
- action: async () => {
- const next = !em.is_flagged;
- em.is_flagged = next;
- _renderGrid();
- try {
- await fetch(`${API_BASE}/api/email/flag/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&on=${next ? 'true' : 'false'}`, { method: 'POST' });
- } catch (e) {
- em.is_flagged = !next;
- _renderGrid();
- console.error('Failed to toggle favorite:', e);
- }
- },
- });
- actions.push({
- label: 'Archive',
- icon: _archIcon,
- action: async () => {
- await fetch(`${API_BASE}/api/email/archive/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- await _animateEmailCardRemoval([em.uid]);
- state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
- _renderGrid();
- _libCacheWriteBack();
- },
- });
- }
-
- // "Select" — switch to multi-select mode with THIS email pre-selected so
- // the user can quickly fan-out to neighbours with the bulk bar.
- // Match the chat-sidebar Select icon — a thick bullet character reads
- // much heavier than a small SVG circle. Nudged up 2px so its visual
- // center lines up with the SVG icons above (which sit a bit higher).
- const _selectIcon = SELECT_MENU_ICON;
- actions.push({
- label: 'Select',
- icon: _selectIcon,
- action: () => {
- state._selectMode = true;
- state._selectedUids.add(em.uid);
- _updateBulkBar();
- _renderGrid();
- },
- });
-
- actions.push(
- { label: 'Delete', icon: _delIcon, danger: true, action: async () => {
- const subject = em.subject || '(no subject)';
- const ok = await styledConfirm(`Delete "${subject}"?`, { confirmText: 'Delete', cancelText: 'Cancel', danger: true });
- if (!ok) return;
- const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(em.uid))}"]`);
- const busy = _showEmailDeleteOverlay(card);
- await busy?.ready;
- try {
- const response = await fetch(`${API_BASE}/api/email/delete/${encodeURIComponent(em.uid)}?${_emailMutationQuery(em)}`, { method: 'DELETE' });
- await _requireSuccessfulEmailMutation(response, 'Failed to delete email');
- } catch (e) {
- busy?.remove?.();
- showToast(e?.message || 'Could not move email to Trash');
- return;
- }
- busy?.remove?.();
- await _animateEmailCardRemoval([em.uid]);
- state._libEmails = state._libEmails.filter(e => String(e.uid) !== String(em.uid));
- _renderGrid();
- _libCacheWriteBack();
- }},
- );
-
- const orderedActions = orderActionMenuItems(actions);
- let addedActionDivider = false;
- for (const [index, a] of orderedActions.entries()) {
- if (!addedActionDivider && index > 0 && actionMenuRank(a) >= 700) {
- const divider = document.createElement('div');
- divider.className = 'dropdown-divider';
- dropdown.appendChild(divider);
- addedActionDivider = true;
- }
- const item = document.createElement('div');
- item.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
- const arrow = a.submenu ? '›' : '';
- item.innerHTML = _icon(a.icon) + `${a.label}${arrow}`;
- item.addEventListener('click', (e) => {
- e.stopPropagation();
- if (a.submenu === 'remind') {
- _showLibRemindSubmenu(em, dropdown);
- return;
- }
- close();
- a.action();
- });
- dropdown.appendChild(item);
- }
- // Mobile-only Cancel item — explicit close for touch users. CSS hides it
- // on desktop where outside-click already dismisses cleanly.
- const _cancelIco = '';
- const cancelItem = document.createElement('div');
- cancelItem.className = 'dropdown-item-compact dropdown-cancel-mobile';
- cancelItem.innerHTML = _icon(_cancelIco) + 'Cancel';
- cancelItem.addEventListener('click', (e) => {
- e.stopPropagation();
- close();
- });
- dropdown.appendChild(cancelItem);
-
- document.body.appendChild(dropdown);
- _fitEmailDropdown(dropdown, rect);
- const close = bindMenuDismiss(dropdown, () => {
- dropdown.remove();
- anchor.classList.remove('reader-more-active');
- }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
-}
-
-// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
-function _showBulkActionsMenu(anchor) {
- document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
- const dropdown = document.createElement('div');
- dropdown.className = 'email-card-dropdown email-bulk-menu';
- const rect = anchor.getBoundingClientRect();
- dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
- const _readIco = '';
- const _unreadIco = '';
- const _doneIco = '';
- const _exportIco = '';
- const items = [
- { label: 'Export Attachments', icon: _exportIco, action: () => _exportSelectedAttachments() },
- { label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
- { label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
- { label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') },
- ];
- for (const a of items) {
- const it = document.createElement('div');
- it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
- it.innerHTML = `${a.icon}${a.label}`;
- it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
- dropdown.appendChild(it);
- }
- // Mobile-only Cancel — matches the per-card and sidebar dropdowns.
- const _cancelIco2 = '';
- const cancelIt = document.createElement('div');
- cancelIt.className = 'dropdown-item-compact dropdown-cancel-mobile';
- cancelIt.innerHTML = `${_cancelIco2}Cancel`;
- cancelIt.addEventListener('click', (e) => {
- e.stopPropagation();
- close();
- // Cancel inside the bulk-Actions menu also exits select mode — matches the
- // documents bulk dropdown.
- state._selectMode = false;
- state._selectedUids.clear();
- _updateBulkBar();
- _renderGrid();
- });
- dropdown.appendChild(cancelIt);
- document.body.appendChild(dropdown);
- _fitEmailDropdown(dropdown, rect);
- const close = bindMenuDismiss(dropdown, () => {
- dropdown.remove();
- }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
-}
-
-function _updateBulkBar() {
- const bar = document.getElementById('email-lib-bulk');
- const grid = document.getElementById('email-lib-grid');
- if (grid) grid.classList.toggle('email-selecting', !!state._selectMode);
- const selectBtn = document.getElementById('email-lib-select-btn');
- if (bar) bar.classList.toggle('hidden', !state._selectMode);
- if (selectBtn) {
- selectBtn.textContent = state._selectMode ? 'Cancel' : 'Select';
- selectBtn.classList.toggle('active', state._selectMode);
- }
- const count = document.getElementById('email-lib-selected-count');
- if (count) count.textContent = `${state._selectedUids.size} Selected`;
- const all = document.getElementById('email-lib-select-all');
- if (all) all.checked = state._libEmails.length > 0 && state._libEmails.every(e => state._selectedUids.has(e.uid));
- // When something's selected, brighten Actions to the same full --fg color as
- // the "N Selected" count (the button is a dimmer 60% --fg by default).
- const actions = document.getElementById('email-lib-bulk-actions');
- if (actions) actions.style.color = state._selectedUids.size > 0 ? 'var(--fg)' : '';
- const deleteBtn = document.getElementById('email-lib-bulk-delete');
- if (deleteBtn) deleteBtn.style.color = state._selectedUids.size > 0 ? 'var(--red)' : '';
-}
-
-async function _bulkAction(action) {
- const uids = Array.from(state._selectedUids);
- if (uids.length === 0) return;
- let failedReadSync = 0;
- const failedUids = new Set();
- if (action === 'delete') {
- const ok = await styledConfirm(
- `Delete ${uids.length} selected email${uids.length === 1 ? '' : 's'}?`,
- { confirmText: 'Delete', cancelText: 'Cancel', danger: true },
- );
- if (!ok) return;
- // Deletion is intentionally optimistic. The user should not have to keep
- // the mail window open while IMAP moves a batch; refresh and restore the
- // cards only if the provider rejects the background operation.
- const removed = new Set(uids.map(uid => String(uid)));
- state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
- state._selectedUids.clear();
- state._selectMode = false;
- _updateBulkBar();
- _renderGrid();
- _libCacheWriteBack();
- showToast(`Moved ${uids.length} email${uids.length === 1 ? '' : 's'} to Trash`);
- fetch(`${API_BASE}/api/email/delete-bulk`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }),
- }).then(async (res) => {
- const data = await res.json().catch(() => null);
- if (!res.ok || data?.success === false || (data?.failed_uids || []).length) {
- throw new Error(data?.error || `${data?.failed_uids?.length || 0} emails could not be deleted`);
- }
- }).catch((error) => {
- console.error('Background bulk email delete failed:', error);
- showToast('Delete failed; restoring mailbox');
- _loadEmailsFresh({ force: true, useCache: false, refreshAfterCache: false }).catch(() => {});
- });
- return;
- }
-
- const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null;
- const actionsBtn = document.getElementById('email-lib-bulk-actions');
- const cancelBtn = document.getElementById('email-lib-bulk-cancel');
- const selectAll = document.getElementById('email-lib-select-all');
- const countEl = document.getElementById('email-lib-selected-count');
- const originalDeleteHtml = deleteBtn?.innerHTML || '';
- const originalCountText = countEl?.textContent || '';
- let busySpinner = null;
- // Loading state for every bulk action, not just delete — large
- // selections (e.g. 90+ Dones) used to silently hammer the server
- // with sequential requests and the user got zero feedback. Now the
- // Actions button (or Delete button) shows a whirlpool + verb-ing
- // label, and the count surfaces progress.
- const verbing = {
- delete: 'Deleting',
- archive: 'Archiving',
- done: 'Marking done',
- read: 'Marking read',
- unread: 'Marking unread',
- }[action] || 'Updating';
- const targetBtn = action === 'delete' ? deleteBtn : actionsBtn;
- let originalTargetHtml = '';
- if (targetBtn) {
- originalTargetHtml = targetBtn.innerHTML;
- targetBtn.disabled = true;
- targetBtn.classList.add('email-bulk-loading');
- targetBtn.innerHTML = `${verbing}`;
- busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
- const spEl = busySpinner.createElement();
- spEl.classList.add('email-bulk-whirlpool');
- targetBtn.appendChild(spEl);
- busySpinner.start();
- }
- if (action !== 'delete' && deleteBtn) deleteBtn.disabled = true;
- if (action === 'delete' && actionsBtn) actionsBtn.disabled = true;
- if (cancelBtn) cancelBtn.disabled = true;
- if (selectAll) selectAll.disabled = true;
- if (countEl) countEl.textContent = `${verbing} ${uids.length}…`;
- const deleteOverlays = action === 'delete'
- ? uids.map(uid => {
- const card = document.querySelector(`#email-lib-grid .doclib-card[data-uid="${CSS.escape(String(uid))}"]`);
- return _showEmailDeleteOverlay(card);
- }).filter(Boolean)
- : [];
- if (deleteOverlays.length) {
- await Promise.all(deleteOverlays.map(busy => busy.ready).filter(Boolean));
- }
-
- // Single-uid worker.
- const handleOne = async (uid) => {
- try {
- if (action === 'archive') {
- const res = await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- const data = await res.json().catch(() => null);
- if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
- } else if (action === 'delete') {
- // Bulk Delete matches the permanent-delete behavior used by the
- // library and albums. Moving a batch through provider Trash folders
- // is unreliable on several IMAP implementations.
- const res = await fetch(`${API_BASE}/api/email/delete-permanent/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
- const data = await res.json().catch(() => null);
- if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
- } else if (action === 'done') {
- // uid may come back from the Set as a string while em.uid is
- // numeric (or vice versa) — coerce both sides so the in-memory
- // state actually flips and the post-loop re-render shows the
- // done checkmark.
- const em = state._libEmails.find(e => String(e.uid) === String(uid));
- if (em) {
- em.is_answered = true;
- em.is_read = true;
- _clearDoneResponseTagsLocal(em);
- }
- const ansRes = await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- const readRes = await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- if (!ansRes.ok || !readRes.ok) throw new Error(`mark-done HTTP ${ansRes.status}/${readRes.status}`);
- } else if (action === 'read' || action === 'unread') {
- const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
- const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
- let data = null;
- try { data = await res.json(); } catch (_) {}
- if (!res.ok || data?.success === false) {
- throw new Error(data?.error || `HTTP ${res.status}`);
- }
- _syncEmailReadState(uid, action === 'read');
- }
- } catch (e) {
- if (action === 'read' || action === 'unread') failedReadSync += 1;
- failedUids.add(String(uid));
- console.error(`Failed to ${action} ${uid}:`, e);
- }
- };
-
- try {
- // Run in parallel with a concurrency cap so 92 emails don't take
- // 30 seconds sequentially but we also don't open 92 simultaneous
- // connections.
- const CONCURRENCY = 6;
- const queue = uids.slice();
- let inFlight = 0;
- let nextSlot = 0;
- let finishedCount = 0;
- let bulkDeletedUids = null;
- if (action === 'delete') {
- const res = await fetch(`${API_BASE}/api/email/delete-bulk`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ uids, folder: state._libFolder, account_id: state._libAccountId || null }),
- });
- const data = await res.json().catch(() => null);
- if (!res.ok || data?.success === false) throw new Error(data?.error || `HTTP ${res.status}`);
- for (const uid of data?.failed_uids || []) failedUids.add(String(uid));
- bulkDeletedUids = (data?.deleted_uids || []).map(String);
- if (countEl) countEl.textContent = `${verbing} ${bulkDeletedUids.length}/${uids.length}…`;
- } else await new Promise((resolve) => {
- const launch = () => {
- while (inFlight < CONCURRENCY && nextSlot < queue.length) {
- const uid = queue[nextSlot++];
- inFlight++;
- handleOne(uid).finally(() => {
- inFlight--;
- finishedCount++;
- if (countEl) countEl.textContent = `${verbing} ${finishedCount}/${queue.length}…`;
- if (nextSlot >= queue.length && inFlight === 0) resolve();
- else launch();
- });
- }
- if (queue.length === 0) resolve();
- };
- launch();
- });
-
- if (action === 'archive' || action === 'delete') {
- const successfulUids = bulkDeletedUids || uids.filter(uid => !failedUids.has(String(uid)));
- if (action === 'delete') {
- deleteOverlays.forEach(busy => busy.remove?.());
- }
- await _animateEmailCardRemoval(successfulUids);
- const removed = new Set(successfulUids.map(uid => String(uid)));
- state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
- } else if (action === 'done' && state._libFilter === 'undone') {
- // The undone filter is a "show only not-done" view — after marking
- // selected emails done, they no longer match. Animate them out and
- // drop them from the local list so the view reflects the filter
- // instead of leaving freshly-done cards sitting there.
- await _animateEmailCardRemoval(uids);
- const removed = new Set(uids.map(uid => String(uid)));
- state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
- }
- } finally {
- deleteOverlays.forEach(busy => busy.remove?.());
- if (busySpinner) busySpinner.destroy();
- // Restore whichever button we hijacked (delete vs actions).
- if (targetBtn) {
- targetBtn.disabled = false;
- targetBtn.classList.remove('email-bulk-loading');
- targetBtn.innerHTML = originalTargetHtml || targetBtn.innerHTML;
- }
- if (deleteBtn && deleteBtn !== targetBtn) {
- deleteBtn.disabled = false;
- deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML;
- }
- if (actionsBtn && actionsBtn !== targetBtn) actionsBtn.disabled = false;
- if (cancelBtn) cancelBtn.disabled = false;
- if (selectAll) selectAll.disabled = false;
- if (countEl) countEl.textContent = originalCountText;
- }
- state._selectedUids.clear();
- state._selectMode = false;
- _updateBulkBar();
- _renderGrid();
- if (failedReadSync > 0) {
- showToast(`Failed to update ${failedReadSync} email${failedReadSync === 1 ? '' : 's'}`);
- }
- if (failedUids.size > 0 && (action === 'delete' || action === 'archive')) {
- showToast(`${failedUids.size} email${failedUids.size === 1 ? '' : 's'} could not be ${action === 'delete' ? 'deleted' : 'archived'}`);
- }
- // Sync successful local mutations into the SWR cache so reopen doesn't
- // briefly show the pre-bulk state.
- _libCacheWriteBack();
-}
-
-// _extractName lives in ./emailLibrary/utils.js
-
-function _aiReplyIcon(data) {
- return '';
-}
-
-function _summaryIcon(data) {
- const fill = data?.cached_summary ? 'var(--accent-primary, var(--red))' : 'currentColor';
- return ``;
-}
-
-async function _emailTranslateLanguage() {
- try {
- const res = await fetch(`${API_BASE}/api/email/config`);
- const cfg = await res.json();
- return cfg?.email_translate_language || 'English';
- } catch (_) {
- return 'English';
- }
-}
-
-async function _runAiReplyFromButton(btn, em, data, mode, noteHint = '') {
- _snapEmailModalToLeftSidebar(btn.closest('.modal'));
- btn.disabled = true;
- const orig = btn.innerHTML;
- let wp = null;
- try {
- wp = spinnerModule.createWhirlpool(14);
- wp.element.style.cssText = 'width:14px;height:14px;display:inline-block;vertical-align:middle;position:relative;top:-2px;';
- btn.innerHTML = '';
- btn.appendChild(wp.element);
- } catch (_) {}
- try {
- if (state._onEmailClick) {
- return await state._onEmailClick({ email: em, emailData: data, mode, noteHint });
- }
- return false;
- } finally {
- try { wp && wp.stop(); } catch (_) {}
- btn.disabled = false;
- btn.innerHTML = orig;
- }
-}
-
-const _AI_REPLY_CONTEXT_DRAFT_PREFIX = 'odysseus:email-ai-reply-context:v2:';
-let _aiReplyChoiceOutsideClose = null;
-
-function _aiReplyContextDraftKey(em, data) {
- const accountId = data?.account_id || em?.account_id || state._libAccountId || '';
- const folder = data?.folder || em?.folder || state._libFolder || 'INBOX';
- const uid = data?.uid || em?.uid || data?.message_id || em?.message_id || '';
- if (!uid) return '';
- return _AI_REPLY_CONTEXT_DRAFT_PREFIX
- + [accountId, folder, uid].map(value => encodeURIComponent(String(value || ''))).join(':');
-}
-
-function _loadAiReplyContextDraft(key) {
- if (!key) return '';
- try { return localStorage.getItem(key) || ''; } catch (_) { return ''; }
-}
-
-function _saveAiReplyContextDraft(key, value) {
- if (!key) return;
- try {
- const text = String(value || '');
- if (text.trim()) localStorage.setItem(key, text);
- else localStorage.removeItem(key);
- } catch (_) {}
-}
-
-function _clearAiReplyContextDraft(key) {
- if (!key) return;
- try { localStorage.removeItem(key); } catch (_) {}
-}
-
-function _closeAiReplyChoice() {
- if (_aiReplyChoiceOutsideClose) {
- document.removeEventListener('click', _aiReplyChoiceOutsideClose, true);
- _aiReplyChoiceOutsideClose = null;
- }
- document.querySelectorAll('.email-ai-reply-choice').forEach(el => {
- const input = el.querySelector('[data-note-input]');
- _saveAiReplyContextDraft(el.dataset.contextDraftKey || '', input?.value || '');
- el.remove();
- });
-}
-
-function _showAiReplyChoice(btn, em, data, positionAnchor = btn) {
- _closeAiReplyChoice();
- const rect = positionAnchor.getBoundingClientRect();
- const menu = document.createElement('div');
- menu.className = 'email-ai-reply-choice';
- /* Clamp width to viewport minus 16px margin so the menu never spills off
- the right edge on narrow mobile screens. */
- const menuMaxW = Math.min(220, window.innerWidth - 16);
- const left = Math.max(8, Math.min(rect.left, window.innerWidth - menuMaxW - 8));
- /* Vertical placement: prefer below the button, but flip above if
- there's not enough room (e.g. button near bottom of viewport).
- Estimated menu height is ~150px (textarea + buttons + padding). */
- const estHeight = 150;
- const spaceBelow = window.innerHeight - rect.bottom - 8;
- const spaceAbove = rect.top - 8;
- let top;
- if (spaceBelow >= estHeight || spaceBelow >= spaceAbove) {
- top = Math.max(8, Math.min(rect.bottom + 6, window.innerHeight - estHeight - 8));
- } else {
- top = Math.max(8, rect.top - estHeight - 6);
- }
- menu.style.cssText = [
- 'position:fixed',
- `left:${left}px`,
- `top:${top}px`,
- `max-width:${menuMaxW}px`,
- `max-height:${window.innerHeight - 16}px`,
- 'overflow:auto',
- 'box-sizing:border-box',
- `z-index:${topPortalZ()}`,
- 'display:flex',
- 'gap:6px',
- 'padding:6px',
- 'background:var(--bg,#111)',
- 'border:1px solid var(--border,#333)',
- 'border-radius:7px',
- 'box-shadow:0 8px 24px rgba(0,0,0,.28)',
- ].join(';');
- menu.innerHTML = `
-
- `;
- const noteInput = menu.querySelector('[data-note-input]');
- const contextDraftKey = _aiReplyContextDraftKey(em, data);
- menu.dataset.contextDraftKey = contextDraftKey;
- noteInput.value = _loadAiReplyContextDraft(contextDraftKey);
- noteInput.addEventListener('input', () => {
- _saveAiReplyContextDraft(contextDraftKey, noteInput.value || '');
- });
- setTimeout(() => noteInput.focus(), 0);
- menu.addEventListener('click', async (ev) => {
- const choice = ev.target.closest('[data-mode]');
- if (!choice) return;
- ev.preventDefault();
- ev.stopPropagation();
- const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
- const noteHint = (noteInput.value || '').trim();
- _saveAiReplyContextDraft(contextDraftKey, noteInput.value || '');
- _closeAiReplyChoice();
- const draftOpened = await _runAiReplyFromButton(btn, em, data, mode, noteHint);
- if (draftOpened === true) _clearAiReplyContextDraft(contextDraftKey);
- });
- // Esc closes the popover; ignore plain clicks inside the menu so the
- // textarea stays focused.
- menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
- document.body.appendChild(menu);
- // Outside-click closer: only fires when the click target is OUTSIDE
- // the menu. The original handler closed on any click which made
- // focusing the textarea immediately dismiss the popover.
- const outsideClose = (ev) => {
- if (menu.contains(ev.target)) return;
- document.removeEventListener('click', outsideClose, true);
- if (_aiReplyChoiceOutsideClose === outsideClose) _aiReplyChoiceOutsideClose = null;
- _closeAiReplyChoice();
- };
- _aiReplyChoiceOutsideClose = outsideClose;
- setTimeout(() => {
- if (_aiReplyChoiceOutsideClose === outsideClose) {
- document.addEventListener('click', outsideClose, true);
- }
- }, 0);
-}
-
-function _handleAiReplyButton(ev, em, data, positionAnchor = ev.currentTarget) {
- ev.stopPropagation();
- const btn = ev.currentTarget;
- // First click on a cached email surfaces the cached draft. Second
- // click clears the cache and opens the Fast/Full + context menu so
- // the user can ask for a fresh draft (with new steering).
- if (data?.cached_ai_reply && !btn.dataset.shownOnce) {
- btn.dataset.shownOnce = '1';
- _runAiReplyFromButton(btn, em, data, 'ai-reply');
- return;
- }
- if (data?.cached_ai_reply) {
- data.cached_ai_reply = null;
- btn.dataset.shownOnce = '';
- }
- _showAiReplyChoice(btn, em, data, positionAnchor);
-}
-
-function _hasMultipleRecipients(data) {
- // Count distinct addresses in To + Cc (minus the current user). Empty
- // fallback when the user's address isn't yet known — no exclusion.
- const myAddress = (window._myEmailAddress || '').toLowerCase();
- const extractEmails = (str) => {
- if (!str) return [];
- return str.split(',')
- .map(s => {
- const m = s.match(/<([^>]+)>/);
- return (m ? m[1] : s).trim().toLowerCase();
- })
- .filter(e => e && e !== myAddress);
- };
- const recipients = new Set([
- ...extractEmails(data.to),
- ...extractEmails(data.cc),
- ]);
- // Sender counts as one other person too
- if (data.from_address && data.from_address.toLowerCase() !== myAddress) {
- recipients.add(data.from_address.toLowerCase());
- }
- return recipients.size > 1;
-}
-
-// _esc lives in ./emailLibrary/utils.js
-
-function _showEmailTranslateSubmenu(reader, parentDropdown) {
- parentDropdown.innerHTML = '';
- const header = document.createElement('div');
- header.className = 'dropdown-item-compact';
- header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
- header.innerHTML = 'Translate to';
- parentDropdown.appendChild(header);
-
- const customRow = document.createElement('div');
- customRow.className = 'dropdown-item-compact email-translate-custom-row';
- customRow.style.cssText = 'display:flex;gap:5px;align-items:center;padding:5px 7px;cursor:default;';
- customRow.innerHTML = `
-
-
- `;
- parentDropdown.appendChild(customRow);
- const input = customRow.querySelector('.email-translate-custom-input');
- const go = customRow.querySelector('.email-translate-custom-go');
- const runCustom = async () => {
- const language = (input?.value || '').trim();
- if (!language) {
- input?.focus();
- return;
- }
- parentDropdown.remove();
- await _translateEmail(reader, language);
- };
- customRow.addEventListener('click', e => e.stopPropagation());
- go?.addEventListener('click', async e => {
- e.stopPropagation();
- await runCustom();
- });
- input?.addEventListener('keydown', async e => {
- if (e.key === 'Enter') {
- e.preventDefault();
- e.stopPropagation();
- await runCustom();
- }
- });
- _emailTranslateLanguage().then(language => {
- if (input && !input.value) input.value = language || 'English';
- }).catch(() => {});
-
- const languages = ['English', 'Swedish', 'Japanese', 'Spanish', 'French', 'German'];
- for (const language of languages) {
- const item = document.createElement('div');
- item.className = 'dropdown-item-compact';
- item.innerHTML = `${language}`;
- item.addEventListener('click', async (e) => {
- e.stopPropagation();
- parentDropdown.remove();
- await _translateEmail(reader, language);
- });
- parentDropdown.appendChild(item);
- }
-}
-
-// ---- Reminder submenu (used by both email menus) ----
-function _showLibRemindSubmenu(em, parentDropdown) {
- parentDropdown.innerHTML = '';
- const header = document.createElement('div');
- header.className = 'dropdown-item-compact';
- header.style.cssText = 'opacity:0.5;font-size:10px;pointer-events:none;text-transform:uppercase;letter-spacing:0.5px;padding-top:6px;';
- header.innerHTML = 'Remind me';
- parentDropdown.appendChild(header);
-
- const now = new Date();
- const laterToday = new Date(now);
- const sixPm = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0);
- if (sixPm - now < 60*60*1000) laterToday.setTime(now.getTime() + 3*60*60*1000);
- else laterToday.setTime(sixPm.getTime());
- const tomorrow = new Date(now); tomorrow.setDate(tomorrow.getDate()+1); tomorrow.setHours(8,0,0,0);
- const daysUntilMon = (8 - now.getDay()) % 7 || 7;
- const nextWeek = new Date(now); nextWeek.setDate(now.getDate()+daysUntilMon); nextWeek.setHours(8,0,0,0);
-
- const presets = [
- { label: 'Later today', sub: laterToday.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: laterToday },
- { label: 'Tomorrow', sub: tomorrow.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: tomorrow },
- { label: 'Next week', sub: nextWeek.toLocaleDateString([], { weekday:'short' }) + ' ' + nextWeek.toLocaleTimeString([], { hour:'numeric', minute:'2-digit' }), date: nextWeek },
- ];
- for (const p of presets) {
- const item = document.createElement('div');
- item.className = 'dropdown-item-compact';
- item.innerHTML = `${p.label}${p.sub}`;
- item.addEventListener('click', async (e) => {
- e.stopPropagation();
- parentDropdown.remove();
- await _createEmailReplyReminder(em, p.date);
- });
- parentDropdown.appendChild(item);
- }
- const customItem = document.createElement('div');
- customItem.className = 'dropdown-item-compact';
- customItem.innerHTML = 'Pick date and time…';
- customItem.addEventListener('click', (e) => {
- e.stopPropagation();
- parentDropdown.remove();
- const tmp = document.createElement('input');
- tmp.type = 'datetime-local';
- const def = new Date(tomorrow);
- const pad = n => String(n).padStart(2,'0');
- tmp.value = `${def.getFullYear()}-${pad(def.getMonth()+1)}-${pad(def.getDate())}T${pad(def.getHours())}:${pad(def.getMinutes())}`;
- tmp.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:13px;';
- document.body.appendChild(tmp);
- tmp.focus();
- if (typeof tmp.showPicker === 'function') { try { tmp.showPicker(); } catch {} }
- tmp.addEventListener('change', async () => {
- if (tmp.value) await _createEmailReplyReminder(em, new Date(tmp.value));
- tmp.remove();
- });
- tmp.addEventListener('blur', () => setTimeout(() => tmp.remove(), 200));
- });
- parentDropdown.appendChild(customItem);
- // "Note" — prompts for free-text and saves it as a note without a
- // due_date, so no timer/reminder fires.
- const noteItem = document.createElement('div');
- noteItem.className = 'dropdown-item-compact';
- noteItem.innerHTML = 'Note';
- noteItem.addEventListener('click', (e) => {
- e.stopPropagation();
- parentDropdown.remove();
- _promptEmailNote(em);
- });
- parentDropdown.appendChild(noteItem);
-}
-
-function _promptEmailNote(em) {
- const overlay = document.createElement('div');
- overlay.style.cssText = 'position:fixed;inset:0;z-index:99998;background:rgba(0,0,0,0.45);display:flex;align-items:center;justify-content:center;padding:16px;';
- const card = document.createElement('div');
- card.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:280px;max-width:min(420px, 92vw);display:flex;flex-direction:column;gap:8px;box-shadow:0 12px 32px rgba(0,0,0,0.4);';
- const subject = em.subject || '(no subject)';
- card.innerHTML = `
- Note about ${_esc(subject)}
-
-
- `;
- overlay.appendChild(card);
- document.body.appendChild(overlay);
- const ta = card.querySelector('[data-note]');
- setTimeout(() => ta.focus(), 0);
- const close = () => overlay.remove();
- overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
- card.querySelector('[data-act="cancel"]').addEventListener('click', close);
- card.querySelector('[data-act="save"]').addEventListener('click', async () => {
- const text = (ta.value || '').trim();
- if (!text) { ta.focus(); return; }
- close();
- await _createEmailReplyReminder(em, null, text);
- });
- ta.addEventListener('keydown', (ev) => {
- if (ev.key === 'Escape') close();
- else if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') card.querySelector('[data-act="save"]').click();
- });
-}
-
-async function _createEmailReplyReminder(em, dueDate, customText = '') {
- const pad = n => String(n).padStart(2,'0');
- const iso = dueDate
- ? `${dueDate.getFullYear()}-${pad(dueDate.getMonth()+1)}-${pad(dueDate.getDate())}T${pad(dueDate.getHours())}:${pad(dueDate.getMinutes())}`
- : null;
- const fullFrom = em.from || em.sender || '';
- // Extract just the first name from "First Last " or fall back to email local part
- let from = 'someone';
- if (fullFrom) {
- const fullName = _extractName(fullFrom);
- if (fullName) {
- // Strip quotes, take the first whitespace-separated word, capitalize
- const first = fullName.replace(/^["']|["']$/g, '').trim().split(/[\s,]+/)[0] || '';
- if (first) from = first.charAt(0).toUpperCase() + first.slice(1);
- }
- }
- const subject = em.subject || '(no subject)';
- const folder = state._libFolder || 'INBOX';
- const deepLink = `${window.location.origin}/#email=${encodeURIComponent(folder)}:${em.uid}`;
- const itemText = customText || `Reply to ${from}: ${subject}`;
- const payload = {
- title: `Reply: ${subject}`,
- note_type: 'todo',
- items: [
- { text: itemText, checked: false },
- ],
- content: `Open email: ${deepLink}`,
- label: 'email reminder',
- source: 'email',
- };
- if (iso) payload.due_date = iso;
- try {
- const res = await fetch(`${API_BASE}/api/notes`, {
- method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- });
- if (!res.ok) throw new Error('Failed');
- const { showToast } = await import('./ui.js?v=20260916largetoolscroll1');
- if (dueDate) {
- const fmt = dueDate.toLocaleString([], { month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
- showToast(`Todo reminder set for ${fmt}`);
- } else {
- showToast('Reply note saved');
- }
- if ('Notification' in window && Notification.permission === 'default') {
- try { Notification.requestPermission(); } catch {}
- }
- } catch (e) {
- const { showError } = await import('./ui.js?v=20260916largetoolscroll1');
- showError('Failed to create reminder');
- }
-}
-
-// Sanitize untrusted HTML email bodies before injecting via innerHTML.
+// The email library now lives in `static/js/emailLibrary/`. This file stays at
+// the old path because five call sites import it, four of them dynamically
+// with a version query string (`emailInbox.js`, `chatStream.js`,
+// `chatRenderer.js`, `document.js`, `settings.js`), and `sw.js` caches URLs
+// verbatim. Re-exporting here means the move needed no coordinated edit to any
+// of them.
//
-// Denylist sanitizer — has to block every well-known XSS sink:
-// -