92 lines
2.8 KiB
Markdown
92 lines
2.8 KiB
Markdown
# thread-grouper
|
|
|
|
<!-- begin:auto-docs -->
|
|
_(no submodules)_
|
|
<!-- end:auto-docs -->
|
|
|
|
## Purpose
|
|
|
|
Groups a flat list of emails into conversation threads using
|
|
In-Reply-To, References, and subject-line heuristics. Each thread is
|
|
returned with its full chronological message list plus a parent /
|
|
child tree rooted at the earliest message, so consumers can render
|
|
either a flat timeline or a nested conversation view.
|
|
|
|
## Features
|
|
|
|
- Parent resolution prefers `In-Reply-To` and falls back to the last
|
|
`References` entry.
|
|
- Subject-line normalisation strips `Re:`, `Fwd:`, `Aw:`, `Sv:`, `Vs:`
|
|
prefixes (with optional `[N]` ticket-tracker suffixes).
|
|
- A reply with a significantly different subject starts a new thread
|
|
even when it references the original — heuristic catches
|
|
"hijacked" reply chains.
|
|
- Stable chronological ordering via `Date.parse` on the email `date`
|
|
field (epoch-zero when missing, matching upstream behaviour).
|
|
- Records `email_processing.thread.grouped_total` with the produced
|
|
thread count per call.
|
|
|
|
## Usage: Basic
|
|
|
|
```ts
|
|
import { groupThreads } from "@suaveplan/email-processing/threading";
|
|
|
|
const threads = groupThreads(
|
|
messages.map((m) => ({
|
|
messageId: m.messageId ?? "",
|
|
inReplyTo: m.inReplyTo,
|
|
references: m.references,
|
|
subject: m.subject,
|
|
date: m.date,
|
|
}))
|
|
);
|
|
for (const t of threads) {
|
|
console.log(t.threadId, t.subject, t.messageCount);
|
|
}
|
|
```
|
|
|
|
## Usage: Advanced
|
|
|
|
```ts
|
|
import {
|
|
groupThreads,
|
|
normalizeSubject,
|
|
isSubjectChange,
|
|
parseReferences,
|
|
} from "@suaveplan/email-processing/threading";
|
|
|
|
const threads = groupThreads(messages);
|
|
|
|
function flatten(node) {
|
|
return [node.message, ...node.children.flatMap(flatten)];
|
|
}
|
|
|
|
const summary = threads.map((t) => ({
|
|
threadId: t.threadId,
|
|
subject: normalizeSubject(t.subject ?? ""),
|
|
flat: flatten(t.root).map((m) => m.messageId),
|
|
refs: parseReferences(t.messages.at(-1)?.references),
|
|
}));
|
|
```
|
|
|
|
## API Reference
|
|
|
|
- `ThreadableEmail` — `{ messageId, inReplyTo?, references?, subject?,
|
|
date?, from? }`.
|
|
- `ThreadNode` — `{ message, children }`.
|
|
- `EmailThread` — `{ threadId, subject?, root, messageCount, messages }`.
|
|
- `normalizeSubject(subject)`.
|
|
- `parseReferences(references?)` — `<id>` extraction.
|
|
- `isSubjectChange(original, reply)` — heuristic.
|
|
- `groupThreads(emails)` — returns chronological `EmailThread[]`.
|
|
|
|
## Implementation Notes
|
|
|
|
`buildTree` falls back to a `"messageId: ''"` placeholder root only
|
|
when the input array is unexpectedly empty — `groupThreads` itself
|
|
short-circuits empty input, so the fallback exists purely for type
|
|
safety. The grouper's chronological pass uses the most-recent
|
|
reference (last entry in `References`) when `In-Reply-To` is absent,
|
|
matching most MUA conventions. Subject-change detection is
|
|
case-insensitive and ignores whitespace differences.
|