agent-inbox 0.2.2 → 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/bench/inbox-growth.bench.ts +224 -0
- 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 +29 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +124 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -0
- package/dist/jsonrpc/mail-push-types.d.ts +9 -0
- package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push-types.js +1 -0
- package/dist/jsonrpc/mail-push-types.js.map +1 -1
- package/dist/jsonrpc/mail-server.d.ts +8 -1
- package/dist/jsonrpc/mail-server.d.ts.map +1 -1
- package/dist/jsonrpc/mail-server.js +42 -1
- package/dist/jsonrpc/mail-server.js.map +1 -1
- 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/push/notifier.d.ts +21 -0
- package/dist/push/notifier.d.ts.map +1 -1
- package/dist/push/notifier.js +84 -2
- package/dist/push/notifier.js.map +1 -1
- 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 +21 -0
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +12 -0
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +50 -0
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +14 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +79 -1
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/traceability/traceability.d.ts.map +1 -1
- package/dist/traceability/traceability.js +7 -17
- package/dist/traceability/traceability.js.map +1 -1
- package/dist/types.d.ts +80 -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 +29 -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 +186 -1
- package/src/jsonrpc/mail-push-types.ts +10 -0
- package/src/jsonrpc/mail-server.ts +48 -1
- 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/push/notifier.ts +98 -2
- package/src/router/destination.ts +140 -0
- package/src/router/message-router.ts +41 -4
- package/src/storage/interface.ts +22 -0
- package/src/storage/memory.ts +59 -0
- package/src/storage/sqlite.ts +114 -1
- package/src/traceability/traceability.ts +7 -16
- package/src/types.ts +74 -0
- package/test/federation/delivery-queue-sqlite.test.ts +158 -0
- package/test/load.test.ts +288 -0
- 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
- package/test/mail-presence.test.ts +149 -0
- package/test/mail-push.test.ts +44 -0
- package/test/mail-server.test.ts +25 -0
- package/test/push-notifier.test.ts +81 -0
- package/test/sqlite-storage.test.ts +106 -0
- package/test/storage.test.ts +92 -0
- package/vitest.bench.config.ts +8 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { FsAttachmentStore } from "../../src/mail/fs-attachment-store.js";
|
|
6
|
+
|
|
7
|
+
describe("FsAttachmentStore", () => {
|
|
8
|
+
let dir: string;
|
|
9
|
+
let store: FsAttachmentStore;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), "att-"));
|
|
13
|
+
store = new FsAttachmentStore(dir);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("stores and retrieves bytes by ref", async () => {
|
|
21
|
+
const bytes = Buffer.from("hello filesystem attachment");
|
|
22
|
+
const ref = await store.put(bytes, { contentType: "text/plain" });
|
|
23
|
+
const got = await store.get(ref);
|
|
24
|
+
expect(got.equals(bytes)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("produces content-addressed (sha256:) refs", async () => {
|
|
28
|
+
const ref = await store.put(Buffer.from("x"), { contentType: "text/plain" });
|
|
29
|
+
expect(ref).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("dedups identical bytes to the same ref and a single file", async () => {
|
|
33
|
+
const a = await store.put(Buffer.from("same"), { contentType: "text/plain" });
|
|
34
|
+
const b = await store.put(Buffer.from("same"), { contentType: "text/plain" });
|
|
35
|
+
expect(a).toBe(b);
|
|
36
|
+
// One shard dir, one file in it.
|
|
37
|
+
const shards = await fs.readdir(dir);
|
|
38
|
+
expect(shards).toHaveLength(1);
|
|
39
|
+
const files = await fs.readdir(path.join(dir, shards[0]));
|
|
40
|
+
expect(files.filter((f) => !f.includes(".tmp-"))).toHaveLength(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("gives different refs for different bytes", async () => {
|
|
44
|
+
const a = await store.put(Buffer.from("one"), { contentType: "text/plain" });
|
|
45
|
+
const b = await store.put(Buffer.from("two"), { contentType: "text/plain" });
|
|
46
|
+
expect(a).not.toBe(b);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("shards files by the first byte of the hash", async () => {
|
|
50
|
+
const bytes = Buffer.from("shard me");
|
|
51
|
+
const ref = await store.put(bytes, { contentType: "text/plain" });
|
|
52
|
+
const hex = ref.slice("sha256:".length);
|
|
53
|
+
const expected = path.join(dir, hex.slice(0, 2), hex);
|
|
54
|
+
await expect(fs.access(expected)).resolves.toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("deletes by ref", async () => {
|
|
58
|
+
const ref = await store.put(Buffer.from("gone"), { contentType: "text/plain" });
|
|
59
|
+
await store.delete(ref);
|
|
60
|
+
await expect(store.get(ref)).rejects.toThrow(/not found/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("delete is a no-op for a missing ref", async () => {
|
|
64
|
+
const ref = "sha256:" + "a".repeat(64);
|
|
65
|
+
await expect(store.delete(ref)).resolves.toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("throws a clear error on a missing ref", async () => {
|
|
69
|
+
await expect(store.get("sha256:" + "b".repeat(64))).rejects.toThrow(/not found/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects malformed / path-traversal refs", async () => {
|
|
73
|
+
await expect(store.get("sha256:../../etc/passwd")).rejects.toThrow(/Invalid attachment ref/);
|
|
74
|
+
await expect(store.get("../../etc/passwd")).rejects.toThrow(/Invalid attachment ref/);
|
|
75
|
+
await expect(store.delete("sha256:..")).rejects.toThrow(/Invalid attachment ref/);
|
|
76
|
+
await expect(store.get("sha256:ABC")).rejects.toThrow(/Invalid attachment ref/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("persists across store instances over the same directory", async () => {
|
|
80
|
+
const ref = await store.put(Buffer.from("durable"), { contentType: "text/plain" });
|
|
81
|
+
const reopened = new FsAttachmentStore(dir);
|
|
82
|
+
const got = await reopened.get(ref);
|
|
83
|
+
expect(got.toString()).toBe("durable");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("handles concurrent puts of identical bytes without corruption", async () => {
|
|
87
|
+
const bytes = Buffer.from("racy-content");
|
|
88
|
+
const refs = await Promise.all(
|
|
89
|
+
Array.from({ length: 8 }, () => store.put(bytes, { contentType: "text/plain" }))
|
|
90
|
+
);
|
|
91
|
+
expect(new Set(refs).size).toBe(1);
|
|
92
|
+
expect((await store.get(refs[0])).equals(bytes)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("round-trips binary content exactly", async () => {
|
|
96
|
+
const bytes = Buffer.from([0x00, 0xff, 0x10, 0x7f, 0x80, 0x00, 0x01]);
|
|
97
|
+
const ref = await store.put(bytes, { contentType: "application/octet-stream" });
|
|
98
|
+
expect((await store.get(ref)).equals(bytes)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("gc", () => {
|
|
102
|
+
it("removes orphan files not in the live set", async () => {
|
|
103
|
+
const keep = await store.put(Buffer.from("keep"), { contentType: "text/plain" });
|
|
104
|
+
const drop = await store.put(Buffer.from("drop"), { contentType: "text/plain" });
|
|
105
|
+
|
|
106
|
+
// minAgeMs: 0 so just-written files are eligible (the default 1h grace
|
|
107
|
+
// period protects in-flight attachments from a concurrent gc).
|
|
108
|
+
const removed = await store.gc(new Set([keep]), 0);
|
|
109
|
+
expect(removed).toBe(1);
|
|
110
|
+
await expect(store.get(keep)).resolves.toBeTruthy();
|
|
111
|
+
await expect(store.get(drop)).rejects.toThrow(/not found/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not delete recently-written orphans within the grace period", async () => {
|
|
115
|
+
const drop = await store.put(Buffer.from("fresh-orphan"), { contentType: "text/plain" });
|
|
116
|
+
// Default grace period (1h) — the just-written file is too new to GC.
|
|
117
|
+
const removed = await store.gc(new Set());
|
|
118
|
+
expect(removed).toBe(0);
|
|
119
|
+
await expect(store.get(drop)).resolves.toBeTruthy();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("ignores stray non-directory entries at the root", async () => {
|
|
123
|
+
await store.put(Buffer.from("real"), { contentType: "text/plain" });
|
|
124
|
+
await fs.writeFile(path.join(dir, "stray.txt"), "junk");
|
|
125
|
+
// Should not throw on the stray file.
|
|
126
|
+
await expect(store.gc(new Set(), 0)).resolves.toBeTypeOf("number");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns 0 when the directory does not exist yet", async () => {
|
|
130
|
+
const fresh = new FsAttachmentStore(path.join(dir, "does-not-exist"));
|
|
131
|
+
expect(await fresh.gc(new Set())).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
});
|