Skip to content

Commit 194eda2

Browse files
committed
fix(threads): count Sent replies in the Inbox thread badge (#576)
The threaded Inbox counted a thread's messages in INBOX only, while the thread route that expanding the row and the conversation view use loads every folder. Your own replies live in Sent, so a received message plus your reply counted 1: the row could not expand and the reading pane opened the single-message view, hiding the reply until a second message from the other party arrived. Count across all folders, as non-INBOX views already did. The count is still distinct per account and Message-ID, so a copy in Archive or a second Sent folder counts once, and it uses the existing (account_id, thread_key, message_id) index; EXPLAIN ANALYZE on a 35k message Inbox is unchanged at ~44ms. A two-message thread now behaves like any longer one: delete and move on the row act on the whole conversation, Sent reply included; archive and spam still touch only the Inbox copies.
1 parent bc0bc99 commit 194eda2

3 files changed

Lines changed: 128 additions & 17 deletions

File tree

‎backend/src/services/messageService.js‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,6 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit
8383
if (threaded === 'true' || threaded === true) {
8484
const filterValues = [...values];
8585
const threadAccountParam = isSpecificAccount ? [resolvedAccountId] : scopedAccountIds;
86-
// For INBOX-specific views the thread badge must match the expansion, so scope
87-
// thread_totals to that folder. For other folders (All Mail, Sent, etc.) count
88-
// across all folders so the badge reflects the true thread size.
89-
const threadFolderFilter = isSpecificAccount
90-
? (folder === 'INBOX' ? `AND folder = $2` : '')
91-
: `AND folder = 'INBOX'`;
9286

9387
const threadResult = await query(`
9488
WITH paged_threads AS (
@@ -134,10 +128,14 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit
134128
-- has to count both or it reads 1 beside a conversation holding 2 (#476).
135129
COUNT(DISTINCT (m.account_id, m.message_id))::int AS message_count
136130
FROM messages m
131+
-- Every folder, not just the one listed: the badge has to match what expanding the
132+
-- row or opening the conversation shows, and the thread route loads the thread from
133+
-- all folders. An INBOX-only count left your own replies (in Sent) out, so a received
134+
-- message plus your reply counted 1, and the row could neither expand nor open the
135+
-- conversation view until a second reply arrived (#576).
137136
WHERE m.account_id = ANY($${p})
138137
AND m.is_deleted = false
139138
AND m.message_id IS NOT NULL
140-
${threadFolderFilter}
141139
AND m.thread_key IN (SELECT thread_id FROM paged_threads)
142140
GROUP BY m.thread_key
143141
),

‎backend/src/services/messageService.test.js‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,11 @@ describe('listMessages — threaded mode', () => {
173173
expect(query.mock.calls[2][0]).toContain('DISTINCT ON (m.account_id, m.thread_key, m.message_id)');
174174
});
175175

176-
it('scopes thread_totals to INBOX when viewing a specific account INBOX', async () => {
176+
// #576: the badge counts the thread across folders so it matches the thread route, which
177+
// loads every folder. Scoped to INBOX, a received message plus your reply (in Sent) counted 1.
178+
const threadTotalsSql = (sql) => sql.slice(sql.indexOf('thread_totals AS'), sql.indexOf('ranked AS'));
179+
180+
it('counts thread messages across all folders for a specific account INBOX (#576)', async () => {
177181
query
178182
.mockResolvedValueOnce({ rows: [{ id: 'acc-1' }] })
179183
.mockResolvedValueOnce({ rows: [{ total_count: 10, unread_count: 0 }] })
@@ -182,8 +186,9 @@ describe('listMessages — threaded mode', () => {
182186

183187
await listMessages({ userId: 'user-1', accountId: 'acc-1', folder: 'INBOX', threaded: 'true' });
184188

185-
const cteSql = query.mock.calls[2][0];
186-
expect(cteSql).toContain('AND folder = $2');
189+
const totals = threadTotalsSql(query.mock.calls[2][0]);
190+
expect(totals).toContain('COUNT(DISTINCT (m.account_id, m.message_id))');
191+
expect(totals).not.toMatch(/folder\s*=/);
187192
});
188193

189194
it('counts thread messages across all folders when viewing a non-INBOX folder', async () => {
@@ -195,13 +200,12 @@ describe('listMessages — threaded mode', () => {
195200

196201
await listMessages({ userId: 'user-1', accountId: 'acc-1', folder: 'Sent', threaded: 'true' });
197202

198-
// thread_totals must not be scoped to a specific folder so the badge reflects true thread size
199-
const cteSql = query.mock.calls[2][0];
200-
expect(cteSql).not.toContain('AND folder = $2');
201-
expect(cteSql).not.toContain("AND folder = 'INBOX'");
203+
const totals = threadTotalsSql(query.mock.calls[2][0]);
204+
expect(totals).toContain('COUNT(DISTINCT (m.account_id, m.message_id))');
205+
expect(totals).not.toMatch(/folder\s*=/);
202206
});
203207

204-
it('scopes thread_totals to INBOX for unified inbox threaded view', async () => {
208+
it('counts thread messages across all folders for the unified inbox threaded view (#576)', async () => {
205209
query
206210
.mockResolvedValueOnce({ rows: [{ id: 'acc-1' }, { id: 'acc-2' }] })
207211
.mockResolvedValueOnce({ rows: [{ n: 20 }] })
@@ -210,8 +214,12 @@ describe('listMessages — threaded mode', () => {
210214

211215
await listMessages({ userId: 'user-1', threaded: 'true' });
212216

213-
const cteSql = query.mock.calls[2][0];
214-
expect(cteSql).toContain("AND folder = 'INBOX'");
217+
const sql = query.mock.calls[2][0];
218+
const totals = threadTotalsSql(sql);
219+
expect(totals).toContain('COUNT(DISTINCT (m.account_id, m.message_id))');
220+
expect(totals).not.toMatch(/folder\s*=/);
221+
// The listed rows themselves stay INBOX-only; only the count widens.
222+
expect(sql).toContain("m.folder = 'INBOX'");
215223
});
216224
});
217225

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import pg from 'pg';
3+
4+
vi.mock('./db.js', () => ({ query: vi.fn() }));
5+
import { query } from './db.js';
6+
import { listMessages } from './messageService.js';
7+
8+
// #576: the threaded Inbox badge counts the whole thread, Sent replies included, so it matches
9+
// what the thread route loads. Uses only connection-local temporary tables in an explicitly
10+
// supplied database.
11+
describe.skipIf(!process.env.MAILFLOW_TEST_DATABASE_URL)('threaded Inbox message_count PostgreSQL', () => {
12+
let database;
13+
const account = '22222222-2222-4222-8222-222222222222';
14+
let nextId = 1;
15+
const id = () => `00000000-0000-4000-8000-${String(nextId++).padStart(12, '0')}`;
16+
17+
beforeAll(async () => {
18+
database = new pg.Client({ connectionString: process.env.MAILFLOW_TEST_DATABASE_URL });
19+
await database.connect();
20+
await database.query(`
21+
CREATE TEMP TABLE email_accounts (id uuid, user_id text, enabled boolean,
22+
include_in_unified_inbox boolean, name text, email_address text, color text);
23+
CREATE TEMP TABLE folders (account_id uuid, path text, total_count integer DEFAULT 0,
24+
unread_count integer DEFAULT 0);
25+
CREATE TEMP TABLE messages (id uuid PRIMARY KEY, account_id uuid, folder text,
26+
uid bigint, message_id text, in_reply_to text, thread_references text,
27+
thread_id text, thread_key text, date timestamptz, is_deleted boolean DEFAULT false,
28+
subject text, from_name text, from_email text, to_addresses jsonb, cc_addresses jsonb,
29+
reply_to jsonb, snippet text, is_read boolean DEFAULT false, is_starred boolean DEFAULT false,
30+
has_attachments boolean DEFAULT false, category text, list_unsubscribe text,
31+
list_unsubscribe_post text, delivery_addresses jsonb, spam_verdict text,
32+
spam_user_override text, spam_score_ml real);
33+
CREATE TEMP TABLE contacts (id uuid, user_id text, primary_email text, photo_data text)`);
34+
query.mockImplementation((sql, values) => database.query(sql, values));
35+
});
36+
afterAll(async () => {
37+
await database?.end();
38+
});
39+
beforeEach(async () => {
40+
await database.query('TRUNCATE messages, folders, email_accounts, contacts');
41+
await database.query(
42+
"INSERT INTO email_accounts VALUES ($1,'owner',true,true,'Work','me@example.com','#123456')",
43+
[account],
44+
);
45+
});
46+
47+
const insert = (folder, messageId, threadKey, date, subject) => database.query(
48+
`INSERT INTO messages(id,account_id,folder,message_id,thread_id,thread_key,date,subject)
49+
VALUES ($1,$2,$3,$4,$5,$5,$6,$7)`,
50+
[id(), account, folder, messageId, threadKey, date, subject],
51+
);
52+
53+
const inboxRows = async (accountId) => {
54+
const result = await listMessages({ userId: 'owner', accountId, folder: 'INBOX', threaded: true });
55+
return Object.fromEntries(result.messages.map(row => [row.thread_id, row]));
56+
};
57+
58+
for (const scope of ['account', 'unified']) {
59+
const accountId = scope === 'account' ? account : undefined;
60+
61+
it(`counts your Sent reply in a received thread (${scope} Inbox)`, async () => {
62+
await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question');
63+
await insert('Sent', '<b@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question');
64+
65+
const row = (await inboxRows(accountId))['<a@ext>'];
66+
expect(row.message_count).toBe(2);
67+
// The listed row is still the Inbox message; only the count widens.
68+
expect(row.folder).toBe('INBOX');
69+
expect(row.subject).toBe('Question');
70+
});
71+
72+
it(`counts a conversation started from MailFlow after the first reply (${scope} Inbox)`, async () => {
73+
await insert('Sent', '<s@me>', '<s@me>', '2026-10-01T09:00Z', 'Proposal');
74+
await insert('INBOX', '<r1@ext>', '<s@me>', '2026-10-01T12:00Z', 'Re: Proposal');
75+
76+
expect((await inboxRows(accountId))['<s@me>'].message_count).toBe(2);
77+
});
78+
79+
it(`counts one email once when it sits in several folders (${scope} Inbox)`, async () => {
80+
await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question');
81+
await insert('Archive', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question');
82+
await insert('Sent', '<b@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question');
83+
await insert('Sent Items', '<b@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question');
84+
85+
expect((await inboxRows(accountId))['<a@ext>'].message_count).toBe(2);
86+
});
87+
88+
it(`leaves a lone received message at 1 and lists no Sent-only thread (${scope} Inbox)`, async () => {
89+
await insert('INBOX', '<solo@ext>', '<solo@ext>', '2026-10-01T10:00Z', 'Hello');
90+
await insert('Sent', '<out@me>', '<out@me>', '2026-10-01T11:00Z', 'Unanswered');
91+
92+
const rows = await inboxRows(accountId);
93+
expect(rows['<solo@ext>'].message_count).toBe(1);
94+
expect(rows['<out@me>']).toBeUndefined();
95+
});
96+
}
97+
98+
it('does not count deleted messages', async () => {
99+
await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question');
100+
await insert('Sent', '<b@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question');
101+
await database.query("UPDATE messages SET is_deleted = true WHERE message_id = '<b@me>'");
102+
103+
expect((await inboxRows(account))['<a@ext>'].message_count).toBe(1);
104+
});
105+
});

0 commit comments

Comments
 (0)