@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/transport.ts
ADDED
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MessageTransport,
|
|
3
|
+
OutboundMessage,
|
|
4
|
+
SendReceipt,
|
|
5
|
+
InboundMessage,
|
|
6
|
+
MessageRef,
|
|
7
|
+
Mailbox,
|
|
8
|
+
MailboxStatus,
|
|
9
|
+
SearchQuery,
|
|
10
|
+
Thread,
|
|
11
|
+
MessageHeaders,
|
|
12
|
+
BodyStructure,
|
|
13
|
+
MessagePart,
|
|
14
|
+
SyncState,
|
|
15
|
+
SyncResult,
|
|
16
|
+
ListInfo,
|
|
17
|
+
MailboxEvent,
|
|
18
|
+
Unsubscribe,
|
|
19
|
+
CryptoProvider,
|
|
20
|
+
} from "@intx/types/runtime";
|
|
21
|
+
import {
|
|
22
|
+
createAddressEntry,
|
|
23
|
+
createMailboxStore,
|
|
24
|
+
requireMessage,
|
|
25
|
+
appendToMailbox,
|
|
26
|
+
type AddressEntry,
|
|
27
|
+
} from "./mailbox";
|
|
28
|
+
import { parseHeaderSection } from "@intx/mime";
|
|
29
|
+
import { buildMessageHeaders } from "./headers";
|
|
30
|
+
import {
|
|
31
|
+
executeSend,
|
|
32
|
+
type RemoteSendHandler,
|
|
33
|
+
type MessageSentHandler,
|
|
34
|
+
} from "./send";
|
|
35
|
+
import { executeSearch } from "./search";
|
|
36
|
+
import { executeThread } from "./thread";
|
|
37
|
+
import {
|
|
38
|
+
fetchHeaders as doFetchHeaders,
|
|
39
|
+
fetchStructure as doFetchStructure,
|
|
40
|
+
fetchPart as doFetchPart,
|
|
41
|
+
fetchFull as doFetchFull,
|
|
42
|
+
} from "./fetch";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The hub-side surface a transport must expose to coordinate per-agent
|
|
46
|
+
* registration, mail routing, and outbound-audit hooks. SessionManager
|
|
47
|
+
* and HubLink in `@intx/hub-agent` depend on this interface rather
|
|
48
|
+
* than on `InMemoryTransport` directly so custom hosts can supply
|
|
49
|
+
* their own backend (e.g. an SMTP/IMAP relay) without touching the
|
|
50
|
+
* package's seams.
|
|
51
|
+
*/
|
|
52
|
+
export interface HubTransport {
|
|
53
|
+
register(address: string, crypto: CryptoProvider): void;
|
|
54
|
+
unregister(address: string): void;
|
|
55
|
+
getTransportFor(address: string): MessageTransport;
|
|
56
|
+
setRemoteSendHandler(handler: RemoteSendHandler): void;
|
|
57
|
+
addMessageSentHandler(handler: MessageSentHandler): void;
|
|
58
|
+
/**
|
|
59
|
+
* Drop a hub-routed RFC 2822 message directly into an address's
|
|
60
|
+
* inbox. Used by the wire layer (HubLink) for inbound mail frames.
|
|
61
|
+
*/
|
|
62
|
+
deliver(address: string, message: Uint8Array): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* In-memory MessageTransport implementing full IMAP semantics within a
|
|
67
|
+
* single process. Messages are stored as real RFC 2822 MIME byte buffers.
|
|
68
|
+
*
|
|
69
|
+
* Every outbound message is PGP/MIME signed with the sender's CryptoProvider.
|
|
70
|
+
* Signature verification runs on fetchFull().
|
|
71
|
+
*
|
|
72
|
+
* Addresses must be registered before sending or receiving messages.
|
|
73
|
+
*/
|
|
74
|
+
export class InMemoryTransport implements MessageTransport, HubTransport {
|
|
75
|
+
readonly #entries = new Map<string, AddressEntry>();
|
|
76
|
+
#remoteSendHandler: RemoteSendHandler | undefined;
|
|
77
|
+
readonly #messageSentHandlers = new Set<MessageSentHandler>();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Set a handler for delivering messages to recipients not registered on
|
|
81
|
+
* this transport. The federation layer calls this to wire up the websocket
|
|
82
|
+
* connection to the hub. When set, send() forwards unregistered recipients
|
|
83
|
+
* to this handler instead of throwing.
|
|
84
|
+
*/
|
|
85
|
+
setRemoteSendHandler(handler: RemoteSendHandler): void {
|
|
86
|
+
this.#remoteSendHandler = handler;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Register a handler that fires after every successful send(). Multiple
|
|
91
|
+
* handlers may be registered. The message is already delivered when
|
|
92
|
+
* handlers fire — a handler rejection does not mean the message was not
|
|
93
|
+
* delivered.
|
|
94
|
+
*/
|
|
95
|
+
addMessageSentHandler(handler: MessageSentHandler): void {
|
|
96
|
+
this.#messageSentHandlers.add(handler);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Register an address with its CryptoProvider. Creates the default set
|
|
101
|
+
* of mailboxes (INBOX, Sent, Drafts, Archive, Trash).
|
|
102
|
+
*
|
|
103
|
+
* Throws if the address is already registered.
|
|
104
|
+
*/
|
|
105
|
+
register(address: string, crypto: CryptoProvider): void {
|
|
106
|
+
if (this.#entries.has(address)) {
|
|
107
|
+
throw new Error(`Address "${address}" is already registered`);
|
|
108
|
+
}
|
|
109
|
+
this.#entries.set(address, createAddressEntry(crypto));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Remove an address's mailboxes and crypto provider. Called when a
|
|
114
|
+
* session is destroyed so the address can be re-registered later.
|
|
115
|
+
*/
|
|
116
|
+
unregister(address: string): void {
|
|
117
|
+
this.#entries.delete(address);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Outbound
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
async send(
|
|
125
|
+
_message: OutboundMessage,
|
|
126
|
+
_signal?: AbortSignal,
|
|
127
|
+
): Promise<SendReceipt> {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Use createInMemoryTransport().getTransportFor(address) to send messages",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async append(
|
|
134
|
+
_mailbox: string,
|
|
135
|
+
_message: InboundMessage,
|
|
136
|
+
_flags?: string[],
|
|
137
|
+
_signal?: AbortSignal,
|
|
138
|
+
): Promise<MessageRef> {
|
|
139
|
+
throw new Error(
|
|
140
|
+
"Use createInMemoryTransport().getTransportFor(address) to append messages",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Mailbox management (per-address — use getTransportFor)
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
async listMailboxes(_signal?: AbortSignal): Promise<Mailbox[]> {
|
|
149
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async createMailbox(_name: string, _signal?: AbortSignal): Promise<Mailbox> {
|
|
153
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async deleteMailbox(_name: string, _signal?: AbortSignal): Promise<void> {
|
|
157
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async getMailboxStatus(
|
|
161
|
+
_name: string,
|
|
162
|
+
_signal?: AbortSignal,
|
|
163
|
+
): Promise<MailboxStatus> {
|
|
164
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async search(
|
|
168
|
+
_mailbox: string,
|
|
169
|
+
_query: SearchQuery,
|
|
170
|
+
_signal?: AbortSignal,
|
|
171
|
+
): Promise<MessageRef[]> {
|
|
172
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async thread(
|
|
176
|
+
_mailbox: string,
|
|
177
|
+
_algorithm: "references" | "orderedsubject",
|
|
178
|
+
_query?: SearchQuery,
|
|
179
|
+
_signal?: AbortSignal,
|
|
180
|
+
): Promise<Thread[]> {
|
|
181
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async fetchHeaders(
|
|
185
|
+
_ref: MessageRef,
|
|
186
|
+
_signal?: AbortSignal,
|
|
187
|
+
): Promise<MessageHeaders> {
|
|
188
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async fetchStructure(
|
|
192
|
+
_ref: MessageRef,
|
|
193
|
+
_signal?: AbortSignal,
|
|
194
|
+
): Promise<BodyStructure> {
|
|
195
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async fetchPart(
|
|
199
|
+
_ref: MessageRef,
|
|
200
|
+
_partPath: string,
|
|
201
|
+
_signal?: AbortSignal,
|
|
202
|
+
): Promise<MessagePart> {
|
|
203
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async fetchFull(
|
|
207
|
+
_ref: MessageRef,
|
|
208
|
+
_signal?: AbortSignal,
|
|
209
|
+
): Promise<InboundMessage> {
|
|
210
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async setFlags(
|
|
214
|
+
_ref: MessageRef,
|
|
215
|
+
_flags: string[],
|
|
216
|
+
_signal?: AbortSignal,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async clearFlags(
|
|
222
|
+
_ref: MessageRef,
|
|
223
|
+
_flags: string[],
|
|
224
|
+
_signal?: AbortSignal,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async move(
|
|
230
|
+
_ref: MessageRef,
|
|
231
|
+
_toMailbox: string,
|
|
232
|
+
_signal?: AbortSignal,
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async copy(
|
|
238
|
+
_ref: MessageRef,
|
|
239
|
+
_toMailbox: string,
|
|
240
|
+
_signal?: AbortSignal,
|
|
241
|
+
): Promise<void> {
|
|
242
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async expunge(_mailbox: string, _signal?: AbortSignal): Promise<void> {
|
|
246
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
watch(
|
|
250
|
+
_mailbox: string,
|
|
251
|
+
_callback: (event: MailboxEvent) => void,
|
|
252
|
+
): Unsubscribe {
|
|
253
|
+
throw new Error("Use getTransportFor(address) for per-address operations");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async sync(
|
|
257
|
+
_mailbox: string,
|
|
258
|
+
_knownState: SyncState,
|
|
259
|
+
_signal?: AbortSignal,
|
|
260
|
+
): Promise<SyncResult> {
|
|
261
|
+
throw new Error("sync() (QRESYNC) is not implemented");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async createList(
|
|
265
|
+
_address: string,
|
|
266
|
+
_name: string,
|
|
267
|
+
_signal?: AbortSignal,
|
|
268
|
+
): Promise<ListInfo> {
|
|
269
|
+
throw new Error("Distribution list management is not implemented");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async listMembers(
|
|
273
|
+
_address: string,
|
|
274
|
+
_signal?: AbortSignal,
|
|
275
|
+
): Promise<string[]> {
|
|
276
|
+
throw new Error("Distribution list management is not implemented");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async subscribe(
|
|
280
|
+
_listAddress: string,
|
|
281
|
+
_subscriberAddress: string,
|
|
282
|
+
_signal?: AbortSignal,
|
|
283
|
+
): Promise<void> {
|
|
284
|
+
throw new Error("Distribution list management is not implemented");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async unsubscribe(
|
|
288
|
+
_listAddress: string,
|
|
289
|
+
_subscriberAddress: string,
|
|
290
|
+
_signal?: AbortSignal,
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
throw new Error("Distribution list management is not implemented");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Inbound delivery from federation
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Deliver a signed MIME message to an address's INBOX. Used by the
|
|
301
|
+
* federation layer when a message arrives from the hub over the
|
|
302
|
+
* websocket — the message is already assembled and signed by the
|
|
303
|
+
* originating sender, so no further processing is needed beyond
|
|
304
|
+
* envelope parsing and storage.
|
|
305
|
+
*
|
|
306
|
+
* Throws if the address is not registered.
|
|
307
|
+
*/
|
|
308
|
+
deliver(address: string, message: Uint8Array): void {
|
|
309
|
+
const entry = this.#entries.get(address);
|
|
310
|
+
if (entry === undefined) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Address "${address}" is not registered — cannot deliver mail`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const inbox = entry.mailboxes.get("INBOX");
|
|
316
|
+
if (inbox === undefined) {
|
|
317
|
+
throw new Error(`Address "${address}" has no INBOX`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const { headers } = parseHeaderSection(message);
|
|
321
|
+
|
|
322
|
+
const messageId = headers.get("message-id");
|
|
323
|
+
const from = headers.get("from");
|
|
324
|
+
const dateRaw = headers.get("date");
|
|
325
|
+
if (messageId === undefined) {
|
|
326
|
+
throw new Error("Cannot deliver message: missing Message-ID header");
|
|
327
|
+
}
|
|
328
|
+
if (from === undefined) {
|
|
329
|
+
throw new Error("Cannot deliver message: missing From header");
|
|
330
|
+
}
|
|
331
|
+
if (dateRaw === undefined) {
|
|
332
|
+
throw new Error("Cannot deliver message: missing Date header");
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const msgHeaders = buildMessageHeaders(headers);
|
|
336
|
+
|
|
337
|
+
const toRaw = headers.get("to") ?? "";
|
|
338
|
+
const to = toRaw
|
|
339
|
+
? toRaw
|
|
340
|
+
.split(",")
|
|
341
|
+
.map((s) => s.trim())
|
|
342
|
+
.filter(Boolean)
|
|
343
|
+
: [];
|
|
344
|
+
|
|
345
|
+
const refsRaw = headers.get("references");
|
|
346
|
+
const references = refsRaw ? refsRaw.split(/\s+/).filter(Boolean) : [];
|
|
347
|
+
|
|
348
|
+
const envelope: import("./mailbox").StoredEnvelope = {
|
|
349
|
+
messageId,
|
|
350
|
+
from,
|
|
351
|
+
to,
|
|
352
|
+
subject: headers.get("subject") ?? "",
|
|
353
|
+
date: new Date(dateRaw),
|
|
354
|
+
inReplyTo: headers.get("in-reply-to"),
|
|
355
|
+
references,
|
|
356
|
+
interchangeType: headers.get("interchange-type"),
|
|
357
|
+
interchangeCorrelationId: headers.get("interchange-correlation-id"),
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const uid = appendToMailbox(inbox, message, envelope, []);
|
|
361
|
+
|
|
362
|
+
const callbacks = entry.watchCallbacks.get("INBOX");
|
|
363
|
+
if (callbacks !== undefined && callbacks.size > 0) {
|
|
364
|
+
const event: import("@intx/types/runtime").MailboxEvent = {
|
|
365
|
+
type: "exists",
|
|
366
|
+
uid,
|
|
367
|
+
headers: msgHeaders,
|
|
368
|
+
};
|
|
369
|
+
for (const cb of callbacks) {
|
|
370
|
+
queueMicrotask(() => cb(event));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Internal: per-address view
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Returns a MessageTransport scoped to the given address. Callers use
|
|
381
|
+
* this to send and read mail as that address.
|
|
382
|
+
*/
|
|
383
|
+
getTransportFor(address: string): MessageTransport {
|
|
384
|
+
if (!this.#entries.has(address)) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Address "${address}" is not registered — call register() first`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
return new ScopedMessageTransport(
|
|
390
|
+
address,
|
|
391
|
+
this.#entries,
|
|
392
|
+
() => this.#remoteSendHandler,
|
|
393
|
+
() => this.#messageSentHandlers,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* MessageTransport scoped to a single address. All operations target that
|
|
400
|
+
* address's mailboxes. Constructed via InMemoryTransport.getTransportFor().
|
|
401
|
+
*/
|
|
402
|
+
class ScopedMessageTransport implements MessageTransport {
|
|
403
|
+
readonly #address: string;
|
|
404
|
+
readonly #entries: Map<string, AddressEntry>;
|
|
405
|
+
readonly #getRemoteSendHandler: () => RemoteSendHandler | undefined;
|
|
406
|
+
readonly #getMessageSentHandlers: () => Set<MessageSentHandler>;
|
|
407
|
+
|
|
408
|
+
constructor(
|
|
409
|
+
address: string,
|
|
410
|
+
entries: Map<string, AddressEntry>,
|
|
411
|
+
getRemoteSendHandler: () => RemoteSendHandler | undefined,
|
|
412
|
+
getMessageSentHandlers: () => Set<MessageSentHandler>,
|
|
413
|
+
) {
|
|
414
|
+
this.#address = address;
|
|
415
|
+
this.#entries = entries;
|
|
416
|
+
this.#getRemoteSendHandler = getRemoteSendHandler;
|
|
417
|
+
this.#getMessageSentHandlers = getMessageSentHandlers;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
get #entry(): AddressEntry {
|
|
421
|
+
const e = this.#entries.get(this.#address);
|
|
422
|
+
if (e === undefined) {
|
|
423
|
+
throw new Error(`Address "${this.#address}" has been deregistered`);
|
|
424
|
+
}
|
|
425
|
+
return e;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
#requireMailbox(name: string) {
|
|
429
|
+
const store = this.#entry.mailboxes.get(name);
|
|
430
|
+
if (store === undefined) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
`Mailbox "${name}" does not exist for address "${this.#address}"`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return store;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async send(
|
|
439
|
+
message: OutboundMessage,
|
|
440
|
+
_signal?: AbortSignal,
|
|
441
|
+
): Promise<SendReceipt> {
|
|
442
|
+
// Trip the deregistered guard so callers using a stale scoped handle
|
|
443
|
+
// see a precise error rather than the generic "sender is not
|
|
444
|
+
// registered" thrown by executeSend.
|
|
445
|
+
void this.#entry;
|
|
446
|
+
|
|
447
|
+
const handlers = this.#getMessageSentHandlers();
|
|
448
|
+
const aggregatedHandler: MessageSentHandler | undefined =
|
|
449
|
+
handlers.size > 0
|
|
450
|
+
? async (ctx) => {
|
|
451
|
+
await Promise.allSettled([...handlers].map((h) => h(ctx)));
|
|
452
|
+
}
|
|
453
|
+
: undefined;
|
|
454
|
+
return executeSend(
|
|
455
|
+
this.#address,
|
|
456
|
+
message,
|
|
457
|
+
this.#entries,
|
|
458
|
+
this.#getRemoteSendHandler(),
|
|
459
|
+
aggregatedHandler,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async append(
|
|
464
|
+
mailbox: string,
|
|
465
|
+
message: InboundMessage,
|
|
466
|
+
flags?: string[],
|
|
467
|
+
_signal?: AbortSignal,
|
|
468
|
+
): Promise<MessageRef> {
|
|
469
|
+
const store = this.#requireMailbox(mailbox);
|
|
470
|
+
// For append, we need to convert InboundMessage back to raw bytes.
|
|
471
|
+
// Since InboundMessage may come from a prior fetchFull, we need the raw
|
|
472
|
+
// bytes. This is a design gap — append() takes InboundMessage but we
|
|
473
|
+
// need Uint8Array. We store a minimal representation.
|
|
474
|
+
//
|
|
475
|
+
// For now, serialize the InboundMessage as a minimal RFC 2822 message.
|
|
476
|
+
const raw = inboundMessageToRaw(message);
|
|
477
|
+
const envelope = {
|
|
478
|
+
messageId: message.headers.messageId,
|
|
479
|
+
from: message.headers.from,
|
|
480
|
+
to: message.headers.to,
|
|
481
|
+
subject: message.headers.subject ?? "",
|
|
482
|
+
date: new Date(message.headers.date),
|
|
483
|
+
inReplyTo: message.headers.inReplyTo,
|
|
484
|
+
references: message.headers.references ?? [],
|
|
485
|
+
interchangeType: message.headers.interchangeType,
|
|
486
|
+
interchangeCorrelationId: message.headers.interchangeCorrelationId,
|
|
487
|
+
};
|
|
488
|
+
const uid = appendToMailbox(store, raw, envelope, flags ?? []);
|
|
489
|
+
return { uid, mailbox };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async listMailboxes(_signal?: AbortSignal): Promise<Mailbox[]> {
|
|
493
|
+
return Array.from(this.#entry.mailboxes.keys()).map((name) => ({
|
|
494
|
+
name,
|
|
495
|
+
}));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async createMailbox(name: string, _signal?: AbortSignal): Promise<Mailbox> {
|
|
499
|
+
if (this.#entry.mailboxes.has(name)) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
`Mailbox "${name}" already exists for address "${this.#address}"`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
this.#entry.mailboxes.set(name, createMailboxStore());
|
|
505
|
+
return { name };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async deleteMailbox(name: string, _signal?: AbortSignal): Promise<void> {
|
|
509
|
+
if (!this.#entry.mailboxes.has(name)) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
`Mailbox "${name}" does not exist for address "${this.#address}"`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
this.#entry.mailboxes.delete(name);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async getMailboxStatus(
|
|
518
|
+
name: string,
|
|
519
|
+
_signal?: AbortSignal,
|
|
520
|
+
): Promise<MailboxStatus> {
|
|
521
|
+
const store = this.#requireMailbox(name);
|
|
522
|
+
const unseen = store.messages.filter((m) => !m.flags.has("\\Seen")).length;
|
|
523
|
+
return {
|
|
524
|
+
total: store.messages.length,
|
|
525
|
+
unseen,
|
|
526
|
+
recent: 0,
|
|
527
|
+
uidNext: store.uidCounter,
|
|
528
|
+
uidValidity: store.uidValidity,
|
|
529
|
+
highestModSeq: store.modseqCounter - 1,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async search(
|
|
534
|
+
mailbox: string,
|
|
535
|
+
query: SearchQuery,
|
|
536
|
+
_signal?: AbortSignal,
|
|
537
|
+
): Promise<MessageRef[]> {
|
|
538
|
+
const store = this.#requireMailbox(mailbox);
|
|
539
|
+
return executeSearch(mailbox, store, query);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async thread(
|
|
543
|
+
mailbox: string,
|
|
544
|
+
algorithm: "references" | "orderedsubject",
|
|
545
|
+
query?: SearchQuery,
|
|
546
|
+
_signal?: AbortSignal,
|
|
547
|
+
): Promise<Thread[]> {
|
|
548
|
+
const store = this.#requireMailbox(mailbox);
|
|
549
|
+
return executeThread(mailbox, store, algorithm, query);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async fetchHeaders(
|
|
553
|
+
ref: MessageRef,
|
|
554
|
+
_signal?: AbortSignal,
|
|
555
|
+
): Promise<MessageHeaders> {
|
|
556
|
+
const store = this.#requireMailbox(ref.mailbox);
|
|
557
|
+
return doFetchHeaders(ref, store);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async fetchStructure(
|
|
561
|
+
ref: MessageRef,
|
|
562
|
+
_signal?: AbortSignal,
|
|
563
|
+
): Promise<BodyStructure> {
|
|
564
|
+
const store = this.#requireMailbox(ref.mailbox);
|
|
565
|
+
return doFetchStructure(ref, store);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async fetchPart(
|
|
569
|
+
ref: MessageRef,
|
|
570
|
+
partPath: string,
|
|
571
|
+
_signal?: AbortSignal,
|
|
572
|
+
): Promise<MessagePart> {
|
|
573
|
+
const store = this.#requireMailbox(ref.mailbox);
|
|
574
|
+
return doFetchPart(ref, partPath, store);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async fetchFull(
|
|
578
|
+
ref: MessageRef,
|
|
579
|
+
_signal?: AbortSignal,
|
|
580
|
+
): Promise<InboundMessage> {
|
|
581
|
+
const store = this.#requireMailbox(ref.mailbox);
|
|
582
|
+
return doFetchFull(ref, store, (addr) => this.#entries.get(addr)?.crypto);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async setFlags(
|
|
586
|
+
ref: MessageRef,
|
|
587
|
+
flags: string[],
|
|
588
|
+
_signal?: AbortSignal,
|
|
589
|
+
): Promise<void> {
|
|
590
|
+
const store = this.#requireMailbox(ref.mailbox);
|
|
591
|
+
const msg = requireMessage(store, ref.uid, ref.mailbox);
|
|
592
|
+
for (const flag of flags) {
|
|
593
|
+
msg.flags.add(flag);
|
|
594
|
+
}
|
|
595
|
+
msg.modseq = store.modseqCounter++;
|
|
596
|
+
this.#fireWatchCallbacks(ref.mailbox, {
|
|
597
|
+
type: "flagsChanged",
|
|
598
|
+
uid: ref.uid,
|
|
599
|
+
flags: Array.from(msg.flags),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async clearFlags(
|
|
604
|
+
ref: MessageRef,
|
|
605
|
+
flags: string[],
|
|
606
|
+
_signal?: AbortSignal,
|
|
607
|
+
): Promise<void> {
|
|
608
|
+
const store = this.#requireMailbox(ref.mailbox);
|
|
609
|
+
const msg = requireMessage(store, ref.uid, ref.mailbox);
|
|
610
|
+
for (const flag of flags) {
|
|
611
|
+
msg.flags.delete(flag);
|
|
612
|
+
}
|
|
613
|
+
msg.modseq = store.modseqCounter++;
|
|
614
|
+
this.#fireWatchCallbacks(ref.mailbox, {
|
|
615
|
+
type: "flagsChanged",
|
|
616
|
+
uid: ref.uid,
|
|
617
|
+
flags: Array.from(msg.flags),
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async move(
|
|
622
|
+
ref: MessageRef,
|
|
623
|
+
toMailbox: string,
|
|
624
|
+
_signal?: AbortSignal,
|
|
625
|
+
): Promise<void> {
|
|
626
|
+
const fromStore = this.#requireMailbox(ref.mailbox);
|
|
627
|
+
const toStore = this.#requireMailbox(toMailbox);
|
|
628
|
+
const msgIdx = fromStore.messages.findIndex((m) => m.uid === ref.uid);
|
|
629
|
+
if (msgIdx === -1) {
|
|
630
|
+
throw new Error(
|
|
631
|
+
`Message UID ${ref.uid} not found in mailbox "${ref.mailbox}"`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by findIndex !== -1 above
|
|
635
|
+
const msg = fromStore.messages[msgIdx]!;
|
|
636
|
+
fromStore.messages.splice(msgIdx, 1);
|
|
637
|
+
|
|
638
|
+
const newUid = appendToMailbox(
|
|
639
|
+
toStore,
|
|
640
|
+
msg.raw,
|
|
641
|
+
msg.envelope,
|
|
642
|
+
Array.from(msg.flags),
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
this.#fireWatchCallbacks(ref.mailbox, {
|
|
646
|
+
type: "expunged",
|
|
647
|
+
uid: ref.uid,
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Notify watchers of the new message in the destination mailbox.
|
|
651
|
+
const { headers: parsedHeaders } = parseHeaderSection(msg.raw);
|
|
652
|
+
const msgHeaders = this.#buildMessageHeaders(parsedHeaders);
|
|
653
|
+
this.#fireWatchCallbacks(toMailbox, {
|
|
654
|
+
type: "exists",
|
|
655
|
+
uid: newUid,
|
|
656
|
+
headers: msgHeaders,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async copy(
|
|
661
|
+
ref: MessageRef,
|
|
662
|
+
toMailbox: string,
|
|
663
|
+
_signal?: AbortSignal,
|
|
664
|
+
): Promise<void> {
|
|
665
|
+
const fromStore = this.#requireMailbox(ref.mailbox);
|
|
666
|
+
const toStore = this.#requireMailbox(toMailbox);
|
|
667
|
+
const msg = requireMessage(fromStore, ref.uid, ref.mailbox);
|
|
668
|
+
|
|
669
|
+
const newUid = appendToMailbox(
|
|
670
|
+
toStore,
|
|
671
|
+
msg.raw,
|
|
672
|
+
msg.envelope,
|
|
673
|
+
Array.from(msg.flags),
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const { headers: parsedHeaders } = parseHeaderSection(msg.raw);
|
|
677
|
+
const msgHeaders = this.#buildMessageHeaders(parsedHeaders);
|
|
678
|
+
this.#fireWatchCallbacks(toMailbox, {
|
|
679
|
+
type: "exists",
|
|
680
|
+
uid: newUid,
|
|
681
|
+
headers: msgHeaders,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async expunge(mailbox: string, _signal?: AbortSignal): Promise<void> {
|
|
686
|
+
const store = this.#requireMailbox(mailbox);
|
|
687
|
+
const toExpunge = store.messages.filter((m) => m.flags.has("\\Deleted"));
|
|
688
|
+
|
|
689
|
+
store.messages = store.messages.filter((m) => !m.flags.has("\\Deleted"));
|
|
690
|
+
|
|
691
|
+
for (const msg of toExpunge) {
|
|
692
|
+
this.#fireWatchCallbacks(mailbox, {
|
|
693
|
+
type: "expunged",
|
|
694
|
+
uid: msg.uid,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
watch(mailbox: string, callback: (event: MailboxEvent) => void): Unsubscribe {
|
|
700
|
+
this.#requireMailbox(mailbox);
|
|
701
|
+
let callbacks = this.#entry.watchCallbacks.get(mailbox);
|
|
702
|
+
if (callbacks === undefined) {
|
|
703
|
+
callbacks = new Set();
|
|
704
|
+
this.#entry.watchCallbacks.set(mailbox, callbacks);
|
|
705
|
+
}
|
|
706
|
+
callbacks.add(callback);
|
|
707
|
+
|
|
708
|
+
return () => {
|
|
709
|
+
const cbs = this.#entry.watchCallbacks.get(mailbox);
|
|
710
|
+
cbs?.delete(callback);
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async sync(
|
|
715
|
+
_mailbox: string,
|
|
716
|
+
_knownState: SyncState,
|
|
717
|
+
_signal?: AbortSignal,
|
|
718
|
+
): Promise<SyncResult> {
|
|
719
|
+
throw new Error("sync() (QRESYNC) is not implemented");
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async createList(
|
|
723
|
+
_address: string,
|
|
724
|
+
_name: string,
|
|
725
|
+
_signal?: AbortSignal,
|
|
726
|
+
): Promise<ListInfo> {
|
|
727
|
+
throw new Error("Distribution list management is not implemented");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async listMembers(
|
|
731
|
+
_address: string,
|
|
732
|
+
_signal?: AbortSignal,
|
|
733
|
+
): Promise<string[]> {
|
|
734
|
+
throw new Error("Distribution list management is not implemented");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async subscribe(
|
|
738
|
+
_listAddress: string,
|
|
739
|
+
_subscriberAddress: string,
|
|
740
|
+
_signal?: AbortSignal,
|
|
741
|
+
): Promise<void> {
|
|
742
|
+
throw new Error("Distribution list management is not implemented");
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async unsubscribe(
|
|
746
|
+
_listAddress: string,
|
|
747
|
+
_subscriberAddress: string,
|
|
748
|
+
_signal?: AbortSignal,
|
|
749
|
+
): Promise<void> {
|
|
750
|
+
throw new Error("Distribution list management is not implemented");
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
#fireWatchCallbacks(mailbox: string, event: MailboxEvent): void {
|
|
754
|
+
const callbacks = this.#entry.watchCallbacks.get(mailbox);
|
|
755
|
+
if (callbacks === undefined || callbacks.size === 0) return;
|
|
756
|
+
for (const cb of callbacks) {
|
|
757
|
+
queueMicrotask(() => cb(event));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
#buildMessageHeaders(
|
|
762
|
+
headers: Map<string, string>,
|
|
763
|
+
): import("@intx/types/runtime").MessageHeaders {
|
|
764
|
+
return buildMessageHeaders(headers);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function inboundMessageToRaw(message: InboundMessage): Uint8Array {
|
|
769
|
+
const enc = new TextEncoder();
|
|
770
|
+
const CRLF = "\r\n";
|
|
771
|
+
let headers = "";
|
|
772
|
+
headers += `From: ${message.headers.from}${CRLF}`;
|
|
773
|
+
headers += `To: ${message.headers.to.join(", ")}${CRLF}`;
|
|
774
|
+
if (message.headers.cc && message.headers.cc.length > 0) {
|
|
775
|
+
headers += `Cc: ${message.headers.cc.join(", ")}${CRLF}`;
|
|
776
|
+
}
|
|
777
|
+
headers += `Date: ${message.headers.date}${CRLF}`;
|
|
778
|
+
headers += `Message-ID: ${message.headers.messageId}${CRLF}`;
|
|
779
|
+
if (message.headers.subject !== undefined) {
|
|
780
|
+
headers += `Subject: ${message.headers.subject}${CRLF}`;
|
|
781
|
+
}
|
|
782
|
+
if (message.headers.inReplyTo !== undefined) {
|
|
783
|
+
headers += `In-Reply-To: ${message.headers.inReplyTo}${CRLF}`;
|
|
784
|
+
}
|
|
785
|
+
if (message.headers.references && message.headers.references.length > 0) {
|
|
786
|
+
headers += `References: ${message.headers.references.join(" ")}${CRLF}`;
|
|
787
|
+
}
|
|
788
|
+
if (message.headers.interchangeType !== undefined) {
|
|
789
|
+
headers += `Interchange-Type: ${message.headers.interchangeType}${CRLF}`;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const body =
|
|
793
|
+
message.content ??
|
|
794
|
+
(message.payload !== undefined ? JSON.stringify(message.payload) : "");
|
|
795
|
+
headers += `Content-Type: text/plain${CRLF}`;
|
|
796
|
+
headers += `${CRLF}`;
|
|
797
|
+
return enc.encode(headers + body);
|
|
798
|
+
}
|