agent-inbox 0.2.4 → 0.2.5

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.
Files changed (138) hide show
  1. package/CLAUDE.md +1 -92
  2. package/README.md +6 -73
  3. package/dist/federation/connection-manager.d.ts +0 -8
  4. package/dist/federation/connection-manager.d.ts.map +1 -1
  5. package/dist/federation/connection-manager.js +0 -12
  6. package/dist/federation/connection-manager.js.map +1 -1
  7. package/dist/federation/delivery-queue.d.ts +3 -11
  8. package/dist/federation/delivery-queue.d.ts.map +1 -1
  9. package/dist/federation/delivery-queue.js +8 -38
  10. package/dist/federation/delivery-queue.js.map +1 -1
  11. package/dist/index.d.ts +0 -17
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +0 -98
  14. package/dist/index.js.map +1 -1
  15. package/dist/jsonrpc/mail-push-types.d.ts +22 -2
  16. package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
  17. package/dist/jsonrpc/mail-push-types.js +18 -1
  18. package/dist/jsonrpc/mail-push-types.js.map +1 -1
  19. package/dist/jsonrpc/mail-push.d.ts +12 -1
  20. package/dist/jsonrpc/mail-push.d.ts.map +1 -1
  21. package/dist/jsonrpc/mail-push.js +13 -2
  22. package/dist/jsonrpc/mail-push.js.map +1 -1
  23. package/dist/jsonrpc/mail-server.d.ts.map +1 -1
  24. package/dist/jsonrpc/mail-server.js +42 -18
  25. package/dist/jsonrpc/mail-server.js.map +1 -1
  26. package/dist/router/message-router.d.ts +0 -15
  27. package/dist/router/message-router.d.ts.map +1 -1
  28. package/dist/router/message-router.js +3 -25
  29. package/dist/router/message-router.js.map +1 -1
  30. package/dist/storage/interface.d.ts +2 -9
  31. package/dist/storage/interface.d.ts.map +1 -1
  32. package/dist/storage/memory.d.ts +1 -4
  33. package/dist/storage/memory.d.ts.map +1 -1
  34. package/dist/storage/memory.js +6 -12
  35. package/dist/storage/memory.js.map +1 -1
  36. package/dist/storage/sqlite.d.ts +1 -6
  37. package/dist/storage/sqlite.d.ts.map +1 -1
  38. package/dist/storage/sqlite.js +6 -28
  39. package/dist/storage/sqlite.js.map +1 -1
  40. package/dist/types.d.ts +0 -79
  41. package/dist/types.d.ts.map +1 -1
  42. package/docs/DESIGN.md +0 -15
  43. package/package.json +3 -28
  44. package/rules/agent-inbox.md +0 -1
  45. package/src/federation/connection-manager.ts +0 -12
  46. package/src/federation/delivery-queue.ts +8 -38
  47. package/src/index.ts +0 -148
  48. package/src/jsonrpc/mail-push-types.ts +43 -2
  49. package/src/jsonrpc/mail-push.ts +34 -3
  50. package/src/jsonrpc/mail-server.ts +45 -26
  51. package/src/router/message-router.ts +4 -41
  52. package/src/storage/interface.ts +2 -11
  53. package/src/storage/memory.ts +8 -15
  54. package/src/storage/sqlite.ts +9 -36
  55. package/src/types.ts +0 -73
  56. package/test/load.test.ts +1 -1
  57. package/test/mail-push.test.ts +101 -1
  58. package/test/mail-server.test.ts +108 -0
  59. package/AGENTS.md +0 -18
  60. package/dist/federation/queue-store.d.ts +0 -42
  61. package/dist/federation/queue-store.d.ts.map +0 -1
  62. package/dist/federation/queue-store.js +0 -87
  63. package/dist/federation/queue-store.js.map +0 -1
  64. package/dist/index.d.mts +0 -2
  65. package/dist/index.mjs +0 -1
  66. package/dist/index.mjs.map +0 -1
  67. package/dist/mail/address-book.d.ts +0 -43
  68. package/dist/mail/address-book.d.ts.map +0 -1
  69. package/dist/mail/address-book.js +0 -95
  70. package/dist/mail/address-book.js.map +0 -1
  71. package/dist/mail/attachment-store.d.ts +0 -31
  72. package/dist/mail/attachment-store.d.ts.map +0 -1
  73. package/dist/mail/attachment-store.js +0 -74
  74. package/dist/mail/attachment-store.js.map +0 -1
  75. package/dist/mail/email-mapper.d.ts +0 -41
  76. package/dist/mail/email-mapper.d.ts.map +0 -1
  77. package/dist/mail/email-mapper.js +0 -216
  78. package/dist/mail/email-mapper.js.map +0 -1
  79. package/dist/mail/fs-attachment-store.d.ts +0 -38
  80. package/dist/mail/fs-attachment-store.d.ts.map +0 -1
  81. package/dist/mail/fs-attachment-store.js +0 -165
  82. package/dist/mail/fs-attachment-store.js.map +0 -1
  83. package/dist/mail/mail-gateway.d.ts +0 -114
  84. package/dist/mail/mail-gateway.d.ts.map +0 -1
  85. package/dist/mail/mail-gateway.js +0 -402
  86. package/dist/mail/mail-gateway.js.map +0 -1
  87. package/dist/mail/provider-transport.d.ts +0 -138
  88. package/dist/mail/provider-transport.d.ts.map +0 -1
  89. package/dist/mail/provider-transport.js +0 -434
  90. package/dist/mail/provider-transport.js.map +0 -1
  91. package/dist/mail/rate-limiter.d.ts +0 -20
  92. package/dist/mail/rate-limiter.d.ts.map +0 -1
  93. package/dist/mail/rate-limiter.js +0 -56
  94. package/dist/mail/rate-limiter.js.map +0 -1
  95. package/dist/mail/smtp-transport.d.ts +0 -141
  96. package/dist/mail/smtp-transport.d.ts.map +0 -1
  97. package/dist/mail/smtp-transport.js +0 -415
  98. package/dist/mail/smtp-transport.js.map +0 -1
  99. package/dist/mail/types.d.ts +0 -177
  100. package/dist/mail/types.d.ts.map +0 -1
  101. package/dist/mail/types.js +0 -11
  102. package/dist/mail/types.js.map +0 -1
  103. package/dist/router/destination.d.ts +0 -69
  104. package/dist/router/destination.d.ts.map +0 -1
  105. package/dist/router/destination.js +0 -106
  106. package/dist/router/destination.js.map +0 -1
  107. package/docs/MAIL-INTEROP-PLAN.md +0 -660
  108. package/renovate.json5 +0 -6
  109. package/src/federation/queue-store.ts +0 -124
  110. package/src/mail/address-book.ts +0 -111
  111. package/src/mail/attachment-store.ts +0 -90
  112. package/src/mail/email-mapper.ts +0 -288
  113. package/src/mail/fs-attachment-store.ts +0 -163
  114. package/src/mail/mail-gateway.ts +0 -505
  115. package/src/mail/provider-transport.ts +0 -577
  116. package/src/mail/rate-limiter.ts +0 -51
  117. package/src/mail/smtp-transport.ts +0 -589
  118. package/src/mail/types.ts +0 -221
  119. package/src/router/destination.ts +0 -140
  120. package/test/federation/delivery-queue-sqlite.test.ts +0 -158
  121. package/test/mail/address-book.test.ts +0 -111
  122. package/test/mail/attachment-store-contract.test.ts +0 -92
  123. package/test/mail/attachment-store.test.ts +0 -69
  124. package/test/mail/destination.test.ts +0 -115
  125. package/test/mail/dsn-parse.test.ts +0 -239
  126. package/test/mail/email-mapper.test.ts +0 -341
  127. package/test/mail/external-id.test.ts +0 -43
  128. package/test/mail/fs-attachment-store.test.ts +0 -134
  129. package/test/mail/full-flow-e2e.test.ts +0 -200
  130. package/test/mail/mail-gateway.test.ts +0 -419
  131. package/test/mail/mail-transport-contract.test.ts +0 -134
  132. package/test/mail/mock-mail.ts +0 -161
  133. package/test/mail/mock-postmark.ts +0 -66
  134. package/test/mail/provider-transport.test.ts +0 -381
  135. package/test/mail/rate-limiter.test.ts +0 -48
  136. package/test/mail/router-mail-integration.test.ts +0 -138
  137. package/test/mail/smtp-e2e.test.ts +0 -98
  138. package/test/mail/smtp-transport.test.ts +0 -138
