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.
- package/AGENTS.md +18 -0
- package/CLAUDE.md +92 -1
- package/README.md +73 -6
- package/dist/federation/connection-manager.d.ts +8 -0
- package/dist/federation/connection-manager.d.ts.map +1 -1
- package/dist/federation/connection-manager.js +12 -0
- package/dist/federation/connection-manager.js.map +1 -1
- package/dist/federation/delivery-queue.d.ts +11 -3
- package/dist/federation/delivery-queue.d.ts.map +1 -1
- package/dist/federation/delivery-queue.js +38 -8
- package/dist/federation/delivery-queue.js.map +1 -1
- package/dist/federation/queue-store.d.ts +42 -0
- package/dist/federation/queue-store.d.ts.map +1 -0
- package/dist/federation/queue-store.js +87 -0
- package/dist/federation/queue-store.js.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +98 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mail/address-book.d.ts +43 -0
- package/dist/mail/address-book.d.ts.map +1 -0
- package/dist/mail/address-book.js +95 -0
- package/dist/mail/address-book.js.map +1 -0
- package/dist/mail/attachment-store.d.ts +31 -0
- package/dist/mail/attachment-store.d.ts.map +1 -0
- package/dist/mail/attachment-store.js +74 -0
- package/dist/mail/attachment-store.js.map +1 -0
- package/dist/mail/email-mapper.d.ts +41 -0
- package/dist/mail/email-mapper.d.ts.map +1 -0
- package/dist/mail/email-mapper.js +216 -0
- package/dist/mail/email-mapper.js.map +1 -0
- package/dist/mail/fs-attachment-store.d.ts +38 -0
- package/dist/mail/fs-attachment-store.d.ts.map +1 -0
- package/dist/mail/fs-attachment-store.js +165 -0
- package/dist/mail/fs-attachment-store.js.map +1 -0
- package/dist/mail/mail-gateway.d.ts +114 -0
- package/dist/mail/mail-gateway.d.ts.map +1 -0
- package/dist/mail/mail-gateway.js +402 -0
- package/dist/mail/mail-gateway.js.map +1 -0
- package/dist/mail/provider-transport.d.ts +138 -0
- package/dist/mail/provider-transport.d.ts.map +1 -0
- package/dist/mail/provider-transport.js +434 -0
- package/dist/mail/provider-transport.js.map +1 -0
- package/dist/mail/rate-limiter.d.ts +20 -0
- package/dist/mail/rate-limiter.d.ts.map +1 -0
- package/dist/mail/rate-limiter.js +56 -0
- package/dist/mail/rate-limiter.js.map +1 -0
- package/dist/mail/smtp-transport.d.ts +141 -0
- package/dist/mail/smtp-transport.d.ts.map +1 -0
- package/dist/mail/smtp-transport.js +415 -0
- package/dist/mail/smtp-transport.js.map +1 -0
- package/dist/mail/types.d.ts +177 -0
- package/dist/mail/types.d.ts.map +1 -0
- package/dist/mail/types.js +11 -0
- package/dist/mail/types.js.map +1 -0
- package/dist/router/destination.d.ts +69 -0
- package/dist/router/destination.d.ts.map +1 -0
- package/dist/router/destination.js +106 -0
- package/dist/router/destination.js.map +1 -0
- package/dist/router/message-router.d.ts +15 -0
- package/dist/router/message-router.d.ts.map +1 -1
- package/dist/router/message-router.js +25 -3
- package/dist/router/message-router.js.map +1 -1
- package/dist/storage/interface.d.ts +9 -0
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +4 -0
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +12 -0
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +6 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +28 -0
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/DESIGN.md +15 -0
- package/docs/MAIL-INTEROP-PLAN.md +660 -0
- package/package.json +28 -3
- package/renovate.json5 +6 -0
- package/rules/agent-inbox.md +1 -0
- package/src/federation/connection-manager.ts +12 -0
- package/src/federation/delivery-queue.ts +38 -8
- package/src/federation/queue-store.ts +124 -0
- package/src/index.ts +148 -0
- package/src/mail/address-book.ts +111 -0
- package/src/mail/attachment-store.ts +90 -0
- package/src/mail/email-mapper.ts +288 -0
- package/src/mail/fs-attachment-store.ts +163 -0
- package/src/mail/mail-gateway.ts +505 -0
- package/src/mail/provider-transport.ts +577 -0
- package/src/mail/rate-limiter.ts +51 -0
- package/src/mail/smtp-transport.ts +589 -0
- package/src/mail/types.ts +221 -0
- package/src/router/destination.ts +140 -0
- package/src/router/message-router.ts +41 -4
- package/src/storage/interface.ts +11 -0
- package/src/storage/memory.ts +15 -0
- package/src/storage/sqlite.ts +36 -0
- package/src/types.ts +73 -0
- package/test/federation/delivery-queue-sqlite.test.ts +158 -0
- package/test/load.test.ts +1 -1
- package/test/mail/address-book.test.ts +111 -0
- package/test/mail/attachment-store-contract.test.ts +92 -0
- package/test/mail/attachment-store.test.ts +69 -0
- package/test/mail/destination.test.ts +115 -0
- package/test/mail/dsn-parse.test.ts +239 -0
- package/test/mail/email-mapper.test.ts +341 -0
- package/test/mail/external-id.test.ts +43 -0
- package/test/mail/fs-attachment-store.test.ts +134 -0
- package/test/mail/full-flow-e2e.test.ts +200 -0
- package/test/mail/mail-gateway.test.ts +419 -0
- package/test/mail/mail-transport-contract.test.ts +134 -0
- package/test/mail/mock-mail.ts +161 -0
- package/test/mail/mock-postmark.ts +66 -0
- package/test/mail/provider-transport.test.ts +381 -0
- package/test/mail/rate-limiter.test.ts +48 -0
- package/test/mail/router-mail-integration.test.ts +138 -0
- package/test/mail/smtp-e2e.test.ts +98 -0
- package/test/mail/smtp-transport.test.ts +138 -0
|
@@ -0,0 +1,419 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared MailTransport contract suite.
|
|
3
|
+
*
|
|
4
|
+
* Any backend that provides a MailTransportHarness must pass this suite
|
|
5
|
+
* unchanged — it encodes the hardening guarantees from §2/§3 of the plan.
|
|
6
|
+
* Run it against new backends by adding another `runMailTransportContract(...)`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import type { MailTransportHarness } from "./mock-mail.js";
|
|
11
|
+
import { mockMailHarness, makeInboundMail, makeOutboundMail } from "./mock-mail.js";
|
|
12
|
+
import { postmarkHarness } from "./mock-postmark.js";
|
|
13
|
+
|
|
14
|
+
export function runMailTransportContract(
|
|
15
|
+
name: string,
|
|
16
|
+
makeHarness: () => MailTransportHarness
|
|
17
|
+
): void {
|
|
18
|
+
describe(`MailTransport contract: ${name}`, () => {
|
|
19
|
+
let h: MailTransportHarness;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
h = makeHarness();
|
|
23
|
+
await h.transport.start();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await h.transport.stop();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("start() leaves the transport in a ready state", async () => {
|
|
31
|
+
expect(h.transport.state).toBe("ready");
|
|
32
|
+
expect((await h.transport.health()).state).toBe("ready");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("start() is idempotent", async () => {
|
|
36
|
+
await h.transport.start();
|
|
37
|
+
await h.transport.start();
|
|
38
|
+
expect(h.transport.state).toBe("ready");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("send() returns delivered (never throws) on success", async () => {
|
|
42
|
+
h.programNextSend({ disposition: "delivered", remoteMessageId: "x" });
|
|
43
|
+
const res = await h.transport.send(makeOutboundMail());
|
|
44
|
+
expect(res.disposition).toBe("delivered");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("send() returns — does not throw — on transient failure", async () => {
|
|
48
|
+
h.programNextSend({ disposition: "transient", code: 451 });
|
|
49
|
+
const res = await h.transport.send(makeOutboundMail());
|
|
50
|
+
expect(res.disposition).toBe("transient");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("send() returns — does not throw — on permanent failure", async () => {
|
|
54
|
+
h.programNextSend({ disposition: "permanent", code: 550 });
|
|
55
|
+
const res = await h.transport.send(makeOutboundMail());
|
|
56
|
+
expect(res.disposition).toBe("permanent");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("is idempotent on idempotencyKey: a duplicate delivered send produces no second email", async () => {
|
|
60
|
+
const env = makeOutboundMail({ idempotencyKey: "dup-1" });
|
|
61
|
+
const first = await h.transport.send(env);
|
|
62
|
+
const second = await h.transport.send(env);
|
|
63
|
+
expect(first.disposition).toBe("delivered");
|
|
64
|
+
expect(second.disposition).toBe("delivered");
|
|
65
|
+
// Both report delivered, but the second is a replay, not a new email.
|
|
66
|
+
expect(second.detail).toMatch(/idempotent|replay/i);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ACKs inbound only after the handler resolves", async () => {
|
|
70
|
+
const order: string[] = [];
|
|
71
|
+
h.transport.onReceive(async () => {
|
|
72
|
+
order.push("handler-start");
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
order.push("handler-end");
|
|
75
|
+
});
|
|
76
|
+
await h.injectInbound(makeInboundMail());
|
|
77
|
+
// injectInbound resolving (the ACK) must come strictly after handler-end.
|
|
78
|
+
expect(order).toEqual(["handler-start", "handler-end"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("NACKs inbound (rejects) when the handler throws", async () => {
|
|
82
|
+
h.transport.onReceive(async () => {
|
|
83
|
+
throw new Error("storage commit failed");
|
|
84
|
+
});
|
|
85
|
+
await expect(h.injectInbound(makeInboundMail())).rejects.toThrow(
|
|
86
|
+
/storage commit failed/
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("delivers the inbound message to the handler intact", async () => {
|
|
91
|
+
let received: unknown;
|
|
92
|
+
h.transport.onReceive(async (mail) => {
|
|
93
|
+
received = mail;
|
|
94
|
+
});
|
|
95
|
+
const mail = makeInboundMail({ subject: "specific-subject" });
|
|
96
|
+
await h.injectInbound(mail);
|
|
97
|
+
expect((received as { subject: string }).subject).toBe("specific-subject");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Run the contract against the in-process mock and the Postmark provider
|
|
103
|
+
// backend (via a fake client). Real SMTP I/O is covered by smtp-e2e.test.ts.
|
|
104
|
+
runMailTransportContract("MockMailTransport", () => mockMailHarness());
|
|
105
|
+
runMailTransportContract("PostmarkTransport", () => postmarkHarness());
|
|
106
|
+
|
|
107
|
+
describe("MockMailTransport specifics", () => {
|
|
108
|
+
let h: ReturnType<typeof mockMailHarness>;
|
|
109
|
+
|
|
110
|
+
beforeEach(async () => {
|
|
111
|
+
h = mockMailHarness();
|
|
112
|
+
await h.transport.start();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("send() throws (programmer error) when not started", async () => {
|
|
116
|
+
const fresh = mockMailHarness();
|
|
117
|
+
await expect(fresh.transport.send(makeOutboundMail())).rejects.toThrow(
|
|
118
|
+
/state/
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("records one entry in sent per delivered email and dedups replays", async () => {
|
|
123
|
+
const env = makeOutboundMail({ idempotencyKey: "k1" });
|
|
124
|
+
await h.transport.send(env);
|
|
125
|
+
await h.transport.send(env);
|
|
126
|
+
expect(h.mock.sent).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("does not record sent for a transient failure", async () => {
|
|
130
|
+
h.programNextSend({ disposition: "transient" });
|
|
131
|
+
await h.transport.send(makeOutboundMail({ idempotencyKey: "k2" }));
|
|
132
|
+
expect(h.mock.sent).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
});
|