@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.
@@ -0,0 +1,684 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion -- test refs[0]! always follows expect(refs.length) checks */
2
+ import { describe, test, expect } from "bun:test";
3
+ import { generateKeyPair, createNodeCrypto } from "@intx/crypto-node";
4
+ import { createInMemoryTransport } from "./index";
5
+ import type { MailboxEvent, MessageRef } from "@intx/types/runtime";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Test fixtures
9
+ // ---------------------------------------------------------------------------
10
+
11
+ async function createTestTransport() {
12
+ const transport = createInMemoryTransport();
13
+
14
+ const kpA = await generateKeyPair();
15
+ const kpB = await generateKeyPair();
16
+ const cryptoA = createNodeCrypto(kpA);
17
+ const cryptoB = createNodeCrypto(kpB);
18
+
19
+ transport.register("alpha@test.interchange", cryptoA);
20
+ transport.register("beta@test.interchange", cryptoB);
21
+
22
+ const alphaTransport = transport.getTransportFor("alpha@test.interchange");
23
+ const betaTransport = transport.getTransportFor("beta@test.interchange");
24
+
25
+ return { transport, alphaTransport, betaTransport, cryptoA, cryptoB };
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Test 1: send + watch — verify async delivery
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe("send and watch", () => {
33
+ test("watch callback fires asynchronously after send returns", async () => {
34
+ const { alphaTransport, betaTransport } = await createTestTransport();
35
+
36
+ let callbackFired = false;
37
+ let receivedEvent: MailboxEvent | undefined;
38
+
39
+ const unwatch = betaTransport.watch("INBOX", (event) => {
40
+ callbackFired = true;
41
+ receivedEvent = event;
42
+ });
43
+
44
+ await alphaTransport.send({
45
+ to: "beta@test.interchange",
46
+ type: "conversation.message",
47
+ subject: "Hello",
48
+ content: "Hello from alpha",
49
+ });
50
+
51
+ // After send completes (which includes async steps + microtask callbacks),
52
+ // the watch callback should have fired.
53
+ await new Promise((r) => setTimeout(r, 10));
54
+
55
+ expect(callbackFired).toBe(true);
56
+ expect(receivedEvent?.type).toBe("exists");
57
+ if (receivedEvent?.type === "exists") {
58
+ expect(receivedEvent.headers.from).toBe("alpha@test.interchange");
59
+ expect(receivedEvent.headers.interchangeType).toBe(
60
+ "conversation.message",
61
+ );
62
+ }
63
+
64
+ unwatch();
65
+ });
66
+
67
+ test("unwatch prevents further callbacks", async () => {
68
+ const { alphaTransport, betaTransport } = await createTestTransport();
69
+
70
+ let count = 0;
71
+ const unwatch = betaTransport.watch("INBOX", () => {
72
+ count++;
73
+ });
74
+
75
+ await alphaTransport.send({
76
+ to: "beta@test.interchange",
77
+ type: "conversation.message",
78
+ content: "first",
79
+ });
80
+ await new Promise((r) => setTimeout(r, 10));
81
+ expect(count).toBe(1);
82
+
83
+ unwatch();
84
+
85
+ await alphaTransport.send({
86
+ to: "beta@test.interchange",
87
+ type: "conversation.message",
88
+ content: "second",
89
+ });
90
+ await new Promise((r) => setTimeout(r, 10));
91
+ expect(count).toBe(1);
92
+ });
93
+ });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Test 2: search by Interchange-Type header
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe("search", () => {
100
+ test("search by Interchange-Type header finds message", async () => {
101
+ const { alphaTransport, betaTransport } = await createTestTransport();
102
+
103
+ await alphaTransport.send({
104
+ to: "beta@test.interchange",
105
+ type: "conversation.message",
106
+ content: "test content",
107
+ });
108
+
109
+ const refs = await betaTransport.search("INBOX", {
110
+ header: { field: "Interchange-Type", contains: "conversation.message" },
111
+ });
112
+
113
+ expect(refs.length).toBe(1);
114
+ expect(refs[0]!.mailbox).toBe("INBOX");
115
+ });
116
+
117
+ test("search for non-existing header returns empty", async () => {
118
+ const { alphaTransport, betaTransport } = await createTestTransport();
119
+
120
+ await alphaTransport.send({
121
+ to: "beta@test.interchange",
122
+ type: "conversation.message",
123
+ content: "test",
124
+ });
125
+
126
+ const refs = await betaTransport.search("INBOX", {
127
+ header: { field: "Interchange-Type", contains: "offering.request" },
128
+ });
129
+
130
+ expect(refs.length).toBe(0);
131
+ });
132
+
133
+ test("search by hasFlags finds flagged messages", async () => {
134
+ const { alphaTransport, betaTransport } = await createTestTransport();
135
+
136
+ await alphaTransport.send({
137
+ to: "beta@test.interchange",
138
+ type: "conversation.message",
139
+ content: "test",
140
+ });
141
+
142
+ const refs = await betaTransport.search("INBOX", {});
143
+ expect(refs.length).toBe(1);
144
+
145
+ // Set the $Processed flag.
146
+ await betaTransport.setFlags(refs[0]!, ["$Processed"]);
147
+
148
+ // Should NOT find with missingFlags $Processed.
149
+ const unprocessed = await betaTransport.search("INBOX", {
150
+ missingFlags: ["$Processed"],
151
+ });
152
+ expect(unprocessed.length).toBe(0);
153
+
154
+ // Should find with hasFlags $Processed.
155
+ const processed = await betaTransport.search("INBOX", {
156
+ hasFlags: ["$Processed"],
157
+ });
158
+ expect(processed.length).toBe(1);
159
+ });
160
+
161
+ test("UNKEYWORD $Processed does not find processed messages (test 4 from plan)", async () => {
162
+ const { alphaTransport, betaTransport } = await createTestTransport();
163
+
164
+ await alphaTransport.send({
165
+ to: "beta@test.interchange",
166
+ type: "conversation.message",
167
+ content: "processed message",
168
+ });
169
+
170
+ const refs = await betaTransport.search("INBOX", {});
171
+ await betaTransport.setFlags(refs[0]!, ["$Processed"]);
172
+
173
+ const notFound = await betaTransport.search("INBOX", {
174
+ missingFlags: ["$Processed"],
175
+ });
176
+ expect(notFound.length).toBe(0);
177
+ });
178
+
179
+ test("search by from address", async () => {
180
+ const { alphaTransport, betaTransport } = await createTestTransport();
181
+
182
+ await alphaTransport.send({
183
+ to: "beta@test.interchange",
184
+ type: "conversation.message",
185
+ content: "hello",
186
+ });
187
+
188
+ const found = await betaTransport.search("INBOX", {
189
+ from: "alpha@test.interchange",
190
+ });
191
+ expect(found.length).toBe(1);
192
+
193
+ const notFound = await betaTransport.search("INBOX", {
194
+ from: "nobody@test.interchange",
195
+ });
196
+ expect(notFound.length).toBe(0);
197
+ });
198
+
199
+ test("boolean AND composition", async () => {
200
+ const { alphaTransport, betaTransport } = await createTestTransport();
201
+
202
+ await alphaTransport.send({
203
+ to: "beta@test.interchange",
204
+ type: "conversation.message",
205
+ content: "hello",
206
+ });
207
+
208
+ const found = await betaTransport.search("INBOX", {
209
+ and: [
210
+ { from: "alpha@test.interchange" },
211
+ {
212
+ header: {
213
+ field: "Interchange-Type",
214
+ contains: "conversation.message",
215
+ },
216
+ },
217
+ ],
218
+ });
219
+ expect(found.length).toBe(1);
220
+
221
+ const notFound = await betaTransport.search("INBOX", {
222
+ and: [
223
+ { from: "alpha@test.interchange" },
224
+ { header: { field: "Interchange-Type", contains: "offering.request" } },
225
+ ],
226
+ });
227
+ expect(notFound.length).toBe(0);
228
+ });
229
+ });
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Test 3: fetchFull — verify signatureStatus is "valid"
233
+ // ---------------------------------------------------------------------------
234
+
235
+ describe("fetchFull", () => {
236
+ test("fetchFull returns signatureStatus valid for signed message", async () => {
237
+ const { alphaTransport, betaTransport } = await createTestTransport();
238
+
239
+ await alphaTransport.send({
240
+ to: "beta@test.interchange",
241
+ type: "conversation.message",
242
+ content: "hello world",
243
+ subject: "Test",
244
+ });
245
+
246
+ const refs = await betaTransport.search("INBOX", {});
247
+ expect(refs.length).toBe(1);
248
+
249
+ const msg = await betaTransport.fetchFull(refs[0]!);
250
+ expect(msg.signatureStatus).toBe("valid");
251
+ expect(msg.headers.from).toBe("alpha@test.interchange");
252
+ expect(msg.headers.interchangeType).toBe("conversation.message");
253
+ });
254
+
255
+ test("fetchFull parses conversation content", async () => {
256
+ const { alphaTransport, betaTransport } = await createTestTransport();
257
+
258
+ await alphaTransport.send({
259
+ to: "beta@test.interchange",
260
+ type: "conversation.message",
261
+ content: "Hello, beta!",
262
+ });
263
+
264
+ const refs = await betaTransport.search("INBOX", {});
265
+ const msg = await betaTransport.fetchFull(refs[0]!);
266
+ expect(msg.content).toBe("Hello, beta!");
267
+ expect(msg.payload).toBeUndefined();
268
+ });
269
+
270
+ test("fetchFull parses structured payload", async () => {
271
+ const { alphaTransport, betaTransport } = await createTestTransport();
272
+
273
+ await alphaTransport.send({
274
+ to: "beta@test.interchange",
275
+ type: "offering.request",
276
+ payload: { offeringId: "code-review", parameters: { branch: "main" } },
277
+ });
278
+
279
+ const refs = await betaTransport.search("INBOX", {});
280
+ const msg = await betaTransport.fetchFull(refs[0]!);
281
+ expect(msg.signatureStatus).toBe("valid");
282
+ expect(msg.payload).toBeDefined();
283
+ expect(msg.payload!.type).toBe("offering.request");
284
+ expect(msg.payload!.body["offeringId"]).toBe("code-review");
285
+ });
286
+
287
+ test("fetchHeaders returns parsed headers", async () => {
288
+ const { alphaTransport, betaTransport } = await createTestTransport();
289
+
290
+ await alphaTransport.send({
291
+ to: "beta@test.interchange",
292
+ type: "conversation.message",
293
+ content: "headers test",
294
+ subject: "Subject Line",
295
+ });
296
+
297
+ const refs = await betaTransport.search("INBOX", {});
298
+ const headers = await betaTransport.fetchHeaders(refs[0]!);
299
+ expect(headers.from).toBe("alpha@test.interchange");
300
+ expect(headers.to).toContain("beta@test.interchange");
301
+ expect(headers.subject).toBe("Subject Line");
302
+ expect(headers.interchangeType).toBe("conversation.message");
303
+ });
304
+ });
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Test 5: Threading — REFERENCES algorithm tree structure
308
+ // ---------------------------------------------------------------------------
309
+
310
+ describe("thread", () => {
311
+ test("3 messages in a thread produce correct tree structure", async () => {
312
+ const { alphaTransport, betaTransport } = await createTestTransport();
313
+
314
+ // Message A (root).
315
+ const receiptA = await alphaTransport.send({
316
+ to: "beta@test.interchange",
317
+ type: "conversation.message",
318
+ subject: "Thread root",
319
+ content: "Message A",
320
+ });
321
+
322
+ // Message B (reply to A).
323
+ const receiptB = await alphaTransport.send({
324
+ to: "beta@test.interchange",
325
+ type: "conversation.message",
326
+ subject: "Re: Thread root",
327
+ content: "Message B",
328
+ inReplyTo: receiptA.messageId,
329
+ });
330
+
331
+ // Message C (reply to B).
332
+ await alphaTransport.send({
333
+ to: "beta@test.interchange",
334
+ type: "conversation.message",
335
+ subject: "Re: Thread root",
336
+ content: "Message C",
337
+ inReplyTo: receiptB.messageId,
338
+ });
339
+
340
+ const threads = await betaTransport.thread("INBOX", "references");
341
+ expect(threads.length).toBeGreaterThan(0);
342
+
343
+ // The root thread should have children (a non-trivial tree).
344
+ const allRefs: MessageRef[] = [];
345
+ function collectRefs(nodes: typeof threads) {
346
+ for (const node of nodes) {
347
+ allRefs.push(node.ref);
348
+ collectRefs(node.children);
349
+ }
350
+ }
351
+ collectRefs(threads);
352
+ expect(allRefs.length).toBe(3);
353
+ });
354
+ });
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // Test 6: UID monotonicity
358
+ // ---------------------------------------------------------------------------
359
+
360
+ describe("UID ordering", () => {
361
+ test("UIDs are monotonically increasing (1, 2, 3)", async () => {
362
+ const { alphaTransport, betaTransport } = await createTestTransport();
363
+
364
+ for (let i = 0; i < 3; i++) {
365
+ await alphaTransport.send({
366
+ to: "beta@test.interchange",
367
+ type: "conversation.message",
368
+ content: `Message ${i + 1}`,
369
+ });
370
+ }
371
+
372
+ const refs = await betaTransport.search("INBOX", {});
373
+ expect(refs.length).toBe(3);
374
+
375
+ const uids = refs.map((r) => r.uid);
376
+ expect(uids[0]).toBe(1);
377
+ expect(uids[1]).toBe(2);
378
+ expect(uids[2]).toBe(3);
379
+ });
380
+ });
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Test 7: MODSEQ increments on flag change
384
+ // ---------------------------------------------------------------------------
385
+
386
+ describe("MODSEQ", () => {
387
+ test("MODSEQ increments when flags change", async () => {
388
+ const { alphaTransport, betaTransport } = await createTestTransport();
389
+
390
+ await alphaTransport.send({
391
+ to: "beta@test.interchange",
392
+ type: "conversation.message",
393
+ content: "modseq test",
394
+ });
395
+
396
+ const statusBefore = await betaTransport.getMailboxStatus("INBOX");
397
+ const modseqBefore = statusBefore.highestModSeq;
398
+
399
+ const refs = await betaTransport.search("INBOX", {});
400
+ await betaTransport.setFlags(refs[0]!, ["$Processed"]);
401
+
402
+ const statusAfter = await betaTransport.getMailboxStatus("INBOX");
403
+ expect(statusAfter.highestModSeq).toBeGreaterThan(modseqBefore);
404
+ });
405
+ });
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Test 8: Sending to unknown address throws
409
+ // ---------------------------------------------------------------------------
410
+
411
+ describe("error handling", () => {
412
+ test("send to unknown address throws", async () => {
413
+ const { alphaTransport } = await createTestTransport();
414
+
415
+ await expect(
416
+ alphaTransport.send({
417
+ to: "nobody@unknown.test",
418
+ type: "conversation.message",
419
+ content: "hello",
420
+ }),
421
+ ).rejects.toThrow(/not registered/);
422
+ });
423
+
424
+ test("fetch non-existent UID throws", async () => {
425
+ const { betaTransport } = await createTestTransport();
426
+
427
+ await expect(
428
+ betaTransport.fetchFull({ uid: 9999, mailbox: "INBOX" }),
429
+ ).rejects.toThrow(/not found/);
430
+ });
431
+
432
+ test("register throws on duplicate registration", async () => {
433
+ const transport = createInMemoryTransport();
434
+ const kp = await generateKeyPair();
435
+ const crypto = createNodeCrypto(kp);
436
+ transport.register("alpha@test.interchange", crypto);
437
+ expect(() => transport.register("alpha@test.interchange", crypto)).toThrow(
438
+ /already registered/,
439
+ );
440
+ });
441
+
442
+ test("getTransportFor throws for unregistered address", () => {
443
+ const transport = createInMemoryTransport();
444
+ expect(() => transport.getTransportFor("nobody@test.interchange")).toThrow(
445
+ /not registered/,
446
+ );
447
+ });
448
+ });
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Registration lifecycle — guards the single-entry-map invariant.
452
+ // ---------------------------------------------------------------------------
453
+
454
+ describe("registration lifecycle", () => {
455
+ test("unregister then re-register works (entry is fully removed)", async () => {
456
+ const transport = createInMemoryTransport();
457
+ const kp = await generateKeyPair();
458
+ const crypto = createNodeCrypto(kp);
459
+
460
+ transport.register("alpha@test.interchange", crypto);
461
+ transport.unregister("alpha@test.interchange");
462
+ expect(() =>
463
+ transport.register("alpha@test.interchange", crypto),
464
+ ).not.toThrow();
465
+ });
466
+
467
+ test("send from a scoped transport whose address was deregistered throws", async () => {
468
+ const { transport, alphaTransport } = await createTestTransport();
469
+ transport.unregister("alpha@test.interchange");
470
+
471
+ await expect(
472
+ alphaTransport.send({
473
+ to: "beta@test.interchange",
474
+ type: "conversation.message",
475
+ content: "hi",
476
+ }),
477
+ ).rejects.toThrow(/deregistered|not registered/);
478
+ });
479
+
480
+ test("fetchFull returns signatureStatus 'unknown' after sender is unregistered", async () => {
481
+ const { transport, alphaTransport, betaTransport } =
482
+ await createTestTransport();
483
+
484
+ await alphaTransport.send({
485
+ to: "beta@test.interchange",
486
+ type: "conversation.message",
487
+ content: "hi",
488
+ });
489
+
490
+ await new Promise((r) => setTimeout(r, 10));
491
+
492
+ const refs = await betaTransport.search("INBOX", {});
493
+ expect(refs.length).toBe(1);
494
+
495
+ transport.unregister("alpha@test.interchange");
496
+
497
+ const full = await betaTransport.fetchFull(refs[0]!);
498
+ expect(full.signatureStatus).toBe("unknown");
499
+ });
500
+ });
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Additional: mailbox management
504
+ // ---------------------------------------------------------------------------
505
+
506
+ describe("mailbox management", () => {
507
+ test("listMailboxes returns default mailboxes", async () => {
508
+ const { betaTransport } = await createTestTransport();
509
+ const mailboxes = await betaTransport.listMailboxes();
510
+ const names = mailboxes.map((m) => m.name);
511
+ expect(names).toContain("INBOX");
512
+ expect(names).toContain("Sent");
513
+ expect(names).toContain("Drafts");
514
+ expect(names).toContain("Archive");
515
+ expect(names).toContain("Trash");
516
+ });
517
+
518
+ test("sent copy appears in Sent mailbox", async () => {
519
+ const { alphaTransport } = await createTestTransport();
520
+
521
+ await alphaTransport.send({
522
+ to: "beta@test.interchange",
523
+ type: "conversation.message",
524
+ content: "sent copy test",
525
+ });
526
+
527
+ const sentRefs = await alphaTransport.search("Sent", {});
528
+ expect(sentRefs.length).toBe(1);
529
+ });
530
+
531
+ test("expunge removes deleted messages", async () => {
532
+ const { alphaTransport, betaTransport } = await createTestTransport();
533
+
534
+ await alphaTransport.send({
535
+ to: "beta@test.interchange",
536
+ type: "conversation.message",
537
+ content: "to be deleted",
538
+ });
539
+
540
+ const refs = await betaTransport.search("INBOX", {});
541
+ expect(refs.length).toBe(1);
542
+
543
+ await betaTransport.setFlags(refs[0]!, ["\\Deleted"]);
544
+ await betaTransport.expunge("INBOX");
545
+
546
+ const remaining = await betaTransport.search("INBOX", {});
547
+ expect(remaining.length).toBe(0);
548
+ });
549
+
550
+ test("move transfers message to destination mailbox", async () => {
551
+ const { alphaTransport, betaTransport } = await createTestTransport();
552
+
553
+ await alphaTransport.send({
554
+ to: "beta@test.interchange",
555
+ type: "conversation.message",
556
+ content: "to be archived",
557
+ });
558
+
559
+ const refs = await betaTransport.search("INBOX", {});
560
+ await betaTransport.move(refs[0]!, "Archive");
561
+
562
+ const inboxRefs = await betaTransport.search("INBOX", {});
563
+ expect(inboxRefs.length).toBe(0);
564
+
565
+ const archiveRefs = await betaTransport.search("Archive", {});
566
+ expect(archiveRefs.length).toBe(1);
567
+ });
568
+ });
569
+
570
+ // ---------------------------------------------------------------------------
571
+ // Signature round-trip
572
+ // ---------------------------------------------------------------------------
573
+
574
+ describe("signature round-trip", () => {
575
+ test("signature is valid even with structured message", async () => {
576
+ const { alphaTransport, betaTransport } = await createTestTransport();
577
+
578
+ await alphaTransport.send({
579
+ to: "beta@test.interchange",
580
+ type: "offering.request",
581
+ payload: { offeringId: "test", parameters: {} },
582
+ summary: "Test offering",
583
+ });
584
+
585
+ const refs = await betaTransport.search("INBOX", {});
586
+ const msg = await betaTransport.fetchFull(refs[0]!);
587
+ expect(msg.signatureStatus).toBe("valid");
588
+ });
589
+ });
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // deliver() — federation inbound delivery
593
+ // ---------------------------------------------------------------------------
594
+
595
+ describe("deliver", () => {
596
+ const VALID_MESSAGE = new TextEncoder().encode(
597
+ [
598
+ "From: sender@remote",
599
+ "To: alpha@test.interchange",
600
+ "Date: Thu, 17 Apr 2026 12:00:00 +0000",
601
+ "Message-ID: <fed-1@remote>",
602
+ "Subject: Hello",
603
+ "Content-Type: text/plain",
604
+ "",
605
+ "Body text",
606
+ ].join("\r\n"),
607
+ );
608
+
609
+ test("delivers a well-formed message to INBOX", async () => {
610
+ const { transport } = await createTestTransport();
611
+ const alphaTransport = transport.getTransportFor("alpha@test.interchange");
612
+
613
+ transport.deliver("alpha@test.interchange", VALID_MESSAGE);
614
+
615
+ const refs = await alphaTransport.search("INBOX", {});
616
+ expect(refs).toHaveLength(1);
617
+ const headers = await alphaTransport.fetchHeaders(refs[0]!);
618
+ expect(headers.messageId).toBe("<fed-1@remote>");
619
+ expect(headers.from).toBe("sender@remote");
620
+ });
621
+
622
+ test("fires watch callback on delivery", async () => {
623
+ const { transport } = await createTestTransport();
624
+ const alphaTransport = transport.getTransportFor("alpha@test.interchange");
625
+
626
+ const events: MailboxEvent[] = [];
627
+ alphaTransport.watch("INBOX", (event) => events.push(event));
628
+
629
+ transport.deliver("alpha@test.interchange", VALID_MESSAGE);
630
+
631
+ // Watch callbacks are scheduled via queueMicrotask.
632
+ await new Promise((r) => setTimeout(r, 10));
633
+ expect(events).toHaveLength(1);
634
+ expect(events[0]!.type).toBe("exists");
635
+ });
636
+
637
+ test("throws for unregistered address", async () => {
638
+ const { transport } = await createTestTransport();
639
+
640
+ expect(() =>
641
+ transport.deliver("nobody@test.interchange", VALID_MESSAGE),
642
+ ).toThrow(/not registered/);
643
+ });
644
+
645
+ test("throws for missing Message-ID header", async () => {
646
+ const { transport } = await createTestTransport();
647
+ const msg = new TextEncoder().encode(
648
+ ["From: x@y", "Date: Thu, 17 Apr 2026 12:00:00 +0000", "", "body"].join(
649
+ "\r\n",
650
+ ),
651
+ );
652
+
653
+ expect(() => transport.deliver("alpha@test.interchange", msg)).toThrow(
654
+ /Message-ID/,
655
+ );
656
+ });
657
+
658
+ test("throws for missing From header", async () => {
659
+ const { transport } = await createTestTransport();
660
+ const msg = new TextEncoder().encode(
661
+ [
662
+ "Message-ID: <x@y>",
663
+ "Date: Thu, 17 Apr 2026 12:00:00 +0000",
664
+ "",
665
+ "body",
666
+ ].join("\r\n"),
667
+ );
668
+
669
+ expect(() => transport.deliver("alpha@test.interchange", msg)).toThrow(
670
+ /From/,
671
+ );
672
+ });
673
+
674
+ test("throws for missing Date header", async () => {
675
+ const { transport } = await createTestTransport();
676
+ const msg = new TextEncoder().encode(
677
+ ["From: x@y", "Message-ID: <x@y>", "", "body"].join("\r\n"),
678
+ );
679
+
680
+ expect(() => transport.deliver("alpha@test.interchange", msg)).toThrow(
681
+ /Date/,
682
+ );
683
+ });
684
+ });
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export { InMemoryTransport, type HubTransport } from "./transport";
2
+ export type {
3
+ RemoteSendHandler,
4
+ MessageSentHandler,
5
+ MessageSentContext,
6
+ } from "./send";
7
+
8
+ /**
9
+ * Create a fresh in-memory transport instance.
10
+ *
11
+ * The returned transport is shared across all addresses in a single
12
+ * process. Register addresses before sending messages:
13
+ *
14
+ * const transport = createInMemoryTransport();
15
+ * transport.register("alpha@local.interchange", cryptoProviderA);
16
+ * transport.register("beta@local.interchange", cryptoProviderB);
17
+ *
18
+ * const alphaTransport = transport.getTransportFor("alpha@local.interchange");
19
+ * await alphaTransport.send({ to: "beta@local.interchange", ... });
20
+ */
21
+ import { InMemoryTransport } from "./transport";
22
+
23
+ export function createInMemoryTransport(): InMemoryTransport {
24
+ return new InMemoryTransport();
25
+ }