agent-inbox 0.2.3 → 0.2.4

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 (122) hide show
  1. package/AGENTS.md +18 -0
  2. package/CLAUDE.md +92 -1
  3. package/README.md +73 -6
  4. package/dist/federation/connection-manager.d.ts +8 -0
  5. package/dist/federation/connection-manager.d.ts.map +1 -1
  6. package/dist/federation/connection-manager.js +12 -0
  7. package/dist/federation/connection-manager.js.map +1 -1
  8. package/dist/federation/delivery-queue.d.ts +11 -3
  9. package/dist/federation/delivery-queue.d.ts.map +1 -1
  10. package/dist/federation/delivery-queue.js +38 -8
  11. package/dist/federation/delivery-queue.js.map +1 -1
  12. package/dist/federation/queue-store.d.ts +42 -0
  13. package/dist/federation/queue-store.d.ts.map +1 -0
  14. package/dist/federation/queue-store.js +87 -0
  15. package/dist/federation/queue-store.js.map +1 -0
  16. package/dist/index.d.mts +2 -0
  17. package/dist/index.d.ts +17 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +98 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs +1 -0
  22. package/dist/index.mjs.map +1 -0
  23. package/dist/mail/address-book.d.ts +43 -0
  24. package/dist/mail/address-book.d.ts.map +1 -0
  25. package/dist/mail/address-book.js +95 -0
  26. package/dist/mail/address-book.js.map +1 -0
  27. package/dist/mail/attachment-store.d.ts +31 -0
  28. package/dist/mail/attachment-store.d.ts.map +1 -0
  29. package/dist/mail/attachment-store.js +74 -0
  30. package/dist/mail/attachment-store.js.map +1 -0
  31. package/dist/mail/email-mapper.d.ts +41 -0
  32. package/dist/mail/email-mapper.d.ts.map +1 -0
  33. package/dist/mail/email-mapper.js +216 -0
  34. package/dist/mail/email-mapper.js.map +1 -0
  35. package/dist/mail/fs-attachment-store.d.ts +38 -0
  36. package/dist/mail/fs-attachment-store.d.ts.map +1 -0
  37. package/dist/mail/fs-attachment-store.js +165 -0
  38. package/dist/mail/fs-attachment-store.js.map +1 -0
  39. package/dist/mail/mail-gateway.d.ts +114 -0
  40. package/dist/mail/mail-gateway.d.ts.map +1 -0
  41. package/dist/mail/mail-gateway.js +402 -0
  42. package/dist/mail/mail-gateway.js.map +1 -0
  43. package/dist/mail/provider-transport.d.ts +138 -0
  44. package/dist/mail/provider-transport.d.ts.map +1 -0
  45. package/dist/mail/provider-transport.js +434 -0
  46. package/dist/mail/provider-transport.js.map +1 -0
  47. package/dist/mail/rate-limiter.d.ts +20 -0
  48. package/dist/mail/rate-limiter.d.ts.map +1 -0
  49. package/dist/mail/rate-limiter.js +56 -0
  50. package/dist/mail/rate-limiter.js.map +1 -0
  51. package/dist/mail/smtp-transport.d.ts +141 -0
  52. package/dist/mail/smtp-transport.d.ts.map +1 -0
  53. package/dist/mail/smtp-transport.js +415 -0
  54. package/dist/mail/smtp-transport.js.map +1 -0
  55. package/dist/mail/types.d.ts +177 -0
  56. package/dist/mail/types.d.ts.map +1 -0
  57. package/dist/mail/types.js +11 -0
  58. package/dist/mail/types.js.map +1 -0
  59. package/dist/router/destination.d.ts +69 -0
  60. package/dist/router/destination.d.ts.map +1 -0
  61. package/dist/router/destination.js +106 -0
  62. package/dist/router/destination.js.map +1 -0
  63. package/dist/router/message-router.d.ts +15 -0
  64. package/dist/router/message-router.d.ts.map +1 -1
  65. package/dist/router/message-router.js +25 -3
  66. package/dist/router/message-router.js.map +1 -1
  67. package/dist/storage/interface.d.ts +9 -0
  68. package/dist/storage/interface.d.ts.map +1 -1
  69. package/dist/storage/memory.d.ts +4 -0
  70. package/dist/storage/memory.d.ts.map +1 -1
  71. package/dist/storage/memory.js +12 -0
  72. package/dist/storage/memory.js.map +1 -1
  73. package/dist/storage/sqlite.d.ts +6 -0
  74. package/dist/storage/sqlite.d.ts.map +1 -1
  75. package/dist/storage/sqlite.js +28 -0
  76. package/dist/storage/sqlite.js.map +1 -1
  77. package/dist/types.d.ts +79 -0
  78. package/dist/types.d.ts.map +1 -1
  79. package/docs/DESIGN.md +15 -0
  80. package/docs/MAIL-INTEROP-PLAN.md +660 -0
  81. package/package.json +28 -3
  82. package/renovate.json5 +6 -0
  83. package/rules/agent-inbox.md +1 -0
  84. package/src/federation/connection-manager.ts +12 -0
  85. package/src/federation/delivery-queue.ts +38 -8
  86. package/src/federation/queue-store.ts +124 -0
  87. package/src/index.ts +148 -0
  88. package/src/mail/address-book.ts +111 -0
  89. package/src/mail/attachment-store.ts +90 -0
  90. package/src/mail/email-mapper.ts +288 -0
  91. package/src/mail/fs-attachment-store.ts +163 -0
  92. package/src/mail/mail-gateway.ts +505 -0
  93. package/src/mail/provider-transport.ts +577 -0
  94. package/src/mail/rate-limiter.ts +51 -0
  95. package/src/mail/smtp-transport.ts +589 -0
  96. package/src/mail/types.ts +221 -0
  97. package/src/router/destination.ts +140 -0
  98. package/src/router/message-router.ts +41 -4
  99. package/src/storage/interface.ts +11 -0
  100. package/src/storage/memory.ts +15 -0
  101. package/src/storage/sqlite.ts +36 -0
  102. package/src/types.ts +73 -0
  103. package/test/federation/delivery-queue-sqlite.test.ts +158 -0
  104. package/test/load.test.ts +1 -1
  105. package/test/mail/address-book.test.ts +111 -0
  106. package/test/mail/attachment-store-contract.test.ts +92 -0
  107. package/test/mail/attachment-store.test.ts +69 -0
  108. package/test/mail/destination.test.ts +115 -0
  109. package/test/mail/dsn-parse.test.ts +239 -0
  110. package/test/mail/email-mapper.test.ts +341 -0
  111. package/test/mail/external-id.test.ts +43 -0
  112. package/test/mail/fs-attachment-store.test.ts +134 -0
  113. package/test/mail/full-flow-e2e.test.ts +200 -0
  114. package/test/mail/mail-gateway.test.ts +419 -0
  115. package/test/mail/mail-transport-contract.test.ts +134 -0
  116. package/test/mail/mock-mail.ts +161 -0
  117. package/test/mail/mock-postmark.ts +66 -0
  118. package/test/mail/provider-transport.test.ts +381 -0
  119. package/test/mail/rate-limiter.test.ts +48 -0
  120. package/test/mail/router-mail-integration.test.ts +138 -0
  121. package/test/mail/smtp-e2e.test.ts +98 -0
  122. package/test/mail/smtp-transport.test.ts +138 -0
