Skip to content

Commit aa5f90a

Browse files
committed
fix(threads): leave drafts out of the Inbox thread badge (#576)
194eda2 counted a thread across every folder, which includes Drafts. A received message with a saved reply then counted 2, so its row became a conversation, and deleting or moving a conversation takes every message in it. A deleted draft is expunged, so deleting that message from the Inbox list would have thrown the unsent reply away with it; before 194eda2 the row counted 1 and only the message itself went. The count skips drafts: messages flagged \Draft, and messages in the account's mapped Drafts folder or a folder the existing Drafts rules match (special use, or a stock name at the top level). A user folder that merely has "drafts" in its name still counts. The Postgres tests cover the flag, each folder rule and a Sent reply next to a draft; draft.lookup's temp schema gains the columns the count now reads. EXPLAIN ANALYZE on the largest prod Inboxes is unchanged.
1 parent 41c2494 commit aa5f90a

4 files changed

Lines changed: 70 additions & 12 deletions

File tree

‎backend/src/routes/draft.lookup.postgres.test.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ describe.skipIf(!process.env.MAILFLOW_TEST_DATABASE_URL)('cached reply indicator
2222
await database.query(`
2323
CREATE TEMP TABLE email_accounts (id uuid, user_id text, enabled boolean,
2424
include_in_unified_inbox boolean, folder_mappings jsonb);
25-
CREATE TEMP TABLE folders (account_id uuid, path text, special_use text, no_select boolean);
25+
CREATE TEMP TABLE folders (account_id uuid, path text, special_use text, no_select boolean,
26+
name text, delimiter text);
2627
CREATE TEMP TABLE messages (id uuid PRIMARY KEY, account_id uuid, folder text,
2728
uid bigint, message_id text, in_reply_to text, thread_references text,
28-
thread_id text, thread_key text, date timestamptz, is_deleted boolean DEFAULT false)`);
29+
thread_id text, thread_key text, date timestamptz, is_deleted boolean DEFAULT false,
30+
flags jsonb)`);
2931
await database.query(`
3032
ALTER TABLE email_accounts ADD name text, ADD email_address text, ADD color text;
3133
ALTER TABLE folders ADD total_count integer DEFAULT 0, ADD unread_count integer DEFAULT 0;

‎backend/src/services/messageService.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { query } from './db.js';
22
import { resolveAccountScope } from './unifiedInbox.js';
3+
import { DRAFTS_FOLDER_SQL } from '../utils/mailUtils.js';
34

45
export async function listMessages({ userId, accountId, folder = 'INBOX', limit = 50, offset = 0, unreadOnly, threaded, category }) {
56
const accountsResult = await query(
@@ -133,9 +134,22 @@ export async function listMessages({ userId, accountId, folder = 'INBOX', limit
133134
-- all folders. An INBOX-only count left your own replies (in Sent) out, so a received
134135
-- message plus your reply counted 1, and the row could neither expand nor open the
135136
-- conversation view until a second reply arrived (#576).
137+
--
138+
-- Except drafts. A count of 2 makes the row a conversation, and deleting or moving a
139+
-- conversation takes every message in it; a deleted draft is expunged for good. A saved
140+
-- reply must not turn its message into a conversation and be swept away with it. A draft
141+
-- is known by its IMAP Draft flag or by its folder: the account's mapped Drafts folder, or
142+
-- the special-use and stock-name rules the rest of the app uses (mailUtils.js).
136143
WHERE m.account_id = ANY($${p})
137144
AND m.is_deleted = false
138145
AND m.message_id IS NOT NULL
146+
AND NOT COALESCE(m.flags ? '\\Draft', false)
147+
AND NOT EXISTS (
148+
SELECT 1 FROM folders f
149+
WHERE f.account_id = m.account_id AND f.path = m.folder
150+
AND (${DRAFTS_FOLDER_SQL}
151+
OR f.path = (SELECT a.folder_mappings->>'drafts' FROM email_accounts a WHERE a.id = m.account_id))
152+
)
139153
AND m.thread_key IN (SELECT thread_id FROM paged_threads)
140154
GROUP BY m.thread_key
141155
),

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

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,18 @@ describe.skipIf(!process.env.MAILFLOW_TEST_DATABASE_URL)('threaded Inbox message
1919
await database.connect();
2020
await database.query(`
2121
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);
22+
include_in_unified_inbox boolean, name text, email_address text, color text,
23+
folder_mappings jsonb);
24+
CREATE TEMP TABLE folders (account_id uuid, path text, name text, delimiter text,
25+
special_use text, total_count integer DEFAULT 0, unread_count integer DEFAULT 0);
2526
CREATE TEMP TABLE messages (id uuid PRIMARY KEY, account_id uuid, folder text,
2627
uid bigint, message_id text, in_reply_to text, thread_references text,
2728
thread_id text, thread_key text, date timestamptz, is_deleted boolean DEFAULT false,
2829
subject text, from_name text, from_email text, to_addresses jsonb, cc_addresses jsonb,
2930
reply_to jsonb, snippet text, is_read boolean DEFAULT false, is_starred boolean DEFAULT false,
3031
has_attachments boolean DEFAULT false, category text, list_unsubscribe text,
3132
list_unsubscribe_post text, delivery_addresses jsonb, spam_verdict text,
32-
spam_user_override text, spam_score_ml real);
33+
spam_user_override text, spam_score_ml real, flags jsonb);
3334
CREATE TEMP TABLE contacts (id uuid, user_id text, primary_email text, photo_data text)`);
3435
query.mockImplementation((sql, values) => database.query(sql, values));
3536
});
@@ -39,15 +40,19 @@ describe.skipIf(!process.env.MAILFLOW_TEST_DATABASE_URL)('threaded Inbox message
3940
beforeEach(async () => {
4041
await database.query('TRUNCATE messages, folders, email_accounts, contacts');
4142
await database.query(
42-
"INSERT INTO email_accounts VALUES ($1,'owner',true,true,'Work','me@example.com','#123456')",
43+
"INSERT INTO email_accounts VALUES ($1,'owner',true,true,'Work','me@example.com','#123456',NULL)",
4344
[account],
4445
);
4546
});
4647

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],
48+
const insert = (folder, messageId, threadKey, date, subject, flags = null) => database.query(
49+
`INSERT INTO messages(id,account_id,folder,message_id,thread_id,thread_key,date,subject,flags)
50+
VALUES ($1,$2,$3,$4,$5,$5,$6,$7,$8)`,
51+
[id(), account, folder, messageId, threadKey, date, subject, flags && JSON.stringify(flags)],
52+
);
53+
const folder = (path, { name = path, specialUse = null, delimiter = '/' } = {}) => database.query(
54+
'INSERT INTO folders(account_id,path,name,delimiter,special_use) VALUES ($1,$2,$3,$4,$5)',
55+
[account, path, name, delimiter, specialUse],
5156
);
5257

5358
const inboxRows = async (accountId) => {
@@ -95,6 +100,43 @@ describe.skipIf(!process.env.MAILFLOW_TEST_DATABASE_URL)('threaded Inbox message
95100
});
96101
}
97102

103+
// A count of 2 makes the row a conversation, and deleting a conversation expunges its drafts:
104+
// a saved reply must not turn a lone message into one.
105+
it('does not count a saved reply draft, by its \\Draft flag', async () => {
106+
await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question');
107+
await insert('Drafts', '<d@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question', ['\\Draft', '\\Seen']);
108+
expect((await inboxRows(account))['<a@ext>'].message_count).toBe(1);
109+
expect((await inboxRows(undefined))['<a@ext>'].message_count).toBe(1);
110+
});
111+
112+
it('does not count an unflagged draft in the special-use, stock-named or mapped Drafts folder', async () => {
113+
await folder('[Gmail]/Entwürfe', { name: 'Entwürfe', specialUse: '\\Drafts' });
114+
await folder('Drafts');
115+
await folder('Brouillons');
116+
await database.query(`UPDATE email_accounts SET folder_mappings = '{"drafts":"Brouillons"}'`);
117+
await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question');
118+
await insert('[Gmail]/Entwürfe', '<d1@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question');
119+
await insert('Drafts', '<d2@me>', '<a@ext>', '2026-10-01T11:01Z', 'Re: Question');
120+
await insert('Brouillons', '<d3@me>', '<a@ext>', '2026-10-01T11:02Z', 'Re: Question');
121+
expect((await inboxRows(account))['<a@ext>'].message_count).toBe(1);
122+
});
123+
124+
it('still counts a message in a user folder that merely has "drafts" in its name', async () => {
125+
await folder('Clients/Drafts', { name: 'Drafts' });
126+
await folder('Blog drafts');
127+
await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question');
128+
await insert('Clients/Drafts', '<b@ext>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question');
129+
await insert('Blog drafts', '<c@ext>', '<a@ext>', '2026-10-01T12:00Z', 'Re: Question');
130+
expect((await inboxRows(account))['<a@ext>'].message_count).toBe(3);
131+
});
132+
133+
it('counts the Sent reply but not the draft beside it', async () => {
134+
await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question');
135+
await insert('Sent', '<b@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question', ['\\Seen']);
136+
await insert('Drafts', '<d@me>', '<a@ext>', '2026-10-01T12:00Z', 'Re: Question', ['\\Draft']);
137+
expect((await inboxRows(account))['<a@ext>'].message_count).toBe(2);
138+
});
139+
98140
it('does not count deleted messages', async () => {
99141
await insert('INBOX', '<a@ext>', '<a@ext>', '2026-10-01T10:00Z', 'Question');
100142
await insert('Sent', '<b@me>', '<a@ext>', '2026-10-01T11:00Z', 'Re: Question');

‎backend/src/utils/mailUtils.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export async function mappedFolderUsable(accountId, path) {
3030
const TOP_LEVEL_FOLDER_SQL = `(path = name OR upper(path) = ('INBOX' || delimiter || upper(name)))`;
3131
const TRASH_FOLDER_SQL = `(special_use = '\\Trash'
3232
OR (lower(name) IN ('trash', 'deleted', 'deleted items', 'deleted messages') AND ${TOP_LEVEL_FOLDER_SQL}))`;
33-
const DRAFTS_FOLDER_SQL = `(special_use = '\\Drafts'
33+
export const DRAFTS_FOLDER_SQL = `(special_use = '\\Drafts'
3434
OR (lower(name) IN ('drafts', 'draft') AND ${TOP_LEVEL_FOLDER_SQL}))`;
3535

3636
// Resolve the canonical trash folder path for an account (used as move destination).

0 commit comments

Comments
 (0)