@intx/mail-memory 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/package.json +18 -0
- package/src/fetch.ts +215 -0
- package/src/headers.ts +87 -0
- package/src/index.test.ts +684 -0
- package/src/index.ts +25 -0
- package/src/mailbox.ts +113 -0
- package/src/search.ts +170 -0
- package/src/send.ts +293 -0
- package/src/thread.ts +275 -0
- package/src/transport.ts +798 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/thread.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-non-null-assertion -- Map.get()! after has() checks in threading algorithm */
|
|
2
|
+
import type { Thread, SearchQuery } from "@intx/types/runtime";
|
|
3
|
+
import type { MailboxStore, StoredMessage } from "./mailbox";
|
|
4
|
+
import { executeSearch } from "./search";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RFC 5256 REFERENCES threading algorithm.
|
|
8
|
+
*
|
|
9
|
+
* Builds parent-child relationships from In-Reply-To and References headers.
|
|
10
|
+
* The algorithm:
|
|
11
|
+
* 1. For each message, collect its References chain (oldest → newest ancestor).
|
|
12
|
+
* 2. Link messages into a tree using these chains.
|
|
13
|
+
* 3. Create dummy containers for referenced messages not present in the set.
|
|
14
|
+
* 4. Prune dummy containers with no children; promote children of childless dummies.
|
|
15
|
+
* 5. Gather root-level containers with the same base subject (skipped here —
|
|
16
|
+
* we implement only the parent/child linking portion which is what this
|
|
17
|
+
* transport needs; subject-based gathering is optional for our use case).
|
|
18
|
+
* 6. Sort threads at each level.
|
|
19
|
+
*
|
|
20
|
+
* Note: RFC 5256 also defines an ORDEREDSUBJECT algorithm. For that, messages
|
|
21
|
+
* are sorted by subject and date without reference tracking.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Container = {
|
|
25
|
+
messageId: string;
|
|
26
|
+
message: StoredMessage | null;
|
|
27
|
+
parent: Container | null;
|
|
28
|
+
children: Container[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function executeThread(
|
|
32
|
+
mailboxName: string,
|
|
33
|
+
store: MailboxStore,
|
|
34
|
+
algorithm: "references" | "orderedsubject",
|
|
35
|
+
query?: SearchQuery,
|
|
36
|
+
): Thread[] {
|
|
37
|
+
let messages: StoredMessage[];
|
|
38
|
+
|
|
39
|
+
if (query !== undefined) {
|
|
40
|
+
const refs = executeSearch(mailboxName, store, query);
|
|
41
|
+
const uidSet = new Set(refs.map((r) => r.uid));
|
|
42
|
+
messages = store.messages.filter((m) => uidSet.has(m.uid));
|
|
43
|
+
} else {
|
|
44
|
+
messages = [...store.messages];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (messages.length === 0) return [];
|
|
48
|
+
|
|
49
|
+
if (algorithm === "orderedsubject") {
|
|
50
|
+
return orderedSubjectThread(mailboxName, messages);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return referencesThread(mailboxName, messages);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* RFC 5256 ORDEREDSUBJECT: sort by base subject, then date.
|
|
58
|
+
* All messages with the same base subject form one thread; the first by date
|
|
59
|
+
* is the root, the rest are direct children.
|
|
60
|
+
*/
|
|
61
|
+
function orderedSubjectThread(
|
|
62
|
+
mailboxName: string,
|
|
63
|
+
messages: StoredMessage[],
|
|
64
|
+
): Thread[] {
|
|
65
|
+
const bySubject = new Map<string, StoredMessage[]>();
|
|
66
|
+
|
|
67
|
+
for (const msg of messages) {
|
|
68
|
+
const base = baseSubject(msg.envelope.subject);
|
|
69
|
+
const bucket = bySubject.get(base);
|
|
70
|
+
if (bucket === undefined) {
|
|
71
|
+
bySubject.set(base, [msg]);
|
|
72
|
+
} else {
|
|
73
|
+
bucket.push(msg);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const threads: Thread[] = [];
|
|
78
|
+
for (const [, msgs] of bySubject) {
|
|
79
|
+
const sorted = msgs.sort(
|
|
80
|
+
(a, b) => a.envelope.date.getTime() - b.envelope.date.getTime(),
|
|
81
|
+
);
|
|
82
|
+
const root = sorted[0]!;
|
|
83
|
+
const rootThread: Thread = {
|
|
84
|
+
ref: { uid: root.uid, mailbox: mailboxName },
|
|
85
|
+
children: sorted.slice(1).map((m) => ({
|
|
86
|
+
ref: { uid: m.uid, mailbox: mailboxName },
|
|
87
|
+
children: [],
|
|
88
|
+
})),
|
|
89
|
+
};
|
|
90
|
+
threads.push(rootThread);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return threads.sort((a, b) => {
|
|
94
|
+
const aMsg = messages.find((m) => m.uid === a.ref.uid)!;
|
|
95
|
+
const bMsg = messages.find((m) => m.uid === b.ref.uid)!;
|
|
96
|
+
return aMsg.envelope.date.getTime() - bMsg.envelope.date.getTime();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* RFC 5256 REFERENCES algorithm.
|
|
102
|
+
*
|
|
103
|
+
* Step 1: For each message, create a container. Walk its References list
|
|
104
|
+
* (and In-Reply-To if not already in References) and link containers
|
|
105
|
+
* as parent-child in left-to-right order.
|
|
106
|
+
*
|
|
107
|
+
* Step 2: Build the id_table mapping Message-IDs to containers.
|
|
108
|
+
*
|
|
109
|
+
* Step 3: Prune empty containers (those with no message).
|
|
110
|
+
*
|
|
111
|
+
* Step 4: Collect root containers.
|
|
112
|
+
*
|
|
113
|
+
* Step 5: Sort each container's children by date.
|
|
114
|
+
*/
|
|
115
|
+
function referencesThread(
|
|
116
|
+
mailboxName: string,
|
|
117
|
+
messages: StoredMessage[],
|
|
118
|
+
): Thread[] {
|
|
119
|
+
const idTable = new Map<string, Container>();
|
|
120
|
+
|
|
121
|
+
function getOrCreate(msgId: string): Container {
|
|
122
|
+
const existing = idTable.get(msgId);
|
|
123
|
+
if (existing !== undefined) return existing;
|
|
124
|
+
const c: Container = {
|
|
125
|
+
messageId: msgId,
|
|
126
|
+
message: null,
|
|
127
|
+
parent: null,
|
|
128
|
+
children: [],
|
|
129
|
+
};
|
|
130
|
+
idTable.set(msgId, c);
|
|
131
|
+
return c;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Step 1 & 2: Build containers and link parent-child relationships.
|
|
135
|
+
for (const msg of messages) {
|
|
136
|
+
const container = getOrCreate(msg.envelope.messageId);
|
|
137
|
+
container.message = msg;
|
|
138
|
+
|
|
139
|
+
// Build the reference list: References + In-Reply-To (deduplicated).
|
|
140
|
+
const refs = buildRefList(msg.envelope.references, msg.envelope.inReplyTo);
|
|
141
|
+
|
|
142
|
+
// Link: refs[i] is parent of refs[i+1], last ref is parent of this message.
|
|
143
|
+
let prevContainer: Container | null = null;
|
|
144
|
+
for (const refId of refs) {
|
|
145
|
+
const refContainer = getOrCreate(refId);
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
prevContainer !== null &&
|
|
149
|
+
refContainer.parent === null &&
|
|
150
|
+
!isAncestor(refContainer, prevContainer)
|
|
151
|
+
) {
|
|
152
|
+
prevContainer.children.push(refContainer);
|
|
153
|
+
refContainer.parent = prevContainer;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
prevContainer = refContainer;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Link the last reference as parent of this message (if no circular reference).
|
|
160
|
+
if (
|
|
161
|
+
prevContainer !== null &&
|
|
162
|
+
container.parent === null &&
|
|
163
|
+
!isAncestor(container, prevContainer)
|
|
164
|
+
) {
|
|
165
|
+
prevContainer.children.push(container);
|
|
166
|
+
container.parent = prevContainer;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Step 3: Find root containers (no parent).
|
|
171
|
+
const roots: Container[] = [];
|
|
172
|
+
for (const [, c] of idTable) {
|
|
173
|
+
if (c.parent === null) {
|
|
174
|
+
roots.push(c);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Step 4: Prune dummy containers (containers with no message).
|
|
179
|
+
// A dummy with no children is dropped.
|
|
180
|
+
// A dummy with children: the children are promoted to the dummy's parent level.
|
|
181
|
+
const prunedRoots = pruneContainers(roots);
|
|
182
|
+
|
|
183
|
+
// Step 5: Sort and convert to Thread[].
|
|
184
|
+
return containersToThreads(mailboxName, prunedRoots);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildRefList(references: string[], inReplyTo?: string): string[] {
|
|
188
|
+
const seen = new Set<string>();
|
|
189
|
+
const result: string[] = [];
|
|
190
|
+
|
|
191
|
+
for (const ref of references) {
|
|
192
|
+
if (ref && !seen.has(ref)) {
|
|
193
|
+
seen.add(ref);
|
|
194
|
+
result.push(ref);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (inReplyTo !== undefined && inReplyTo !== "" && !seen.has(inReplyTo)) {
|
|
199
|
+
result.push(inReplyTo);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isAncestor(potentialAncestor: Container, of: Container): boolean {
|
|
206
|
+
let cur: Container | null = of;
|
|
207
|
+
while (cur !== null) {
|
|
208
|
+
if (cur === potentialAncestor) return true;
|
|
209
|
+
cur = cur.parent;
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pruneContainers(containers: Container[]): Container[] {
|
|
215
|
+
const result: Container[] = [];
|
|
216
|
+
for (const c of containers) {
|
|
217
|
+
if (c.message === null && c.children.length === 0) {
|
|
218
|
+
// Dummy with no children: drop it.
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (c.message === null && c.children.length > 0) {
|
|
222
|
+
// Dummy with children: promote children (skip the dummy).
|
|
223
|
+
const promotedChildren = pruneContainers(c.children);
|
|
224
|
+
result.push(...promotedChildren);
|
|
225
|
+
} else {
|
|
226
|
+
// Real message: recurse into children.
|
|
227
|
+
c.children = pruneContainers(c.children);
|
|
228
|
+
result.push(c);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function containerDate(c: Container): number {
|
|
235
|
+
if (c.message !== null) {
|
|
236
|
+
return c.message.envelope.date.getTime();
|
|
237
|
+
}
|
|
238
|
+
// For dummy containers, use the earliest child date.
|
|
239
|
+
let earliest = Infinity;
|
|
240
|
+
for (const child of c.children) {
|
|
241
|
+
const d = containerDate(child);
|
|
242
|
+
if (d < earliest) earliest = d;
|
|
243
|
+
}
|
|
244
|
+
return earliest === Infinity ? 0 : earliest;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function containersToThreads(
|
|
248
|
+
mailboxName: string,
|
|
249
|
+
containers: Container[],
|
|
250
|
+
): Thread[] {
|
|
251
|
+
// Sort by date of the container (or earliest descendant for dummies).
|
|
252
|
+
const sorted = containers.sort((a, b) => containerDate(a) - containerDate(b));
|
|
253
|
+
|
|
254
|
+
return sorted
|
|
255
|
+
.filter((c) => c.message !== null)
|
|
256
|
+
.map((c) => ({
|
|
257
|
+
ref: { uid: c.message!.uid, mailbox: mailboxName },
|
|
258
|
+
children: containersToThreads(mailboxName, c.children),
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function baseSubject(subject: string): string {
|
|
263
|
+
// Strip "Re:", "Fwd:", "Fw:" prefixes (case-insensitive) repeatedly.
|
|
264
|
+
let s = subject.trim();
|
|
265
|
+
let changed = true;
|
|
266
|
+
while (changed) {
|
|
267
|
+
changed = false;
|
|
268
|
+
const m = s.match(/^(?:re|fwd?)\s*:\s*/i);
|
|
269
|
+
if (m !== null) {
|
|
270
|
+
s = s.slice(m[0].length).trim();
|
|
271
|
+
changed = true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return s;
|
|
275
|
+
}
|