diff --git a/README.md b/README.md
index 5dfea7b2..5b1e24ec 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ If you contribute code, please read the [Contributor License Agreement](CLA.md).
- **Unified inbox** — all accounts merged in one view, sorted by date
- **Email categorization** — automatic inbox tabs (Primary, Newsletters, Social, Notifications, Other) sort incoming mail by type using header detection and sender heuristics; AI reclassify button for misclassifications
- **Unsubscribe** — one-click unsubscribe button appears in the message pane for detected newsletters; sends the request or opens the unsubscribe URL automatically
-- **Conversation threads** — messages grouped into reply chains with inline sent replies
+- **Conversation threads** — choose flat messages, expandable thread rows, or a Gmail-style full conversation reading pane; the full pane includes sent replies, keeps older messages collapsed, and loads historical message bodies on demand
- **Rich text compose** — WYSIWYG editor with font family, size, color, highlight, tables, emoji, links, attachments, image resize handles, and Excel table paste
- **Attachments** — send and receive file attachments across all accounts
- **Multiple layouts** — classic, compact, wide reader, vertical split, and more
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index e9d69b9a..f0741362 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -16,6 +16,7 @@ import { sendSystemEmail } from '../services/mailer.js';
import { invalidateGlobalCategorizationCache } from '../services/categorizer.js';
import { sanitizeGtdPrefs } from '../utils/gtdPrefs.js';
import { sanitizeRightSidebarPrefs } from '../utils/rightSidebarPrefs.js';
+import { sanitizeConversationMode } from '../utils/conversationMode.js';
import { redisClient } from '../services/redis.js';
import { consume as rlConsume, reset as rlReset } from '../services/rateLimiter.js';
@@ -755,7 +756,7 @@ router.patch('/preferences', async (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Not authenticated' });
const { theme, font, layout, notificationSound, pageSize, scrollMode, syncInterval,
blockRemoteImages, imageWhitelist, shortcuts, hiddenFolders, language,
- threadedView, plaintextEmail, hoverQuickActions, swipeActions,
+ threadedView, conversationMode, plaintextEmail, hoverQuickActions, swipeActions,
expandedAccounts, collapsedFolders, favoriteFolders, recentFolders, fontSize,
showAppBadge, showFaviconBadge, replyDefault, sidebarWidth,
categorizationEnabled, markReadBehavior, markReadDelay, aiActions,
@@ -781,6 +782,7 @@ router.patch('/preferences', async (req, res) => {
const markReadBehaviorVal = ['immediate', 'delay', 'manual'].includes(markReadBehavior) ? markReadBehavior : null;
const markReadDelayVal = (() => { const n = parseInt(markReadDelay); return (n >= 1 && n <= 10) ? String(n) : null; })();
const autoLockMinutesVal = [0, 1, 5, 15, 30].includes(Number(autoLockMinutes)) ? String(Number(autoLockMinutes)) : null;
+ const conversationModeVal = sanitizeConversationMode(conversationMode);
// Folder-structure sync cadence in seconds; 0 = never.
const folderSyncIntervalVal = folderSyncInterval != null && [0, 900, 1800, 3600].includes(Number(folderSyncInterval)) ? String(Number(folderSyncInterval)) : null;
// User-defined AI actions: bound the array and each field so the JSONB can't grow unbounded.
@@ -833,6 +835,7 @@ router.patch('/preferences', async (req, res) => {
|| CASE WHEN $36::boolean IS NOT NULL THEN jsonb_build_object('showMobileAvatars', $36::boolean) ELSE '{}'::jsonb END
|| CASE WHEN $37::boolean IS NOT NULL THEN jsonb_build_object('gravatarAvatars', $37::boolean) ELSE '{}'::jsonb END
|| CASE WHEN $38::text IS NOT NULL THEN jsonb_build_object('folderSyncInterval', $38::text) ELSE '{}'::jsonb END
+ || CASE WHEN $39::text IS NOT NULL THEN jsonb_build_object('conversationMode', $39::text) ELSE '{}'::jsonb END
WHERE id = $1
`, [req.session.userId, theme ?? null, font ?? null, layout ?? null, notificationSound ?? null,
pageSize ?? null, scrollMode ?? null, syncInterval ?? null,
@@ -842,7 +845,7 @@ router.patch('/preferences', async (req, res) => {
showAppBadge ?? null, showFaviconBadge ?? null, replyDefaultVal, sidebarWidthVal,
categorizationEnabled ?? null, markReadBehaviorVal, markReadDelayVal, aiActionsJson,
rightSidebarWidth, rightSidebarHidden, gtdCollapsedSectionsJson, gtdPetSlug, autoLockMinutesVal,
- showMobileAvatars ?? null, gravatarAvatars ?? null, folderSyncIntervalVal]);
+ showMobileAvatars ?? null, gravatarAvatars ?? null, folderSyncIntervalVal, conversationModeVal]);
if (syncInterval != null) {
const ms = parseInt(syncInterval) * 1000;
diff --git a/backend/src/routes/mail.js b/backend/src/routes/mail.js
index e35d815b..c4370049 100644
--- a/backend/src/routes/mail.js
+++ b/backend/src/routes/mail.js
@@ -106,7 +106,7 @@ function emitGtdSectionsRefresh(rows, userId) {
// Get messages (unified or per-account/folder)
router.get('/messages', async (req, res) => {
- const { accountId, folder = 'INBOX', limit = 50, offset = 0, unreadOnly, threaded, category } = req.query;
+ const { accountId, folder = 'INBOX', limit = 50, offset = 0, unreadOnly, threaded, threadScope, category } = req.query;
if (!isValidFolderName(folder)) return res.status(400).json({ error: 'Invalid folder name' });
@@ -114,6 +114,7 @@ router.get('/messages', async (req, res) => {
// WHERE clause in listMessages (even though it uses parameterised queries, belt-and-suspenders).
const VALID_CATEGORIES = new Set(['primary', 'newsletter', 'promotion', 'automated', 'social']);
const safeCategory = VALID_CATEGORIES.has(category) ? category : undefined;
+ const safeThreadScope = threadScope === 'all' ? 'all' : 'folder';
const { messages, total, threaded: isThreaded, resolvedAccountId } = await listMessages({
userId: req.session.userId,
@@ -123,6 +124,7 @@ router.get('/messages', async (req, res) => {
offset,
unreadOnly,
threaded,
+ threadScope: safeThreadScope,
category: safeCategory,
});
@@ -248,11 +250,11 @@ router.get('/thread/:threadId', async (req, res) => {
// Show all non-deleted messages in the thread regardless of folder. This includes
// Sent replies (which have distinct message_ids) alongside received messages.
- // DISTINCT ON (m.message_id) deduplicates the same message appearing in multiple
- // folders (e.g. Gmail's All Mail), preferring the INBOX copy.
+ // The RFC Message-ID deduplicates copies across folders (e.g. Gmail's All Mail),
+ // while missing IDs fall back to the row ID so unrelated messages stay distinct.
const result = await query(`
WITH deduped AS (
- SELECT DISTINCT ON (m.message_id)
+ SELECT DISTINCT ON (COALESCE(NULLIF(m.message_id, ''), m.id::text))
m.id, m.uid, m.folder, m.message_id, m.thread_id, m.subject,
m.from_name, m.from_email, m.to_addresses, m.cc_addresses,
m.reply_to, m.in_reply_to,
@@ -265,7 +267,7 @@ router.get('/thread/:threadId', async (req, res) => {
WHERE m.is_deleted = false
AND m.account_id = ANY($1)
AND m.thread_key = $2
- ORDER BY m.message_id,
+ ORDER BY COALESCE(NULLIF(m.message_id, ''), m.id::text),
CASE WHEN m.folder = 'INBOX' THEN 0 ELSE 1 END,
m.date ASC
)
diff --git a/backend/src/routes/mail.resolve.test.js b/backend/src/routes/mail.resolve.test.js
index 774d4e5e..bbed7ea2 100644
--- a/backend/src/routes/mail.resolve.test.js
+++ b/backend/src/routes/mail.resolve.test.js
@@ -71,4 +71,16 @@ describe('GET /api/mail/resolve-message account scope', () => {
expect(response.status).toBe(400);
expect(query).not.toHaveBeenCalled();
});
+
+ it('keeps messages without an RFC Message-ID distinct in thread results', async () => {
+ query.mockResolvedValueOnce({ rows: [{ id: ACCOUNT_ID }] });
+ query.mockResolvedValueOnce({ rows: [] });
+
+ const response = await fetch(`${base}/api/mail/thread/thread-1`);
+
+ expect(response.status).toBe(200);
+ const [sql] = query.mock.calls[1];
+ expect(sql).toContain("DISTINCT ON (COALESCE(NULLIF(m.message_id, ''), m.id::text))");
+ expect(sql).toContain("ORDER BY COALESCE(NULLIF(m.message_id, ''), m.id::text)");
+ });
});
diff --git a/backend/src/services/messageService.js b/backend/src/services/messageService.js
index b65c863c..9f389d5e 100644
--- a/backend/src/services/messageService.js
+++ b/backend/src/services/messageService.js
@@ -1,6 +1,6 @@
import { query } from './db.js';
-export async function listMessages({ userId, accountId, folder = 'INBOX', limit = 50, offset = 0, unreadOnly, threaded, category }) {
+export async function listMessages({ userId, accountId, folder = 'INBOX', limit = 50, offset = 0, unreadOnly, threaded, threadScope = 'folder', category }) {
const accountsResult = await query(
'SELECT id FROM email_accounts WHERE user_id = $1 AND enabled = true',
[userId]
@@ -74,9 +74,11 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit
// For INBOX-specific views the thread badge must match the expansion, so scope
// thread_totals to that folder. For other folders (All Mail, Sent, etc.) count
// across all folders so the badge reflects the true thread size.
- const threadFolderFilter = isSpecificAccount
- ? (folder === 'INBOX' ? `AND folder = $2` : '')
- : `AND folder = 'INBOX'`;
+ const threadFolderFilter = threadScope === 'all'
+ ? ''
+ : isSpecificAccount
+ ? (folder === 'INBOX' ? `AND folder = $2` : '')
+ : `AND folder = 'INBOX'`;
const threadResult = await query(`
WITH paged_threads AS (
diff --git a/backend/src/services/messageService.test.js b/backend/src/services/messageService.test.js
index 34700a58..56fd322b 100644
--- a/backend/src/services/messageService.test.js
+++ b/backend/src/services/messageService.test.js
@@ -122,6 +122,47 @@ describe('listMessages — threaded mode', () => {
expect(cteSql).toContain('AND folder = $2');
});
+ it('keeps INBOX-scoped thread totals when threadScope is folder', async () => {
+ query
+ .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] })
+ .mockResolvedValueOnce({ rows: [{ total_count: 10, unread_count: 0 }] })
+ .mockResolvedValueOnce({ rows: [] })
+ .mockResolvedValueOnce({ rows: [{ total: 0 }] });
+
+ await listMessages({
+ userId: 'user-1',
+ accountId: 'acc-1',
+ folder: 'INBOX',
+ threaded: 'true',
+ threadScope: 'folder',
+ });
+
+ const cteSql = query.mock.calls[2][0];
+ const threadTotalsSql = cteSql.split('thread_totals AS (')[1];
+ expect(threadTotalsSql).toContain('AND folder = $2');
+ });
+
+ it('counts thread totals across folders without widening the selected folder query', async () => {
+ query
+ .mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] })
+ .mockResolvedValueOnce({ rows: [{ total_count: 10, unread_count: 0 }] })
+ .mockResolvedValueOnce({ rows: [] })
+ .mockResolvedValueOnce({ rows: [{ total: 0 }] });
+
+ await listMessages({
+ userId: 'user-1',
+ accountId: 'acc-1',
+ folder: 'INBOX',
+ threaded: 'true',
+ threadScope: 'all',
+ });
+
+ const cteSql = query.mock.calls[2][0];
+ const [selectedFolderSql, threadTotalsSql] = cteSql.split('thread_totals AS (');
+ expect(selectedFolderSql).toContain('m.folder = $2');
+ expect(threadTotalsSql).not.toContain('AND folder = $2');
+ });
+
it('counts thread messages across all folders when viewing a non-INBOX folder', async () => {
query
.mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] })
diff --git a/backend/src/utils/conversationMode.js b/backend/src/utils/conversationMode.js
new file mode 100644
index 00000000..79054acd
--- /dev/null
+++ b/backend/src/utils/conversationMode.js
@@ -0,0 +1,5 @@
+const MODES = new Set(['off', 'list', 'pane']);
+
+export function sanitizeConversationMode(value) {
+ return MODES.has(value) ? value : null;
+}
diff --git a/backend/src/utils/conversationMode.test.js b/backend/src/utils/conversationMode.test.js
new file mode 100644
index 00000000..c92ae305
--- /dev/null
+++ b/backend/src/utils/conversationMode.test.js
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest';
+import { sanitizeConversationMode } from './conversationMode.js';
+
+describe('sanitizeConversationMode', () => {
+ it.each(['off', 'list', 'pane'])('accepts %s', mode => {
+ expect(sanitizeConversationMode(mode)).toBe(mode);
+ });
+
+ it('rejects other values', () => {
+ expect(sanitizeConversationMode('threaded')).toBeNull();
+ });
+});
diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx
index 52904473..4f276356 100644
--- a/frontend/src/components/AdminPanel.jsx
+++ b/frontend/src/components/AdminPanel.jsx
@@ -1419,7 +1419,7 @@ function SwipeActionIcon({ action, size = 17 }) {
function LayoutsTab() {
const { t } = useTranslation();
const isMobile = useMobile();
- const { layout, setLayout, pageSize, setPageSize, scrollMode, setScrollMode, swipeActions, setSwipeAction, syncInterval, setSyncInterval, folderSyncInterval, setFolderSyncInterval, threadedView, setThreadedView, plaintextEmail, setPlaintextEmail, hoverQuickActions, setHoverQuickActions, showMobileAvatars, setShowMobileAvatars, gravatarAvatars, setGravatarAvatars, replyDefault, setReplyDefault, markReadBehavior, setMarkReadBehavior, markReadDelay, setMarkReadDelay } = useStore();
+ const { layout, setLayout, pageSize, setPageSize, scrollMode, setScrollMode, swipeActions, setSwipeAction, syncInterval, setSyncInterval, folderSyncInterval, setFolderSyncInterval, conversationMode, setConversationMode, plaintextEmail, setPlaintextEmail, hoverQuickActions, setHoverQuickActions, showMobileAvatars, setShowMobileAvatars, gravatarAvatars, setGravatarAvatars, replyDefault, setReplyDefault, markReadBehavior, setMarkReadBehavior, markReadDelay, setMarkReadDelay } = useStore();
// "Set MailFlow as your default email app": registerProtocolHandler is the
// cross-browser path (works in Firefox and non-installed Chromium) and must be
@@ -1799,16 +1799,17 @@ function LayoutsTab() {
{[
- { id: false, label: t('admin.messageList.threadingOff'), desc: t('admin.messageList.threadingOffDesc') },
- { id: true, label: t('admin.messageList.threadingOn'), desc: t('admin.messageList.threadingOnDesc') },
+ { id: 'off', label: t('admin.messageList.threadingOff'), desc: t('admin.messageList.threadingOffDesc') },
+ { id: 'list', label: t('admin.messageList.threadingList'), desc: t('admin.messageList.threadingListDesc') },
+ { id: 'pane', label: t('admin.messageList.threadingPane'), desc: t('admin.messageList.threadingPaneDesc') },
].map(({ id, label, desc }) => {
- const active = threadedView === id;
+ const active = conversationMode === id;
return (
);
})}
diff --git a/frontend/src/components/AdminPanel.layout.test.js b/frontend/src/components/AdminPanel.layout.test.js
new file mode 100644
index 00000000..9efeb621
--- /dev/null
+++ b/frontend/src/components/AdminPanel.layout.test.js
@@ -0,0 +1,20 @@
+import { readFileSync } from 'node:fs';
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+const source = readFileSync(new URL('./AdminPanel.jsx', import.meta.url), 'utf8');
+
+describe('conversation mode layout', () => {
+ it('keeps all three mode cards in one equal-width row', () => {
+ const start = source.indexOf('{/* Threading mode */}');
+ const end = source.indexOf('{/* Mark as read behaviour */}', start);
+ assert.notEqual(start, -1);
+ assert.notEqual(end, -1);
+
+ const block = source.slice(start, end);
+ assert.match(block, /
/);
+ assert.doesNotMatch(block, /flexWrap/);
+ assert.match(block, /flex: 1, minWidth: 0, padding: '10px 12px'/);
+ assert.equal((block.match(/overflowWrap: 'anywhere'/g) || []).length, 2);
+ });
+});
diff --git a/frontend/src/components/ConversationMessageCard.jsx b/frontend/src/components/ConversationMessageCard.jsx
new file mode 100644
index 00000000..87a597a6
--- /dev/null
+++ b/frontend/src/components/ConversationMessageCard.jsx
@@ -0,0 +1,233 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useStore } from '../store/index.js';
+import { api } from '../utils/api.js';
+import { formatDate } from '../utils/formatDate.js';
+import { BUILTIN_SUMMARIZE } from '../aiActions.js';
+import { resolveConversationMessageDisclosure } from '../utils/conversation.js';
+import MessageBodyView from './MessageBodyView.jsx';
+import MessageHeaderModal from './MessageHeaderModal.jsx';
+
+function addresses(raw) {
+ try {
+ const list = Array.isArray(raw) ? raw : JSON.parse(raw || '[]');
+ return list.map(item => item.name ? `${item.name} <${item.email}>` : item.email).filter(Boolean).join(', ');
+ } catch {
+ return '';
+ }
+}
+
+export default function ConversationMessageCard({ message, expanded, onToggle, onReply, onForward }) {
+ const { t } = useTranslation();
+ const { updateMessage, addNotification, aiActions } = useStore();
+ const [body, setBody] = useState(null);
+ const [showHeaderModal, setShowHeaderModal] = useState(false);
+ const [starBusy, setStarBusy] = useState(false);
+ const [showMoreMenu, setShowMoreMenu] = useState(false);
+ const [unsubscribeStatus, setUnsubscribeStatus] = useState(null);
+ const [aiStatus, setAiStatus] = useState(null);
+ const [aiResult, setAiResult] = useState(null);
+ const [hasBeenExpanded, setHasBeenExpanded] = useState(expanded);
+ const [visuallyExpanded, setVisuallyExpanded] = useState(expanded);
+ const aiAbortRef = useRef(null);
+
+ useEffect(() => () => aiAbortRef.current?.abort(), []);
+ useEffect(() => {
+ if (expanded) setHasBeenExpanded(true);
+ }, [expanded]);
+ useEffect(() => {
+ if (!expanded) {
+ setVisuallyExpanded(false);
+ return undefined;
+ }
+ const frame = requestAnimationFrame(() => setVisuallyExpanded(true));
+ return () => cancelAnimationFrame(frame);
+ }, [expanded]);
+ useEffect(() => {
+ if (!expanded) setShowMoreMenu(false);
+ }, [expanded]);
+
+ const sender = message.from_name || message.from_email || t('common.unknown');
+ const recipients = addresses(message.to_addresses);
+ const disclosure = resolveConversationMessageDisclosure({ expanded, hasBeenExpanded });
+ const toggleStar = async event => {
+ event.stopPropagation();
+ if (starBusy) return;
+ const starred = !message.is_starred;
+ setStarBusy(true);
+ updateMessage(message.id, { is_starred: starred });
+ try {
+ await api.markStarred(message.id, starred);
+ } catch {
+ updateMessage(message.id, { is_starred: !starred });
+ addNotification({ type: 'error', title: t('common.error', { message: t('message.star') }) });
+ } finally {
+ setStarBusy(false);
+ }
+ };
+
+ const toggleMoreMenu = () => {
+ const next = !showMoreMenu;
+ setShowMoreMenu(next);
+ if (next && !aiStatus) api.ai.status().then(setAiStatus).catch(() => setAiStatus({ enabled: false }));
+ };
+
+ const unsubscribe = async () => {
+ if (unsubscribeStatus === 'loading') return;
+ setShowMoreMenu(false);
+ setUnsubscribeStatus('loading');
+ try {
+ const result = await api.unsubscribeMessage(message.id);
+ const succeeded = ['one-click', 'url', 'mailto'].includes(result.type);
+ if (!succeeded) throw new Error(t('message.unsubscribe.error'));
+ if (result.type === 'url' && result.url) window.open(result.url, '_blank', 'noopener,noreferrer');
+ if (result.type === 'mailto' && result.mailto) window.open(result.mailto, '_blank', 'noopener,noreferrer');
+ setUnsubscribeStatus('done');
+ addNotification({ title: t('message.unsubscribe.done') });
+ } catch {
+ setUnsubscribeStatus('error');
+ addNotification({ type: 'error', title: t('message.unsubscribe.error') });
+ }
+ };
+
+ const runAiAction = async action => {
+ const textContent = body?.text
+ || body?.html?.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
+ || '';
+ if (!action?.id || !textContent) return;
+ setShowMoreMenu(false);
+ aiAbortRef.current?.abort();
+ const controller = new AbortController();
+ aiAbortRef.current = controller;
+ const label = action.id === BUILTIN_SUMMARIZE.id ? t('message.summary') : action.label;
+ setAiResult({ status: 'loading', label, text: '' });
+ try {
+ const response = await fetch('/api/ai/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'MailFlow' },
+ credentials: 'include',
+ signal: controller.signal,
+ body: JSON.stringify({ messages: [{ role: 'user', content: `${action.prompt}\n\n${textContent.slice(0, 6000)}` }] }),
+ });
+ if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || response.statusText);
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let fullText = '';
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop();
+ for (const line of lines) {
+ if (!line.startsWith('data: ')) continue;
+ const chunk = line.slice(6).trim();
+ if (chunk === '[DONE]') continue;
+ try {
+ const delta = JSON.parse(chunk)?.choices?.[0]?.delta?.content;
+ if (delta) {
+ fullText += delta;
+ setAiResult({ status: 'loading', label, text: fullText });
+ }
+ } catch { /* Ignore malformed streaming chunks. */ }
+ }
+ }
+ setAiResult({ status: 'done', label, text: fullText });
+ } catch (requestError) {
+ if (requestError.name !== 'AbortError') setAiResult({ status: 'error', label, text: requestError.message });
+ }
+ };
+
+ return (
+
+
+
+ {disclosure.renderShell && (
+
+ {disclosure.renderContent && (
+
+
+
+
+
+
+
+
+ {showMoreMenu && <>
+
setShowMoreMenu(false)} style={{ position: 'fixed', inset: 0, zIndex: 19 }} />
+
+
+ {message.list_unsubscribe && !message.unsubscribed_at && unsubscribeStatus !== 'done' &&
}
+ {aiStatus?.enabled && aiStatus?.features?.summarize && body && <>
+
+
+ {(aiActions || []).map(action =>
)}
+ >}
+
+ >}
+
+
+ {aiResult &&
+
{aiResult.label}{aiResult.status === 'loading' ? t('common.loading') : aiResult.status === 'error' ? t('common.error', { message: aiResult.text }) : ''}
+ {aiResult.status !== 'error' && aiResult.text &&
{aiResult.text}
}
+
}
+
+ {body &&
{t('compose.from')}: {message.account_email || message.account_name || ''}
}
+
+
+ )}
+
+ )}
+
+ {showHeaderModal &&
setShowHeaderModal(false)} />}
+
+ );
+}
diff --git a/frontend/src/components/ConversationPane.jsx b/frontend/src/components/ConversationPane.jsx
new file mode 100644
index 00000000..e495592f
--- /dev/null
+++ b/frontend/src/components/ConversationPane.jsx
@@ -0,0 +1,391 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useStore } from '../store/index.js';
+import { api } from '../utils/api.js';
+import { useMobile } from '../hooks/useMobile.js';
+import { openForwardFromMessage, openReplyFromMessage } from '../utils/composeFromMessage.js';
+import {
+ conversationMembershipKey,
+ conversationListScopeMessages,
+ conversationPaneOwnsAutoRead,
+ conversationReadTargets,
+ inboxConversationReadTargets,
+ initialExpandedMessageIds,
+ newestConversationMessage,
+ unreadConversationIds,
+ reconcileExpandedMessageIds,
+ shouldFallbackToSingleMessagePane,
+} from '../utils/conversation.js';
+import { useConversation } from '../hooks/useConversation.js';
+import {
+ conversationActionIds,
+ conversationSpamTargets,
+ groupConversationMessagesByAccount,
+ newestSnoozeTarget,
+} from '../utils/conversationActions.js';
+import ConversationMessageCard from './ConversationMessageCard.jsx';
+import MessagePane from './MessagePane.jsx';
+
+function ConversationIcon({ type }) {
+ if (type === 'read') return ;
+ if (type === 'unread') return ;
+ if (type === 'archive') return ;
+ if (type === 'move') return ;
+ if (type === 'spam') return ;
+ if (type === 'snooze') return ;
+ if (type === 'delete') return ;
+ return ;
+}
+
+function ToolbarButton({ title, onClick, disabled, danger = false, children }) {
+ return ;
+}
+
+export default function ConversationPane({ message, threadId, refreshKey }) {
+ const { t } = useTranslation();
+ const isMobile = useMobile();
+ const {
+ accounts, openCompose, setSelectedMessage, updateMessage,
+ decrementUnread, incrementUnread, adjustCategoryCount,
+ markReadBehavior, markReadDelay, replyDefault, addNotification,
+ selectedMessageSource,
+ selectedAccountId, selectedFolder, setSelectedAccount,
+ setUnreadCounts, setCategoryCounts,
+ } = useStore();
+ const { messages, loading, error, retry } = useConversation(threadId, refreshKey);
+ const [expandedIds, setExpandedIds] = useState(() => initialExpandedMessageIds([]));
+ const previousMessagesRef = useRef([]);
+ const automaticExpandedIdRef = useRef(null);
+ const messagesRef = useRef(messages);
+ const autoReadRunRef = useRef(null);
+ const [paneScrolled, setPaneScrolled] = useState(false);
+ const [actionBusy, setActionBusy] = useState(null);
+ const [showMovePicker, setShowMovePicker] = useState(false);
+ const [moveFolders, setMoveFolders] = useState([]);
+ const [moveFoldersLoading, setMoveFoldersLoading] = useState(false);
+ const [moveSearch, setMoveSearch] = useState('');
+ const [showSnoozePicker, setShowSnoozePicker] = useState(false);
+ const [customSnoozeValue, setCustomSnoozeValue] = useState('');
+ messagesRef.current = messages;
+
+ useEffect(() => {
+ const previousMessages = previousMessagesRef.current;
+ if (messages.length === 0) return;
+ if (previousMessages.length === 0) {
+ const initial = initialExpandedMessageIds(messages);
+ setExpandedIds(initial);
+ automaticExpandedIdRef.current = [...initial][0] || null;
+ } else {
+ setExpandedIds(current => {
+ const result = reconcileExpandedMessageIds({
+ previousMessages,
+ nextMessages: messages,
+ expandedIds: current,
+ automaticExpandedId: automaticExpandedIdRef.current,
+ });
+ automaticExpandedIdRef.current = result.automaticExpandedId;
+ return result.expandedIds;
+ });
+ }
+ previousMessagesRef.current = messages;
+ }, [messages]);
+
+ const setConversationRead = useCallback(async read => {
+ const currentMessages = messagesRef.current;
+ const targets = conversationReadTargets(currentMessages, read);
+ const ids = targets.map(item => item.id);
+ if (ids.length === 0) return;
+ const scopedMessages = conversationListScopeMessages(currentMessages, { selectedAccountId, selectedFolder });
+ const previousParentReadState = {
+ is_read: message?.is_read,
+ unread_count: message?.unread_count,
+ };
+ const counterTargetIds = new Set(inboxConversationReadTargets(currentMessages, read).map(item => item.id));
+
+ targets.forEach(item => {
+ updateMessage(item.id, { is_read: read });
+ if (!counterTargetIds.has(item.id)) return;
+ if (read) {
+ decrementUnread(item.account_id);
+ adjustCategoryCount(item.category, -1);
+ } else {
+ incrementUnread(item.account_id);
+ adjustCategoryCount(item.category, 1);
+ }
+ });
+ updateMessage(message?.id, {
+ is_read: read,
+ unread_count: read ? 0 : scopedMessages.length,
+ });
+
+ try {
+ await api.bulkRead(ids, read);
+ } catch (requestError) {
+ targets.forEach(item => {
+ updateMessage(item.id, { is_read: !read });
+ if (!counterTargetIds.has(item.id)) return;
+ if (read) {
+ incrementUnread(item.account_id);
+ adjustCategoryCount(item.category, 1);
+ } else {
+ decrementUnread(item.account_id);
+ adjustCategoryCount(item.category, -1);
+ }
+ });
+ updateMessage(message?.id, previousParentReadState);
+ addNotification({ type: 'error', title: t('common.error', { message: requestError.message || t('message.loadingError') }) });
+ }
+ }, [addNotification, adjustCategoryCount, decrementUnread, incrementUnread, message?.id, message?.is_read, message?.unread_count, selectedAccountId, selectedFolder, t, updateMessage]);
+
+ const membershipKey = useMemo(() => conversationMembershipKey(messages), [messages]);
+
+ useEffect(() => {
+ const currentMessages = messagesRef.current;
+ if (!conversationPaneOwnsAutoRead(selectedMessageSource)) {
+ autoReadRunRef.current = null;
+ return undefined;
+ }
+ if (markReadBehavior === 'manual') {
+ autoReadRunRef.current = null;
+ return undefined;
+ }
+ if (currentMessages.length === 0) return undefined;
+ const unread = unreadConversationIds(currentMessages);
+ if (unread.length === 0) return undefined;
+ const runKey = `${threadId}:${membershipKey}:${markReadBehavior}`;
+ if (markReadBehavior === 'immediate') {
+ if (autoReadRunRef.current === runKey) return undefined;
+ autoReadRunRef.current = runKey;
+ setConversationRead(true);
+ return undefined;
+ }
+ const timer = setTimeout(() => {
+ if (autoReadRunRef.current === runKey) return;
+ autoReadRunRef.current = runKey;
+ setConversationRead(true);
+ }, (markReadDelay || 1) * 1000);
+ return () => clearTimeout(timer);
+ }, [markReadBehavior, markReadDelay, membershipKey, selectedMessageSource, setConversationRead, threadId]);
+
+ const toggleExpanded = useCallback(id => {
+ setExpandedIds(current => {
+ const next = new Set(current);
+ if (next.has(id)) {
+ next.delete(id);
+ if (automaticExpandedIdRef.current === id) automaticExpandedIdRef.current = null;
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ }, []);
+
+ const replyTo = useCallback(async (target, replyAll = replyDefault === 'replyAll') => {
+ try {
+ await openReplyFromMessage(target, {
+ accounts,
+ openCompose,
+ getMessageBody: api.getMessageBody,
+ replyAll,
+ });
+ } catch (requestError) {
+ addNotification({ type: 'error', title: t('common.error', { message: requestError.message || t('message.loadingError') }) });
+ }
+ }, [accounts, addNotification, openCompose, replyDefault, t]);
+
+ const forward = useCallback(async target => {
+ try {
+ await openForwardFromMessage(target, { openCompose, getMessageBody: api.getMessageBody });
+ } catch (requestError) {
+ addNotification({ type: 'error', title: t('common.error', { message: requestError.message || t('message.loadingError') }) });
+ }
+ }, [addNotification, openCompose, t]);
+
+ const newest = useMemo(() => newestConversationMessage(messages), [messages]);
+ const subject = message?.subject || newest?.subject || t('common.noSubject');
+ const actionIds = useMemo(() => conversationActionIds(messages), [messages]);
+
+ const refreshAndClose = useCallback(async () => {
+ const [unreadResult, categoryResult] = await Promise.allSettled([
+ api.getUnreadCounts(),
+ api.getCategoryCounts(selectedAccountId ? { accountId: selectedAccountId } : {}),
+ ]);
+ if (unreadResult.status === 'fulfilled') setUnreadCounts(unreadResult.value);
+ if (categoryResult.status === 'fulfilled') setCategoryCounts(categoryResult.value.counts || {});
+ setSelectedAccount(selectedAccountId, selectedFolder);
+ }, [selectedAccountId, selectedFolder, setCategoryCounts, setSelectedAccount, setUnreadCounts]);
+
+ const archiveConversation = useCallback(async () => {
+ if (!actionIds.length || actionBusy) return;
+ setActionBusy('archive');
+ try {
+ const result = await api.bulkArchive(actionIds);
+ const succeeded = new Set(result.archived || []);
+ const failed = actionIds.length - succeeded.size;
+ addNotification(failed ? {
+ type: 'error',
+ title: result.noArchiveFolder?.length ? t('messageList.bulkArchived.noFolderTitle') : t('messageList.bulkArchived.failTitle'),
+ body: result.noArchiveFolder?.length ? t('messageList.bulkArchived.noFolderBody') : t('messageList.bulkArchived.failBody', { count: failed }),
+ } : { title: t('messageList.bulkArchived.title', { count: succeeded.size }), body: t('messageList.bulkArchived.body') });
+ if (succeeded.size) await refreshAndClose();
+ } catch (requestError) {
+ addNotification({ type: 'error', title: t('messageList.bulkArchived.failTitle'), body: requestError.message || t('messageList.bulkArchived.failBody', { count: actionIds.length }) });
+ } finally {
+ setActionBusy(null);
+ }
+ }, [actionBusy, actionIds, addNotification, refreshAndClose, t]);
+
+ const deleteConversation = useCallback(async () => {
+ if (!actionIds.length || actionBusy) return;
+ setActionBusy('delete');
+ try {
+ const result = await api.bulkDelete(actionIds);
+ const succeeded = new Set(result.deleted || []);
+ const failed = actionIds.length - succeeded.size;
+ addNotification(failed ? {
+ type: 'error', title: t('messageList.bulkDeleted.failTitle'), body: t('messageList.bulkDeleted.failBody', { count: failed }),
+ } : { title: t('messageList.bulkDeleted.title', { count: succeeded.size }), body: t('messageList.bulkDeleted.body') });
+ if (succeeded.size) await refreshAndClose();
+ } catch (requestError) {
+ addNotification({ type: 'error', title: t('messageList.bulkDeleted.failTitle'), body: requestError.message || t('messageList.bulkDeleted.failBody', { count: actionIds.length }) });
+ } finally {
+ setActionBusy(null);
+ }
+ }, [actionBusy, actionIds, addNotification, refreshAndClose, t]);
+
+ const openMovePicker = useCallback(async () => {
+ if (showMovePicker) {
+ setShowMovePicker(false);
+ return;
+ }
+ setShowSnoozePicker(false);
+ setShowMovePicker(true);
+ setMoveSearch('');
+ setMoveFoldersLoading(true);
+ try {
+ const accountIds = Object.keys(groupConversationMessagesByAccount(messages));
+ const folderLists = await Promise.all(accountIds.map(accountId => api.getFolders(accountId)
+ .then(data => Array.isArray(data) ? data : (data.folders || []))));
+ const availableInEveryAccount = (folderLists[0] || []).filter(folder =>
+ folderLists.every(list => list.some(candidate => candidate.path === folder.path))
+ && messages.some(item => item.folder !== folder.path));
+ setMoveFolders(availableInEveryAccount);
+ } catch {
+ setMoveFolders([]);
+ } finally {
+ setMoveFoldersLoading(false);
+ }
+ }, [messages, showMovePicker]);
+
+ const moveConversation = useCallback(async folder => {
+ if (!folder || actionBusy) return;
+ setShowMovePicker(false);
+ setActionBusy('move');
+ const groups = Object.values(groupConversationMessagesByAccount(messages));
+ try {
+ const results = await Promise.allSettled(groups.map(group => api.bulkMove(group.map(item => item.id), folder)));
+ const succeeded = new Set(results.flatMap(result => result.status === 'fulfilled' ? (result.value.moved || []) : []));
+ const failed = actionIds.length - succeeded.size;
+ addNotification(failed ? {
+ type: 'error', title: t('messageList.bulkMoved.failTitle'), body: t('messageList.bulkMoved.failBody', { count: failed }),
+ } : { title: t('messageList.bulkMoved.title', { count: succeeded.size }), body: folder });
+ if (succeeded.size) await refreshAndClose();
+ } finally {
+ setActionBusy(null);
+ }
+ }, [actionBusy, actionIds.length, addNotification, messages, refreshAndClose, t]);
+
+ const spamConversation = useCallback(async () => {
+ if (actionBusy) return;
+ const targets = conversationSpamTargets(messages, accounts);
+ if (!targets.length) return;
+ setActionBusy('spam');
+ try {
+ const results = await Promise.allSettled(targets.map(item => api.markSpam(item.id)));
+ const succeeded = results.filter(result => result.status === 'fulfilled').length;
+ const failed = targets.length - succeeded;
+ addNotification(failed ? {
+ type: 'error', title: t('spam.failTitle'), body: t('spam.failBodyBulk', { count: failed }),
+ } : { title: t('spam.movedToSpamBulk', { count: succeeded }) });
+ if (succeeded) await refreshAndClose();
+ } finally {
+ setActionBusy(null);
+ }
+ }, [accounts, actionBusy, addNotification, messages, refreshAndClose, t]);
+
+ const snoozeConversation = useCallback(async until => {
+ const target = newestSnoozeTarget(messages);
+ if (!target || !until || actionBusy) return;
+ setShowSnoozePicker(false);
+ setActionBusy('snooze');
+ try {
+ await api.snoozeMessage(target.id, until);
+ addNotification({ title: t('message.snoozed.title'), body: subject });
+ await refreshAndClose();
+ } catch (requestError) {
+ addNotification({ type: 'error', title: t('message.snoozed.failTitle'), body: requestError.message || t('message.snoozed.failBody') });
+ } finally {
+ setActionBusy(null);
+ }
+ }, [actionBusy, addNotification, messages, refreshAndClose, subject, t]);
+
+ if (!message) return null;
+ if (shouldFallbackToSingleMessagePane({ loading, error, messages })) return ;
+
+ return (
+
+ {isMobile && (
+
+
+
{subject}
+
+ )}
+
+
+ {!isMobile &&
}
+
{subject}
{t('conversation.messages', { count: messages.length })}
+
+
+
+ {showMovePicker && <>
+
setShowMovePicker(false)} style={{ position: 'fixed', inset: 0, zIndex: 29 }} />
+
+ {!moveFoldersLoading && moveFolders.length > 0 &&
setMoveSearch(event.target.value)} placeholder={t('contextMenu.folders.search')} style={{ width: '100%', boxSizing: 'border-box', padding: '6px 8px', border: '1px solid var(--border)', borderRadius: 5, background: 'var(--bg-tertiary)', color: 'var(--text-primary)' }} />
}
+ {moveFoldersLoading ?
{t('contextMenu.folders.loading')}
+ : moveFolders.length === 0 ?
{t('contextMenu.folders.empty')}
+ : moveFolders.filter(folder => (folder.name || folder.path).toLowerCase().includes(moveSearch.trim().toLowerCase())).map(folder =>
)}
+
+ >}
+
+
+
+
{ setShowMovePicker(false); setShowSnoozePicker(value => !value); }} disabled={Boolean(actionBusy) || !newestSnoozeTarget(messages)} title={t('contextMenu.snooze.label')}>
+ {showSnoozePicker && <>
+
setShowSnoozePicker(false)} style={{ position: 'fixed', inset: 0, zIndex: 29 }} />
+
+ {[
+ { label: t('contextMenu.snooze.threeHours'), date: () => new Date(Date.now() + 3 * 60 * 60 * 1000) },
+ { label: t('contextMenu.snooze.tomorrowMorning'), date: () => { const date = new Date(); date.setDate(date.getDate() + 1); date.setHours(9, 0, 0, 0); return date; } },
+ { label: t('contextMenu.snooze.nextWeek'), date: () => { const date = new Date(); date.setDate(date.getDate() + 7); date.setHours(9, 0, 0, 0); return date; } },
+ ].map(option =>
)}
+
+
+ setCustomSnoozeValue(event.target.value)} aria-label={t('contextMenu.snooze.custom')} style={{ minWidth: 0, flex: 1, padding: '5px 6px', border: '1px solid var(--border)', borderRadius: 5, background: 'var(--bg-tertiary)', color: 'var(--text-primary)', colorScheme: 'dark light' }} />
+
+
+
+ >}
+
+
setConversationRead(messages.some(item => !item.is_read))} disabled={Boolean(actionBusy)} title={messages.some(item => !item.is_read) ? t('contextMenu.markRead') : t('contextMenu.markUnread')}> !item.is_read) ? 'read' : 'unread'} />
+
+
+
+
setPaneScrolled(event.currentTarget.scrollTop > 4)} style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
+ {loading &&
}
+ {error && !loading &&
{error}
}
+ {!loading && !error && messages.map(item =>
toggleExpanded(item.id)} onReply={target => replyTo(target)} onForward={target => forward(target)} />)}
+ {!loading && !error && newest && }
+
+
+ );
+}
diff --git a/frontend/src/components/MailApp.jsx b/frontend/src/components/MailApp.jsx
index 8e2df0e8..c5abbab9 100644
--- a/frontend/src/components/MailApp.jsx
+++ b/frontend/src/components/MailApp.jsx
@@ -11,7 +11,7 @@ import { setPending, pendingMarkReadMap, completedMarkReadMap } from '../utils/p
import { buildKeyMap, buildModKeyMap, getEffectiveShortcuts, getGroupedActions, parseModKey, modLabel, SPECIAL_KEYS, SPECIAL_KEY_LABELS } from '../utils/defaultShortcuts.js';
import Sidebar from './Sidebar.jsx';
import MessageList from './MessageList.jsx';
-import MessagePane from './MessagePane.jsx';
+import ReadingPane from './ReadingPane.jsx';
import GtdSidebarContent from './GtdSidebarContent.jsx';
import NotificationToasts from './NotificationToasts.jsx';
import CommandPalette from './CommandPalette.jsx';
@@ -763,7 +763,7 @@ export default function MailApp() {
-
+
>
) : (
@@ -805,7 +805,7 @@ export default function MailApp() {
onMouseLeave={e => { e.currentTarget.style.background = 'var(--border-subtle)'; }}
/>
)}
-
+
{/* Generic right-sidebar column, populated from the content seam above. */}
{currentLayout.direction === 'row' && rightSidebarContent != null && (
<>
diff --git a/frontend/src/components/MessageBodyView.jsx b/frontend/src/components/MessageBodyView.jsx
new file mode 100644
index 00000000..d8c0794e
--- /dev/null
+++ b/frontend/src/components/MessageBodyView.jsx
@@ -0,0 +1,455 @@
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useStore } from '../store/index.js';
+import { api } from '../utils/api.js';
+import { useMobile } from '../hooks/useMobile.js';
+import { fetchMessageBodyWithRetry } from '../utils/messageBody.js';
+
+const USE_DIV_RENDER = import.meta.env.VITE_EMAIL_DIV_RENDER === 'true';
+
+let prepareEmailHtml = null;
+let injectEmailStyles = null;
+let removeEmailStyles = null;
+if (USE_DIV_RENDER) {
+ ({ prepareEmailHtml } = await import('../utils/scopeEmailCss.js'));
+ ({ injectEmailStyles, removeEmailStyles } = await import('../utils/emailStyleRegistry.js'));
+}
+
+const BODY_CACHE_LIMIT = 50;
+const bodyCache = new Map();
+const bodyCacheOrder = [];
+const imagesRequested = new Set();
+
+function cacheBody(messageId, body) {
+ if (!body?.html && !body?.text) return;
+ bodyCache.set(messageId, body);
+ const previousIndex = bodyCacheOrder.indexOf(messageId);
+ if (previousIndex >= 0) bodyCacheOrder.splice(previousIndex, 1);
+ bodyCacheOrder.push(messageId);
+ while (bodyCacheOrder.length > BODY_CACHE_LIMIT) {
+ bodyCache.delete(bodyCacheOrder.shift());
+ }
+}
+
+function evictBody(messageId) {
+ bodyCache.delete(messageId);
+ const index = bodyCacheOrder.indexOf(messageId);
+ if (index >= 0) bodyCacheOrder.splice(index, 1);
+}
+
+function evictBodies(predicate, clearImageRequests = false) {
+ for (const [id, body] of bodyCache) {
+ if (!predicate(body)) continue;
+ bodyCache.delete(id);
+ if (clearImageRequests) imagesRequested.delete(id);
+ }
+ for (let index = bodyCacheOrder.length - 1; index >= 0; index -= 1) {
+ if (!bodyCache.has(bodyCacheOrder[index])) bodyCacheOrder.splice(index, 1);
+ }
+}
+
+function linkifyText(text) {
+ const escaped = text.replace(/&/g, '&').replace(//g, '>');
+ return escaped.replace(
+ /https?:\/\/[^\s<>"']+/g,
+ url => `
${url}`,
+ );
+}
+
+function formatBytes(bytes) {
+ if (!bytes) return '';
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function FileIcon({ type }) {
+ const normalized = (type || '').toLowerCase();
+ const props = { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.75 };
+ if (normalized.startsWith('image/')) return
;
+ if (normalized === 'application/pdf' || normalized.includes('word') || normalized.includes('document')) return
;
+ if (normalized.includes('sheet') || normalized.includes('excel') || normalized.includes('csv')) return
;
+ if (normalized.includes('zip') || normalized.includes('compressed') || normalized.includes('archive')) return
;
+ if (normalized.startsWith('video/')) return
;
+ if (normalized.startsWith('audio/')) return
;
+ return
;
+}
+
+export default function MessageBodyView({ message, eager = true, onBodyLoaded, beforeContent = null, banner = null, inset = true, framed = true }) {
+ const { t } = useTranslation();
+ const isMobile = useMobile();
+ const { imageWhitelist, addToImageWhitelist, blockRemoteImages, addNotification } = useStore();
+ const [body, setBody] = useState(null);
+ const [bodyError, setBodyError] = useState(null);
+ const [loadingBody, setLoadingBody] = useState(false);
+ const [retryKey, setRetryKey] = useState(0);
+ const [downloadingPart, setDownloadingPart] = useState(null);
+ const [savingAllow, setSavingAllow] = useState(false);
+ const iframeRef = useRef(null);
+ const resizeObserverRef = useRef(null);
+ const emailScaleRef = useRef(1);
+ const outerRef = useRef(null);
+ const scaleRef = useRef(null);
+ const innerRef = useRef(null);
+ const previousBlockingPolicyRef = useRef(null);
+
+ const messageId = message?.id;
+ const prepared = useMemo(() => {
+ if (!USE_DIV_RENDER || !body?.html) return null;
+ return prepareEmailHtml(body.html, String(messageId ?? 'preview'));
+ }, [body?.html, messageId]);
+
+ useEffect(() => {
+ onBodyLoaded?.(body);
+ }, [body, onBodyLoaded]);
+
+ useEffect(() => {
+ const previous = previousBlockingPolicyRef.current;
+ const current = {
+ blockRemoteImages,
+ addressCount: (imageWhitelist?.addresses || []).length,
+ domainCount: (imageWhitelist?.domains || []).length,
+ };
+ previousBlockingPolicyRef.current = current;
+ if (!previous) return;
+
+ const tightened = (!previous.blockRemoteImages && current.blockRemoteImages)
+ || previous.addressCount > current.addressCount
+ || previous.domainCount > current.domainCount;
+ const loosened = (previous.blockRemoteImages && !current.blockRemoteImages)
+ || (!tightened && (current.addressCount > previous.addressCount || current.domainCount > previous.domainCount));
+
+ if (tightened) evictBodies(cached => !cached?.hasBlockedRemoteImages, true);
+ if (loosened) evictBodies(cached => cached?.hasBlockedRemoteImages);
+ if (tightened || loosened) setRetryKey(key => key + 1);
+ }, [blockRemoteImages, imageWhitelist]);
+
+ useLayoutEffect(() => {
+ if (!messageId) {
+ setBody(null);
+ setBodyError(null);
+ setLoadingBody(false);
+ return;
+ }
+
+ const wantsImages = imagesRequested.has(messageId);
+ const cached = bodyCache.get(messageId);
+ if (cached && (!wantsImages || !cached.hasBlockedRemoteImages)) {
+ setBody(cached);
+ setBodyError(null);
+ setLoadingBody(false);
+ return;
+ }
+ if (!eager) {
+ setBody(null);
+ setBodyError(null);
+ setLoadingBody(false);
+ return;
+ }
+ if (cached) evictBody(messageId);
+
+ let cancelled = false;
+ setBody(null);
+ setBodyError(null);
+ setLoadingBody(true);
+
+ fetchMessageBodyWithRetry(messageId, {
+ load: api.getMessageBody,
+ remoteImages: wantsImages,
+ isCancelled: () => cancelled,
+ })
+ .then(data => {
+ if (cancelled) return;
+ cacheBody(messageId, data);
+ setBody(data);
+ })
+ .catch(error => {
+ if (!cancelled) setBodyError(error.message);
+ })
+ .finally(() => {
+ if (!cancelled) setLoadingBody(false);
+ });
+
+ return () => { cancelled = true; };
+ }, [eager, messageId, retryKey]);
+
+ useEffect(() => {
+ const iframe = iframeRef.current;
+ if (!iframe || !body?.html) return;
+ let animationFrame;
+ let lastHeight = 0;
+
+ const setHeight = () => {
+ const doc = iframe.contentDocument;
+ if (!doc) return;
+ const html = doc.documentElement;
+ const emailBody = doc.body;
+ const height = Math.max(
+ html?.scrollHeight || 0,
+ html?.offsetHeight || 0,
+ emailBody?.scrollHeight || 0,
+ emailBody?.offsetHeight || 0,
+ );
+ const scaledHeight = Math.round(height * emailScaleRef.current);
+ if (scaledHeight > lastHeight) {
+ lastHeight = scaledHeight;
+ iframe.style.height = `${scaledHeight}px`;
+ }
+ };
+
+ const onLoaded = () => {
+ emailScaleRef.current = 1;
+ const doc = iframe.contentDocument;
+ if (!doc) return;
+ const emailBody = doc.body;
+ const html = doc.documentElement;
+ for (const element of [emailBody, html]) {
+ if (!element) continue;
+ element.style.setProperty('height', 'auto', 'important');
+ element.style.setProperty('min-height', '0', 'important');
+ element.style.setProperty('overflow-y', 'hidden', 'important');
+ }
+
+ const iframeWidth = iframe.offsetWidth;
+ if (iframeWidth > 0) {
+ emailBody?.style.setProperty('overflow-x', 'visible', 'important');
+ html?.style.setProperty('overflow-x', 'visible', 'important');
+ const contentWidth = Math.max(html?.scrollWidth || 0, emailBody?.scrollWidth || 0);
+ emailBody?.style.removeProperty('overflow-x');
+ html?.style.removeProperty('overflow-x');
+ const wrapper = doc.getElementById('mf-scale-wrapper');
+ if (contentWidth > iframeWidth + 2 && wrapper) {
+ const scale = iframeWidth / contentWidth;
+ emailScaleRef.current = scale;
+ wrapper.style.transform = `scale(${scale})`;
+ wrapper.style.transformOrigin = 'top left';
+ wrapper.style.width = `${contentWidth}px`;
+ }
+ }
+
+ const expandedElements = new Set();
+ const view = doc.defaultView;
+ const expandScrollContainers = () => {
+ if (!view) return;
+ Array.from(doc.querySelectorAll('*')).reverse().forEach(element => {
+ const overflowY = view.getComputedStyle(element).overflowY;
+ const isScrollable = (overflowY === 'auto' || overflowY === 'scroll') && element.scrollHeight > element.clientHeight + 2;
+ const grew = expandedElements.has(element) && element.scrollHeight > element.clientHeight + 2;
+ if (!isScrollable && !grew) return;
+ expandedElements.add(element);
+ element.style.setProperty('overflow-y', 'hidden', 'important');
+ element.style.setProperty('max-height', 'none', 'important');
+ element.style.setProperty('height', `${element.scrollHeight}px`, 'important');
+ });
+ };
+
+ expandScrollContainers();
+ lastHeight = 0;
+ setHeight();
+ animationFrame = requestAnimationFrame(setHeight);
+ doc.addEventListener('click', event => {
+ const anchor = event.target.closest('a[href]');
+ if (!anchor) return;
+ event.preventDefault();
+ let href = anchor.getAttribute('href') || '';
+ if (href.startsWith('//')) href = `https:${href}`;
+ if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) window.open(href, '_blank', 'noopener,noreferrer');
+ });
+ doc.querySelectorAll('img').forEach(image => {
+ if (image.complete) return;
+ image.addEventListener('load', () => { expandScrollContainers(); requestAnimationFrame(setHeight); }, { once: true });
+ image.addEventListener('error', () => requestAnimationFrame(setHeight), { once: true });
+ });
+ const root = doc.body || doc.documentElement;
+ if (window.ResizeObserver && root) {
+ resizeObserverRef.current = new ResizeObserver(() => requestAnimationFrame(setHeight));
+ resizeObserverRef.current.observe(root);
+ }
+ };
+
+ iframe.addEventListener('load', onLoaded, { once: true });
+ if (iframe.contentDocument?.readyState === 'complete') onLoaded();
+ return () => {
+ cancelAnimationFrame(animationFrame);
+ resizeObserverRef.current?.disconnect();
+ resizeObserverRef.current = null;
+ iframe.removeEventListener('load', onLoaded);
+ emailScaleRef.current = 1;
+ };
+ }, [body?.html, messageId]);
+
+ useLayoutEffect(() => {
+ if (!prepared) return;
+ injectEmailStyles(prepared.prefix, prepared.styleBlocks);
+ return () => removeEmailStyles(prepared.prefix);
+ }, [prepared]);
+
+ useEffect(() => {
+ if (!USE_DIV_RENDER || !prepared) return;
+ let animationFrame = null;
+ const expandedElements = new Set();
+ const expandScrollContainers = root => {
+ if (!root) return;
+ Array.from(root.querySelectorAll('*')).reverse().forEach(element => {
+ const overflowY = window.getComputedStyle(element).overflowY;
+ const isScrollable = (overflowY === 'auto' || overflowY === 'scroll') && element.scrollHeight > element.clientHeight + 2;
+ const grew = expandedElements.has(element) && element.scrollHeight > element.clientHeight + 2;
+ if (!isScrollable && !grew) return;
+ expandedElements.add(element);
+ element.style.setProperty('overflow-y', 'hidden', 'important');
+ element.style.setProperty('max-height', 'none', 'important');
+ element.style.setProperty('height', `${element.scrollHeight}px`, 'important');
+ });
+ };
+ const applyScale = () => {
+ const inner = innerRef.current;
+ const outer = outerRef.current;
+ const scaler = scaleRef.current;
+ if (!inner || !outer || !scaler) return;
+ scaler.style.transform = '';
+ scaler.style.transformOrigin = '';
+ scaler.style.width = '';
+ outer.style.height = '';
+ outer.style.overflowX = '';
+ outer.style.overflowY = '';
+ expandScrollContainers(inner);
+ const containerWidth = outer.clientWidth;
+ const contentWidth = inner.scrollWidth;
+ if (containerWidth > 0 && contentWidth > containerWidth + 2) {
+ const scale = containerWidth / contentWidth;
+ scaler.style.width = `${contentWidth}px`;
+ scaler.style.transform = `scale(${scale})`;
+ scaler.style.transformOrigin = 'top left';
+ outer.style.height = `${Math.round(inner.scrollHeight * scale)}px`;
+ outer.style.overflowX = 'hidden';
+ outer.style.overflowY = 'hidden';
+ }
+ };
+ const scheduleScale = () => {
+ if (animationFrame) cancelAnimationFrame(animationFrame);
+ animationFrame = requestAnimationFrame(() => { animationFrame = null; applyScale(); });
+ };
+ const imageListeners = [];
+ innerRef.current?.querySelectorAll('img').forEach(image => {
+ if (image.complete) return;
+ const handler = () => scheduleScale();
+ image.addEventListener('load', handler, { once: true });
+ imageListeners.push({ image, handler });
+ });
+ let observer;
+ if (window.ResizeObserver && innerRef.current) {
+ observer = new ResizeObserver(scheduleScale);
+ observer.observe(innerRef.current);
+ }
+ scheduleScale();
+ return () => {
+ if (animationFrame) cancelAnimationFrame(animationFrame);
+ observer?.disconnect();
+ imageListeners.forEach(({ image, handler }) => image.removeEventListener('load', handler));
+ };
+ }, [prepared]);
+
+ const retry = () => {
+ if (messageId) evictBody(messageId);
+ setRetryKey(key => key + 1);
+ };
+
+ const loadImages = () => {
+ imagesRequested.add(messageId);
+ retry();
+ };
+
+ const allowRemoteImages = async (type, value) => {
+ if (!value) return;
+ setSavingAllow(true);
+ try {
+ await addToImageWhitelist({ type, value });
+ evictBodies(cached => cached?.hasBlockedRemoteImages);
+ setRetryKey(key => key + 1);
+ } catch {
+ addNotification({ title: t('message.whitelistFail.title'), body: t('message.whitelistFail.body') });
+ } finally {
+ setSavingAllow(false);
+ }
+ };
+
+ const downloadAttachment = async attachment => {
+ setDownloadingPart(attachment.part);
+ try {
+ const response = await fetch(`/api/mail/messages/${messageId}/attachments/${encodeURIComponent(attachment.part)}`, { credentials: 'include' });
+ if (!response.ok) throw new Error('Download failed');
+ const url = URL.createObjectURL(await response.blob());
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = attachment.filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(url);
+ } catch (error) {
+ console.error('Download error:', error);
+ } finally {
+ setDownloadingPart(null);
+ }
+ };
+
+ const handleEmailClick = event => {
+ const anchor = event.target.closest('a[href]');
+ if (!anchor) return;
+ event.preventDefault();
+ let href = anchor.getAttribute('href') || '';
+ if (href.startsWith('//')) href = `https:${href}`;
+ if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) window.open(href, '_blank', 'noopener,noreferrer');
+ };
+
+ const attachments = body?.attachments || [];
+ const senderEmail = message?.from_email?.toLowerCase() || '';
+ const senderDomain = senderEmail.includes('@') ? senderEmail.split('@')[1] : '';
+ const horizontalPadding = inset && !isMobile ? '0 28px 24px' : '0 0 16px';
+
+ return (
+ <>
+ {attachments.length > 0 && (
+
+
+
+ {attachments.map((attachment, index) => (
+
+ ))}
+
+
+ )}
+
+ {beforeContent}
+
+ {loadingBody &&
{['62%', '88%', '75%', '50%', '82%', '68%', '90%', '58%'].map((width, index) =>
)}
}
+
+ {!loadingBody && bodyError &&
{t('message.loadingError')}
{bodyError}
}
+
+ {!loadingBody && !bodyError && body && !body.html && !body.text &&
{t('message.noContent')}
}
+
+ {!loadingBody && !bodyError && body?.html && (
+
+ {banner}
+ {body.hasBlockedRemoteImages &&
{t('message.remoteImagesBlocked')}{[
+ { label: t('message.loadImages'), handler: loadImages },
+ senderEmail && { label: t('message.allowSender', { email: senderEmail }), handler: () => allowRemoteImages('address', senderEmail) },
+ senderDomain && { label: t('message.allowDomain', { domain: senderDomain }), handler: () => allowRemoteImages('domain', senderDomain) },
+ ].filter(Boolean).map(action => )}
}
+
+ )}
+
+ {!loadingBody && !bodyError && body?.text && !body.html &&
}
+ >
+ );
+}
diff --git a/frontend/src/components/MessageList.jsx b/frontend/src/components/MessageList.jsx
index 17afb5f7..3498b906 100644
--- a/frontend/src/components/MessageList.jsx
+++ b/frontend/src/components/MessageList.jsx
@@ -20,6 +20,7 @@ import { shortcutBus } from '../utils/shortcutBus.js';
import { createLatestRequest } from '../utils/latestRequest.js';
import { pendingMarkReadMap, completedMarkReadMap, setPending } from '../utils/pendingReads.js';
import { applyDeleteGuard, clearDeleteGuard, clearPendingDelete, setCompletedDelete, setPendingDelete } from '../utils/pendingDeletes.js';
+import { conversationListParams, expandsThreadsInline, groupsMessageList } from '../utils/conversationMode.js';
// Folder icon for move picker
function FolderIcon({ specialUse, size = 13 }) {
@@ -110,7 +111,7 @@ export default function MessageList() {
searchResults, setSearchResults, openCompose, accountsReady, accounts,
messagesRefreshToken, layout, setLayout, pageSize, setPageSize, scrollMode,
setMobileSidebarOpen, unreadCounts, showContacts, setShowContacts,
- threadedView, expandedThreadId, setExpandedThreadId,
+ conversationMode, expandedThreadId, setExpandedThreadId,
threadMessages, setThreadMessages, loadingThread, setLoadingThread,
hoverQuickActions, showMobileAvatars, gravatarAvatars,
swipeActions,
@@ -123,6 +124,8 @@ export default function MessageList() {
// RFC message_id of the open message, so a row highlights when it is a different DB copy
// of the selected message (multi-folder model) — e.g. the inbox copy of a GTD sidebar click.
const selectedMid = useStore(selectSelectedMessageMid);
+ const threadedView = groupsMessageList(conversationMode);
+ const inlineThreadExpansion = expandsThreadsInline(conversationMode);
const isMobile = useMobile();
const isUnified = selectedAccountId === null;
@@ -328,7 +331,7 @@ export default function MessageList() {
params.folder = selectedFolder;
}
if (unreadOnly) params.unreadOnly = 'true';
- if (threadedView) params.threaded = 'true';
+ Object.assign(params, conversationListParams(conversationMode));
if (selectedFolder === 'INBOX' && (categorizationEnabled || selectedAccount?.categorization_enabled)) params.category = activeCategory;
await refreshRequestRef.current.run(
() => api.getMessages(params),
@@ -360,7 +363,7 @@ export default function MessageList() {
};
run();
return () => { cancelled = true; };
- }, [selectedAccountId, selectedFolder, unreadOnly, activeCategory, pageSize, scrollMode, accountsReady, accounts.length, messagesRefreshToken, threadedView, categorizationEnabled, selectedAccount?.categorization_enabled, applyReadGuard, setHasMoreMessages, setLoadingMessages, setMessages, setMessagesOffset, setMessagesTotal]);
+ }, [selectedAccountId, selectedFolder, unreadOnly, activeCategory, pageSize, scrollMode, accountsReady, accounts.length, messagesRefreshToken, conversationMode, categorizationEnabled, selectedAccount?.categorization_enabled, applyReadGuard, setHasMoreMessages, setLoadingMessages, setMessages, setMessagesOffset, setMessagesTotal]);
// Load next page (called by scroll or button)
const loadMore = useCallback(async () => {
@@ -375,7 +378,7 @@ export default function MessageList() {
params.folder = selectedFolder;
}
if (unreadOnly) params.unreadOnly = 'true';
- if (useStore.getState().threadedView) params.threaded = 'true';
+ Object.assign(params, conversationListParams(useStore.getState().conversationMode));
if (selectedFolder === 'INBOX' && (categorizationEnabled || selectedAccount?.categorization_enabled)) params.category = activeCategory;
const data = await api.getMessages(params);
appendMessages(applyReadGuard(data.messages));
@@ -407,7 +410,7 @@ export default function MessageList() {
}
if (selectedAccountId) { params.accountId = selectedAccountId; params.folder = selectedFolder; }
if (unreadOnly) params.unreadOnly = 'true';
- if (state.threadedView) params.threaded = 'true';
+ Object.assign(params, conversationListParams(state.conversationMode));
if (selectedFolder === 'INBOX' && (categorizationEnabled || selectedAccount?.categorization_enabled)) params.category = activeCategory;
await refreshRequestRef.current.run(
() => api.getMessages(params),
@@ -564,7 +567,7 @@ export default function MessageList() {
const params = { limit: pageSize, offset: (pageNum - 1) * pageSize };
if (selectedAccountId) { params.accountId = selectedAccountId; params.folder = selectedFolder; }
if (unreadOnly) params.unreadOnly = 'true';
- if (threadedView) params.threaded = 'true';
+ Object.assign(params, conversationListParams(conversationMode));
if (selectedFolder === 'INBOX' && (categorizationEnabled || selectedAccount?.categorization_enabled)) params.category = activeCategory;
await refreshRequestRef.current.run(
() => api.getMessages(params),
@@ -582,7 +585,7 @@ export default function MessageList() {
} finally {
setLoadingMessages(false);
}
- }, [selectedAccountId, selectedFolder, unreadOnly, activeCategory, pageSize, loadingMessages, threadedView, categorizationEnabled, selectedAccount?.categorization_enabled, applyReadGuard, setExpandedThreadId, setHasMoreMessages, setLoadingMessages, setMessages, setMessagesOffset, setMessagesTotal]);
+ }, [selectedAccountId, selectedFolder, unreadOnly, activeCategory, pageSize, loadingMessages, conversationMode, categorizationEnabled, selectedAccount?.categorization_enabled, applyReadGuard, setExpandedThreadId, setHasMoreMessages, setLoadingMessages, setMessages, setMessagesOffset, setMessagesTotal]);
const handleSync = async () => {
if (syncing) return;
@@ -2156,6 +2159,13 @@ export default function MessageList() {
handleSelect(message);
return;
}
+ if (!inlineThreadExpansion) {
+ clearTimeout(autoMarkReadTimerRef.current);
+ autoMarkReadTimerRef.current = null;
+ setSelectedMessage(message.id);
+ listRef.current?.focus({ preventScroll: true });
+ return;
+ }
if (expandedThreadId === tid) {
setExpandedThreadId(null);
return;
@@ -3374,7 +3384,8 @@ export default function MessageList() {
- }
{messageCount}
)}
@@ -4092,7 +4103,7 @@ function ThreadRow({ message, isExpanded, threadMsgs, isLoadingThread, selectedM
{/* end swipe container */}
{/* Expanded sub-rows */}
- {isExpanded && (
+ {inlineExpansion && isExpanded && (
{isLoadingThread ? (
diff --git a/frontend/src/components/MessagePane.jsx b/frontend/src/components/MessagePane.jsx
index 969b7d47..e697ebc7 100644
--- a/frontend/src/components/MessagePane.jsx
+++ b/frontend/src/components/MessagePane.jsx
@@ -12,29 +12,14 @@ import DOMPurify from 'dompurify';
import { BUILTIN_SUMMARIZE } from '../aiActions.js';
import { getResults, saveResult, removeResult } from '../aiResults.js';
import { renderMarkdown } from '../utils/renderMarkdown.js';
-const USE_DIV_RENDER = import.meta.env.VITE_EMAIL_DIV_RENDER === 'true';
-const MESSAGE_OPENING_EVENT = 'mailflow:message-opening';
-
-// Module-level regex so the spam-name heuristic isn't recompiled on every
-// render — same heuristic as ContextMenu.jsx, both files read this constant.
-const SPAM_NAME_RE = /(spam|junk|bulk|indesiderata|spamverdacht|courrier\s*ind|posta\s*indesiderata)/i;
-
-// Lazy-load the div-renderer utilities so PostCSS is excluded from the flag-off
-// bundle. Rollup treats the import() calls inside this block as dead code when
-// USE_DIV_RENDER compiles to false, stripping PostCSS and both utility modules.
-// In the flag-on build they live in the same chunk, so the dynamic imports
-// resolve synchronously — no perceptible delay before first render.
-let prepareEmailHtml = null;
-let injectEmailStyles = null;
-let removeEmailStyles = null;
-if (USE_DIV_RENDER) {
- ({ prepareEmailHtml } = await import('../utils/scopeEmailCss.js'));
- ({ injectEmailStyles, removeEmailStyles } = await import('../utils/emailStyleRegistry.js'));
-}
import { senderColor } from '../themes.js';
import MessageHeaderModal from './MessageHeaderModal.jsx';
import FolderIcon from './FolderIcon.jsx';
import TodoistTaskModal from './TodoistTaskModal.jsx';
+import MessageBodyView from './MessageBodyView.jsx';
+
+const MESSAGE_OPENING_EVENT = 'mailflow:message-opening';
+const SPAM_NAME_RE = /(spam|junk|bulk|indesiderata|spamverdacht|courrier\s*ind|posta\s*indesiderata)/i;
function parseAddressField(raw) {
try {
@@ -43,56 +28,12 @@ function parseAddressField(raw) {
} catch { return ''; }
}
-function linkifyText(text) {
- const escaped = text.replace(/&/g, '&').replace(//g, '>');
- return escaped.replace(
- /https?:\/\/[^\s<>"']+/g,
- url => `
${url}`
- );
-}
-
-function formatBytes(bytes) {
- if (!bytes) return '';
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
-
-function fileIcon(type) {
- const t = (type || '').toLowerCase();
- const p = { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.75 };
- if (t.startsWith('image/')) return (
-
- );
- if (t === 'application/pdf') return (
-
- );
- if (t.includes('word') || t.includes('document')) return (
-
- );
- if (t.includes('sheet') || t.includes('excel') || t.includes('csv')) return (
-
- );
- if (t.includes('zip') || t.includes('compressed') || t.includes('archive')) return (
-
- );
- if (t.startsWith('video/')) return (
-
- );
- if (t.startsWith('audio/')) return (
-
- );
- return (
-
- );
-}
-
export default function MessagePane() {
const { t } = useTranslation();
const {
messages, searchResults, searchQuery, selectedMessageId, setSelectedMessage,
updateMessage, removeMessage, decrementUnread, incrementUnread, openCompose, accounts, addNotification,
- imageWhitelist, addToImageWhitelist, blockRemoteImages, threadMessages,
+ threadMessages,
replyDefault, shortcuts, recentFolders, favoriteFolders, todoistConnected,
categorizationEnabled, setCategoryCounts, adjustCategoryCount,
aiActions, setShowAdmin, setAdminTab,
@@ -183,7 +124,6 @@ export default function MessagePane() {
// previous (possibly taller) email.
useLayoutEffect(() => {
if (scrollContainerRef.current) scrollContainerRef.current.scrollTop = 0;
- if (iframeRef.current) iframeRef.current.style.height = '300px';
}, [selectedMessageId]);
useEffect(() => {
@@ -263,12 +203,7 @@ export default function MessagePane() {
const hasNext = currentIdx >= 0 && currentIdx < allMessages.length - 1;
const [body, setBody] = useState(null);
- const [bodyError, setBodyError] = useState(null);
- const [retryKey, setRetryKey] = useState(0);
- const [loadingBody, setLoadingBody] = useState(false);
- const [downloadingPart, setDownloadingPart] = useState(null);
const [showReplyMenu, setShowReplyMenu] = useState(false);
- const [savingAllow, setSavingAllow] = useState(false);
const [paneScrolled, setPaneScrolled] = useState(false);
const [showHeaderModal, setShowHeaderModal] = useState(false);
const [resolvedSubject, setResolvedSubject] = useState(null);
@@ -291,450 +226,8 @@ export default function MessagePane() {
// One AbortController per in-flight action, keyed by action key.
const aiAbortRefs = useRef({});
const scrollContainerRef = useRef(null);
- const iframeRef = useRef(null);
- const roRef = useRef(null);
- // useMemo so prepared is available in the same render as body.html — no extra frame,
- // no flash of empty content between skeleton-gone and email-shown.
- const prepared = useMemo(() => {
- if (!USE_DIV_RENDER || !body?.html) return null;
- return prepareEmailHtml(body.html, String(message?.id ?? 'preview'));
- }, [body?.html, message?.id]);
- const outerRef = useRef(null);
- const scaleRef = useRef(null);
- const innerRef = useRef(null);
- const bodyCache = useRef({}); // messageId -> body, so revisiting is instant (capped at 50)
- const bodyCacheOrder = useRef([]); // insertion-order keys for LRU eviction
- // Session-scoped set of message IDs where the user has clicked "Load images once"
- const imagesRequestedRef = useRef(new Set());
// Ref holding the latest pane action handlers so shortcut subscriptions ([] deps) never go stale
const paneActionsRef = useRef({});
- const emailScaleRef = useRef(1); // scale applied to wide emails that resist CSS reflow
-
- // Track previous blocking policy so we can detect tightening vs loosening.
- const prevBlockingPolicyRef = useRef(null);
-
- // Flush body cache when the image-blocking policy changes:
- // - Tightening (blocking ON, or whitelist entry removed): evict unblocked entries so they
- // re-fetch with blocking applied. Also clear imagesRequestedRef for evicted IDs so a
- // prior "load images once" click doesn't silently bypass the re-tightened policy.
- // - Loosening globally (blocking turned OFF): evict blocked entries so the current email
- // immediately shows images without requiring navigation. Whitelist additions are handled
- // directly in handleAllowSender/Domain to avoid triggering a double-eviction here.
- useEffect(() => {
- const prev = prevBlockingPolicyRef.current;
- const curr = {
- blockRemoteImages,
- addrCount: (imageWhitelist?.addresses || []).length,
- domainCount: (imageWhitelist?.domains || []).length,
- };
- prevBlockingPolicyRef.current = curr;
- if (!prev) return; // skip initial mount
-
- const tightened =
- (!prev.blockRemoteImages && curr.blockRemoteImages) ||
- prev.addrCount > curr.addrCount ||
- prev.domainCount > curr.domainCount;
- const loosenedGlobally = prev.blockRemoteImages && !curr.blockRemoteImages;
- // Whitelist additions from any source (email banner or Admin Privacy tab) need
- // blocked cache entries evicted. The email banner handlers also do this directly
- // but a second eviction pass on an already-empty slot is harmless.
- const loosenedViaWhitelist = !tightened && (
- curr.addrCount > prev.addrCount || curr.domainCount > prev.domainCount
- );
-
- let evicted = false;
- if (tightened) {
- for (const id of Object.keys(bodyCache.current)) {
- if (!bodyCache.current[id]?.hasBlockedRemoteImages) {
- delete bodyCache.current[id];
- imagesRequestedRef.current.delete(id); // clear "load once" so policy is respected
- evicted = true;
- }
- }
- }
- if (loosenedGlobally || loosenedViaWhitelist) {
- for (const id of Object.keys(bodyCache.current)) {
- if (bodyCache.current[id]?.hasBlockedRemoteImages) {
- delete bodyCache.current[id];
- evicted = true;
- }
- }
- }
- if (evicted) {
- bodyCacheOrder.current = bodyCacheOrder.current.filter(id => bodyCache.current[id]);
- setRetryKey(k => k + 1);
- }
- }, [blockRemoteImages, imageWhitelist]);
-
- useLayoutEffect(() => {
- if (!selectedMessageId) {
- setBody(null);
- setBodyError(null);
- setLoadingBody(false);
- return;
- }
-
- // Serve from cache when available — avoids re-fetching on revisit.
- // Skip the cache (or clear a stale blocked entry) when the user has explicitly
- // requested images for this message so we re-fetch with ?remoteImages=1.
- const wantsImages = imagesRequestedRef.current.has(selectedMessageId);
- const cached = bodyCache.current[selectedMessageId];
- if (cached && (cached.html || cached.text)) {
- if (!wantsImages || !cached.hasBlockedRemoteImages) {
- setBody(cached);
- setBodyError(null);
- setLoadingBody(false);
- return;
- }
- // Cache has the blocked version but user wants images — evict and re-fetch
- delete bodyCache.current[selectedMessageId];
- }
-
- // Clear previous content immediately so stale body never shows for a new message
- setBody(null);
- setBodyError(null);
- setLoadingBody(true);
-
- // Cancellation flag — prevents a slow in-flight fetch for a previous message
- // from overwriting state after the user has already moved to a different message.
- let cancelled = false;
-
- // Auto-retry helper: retries on transient errors (not-found race, dead IMAP
- // connection, etc.) with exponential backoff before surfacing a permanent error.
- const fetchWithRetry = async (id, attemptsLeft = 2, delay = 500) => {
- try {
- return await api.getMessageBody(id, imagesRequestedRef.current.has(id));
- } catch (err) {
- const isNotFound = /not found/i.test(err.message);
- const isTransient = /Command failed|Command canceled|timed out|ECONNRESET|socket hang up|EPIPE/i.test(err.message);
- if ((isNotFound || isTransient) && attemptsLeft > 0 && !cancelled) {
- await new Promise(r => setTimeout(r, delay));
- if (cancelled) throw err; // user navigated away during wait
- return fetchWithRetry(id, attemptsLeft - 1, delay * 2);
- }
- throw err;
- }
- };
-
- fetchWithRetry(selectedMessageId)
- .then(data => {
- if (cancelled) return;
- // Only cache if there's real content — empty results can be retried
- if (data.html || data.text) {
- bodyCache.current[selectedMessageId] = data;
- bodyCacheOrder.current.push(selectedMessageId);
- // Evict oldest entry when cache exceeds 50 messages
- if (bodyCacheOrder.current.length > 50) {
- const evicted = bodyCacheOrder.current.shift();
- delete bodyCache.current[evicted];
- }
- }
- setBody(data);
- })
- .catch(err => {
- if (cancelled) return;
- setBodyError(err.message);
- })
- .finally(() => {
- if (!cancelled) setLoadingBody(false);
- });
-
- return () => { cancelled = true; };
- }, [selectedMessageId, retryKey]);
-
- // Size the iframe to its full content height so no internal scrollbar appears.
- // The outer overflow:auto container is the only scrollbar the user sees.
- //
- // Key design: overflow:hidden is injected via the srcDoc