@@ -0,0 +1,138 @@
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
+ });
@@ -0,0 +1,98 @@
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
+ });
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ smtpErrorToResult,
4
+ parsedToInboundMail,
5
+ toNodemailerMessage,
6
+ mailauthToResults,
7
+ } from "../../src/mail/smtp-transport.js";
8
+ import { makeOutboundMail } from "./mock-mail.js";
9
+
10
+ describe("smtpErrorToResult", () => {
11
+ it("maps 5xx to permanent", () => {
12
+ expect(smtpErrorToResult({ responseCode: 550, response: "no user" })).toEqual({
13
+ disposition: "permanent",
14
+ code: 550,
15
+ detail: "no user",
16
+ });
17
+ });
18
+
19
+ it("maps 4xx to transient", () => {
20
+ expect(smtpErrorToResult({ responseCode: 451 }).disposition).toBe("transient");
21
+ });
22
+
23
+ it("maps connection/DNS errors (no code) to transient", () => {
24
+ expect(smtpErrorToResult({ message: "ECONNREFUSED" })).toEqual({
25
+ disposition: "transient",
26
+ detail: "ECONNREFUSED",
27
+ });
28
+ });
29
+ });
30
+
31
+ describe("parsedToInboundMail", () => {
32
+ it("builds an InboundMail from parsed fields", () => {
33
+ const mail = parsedToInboundMail(
34
+ {
35
+ from: { value: [{ address: "ext@other.org", name: "Ext" }] },
36
+ to: { value: [{ address: "alice@example.com" }] },
37
+ cc: { value: [{ address: "bob@example.com" }] },
38
+ subject: "hi",
39
+ text: "body",
40
+ messageId: "<m1@other.org>",
41
+ inReplyTo: "<p0@other.org>",
42
+ references: "<root@other.org> <p0@other.org>",
43
+ date: new Date("2026-01-01T00:00:00Z"),
44
+ headers: new Map([["x-priority", "1"]]),
45
+ },
46
+ {
47
+ envelopeFrom: "ext@other.org",
48
+ envelopeTo: ["alice@example.com"],
49
+ remote: { ip: "203.0.113.5" },
50
+ sizeBytes: 200,
51
+ }
52
+ );
53
+
54
+ expect(mail.from).toEqual({ address: "ext@other.org", name: "Ext" });
55
+ expect(mail.to).toEqual([{ address: "alice@example.com", name: undefined }]);
56
+ expect(mail.cc).toEqual([{ address: "bob@example.com", name: undefined }]);
57
+ expect(mail.headers.messageId).toBe("<m1@other.org>");
58
+ expect(mail.headers.references).toEqual([
59
+ "<root@other.org>",
60
+ "<p0@other.org>",
61
+ ]);
62
+ expect(mail.headers.raw["x-priority"]).toBe("1");
63
+ expect(mail.remote).toEqual({ ip: "203.0.113.5" });
64
+ expect(mail.receivedAt).toBe("2026-01-01T00:00:00.000Z");
65
+ });
66
+
67
+ it("falls back to the envelope sender when From is missing", () => {
68
+ const mail = parsedToInboundMail(
69
+ { to: { value: [{ address: "alice@example.com" }] } },
70
+ { envelopeFrom: "bounce@mta.net", envelopeTo: [], sizeBytes: 1 }
71
+ );
72
+ expect(mail.from).toEqual({ address: "bounce@mta.net" });
73
+ });
74
+ });
75
+
76
+ describe("toNodemailerMessage", () => {
77
+ it("maps an OutboundMail to nodemailer fields", () => {
78
+ const msg = toNodemailerMessage(
79
+ makeOutboundMail({
80
+ from: { address: "alice@example.com", name: "Alice" },
81
+ to: [{ address: "ext@other.org" }],
82
+ cc: [{ address: "c@other.org" }],
83
+ subject: "subject",
84
+ text: "text body",
85
+ headers: {
86
+ messageId: "<m@example.com>",
87
+ inReplyTo: "<p@other.org>",
88
+ references: ["<r@other.org>"],
89
+ },
90
+ })
91
+ );
92
+ expect(msg.from).toEqual({ name: "Alice", address: "alice@example.com" });
93
+ expect(msg.to).toEqual(["ext@other.org"]);
94
+ expect(msg.cc).toEqual(["c@other.org"]);
95
+ expect(msg.messageId).toBe("<m@example.com>");
96
+ expect(msg.inReplyTo).toBe("<p@other.org>");
97
+ expect(msg.references).toEqual(["<r@other.org>"]);
98
+ });
99
+
100
+ it("includes dkim options when provided", () => {
101
+ const msg = toNodemailerMessage(makeOutboundMail(), {
102
+ domainName: "example.com",
103
+ keySelector: "s1",
104
+ privateKey: "KEY",
105
+ });
106
+ expect(msg.dkim).toEqual({
107
+ domainName: "example.com",
108
+ keySelector: "s1",
109
+ privateKey: "KEY",
110
+ });
111
+ });
112
+ });
113
+
114
+ describe("mailauthToResults", () => {
115
+ it("normalizes a full pass verdict", () => {
116
+ expect(
117
+ mailauthToResults({
118
+ spf: { status: { result: "pass" } },
119
+ dkim: { results: [{ status: { result: "pass" } }] },
120
+ dmarc: { status: { result: "pass" } },
121
+ })
122
+ ).toEqual({ spf: "pass", dkim: "pass", dmarc: "pass" });
123
+ });
124
+
125
+ it("defaults missing verdicts to none", () => {
126
+ expect(mailauthToResults({})).toEqual({
127
+ spf: "none",
128
+ dkim: "none",
129
+ dmarc: "none",
130
+ });
131
+ });
132
+
133
+ it("maps dmarc fail through", () => {
134
+ expect(
135
+ mailauthToResults({ dmarc: { status: { result: "fail" } } }).dmarc
136
+ ).toBe("fail");
137
+ });
138
+ });