@intx/mail-memory 0.3.0 → 0.4.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/README.md +4 -2
- package/dist/mailbox.d.ts +4 -40
- package/dist/mailbox.js +2 -42
- package/dist/send.d.ts +3 -3
- package/dist/send.js +24 -9
- package/dist/transport.d.ts +3 -1
- package/dist/transport.js +27 -38
- package/package.json +6 -5
- package/dist/fetch.d.ts +0 -19
- package/dist/fetch.js +0 -171
- package/dist/headers.d.ts +0 -8
- package/dist/headers.js +0 -77
- package/dist/search.d.ts +0 -12
- package/dist/search.js +0 -151
- package/dist/thread.d.ts +0 -3
- package/dist/thread.js +0 -204
package/README.md
CHANGED
|
@@ -33,5 +33,7 @@ await alphaMail.send({
|
|
|
33
33
|
|
|
34
34
|
Install a `RemoteSendHandler` via `transport.setRemoteSendHandler`
|
|
35
35
|
to forward outbound mail for addresses the transport does not host
|
|
36
|
-
locally
|
|
37
|
-
|
|
36
|
+
locally. The handler receives the raw message, remote recipients, and
|
|
37
|
+
the sender address already validated by the scoped transport. Add one or
|
|
38
|
+
more `MessageSentHandler`s via `transport.addMessageSentHandler` for
|
|
39
|
+
post-send observability.
|
package/dist/mailbox.d.ts
CHANGED
|
@@ -1,49 +1,13 @@
|
|
|
1
1
|
import type { CryptoProvider, MailboxEvent } from "@intx/types/runtime";
|
|
2
|
+
import { type MailboxStore } from "@intx/mailbox";
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
4
|
+
* Per-address state for the in-memory transport: one `MailboxStore` per
|
|
5
|
+
* mailbox, the watch callbacks registered against each mailbox, and the
|
|
6
|
+
* address's `CryptoProvider`.
|
|
5
7
|
*/
|
|
6
|
-
export type StoredEnvelope = {
|
|
7
|
-
messageId: string;
|
|
8
|
-
from: string;
|
|
9
|
-
to: string[];
|
|
10
|
-
subject: string;
|
|
11
|
-
date: Date;
|
|
12
|
-
inReplyTo: string | undefined;
|
|
13
|
-
references: string[];
|
|
14
|
-
interchangeType: string | undefined;
|
|
15
|
-
interchangeCorrelationId: string | undefined;
|
|
16
|
-
};
|
|
17
|
-
/**
|
|
18
|
-
* A single message stored in memory. The `raw` field contains the complete
|
|
19
|
-
* RFC 2822 message bytes (headers + MIME body). All fetch operations parse
|
|
20
|
-
* from these bytes, guaranteeing byte-exact signature verification.
|
|
21
|
-
*/
|
|
22
|
-
export type StoredMessage = {
|
|
23
|
-
uid: number;
|
|
24
|
-
modseq: number;
|
|
25
|
-
flags: Set<string>;
|
|
26
|
-
raw: Uint8Array;
|
|
27
|
-
envelope: StoredEnvelope;
|
|
28
|
-
};
|
|
29
|
-
export type MailboxStore = {
|
|
30
|
-
messages: StoredMessage[];
|
|
31
|
-
uidCounter: number;
|
|
32
|
-
modseqCounter: number;
|
|
33
|
-
uidValidity: number;
|
|
34
|
-
};
|
|
35
8
|
export type AddressEntry = {
|
|
36
9
|
mailboxes: Map<string, MailboxStore>;
|
|
37
10
|
watchCallbacks: Map<string, Set<(event: MailboxEvent) => void>>;
|
|
38
11
|
crypto: CryptoProvider;
|
|
39
12
|
};
|
|
40
|
-
export declare const DEFAULT_MAILBOXES: readonly ["INBOX", "Sent", "Drafts", "Archive", "Trash"];
|
|
41
|
-
export declare function createMailboxStore(): MailboxStore;
|
|
42
13
|
export declare function createAddressEntry(crypto: CryptoProvider): AddressEntry;
|
|
43
|
-
/**
|
|
44
|
-
* Append a message to a mailbox store. Assigns UID and MODSEQ, returns the
|
|
45
|
-
* assigned UID.
|
|
46
|
-
*/
|
|
47
|
-
export declare function appendToMailbox(store: MailboxStore, raw: Uint8Array, envelope: StoredEnvelope, flags: string[]): number;
|
|
48
|
-
export declare function findMessage(store: MailboxStore, uid: number): StoredMessage | undefined;
|
|
49
|
-
export declare function requireMessage(store: MailboxStore, uid: number, mailboxName: string): StoredMessage;
|
package/dist/mailbox.js
CHANGED
|
@@ -1,22 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
"INBOX",
|
|
3
|
-
"Sent",
|
|
4
|
-
"Drafts",
|
|
5
|
-
"Archive",
|
|
6
|
-
"Trash",
|
|
7
|
-
];
|
|
8
|
-
export function createMailboxStore() {
|
|
9
|
-
return {
|
|
10
|
-
messages: [],
|
|
11
|
-
uidCounter: 1,
|
|
12
|
-
modseqCounter: 1,
|
|
13
|
-
uidValidity: Date.now(),
|
|
14
|
-
};
|
|
15
|
-
}
|
|
1
|
+
import { DEFAULT_MAILBOXES, createInMemoryMailboxStore, } from "@intx/mailbox";
|
|
16
2
|
export function createAddressEntry(crypto) {
|
|
17
3
|
const mailboxes = new Map();
|
|
18
4
|
for (const name of DEFAULT_MAILBOXES) {
|
|
19
|
-
mailboxes.set(name,
|
|
5
|
+
mailboxes.set(name, createInMemoryMailboxStore());
|
|
20
6
|
}
|
|
21
7
|
return {
|
|
22
8
|
mailboxes,
|
|
@@ -24,29 +10,3 @@ export function createAddressEntry(crypto) {
|
|
|
24
10
|
crypto,
|
|
25
11
|
};
|
|
26
12
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Append a message to a mailbox store. Assigns UID and MODSEQ, returns the
|
|
29
|
-
* assigned UID.
|
|
30
|
-
*/
|
|
31
|
-
export function appendToMailbox(store, raw, envelope, flags) {
|
|
32
|
-
const uid = store.uidCounter++;
|
|
33
|
-
const modseq = store.modseqCounter++;
|
|
34
|
-
store.messages.push({
|
|
35
|
-
uid,
|
|
36
|
-
modseq,
|
|
37
|
-
flags: new Set(flags),
|
|
38
|
-
raw,
|
|
39
|
-
envelope,
|
|
40
|
-
});
|
|
41
|
-
return uid;
|
|
42
|
-
}
|
|
43
|
-
export function findMessage(store, uid) {
|
|
44
|
-
return store.messages.find((m) => m.uid === uid);
|
|
45
|
-
}
|
|
46
|
-
export function requireMessage(store, uid, mailboxName) {
|
|
47
|
-
const msg = findMessage(store, uid);
|
|
48
|
-
if (msg === undefined) {
|
|
49
|
-
throw new Error(`Message UID ${uid} not found in mailbox "${mailboxName}"`);
|
|
50
|
-
}
|
|
51
|
-
return msg;
|
|
52
|
-
}
|
package/dist/send.d.ts
CHANGED
|
@@ -2,10 +2,10 @@ import type { OutboundMessage, SendReceipt } from "@intx/types/runtime";
|
|
|
2
2
|
import type { AddressEntry } from "./mailbox.js";
|
|
3
3
|
/**
|
|
4
4
|
* Callback for delivering messages to recipients not registered on this
|
|
5
|
-
* transport. The federation layer provides this to forward messages
|
|
6
|
-
* the hub for remote routing.
|
|
5
|
+
* transport. The federation layer provides this to forward messages and the
|
|
6
|
+
* transport-validated sender address to the hub for remote routing.
|
|
7
7
|
*/
|
|
8
|
-
export type RemoteSendHandler = (rawMessage: Uint8Array, recipients: string[]) => Promise<void>;
|
|
8
|
+
export type RemoteSendHandler = (rawMessage: Uint8Array, recipients: string[], senderAddress: string) => Promise<void>;
|
|
9
9
|
/**
|
|
10
10
|
* Context passed to MessageSentHandler callbacks after a message is fully
|
|
11
11
|
* assembled and delivered.
|
package/dist/send.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { assembleSignedContent, assembleMessage, generateMessageId, parseHeaderSection, createDetachedSignatureFromProvider, } from "@intx/mime";
|
|
3
|
-
import { buildMessageHeaders } from "./headers.js";
|
|
1
|
+
import { buildMessageHeaders } from "@intx/mailbox";
|
|
2
|
+
import { assembleSignedContent, assembleMessage, generateMessageId, isMessageId, parseHeaderSection, createDetachedSignatureFromProvider, } from "@intx/mime";
|
|
4
3
|
const CONVERSATION_TYPES = new Set([
|
|
5
4
|
"conversation.message",
|
|
6
5
|
"conversation.join",
|
|
@@ -77,7 +76,7 @@ export async function executeSend(senderAddress, message, entries, onRemoteSend,
|
|
|
77
76
|
const signedContentBytes = assembleSignedContent(content);
|
|
78
77
|
const signatureBytes = await createDetachedSignatureFromProvider(signedContentBytes, senderCrypto);
|
|
79
78
|
const ccAddresses = ccAddressList.length > 0 ? ccAddressList : undefined;
|
|
80
|
-
const refs = buildReferences(message.inReplyTo,
|
|
79
|
+
const refs = buildReferences(message.inReplyTo, message.references);
|
|
81
80
|
const mimeHeaders = {
|
|
82
81
|
from: senderAddress,
|
|
83
82
|
to: recipients,
|
|
@@ -120,7 +119,7 @@ export async function executeSend(senderAddress, message, entries, onRemoteSend,
|
|
|
120
119
|
if (inbox === undefined) {
|
|
121
120
|
throw new Error(`Mailbox "INBOX" does not exist for recipient "${recipient}"`);
|
|
122
121
|
}
|
|
123
|
-
const uid =
|
|
122
|
+
const uid = inbox.append(rawBytes, envelope, []);
|
|
124
123
|
deliveredUids.push({ address: recipient, uid });
|
|
125
124
|
}
|
|
126
125
|
// Append copy to sender's Sent mailbox.
|
|
@@ -128,7 +127,7 @@ export async function executeSend(senderAddress, message, entries, onRemoteSend,
|
|
|
128
127
|
if (sentStore === undefined) {
|
|
129
128
|
throw new Error(`Mailbox "Sent" does not exist for sender "${senderAddress}"`);
|
|
130
129
|
}
|
|
131
|
-
|
|
130
|
+
sentStore.append(rawBytes, envelope, ["\\Seen"]);
|
|
132
131
|
// Fire local recipient watch callbacks ASYNCHRONOUSLY (per MESSAGE.md
|
|
133
132
|
// requirement). queueMicrotask ensures callbacks never run synchronously
|
|
134
133
|
// on the sender's call stack, preserving real IMAP IDLE async delivery
|
|
@@ -155,7 +154,7 @@ export async function executeSend(senderAddress, message, entries, onRemoteSend,
|
|
|
155
154
|
}
|
|
156
155
|
// Forward to remote recipients via federation hook.
|
|
157
156
|
if (remoteRecipients.length > 0 && onRemoteSend !== undefined) {
|
|
158
|
-
await onRemoteSend(rawBytes, remoteRecipients);
|
|
157
|
+
await onRemoteSend(rawBytes, remoteRecipients, senderAddress);
|
|
159
158
|
}
|
|
160
159
|
if (onMessageSent !== undefined) {
|
|
161
160
|
const localOnly = remoteRecipients.length === 0;
|
|
@@ -180,10 +179,26 @@ export async function executeSend(senderAddress, message, entries, onRemoteSend,
|
|
|
180
179
|
status: remoteRecipients.length > 0 ? "queued" : "delivered",
|
|
181
180
|
};
|
|
182
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Assemble the wire `References` chain for an outbound message.
|
|
184
|
+
*
|
|
185
|
+
* When the caller supplies a full ancestry (`existingReferences` -- the
|
|
186
|
+
* parent's References plus the parent's Message-Id, built by the threaded
|
|
187
|
+
* reply path), that chain is used and `inReplyTo` is appended only when it is
|
|
188
|
+
* not already the tail. Otherwise the chain is derived from `inReplyTo` alone,
|
|
189
|
+
* preserving the pre-existing single-element behavior.
|
|
190
|
+
*
|
|
191
|
+
* The caller-supplied chain is filtered to RFC 2822 message identifiers:
|
|
192
|
+
* inbound mail can carry a headerless-derived (sha256) or otherwise malformed
|
|
193
|
+
* Message-Id that is a valid claim-check key but not a valid `<id@host>`
|
|
194
|
+
* identifier, and such a value must not leak into a `References` header. The
|
|
195
|
+
* `inReplyTo` value is appended without filtering, matching the pre-existing
|
|
196
|
+
* `In-Reply-To`/`References` behavior for a bare reply.
|
|
197
|
+
*/
|
|
183
198
|
function buildReferences(inReplyTo, existingReferences) {
|
|
199
|
+
const refs = (existingReferences ?? []).filter(isMessageId);
|
|
184
200
|
if (inReplyTo === undefined)
|
|
185
|
-
return
|
|
186
|
-
const refs = existingReferences ?? [];
|
|
201
|
+
return refs.length > 0 ? refs : undefined;
|
|
187
202
|
if (!refs.includes(inReplyTo)) {
|
|
188
203
|
return [...refs, inReplyTo];
|
|
189
204
|
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -73,7 +73,9 @@ export declare class InMemoryTransport implements MessageTransport, HubTransport
|
|
|
73
73
|
clearFlags(_ref: MessageRef, _flags: string[], _signal?: AbortSignal): Promise<void>;
|
|
74
74
|
move(_ref: MessageRef, _toMailbox: string, _signal?: AbortSignal): Promise<void>;
|
|
75
75
|
copy(_ref: MessageRef, _toMailbox: string, _signal?: AbortSignal): Promise<void>;
|
|
76
|
-
expunge(_mailbox: string, _signal?: AbortSignal): Promise<
|
|
76
|
+
expunge(_mailbox: string, _signal?: AbortSignal): Promise<{
|
|
77
|
+
expungedUids: number[];
|
|
78
|
+
}>;
|
|
77
79
|
watch(_mailbox: string, _callback: (event: MailboxEvent) => void): Unsubscribe;
|
|
78
80
|
sync(_mailbox: string, _knownState: SyncState, _signal?: AbortSignal): Promise<SyncResult>;
|
|
79
81
|
createList(_address: string, _name: string, _signal?: AbortSignal): Promise<ListInfo>;
|
package/dist/transport.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import { createAddressEntry, createMailboxStore, requireMessage, appendToMailbox, } from "./mailbox.js";
|
|
2
1
|
import { parseHeaderSection } from "@intx/mime";
|
|
3
|
-
import { buildMessageHeaders } from "
|
|
2
|
+
import { buildMessageHeaders, createInMemoryMailboxStore, executeSearch, executeThread, fetchHeaders as doFetchHeaders, fetchStructure as doFetchStructure, fetchPart as doFetchPart, fetchFull as doFetchFull, requireMessage, } from "@intx/mailbox";
|
|
3
|
+
import { createAddressEntry } from "./mailbox.js";
|
|
4
4
|
import { executeSend, } from "./send.js";
|
|
5
|
-
import { executeSearch } from "./search.js";
|
|
6
|
-
import { executeThread } from "./thread.js";
|
|
7
|
-
import { fetchHeaders as doFetchHeaders, fetchStructure as doFetchStructure, fetchPart as doFetchPart, fetchFull as doFetchFull, } from "./fetch.js";
|
|
8
5
|
/**
|
|
9
6
|
* In-memory MessageTransport implementing full IMAP semantics within a
|
|
10
7
|
* single process. Messages are stored as real RFC 2822 MIME byte buffers.
|
|
@@ -185,7 +182,7 @@ export class InMemoryTransport {
|
|
|
185
182
|
interchangeType: headers.get("interchange-type"),
|
|
186
183
|
interchangeCorrelationId: headers.get("interchange-correlation-id"),
|
|
187
184
|
};
|
|
188
|
-
const uid =
|
|
185
|
+
const uid = inbox.append(message, envelope, []);
|
|
189
186
|
const callbacks = entry.watchCallbacks.get("INBOX");
|
|
190
187
|
if (callbacks !== undefined && callbacks.size > 0) {
|
|
191
188
|
const event = {
|
|
@@ -274,7 +271,7 @@ class ScopedMessageTransport {
|
|
|
274
271
|
interchangeType: message.headers.interchangeType,
|
|
275
272
|
interchangeCorrelationId: message.headers.interchangeCorrelationId,
|
|
276
273
|
};
|
|
277
|
-
const uid =
|
|
274
|
+
const uid = store.append(raw, envelope, flags ?? []);
|
|
278
275
|
return { uid, mailbox };
|
|
279
276
|
}
|
|
280
277
|
async listMailboxes(_signal) {
|
|
@@ -286,7 +283,7 @@ class ScopedMessageTransport {
|
|
|
286
283
|
if (this.#entry.mailboxes.has(name)) {
|
|
287
284
|
throw new Error(`Mailbox "${name}" already exists for address "${this.#address}"`);
|
|
288
285
|
}
|
|
289
|
-
this.#entry.mailboxes.set(name,
|
|
286
|
+
this.#entry.mailboxes.set(name, createInMemoryMailboxStore());
|
|
290
287
|
return { name };
|
|
291
288
|
}
|
|
292
289
|
async deleteMailbox(name, _signal) {
|
|
@@ -302,42 +299,38 @@ class ScopedMessageTransport {
|
|
|
302
299
|
total: store.messages.length,
|
|
303
300
|
unseen,
|
|
304
301
|
recent: 0,
|
|
305
|
-
uidNext: store.
|
|
302
|
+
uidNext: store.uidNext,
|
|
306
303
|
uidValidity: store.uidValidity,
|
|
307
|
-
highestModSeq: store.
|
|
304
|
+
highestModSeq: store.highestModSeq,
|
|
308
305
|
};
|
|
309
306
|
}
|
|
310
307
|
async search(mailbox, query, _signal) {
|
|
311
308
|
const store = this.#requireMailbox(mailbox);
|
|
312
|
-
return executeSearch(mailbox, store, query);
|
|
309
|
+
return await executeSearch(mailbox, store, query);
|
|
313
310
|
}
|
|
314
311
|
async thread(mailbox, algorithm, query, _signal) {
|
|
315
312
|
const store = this.#requireMailbox(mailbox);
|
|
316
|
-
return executeThread(mailbox, store, algorithm, query);
|
|
313
|
+
return await executeThread(mailbox, store, algorithm, query);
|
|
317
314
|
}
|
|
318
315
|
async fetchHeaders(ref, _signal) {
|
|
319
316
|
const store = this.#requireMailbox(ref.mailbox);
|
|
320
|
-
return doFetchHeaders(ref, store);
|
|
317
|
+
return await doFetchHeaders(ref, store);
|
|
321
318
|
}
|
|
322
319
|
async fetchStructure(ref, _signal) {
|
|
323
320
|
const store = this.#requireMailbox(ref.mailbox);
|
|
324
|
-
return doFetchStructure(ref, store);
|
|
321
|
+
return await doFetchStructure(ref, store);
|
|
325
322
|
}
|
|
326
323
|
async fetchPart(ref, partPath, _signal) {
|
|
327
324
|
const store = this.#requireMailbox(ref.mailbox);
|
|
328
|
-
return doFetchPart(ref, partPath, store);
|
|
325
|
+
return await doFetchPart(ref, partPath, store);
|
|
329
326
|
}
|
|
330
327
|
async fetchFull(ref, _signal) {
|
|
331
328
|
const store = this.#requireMailbox(ref.mailbox);
|
|
332
|
-
return doFetchFull(ref, store, (addr) => this.#entries.get(addr)?.crypto);
|
|
329
|
+
return await doFetchFull(ref, store, (addr) => this.#entries.get(addr)?.crypto);
|
|
333
330
|
}
|
|
334
331
|
async setFlags(ref, flags, _signal) {
|
|
335
332
|
const store = this.#requireMailbox(ref.mailbox);
|
|
336
|
-
const msg =
|
|
337
|
-
for (const flag of flags) {
|
|
338
|
-
msg.flags.add(flag);
|
|
339
|
-
}
|
|
340
|
-
msg.modseq = store.modseqCounter++;
|
|
333
|
+
const msg = store.addFlags(ref.uid, flags);
|
|
341
334
|
this.#fireWatchCallbacks(ref.mailbox, {
|
|
342
335
|
type: "flagsChanged",
|
|
343
336
|
uid: ref.uid,
|
|
@@ -346,11 +339,7 @@ class ScopedMessageTransport {
|
|
|
346
339
|
}
|
|
347
340
|
async clearFlags(ref, flags, _signal) {
|
|
348
341
|
const store = this.#requireMailbox(ref.mailbox);
|
|
349
|
-
const msg =
|
|
350
|
-
for (const flag of flags) {
|
|
351
|
-
msg.flags.delete(flag);
|
|
352
|
-
}
|
|
353
|
-
msg.modseq = store.modseqCounter++;
|
|
342
|
+
const msg = store.removeFlags(ref.uid, flags);
|
|
354
343
|
this.#fireWatchCallbacks(ref.mailbox, {
|
|
355
344
|
type: "flagsChanged",
|
|
356
345
|
uid: ref.uid,
|
|
@@ -360,20 +349,16 @@ class ScopedMessageTransport {
|
|
|
360
349
|
async move(ref, toMailbox, _signal) {
|
|
361
350
|
const fromStore = this.#requireMailbox(ref.mailbox);
|
|
362
351
|
const toStore = this.#requireMailbox(toMailbox);
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by findIndex !== -1 above
|
|
368
|
-
const msg = fromStore.messages[msgIdx];
|
|
369
|
-
fromStore.messages.splice(msgIdx, 1);
|
|
370
|
-
const newUid = appendToMailbox(toStore, msg.raw, msg.envelope, Array.from(msg.flags));
|
|
352
|
+
const msg = requireMessage(fromStore, ref.uid, ref.mailbox);
|
|
353
|
+
const raw = await fromStore.readRaw(ref.uid);
|
|
354
|
+
fromStore.remove(ref.uid);
|
|
355
|
+
const newUid = toStore.append(raw, msg.envelope, Array.from(msg.flags));
|
|
371
356
|
this.#fireWatchCallbacks(ref.mailbox, {
|
|
372
357
|
type: "expunged",
|
|
373
358
|
uid: ref.uid,
|
|
374
359
|
});
|
|
375
360
|
// Notify watchers of the new message in the destination mailbox.
|
|
376
|
-
const { headers: parsedHeaders } = parseHeaderSection(
|
|
361
|
+
const { headers: parsedHeaders } = parseHeaderSection(raw);
|
|
377
362
|
const msgHeaders = this.#buildMessageHeaders(parsedHeaders);
|
|
378
363
|
this.#fireWatchCallbacks(toMailbox, {
|
|
379
364
|
type: "exists",
|
|
@@ -385,8 +370,9 @@ class ScopedMessageTransport {
|
|
|
385
370
|
const fromStore = this.#requireMailbox(ref.mailbox);
|
|
386
371
|
const toStore = this.#requireMailbox(toMailbox);
|
|
387
372
|
const msg = requireMessage(fromStore, ref.uid, ref.mailbox);
|
|
388
|
-
const
|
|
389
|
-
const
|
|
373
|
+
const raw = await fromStore.readRaw(ref.uid);
|
|
374
|
+
const newUid = toStore.append(raw, msg.envelope, Array.from(msg.flags));
|
|
375
|
+
const { headers: parsedHeaders } = parseHeaderSection(raw);
|
|
390
376
|
const msgHeaders = this.#buildMessageHeaders(parsedHeaders);
|
|
391
377
|
this.#fireWatchCallbacks(toMailbox, {
|
|
392
378
|
type: "exists",
|
|
@@ -397,13 +383,16 @@ class ScopedMessageTransport {
|
|
|
397
383
|
async expunge(mailbox, _signal) {
|
|
398
384
|
const store = this.#requireMailbox(mailbox);
|
|
399
385
|
const toExpunge = store.messages.filter((m) => m.flags.has("\\Deleted"));
|
|
400
|
-
|
|
386
|
+
for (const msg of toExpunge) {
|
|
387
|
+
store.remove(msg.uid);
|
|
388
|
+
}
|
|
401
389
|
for (const msg of toExpunge) {
|
|
402
390
|
this.#fireWatchCallbacks(mailbox, {
|
|
403
391
|
type: "expunged",
|
|
404
392
|
uid: msg.uid,
|
|
405
393
|
});
|
|
406
394
|
}
|
|
395
|
+
return { expungedUids: toExpunge.map((m) => m.uid) };
|
|
407
396
|
}
|
|
408
397
|
watch(mailbox, callback) {
|
|
409
398
|
this.#requireMailbox(mailbox);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intx/mail-memory",
|
|
3
3
|
"description": "In-memory MessageTransport for single-process and test environments",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"license": "LGPL-2.1-only",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@intx/crypto": "0.
|
|
16
|
-
"@intx/log": "0.
|
|
17
|
-
"@intx/
|
|
18
|
-
"@intx/
|
|
15
|
+
"@intx/crypto": "0.4.0",
|
|
16
|
+
"@intx/log": "0.4.0",
|
|
17
|
+
"@intx/mailbox": "0.4.0",
|
|
18
|
+
"@intx/mime": "0.4.0",
|
|
19
|
+
"@intx/types": "0.4.0",
|
|
19
20
|
"arktype": "^2.1.29"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
package/dist/fetch.d.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { MessageHeaders, BodyStructure, MessagePart, InboundMessage, CryptoProvider, MessageRef } from "@intx/types/runtime";
|
|
2
|
-
import type { MailboxStore } from "./mailbox.js";
|
|
3
|
-
/**
|
|
4
|
-
* Parse raw RFC 2822 headers from a stored message.
|
|
5
|
-
*/
|
|
6
|
-
export declare function fetchHeaders(ref: MessageRef, store: MailboxStore): MessageHeaders;
|
|
7
|
-
/**
|
|
8
|
-
* Compute the MIME tree structure (BODYSTRUCTURE) without transferring content.
|
|
9
|
-
*/
|
|
10
|
-
export declare function fetchStructure(ref: MessageRef, store: MailboxStore): BodyStructure;
|
|
11
|
-
/**
|
|
12
|
-
* Fetch a single MIME part by dot-separated path.
|
|
13
|
-
*/
|
|
14
|
-
export declare function fetchPart(ref: MessageRef, partPath: string, store: MailboxStore): MessagePart;
|
|
15
|
-
/**
|
|
16
|
-
* Fetch a complete message, verify its PGP/MIME signature, and return
|
|
17
|
-
* a fully parsed InboundMessage.
|
|
18
|
-
*/
|
|
19
|
-
export declare function fetchFull(ref: MessageRef, store: MailboxStore, getCrypto: (fromAddress: string) => CryptoProvider | undefined): Promise<InboundMessage>;
|
package/dist/fetch.js
DELETED
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME multipart parsing with bounds checks */
|
|
2
|
-
import { type } from "arktype";
|
|
3
|
-
import { InterchangeType } from "@intx/types/runtime";
|
|
4
|
-
import { base64Decode } from "@intx/types";
|
|
5
|
-
import { requireMessage } from "./mailbox.js";
|
|
6
|
-
import { parseHeaderSection, parseMimePart, extractBoundary, parseMultipart, extractPartByPath, extractAttachments, } from "@intx/mime";
|
|
7
|
-
import { buildMessageHeaders } from "./headers.js";
|
|
8
|
-
import { verifyDetachedSignature } from "@intx/crypto";
|
|
9
|
-
const MessagePayload = type({
|
|
10
|
-
type: InterchangeType,
|
|
11
|
-
version: "string",
|
|
12
|
-
body: "Record<string, unknown>",
|
|
13
|
-
});
|
|
14
|
-
/**
|
|
15
|
-
* Parse raw RFC 2822 headers from a stored message.
|
|
16
|
-
*/
|
|
17
|
-
export function fetchHeaders(ref, store) {
|
|
18
|
-
const msg = requireMessage(store, ref.uid, ref.mailbox);
|
|
19
|
-
const { headers } = parseHeaderSection(msg.raw);
|
|
20
|
-
return buildMessageHeaders(headers);
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Compute the MIME tree structure (BODYSTRUCTURE) without transferring content.
|
|
24
|
-
*/
|
|
25
|
-
export function fetchStructure(ref, store) {
|
|
26
|
-
const msg = requireMessage(store, ref.uid, ref.mailbox);
|
|
27
|
-
const { headers, bodyOffset } = parseHeaderSection(msg.raw);
|
|
28
|
-
const body = msg.raw.slice(bodyOffset);
|
|
29
|
-
const contentType = headers.get("content-type") ?? "application/octet-stream";
|
|
30
|
-
return buildStructure(body, contentType);
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Fetch a single MIME part by dot-separated path.
|
|
34
|
-
*/
|
|
35
|
-
export function fetchPart(ref, partPath, store) {
|
|
36
|
-
const msg = requireMessage(store, ref.uid, ref.mailbox);
|
|
37
|
-
const partBytes = extractPartByPath(msg.raw, partPath);
|
|
38
|
-
const part = parseMimePart(partBytes);
|
|
39
|
-
const enc = part.headers.get("content-transfer-encoding") ?? "7bit";
|
|
40
|
-
let content;
|
|
41
|
-
if (enc.toLowerCase() === "base64") {
|
|
42
|
-
const b64 = new TextDecoder().decode(part.body).replace(/\s/g, "");
|
|
43
|
-
content = base64Decode(b64);
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
content = part.body;
|
|
47
|
-
}
|
|
48
|
-
const result = {
|
|
49
|
-
contentType: part.contentType,
|
|
50
|
-
content,
|
|
51
|
-
};
|
|
52
|
-
if (enc !== "7bit")
|
|
53
|
-
result.encoding = enc;
|
|
54
|
-
return result;
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Fetch a complete message, verify its PGP/MIME signature, and return
|
|
58
|
-
* a fully parsed InboundMessage.
|
|
59
|
-
*/
|
|
60
|
-
export async function fetchFull(ref, store, getCrypto) {
|
|
61
|
-
const msg = requireMessage(store, ref.uid, ref.mailbox);
|
|
62
|
-
const { headers } = parseHeaderSection(msg.raw);
|
|
63
|
-
const parsedHeaders = buildMessageHeaders(headers);
|
|
64
|
-
const rawType = parsedHeaders.interchangeType;
|
|
65
|
-
const isConversation = rawType === "conversation.message" ||
|
|
66
|
-
rawType === "conversation.join" ||
|
|
67
|
-
rawType === "conversation.leave" ||
|
|
68
|
-
rawType === undefined;
|
|
69
|
-
const signatureStatus = await verifyMessageSignature(msg.raw, parsedHeaders.from, getCrypto);
|
|
70
|
-
const result = {
|
|
71
|
-
ref,
|
|
72
|
-
headers: parsedHeaders,
|
|
73
|
-
flags: Array.from(msg.flags),
|
|
74
|
-
signatureStatus,
|
|
75
|
-
};
|
|
76
|
-
try {
|
|
77
|
-
if (isConversation) {
|
|
78
|
-
const part1 = parseMimePart(extractPartByPath(msg.raw, "1"));
|
|
79
|
-
const part1Mime = part1.contentType.split(";")[0].trim().toLowerCase();
|
|
80
|
-
if (part1Mime.startsWith("multipart/")) {
|
|
81
|
-
// Conversation shape: multipart/mixed with the text body at 1.1.
|
|
82
|
-
const textPart = parseMimePart(extractPartByPath(msg.raw, "1.1"));
|
|
83
|
-
result.content = new TextDecoder("utf-8", { fatal: false }).decode(textPart.body);
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
// A conversation message is "literally a signed email", so a sender
|
|
87
|
-
// (e.g. a plain mail client) may sign a bare text/plain part with no
|
|
88
|
-
// multipart/mixed wrapper. This branch reads that body directly. Our
|
|
89
|
-
// own assembler always emits multipart/mixed; without this branch a
|
|
90
|
-
// bare text/plain message would fail the 1.1 lookup and silently lose
|
|
91
|
-
// its content to the catch below.
|
|
92
|
-
result.content = new TextDecoder("utf-8", { fatal: false }).decode(part1.body);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
else {
|
|
96
|
-
// Structured messages carry their JSON payload at 1.1. Attachments on
|
|
97
|
-
// structured messages are intentionally not parsed: they have no
|
|
98
|
-
// producer today, so parsing them would handle a shape nobody sends.
|
|
99
|
-
const part11Bytes = extractPartByPath(msg.raw, "1.1");
|
|
100
|
-
const part11 = parseMimePart(part11Bytes);
|
|
101
|
-
const jsonText = new TextDecoder("utf-8", { fatal: false }).decode(part11.body);
|
|
102
|
-
const validated = MessagePayload(JSON.parse(jsonText));
|
|
103
|
-
if (validated instanceof type.errors) {
|
|
104
|
-
throw new Error(`invalid message payload: ${validated.summary}`);
|
|
105
|
-
}
|
|
106
|
-
result.payload = validated;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
// If we can't parse the content, return what we have with the signature status.
|
|
111
|
-
}
|
|
112
|
-
// Attachment parsing is deliberately outside the catch above: a malformed
|
|
113
|
-
// attachment must surface as a thrown error, not be silently dropped.
|
|
114
|
-
if (isConversation) {
|
|
115
|
-
const attachments = extractAttachments(msg.raw);
|
|
116
|
-
if (attachments.length > 0) {
|
|
117
|
-
result.attachments = attachments;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
return result;
|
|
121
|
-
}
|
|
122
|
-
async function verifyMessageSignature(raw, fromAddress, getCrypto) {
|
|
123
|
-
const senderCrypto = getCrypto(fromAddress);
|
|
124
|
-
if (senderCrypto === undefined) {
|
|
125
|
-
return "unknown";
|
|
126
|
-
}
|
|
127
|
-
try {
|
|
128
|
-
const { headers, bodyOffset } = parseHeaderSection(raw);
|
|
129
|
-
const body = raw.slice(bodyOffset);
|
|
130
|
-
const contentType = headers.get("content-type") ?? "";
|
|
131
|
-
if (!contentType.toLowerCase().includes("multipart/signed")) {
|
|
132
|
-
return "missing";
|
|
133
|
-
}
|
|
134
|
-
const boundary = extractBoundary(contentType);
|
|
135
|
-
if (boundary === undefined)
|
|
136
|
-
return "missing";
|
|
137
|
-
const parts = parseMultipart(body, boundary);
|
|
138
|
-
if (parts.length < 2)
|
|
139
|
-
return "missing";
|
|
140
|
-
const signedContentBytes = parts[0];
|
|
141
|
-
const sigPartBytes = parts[1];
|
|
142
|
-
const sigPart = parseMimePart(sigPartBytes);
|
|
143
|
-
if (!sigPart.contentType.toLowerCase().includes("application/pgp-signature")) {
|
|
144
|
-
return "missing";
|
|
145
|
-
}
|
|
146
|
-
const publicKey = senderCrypto.getPublicKey();
|
|
147
|
-
const valid = await verifyDetachedSignature(signedContentBytes, sigPart.body, publicKey);
|
|
148
|
-
return valid ? "valid" : "invalid";
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
return "invalid";
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
function buildStructure(body, contentType) {
|
|
155
|
-
const ct = contentType.toLowerCase();
|
|
156
|
-
if (!ct.startsWith("multipart/")) {
|
|
157
|
-
return { contentType, size: body.length };
|
|
158
|
-
}
|
|
159
|
-
const boundary = extractBoundary(contentType);
|
|
160
|
-
if (boundary === undefined) {
|
|
161
|
-
return { contentType, size: body.length };
|
|
162
|
-
}
|
|
163
|
-
const parts = parseMultipart(body, boundary);
|
|
164
|
-
const subStructures = parts.map((partBytes) => {
|
|
165
|
-
const { headers, bodyOffset } = parseHeaderSection(partBytes);
|
|
166
|
-
const partBody = partBytes.slice(bodyOffset);
|
|
167
|
-
const partContentType = headers.get("content-type") ?? "application/octet-stream";
|
|
168
|
-
return buildStructure(partBody, partContentType);
|
|
169
|
-
});
|
|
170
|
-
return { contentType, parts: subStructures };
|
|
171
|
-
}
|
package/dist/headers.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import type { MessageHeaders } from "@intx/types/runtime";
|
|
2
|
-
/**
|
|
3
|
-
* Build a MessageHeaders object from a parsed header map.
|
|
4
|
-
*
|
|
5
|
-
* Uses exactOptionalPropertyTypes-safe construction: optional fields are
|
|
6
|
-
* only included in the returned object when they carry actual values.
|
|
7
|
-
*/
|
|
8
|
-
export declare function buildMessageHeaders(headers: Map<string, string>): MessageHeaders;
|
package/dist/headers.js
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { type } from "arktype";
|
|
2
|
-
import { InterchangeType } from "@intx/types/runtime";
|
|
3
|
-
function isInterchangeType(s) {
|
|
4
|
-
return !(InterchangeType(s) instanceof type.errors);
|
|
5
|
-
}
|
|
6
|
-
/**
|
|
7
|
-
* Build a MessageHeaders object from a parsed header map.
|
|
8
|
-
*
|
|
9
|
-
* Uses exactOptionalPropertyTypes-safe construction: optional fields are
|
|
10
|
-
* only included in the returned object when they carry actual values.
|
|
11
|
-
*/
|
|
12
|
-
export function buildMessageHeaders(headers) {
|
|
13
|
-
const from = headers.get("from") ?? "";
|
|
14
|
-
const toRaw = headers.get("to") ?? "";
|
|
15
|
-
const to = toRaw
|
|
16
|
-
? toRaw
|
|
17
|
-
.split(",")
|
|
18
|
-
.map((s) => s.trim())
|
|
19
|
-
.filter(Boolean)
|
|
20
|
-
: [];
|
|
21
|
-
const date = headers.get("date") ?? "";
|
|
22
|
-
const messageId = headers.get("message-id") ?? "";
|
|
23
|
-
const result = { from, to, date, messageId };
|
|
24
|
-
const ccRaw = headers.get("cc");
|
|
25
|
-
if (ccRaw !== undefined) {
|
|
26
|
-
const cc = ccRaw
|
|
27
|
-
.split(",")
|
|
28
|
-
.map((s) => s.trim())
|
|
29
|
-
.filter(Boolean);
|
|
30
|
-
if (cc.length > 0)
|
|
31
|
-
result.cc = cc;
|
|
32
|
-
}
|
|
33
|
-
const refsRaw = headers.get("references");
|
|
34
|
-
if (refsRaw !== undefined) {
|
|
35
|
-
const refs = refsRaw.split(/\s+/).filter(Boolean);
|
|
36
|
-
if (refs.length > 0)
|
|
37
|
-
result.references = refs;
|
|
38
|
-
}
|
|
39
|
-
const inReplyTo = headers.get("in-reply-to");
|
|
40
|
-
if (inReplyTo !== undefined)
|
|
41
|
-
result.inReplyTo = inReplyTo;
|
|
42
|
-
const subject = headers.get("subject");
|
|
43
|
-
if (subject !== undefined)
|
|
44
|
-
result.subject = subject;
|
|
45
|
-
const listId = headers.get("list-id");
|
|
46
|
-
if (listId !== undefined)
|
|
47
|
-
result.listId = listId;
|
|
48
|
-
const rawType = headers.get("interchange-type");
|
|
49
|
-
if (rawType !== undefined && isInterchangeType(rawType)) {
|
|
50
|
-
result.interchangeType = rawType;
|
|
51
|
-
}
|
|
52
|
-
const corrId = headers.get("interchange-correlation-id");
|
|
53
|
-
if (corrId !== undefined)
|
|
54
|
-
result.interchangeCorrelationId = corrId;
|
|
55
|
-
const tenantId = headers.get("interchange-tenant-id");
|
|
56
|
-
if (tenantId !== undefined)
|
|
57
|
-
result.interchangeTenantId = tenantId;
|
|
58
|
-
const agentId = headers.get("interchange-agent-id");
|
|
59
|
-
if (agentId !== undefined)
|
|
60
|
-
result.interchangeAgentId = agentId;
|
|
61
|
-
const sessionId = headers.get("interchange-session-id");
|
|
62
|
-
if (sessionId !== undefined)
|
|
63
|
-
result.interchangeSessionId = sessionId;
|
|
64
|
-
const offeringId = headers.get("interchange-offering-id");
|
|
65
|
-
if (offeringId !== undefined)
|
|
66
|
-
result.interchangeOfferingId = offeringId;
|
|
67
|
-
const schemaVersion = headers.get("interchange-schema-version");
|
|
68
|
-
if (schemaVersion !== undefined)
|
|
69
|
-
result.interchangeSchemaVersion = schemaVersion;
|
|
70
|
-
const traceparent = headers.get("traceparent");
|
|
71
|
-
if (traceparent !== undefined)
|
|
72
|
-
result.traceparent = traceparent;
|
|
73
|
-
const tracestate = headers.get("tracestate");
|
|
74
|
-
if (tracestate !== undefined)
|
|
75
|
-
result.tracestate = tracestate;
|
|
76
|
-
return result;
|
|
77
|
-
}
|
package/dist/search.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { SearchQuery, MessageRef } from "@intx/types/runtime";
|
|
2
|
-
import type { MailboxStore } from "./mailbox.js";
|
|
3
|
-
/**
|
|
4
|
-
* Execute an IMAP SEARCH-equivalent query over an in-memory mailbox.
|
|
5
|
-
*
|
|
6
|
-
* Supports: from, to, cc, bcc, header (field match), before/after/on,
|
|
7
|
-
* sentBefore/sentAfter/sentOn, hasFlags, missingFlags, body, text,
|
|
8
|
-
* largerThan, smallerThan, and boolean and/or/not composition.
|
|
9
|
-
*
|
|
10
|
-
* Returns MessageRef[] for all matching messages, ordered by UID.
|
|
11
|
-
*/
|
|
12
|
-
export declare function executeSearch(mailboxName: string, store: MailboxStore, query: SearchQuery): MessageRef[];
|
package/dist/search.js
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
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/thread.d.ts
DELETED
package/dist/thread.js
DELETED
|
@@ -1,204 +0,0 @@
|
|
|
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
|
-
}
|