Skip to content
Open
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions backend/src/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
12 changes: 7 additions & 5 deletions backend/src/routes/mail.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,15 @@ 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' });

// Validate category param — only allow known values to prevent SQL injection via the
// 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,
Expand All @@ -123,6 +124,7 @@ router.get('/messages', async (req, res) => {
offset,
unreadOnly,
threaded,
threadScope: safeThreadScope,
category: safeCategory,
});

Expand Down Expand Up @@ -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,
Expand All @@ -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
)
Expand Down
12 changes: 12 additions & 0 deletions backend/src/routes/mail.resolve.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
});
});
10 changes: 6 additions & 4 deletions backend/src/services/messageService.js
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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 (
Expand Down
41 changes: 41 additions & 0 deletions backend/src/services/messageService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] })
Expand Down
5 changes: 5 additions & 0 deletions backend/src/utils/conversationMode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const MODES = new Set(['off', 'list', 'pane']);

export function sanitizeConversationMode(value) {
return MODES.has(value) ? value : null;
}
12 changes: 12 additions & 0 deletions backend/src/utils/conversationMode.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
17 changes: 9 additions & 8 deletions frontend/src/components/AdminPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1799,25 +1799,26 @@ function LayoutsTab() {
</div>
<div style={{ display: 'flex', gap: 8 }}>
{[
{ 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 (
<button
key={String(id)}
onClick={() => setThreadedView(id)}
onClick={() => setConversationMode(id)}
style={{
flex: 1, padding: '10px 12px', textAlign: 'left',
flex: 1, minWidth: 0, padding: '10px 12px', textAlign: 'left',
background: active ? 'var(--bg-hover)' : 'var(--bg-tertiary)',
border: `2px solid ${active ? 'var(--accent)' : 'var(--border-subtle)'}`,
borderRadius: 8, cursor: 'pointer', transition: 'all 0.15s', outline: 'none',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.borderColor = 'var(--border)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.borderColor = 'var(--border-subtle)'; }}
>
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>{desc}</div>
<div style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', marginBottom: 2, overflowWrap: 'anywhere' }}>{label}</div>
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', overflowWrap: 'anywhere' }}>{desc}</div>
</button>
);
})}
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/AdminPanel.layout.test.js
Original file line number Diff line number Diff line change
@@ -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, /<div style=\{\{ display: 'flex', gap: 8 \}\}>/);
assert.doesNotMatch(block, /flexWrap/);
assert.match(block, /flex: 1, minWidth: 0, padding: '10px 12px'/);
assert.equal((block.match(/overflowWrap: 'anywhere'/g) || []).length, 2);
});
});
Loading