@@ -1,381 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- PostmarkTransport,
4
- outboundToPostmark,
5
- postmarkInboundToMail,
6
- postmarkBounceToInbound,
7
- postmarkResponseToResult,
8
- postmarkErrorToResult,
9
- parseAuthResults,
10
- type PostmarkInbound,
11
- } from "../../src/mail/provider-transport.js";
12
- import * as http from "node:http";
13
- import { makeOutboundMail } from "./mock-mail.js";
14
- import { fakePostmarkClient } from "./mock-postmark.js";
15
-
16
- function post(
17
- port: number,
18
- path: string,
19
- body: unknown,
20
- headers: Record<string, string> = {}
21
- ): Promise<number> {
22
- return new Promise((resolve, reject) => {
23
- const data = Buffer.from(JSON.stringify(body));
24
- const req = http.request(
25
- {
26
- host: "127.0.0.1",
27
- port,
28
- path,
29
- method: "POST",
30
- headers: {
31
- "content-type": "application/json",
32
- "content-length": data.length,
33
- ...headers,
34
- },
35
- },
36
- (res) => {
37
- res.resume();
38
- res.on("end", () => resolve(res.statusCode ?? 0));
39
- }
40
- );
41
- req.on("error", reject);
42
- req.end(data);
43
- });
44
- }
45
-
46
- describe("postmarkResponseToResult / postmarkErrorToResult", () => {
47
- it("maps ErrorCode 0 to delivered with the provider message id", () => {
48
- expect(postmarkResponseToResult({ ErrorCode: 0, MessageID: "pm-9" })).toEqual({
49
- disposition: "delivered",
50
- remoteMessageId: "pm-9",
51
- });
52
- });
53
-
54
- it("maps 429 to transient", () => {
55
- expect(postmarkErrorToResult({ statusCode: 429, message: "rate" }).disposition).toBe(
56
- "transient"
57
- );
58
- });
59
-
60
- it("maps 5xx to transient", () => {
61
- expect(postmarkErrorToResult({ statusCode: 500 }).disposition).toBe("transient");
62
- });
63
-
64
- it("maps inactive-recipient (422/406) to permanent", () => {
65
- const r = postmarkErrorToResult({ statusCode: 422, code: 406, message: "inactive" });
66
- expect(r.disposition).toBe("permanent");
67
- expect(r.code).toBe(406);
68
- });
69
-
70
- it("maps invalid-email (300) to permanent", () => {
71
- expect(postmarkErrorToResult({ statusCode: 422, code: 300 }).disposition).toBe(
72
- "permanent"
73
- );
74
- });
75
-
76
- it("treats unknown errors as transient", () => {
77
- expect(postmarkErrorToResult({ message: "boom" }).disposition).toBe("transient");
78
- });
79
- });
80
-
81
- describe("outboundToPostmark", () => {
82
- it("maps addresses, body, and threading headers", () => {
83
- const msg = outboundToPostmark(
84
- makeOutboundMail({
85
- from: { address: "alice@example.com", name: "Alice" },
86
- to: [{ address: "ext@other.org" }],
87
- cc: [{ address: "c@other.org" }],
88
- subject: "subject",
89
- text: "body",
90
- html: "<p>body</p>",
91
- headers: {
92
- messageId: "<m@example.com>",
93
- inReplyTo: "<p@other.org>",
94
- references: ["<r1@other.org>", "<p@other.org>"],
95
- },
96
- })
97
- );
98
- expect(msg.From).toBe('"Alice" <alice@example.com>');
99
- expect(msg.To).toBe("ext@other.org");
100
- expect(msg.Cc).toBe("c@other.org");
101
- expect(msg.Subject).toBe("subject");
102
- expect(msg.TextBody).toBe("body");
103
- expect(msg.HtmlBody).toBe("<p>body</p>");
104
- expect(msg.Headers).toContainEqual({ Name: "Message-ID", Value: "<m@example.com>" });
105
- expect(msg.Headers).toContainEqual({ Name: "In-Reply-To", Value: "<p@other.org>" });
106
- expect(msg.Headers).toContainEqual({
107
- Name: "References",
108
- Value: "<r1@other.org> <p@other.org>",
109
- });
110
- });
111
-
112
- it("includes base64 attachment content when resolved", () => {
113
- const msg = outboundToPostmark(
114
- makeOutboundMail({
115
- attachments: [
116
- {
117
- filename: "a.txt",
118
- contentType: "text/plain",
119
- contentRef: "sha256:abc",
120
- sizeBytes: 3,
121
- },
122
- ],
123
- }),
124
- new Map([["sha256:abc", "Zm9v"]])
125
- );
126
- expect(msg.Attachments).toEqual([
127
- { Name: "a.txt", ContentType: "text/plain", ContentID: undefined, Content: "Zm9v" },
128
- ]);
129
- });
130
- });
131
-
132
- describe("postmarkInboundToMail", () => {
133
- const payload: PostmarkInbound = {
134
- FromFull: { Email: "ext@other.org", Name: "Ext" },
135
- ToFull: [{ Email: "alice@example.com" }],
136
- CcFull: [{ Email: "bob@example.com" }],
137
- OriginalRecipient: "alice+scope@example.com",
138
- Subject: "hi",
139
- TextBody: "hello",
140
- HtmlBody: "<p>hello</p>",
141
- Date: "2026-02-02T10:00:00Z",
142
- Headers: [
143
- { Name: "Message-ID", Value: "<m1@other.org>" },
144
- { Name: "In-Reply-To", Value: "<p0@other.org>" },
145
- { Name: "References", Value: "<root@other.org> <p0@other.org>" },
146
- {
147
- Name: "Authentication-Results",
148
- Value: "mx.example.com; spf=pass; dkim=pass; dmarc=pass",
149
- },
150
- ],
151
- };
152
-
153
- it("maps core fields and threading headers", () => {
154
- const mail = postmarkInboundToMail(payload);
155
- expect(mail.from).toEqual({ address: "ext@other.org", name: "Ext" });
156
- expect(mail.to).toEqual([{ address: "alice@example.com", name: undefined }]);
157
- expect(mail.cc).toEqual([{ address: "bob@example.com", name: undefined }]);
158
- expect(mail.subject).toBe("hi");
159
- expect(mail.text).toBe("hello");
160
- expect(mail.html).toBe("<p>hello</p>");
161
- expect(mail.headers.messageId).toBe("<m1@other.org>");
162
- expect(mail.headers.inReplyTo).toBe("<p0@other.org>");
163
- expect(mail.headers.references).toEqual(["<root@other.org>", "<p0@other.org>"]);
164
- expect(mail.envelopeTo).toEqual(["alice+scope@example.com"]);
165
- });
166
-
167
- it("extracts auth results from the Authentication-Results header", () => {
168
- const mail = postmarkInboundToMail(payload);
169
- expect(mail.authResults).toEqual({ spf: "pass", dkim: "pass", dmarc: "pass" });
170
- });
171
-
172
- it("attaches stored attachment metadata", () => {
173
- const mail = postmarkInboundToMail(payload, {
174
- attachments: [
175
- { filename: "x.pdf", contentType: "application/pdf", contentRef: "sha256:z", sizeBytes: 9 },
176
- ],
177
- });
178
- expect(mail.attachments).toHaveLength(1);
179
- });
180
-
181
- it("counts attachment bytes toward sizeBytes (consistent with the SMTP path)", () => {
182
- const bodyOnly = postmarkInboundToMail({ TextBody: "hi" });
183
- const withAttachment = postmarkInboundToMail({
184
- TextBody: "hi",
185
- Attachments: [
186
- { Name: "big.bin", Content: "", ContentType: "application/octet-stream", ContentLength: 100_000 },
187
- ],
188
- });
189
- expect(withAttachment.sizeBytes).toBe(bodyOnly.sizeBytes + 100_000);
190
- });
191
- });
192
-
193
- describe("postmarkBounceToInbound", () => {
194
- it("maps a hard bounce to a failed DSN", () => {
195
- const mail = postmarkBounceToInbound({
196
- RecordType: "Bounce",
197
- Type: "HardBounce",
198
- TypeCode: 1,
199
- Email: "user@dead.example",
200
- MessageID: "pm-123",
201
- Description: "The server was unable to deliver",
202
- Details: "550 no such user",
203
- });
204
- expect(mail.bounce).toEqual({
205
- action: "failed",
206
- recipient: "user@dead.example",
207
- status: "1",
208
- originalMessageId: "pm-123",
209
- diagnostic: "550 no such user",
210
- });
211
- });
212
-
213
- it("maps a soft bounce to a delayed DSN", () => {
214
- const mail = postmarkBounceToInbound({ Type: "SoftBounce", Email: "u@x" });
215
- expect(mail.bounce?.action).toBe("delayed");
216
- });
217
- });
218
-
219
- describe("parseAuthResults", () => {
220
- it("returns undefined without a header", () => {
221
- expect(parseAuthResults(undefined)).toBeUndefined();
222
- });
223
- it("parses a mixed verdict", () => {
224
- expect(parseAuthResults("x; spf=fail; dkim=none; dmarc=fail")).toEqual({
225
- spf: "fail",
226
- dkim: "none",
227
- dmarc: "fail",
228
- });
229
- });
230
- });
231
-
232
- describe("PostmarkTransport send (fake client)", () => {
233
- it("sends via the client and maps a delivered response", async () => {
234
- const client = fakePostmarkClient();
235
- const t = new PostmarkTransport({ client });
236
- await t.start();
237
- const res = await t.send(makeOutboundMail({ idempotencyKey: "k1" }));
238
- expect(res.disposition).toBe("delivered");
239
- expect(client.sent).toHaveLength(1);
240
- });
241
-
242
- it("is idempotent: a duplicate key does not re-send", async () => {
243
- const client = fakePostmarkClient();
244
- const t = new PostmarkTransport({ client });
245
- await t.start();
246
- const env = makeOutboundMail({ idempotencyKey: "dup" });
247
- await t.send(env);
248
- const second = await t.send(env);
249
- expect(second.detail).toMatch(/idempotent|replay/i);
250
- expect(client.sent).toHaveLength(1);
251
- });
252
-
253
- it("ingests an inbound webhook payload and ACKs after the handler", async () => {
254
- const client = fakePostmarkClient();
255
- const t = new PostmarkTransport({ client });
256
- const order: string[] = [];
257
- t.onReceive(async (mail) => {
258
- order.push(`handler:${mail.subject}`);
259
- });
260
- await t.start();
261
- await t.ingestInboundWebhook({
262
- FromFull: { Email: "ext@other.org" },
263
- ToFull: [{ Email: "alice@example.com" }],
264
- Subject: "webhook subject",
265
- TextBody: "body",
266
- Headers: [{ Name: "Message-ID", Value: "<w1@other.org>" }],
267
- });
268
- expect(order).toEqual(["handler:webhook subject"]);
269
- });
270
-
271
- it("propagates a handler throw from a bounce webhook (NACK)", async () => {
272
- const client = fakePostmarkClient();
273
- const t = new PostmarkTransport({ client });
274
- t.onReceive(async () => {
275
- throw new Error("commit failed");
276
- });
277
- await t.start();
278
- await expect(
279
- t.ingestBounceWebhook({ Type: "HardBounce", Email: "u@x", MessageID: "pm-1" })
280
- ).rejects.toThrow(/commit failed/);
281
- });
282
- });
283
-
284
- describe("PostmarkTransport built-in webhook listener", () => {
285
- it("returns 200 after the inbound handler commits, 500 on throw", async () => {
286
- const port = 31000 + Math.floor(Math.random() * 4000);
287
- const client = fakePostmarkClient();
288
- let shouldThrow = false;
289
- const received: string[] = [];
290
- const t = new PostmarkTransport({
291
- client,
292
- webhook: { port, inboundPath: "/in", bouncePath: "/bounce" },
293
- });
294
- t.onReceive(async (mail) => {
295
- if (shouldThrow) throw new Error("commit failed");
296
- received.push(mail.subject ?? "");
297
- });
298
- await t.start();
299
- try {
300
- const ok = await post(port, "/in", {
301
- FromFull: { Email: "ext@other.org" },
302
- ToFull: [{ Email: "alice@example.com" }],
303
- Subject: "http subject",
304
- TextBody: "b",
305
- Headers: [{ Name: "Message-ID", Value: "<h1@x>" }],
306
- });
307
- expect(ok).toBe(200);
308
- expect(received).toEqual(["http subject"]);
309
-
310
- shouldThrow = true;
311
- const fail = await post(port, "/in", {
312
- FromFull: { Email: "ext@other.org" },
313
- ToFull: [{ Email: "alice@example.com" }],
314
- Subject: "will fail",
315
- Headers: [{ Name: "Message-ID", Value: "<h2@x>" }],
316
- });
317
- expect(fail).toBe(500);
318
- } finally {
319
- await t.stop();
320
- }
321
- });
322
-
323
- it("enforces Basic Auth on the webhook when configured", async () => {
324
- const port = 31000 + Math.floor(Math.random() * 4000);
325
- const received: string[] = [];
326
- const t = new PostmarkTransport({
327
- client: fakePostmarkClient(),
328
- webhook: {
329
- port,
330
- inboundPath: "/in",
331
- basicAuth: { user: "pm", pass: "secret" },
332
- },
333
- });
334
- t.onReceive(async (mail) => {
335
- received.push(mail.subject ?? "");
336
- });
337
- await t.start();
338
- try {
339
- const body = {
340
- FromFull: { Email: "ext@other.org" },
341
- ToFull: [{ Email: "alice@example.com" }],
342
- Subject: "authed",
343
- Headers: [{ Name: "Message-ID", Value: "<a1@x>" }],
344
- };
345
- // No credentials → 401, handler not called.
346
- expect(await post(port, "/in", body)).toBe(401);
347
- // Wrong credentials → 401.
348
- const wrong = "Basic " + Buffer.from("pm:nope").toString("base64");
349
- expect(await post(port, "/in", body, { authorization: wrong })).toBe(401);
350
- expect(received).toHaveLength(0);
351
- // Correct credentials → 200, handler called.
352
- const right = "Basic " + Buffer.from("pm:secret").toString("base64");
353
- expect(await post(port, "/in", body, { authorization: right })).toBe(200);
354
- expect(received).toEqual(["authed"]);
355
- } finally {
356
- await t.stop();
357
- }
358
- });
359
-
360
- it("rejects an oversized webhook body with 413", async () => {
361
- const port = 31000 + Math.floor(Math.random() * 4000);
362
- const received: string[] = [];
363
- // Small maxMessageBytes → maxBody falls to the 1 MiB floor.
364
- const t = new PostmarkTransport({
365
- client: fakePostmarkClient(),
366
- maxMessageBytes: 1000,
367
- webhook: { port, inboundPath: "/in" },
368
- });
369
- t.onReceive(async (mail) => {
370
- received.push(mail.subject ?? "");
371
- });
372
- await t.start();
373
- try {
374
- const huge = { TextBody: "x".repeat(1_200_000), Subject: "too big" };
375
- expect(await post(port, "/in", huge)).toBe(413);
376
- expect(received).toHaveLength(0);
377
- } finally {
378
- await t.stop();
379
- }
380
- });
381
- });
@@ -1,48 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { RateLimiter } from "../../src/mail/rate-limiter.js";
3
-
4
- describe("RateLimiter", () => {
5
- it("allows up to max events in the window", () => {
6
- const rl = new RateLimiter(1000, 3);
7
- expect(rl.tryAcquire("a", 0)).toBe(true);
8
- expect(rl.tryAcquire("a", 1)).toBe(true);
9
- expect(rl.tryAcquire("a", 2)).toBe(true);
10
- expect(rl.tryAcquire("a", 3)).toBe(false); // 4th in window
11
- });
12
-
13
- it("tracks keys independently", () => {
14
- const rl = new RateLimiter(1000, 1);
15
- expect(rl.tryAcquire("a", 0)).toBe(true);
16
- expect(rl.tryAcquire("b", 0)).toBe(true);
17
- expect(rl.tryAcquire("a", 0)).toBe(false);
18
- });
19
-
20
- it("frees capacity as events age out of the window", () => {
21
- const rl = new RateLimiter(1000, 2);
22
- expect(rl.tryAcquire("a", 0)).toBe(true);
23
- expect(rl.tryAcquire("a", 500)).toBe(true);
24
- expect(rl.tryAcquire("a", 600)).toBe(false);
25
- // At t=1001 the t=0 event has aged out (cutoff = 1, 0 <= 1).
26
- expect(rl.tryAcquire("a", 1001)).toBe(true);
27
- });
28
-
29
- it("treats max <= 0 as fully closed", () => {
30
- const rl = new RateLimiter(1000, 0);
31
- expect(rl.tryAcquire("a", 0)).toBe(false);
32
- });
33
-
34
- it("prune drops idle keys", () => {
35
- const rl = new RateLimiter(1000, 5);
36
- rl.tryAcquire("a", 0);
37
- rl.prune(5000);
38
- expect(rl.count("a", 5000)).toBe(0);
39
- });
40
-
41
- it("count reflects events within the window", () => {
42
- const rl = new RateLimiter(1000, 5);
43
- rl.tryAcquire("a", 0);
44
- rl.tryAcquire("a", 100);
45
- expect(rl.count("a", 200)).toBe(2);
46
- expect(rl.count("a", 1050)).toBe(1); // t=0 aged out (cutoff=50), t=100 remains
47
- });
48
- });
@@ -1,138 +0,0 @@
1
- import { describe, it, expect, beforeEach } from "vitest";
2
- import { EventEmitter } from "node:events";
3
- import { InMemoryStorage } from "../../src/storage/memory.js";
4
- import {
5
- MessageRouter,
6
- type MailEgress,
7
- } from "../../src/router/message-router.js";
8
- import { MailGateway } from "../../src/mail/mail-gateway.js";
9
- import { MockMailTransport } from "./mock-mail.js";
10
- import type { Agent, Message } from "../../src/types.js";
11
-
12
- function agent(id: string): Agent {
13
- return {
14
- agent_id: id,
15
- scope: "default",
16
- status: "active",
17
- metadata: {},
18
- registered_at: new Date().toISOString(),
19
- last_active_at: new Date().toISOString(),
20
- };
21
- }
22
-
23
- const tick = () => new Promise((r) => setImmediate(r));
24
-
25
- describe("router ↔ mail integration", () => {
26
- let storage: InMemoryStorage;
27
- let events: EventEmitter;
28
- let router: MessageRouter;
29
- let transport: MockMailTransport;
30
- let gateway: MailGateway;
31
-
32
- beforeEach(async () => {
33
- storage = new InMemoryStorage();
34
- events = new EventEmitter();
35
- router = new MessageRouter(storage, events);
36
- storage.putAgent(agent("alice"));
37
- storage.putAgent(agent("bob"));
38
- transport = new MockMailTransport();
39
- await transport.start();
40
- gateway = new MailGateway({
41
- transport,
42
- storage,
43
- router,
44
- events,
45
- config: {
46
- identity: {
47
- localDomains: ["example.com"],
48
- mappings: [{ agentId: "alice", address: "alice@example.com" }],
49
- },
50
- routableDomains: ["external.org"],
51
- },
52
- });
53
- router.setMail(gateway);
54
- });
55
-
56
- it("routes external mail recipients to the gateway, not local delivery", async () => {
57
- const msg = await router.routeMessage({
58
- from: "alice",
59
- to: [{ agent_id: "user@external.org", kind: "to" }],
60
- payload: "hello world",
61
- });
62
- await tick();
63
-
64
- expect(transport.sent).toHaveLength(1);
65
- expect(transport.sent[0].to).toEqual([{ address: "user@external.org" }]);
66
- // The mail recipient is not treated as a delivered local agent.
67
- const stored = storage.getMessage(msg.id)!;
68
- expect(stored.recipients[0].delivered_at).toBeTruthy(); // set by gateway on send
69
- });
70
-
71
- it("delivers local recipients locally and mail recipients via the gateway", async () => {
72
- await router.routeMessage({
73
- from: "alice",
74
- to: [
75
- { agent_id: "bob", kind: "to" },
76
- { agent_id: "user@external.org", kind: "to" },
77
- ],
78
- payload: "mixed",
79
- });
80
- await tick();
81
-
82
- expect(storage.getInbox("bob")).toHaveLength(1);
83
- expect(transport.sent).toHaveLength(1);
84
- });
85
-
86
- it("does not send local-only messages through mail", async () => {
87
- await router.routeMessage({
88
- from: "alice",
89
- to: [{ agent_id: "bob", kind: "to" }],
90
- payload: "local only",
91
- });
92
- await tick();
93
- expect(transport.sent).toHaveLength(0);
94
- });
95
-
96
- it("sends non-mail remote recipients to federation, not mail", async () => {
97
- const routed: Message[] = [];
98
- const fakeFederation = {
99
- route: async (m: Message) => {
100
- routed.push(m);
101
- return { delivered: false };
102
- },
103
- };
104
- // Cast: the router only calls .route() on the federation handle.
105
- router.setFederation(fakeFederation as never);
106
-
107
- await router.routeMessage({
108
- from: "alice",
109
- to: [{ agent_id: "carol@system-2", kind: "to" }],
110
- payload: "federated",
111
- });
112
- await tick();
113
-
114
- expect(routed).toHaveLength(1);
115
- expect(transport.sent).toHaveLength(0);
116
- });
117
-
118
- it("ownsRecipient distinguishes mail domains from federation systems", () => {
119
- expect(gateway.ownsRecipient("user@external.org")).toBe(true);
120
- expect(gateway.ownsRecipient("user@example.com")).toBe(true);
121
- expect(gateway.ownsRecipient("carol@system-2")).toBe(false);
122
- expect(gateway.ownsRecipient("bob")).toBe(false);
123
- });
124
- });
125
-
126
- describe("MailEgress contract shape", () => {
127
- it("MailGateway satisfies MailEgress", () => {
128
- const eg: MailEgress = new MailGateway({
129
- transport: new MockMailTransport(),
130
- storage: new InMemoryStorage(),
131
- router: new MessageRouter(new InMemoryStorage(), new EventEmitter()),
132
- events: new EventEmitter(),
133
- config: { identity: { localDomains: ["x.com"], mappings: [] } },
134
- });
135
- expect(typeof eg.ownsRecipient).toBe("function");
136
- expect(typeof eg.send).toBe("function");
137
- });
138
- });
@@ -1,98 +0,0 @@
1
- import { describe, it, expect, afterEach } from "vitest";
2
- import { SmtpTransport } from "../../src/mail/smtp-transport.js";
3
- import { makeOutboundMail } from "./mock-mail.js";
4
- import type { InboundMail } from "../../src/mail/types.js";
5
-
6
- // These libs are optional peer deps; skip the real-socket e2e if absent.
7
- const haveLibs = await Promise.all([
8
- import("smtp-server").then(() => true).catch(() => false),
9
- import("nodemailer").then(() => true).catch(() => false),
10
- import("mailparser").then(() => true).catch(() => false),
11
- ]).then((r) => r.every(Boolean));
12
-
13
- const randomPort = () => 30000 + Math.floor(Math.random() * 5000);
14
-
15
- describe.skipIf(!haveLibs)("SmtpTransport e2e (loopback)", () => {
16
- const transports: SmtpTransport[] = [];
17
- afterEach(async () => {
18
- for (const t of transports) await t.stop().catch(() => {});
19
- transports.length = 0;
20
- });
21
-
22
- it("delivers a message from one transport to another and ACKs after commit", async () => {
23
- const recvPort = randomPort();
24
-
25
- // Receiver: captures inbound and resolves only after a simulated commit.
26
- const received: InboundMail[] = [];
27
- const committed: string[] = [];
28
- const receiver = new SmtpTransport({ listenPort: recvPort });
29
- receiver.onReceive(async (mail) => {
30
- received.push(mail);
31
- await new Promise((r) => setTimeout(r, 5)); // simulate async storage commit
32
- committed.push("done");
33
- });
34
- transports.push(receiver);
35
- await receiver.start();
36
-
37
- // Sender: listens on a different port, relays to the receiver.
38
- const sender = new SmtpTransport({
39
- listenPort: randomPort(),
40
- relay: { host: "127.0.0.1", port: recvPort, secure: false },
41
- });
42
- transports.push(sender);
43
- await sender.start();
44
-
45
- const result = await sender.send(
46
- makeOutboundMail({
47
- idempotencyKey: "e2e-1",
48
- from: { address: "alice@example.com" },
49
- to: [{ address: "bob@receiver.test" }],
50
- subject: "e2e subject",
51
- text: "e2e body",
52
- headers: { messageId: "<e2e-1@example.com>" },
53
- })
54
- );
55
-
56
- expect(result.disposition).toBe("delivered");
57
-
58
- // Allow the receiver's onData/handler to run.
59
- await new Promise((r) => setTimeout(r, 100));
60
-
61
- expect(received).toHaveLength(1);
62
- expect(received[0].subject).toBe("e2e subject");
63
- expect(received[0].text?.trim()).toBe("e2e body");
64
- expect(received[0].headers.messageId).toBe("<e2e-1@example.com>");
65
- // The handler's commit ran (ACK happened after it).
66
- expect(committed).toEqual(["done"]);
67
- });
68
-
69
- it("is idempotent on the sender for a repeated idempotencyKey", async () => {
70
- const recvPort = randomPort();
71
- const received: InboundMail[] = [];
72
- const receiver = new SmtpTransport({ listenPort: recvPort });
73
- receiver.onReceive(async (mail) => {
74
- received.push(mail);
75
- });
76
- transports.push(receiver);
77
- await receiver.start();
78
-
79
- const sender = new SmtpTransport({
80
- listenPort: randomPort(),
81
- relay: { host: "127.0.0.1", port: recvPort, secure: false },
82
- });
83
- transports.push(sender);
84
- await sender.start();
85
-
86
- const env = makeOutboundMail({
87
- idempotencyKey: "dup-key",
88
- to: [{ address: "bob@receiver.test" }],
89
- headers: { messageId: "<dup-key@example.com>" },
90
- });
91
- await sender.send(env);
92
- const second = await sender.send(env);
93
-
94
- expect(second.detail).toMatch(/idempotent|replay/i);
95
- await new Promise((r) => setTimeout(r, 100));
96
- expect(received).toHaveLength(1); // not delivered twice
97
- });
98
- });