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.
- package/CLAUDE.md +1 -92
- package/README.md +6 -73
- package/dist/federation/connection-manager.d.ts +0 -8
- package/dist/federation/connection-manager.d.ts.map +1 -1
- package/dist/federation/connection-manager.js +0 -12
- package/dist/federation/connection-manager.js.map +1 -1
- package/dist/federation/delivery-queue.d.ts +3 -11
- package/dist/federation/delivery-queue.d.ts.map +1 -1
- package/dist/federation/delivery-queue.js +8 -38
- package/dist/federation/delivery-queue.js.map +1 -1
- package/dist/index.d.ts +0 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -98
- package/dist/index.js.map +1 -1
- package/dist/jsonrpc/mail-push-types.d.ts +22 -2
- package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push-types.js +18 -1
- package/dist/jsonrpc/mail-push-types.js.map +1 -1
- package/dist/jsonrpc/mail-push.d.ts +12 -1
- package/dist/jsonrpc/mail-push.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push.js +13 -2
- package/dist/jsonrpc/mail-push.js.map +1 -1
- package/dist/jsonrpc/mail-server.d.ts.map +1 -1
- package/dist/jsonrpc/mail-server.js +42 -18
- package/dist/jsonrpc/mail-server.js.map +1 -1
- package/dist/router/message-router.d.ts +0 -15
- package/dist/router/message-router.d.ts.map +1 -1
- package/dist/router/message-router.js +3 -25
- package/dist/router/message-router.js.map +1 -1
- package/dist/storage/interface.d.ts +2 -9
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +1 -4
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +6 -12
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +1 -6
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +6 -28
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types.d.ts +0 -79
- package/dist/types.d.ts.map +1 -1
- package/docs/DESIGN.md +0 -15
- package/package.json +3 -28
- package/rules/agent-inbox.md +0 -1
- package/src/federation/connection-manager.ts +0 -12
- package/src/federation/delivery-queue.ts +8 -38
- package/src/index.ts +0 -148
- package/src/jsonrpc/mail-push-types.ts +43 -2
- package/src/jsonrpc/mail-push.ts +34 -3
- package/src/jsonrpc/mail-server.ts +45 -26
- package/src/router/message-router.ts +4 -41
- package/src/storage/interface.ts +2 -11
- package/src/storage/memory.ts +8 -15
- package/src/storage/sqlite.ts +9 -36
- package/src/types.ts +0 -73
- package/test/load.test.ts +1 -1
- package/test/mail-push.test.ts +101 -1
- package/test/mail-server.test.ts +108 -0
- package/AGENTS.md +0 -18
- package/dist/federation/queue-store.d.ts +0 -42
- package/dist/federation/queue-store.d.ts.map +0 -1
- package/dist/federation/queue-store.js +0 -87
- package/dist/federation/queue-store.js.map +0 -1
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/mail/address-book.d.ts +0 -43
- package/dist/mail/address-book.d.ts.map +0 -1
- package/dist/mail/address-book.js +0 -95
- package/dist/mail/address-book.js.map +0 -1
- package/dist/mail/attachment-store.d.ts +0 -31
- package/dist/mail/attachment-store.d.ts.map +0 -1
- package/dist/mail/attachment-store.js +0 -74
- package/dist/mail/attachment-store.js.map +0 -1
- package/dist/mail/email-mapper.d.ts +0 -41
- package/dist/mail/email-mapper.d.ts.map +0 -1
- package/dist/mail/email-mapper.js +0 -216
- package/dist/mail/email-mapper.js.map +0 -1
- package/dist/mail/fs-attachment-store.d.ts +0 -38
- package/dist/mail/fs-attachment-store.d.ts.map +0 -1
- package/dist/mail/fs-attachment-store.js +0 -165
- package/dist/mail/fs-attachment-store.js.map +0 -1
- package/dist/mail/mail-gateway.d.ts +0 -114
- package/dist/mail/mail-gateway.d.ts.map +0 -1
- package/dist/mail/mail-gateway.js +0 -402
- package/dist/mail/mail-gateway.js.map +0 -1
- package/dist/mail/provider-transport.d.ts +0 -138
- package/dist/mail/provider-transport.d.ts.map +0 -1
- package/dist/mail/provider-transport.js +0 -434
- package/dist/mail/provider-transport.js.map +0 -1
- package/dist/mail/rate-limiter.d.ts +0 -20
- package/dist/mail/rate-limiter.d.ts.map +0 -1
- package/dist/mail/rate-limiter.js +0 -56
- package/dist/mail/rate-limiter.js.map +0 -1
- package/dist/mail/smtp-transport.d.ts +0 -141
- package/dist/mail/smtp-transport.d.ts.map +0 -1
- package/dist/mail/smtp-transport.js +0 -415
- package/dist/mail/smtp-transport.js.map +0 -1
- package/dist/mail/types.d.ts +0 -177
- package/dist/mail/types.d.ts.map +0 -1
- package/dist/mail/types.js +0 -11
- package/dist/mail/types.js.map +0 -1
- package/dist/router/destination.d.ts +0 -69
- package/dist/router/destination.d.ts.map +0 -1
- package/dist/router/destination.js +0 -106
- package/dist/router/destination.js.map +0 -1
- package/docs/MAIL-INTEROP-PLAN.md +0 -660
- package/renovate.json5 +0 -6
- package/src/federation/queue-store.ts +0 -124
- package/src/mail/address-book.ts +0 -111
- package/src/mail/attachment-store.ts +0 -90
- package/src/mail/email-mapper.ts +0 -288
- package/src/mail/fs-attachment-store.ts +0 -163
- package/src/mail/mail-gateway.ts +0 -505
- package/src/mail/provider-transport.ts +0 -577
- package/src/mail/rate-limiter.ts +0 -51
- package/src/mail/smtp-transport.ts +0 -589
- package/src/mail/types.ts +0 -221
- package/src/router/destination.ts +0 -140
- package/test/federation/delivery-queue-sqlite.test.ts +0 -158
- package/test/mail/address-book.test.ts +0 -111
- package/test/mail/attachment-store-contract.test.ts +0 -92
- package/test/mail/attachment-store.test.ts +0 -69
- package/test/mail/destination.test.ts +0 -115
- package/test/mail/dsn-parse.test.ts +0 -239
- package/test/mail/email-mapper.test.ts +0 -341
- package/test/mail/external-id.test.ts +0 -43
- package/test/mail/fs-attachment-store.test.ts +0 -134
- package/test/mail/full-flow-e2e.test.ts +0 -200
- package/test/mail/mail-gateway.test.ts +0 -419
- package/test/mail/mail-transport-contract.test.ts +0 -134
- package/test/mail/mock-mail.ts +0 -161
- package/test/mail/mock-postmark.ts +0 -66
- package/test/mail/provider-transport.test.ts +0 -381
- package/test/mail/rate-limiter.test.ts +0 -48
- package/test/mail/router-mail-integration.test.ts +0 -138
- package/test/mail/smtp-e2e.test.ts +0 -98
- 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
|
-
});
|