Merge pull request #35 from lucletoffe/fix/reply-threading-msgid

fix(reply): send the parent's RFC msg-id in In-Reply-To, not its JMAP id
This commit is contained in:
Linus Rath
2026-08-06 16:25:28 +02:00
committed by GitHub
5 changed files with 102 additions and 1 deletions
+2
View File
@@ -12,6 +12,8 @@ const EMAIL_FULL_PROPERTIES = [
...EMAIL_LIST_PROPERTIES,
'bodyStructure', 'textBody', 'htmlBody', 'bodyValues',
'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt',
// Needed to thread replies (In-Reply-To/References) — see lib/email-threading.
'messageId', 'inReplyTo', 'references',
];
// Prefix a shared account's folder ids so they can't collide with the user's
+4
View File
@@ -84,6 +84,10 @@ export interface Email {
attachments?: Attachment[];
blobId?: string;
bodyStructure?: BodyPart;
/** RFC 5322 msg-ids, bare (no angle brackets) per RFC 8621 §4.1.2.3. */
messageId?: string[];
inReplyTo?: string[];
references?: string[];
}
export interface MailboxRights {
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { computeReplyThreadingHeaders } from '../email-threading';
describe('computeReplyThreadingHeaders', () => {
it('returns undefined when the parent has no msg-id', () => {
expect(computeReplyThreadingHeaders(undefined)).toBeUndefined();
expect(computeReplyThreadingHeaders({})).toBeUndefined();
expect(computeReplyThreadingHeaders({ messageId: [] })).toBeUndefined();
});
it('brackets the parent msg-id', () => {
expect(computeReplyThreadingHeaders({ messageId: ['abc@host'] })).toEqual({
inReplyTo: '<abc@host>',
references: '<abc@host>',
});
});
it('keeps the ancestor chain and closes it with the parent', () => {
expect(
computeReplyThreadingHeaders({
messageId: ['c@host'],
references: ['a@host', 'b@host'],
}),
).toEqual({
inReplyTo: '<c@host>',
references: '<a@host> <b@host> <c@host>',
});
});
it('de-dupes an ancestor that is already the parent', () => {
expect(
computeReplyThreadingHeaders({ messageId: ['a@host'], references: ['a@host'] }),
).toEqual({ inReplyTo: '<a@host>', references: '<a@host>' });
});
it('tolerates ids that already carry angle brackets', () => {
expect(
computeReplyThreadingHeaders({ messageId: ['<a@host>'], references: ['<z@host>'] }),
).toEqual({ inReplyTo: '<a@host>', references: '<z@host> <a@host>' });
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* RFC 5322 §3.6.4 threading headers for replies.
*
* The JMAP `Email` id (`gos2aabtux`) is a server-local handle — it is NOT the
* RFC msg-id. Putting it in In-Reply-To/References breaks the conversation for
* every downstream client, including the account's own server: Stalwart hashes
* the referenced msg-ids at ingest time, finds nothing that matches, and files
* the reply under a brand-new thread.
*
* `messageId` / `references` come back from JMAP as bare ids (no angle
* brackets, RFC 8621 §4.1.2.3); the wire headers need them bracketed.
*/
export interface ParentThreadingInfo {
messageId?: string[];
references?: string[];
}
export interface ReplyThreadingHeaders {
/** In-Reply-To header value, e.g. `<abc@host>` */
inReplyTo: string;
/** References header value: ancestors then parent, space separated */
references: string;
}
function stripBrackets(id: string): string {
return id.trim().replace(/^<|>$/g, '');
}
export function computeReplyThreadingHeaders(
parent: ParentThreadingInfo | undefined,
): ReplyThreadingHeaders | undefined {
const parentId = parent?.messageId?.map(stripBrackets).find(Boolean);
if (!parentId) return undefined;
const ancestors = (parent?.references ?? []).map(stripBrackets).filter(Boolean);
// De-dupe while preserving order; the parent's id closes the chain.
const seen = new Set<string>();
const chain: string[] = [];
for (const id of [...ancestors, parentId]) {
if (seen.has(id)) continue;
seen.add(id);
chain.push(id);
}
return {
inReplyTo: `<${parentId}>`,
references: chain.map((id) => `<${id}>`).join(' '),
};
}
+4 -1
View File
@@ -27,6 +27,7 @@ import {
} from '../stores/settings-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
import { computeReplyThreadingHeaders } from '../lib/email-threading';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useSheetDrag } from '../lib/use-sheet-drag';
import { useLocaleStore } from '../stores/locale-store';
@@ -367,6 +368,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (!email) return;
const from = email.from?.[0];
if (!from && mode !== 'forward') return;
const threading = computeReplyThreadingHeaders(email);
navigation.navigate('Compose', {
mode,
replyTo: {
@@ -376,7 +378,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
subject: email.subject ?? '',
body: plainTextBody(email),
receivedAt: email.receivedAt,
inReplyTo: email.id,
inReplyTo: threading?.inReplyTo,
references: threading?.references,
},
});
};