mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>' });
|
||||
});
|
||||
});
|
||||
@@ -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(' '),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user