diff --git a/src/api/email.ts b/src/api/email.ts index 4333d9ff..db31d50f 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -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 diff --git a/src/api/types.ts b/src/api/types.ts index a5042c6d..a80a9642 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 { diff --git a/src/lib/__tests__/email-threading.test.ts b/src/lib/__tests__/email-threading.test.ts new file mode 100644 index 00000000..fdc12089 --- /dev/null +++ b/src/lib/__tests__/email-threading.test.ts @@ -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: '', + references: '', + }); + }); + + it('keeps the ancestor chain and closes it with the parent', () => { + expect( + computeReplyThreadingHeaders({ + messageId: ['c@host'], + references: ['a@host', 'b@host'], + }), + ).toEqual({ + inReplyTo: '', + references: ' ', + }); + }); + + it('de-dupes an ancestor that is already the parent', () => { + expect( + computeReplyThreadingHeaders({ messageId: ['a@host'], references: ['a@host'] }), + ).toEqual({ inReplyTo: '', references: '' }); + }); + + it('tolerates ids that already carry angle brackets', () => { + expect( + computeReplyThreadingHeaders({ messageId: [''], references: [''] }), + ).toEqual({ inReplyTo: '', references: ' ' }); + }); +}); diff --git a/src/lib/email-threading.ts b/src/lib/email-threading.ts new file mode 100644 index 00000000..47239478 --- /dev/null +++ b/src/lib/email-threading.ts @@ -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. `` */ + 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(); + 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(' '), + }; +} diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 564bdce1..daa39a25 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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, }, }); };