first commit
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user