@intx/mail-memory 0.1.2 → 0.3.0
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/LICENSE +176 -0
- package/README.md +3 -3
- package/dist/fetch.d.ts +19 -0
- package/dist/fetch.js +171 -0
- package/dist/headers.d.ts +8 -0
- package/dist/headers.js +77 -0
- package/{src/index.ts → dist/index.d.ts} +4 -12
- package/dist/index.js +18 -0
- package/dist/mailbox.d.ts +49 -0
- package/dist/mailbox.js +52 -0
- package/dist/search.d.ts +12 -0
- package/dist/search.js +151 -0
- package/dist/send.d.ts +52 -0
- package/dist/send.js +191 -0
- package/dist/thread.d.ts +3 -0
- package/dist/thread.js +204 -0
- package/dist/transport.d.ts +98 -0
- package/dist/transport.js +476 -0
- package/package.json +19 -7
- package/src/fetch.ts +0 -215
- package/src/headers.ts +0 -87
- package/src/index.test.ts +0 -684
- package/src/mailbox.ts +0 -113
- package/src/search.ts +0 -170
- package/src/send.ts +0 -293
- package/src/thread.ts +0 -275
- package/src/transport.ts +0 -798
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
package/dist/search.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { parseHeaderSection } from "@intx/mime";
|
|
2
|
+
/**
|
|
3
|
+
* Execute an IMAP SEARCH-equivalent query over an in-memory mailbox.
|
|
4
|
+
*
|
|
5
|
+
* Supports: from, to, cc, bcc, header (field match), before/after/on,
|
|
6
|
+
* sentBefore/sentAfter/sentOn, hasFlags, missingFlags, body, text,
|
|
7
|
+
* largerThan, smallerThan, and boolean and/or/not composition.
|
|
8
|
+
*
|
|
9
|
+
* Returns MessageRef[] for all matching messages, ordered by UID.
|
|
10
|
+
*/
|
|
11
|
+
export function executeSearch(mailboxName, store, query) {
|
|
12
|
+
const results = [];
|
|
13
|
+
for (const msg of store.messages) {
|
|
14
|
+
if (matchMessage(msg, query)) {
|
|
15
|
+
results.push({ uid: msg.uid, mailbox: mailboxName });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
function matchMessage(msg, query) {
|
|
21
|
+
if (query.from !== undefined) {
|
|
22
|
+
if (!msg.envelope.from.toLowerCase().includes(query.from.toLowerCase())) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (query.to !== undefined) {
|
|
27
|
+
const queryTo = query.to;
|
|
28
|
+
const toMatch = msg.envelope.to.some((addr) => addr.toLowerCase().includes(queryTo.toLowerCase()));
|
|
29
|
+
if (!toMatch)
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (query.cc !== undefined) {
|
|
33
|
+
const headers = lazyHeaders(msg);
|
|
34
|
+
const ccHeader = headers.get("cc") ?? "";
|
|
35
|
+
if (!ccHeader.toLowerCase().includes(query.cc.toLowerCase())) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (query.bcc !== undefined) {
|
|
40
|
+
const headers = lazyHeaders(msg);
|
|
41
|
+
const bccHeader = headers.get("bcc") ?? "";
|
|
42
|
+
if (!bccHeader.toLowerCase().includes(query.bcc.toLowerCase())) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (query.header !== undefined) {
|
|
47
|
+
const { field, contains } = query.header;
|
|
48
|
+
const headers = lazyHeaders(msg);
|
|
49
|
+
const value = headers.get(field.toLowerCase()) ?? "";
|
|
50
|
+
if (!value.toLowerCase().includes(contains.toLowerCase())) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (query.before !== undefined) {
|
|
55
|
+
if (msg.envelope.date >= query.before)
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (query.after !== undefined) {
|
|
59
|
+
if (msg.envelope.date <= query.after)
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
if (query.on !== undefined) {
|
|
63
|
+
const d = msg.envelope.date;
|
|
64
|
+
const q = query.on;
|
|
65
|
+
if (d.getUTCFullYear() !== q.getUTCFullYear() ||
|
|
66
|
+
d.getUTCMonth() !== q.getUTCMonth() ||
|
|
67
|
+
d.getUTCDate() !== q.getUTCDate()) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Sent date filters use the Date header (same as envelope date here).
|
|
72
|
+
if (query.sentBefore !== undefined) {
|
|
73
|
+
if (msg.envelope.date >= query.sentBefore)
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
if (query.sentAfter !== undefined) {
|
|
77
|
+
if (msg.envelope.date <= query.sentAfter)
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (query.sentOn !== undefined) {
|
|
81
|
+
const d = msg.envelope.date;
|
|
82
|
+
const q = query.sentOn;
|
|
83
|
+
if (d.getUTCFullYear() !== q.getUTCFullYear() ||
|
|
84
|
+
d.getUTCMonth() !== q.getUTCMonth() ||
|
|
85
|
+
d.getUTCDate() !== q.getUTCDate()) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (query.hasFlags !== undefined) {
|
|
90
|
+
for (const flag of query.hasFlags) {
|
|
91
|
+
if (!msg.flags.has(flag))
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (query.missingFlags !== undefined) {
|
|
96
|
+
for (const flag of query.missingFlags) {
|
|
97
|
+
if (msg.flags.has(flag))
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (query.largerThan !== undefined) {
|
|
102
|
+
if (msg.raw.length <= query.largerThan)
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (query.smallerThan !== undefined) {
|
|
106
|
+
if (msg.raw.length >= query.smallerThan)
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (query.body !== undefined || query.text !== undefined) {
|
|
110
|
+
const rawText = new TextDecoder("utf-8", { fatal: false }).decode(msg.raw);
|
|
111
|
+
if (query.body !== undefined) {
|
|
112
|
+
const { bodyOffset } = parseHeaderSection(msg.raw);
|
|
113
|
+
const bodyText = new TextDecoder("utf-8", { fatal: false }).decode(msg.raw.slice(bodyOffset));
|
|
114
|
+
if (!bodyText.toLowerCase().includes(query.body.toLowerCase())) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (query.text !== undefined) {
|
|
119
|
+
if (!rawText.toLowerCase().includes(query.text.toLowerCase())) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (query.and !== undefined) {
|
|
125
|
+
for (const sub of query.and) {
|
|
126
|
+
if (!matchMessage(msg, sub))
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (query.or !== undefined) {
|
|
131
|
+
if (query.or.length > 0) {
|
|
132
|
+
const anyMatch = query.or.some((sub) => matchMessage(msg, sub));
|
|
133
|
+
if (!anyMatch)
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (query.not !== undefined) {
|
|
138
|
+
if (matchMessage(msg, query.not))
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
const headerCache = new WeakMap();
|
|
144
|
+
function lazyHeaders(msg) {
|
|
145
|
+
const cached = headerCache.get(msg);
|
|
146
|
+
if (cached !== undefined)
|
|
147
|
+
return cached;
|
|
148
|
+
const { headers } = parseHeaderSection(msg.raw);
|
|
149
|
+
headerCache.set(msg, headers);
|
|
150
|
+
return headers;
|
|
151
|
+
}
|
package/dist/send.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { OutboundMessage, SendReceipt } from "@intx/types/runtime";
|
|
2
|
+
import type { AddressEntry } from "./mailbox.js";
|
|
3
|
+
/**
|
|
4
|
+
* Callback for delivering messages to recipients not registered on this
|
|
5
|
+
* transport. The federation layer provides this to forward messages to
|
|
6
|
+
* the hub for remote routing.
|
|
7
|
+
*/
|
|
8
|
+
export type RemoteSendHandler = (rawMessage: Uint8Array, recipients: string[]) => Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Context passed to MessageSentHandler callbacks after a message is fully
|
|
11
|
+
* assembled and delivered.
|
|
12
|
+
*/
|
|
13
|
+
export type MessageSentContext = {
|
|
14
|
+
senderAddress: string;
|
|
15
|
+
rawMessage: Uint8Array;
|
|
16
|
+
messageId: string;
|
|
17
|
+
/** Deduplicated union of to and cc — the full routing set. */
|
|
18
|
+
recipients: string[];
|
|
19
|
+
/** To addresses only (before merging with cc). */
|
|
20
|
+
to: string[];
|
|
21
|
+
/** CC addresses only. Empty array when no CC recipients. */
|
|
22
|
+
cc: string[];
|
|
23
|
+
/** True when all recipients were delivered locally (no remote leg). */
|
|
24
|
+
localOnly: boolean;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Callback fired after a message is fully assembled and delivered. The
|
|
28
|
+
* send is already complete when this fires — a handler rejection does
|
|
29
|
+
* not mean the message was not delivered.
|
|
30
|
+
*
|
|
31
|
+
* Used by the sidecar to commit outbound wire messages to the git audit
|
|
32
|
+
* trail and forward metadata to the hub.
|
|
33
|
+
*/
|
|
34
|
+
export type MessageSentHandler = (ctx: MessageSentContext) => Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Execute the send() flow:
|
|
37
|
+
* 1. Validate sender registration, split recipients into local/remote
|
|
38
|
+
* 2. Build signed content part (MIME bytes to sign)
|
|
39
|
+
* 3. Sign with sender's CryptoProvider
|
|
40
|
+
* 4. Assemble the complete RFC 2822 message
|
|
41
|
+
* 5. Append to each local recipient's INBOX and sender's Sent mailbox
|
|
42
|
+
* 6. Forward to remote recipients via onRemoteSend
|
|
43
|
+
* 7. Schedule watch callbacks asynchronously via queueMicrotask
|
|
44
|
+
* 8. Fire onMessageSent callback (fire-and-forget)
|
|
45
|
+
*
|
|
46
|
+
* If onRemoteSend is not provided and there are remote recipients, send()
|
|
47
|
+
* throws. If onRemoteSend rejects, the error propagates — local delivery
|
|
48
|
+
* that already completed is not rolled back. This is a known limitation:
|
|
49
|
+
* partial delivery is possible when a message has both local and remote
|
|
50
|
+
* recipients and the remote leg fails.
|
|
51
|
+
*/
|
|
52
|
+
export declare function executeSend(senderAddress: string, message: OutboundMessage, entries: Map<string, AddressEntry>, onRemoteSend?: RemoteSendHandler, onMessageSent?: MessageSentHandler): Promise<SendReceipt>;
|
package/dist/send.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { appendToMailbox } from "./mailbox.js";
|
|
2
|
+
import { assembleSignedContent, assembleMessage, generateMessageId, parseHeaderSection, createDetachedSignatureFromProvider, } from "@intx/mime";
|
|
3
|
+
import { buildMessageHeaders } from "./headers.js";
|
|
4
|
+
const CONVERSATION_TYPES = new Set([
|
|
5
|
+
"conversation.message",
|
|
6
|
+
"conversation.join",
|
|
7
|
+
"conversation.leave",
|
|
8
|
+
]);
|
|
9
|
+
/**
|
|
10
|
+
* Execute the send() flow:
|
|
11
|
+
* 1. Validate sender registration, split recipients into local/remote
|
|
12
|
+
* 2. Build signed content part (MIME bytes to sign)
|
|
13
|
+
* 3. Sign with sender's CryptoProvider
|
|
14
|
+
* 4. Assemble the complete RFC 2822 message
|
|
15
|
+
* 5. Append to each local recipient's INBOX and sender's Sent mailbox
|
|
16
|
+
* 6. Forward to remote recipients via onRemoteSend
|
|
17
|
+
* 7. Schedule watch callbacks asynchronously via queueMicrotask
|
|
18
|
+
* 8. Fire onMessageSent callback (fire-and-forget)
|
|
19
|
+
*
|
|
20
|
+
* If onRemoteSend is not provided and there are remote recipients, send()
|
|
21
|
+
* throws. If onRemoteSend rejects, the error propagates — local delivery
|
|
22
|
+
* that already completed is not rolled back. This is a known limitation:
|
|
23
|
+
* partial delivery is possible when a message has both local and remote
|
|
24
|
+
* recipients and the remote leg fails.
|
|
25
|
+
*/
|
|
26
|
+
export async function executeSend(senderAddress, message, entries, onRemoteSend, onMessageSent) {
|
|
27
|
+
const senderEntry = entries.get(senderAddress);
|
|
28
|
+
if (senderEntry === undefined) {
|
|
29
|
+
throw new Error(`Sender "${senderAddress}" is not registered with this transport`);
|
|
30
|
+
}
|
|
31
|
+
const senderCrypto = senderEntry.crypto;
|
|
32
|
+
const recipients = Array.isArray(message.to) ? message.to : [message.to];
|
|
33
|
+
if (recipients.length === 0) {
|
|
34
|
+
throw new Error("OutboundMessage must have at least one recipient");
|
|
35
|
+
}
|
|
36
|
+
const ccAddressList = message.cc !== undefined
|
|
37
|
+
? Array.isArray(message.cc)
|
|
38
|
+
? message.cc
|
|
39
|
+
: [message.cc]
|
|
40
|
+
: [];
|
|
41
|
+
const allAddressees = [...new Set([...recipients, ...ccAddressList])];
|
|
42
|
+
const remoteRecipients = allAddressees.filter((addr) => !entries.has(addr));
|
|
43
|
+
if (remoteRecipients.length > 0 && onRemoteSend === undefined) {
|
|
44
|
+
throw new Error(`Recipient "${remoteRecipients[0]}" is not registered with this transport`);
|
|
45
|
+
}
|
|
46
|
+
const isConversation = CONVERSATION_TYPES.has(message.type);
|
|
47
|
+
if (isConversation && message.payload !== undefined) {
|
|
48
|
+
throw new Error("Conversation messages must not carry a structured payload");
|
|
49
|
+
}
|
|
50
|
+
if (!isConversation && message.content !== undefined) {
|
|
51
|
+
throw new Error("Structured messages must not carry a text content field");
|
|
52
|
+
}
|
|
53
|
+
const messageId = generateMessageId(senderAddress);
|
|
54
|
+
const now = new Date();
|
|
55
|
+
let content;
|
|
56
|
+
if (isConversation) {
|
|
57
|
+
content = {
|
|
58
|
+
kind: "conversation",
|
|
59
|
+
text: message.content ?? "",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const payload = message.payload ?? {};
|
|
64
|
+
const envelope = {
|
|
65
|
+
type: message.type,
|
|
66
|
+
version: "1",
|
|
67
|
+
body: payload,
|
|
68
|
+
};
|
|
69
|
+
const structured = {
|
|
70
|
+
kind: "structured",
|
|
71
|
+
json: envelope,
|
|
72
|
+
};
|
|
73
|
+
if (message.summary !== undefined)
|
|
74
|
+
structured.summary = message.summary;
|
|
75
|
+
content = structured;
|
|
76
|
+
}
|
|
77
|
+
const signedContentBytes = assembleSignedContent(content);
|
|
78
|
+
const signatureBytes = await createDetachedSignatureFromProvider(signedContentBytes, senderCrypto);
|
|
79
|
+
const ccAddresses = ccAddressList.length > 0 ? ccAddressList : undefined;
|
|
80
|
+
const refs = buildReferences(message.inReplyTo, undefined);
|
|
81
|
+
const mimeHeaders = {
|
|
82
|
+
from: senderAddress,
|
|
83
|
+
to: recipients,
|
|
84
|
+
cc: ccAddresses,
|
|
85
|
+
date: now,
|
|
86
|
+
messageId,
|
|
87
|
+
subject: message.subject,
|
|
88
|
+
inReplyTo: message.inReplyTo,
|
|
89
|
+
references: refs,
|
|
90
|
+
mimeVersion: "1.0",
|
|
91
|
+
interchangeType: message.type,
|
|
92
|
+
interchangeCorrelationId: message.correlationId,
|
|
93
|
+
interchangeTenantId: message.tenantId,
|
|
94
|
+
interchangeAgentId: undefined,
|
|
95
|
+
interchangeSessionId: message.sessionId,
|
|
96
|
+
interchangeOfferingId: undefined,
|
|
97
|
+
interchangeSchemaVersion: undefined,
|
|
98
|
+
traceparent: undefined,
|
|
99
|
+
tracestate: undefined,
|
|
100
|
+
};
|
|
101
|
+
const rawBytes = assembleMessage(mimeHeaders, signedContentBytes, signatureBytes);
|
|
102
|
+
const envelope = {
|
|
103
|
+
messageId,
|
|
104
|
+
from: senderAddress,
|
|
105
|
+
to: recipients,
|
|
106
|
+
subject: message.subject ?? "",
|
|
107
|
+
date: now,
|
|
108
|
+
inReplyTo: message.inReplyTo,
|
|
109
|
+
references: refs ?? [],
|
|
110
|
+
interchangeType: message.type,
|
|
111
|
+
interchangeCorrelationId: message.correlationId,
|
|
112
|
+
};
|
|
113
|
+
// Deliver to each local recipient's INBOX.
|
|
114
|
+
const deliveredUids = [];
|
|
115
|
+
for (const recipient of allAddressees) {
|
|
116
|
+
const entry = entries.get(recipient);
|
|
117
|
+
if (entry === undefined)
|
|
118
|
+
continue;
|
|
119
|
+
const inbox = entry.mailboxes.get("INBOX");
|
|
120
|
+
if (inbox === undefined) {
|
|
121
|
+
throw new Error(`Mailbox "INBOX" does not exist for recipient "${recipient}"`);
|
|
122
|
+
}
|
|
123
|
+
const uid = appendToMailbox(inbox, rawBytes, envelope, []);
|
|
124
|
+
deliveredUids.push({ address: recipient, uid });
|
|
125
|
+
}
|
|
126
|
+
// Append copy to sender's Sent mailbox.
|
|
127
|
+
const sentStore = senderEntry.mailboxes.get("Sent");
|
|
128
|
+
if (sentStore === undefined) {
|
|
129
|
+
throw new Error(`Mailbox "Sent" does not exist for sender "${senderAddress}"`);
|
|
130
|
+
}
|
|
131
|
+
appendToMailbox(sentStore, rawBytes, envelope, ["\\Seen"]);
|
|
132
|
+
// Fire local recipient watch callbacks ASYNCHRONOUSLY (per MESSAGE.md
|
|
133
|
+
// requirement). queueMicrotask ensures callbacks never run synchronously
|
|
134
|
+
// on the sender's call stack, preserving real IMAP IDLE async delivery
|
|
135
|
+
// semantics. Scheduled before the remote send so local delivery
|
|
136
|
+
// notifications are not delayed by network latency.
|
|
137
|
+
const { headers: parsedHeaders } = parseHeaderSection(rawBytes);
|
|
138
|
+
const msgHeaders = buildMessageHeaders(parsedHeaders);
|
|
139
|
+
for (const { address, uid } of deliveredUids) {
|
|
140
|
+
const entry = entries.get(address);
|
|
141
|
+
if (entry === undefined) {
|
|
142
|
+
throw new Error(`Entry for "${address}" disappeared between delivery and callback dispatch`);
|
|
143
|
+
}
|
|
144
|
+
const callbacks = entry.watchCallbacks.get("INBOX");
|
|
145
|
+
if (callbacks === undefined || callbacks.size === 0)
|
|
146
|
+
continue;
|
|
147
|
+
const event = {
|
|
148
|
+
type: "exists",
|
|
149
|
+
uid,
|
|
150
|
+
headers: msgHeaders,
|
|
151
|
+
};
|
|
152
|
+
for (const cb of callbacks) {
|
|
153
|
+
queueMicrotask(() => cb(event));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Forward to remote recipients via federation hook.
|
|
157
|
+
if (remoteRecipients.length > 0 && onRemoteSend !== undefined) {
|
|
158
|
+
await onRemoteSend(rawBytes, remoteRecipients);
|
|
159
|
+
}
|
|
160
|
+
if (onMessageSent !== undefined) {
|
|
161
|
+
const localOnly = remoteRecipients.length === 0;
|
|
162
|
+
onMessageSent({
|
|
163
|
+
senderAddress,
|
|
164
|
+
rawMessage: rawBytes,
|
|
165
|
+
messageId,
|
|
166
|
+
recipients: allAddressees,
|
|
167
|
+
to: recipients,
|
|
168
|
+
cc: ccAddressList,
|
|
169
|
+
localOnly,
|
|
170
|
+
}).catch((err) => {
|
|
171
|
+
queueMicrotask(() => {
|
|
172
|
+
throw err instanceof Error
|
|
173
|
+
? err
|
|
174
|
+
: new Error(`MessageSentHandler failed: ${String(err)}`);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
messageId,
|
|
180
|
+
status: remoteRecipients.length > 0 ? "queued" : "delivered",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function buildReferences(inReplyTo, existingReferences) {
|
|
184
|
+
if (inReplyTo === undefined)
|
|
185
|
+
return existingReferences;
|
|
186
|
+
const refs = existingReferences ?? [];
|
|
187
|
+
if (!refs.includes(inReplyTo)) {
|
|
188
|
+
return [...refs, inReplyTo];
|
|
189
|
+
}
|
|
190
|
+
return refs;
|
|
191
|
+
}
|
package/dist/thread.d.ts
ADDED
package/dist/thread.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { executeSearch } from "./search.js";
|
|
2
|
+
export function executeThread(mailboxName, store, algorithm, query) {
|
|
3
|
+
let messages;
|
|
4
|
+
if (query !== undefined) {
|
|
5
|
+
const refs = executeSearch(mailboxName, store, query);
|
|
6
|
+
const uidSet = new Set(refs.map((r) => r.uid));
|
|
7
|
+
messages = store.messages.filter((m) => uidSet.has(m.uid));
|
|
8
|
+
}
|
|
9
|
+
else {
|
|
10
|
+
messages = [...store.messages];
|
|
11
|
+
}
|
|
12
|
+
if (messages.length === 0)
|
|
13
|
+
return [];
|
|
14
|
+
if (algorithm === "orderedsubject") {
|
|
15
|
+
return orderedSubjectThread(mailboxName, messages);
|
|
16
|
+
}
|
|
17
|
+
return referencesThread(mailboxName, messages);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* RFC 5256 ORDEREDSUBJECT: sort by base subject, then date.
|
|
21
|
+
* All messages with the same base subject form one thread; the first by date
|
|
22
|
+
* is the root, the rest are direct children.
|
|
23
|
+
*/
|
|
24
|
+
function orderedSubjectThread(mailboxName, messages) {
|
|
25
|
+
const bySubject = new Map();
|
|
26
|
+
for (const msg of messages) {
|
|
27
|
+
const base = baseSubject(msg.envelope.subject);
|
|
28
|
+
const bucket = bySubject.get(base);
|
|
29
|
+
if (bucket === undefined) {
|
|
30
|
+
bySubject.set(base, [msg]);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
bucket.push(msg);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const threads = [];
|
|
37
|
+
for (const [, msgs] of bySubject) {
|
|
38
|
+
const sorted = msgs.sort((a, b) => a.envelope.date.getTime() - b.envelope.date.getTime());
|
|
39
|
+
const root = sorted[0];
|
|
40
|
+
const rootThread = {
|
|
41
|
+
ref: { uid: root.uid, mailbox: mailboxName },
|
|
42
|
+
children: sorted.slice(1).map((m) => ({
|
|
43
|
+
ref: { uid: m.uid, mailbox: mailboxName },
|
|
44
|
+
children: [],
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
threads.push(rootThread);
|
|
48
|
+
}
|
|
49
|
+
return threads.sort((a, b) => {
|
|
50
|
+
const aMsg = messages.find((m) => m.uid === a.ref.uid);
|
|
51
|
+
const bMsg = messages.find((m) => m.uid === b.ref.uid);
|
|
52
|
+
return aMsg.envelope.date.getTime() - bMsg.envelope.date.getTime();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* RFC 5256 REFERENCES algorithm.
|
|
57
|
+
*
|
|
58
|
+
* Step 1: For each message, create a container. Walk its References list
|
|
59
|
+
* (and In-Reply-To if not already in References) and link containers
|
|
60
|
+
* as parent-child in left-to-right order.
|
|
61
|
+
*
|
|
62
|
+
* Step 2: Build the id_table mapping Message-IDs to containers.
|
|
63
|
+
*
|
|
64
|
+
* Step 3: Prune empty containers (those with no message).
|
|
65
|
+
*
|
|
66
|
+
* Step 4: Collect root containers.
|
|
67
|
+
*
|
|
68
|
+
* Step 5: Sort each container's children by date.
|
|
69
|
+
*/
|
|
70
|
+
function referencesThread(mailboxName, messages) {
|
|
71
|
+
const idTable = new Map();
|
|
72
|
+
function getOrCreate(msgId) {
|
|
73
|
+
const existing = idTable.get(msgId);
|
|
74
|
+
if (existing !== undefined)
|
|
75
|
+
return existing;
|
|
76
|
+
const c = {
|
|
77
|
+
messageId: msgId,
|
|
78
|
+
message: null,
|
|
79
|
+
parent: null,
|
|
80
|
+
children: [],
|
|
81
|
+
};
|
|
82
|
+
idTable.set(msgId, c);
|
|
83
|
+
return c;
|
|
84
|
+
}
|
|
85
|
+
// Step 1 & 2: Build containers and link parent-child relationships.
|
|
86
|
+
for (const msg of messages) {
|
|
87
|
+
const container = getOrCreate(msg.envelope.messageId);
|
|
88
|
+
container.message = msg;
|
|
89
|
+
// Build the reference list: References + In-Reply-To (deduplicated).
|
|
90
|
+
const refs = buildRefList(msg.envelope.references, msg.envelope.inReplyTo);
|
|
91
|
+
// Link: refs[i] is parent of refs[i+1], last ref is parent of this message.
|
|
92
|
+
let prevContainer = null;
|
|
93
|
+
for (const refId of refs) {
|
|
94
|
+
const refContainer = getOrCreate(refId);
|
|
95
|
+
if (prevContainer !== null &&
|
|
96
|
+
refContainer.parent === null &&
|
|
97
|
+
!isAncestor(refContainer, prevContainer)) {
|
|
98
|
+
prevContainer.children.push(refContainer);
|
|
99
|
+
refContainer.parent = prevContainer;
|
|
100
|
+
}
|
|
101
|
+
prevContainer = refContainer;
|
|
102
|
+
}
|
|
103
|
+
// Link the last reference as parent of this message (if no circular reference).
|
|
104
|
+
if (prevContainer !== null &&
|
|
105
|
+
container.parent === null &&
|
|
106
|
+
!isAncestor(container, prevContainer)) {
|
|
107
|
+
prevContainer.children.push(container);
|
|
108
|
+
container.parent = prevContainer;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Step 3: Find root containers (no parent).
|
|
112
|
+
const roots = [];
|
|
113
|
+
for (const [, c] of idTable) {
|
|
114
|
+
if (c.parent === null) {
|
|
115
|
+
roots.push(c);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Step 4: Prune dummy containers (containers with no message).
|
|
119
|
+
// A dummy with no children is dropped.
|
|
120
|
+
// A dummy with children: the children are promoted to the dummy's parent level.
|
|
121
|
+
const prunedRoots = pruneContainers(roots);
|
|
122
|
+
// Step 5: Sort and convert to Thread[].
|
|
123
|
+
return containersToThreads(mailboxName, prunedRoots);
|
|
124
|
+
}
|
|
125
|
+
function buildRefList(references, inReplyTo) {
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const result = [];
|
|
128
|
+
for (const ref of references) {
|
|
129
|
+
if (ref && !seen.has(ref)) {
|
|
130
|
+
seen.add(ref);
|
|
131
|
+
result.push(ref);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (inReplyTo !== undefined && inReplyTo !== "" && !seen.has(inReplyTo)) {
|
|
135
|
+
result.push(inReplyTo);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
function isAncestor(potentialAncestor, of) {
|
|
140
|
+
let cur = of;
|
|
141
|
+
while (cur !== null) {
|
|
142
|
+
if (cur === potentialAncestor)
|
|
143
|
+
return true;
|
|
144
|
+
cur = cur.parent;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
function pruneContainers(containers) {
|
|
149
|
+
const result = [];
|
|
150
|
+
for (const c of containers) {
|
|
151
|
+
if (c.message === null && c.children.length === 0) {
|
|
152
|
+
// Dummy with no children: drop it.
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (c.message === null && c.children.length > 0) {
|
|
156
|
+
// Dummy with children: promote children (skip the dummy).
|
|
157
|
+
const promotedChildren = pruneContainers(c.children);
|
|
158
|
+
result.push(...promotedChildren);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
// Real message: recurse into children.
|
|
162
|
+
c.children = pruneContainers(c.children);
|
|
163
|
+
result.push(c);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
function containerDate(c) {
|
|
169
|
+
if (c.message !== null) {
|
|
170
|
+
return c.message.envelope.date.getTime();
|
|
171
|
+
}
|
|
172
|
+
// For dummy containers, use the earliest child date.
|
|
173
|
+
let earliest = Infinity;
|
|
174
|
+
for (const child of c.children) {
|
|
175
|
+
const d = containerDate(child);
|
|
176
|
+
if (d < earliest)
|
|
177
|
+
earliest = d;
|
|
178
|
+
}
|
|
179
|
+
return earliest === Infinity ? 0 : earliest;
|
|
180
|
+
}
|
|
181
|
+
function containersToThreads(mailboxName, containers) {
|
|
182
|
+
// Sort by date of the container (or earliest descendant for dummies).
|
|
183
|
+
const sorted = containers.sort((a, b) => containerDate(a) - containerDate(b));
|
|
184
|
+
return sorted
|
|
185
|
+
.filter((c) => c.message !== null)
|
|
186
|
+
.map((c) => ({
|
|
187
|
+
ref: { uid: c.message.uid, mailbox: mailboxName },
|
|
188
|
+
children: containersToThreads(mailboxName, c.children),
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
function baseSubject(subject) {
|
|
192
|
+
// Strip "Re:", "Fwd:", "Fw:" prefixes (case-insensitive) repeatedly.
|
|
193
|
+
let s = subject.trim();
|
|
194
|
+
let changed = true;
|
|
195
|
+
while (changed) {
|
|
196
|
+
changed = false;
|
|
197
|
+
const m = s.match(/^(?:re|fwd?)\s*:\s*/i);
|
|
198
|
+
if (m !== null) {
|
|
199
|
+
s = s.slice(m[0].length).trim();
|
|
200
|
+
changed = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return s;
|
|
204
|
+
}
|