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 && ( +
+
+
{t('message.attachment', { count: attachments.length })}
+ {attachments.length > 1 && {t('message.downloadAll')}} +
+
+ {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 => )}
} +
+ {USE_DIV_RENDER ?
: