Files
odysseus/static/js/emailLibrary/bodyRender.js
T
Léo c5db6ae7fd refactor(email): split the email library into seven modules
`emailLibrary/index.js` goes from 11,375 lines to 6,187. What came out:

- `settingsPage.js` (879) — the Email Settings view, its form and controls,
  away/auto-reply including the calendar-event sync, and the display
  preferences. The inline-image preference lives here rather than with the
  renderer because the settings form owns writing it.
- `unsubscribe.js` (1,224) — the bulk-unsubscribe review flow. One exported
  entry point, its own localStorage keys, its own agent-tool-output listeners.
- `reader.js` (676) — opening an email as a docked tab or a floating window,
  plus the AI summary panel both of them share.
- `menus.js` (855) — the reader More menu, the card kebab menu, the bulk
  Actions menu and `_bulkAction`.
- `bodyRender.js` (871) — plain and threaded body rendering, inline MIME
  images, quote folding.
- `attachments.js` (491) — attachment chips and the deferred load for messages
  whose attachment list was not in the list response.
- `aiReply.js` (449) — AI-reply entry points, the per-message context draft,
  the translate and remind submenus.

The extracted modules import back from `index.js`, so the graph has cycles.
That is safe for hoisted function declarations and unsafe for a value read
during evaluation, so nothing crosses a module boundary except functions:
`API_BASE` is re-declared per module, the way `emailInbox.js` and
`emailShared.js` already do it, and `_autoReplyRefreshSeq` moves into
`state.js` because the settings page and the unread-badge refresh both write
it and an imported binding is read-only. The module-graph test enters the
package at each module in turn, which is the order that would expose a
dead-zone read.

Two test helpers grew while doing this. `js_function_source` replaces three
marker-pair slices ("from this signature down to that comment") whose end
marker had moved into another module — the slice ran past the function and
kept passing against the wrong text. Its first implementation balanced braces
by walking characters and ended `_toggleCardPreview` 18 lines early, because
the apostrophe in `// that's a scroll, not a nav` opened a string that ate the
braces after it. It now keys on the invariant the file actually holds: a
top-level declaration starts at column 0, so its closing brace is the next
lone `}` at column 0.
2026-09-30 09:26:34 +02:00

872 lines
37 KiB
JavaScript

