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,200 +0,0 @@
1
- /**
2
- * Full-flow mail e2e — the entire chain over real SMTP sockets.
3
- *
4
- * Two independent agent-inbox "nodes" (each: storage + router + gateway + SMTP
5
- * transport) stand in for two organizations. Node A hosts alice@a.test, node B
6
- * hosts bob@b.test; each node's outbound relay points at the other's inbound
7
- * listener. We then exercise:
8
- *
9
- * 1. alice sends to bob@b.test
10
- * → routerA → gatewayA → SMTP socket → gatewayB → routerB → bob's inbox
11
- * 2. bob replies to alice@a.test (in_reply_to the received message)
12
- * → routerB → gatewayB → SMTP socket → gatewayA → routerA → alice's inbox
13
- *
14
- * Assertions prove ingestion, recipient resolution, delivered-marking, and
15
- * end-to-end RFC threading correlation across real wire transport.
16
- *
17
- * Skipped if the optional SMTP libs are not installed.
18
- */
19
-
20
- import { describe, it, expect, afterEach } from "vitest";
21
- import { EventEmitter } from "node:events";
22
- import { InMemoryStorage } from "../../src/storage/memory.js";
23
- import { MessageRouter } from "../../src/router/message-router.js";
24
- import { MailGateway } from "../../src/mail/mail-gateway.js";
25
- import { SmtpTransport } from "../../src/mail/smtp-transport.js";
26
- import type { Message } from "../../src/types.js";
27
-
28
- const haveLibs = await Promise.all([
29
- import("smtp-server").then(() => true).catch(() => false),
30
- import("nodemailer").then(() => true).catch(() => false),
31
- import("mailparser").then(() => true).catch(() => false),
32
- ]).then((r) => r.every(Boolean));
33
-
34
- const randomPort = () => 30000 + Math.floor(Math.random() * 5000);
35
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
36
-
37
- async function waitFor(
38
- fn: () => boolean,
39
- timeoutMs = 4000,
40
- stepMs = 20
41
- ): Promise<void> {
42
- const start = Date.now();
43
- while (Date.now() - start < timeoutMs) {
44
- if (fn()) return;
45
- await sleep(stepMs);
46
- }
47
- throw new Error("waitFor timed out");
48
- }
49
-
50
- interface Node {
51
- storage: InMemoryStorage;
52
- router: MessageRouter;
53
- gateway: MailGateway;
54
- events: EventEmitter;
55
- }
56
-
57
- function makeNode(opts: {
58
- listenPort: number;
59
- relayPort: number;
60
- localDomain: string;
61
- routableDomain: string;
62
- agentId: string;
63
- agentAddress: string;
64
- }): Node {
65
- const storage = new InMemoryStorage();
66
- const events = new EventEmitter();
67
- const router = new MessageRouter(storage, events, "default");
68
- const transport = new SmtpTransport({
69
- listenPort: opts.listenPort,
70
- relay: { host: "127.0.0.1", port: opts.relayPort, secure: false },
71
- });
72
- const gateway = new MailGateway({
73
- transport,
74
- storage,
75
- router,
76
- events,
77
- config: {
78
- identity: {
79
- localDomains: [opts.localDomain],
80
- mappings: [{ agentId: opts.agentId, address: opts.agentAddress }],
81
- },
82
- routableDomains: [opts.routableDomain],
83
- messageIdDomain: opts.localDomain,
84
- },
85
- });
86
- router.setMail(gateway);
87
- return { storage, router, gateway, events };
88
- }
89
-
90
- describe.skipIf(!haveLibs)("mail full-flow e2e (two nodes over real SMTP)", () => {
91
- const nodes: Node[] = [];
92
- afterEach(async () => {
93
- for (const n of nodes) await n.gateway.stop().catch(() => {});
94
- nodes.length = 0;
95
- });
96
-
97
- it("delivers agent→external→agent and threads the reply back", async () => {
98
- const portA = randomPort();
99
- const portB = randomPort();
100
-
101
- const A = makeNode({
102
- listenPort: portA,
103
- relayPort: portB, // A sends to B
104
- localDomain: "a.test",
105
- routableDomain: "b.test",
106
- agentId: "alice",
107
- agentAddress: "alice@a.test",
108
- });
109
- const B = makeNode({
110
- listenPort: portB,
111
- relayPort: portA, // B sends to A
112
- localDomain: "b.test",
113
- routableDomain: "a.test",
114
- agentId: "bob",
115
- agentAddress: "bob@b.test",
116
- });
117
- nodes.push(A, B);
118
- await A.gateway.start();
119
- await B.gateway.start();
120
-
121
- // --- 1. alice → bob@b.test ------------------------------------------
122
- const outgoing: Message = await A.router.routeMessage({
123
- from: "alice",
124
- to: "bob@b.test",
125
- subject: "Status check",
126
- payload: "How is the deploy going?",
127
- });
128
-
129
- // Lands in bob's inbox on node B (commit-before-ACK means it's committed
130
- // by the time the SMTP 250 returns, but poll to be socket-timing-safe).
131
- await waitFor(() => B.storage.getInbox("bob").length === 1);
132
- const bobMsg = B.storage.getInbox("bob")[0];
133
- expect(bobMsg.sender_id).toBe("alice@a.test");
134
- expect(bobMsg.recipients[0].agent_id).toBe("bob");
135
- expect(bobMsg.subject).toBe("Status check");
136
- // SMTP/MIME normalizes bodies with a trailing newline — trim for comparison.
137
- expect((bobMsg.content as { text: string }).text.trim()).toBe(
138
- "How is the deploy going?"
139
- );
140
- // B recorded the RFC Message-ID for later reply correlation.
141
- expect(bobMsg.metadata.rfcMessageId).toBe(`<${outgoing.id}@a.test>`);
142
-
143
- // A marked alice's outbound recipient delivered after B ACKed.
144
- await waitFor(
145
- () => !!A.storage.getMessage(outgoing.id)?.recipients[0].delivered_at
146
- );
147
-
148
- // --- 2. bob replies → alice@a.test ----------------------------------
149
- await B.router.routeMessage({
150
- from: "bob",
151
- to: "alice@a.test",
152
- subject: "Re: Status check",
153
- payload: "Deploy is green.",
154
- inReplyTo: bobMsg.id,
155
- });
156
-
157
- await waitFor(() => A.storage.getInbox("alice").length === 1);
158
- const aliceReply = A.storage.getInbox("alice")[0];
159
- expect(aliceReply.sender_id).toBe("bob@b.test");
160
- expect((aliceReply.content as { text: string }).text.trim()).toBe(
161
- "Deploy is green."
162
- );
163
- // The reply carries In-Reply-To = the original RFC Message-ID...
164
- expect(aliceReply.metadata.rfcInReplyTo).toBe(`<${outgoing.id}@a.test>`);
165
- // ...and because A sent the original, it correlates the reply back to the
166
- // original inbox message id — full round-trip threading over real SMTP.
167
- expect(aliceReply.in_reply_to).toBe(outgoing.id);
168
- });
169
-
170
- it("queues for durable retry when the relay is unreachable", async () => {
171
- // Node A's relay points at a dead port. A real connection-refused on send
172
- // classifies as transient, so the message goes to the durable retry queue
173
- // rather than bouncing — exercising the failure path over a real socket.
174
- const portA = randomPort();
175
- const deadPort = randomPort();
176
- const A = makeNode({
177
- listenPort: portA,
178
- relayPort: deadPort, // nothing is listening here
179
- localDomain: "a.test",
180
- routableDomain: "b.test",
181
- agentId: "alice",
182
- agentAddress: "alice@a.test",
183
- });
184
- nodes.push(A);
185
- await A.gateway.start();
186
-
187
- const queued: unknown[] = [];
188
- A.events.on("mail.queued", (e) => queued.push(e));
189
-
190
- await A.router.routeMessage({
191
- from: "alice",
192
- to: "bob@b.test",
193
- payload: "will not connect",
194
- });
195
-
196
- // Connection refused → transient → queued for durable retry.
197
- await waitFor(() => queued.length === 1);
198
- expect(A.gateway.queue.size("mail")).toBe(1);
199
- });
200
- });
@@ -1,419 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { EventEmitter } from "node:events";
3
- import { InMemoryStorage } from "../../src/storage/memory.js";
4
- import { MessageRouter } from "../../src/router/message-router.js";
5
- import { MailGateway, type MailGatewayConfig } from "../../src/mail/mail-gateway.js";
6
- import { MockMailTransport, makeInboundMail } from "./mock-mail.js";
7
- import type { Message } from "../../src/types.js";
8
-
9
- function setup(configOver: Partial<MailGatewayConfig> = {}) {
10
- const storage = new InMemoryStorage();
11
- const events = new EventEmitter();
12
- const router = new MessageRouter(storage, events);
13
- const transport = new MockMailTransport();
14
- const config: MailGatewayConfig = {
15
- identity: {
16
- localDomains: ["example.com"],
17
- mappings: [{ agentId: "alice", address: "alice@example.com" }],
18
- },
19
- routableDomains: ["external.org"],
20
- ...configOver,
21
- };
22
- const gateway = new MailGateway({ transport, storage, router, events, config });
23
- router.setMail(gateway);
24
- return { storage, events, router, transport, gateway };
25
- }
26
-
27
- function capture(events: EventEmitter, name: string): unknown[] {
28
- const seen: unknown[] = [];
29
- events.on(name, (e) => seen.push(e));
30
- return seen;
31
- }
32
-
33
- function outboundMessage(over: Partial<Message> = {}): Message {
34
- return {
35
- id: "M1",
36
- scope: "default",
37
- sender_id: "alice",
38
- recipients: [{ agent_id: "user@external.org", kind: "to" }],
39
- subject: "hi",
40
- content: { type: "text", text: "hello" },
41
- importance: "normal",
42
- metadata: {},
43
- created_at: new Date().toISOString(),
44
- ...over,
45
- };
46
- }
47
-
48
- describe("MailGateway ingress", () => {
49
- it("ingests inbound mail into the recipient's inbox", async () => {
50
- const { gateway, storage, events } = setup();
51
- const received = capture(events, "mail.received");
52
-
53
- await gateway.handleInbound(
54
- makeInboundMail({
55
- from: { address: "ext@other.org" },
56
- to: [{ address: "alice@example.com" }],
57
- subject: "question",
58
- text: "what's up",
59
- headers: { messageId: "<q1@other.org>", raw: {} },
60
- })
61
- );
62
-
63
- const inbox = storage.getInbox("alice");
64
- expect(inbox).toHaveLength(1);
65
- expect(inbox[0].subject).toBe("question");
66
- expect(inbox[0].sender_id).toBe("ext@other.org");
67
- expect(received).toHaveLength(1);
68
- });
69
-
70
- it("dedups a redelivered Message-ID (ingests once)", async () => {
71
- const { gateway, storage, events } = setup();
72
- const dup = capture(events, "mail.duplicate");
73
- const mail = makeInboundMail({
74
- to: [{ address: "alice@example.com" }],
75
- headers: { messageId: "<same@x>", raw: {} },
76
- });
77
-
78
- await gateway.handleInbound(mail);
79
- await gateway.handleInbound(mail);
80
-
81
- expect(storage.getInbox("alice")).toHaveLength(1);
82
- expect(dup).toHaveLength(1);
83
- });
84
-
85
- it("rejects oversize inbound mail", async () => {
86
- const { gateway, storage, events } = setup({ maxMessageBytes: 10 });
87
- const rejected = capture(events, "mail.rejected");
88
-
89
- await gateway.handleInbound(
90
- makeInboundMail({ to: [{ address: "alice@example.com" }], sizeBytes: 999 })
91
- );
92
-
93
- expect(storage.getInbox("alice")).toHaveLength(0);
94
- expect(rejected).toEqual([
95
- expect.objectContaining({ reason: "size-exceeded" }),
96
- ]);
97
- });
98
-
99
- it("rejects senders outside the allow-list", async () => {
100
- const { gateway, events } = setup({ allowedSenderDomains: ["trusted.org"] });
101
- const rejected = capture(events, "mail.rejected");
102
-
103
- await gateway.handleInbound(
104
- makeInboundMail({
105
- from: { address: "spammer@evil.net" },
106
- to: [{ address: "alice@example.com" }],
107
- })
108
- );
109
-
110
- expect(rejected).toEqual([
111
- expect.objectContaining({ reason: "sender-not-allowed" }),
112
- ]);
113
- });
114
-
115
- it("rejects DMARC failures when configured", async () => {
116
- const { gateway, events } = setup({ rejectDmarcFail: true });
117
- const rejected = capture(events, "mail.rejected");
118
-
119
- await gateway.handleInbound(
120
- makeInboundMail({
121
- to: [{ address: "alice@example.com" }],
122
- authResults: { spf: "fail", dkim: "fail", dmarc: "fail" },
123
- })
124
- );
125
-
126
- expect(rejected).toEqual([
127
- expect.objectContaining({ reason: "dmarc-fail" }),
128
- ]);
129
- });
130
-
131
- it("rejects mail with no resolvable local recipient", async () => {
132
- const { gateway, events } = setup();
133
- const rejected = capture(events, "mail.rejected");
134
-
135
- await gateway.handleInbound(
136
- makeInboundMail({ to: [{ address: "ghost@example.com" }] })
137
- );
138
-
139
- expect(rejected).toEqual([
140
- expect.objectContaining({ reason: "no-local-recipient" }),
141
- ]);
142
- });
143
-
144
- it("rejects mail with too many attachments", async () => {
145
- const { gateway, storage, events } = setup({ maxAttachments: 1 });
146
- const rejected = capture(events, "mail.rejected");
147
-
148
- await gateway.handleInbound(
149
- makeInboundMail({
150
- to: [{ address: "alice@example.com" }],
151
- attachments: [
152
- { contentType: "text/plain", contentRef: "r1", sizeBytes: 1 },
153
- { contentType: "text/plain", contentRef: "r2", sizeBytes: 1 },
154
- ],
155
- })
156
- );
157
-
158
- expect(storage.getInbox("alice")).toHaveLength(0);
159
- expect(rejected).toEqual([
160
- expect.objectContaining({ reason: "too-many-attachments" }),
161
- ]);
162
- });
163
- });
164
-
165
- describe("MailGateway rate limiting", () => {
166
- it("NACKs (throws) inbound over the per-sender-domain limit", async () => {
167
- const { gateway, storage } = setup({
168
- rateLimit: { windowMs: 60_000, perSenderDomain: 2 },
169
- });
170
- let n = 0;
171
- const mail = () =>
172
- makeInboundMail({
173
- from: { address: "ext@flood.org" },
174
- to: [{ address: "alice@example.com" }],
175
- headers: { messageId: `<flood-${n++}@flood.org>`, raw: {} },
176
- });
177
-
178
- await gateway.handleInbound(mail());
179
- await gateway.handleInbound(mail());
180
- // Third within the window is rejected with a transient NACK.
181
- await expect(gateway.handleInbound(mail())).rejects.toThrow(/rate limit/i);
182
- expect(storage.getInbox("alice")).toHaveLength(2);
183
- });
184
-
185
- it("NACKs inbound over the global limit and emits an event", async () => {
186
- const { gateway, events } = setup({
187
- rateLimit: { windowMs: 60_000, global: 1 },
188
- });
189
- const limited = capture(events, "mail.rate_limited");
190
-
191
- await gateway.handleInbound(
192
- makeInboundMail({
193
- from: { address: "a@one.org" },
194
- to: [{ address: "alice@example.com" }],
195
- })
196
- );
197
- await expect(
198
- gateway.handleInbound(
199
- makeInboundMail({
200
- from: { address: "b@two.org" },
201
- to: [{ address: "alice@example.com" }],
202
- })
203
- )
204
- ).rejects.toThrow(/rate limit/i);
205
- expect(limited).toEqual([expect.objectContaining({ scope: "global" })]);
206
- });
207
-
208
- it("does not rate limit when unconfigured", async () => {
209
- const { gateway, storage } = setup();
210
- for (let i = 0; i < 10; i++) {
211
- await gateway.handleInbound(
212
- makeInboundMail({
213
- to: [{ address: "alice@example.com" }],
214
- headers: { messageId: `<m${i}@x>`, raw: {} },
215
- })
216
- );
217
- }
218
- expect(storage.getInbox("alice")).toHaveLength(10);
219
- });
220
- });
221
-
222
- describe("MailGateway egress", () => {
223
- it("renders and delivers a message, marking recipients delivered", async () => {
224
- const { gateway, storage, transport, events } = setup();
225
- await transport.start();
226
- const sent = capture(events, "mail.sent");
227
- const msg = outboundMessage();
228
- storage.putMessage(msg);
229
-
230
- await gateway.send(msg);
231
-
232
- expect(transport.sent).toHaveLength(1);
233
- expect(transport.sent[0].to).toEqual([{ address: "user@external.org" }]);
234
- expect(transport.sent[0].from).toEqual({ address: "alice@example.com" });
235
- expect(storage.getMessage("M1")!.recipients[0].delivered_at).toBeTruthy();
236
- expect(sent).toHaveLength(1);
237
- // Outbound Message-ID recorded for later correlation.
238
- expect(storage.getMessageIdByExternalId("<M1@example.com>")).toBe("M1");
239
- });
240
-
241
- it("queues a transient failure and delivers it on retry", async () => {
242
- const { gateway, storage, transport, events } = setup();
243
- await transport.start();
244
- const queued = capture(events, "mail.queued");
245
- const msg = outboundMessage();
246
- storage.putMessage(msg);
247
-
248
- transport.programNextSend({ disposition: "transient", code: 451 });
249
- await gateway.send(msg);
250
-
251
- expect(transport.sent).toHaveLength(0);
252
- expect(gateway.queue.size("mail")).toBe(1);
253
- expect(queued).toHaveLength(1);
254
-
255
- // Retry — default outcome is delivered.
256
- await gateway.processRetries();
257
- expect(transport.sent).toHaveLength(1);
258
- expect(gateway.queue.size("mail")).toBe(0);
259
- });
260
-
261
- it("bounces a permanent failure and synthesizes an inbox notice", async () => {
262
- const { gateway, storage, events, transport } = setup();
263
- await transport.start();
264
- const bounced = capture(events, "mail.bounced");
265
- const msg = outboundMessage();
266
- storage.putMessage(msg);
267
-
268
- transport.programNextSend({
269
- disposition: "permanent",
270
- code: 550,
271
- detail: "no such user",
272
- });
273
-
274
- await gateway.send(msg);
275
-
276
- expect(bounced).toEqual([
277
- expect.objectContaining({ messageId: "M1", recipient: "user@external.org" }),
278
- ]);
279
- // A bounce notice landed in alice's inbox.
280
- const aliceInbox = storage.getInbox("alice");
281
- const notice = aliceInbox.find((m) => m.sender_id === "mailer-daemon");
282
- expect(notice).toBeTruthy();
283
- expect(notice!.importance).toBe("high");
284
- expect(notice!.in_reply_to).toBe("M1");
285
- });
286
-
287
- it("respects config-gated bounce suppression", async () => {
288
- const { gateway, storage, events, transport } = setup({
289
- bounce: { emitEvent: false, synthesizeInboxMessage: false },
290
- });
291
- await transport.start();
292
- const bounced = capture(events, "mail.bounced");
293
- const msg = outboundMessage();
294
- storage.putMessage(msg);
295
-
296
- transport.programNextSend({ disposition: "permanent", code: 550 });
297
- await gateway.send(msg);
298
-
299
- expect(bounced).toHaveLength(0);
300
- expect(storage.getInbox("alice")).toHaveLength(0);
301
- });
302
- });
303
-
304
- describe("MailGateway inbound DSN", () => {
305
- it("correlates an inbound bounce to the original message and notifies the sender", async () => {
306
- const { gateway, storage, events } = setup();
307
- const bounced = capture(events, "mail.bounced");
308
-
309
- // Simulate a prior send: original message + recorded RFC id.
310
- const original = outboundMessage({ id: "ORIG" });
311
- storage.putMessage(original);
312
- storage.recordExternalId("<ORIG@example.com>", "ORIG");
313
-
314
- await gateway.handleInbound(
315
- makeInboundMail({
316
- from: { address: "mailer-daemon@external.org" },
317
- to: [{ address: "alice@example.com" }],
318
- bounce: {
319
- action: "failed",
320
- recipient: "user@external.org",
321
- status: "5.1.1",
322
- originalMessageId: "<ORIG@example.com>",
323
- },
324
- })
325
- );
326
-
327
- expect(bounced).toEqual([
328
- expect.objectContaining({ messageId: "ORIG", status: "5.1.1" }),
329
- ]);
330
- const notice = storage
331
- .getInbox("alice")
332
- .find((m) => m.sender_id === "mailer-daemon");
333
- expect(notice).toBeTruthy();
334
- });
335
-
336
- it("treats a delayed DSN as queued, not bounced", async () => {
337
- const { gateway, storage, events } = setup();
338
- const bounced = capture(events, "mail.bounced");
339
- const queued = capture(events, "mail.queued");
340
-
341
- const original = outboundMessage({ id: "ORIG2" });
342
- storage.putMessage(original);
343
- storage.recordExternalId("<ORIG2@example.com>", "ORIG2");
344
-
345
- await gateway.handleInbound(
346
- makeInboundMail({
347
- to: [{ address: "alice@example.com" }],
348
- bounce: {
349
- action: "delayed",
350
- recipient: "user@external.org",
351
- originalMessageId: "<ORIG2@example.com>",
352
- },
353
- })
354
- );
355
-
356
- expect(bounced).toHaveLength(0);
357
- expect(queued).toEqual([
358
- expect.objectContaining({ messageId: "ORIG2", delayed: true }),
359
- ]);
360
- });
361
-
362
- it("dedups a replayed bounce — no repeated event or inbox notice (flood guard)", async () => {
363
- const { gateway, storage, events } = setup();
364
- const bounced = capture(events, "mail.bounced");
365
-
366
- const original = outboundMessage({ id: "ORIG3" });
367
- storage.putMessage(original);
368
- storage.recordExternalId("<ORIG3@example.com>", "ORIG3");
369
-
370
- const dsn = () =>
371
- makeInboundMail({
372
- to: [{ address: "alice@example.com" }],
373
- bounce: {
374
- action: "failed",
375
- recipient: "user@external.org",
376
- status: "5.1.1",
377
- originalMessageId: "<ORIG3@example.com>",
378
- },
379
- });
380
-
381
- await gateway.handleInbound(dsn());
382
- await gateway.handleInbound(dsn()); // replay
383
- await gateway.handleInbound(dsn()); // replay
384
-
385
- expect(bounced).toHaveLength(1);
386
- const notices = storage
387
- .getInbox("alice")
388
- .filter((m) => m.sender_id === "mailer-daemon");
389
- expect(notices).toHaveLength(1);
390
- });
391
- });
392
-
393
- describe("MailGateway durable retry exhaustion", () => {
394
- it("bounces instead of silently dropping when retryMaxAttempts is exhausted", async () => {
395
- const { gateway, storage, events, transport } = setup({
396
- queue: { retryMaxAttempts: 1 },
397
- });
398
- await transport.start();
399
- const bounced = capture(events, "mail.bounced");
400
- const msg = outboundMessage();
401
- storage.putMessage(msg);
402
-
403
- // Initial send fails transiently → queued (attempt 0).
404
- transport.programNextSend({ disposition: "transient", code: 451 });
405
- await gateway.send(msg);
406
- expect(gateway.queue.size("mail")).toBe(1);
407
-
408
- // Retry also fails transiently → attempts hit the cap → bounce, not drop.
409
- transport.programNextSend({ disposition: "transient", code: 451 });
410
- await gateway.processRetries();
411
-
412
- expect(gateway.queue.size("mail")).toBe(0);
413
- expect(bounced).toHaveLength(1);
414
- const notice = storage
415
- .getInbox("alice")
416
- .find((m) => m.sender_id === "mailer-daemon");
417
- expect(notice).toBeTruthy();
418
- });
419
- });