// static/js/emailLibrary/bodyRender.js
//
// Turning a fetched message into reader HTML: the plain and threaded body
// paths, inline MIME image handling, and the quote/thread folding that sits on
// top of the detectors in ./signatureFold.js.
//
// Everything here writes into a reader element via innerHTML, so every branch
// that touches server-supplied HTML goes through `_sanitizeHtml` from
// ./utils.js first. `tests/test_security_regressions.py` counts those call
// sites against the raw per-turn HTML reads, package-wide — so do not name that
// property in a comment, or the count stops matching.
import {
_QUOTE_ICON,
_SIG_ICON,
_extractQuoteMeta,
_extractTurnMetaFromBlockquote,
_foldSignature,
_foldSummary,
_harvestAttribution,
_isBloatedSig,
_looksLikeSignature,
} from './signatureFold.js';
import { state } from './state.js';
import {
_SIG_BLOAT_MIN_CHARS,
_TALON_ORIG_RE,
_TALON_WROTE,
_esc,
_escLinkify,
_formatBubbleDate,
_formatRecipients,
_initials,
_parseTurnMeta,
_sanitizeHtml,
_senderColor,
} from './utils.js';
import { _disableInlineImages } from './settingsPage.js';
const API_BASE = window.location.origin;
/**
* Wrap a probable signature block in a collapsed <details> so it stops
* eating the whole reader. We try, in priority order:
* 1. Mail-client signature wrappers — Gmail's `gmail_signature` div is
* explicit, no guessing required. Same for Apple Mail's data-smartmail.
* 2. The standard "-- " RFC 3676 sig delimiter.
* 3. A common closing phrase ("Best regards", "Cheers", etc.) on its own
* line — fuzzier, but catches sigs without the dash marker.
* 4. "Sent from my iPhone/Android" / "Get Outlook for ..." mobile-client
* boilerplate.
* Anything matched gets wrapped from the marker through end-of-body.
*/
/**
* Render the email body with sig/quote folds. If the backend has cached
* LLM-detected boundary offsets (data.boundaries), use those for an exact
* fold based on plain-text positions. Otherwise fall back to the regex
* detectors. The plain-body branch is always preferred when boundaries
* exist because the offsets are computed against plain text.
*/
// Global escape hatch — when the server's thread parser misfires (it
// occasionally splits a single reply into two bogus "turns" by treating a
// signature/disclaimer as its own message), the user can flip this off to
// fall back to plain rendering. Survives reloads.
const _BUBBLES_DISABLED_KEY = 'odysseus.email.bubblesDisabled';
// Threaded chat-bubble email view is DISABLED for now — too buggy to
// ship. Force plain-text rendering everywhere by always returning true.
// Re-enable by restoring the localStorage-backed body + the toggle
// menu item in the reader's More menu.
function _bubblesDisabled() {
return true;
}
function _setBubblesDisabled(v) {
try { localStorage.setItem(_BUBBLES_DISABLED_KEY, v ? '1' : '0'); } catch {}
}
export function _renderEmailBody(data) {
const plain = (typeof data?.body === 'string' && data.body.length) ? data.body : '';
const folder = String(data?.folder || '').toLowerCase();
const isSentFolder = folder.includes('sent');
const fromAddr = String(data?.from_address || '').toLowerCase().trim();
const isMine = !!fromAddr && _meEmailAddrs().has(fromAddr);
// Messages authored by the user (Sent folder or self-sent copies in INBOX)
// are current authored text. Do not let cached boundaries or HTML
// blockquote parsing hide the whole thing behind "Earlier reply".
if ((isSentFolder || isMine) && plain) {
const plainTurns = _renderPlaintextThread(plain);
if (plainTurns && !/^\s*<details\b/i.test(plainTurns.trim())) {
return _foldSignature(plainTurns, null);
}
return _foldSignature(_escLinkify(plain).replace(/\n/g, '<br>'), null);
}
// Prefer the normalized plain-text thread when the message contains
// explicit reply markers. HTML mail from Outlook/Gmail commonly wraps the
// same quoted message in several nested blockquotes; feeding that markup
// to the HTML walker creates phantom chains such as "Earlier reply >
// Earlier reply > sender". The plain representation has the actual quote
// levels and gives us one stable fold per real quoted message.
const hasPlainThreadMarkers = plain && (
/^\s*>/m.test(plain)
|| /^\s*-{5,}\s*(?:Previous message|Original message)\s*-{5,}\s*$/im.test(plain)
|| /^\s*On\s.+?\s(?:wrote|skrev|schrieb|écrit|escribió)\s*:\s*$/im.test(plain)
);
if (hasPlainThreadMarkers) {
const plainThread = _renderPlaintextThread(plain);
if (plainThread) return _foldSignature(plainThread, data && data.sender_signature || null);
}
// Prefer the server-cached thread parse — that's the richest structure
// and the one the chat-bubble layout is built around. Skip when the user
// has manually disabled bubble rendering.
if (!_bubblesDisabled() && Array.isArray(data && data.thread_turns) && data.thread_turns.length) {
return _foldSignature(
_renderTurnsAsBubbles(data.thread_turns, data),
data && data.sender_signature || null,
);
}
const b = data && data.boundaries;
// Use cached boundaries when present AND we have plain-text body to slice
if (b && plain && (b.sig_start >= 0 || b.quote_start >= 0)) {
// Pick the EARLIER of the two as the cut for "everything below this is
// foldable", but render sig and quote with their own labels.
let sig = (typeof b.sig_start === 'number' && b.sig_start >= 0) ? b.sig_start : -1;
let quote = (typeof b.quote_start === 'number' && b.quote_start >= 0) ? b.quote_start : -1;
// Clamp
if (sig >= plain.length) sig = -1;
if (quote >= plain.length) quote = -1;
let head = plain;
let sigSection = '';
let quoteSection = '';
if (sig >= 0 && quote >= 0) {
const earlier = Math.min(sig, quote);
head = plain.slice(0, earlier);
if (sig < quote) {
sigSection = plain.slice(sig, quote);
quoteSection = plain.slice(quote);
} else {
quoteSection = plain.slice(quote, sig);
sigSection = plain.slice(sig);
}
} else if (sig >= 0) {
head = plain.slice(0, sig);
sigSection = plain.slice(sig);
} else {
head = plain.slice(0, quote);
quoteSection = plain.slice(quote);
}
const fmt = (s) => _escLinkify(s).replace(/\n/g, '<br>');
let out = fmt(head);
if (quoteSection) {
out += '<details class="email-quote-fold">'
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(quoteSection))
+ fmt(quoteSection) + '</details>';
}
if (sigSection) {
const sigHtml = fmt(sigSection);
if (_isBloatedSig(sigHtml)) {
out += '<details class="email-sig-fold">' + _foldSummary('Signature', _SIG_ICON)
+ sigHtml + '</details>';
} else {
// Short closing — leave inline; folding would just add chrome.
out += sigHtml;
}
}
return out;
}
// Fallback: client-side parse (HTML or plaintext).
const hintSig = (data && data.sender_signature) || null;
const isHtml = !!data.body_html;
let rendered;
if (isHtml) {
rendered = _sanitizeHtml(data.body_html);
} else {
const plainTurns = _renderPlaintextThread(data.body || '');
if (plainTurns) return _foldSignature(plainTurns, hintSig);
rendered = _escLinkify(data.body || '').replace(/\n/g, '<br>');
}
const threaded = _renderThreadStructure(rendered);
if (threaded) return _prepareEmailInlineImages(_foldSignature(threaded, hintSig));
return _prepareEmailInlineImages(_foldSignature(_foldQuotedReplies(rendered), hintSig));
}
export function _safeRenderEmailBody(data) {
try {
return _prepareEmailInlineImages(_renderEmailBody(data));
} catch (e) {
console.error('email body render failed:', e);
const plain = (typeof data?.body === 'string') ? data.body : '';
if (plain) return _escLinkify(plain).replace(/\n/g, '<br>');
if (data?.body_html) return _prepareEmailInlineImages(_sanitizeHtml(data.body_html));
return '<span style="opacity:.65">No body</span>';
}
}
function _prepareEmailInlineImages(html) {
const raw = String(html || '');
if (!/<img[\s>]/i.test(raw)) return raw;
const doc = new DOMParser().parseFromString(`<div>${raw}</div>`, 'text/html');
const root = doc.body.firstElementChild;
if (!root) return raw;
root.querySelectorAll('img').forEach((img, idx) => {
const src = (img.getAttribute('src') || '').trim();
const alt = (img.getAttribute('alt') || img.getAttribute('title') || '').trim();
const isHttp = /^https?:\/\//i.test(src);
const isCid = /^cid:/i.test(src);
const cid = isCid ? src.replace(/^cid:/i, '').replace(/^<|>$/g, '').trim() : '';
const label = alt || (isCid ? 'Inline image' : 'Remote image');
const ph = doc.createElement('span');
ph.className = 'email-inline-image-placeholder';
ph.setAttribute('role', 'group');
ph.setAttribute('aria-label', label);
if (src) ph.dataset.emailImgSrc = src;
if (cid) ph.dataset.emailImgCid = cid;
ph.innerHTML = `
<span class="email-inline-image-skeleton">
<span class="email-inline-image-icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
</span>
</span>
<span class="email-inline-image-info">
<span class="email-inline-image-title">${_esc(label)}</span>
<span class="email-inline-image-sub">${isHttp ? 'Remote image blocked' : isCid ? 'Inline image hidden' : 'Image unavailable'}</span>
</span>
<span class="email-inline-image-actions">
${isHttp ? `<a class="email-inline-image-btn email-inline-image-download-btn" href="${_esc(src)}" target="_blank" rel="noopener noreferrer" download title="Download" aria-label="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></a>` : ''}
${isCid ? `<button type="button" class="email-inline-image-btn email-inline-image-download-btn" data-email-img-download="${idx}" title="Download" aria-label="Download"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>` : ''}
</span>
`;
img.replaceWith(ph);
});
return root.innerHTML;
}
export function _wireEmailInlineImages(reader) {
if (!reader) return;
const placeholders = Array.from(reader.querySelectorAll('.email-inline-image-placeholder'));
const visiblePlaceholders = placeholders.filter(ph => (
ph.isConnected &&
(ph.dataset.emailImgSrc || ph.dataset.emailImgCid) &&
getComputedStyle(ph).display !== 'none'
));
const autoLoadInlineImages = state._libViewInlineImages !== false;
const manualPlaceholders = autoLoadInlineImages
? visiblePlaceholders.filter(ph => !ph.dataset.emailImgCid)
: visiblePlaceholders;
const existingBulkBar = reader.querySelector('.email-inline-image-load-all');
if (!manualPlaceholders.length) existingBulkBar?.remove();
const loadPlaceholder = (ph) => {
if (!ph || !ph.isConnected) return;
const src = (ph.dataset.emailImgSrc || '').trim();
const cid = (ph.dataset.emailImgCid || '').trim();
const ctxRoot = reader.dataset?.emailUid
? reader
: ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]');
const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX';
const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || '';
const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || '';
const inlineUrl = () => {
if (!uid || !cid) return '';
const params = new URLSearchParams({ cid, folder });
if (account) params.set('account_id', account);
return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`;
};
const isRemoteImage = /^https?:\/\//i.test(src);
const isCidImage = !!cid && !isRemoteImage;
const loadSrc = isRemoteImage ? src : inlineUrl();
if (!loadSrc || ph.classList.contains('is-loading')) return;
ph.classList.remove('is-error');
ph.classList.add('is-loading');
const sub = ph.querySelector('.email-inline-image-sub');
if (sub) sub.textContent = 'Loading image...';
const actions = ph.querySelector('.email-inline-image-actions');
if (actions) actions.style.display = 'none';
const img = document.createElement('img');
img.className = 'email-inline-image-loaded';
img.alt = ph.querySelector('.email-inline-image-title')?.textContent || 'Loaded email image';
img.loading = 'eager';
img.referrerPolicy = 'no-referrer';
let settled = false;
let objectUrl = '';
let frame = null;
let directFallbackUsed = false;
const ensureFrame = () => {
if (frame) return frame;
frame = document.createElement('span');
frame.className = 'email-inline-image-frame is-loading';
frame.appendChild(img);
if (ph.isConnected) ph.replaceWith(frame);
return frame;
};
const showLoaded = () => {
if (settled) return;
settled = true;
if (frame) frame.classList.remove('is-loading');
img.classList.add('is-visible');
};
const showError = (err) => {
if (settled) return;
settled = true;
if (frame?.isConnected) frame.replaceWith(ph);
ph.classList.remove('is-loading');
ph.classList.add('is-error');
if (sub) sub.textContent = err?.message ? `Image failed: ${err.message}` : 'Image failed to load';
if (actions) actions.style.display = '';
if (objectUrl) {
try { URL.revokeObjectURL(objectUrl); } catch {}
}
};
const loadDirect = () => {
directFallbackUsed = true;
ensureFrame();
img.src = loadSrc;
if (img.complete && img.naturalWidth > 0) showLoaded();
};
img.addEventListener('load', showLoaded, { once: true });
img.addEventListener('error', () => {
if (!directFallbackUsed && objectUrl && isCidImage) {
try { URL.revokeObjectURL(objectUrl); } catch {}
objectUrl = '';
loadDirect();
return;
}
showError();
}, { once: true });
if (isCidImage || isRemoteImage) {
loadDirect();
setTimeout(() => {
if (!settled && frame) {
frame.classList.add('is-loading');
}
}, 12000);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
fetch(loadSrc, { credentials: 'same-origin', signal: controller.signal })
.then(async res => {
clearTimeout(timeoutId);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
if (!blob || !blob.size) throw new Error('Empty image');
const type = blob.type && blob.type.startsWith('image/') ? blob.type : 'image/png';
objectUrl = URL.createObjectURL(blob.type === type ? blob : new Blob([blob], { type }));
ensureFrame();
img.src = objectUrl;
if (img.complete && img.naturalWidth > 0) showLoaded();
if (!settled && typeof img.decode === 'function') {
img.decode().then(showLoaded).catch(() => {
if (img.complete && img.naturalWidth === 0) showError();
});
}
})
.catch(err => {
clearTimeout(timeoutId);
showError(err?.name === 'AbortError' ? new Error('request timed out') : err);
});
setTimeout(() => {
if (!settled && !frame) {
ph.classList.remove('is-loading');
if (sub) sub.textContent = 'Still loading...';
if (actions) actions.style.display = '';
}
}, 12000);
};
if (manualPlaceholders.length && !reader.querySelector('.email-inline-image-load-all')) {
const first = manualPlaceholders[0];
const bar = document.createElement('div');
bar.className = 'email-inline-image-load-all';
const label = autoLoadInlineImages
? `${manualPlaceholders.length} external image${manualPlaceholders.length === 1 ? '' : 's'} blocked`
: `${manualPlaceholders.length} hidden inline image${manualPlaceholders.length === 1 ? '' : 's'}`;
bar.innerHTML = `
<span>${label}</span>
<button type="button" data-email-img-dismiss-all>Dismiss</button>
<button type="button" data-email-img-load-all>Load all</button>
`;
bar.querySelector('[data-email-img-load-all]')?.addEventListener('click', ev => {
ev.preventDefault();
ev.stopPropagation();
bar.remove();
manualPlaceholders.forEach(loadPlaceholder);
});
bar.querySelector('[data-email-img-dismiss-all]')?.addEventListener('click', ev => {
ev.preventDefault();
ev.stopPropagation();
bar.remove();
manualPlaceholders.forEach(ph => ph.remove());
_disableInlineImages().catch(() => {});
});
first.parentNode?.insertBefore(bar, first);
}
placeholders.forEach(ph => {
if (ph.dataset.wired !== '1') {
ph.dataset.wired = '1';
const src = (ph.dataset.emailImgSrc || '').trim();
const cid = (ph.dataset.emailImgCid || '').trim();
const ctxRoot = reader.dataset?.emailUid
? reader
: ph.closest('[data-email-uid]') || reader.closest?.('[data-email-uid]');
const folder = ctxRoot?.dataset?.emailFolder || reader.dataset?.emailFolder || state._libFolder || 'INBOX';
const account = ctxRoot?.dataset?.emailAccount || reader.dataset?.emailAccount || state._libAccountId || '';
const uid = ctxRoot?.dataset?.emailUid || reader.dataset?.emailUid || '';
const inlineUrl = () => {
if (!uid || !cid) return '';
const params = new URLSearchParams({ cid, folder });
if (account) params.set('account_id', account);
return `${API_BASE}/api/email/inline-image/${encodeURIComponent(uid)}?${params.toString()}`;
};
ph.querySelector('[data-email-img-download]')?.addEventListener('click', (ev) => {
ev.preventDefault();
ev.stopPropagation();
const url = inlineUrl();
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.download = 'inline-image';
document.body.appendChild(a);
a.click();
a.remove();
});
}
const cid = (ph.dataset.emailImgCid || '').trim();
if (autoLoadInlineImages && cid && ph.isConnected && ph.dataset.emailImgAutoRequested !== '1') {
ph.dataset.emailImgAutoRequested = '1';
loadPlaceholder(ph);
}
});
}
// ── Chat-bubble rendering for email threads ──
// Each parsed turn renders as a chat bubble. Bubbles for the active
// account's outgoing replies align right; everyone else aligns left.
// Order is reversed so the oldest message sits at the top of the
// conversation and the newest (the message currently being read) sits
// at the bottom — matches the mental model people have from chat.
function _meEmailAddrs() {
const set = new Set();
for (const a of (state._libAccounts || [])) {
if (a && a.from_address) set.add(String(a.from_address).toLowerCase().trim());
if (a && a.imap_user) set.add(String(a.imap_user).toLowerCase().trim());
}
return set;
}
// _parseTurnMeta / _formatBubbleDate / _formatRecipients / _senderColor /
// _initials live in ./emailLibrary/utils.js
function _renderTurnsAsBubbles(turns, data) {
if (!Array.isArray(turns) || !turns.length) return '';
const mineSet = _meEmailAddrs();
const lvl0Email = String(data && data.from_address || '').toLowerCase().trim();
const lvl0Mine = !!lvl0Email && mineSet.has(lvl0Email);
const lvl0Author = (data && (data.from_name || data.from_address)) || '';
const lvl0Date = _formatBubbleDate(data && data.date);
// Newest reply on top, older history below. Turns come ordered shallow→deep
// (level 0 = current reply, deeper levels = older quoted material) so we
// render in source order without reversing.
const ordered = turns.slice();
// Gather per-turn sender identity + frequency for the no-self case below.
const turnIdentity = ordered.map((t) => {
if (t.level === 0) {
return { email: lvl0Email, author: lvl0Author };
}
const p = _parseTurnMeta(t.meta || '');
return { email: p.email, author: p.author };
});
const anyMine = turnIdentity.some(x => x.email && mineSet.has(x.email));
// When the user isn't a participant in this thread (forwarded chains,
// historical archives, etc.), assign the two most frequent senders to
// opposite sides so the conversation still reads side-to-side. Third+
// parties fall back to hash mod 2.
const sideForKey = (() => {
if (anyMine) return null;
const freq = new Map();
const firstSeen = new Map();
turnIdentity.forEach((x, i) => {
const key = (x.email || x.author || '').toLowerCase();
if (!key) return;
freq.set(key, (freq.get(key) || 0) + 1);
if (!firstSeen.has(key)) firstSeen.set(key, i);
});
const sorted = [...freq.entries()]
.sort((a, b) => (b[1] - a[1]) || (firstSeen.get(a[0]) - firstSeen.get(b[0])));
const leftKey = sorted[0] && sorted[0][0];
const rightKey = sorted[1] && sorted[1][0];
return (key) => {
if (!key) return 'theirs';
if (key === leftKey) return 'theirs';
if (key === rightKey) return 'mine';
// Stable hash for 3rd+ parties.
let h = 0;
for (let i = 0; i < key.length; i++) h = ((h << 5) - h + key.charCodeAt(i)) | 0;
return (h & 1) ? 'mine' : 'theirs';
};
})();
const rows = ordered.map((t, i) => {
let isMine, author, date;
if (t.level === 0) {
isMine = lvl0Mine;
author = lvl0Author || 'Me';
date = lvl0Date;
} else {
const p = _parseTurnMeta(t.meta || '');
isMine = !!p.email && mineSet.has(p.email);
author = p.author || (t.meta || 'Earlier reply');
date = p.date;
}
// No-self fallback: route by per-sender side mapping.
if (sideForKey) {
const id = turnIdentity[i];
const key = (id.email || id.author || '').toLowerCase();
isMine = sideForKey(key) === 'mine';
}
const side = isMine ? 'mine' : 'theirs';
const initials = _initials(author);
const color = _senderColor(author || (t.level === 0 ? lvl0Email : ''));
const head =
`<div class="email-bubble-head">`
+ `<span class="email-bubble-author" style="color:${color}">${_esc(author)}</span>`
+ (date ? `<span class="email-bubble-date">${_esc(date)}</span>` : '')
+ `</div>`;
const avatar = `<div class="email-bubble-avatar" aria-hidden="true" style="background:${color}">${_esc(initials)}</div>`;
return (
`<div class="email-bubble-row email-bubble-${side}" style="--bubble-accent:${color}">`
+ (isMine ? '' : avatar)
+ `<div class="email-bubble">`
+ head
+ `<div class="email-bubble-body">${_sanitizeHtml(t.body_html || '')}</div>`
+ `</div>`
+ (isMine ? avatar : '')
+ `</div>`
);
});
return `<div class="email-bubbles">${rows.join('')}</div>`;
}
/**
* Render server-cached thread turns (list of {level, body_html, meta})
* into the same nested-card structure the client-side parser produces.
*/
function _renderTurnsFromServer(turns) {
if (!Array.isArray(turns) || !turns.length) return '';
let out = '';
const stack = []; // [{ level, html }]
const wrap = (t) =>
`<details class="email-thread-turn email-quote-fold">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
+ `<div class="email-thread-turn-body">${t.html}</div>`
+ '</details>';
for (const t of turns) {
if (t.level === 0) {
while (stack.length) {
const top = stack.pop();
const w = wrap(top);
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
out += _sanitizeHtml(t.body_html || '');
} else {
while (stack.length && stack[stack.length - 1].level > t.level) {
const top = stack.pop();
const w = wrap(top);
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
if (!stack.length || stack[stack.length - 1].level < t.level) {
stack.push({ level: t.level, meta: t.meta, html: _sanitizeHtml(t.body_html || '') });
} else {
stack[stack.length - 1].html += _sanitizeHtml(t.body_html || '');
if (t.meta && !stack[stack.length - 1].meta) {
stack[stack.length - 1].meta = t.meta;
}
}
}
}
while (stack.length) {
const top = stack.pop();
const w = wrap(top);
if (stack.length) stack[stack.length - 1].html += w; else out += w;
}
// Mark the bottom-most fold for rounded corners.
const lastIdx = out.lastIndexOf('<details class="email-thread-turn email-quote-fold"');
if (lastIdx >= 0) {
out = out.slice(0, lastIdx)
+ out.slice(lastIdx).replace(
'email-thread-turn email-quote-fold"',
'email-thread-turn email-quote-fold last-fold"'
);
}
return out;
}
/**
* Parse an email body's reply chain into a stack of turn-cards.
* Each turn = { author, date, bodyHtml, nested[] } where the body is
* everything UP TO the next quote boundary, and `nested` is the sub-thread
* inside (recursively parsed). Returns null if the email has no quoted
* thread to parse (single message, no folds needed).
*/
// ── Talon-inspired multilingual quote-detection patterns ──
// Sources:
// github.com/mailgun/talon (HTML/text quote detection)
// github.com/crisp-oss/email-reply-parser (locale list)
//
// _TALON_* / _SIG_BLOAT_MIN_CHARS live in ./emailLibrary/utils.js
// _SIG_ICON / _QUOTE_ICON live in ./emailLibrary/signatureFold.js
function _renderThreadStructure(html) {
if (!html || typeof html !== 'string' || html.length > 200000) return null;
let doc;
try { doc = new DOMParser().parseFromString(`<div id="__t">${html}</div>`, 'text/html'); }
catch { return null; }
const root = doc.getElementById('__t');
if (!root) return null;
// Find top-level blockquotes (not nested inside another blockquote).
const tops = Array.from(root.querySelectorAll('blockquote')).filter(b =>
!b.parentElement.closest('blockquote')
);
if (!tops.length) return null;
// Build the current-message body: everything in root up to the first
// top-level blockquote, minus the "On <date>, <author> wrote:" attribution
// line that introduces it.
const head = doc.createElement('div');
let cursor = root.firstChild;
while (cursor && cursor !== tops[0]) {
const next = cursor.nextSibling;
head.appendChild(cursor);
cursor = next;
}
// Strip trailing "On <date>, <name> wrote:" / Outlook-style attribution
// from `head` since the same info will appear in the turn header.
let attribution = _harvestAttribution(head);
// Recursively parse each top-level blockquote into a turn (and its nested chain).
const turnsHtml = [];
for (let i = 0; i < tops.length; i++) {
const bq = tops[i];
// The blockquote may have an Outlook-style "From: / Sent: / Subject:"
// header inside as the first text. Extract that as the turn meta.
const meta = _extractTurnMetaFromBlockquote(bq) || attribution || _extractQuoteMeta(bq.innerHTML);
const innerHtml = bq.innerHTML;
// Heuristic: if a blockquote has no detectable attribution (no "From:",
// no "On <date>... wrote:") AND its content matches signature-style
// patterns (corporate disclaimer, "registered in", legal notices, just
// a name + title), treat it as a Signature fold instead of an Earlier
// Reply. This stops mail clients that wrap signatures in <blockquote>
// from making the signature appear as a phantom prior email.
if (!meta && _looksLikeSignature(innerHtml)) {
turnsHtml.push(
'<details class="email-sig-fold">'
+ _foldSummary('Signature', _SIG_ICON)
+ `<div class="email-sig-body">${innerHtml}</div>`
+ '</details>'
);
attribution = null;
continue;
}
// Recursively render the inside of this blockquote (which may contain
// its own nested blockquotes representing earlier replies).
const nested = _renderThreadStructure(innerHtml);
const bodyHtml = nested || innerHtml;
const isLast = i === tops.length - 1;
turnsHtml.push(
`<details class="email-thread-turn email-quote-fold${isLast ? ' last-fold' : ''}">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, meta || '')
+ `<div class="email-thread-turn-body">${bodyHtml}</div>`
+ '</details>'
);
// Only the first turn uses the harvested attribution; deeper turns
// get their own from inside the blockquote.
attribution = null;
}
return head.innerHTML + turnsHtml.join('');
}
// Looks like a signature / corporate disclaimer rather than a quoted email.
// Used to demote attribution-less blockquotes that some senders wrap their
// sig+disclaimer in (Outlook, EY, big firms) from "Earlier reply" to a
// proper Signature fold. Conservative — only fires when there's no quoted
// reply markers AND it matches strong corporate-noise phrases.
// _looksLikeSignature / _harvestAttribution / _extractTurnMetaFromBlockquote
// live in ./emailLibrary/signatureFold.js
/**
* Wrap any quoted reply chain in a collapsed <details> so deep email threads
* don't dominate the reader. Detects:
* - <blockquote> tags (Gmail / native quoted replies)
* - Outlook-style "From: ... Sent: ... To: ... Subject: ..." headers
* Each gets its own "Earlier thread" toggle.
*/
/**
* Parse a plaintext email body into stacked turn-cards by walking
* `> ` quote-prefix levels and Outlook-style "On X wrote:" / Original-Message
* boundaries. Returns rendered HTML, or null when there's no quoted content
* (caller falls back to flat rendering).
*
* Mirrors talon's `extract_from_plain` and email-reply-parser fragments:
* 1. Lines starting with one or more `>` chars are quoted (level = count of >).
* 2. Increasing the level opens a deeper turn (nested reply).
* 3. `-----Original Message-----` and `On <date>, <name> wrote:` start a
* new turn even without `>`.
* 4. The leading non-quoted segment is the current message.
*/
function _renderPlaintextThread(text) {
if (!text || typeof text !== 'string' || text.length > 200000) return null;
const lines = text.split(/\r?\n/);
const levels = lines.map(l => {
const m = l.match(/^((?:>\s?)+)/);
return m ? (m[1].match(/>/g) || []).length : 0;
});
const hasQuotes = levels.some(l => l > 0);
const attribLineRe = new RegExp(`(?:^|\\n)\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'im');
const hasAttrib = attribLineRe.test(text) || _TALON_ORIG_RE.test(text);
if (!hasQuotes && !hasAttrib) return null;
const turns = [];
let buf = [];
let curLevel = 0;
let pendingMeta = null;
const flush = () => {
if (!buf.length) return;
const t = buf.join('\n').trimEnd();
if (t || curLevel > 0) turns.push({ level: curLevel, text: t, meta: pendingMeta });
buf = [];
pendingMeta = null;
};
for (let i = 0; i < lines.length; i++) {
const lvl = levels[i];
const raw = lines[i];
const stripped = lvl > 0 ? raw.replace(/^(?:>\s?)+/, '') : raw;
const isSeparatorLine = lvl === 0 && /^-{5,}\s*Previous message\s*-{5,}$/i.test(raw.trim());
const isAttribLine = lvl === 0
&& (new RegExp(`^\\s*On\\s.+?\\s${_TALON_WROTE}\\s*:\\s*$`, 'i').test(raw)
|| _TALON_ORIG_RE.test('\n' + raw));
if (isSeparatorLine || isAttribLine) {
flush();
pendingMeta = isSeparatorLine ? null : (_extractQuoteMeta(raw) || raw.trim());
curLevel = 1;
continue;
}
if (lvl !== curLevel) {
flush();
curLevel = lvl;
}
buf.push(stripped);
}
flush();
if (!turns.length || (turns.length === 1 && turns[0].level === 0)) return null;
const fmt = s => _escLinkify(s).replace(/\n/g, '<br>');
let out = '';
const stack = [];
const wrapTurn = (t) =>
`<details class="email-thread-turn email-quote-fold">`
+ _foldSummary('Earlier reply', _QUOTE_ICON, t.meta || '')
+ `<div class="email-thread-turn-body">${t.html}</div>`
+ '</details>';
for (const t of turns) {
if (t.level === 0) {
while (stack.length) {
const top = stack.pop();
const wrapped = wrapTurn(top);
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
}
out += fmt(t.text);
} else {
while (stack.length && stack[stack.length - 1].level > t.level) {
const top = stack.pop();
const wrapped = wrapTurn(top);
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
}
if (!stack.length || stack[stack.length - 1].level < t.level) {
stack.push({ level: t.level, meta: t.meta, html: fmt(t.text) });
} else {
stack[stack.length - 1].html += '<br>' + fmt(t.text);
if (t.meta && !stack[stack.length - 1].meta) stack[stack.length - 1].meta = t.meta;
}
}
}
while (stack.length) {
const top = stack.pop();
const wrapped = wrapTurn(top);
if (stack.length) stack[stack.length - 1].html += wrapped; else out += wrapped;
}
const lastIdx = out.lastIndexOf('<details class="email-thread-turn email-quote-fold"');
if (lastIdx >= 0) {
out = out.slice(0, lastIdx)
+ out.slice(lastIdx).replace(
'email-thread-turn email-quote-fold"',
'email-thread-turn email-quote-fold last-fold"'
);
}
return out;
}
// _foldSummary / _extractQuoteMeta / _SIG_ICON / _QUOTE_ICON
// live in ./emailLibrary/signatureFold.js
function _foldQuotedReplies(html) {
if (!html || typeof html !== 'string') return html;
if (html.length > 200000) return html;
const before = html;
// Use DOMParser for proper nested-blockquote handling. Regex against HTML
// mishandles nesting and leaves orphan close tags that the browser
// re-balances, producing two visually inconsistent fold styles.
try {
const doc = new DOMParser().parseFromString(`<div id="__r">${html}</div>`, 'text/html');
const root = doc.getElementById('__r');
if (root) {
// Only fold TOP-LEVEL blockquotes (children of the root that are not
// already inside another blockquote). The inner blockquote chain stays
// intact inside the fold and renders with the existing
// .email-quote-fold blockquote styles, so everything matches.
const tops = Array.from(root.querySelectorAll('blockquote')).filter(b =>
!b.parentElement.closest('blockquote')
);
if (tops.length) {
for (const bq of tops) {
const det = doc.createElement('details');
det.className = 'email-quote-fold';
// Build the summary as raw HTML — easier than building DOM by hand.
const summary = _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(bq.innerHTML));
det.innerHTML = summary;
bq.parentNode.insertBefore(det, bq);
det.appendChild(bq); // move the original blockquote (and any nested ones) into the details
}
// Tag only the last fold so CSS can give it rounded bottom corners.
const allFolds = root.querySelectorAll('.email-quote-fold');
if (allFolds.length) allFolds[allFolds.length - 1].classList.add('last-fold');
return root.innerHTML;
}
}
} catch (e) {
// Fall through to the legacy regex path below if DOMParser fails
}
// If DOM-pass already wrapped something, we returned above. Otherwise no
// blockquotes were found — try the Outlook-header heuristic.
if (html !== before) return html;
// Outlook-style quoted-reply header — multilingual. Fold from the first
// "From: ... Sent: ... Subject: ..." block through end-of-body so all
// prior thread levels collapse together.
const FROM = '(?:From|Från|Von|De|De\\s|Da|От|Od|Van)';
const SENT = '(?:Sent|Skickat|Gesendet|Envoyé|Inviato|Enviado|Verzonden|Отправлено|Wysłane)';
const SUBJ = '(?:Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat)';
const outlookRe = new RegExp(
`(<br\\s*/?>|</p>|</div>|<p[^>]*>|<div[^>]*>|\\n)\\s*((?:<[^>]+>\\s*)*${FROM}\\s*:\\s*[^<\\n]+(?:<[^>]+>\\s*|\\s)*${SENT}\\s*:[\\s\\S]+?${SUBJ}\\s*:[\\s\\S]+)$`,
'i'
);
const m = html.match(outlookRe);
if (m) {
const idx = html.lastIndexOf(m[0]);
// Outlook fallback only ever produces ONE fold, so tag it as last.
html = html.slice(0, idx) + m[1]
+ '<details class="email-quote-fold last-fold">'
+ _foldSummary('Earlier thread', _QUOTE_ICON, _extractQuoteMeta(m[2]))
+ m[2] + '</details>';
}
return html;
}