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,138 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { InMemoryStorage } from "../../src/storage/memory.js";
|
|
4
|
+
import {
|
|
5
|
+
MessageRouter,
|
|
6
|
+
type MailEgress,
|
|
7
|
+
} from "../../src/router/message-router.js";
|
|
8
|
+
import { MailGateway } from "../../src/mail/mail-gateway.js";
|
|
9
|
+
import { MockMailTransport } from "./mock-mail.js";
|
|
10
|
+
import type { Agent, Message } from "../../src/types.js";
|
|
11
|
+
|
|
12
|
+
function agent(id: string): Agent {
|
|
13
|
+
return {
|
|
14
|
+
agent_id: id,
|
|
15
|
+
scope: "default",
|
|
16
|
+
status: "active",
|
|
17
|
+
metadata: {},
|
|
18
|
+
registered_at: new Date().toISOString(),
|
|
19
|
+
last_active_at: new Date().toISOString(),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
24
|
+
|
|
25
|
+
describe("router ↔ mail integration", () => {
|
|
26
|
+
let storage: InMemoryStorage;
|
|
27
|
+
let events: EventEmitter;
|
|
28
|
+
let router: MessageRouter;
|
|
29
|
+
let transport: MockMailTransport;
|
|
30
|
+
let gateway: MailGateway;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
storage = new InMemoryStorage();
|
|
34
|
+
events = new EventEmitter();
|
|
35
|
+
router = new MessageRouter(storage, events);
|
|
36
|
+
storage.putAgent(agent("alice"));
|
|
37
|
+
storage.putAgent(agent("bob"));
|
|
38
|
+
transport = new MockMailTransport();
|
|
39
|
+
await transport.start();
|
|
40
|
+
gateway = new MailGateway({
|
|
41
|
+
transport,
|
|
42
|
+
storage,
|
|
43
|
+
router,
|
|
44
|
+
events,
|
|
45
|
+
config: {
|
|
46
|
+
identity: {
|
|
47
|
+
localDomains: ["example.com"],
|
|
48
|
+
mappings: [{ agentId: "alice", address: "alice@example.com" }],
|
|
49
|
+
},
|
|
50
|
+
routableDomains: ["external.org"],
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
router.setMail(gateway);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("routes external mail recipients to the gateway, not local delivery", async () => {
|
|
57
|
+
const msg = await router.routeMessage({
|
|
58
|
+
from: "alice",
|
|
59
|
+
to: [{ agent_id: "user@external.org", kind: "to" }],
|
|
60
|
+
payload: "hello world",
|
|
61
|
+
});
|
|
62
|
+
await tick();
|
|
63
|
+
|
|
64
|
+
expect(transport.sent).toHaveLength(1);
|
|
65
|
+
expect(transport.sent[0].to).toEqual([{ address: "user@external.org" }]);
|
|
66
|
+
// The mail recipient is not treated as a delivered local agent.
|
|
67
|
+
const stored = storage.getMessage(msg.id)!;
|
|
68
|
+
expect(stored.recipients[0].delivered_at).toBeTruthy(); // set by gateway on send
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("delivers local recipients locally and mail recipients via the gateway", async () => {
|
|
72
|
+
await router.routeMessage({
|
|
73
|
+
from: "alice",
|
|
74
|
+
to: [
|
|
75
|
+
{ agent_id: "bob", kind: "to" },
|
|
76
|
+
{ agent_id: "user@external.org", kind: "to" },
|
|
77
|
+
],
|
|
78
|
+
payload: "mixed",
|
|
79
|
+
});
|
|
80
|
+
await tick();
|
|
81
|
+
|
|
82
|
+
expect(storage.getInbox("bob")).toHaveLength(1);
|
|
83
|
+
expect(transport.sent).toHaveLength(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not send local-only messages through mail", async () => {
|
|
87
|
+
await router.routeMessage({
|
|
88
|
+
from: "alice",
|
|
89
|
+
to: [{ agent_id: "bob", kind: "to" }],
|
|
90
|
+
payload: "local only",
|
|
91
|
+
});
|
|
92
|
+
await tick();
|
|
93
|
+
expect(transport.sent).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("sends non-mail remote recipients to federation, not mail", async () => {
|
|
97
|
+
const routed: Message[] = [];
|
|
98
|
+
const fakeFederation = {
|
|
99
|
+
route: async (m: Message) => {
|
|
100
|
+
routed.push(m);
|
|
101
|
+
return { delivered: false };
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
// Cast: the router only calls .route() on the federation handle.
|
|
105
|
+
router.setFederation(fakeFederation as never);
|
|
106
|
+
|
|
107
|
+
await router.routeMessage({
|
|
108
|
+
from: "alice",
|
|
109
|
+
to: [{ agent_id: "carol@system-2", kind: "to" }],
|
|
110
|
+
payload: "federated",
|
|
111
|
+
});
|
|
112
|
+
await tick();
|
|
113
|
+
|
|
114
|
+
expect(routed).toHaveLength(1);
|
|
115
|
+
expect(transport.sent).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("ownsRecipient distinguishes mail domains from federation systems", () => {
|
|
119
|
+
expect(gateway.ownsRecipient("user@external.org")).toBe(true);
|
|
120
|
+
expect(gateway.ownsRecipient("user@example.com")).toBe(true);
|
|
121
|
+
expect(gateway.ownsRecipient("carol@system-2")).toBe(false);
|
|
122
|
+
expect(gateway.ownsRecipient("bob")).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("MailEgress contract shape", () => {
|
|
127
|
+
it("MailGateway satisfies MailEgress", () => {
|
|
128
|
+
const eg: MailEgress = new MailGateway({
|
|
129
|
+
transport: new MockMailTransport(),
|
|
130
|
+
storage: new InMemoryStorage(),
|
|
131
|
+
router: new MessageRouter(new InMemoryStorage(), new EventEmitter()),
|
|
132
|
+
events: new EventEmitter(),
|
|
133
|
+
config: { identity: { localDomains: ["x.com"], mappings: [] } },
|
|
134
|
+
});
|
|
135
|
+
expect(typeof eg.ownsRecipient).toBe("function");
|
|
136
|
+
expect(typeof eg.send).toBe("function");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { SmtpTransport } from "../../src/mail/smtp-transport.js";
|
|
3
|
+
import { makeOutboundMail } from "./mock-mail.js";
|
|
4
|
+
import type { InboundMail } from "../../src/mail/types.js";
|
|
5
|
+
|
|
6
|
+
// These libs are optional peer deps; skip the real-socket e2e if absent.
|
|
7
|
+
const haveLibs = await Promise.all([
|
|
8
|
+
import("smtp-server").then(() => true).catch(() => false),
|
|
9
|
+
import("nodemailer").then(() => true).catch(() => false),
|
|
10
|
+
import("mailparser").then(() => true).catch(() => false),
|
|
11
|
+
]).then((r) => r.every(Boolean));
|
|
12
|
+
|
|
13
|
+
const randomPort = () => 30000 + Math.floor(Math.random() * 5000);
|
|
14
|
+
|
|
15
|
+
describe.skipIf(!haveLibs)("SmtpTransport e2e (loopback)", () => {
|
|
16
|
+
const transports: SmtpTransport[] = [];
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
for (const t of transports) await t.stop().catch(() => {});
|
|
19
|
+
transports.length = 0;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("delivers a message from one transport to another and ACKs after commit", async () => {
|
|
23
|
+
const recvPort = randomPort();
|
|
24
|
+
|
|
25
|
+
// Receiver: captures inbound and resolves only after a simulated commit.
|
|
26
|
+
const received: InboundMail[] = [];
|
|
27
|
+
const committed: string[] = [];
|
|
28
|
+
const receiver = new SmtpTransport({ listenPort: recvPort });
|
|
29
|
+
receiver.onReceive(async (mail) => {
|
|
30
|
+
received.push(mail);
|
|
31
|
+
await new Promise((r) => setTimeout(r, 5)); // simulate async storage commit
|
|
32
|
+
committed.push("done");
|
|
33
|
+
});
|
|
34
|
+
transports.push(receiver);
|
|
35
|
+
await receiver.start();
|
|
36
|
+
|
|
37
|
+
// Sender: listens on a different port, relays to the receiver.
|
|
38
|
+
const sender = new SmtpTransport({
|
|
39
|
+
listenPort: randomPort(),
|
|
40
|
+
relay: { host: "127.0.0.1", port: recvPort, secure: false },
|
|
41
|
+
});
|
|
42
|
+
transports.push(sender);
|
|
43
|
+
await sender.start();
|
|
44
|
+
|
|
45
|
+
const result = await sender.send(
|
|
46
|
+
makeOutboundMail({
|
|
47
|
+
idempotencyKey: "e2e-1",
|
|
48
|
+
from: { address: "alice@example.com" },
|
|
49
|
+
to: [{ address: "bob@receiver.test" }],
|
|
50
|
+
subject: "e2e subject",
|
|
51
|
+
text: "e2e body",
|
|
52
|
+
headers: { messageId: "<e2e-1@example.com>" },
|
|
53
|
+
})
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(result.disposition).toBe("delivered");
|
|
57
|
+
|
|
58
|
+
// Allow the receiver's onData/handler to run.
|
|
59
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
60
|
+
|
|
61
|
+
expect(received).toHaveLength(1);
|
|
62
|
+
expect(received[0].subject).toBe("e2e subject");
|
|
63
|
+
expect(received[0].text?.trim()).toBe("e2e body");
|
|
64
|
+
expect(received[0].headers.messageId).toBe("<e2e-1@example.com>");
|
|
65
|
+
// The handler's commit ran (ACK happened after it).
|
|
66
|
+
expect(committed).toEqual(["done"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("is idempotent on the sender for a repeated idempotencyKey", async () => {
|
|
70
|
+
const recvPort = randomPort();
|
|
71
|
+
const received: InboundMail[] = [];
|
|
72
|
+
const receiver = new SmtpTransport({ listenPort: recvPort });
|
|
73
|
+
receiver.onReceive(async (mail) => {
|
|
74
|
+
received.push(mail);
|
|
75
|
+
});
|
|
76
|
+
transports.push(receiver);
|
|
77
|
+
await receiver.start();
|
|
78
|
+
|
|
79
|
+
const sender = new SmtpTransport({
|
|
80
|
+
listenPort: randomPort(),
|
|
81
|
+
relay: { host: "127.0.0.1", port: recvPort, secure: false },
|
|
82
|
+
});
|
|
83
|
+
transports.push(sender);
|
|
84
|
+
await sender.start();
|
|
85
|
+
|
|
86
|
+
const env = makeOutboundMail({
|
|
87
|
+
idempotencyKey: "dup-key",
|
|
88
|
+
to: [{ address: "bob@receiver.test" }],
|
|
89
|
+
headers: { messageId: "<dup-key@example.com>" },
|
|
90
|
+
});
|
|
91
|
+
await sender.send(env);
|
|
92
|
+
const second = await sender.send(env);
|
|
93
|
+
|
|
94
|
+
expect(second.detail).toMatch(/idempotent|replay/i);
|
|
95
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
96
|
+
expect(received).toHaveLength(1); // not delivered twice
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
smtpErrorToResult,
|
|
4
|
+
parsedToInboundMail,
|
|
5
|
+
toNodemailerMessage,
|
|
6
|
+
mailauthToResults,
|
|
7
|
+
} from "../../src/mail/smtp-transport.js";
|
|
8
|
+
import { makeOutboundMail } from "./mock-mail.js";
|
|
9
|
+
|
|
10
|
+
describe("smtpErrorToResult", () => {
|
|
11
|
+
it("maps 5xx to permanent", () => {
|
|
12
|
+
expect(smtpErrorToResult({ responseCode: 550, response: "no user" })).toEqual({
|
|
13
|
+
disposition: "permanent",
|
|
14
|
+
code: 550,
|
|
15
|
+
detail: "no user",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("maps 4xx to transient", () => {
|
|
20
|
+
expect(smtpErrorToResult({ responseCode: 451 }).disposition).toBe("transient");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("maps connection/DNS errors (no code) to transient", () => {
|
|
24
|
+
expect(smtpErrorToResult({ message: "ECONNREFUSED" })).toEqual({
|
|
25
|
+
disposition: "transient",
|
|
26
|
+
detail: "ECONNREFUSED",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("parsedToInboundMail", () => {
|
|
32
|
+
it("builds an InboundMail from parsed fields", () => {
|
|
33
|
+
const mail = parsedToInboundMail(
|
|
34
|
+
{
|
|
35
|
+
from: { value: [{ address: "ext@other.org", name: "Ext" }] },
|
|
36
|
+
to: { value: [{ address: "alice@example.com" }] },
|
|
37
|
+
cc: { value: [{ address: "bob@example.com" }] },
|
|
38
|
+
subject: "hi",
|
|
39
|
+
text: "body",
|
|
40
|
+
messageId: "<m1@other.org>",
|
|
41
|
+
inReplyTo: "<p0@other.org>",
|
|
42
|
+
references: "<root@other.org> <p0@other.org>",
|
|
43
|
+
date: new Date("2026-01-01T00:00:00Z"),
|
|
44
|
+
headers: new Map([["x-priority", "1"]]),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
envelopeFrom: "ext@other.org",
|
|
48
|
+
envelopeTo: ["alice@example.com"],
|
|
49
|
+
remote: { ip: "203.0.113.5" },
|
|
50
|
+
sizeBytes: 200,
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(mail.from).toEqual({ address: "ext@other.org", name: "Ext" });
|
|
55
|
+
expect(mail.to).toEqual([{ address: "alice@example.com", name: undefined }]);
|
|
56
|
+
expect(mail.cc).toEqual([{ address: "bob@example.com", name: undefined }]);
|
|
57
|
+
expect(mail.headers.messageId).toBe("<m1@other.org>");
|
|
58
|
+
expect(mail.headers.references).toEqual([
|
|
59
|
+
"<root@other.org>",
|
|
60
|
+
"<p0@other.org>",
|
|
61
|
+
]);
|
|
62
|
+
expect(mail.headers.raw["x-priority"]).toBe("1");
|
|
63
|
+
expect(mail.remote).toEqual({ ip: "203.0.113.5" });
|
|
64
|
+
expect(mail.receivedAt).toBe("2026-01-01T00:00:00.000Z");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("falls back to the envelope sender when From is missing", () => {
|
|
68
|
+
const mail = parsedToInboundMail(
|
|
69
|
+
{ to: { value: [{ address: "alice@example.com" }] } },
|
|
70
|
+
{ envelopeFrom: "bounce@mta.net", envelopeTo: [], sizeBytes: 1 }
|
|
71
|
+
);
|
|
72
|
+
expect(mail.from).toEqual({ address: "bounce@mta.net" });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("toNodemailerMessage", () => {
|
|
77
|
+
it("maps an OutboundMail to nodemailer fields", () => {
|
|
78
|
+
const msg = toNodemailerMessage(
|
|
79
|
+
makeOutboundMail({
|
|
80
|
+
from: { address: "alice@example.com", name: "Alice" },
|
|
81
|
+
to: [{ address: "ext@other.org" }],
|
|
82
|
+
cc: [{ address: "c@other.org" }],
|
|
83
|
+
subject: "subject",
|
|
84
|
+
text: "text body",
|
|
85
|
+
headers: {
|
|
86
|
+
messageId: "<m@example.com>",
|
|
87
|
+
inReplyTo: "<p@other.org>",
|
|
88
|
+
references: ["<r@other.org>"],
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
expect(msg.from).toEqual({ name: "Alice", address: "alice@example.com" });
|
|
93
|
+
expect(msg.to).toEqual(["ext@other.org"]);
|
|
94
|
+
expect(msg.cc).toEqual(["c@other.org"]);
|
|
95
|
+
expect(msg.messageId).toBe("<m@example.com>");
|
|
96
|
+
expect(msg.inReplyTo).toBe("<p@other.org>");
|
|
97
|
+
expect(msg.references).toEqual(["<r@other.org>"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("includes dkim options when provided", () => {
|
|
101
|
+
const msg = toNodemailerMessage(makeOutboundMail(), {
|
|
102
|
+
domainName: "example.com",
|
|
103
|
+
keySelector: "s1",
|
|
104
|
+
privateKey: "KEY",
|
|
105
|
+
});
|
|
106
|
+
expect(msg.dkim).toEqual({
|
|
107
|
+
domainName: "example.com",
|
|
108
|
+
keySelector: "s1",
|
|
109
|
+
privateKey: "KEY",
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("mailauthToResults", () => {
|
|
115
|
+
it("normalizes a full pass verdict", () => {
|
|
116
|
+
expect(
|
|
117
|
+
mailauthToResults({
|
|
118
|
+
spf: { status: { result: "pass" } },
|
|
119
|
+
dkim: { results: [{ status: { result: "pass" } }] },
|
|
120
|
+
dmarc: { status: { result: "pass" } },
|
|
121
|
+
})
|
|
122
|
+
).toEqual({ spf: "pass", dkim: "pass", dmarc: "pass" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("defaults missing verdicts to none", () => {
|
|
126
|
+
expect(mailauthToResults({})).toEqual({
|
|
127
|
+
spf: "none",
|
|
128
|
+
dkim: "none",
|
|
129
|
+
dmarc: "none",
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("maps dmarc fail through", () => {
|
|
134
|
+
expect(
|
|
135
|
+
mailauthToResults({ dmarc: { status: { result: "fail" } } }).dmarc
|
|
136
|
+
).toBe("fail");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the mail/presence JSON-RPC method.
|
|
3
|
+
*
|
|
4
|
+
* Verifies participant presence resolution from an optional registry,
|
|
5
|
+
* error handling for missing conversations, and fallback to 'unknown'
|
|
6
|
+
* when no registry is provided.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
import { InMemoryStorage } from "../src/storage/memory.js";
|
|
12
|
+
import { MessageRouter } from "../src/router/message-router.js";
|
|
13
|
+
import { MailJsonRpcServer } from "../src/jsonrpc/mail-server.js";
|
|
14
|
+
|
|
15
|
+
describe("mail/presence", () => {
|
|
16
|
+
let storage: InMemoryStorage;
|
|
17
|
+
let events: EventEmitter;
|
|
18
|
+
let router: MessageRouter;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
storage = new InMemoryStorage();
|
|
22
|
+
events = new EventEmitter();
|
|
23
|
+
router = new MessageRouter(storage, events, "default");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function rpc(server: MailJsonRpcServer, method: string, params: Record<string, unknown> = {}) {
|
|
27
|
+
return server.handleRequest({
|
|
28
|
+
jsonrpc: "2.0",
|
|
29
|
+
id: 1,
|
|
30
|
+
method,
|
|
31
|
+
params,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function seedConversationWithParticipants(server: MailJsonRpcServer) {
|
|
36
|
+
const createResp = await rpc(server, "mail/create", {
|
|
37
|
+
subject: "Dispatch thread",
|
|
38
|
+
scope: "dispatch-thread",
|
|
39
|
+
});
|
|
40
|
+
const convId = (createResp.result as { id: string }).id;
|
|
41
|
+
|
|
42
|
+
await rpc(server, "mail/invite", {
|
|
43
|
+
conversationId: convId,
|
|
44
|
+
agentId: "user_abc",
|
|
45
|
+
role: "initiator",
|
|
46
|
+
});
|
|
47
|
+
await rpc(server, "mail/invite", {
|
|
48
|
+
conversationId: convId,
|
|
49
|
+
agentId: "executor-1",
|
|
50
|
+
role: "executor",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return convId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
it("returns participants with presence from registry", async () => {
|
|
57
|
+
const registry = {
|
|
58
|
+
getStatus(agentId: string): string {
|
|
59
|
+
if (agentId === "user_abc") return "active";
|
|
60
|
+
if (agentId === "executor-1") return "away";
|
|
61
|
+
return "unknown";
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const server = new MailJsonRpcServer(storage, router, events, registry);
|
|
65
|
+
const convId = await seedConversationWithParticipants(server);
|
|
66
|
+
|
|
67
|
+
const resp = await rpc(server, "mail/presence", { conversationId: convId });
|
|
68
|
+
|
|
69
|
+
expect(resp.error).toBeUndefined();
|
|
70
|
+
const result = resp.result as {
|
|
71
|
+
conversationId: string;
|
|
72
|
+
participants: Array<{ agent_id: string; role: string; presence: string }>;
|
|
73
|
+
};
|
|
74
|
+
expect(result.conversationId).toBe(convId);
|
|
75
|
+
expect(result.participants).toHaveLength(2);
|
|
76
|
+
|
|
77
|
+
const user = result.participants.find((p) => p.agent_id === "user_abc")!;
|
|
78
|
+
expect(user.role).toBe("initiator");
|
|
79
|
+
expect(user.presence).toBe("active");
|
|
80
|
+
|
|
81
|
+
const executor = result.participants.find((p) => p.agent_id === "executor-1")!;
|
|
82
|
+
expect(executor.role).toBe("executor");
|
|
83
|
+
expect(executor.presence).toBe("away");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns 'unknown' presence when no registry is provided", async () => {
|
|
87
|
+
const server = new MailJsonRpcServer(storage, router, events);
|
|
88
|
+
const convId = await seedConversationWithParticipants(server);
|
|
89
|
+
|
|
90
|
+
const resp = await rpc(server, "mail/presence", { conversationId: convId });
|
|
91
|
+
|
|
92
|
+
const result = resp.result as {
|
|
93
|
+
participants: Array<{ agent_id: string; presence: string }>;
|
|
94
|
+
};
|
|
95
|
+
expect(result.participants).toHaveLength(2);
|
|
96
|
+
expect(result.participants.every((p) => p.presence === "unknown")).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns error for non-existent conversation", async () => {
|
|
100
|
+
const server = new MailJsonRpcServer(storage, router, events);
|
|
101
|
+
|
|
102
|
+
const resp = await rpc(server, "mail/presence", {
|
|
103
|
+
conversationId: "nonexistent",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(resp.error).toBeDefined();
|
|
107
|
+
expect(resp.error!.code).toBe(-32001);
|
|
108
|
+
expect(resp.error!.message).toContain("not found");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns error when conversationId is missing", async () => {
|
|
112
|
+
const server = new MailJsonRpcServer(storage, router, events);
|
|
113
|
+
|
|
114
|
+
const resp = await rpc(server, "mail/presence", {});
|
|
115
|
+
|
|
116
|
+
expect(resp.error).toBeDefined();
|
|
117
|
+
expect(resp.error!.code).toBe(-32602);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns empty participants for conversation with no members", async () => {
|
|
121
|
+
const server = new MailJsonRpcServer(storage, router, events);
|
|
122
|
+
const createResp = await rpc(server, "mail/create", {
|
|
123
|
+
subject: "Empty thread",
|
|
124
|
+
});
|
|
125
|
+
const convId = (createResp.result as { id: string }).id;
|
|
126
|
+
|
|
127
|
+
const resp = await rpc(server, "mail/presence", { conversationId: convId });
|
|
128
|
+
|
|
129
|
+
const result = resp.result as {
|
|
130
|
+
participants: Array<{ agent_id: string }>;
|
|
131
|
+
};
|
|
132
|
+
expect(result.participants).toHaveLength(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("includes joined_at timestamp for each participant", async () => {
|
|
136
|
+
const server = new MailJsonRpcServer(storage, router, events);
|
|
137
|
+
const convId = await seedConversationWithParticipants(server);
|
|
138
|
+
|
|
139
|
+
const resp = await rpc(server, "mail/presence", { conversationId: convId });
|
|
140
|
+
|
|
141
|
+
const result = resp.result as {
|
|
142
|
+
participants: Array<{ agent_id: string; joined_at: string }>;
|
|
143
|
+
};
|
|
144
|
+
for (const p of result.participants) {
|
|
145
|
+
expect(p.joined_at).toBeDefined();
|
|
146
|
+
expect(typeof p.joined_at).toBe("string");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
package/test/mail-push.test.ts
CHANGED
|
@@ -200,6 +200,38 @@ describe("createMailPushBridge", () => {
|
|
|
200
200
|
expect(sendNotification).not.toHaveBeenCalled();
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
it("includes importance in params when turn has importance set", () => {
|
|
204
|
+
const events = new EventEmitter();
|
|
205
|
+
const sendNotification = vi.fn();
|
|
206
|
+
|
|
207
|
+
createMailPushBridge({
|
|
208
|
+
mailEvents: events,
|
|
209
|
+
getSubscribers: () => [{ id: "swarm-1" }],
|
|
210
|
+
sendNotification,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
events.emit("mail.turn.added", makeTurn({ importance: "high" }));
|
|
214
|
+
expect(sendNotification).toHaveBeenCalledOnce();
|
|
215
|
+
const params = sendNotification.mock.calls[0][2];
|
|
216
|
+
expect(params.importance).toBe("high");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("omits importance from params when turn has no importance", () => {
|
|
220
|
+
const events = new EventEmitter();
|
|
221
|
+
const sendNotification = vi.fn();
|
|
222
|
+
|
|
223
|
+
createMailPushBridge({
|
|
224
|
+
mailEvents: events,
|
|
225
|
+
getSubscribers: () => [{ id: "swarm-1" }],
|
|
226
|
+
sendNotification,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
events.emit("mail.turn.added", makeTurn());
|
|
230
|
+
expect(sendNotification).toHaveBeenCalledOnce();
|
|
231
|
+
const params = sendNotification.mock.calls[0][2];
|
|
232
|
+
expect(params).not.toHaveProperty("importance");
|
|
233
|
+
});
|
|
234
|
+
|
|
203
235
|
it("stop() is idempotent", () => {
|
|
204
236
|
const events = new EventEmitter();
|
|
205
237
|
const bridge = createMailPushBridge({
|
|
@@ -212,3 +244,15 @@ describe("createMailPushBridge", () => {
|
|
|
212
244
|
expect(() => bridge.stop()).not.toThrow();
|
|
213
245
|
});
|
|
214
246
|
});
|
|
247
|
+
|
|
248
|
+
describe("buildMailTurnReceivedParams", () => {
|
|
249
|
+
it("maps importance from Turn when present", () => {
|
|
250
|
+
const params = buildMailTurnReceivedParams(makeTurn({ importance: "urgent" }));
|
|
251
|
+
expect(params.importance).toBe("urgent");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("omits importance when Turn has no importance", () => {
|
|
255
|
+
const params = buildMailTurnReceivedParams(makeTurn());
|
|
256
|
+
expect(params).not.toHaveProperty("importance");
|
|
257
|
+
});
|
|
258
|
+
});
|
package/test/mail-server.test.ts
CHANGED
|
@@ -94,6 +94,31 @@ describe("MailJsonRpcServer", () => {
|
|
|
94
94
|
});
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
describe("mail/reopen", () => {
|
|
98
|
+
it("should reopen a closed conversation", async () => {
|
|
99
|
+
const createResp = await rpc("mail/create", { subject: "Test" });
|
|
100
|
+
const convId = (createResp.result as { id: string }).id;
|
|
101
|
+
|
|
102
|
+
// Close first
|
|
103
|
+
await rpc("mail/close", { id: convId });
|
|
104
|
+
expect(storage.getConversation(convId)!.status).toBe("completed");
|
|
105
|
+
|
|
106
|
+
// Reopen
|
|
107
|
+
const reopenResp = await rpc("mail/reopen", { id: convId });
|
|
108
|
+
const result = reopenResp.result as { conversationId: string; status: string };
|
|
109
|
+
expect(result.conversationId).toBe(convId);
|
|
110
|
+
expect(result.status).toBe("active");
|
|
111
|
+
|
|
112
|
+
const conv = storage.getConversation(convId)!;
|
|
113
|
+
expect(conv.status).toBe("active");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should error on non-existent conversation", async () => {
|
|
117
|
+
const resp = await rpc("mail/reopen", { id: "nonexistent" });
|
|
118
|
+
expect(resp.error).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
97
122
|
describe("mail/join & mail/leave", () => {
|
|
98
123
|
it("should add and remove participants", async () => {
|
|
99
124
|
const createResp = await rpc("mail/create", { subject: "Test" });
|
|
@@ -84,6 +84,87 @@ describe("PushNotifier", () => {
|
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
describe("PushNotifier inbox file caps", () => {
|
|
88
|
+
let capDir: string;
|
|
89
|
+
let capStorage: InMemoryStorage;
|
|
90
|
+
let capEvents: EventEmitter;
|
|
91
|
+
let capRouter: MessageRouter;
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
capDir = fs.mkdtempSync(path.join(os.tmpdir(), "inbox-cap-test-"));
|
|
95
|
+
capStorage = new InMemoryStorage();
|
|
96
|
+
capEvents = new EventEmitter();
|
|
97
|
+
capRouter = new MessageRouter(capStorage, capEvents, "default");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
fs.rmSync(capDir, { recursive: true, force: true });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should cap inbox file at maxEntries by dropping oldest entries", async () => {
|
|
105
|
+
const capped = new PushNotifier(
|
|
106
|
+
{ inboxDir: capDir, maxEntriesPerInbox: 3 },
|
|
107
|
+
capStorage,
|
|
108
|
+
capEvents
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < 6; i++) {
|
|
112
|
+
await capRouter.routeMessage({
|
|
113
|
+
from: "alice",
|
|
114
|
+
to: "bob",
|
|
115
|
+
payload: `msg-${i}`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const raw = fs.readFileSync(capped.agentInboxPath("bob"), "utf-8");
|
|
120
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
121
|
+
expect(lines).toHaveLength(3);
|
|
122
|
+
|
|
123
|
+
const entries = lines.map((l) => JSON.parse(l) as InboxFileEntry);
|
|
124
|
+
const texts = entries.map((e) => (e.content as { text: string }).text);
|
|
125
|
+
expect(texts).toEqual(["msg-3", "msg-4", "msg-5"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should cap inbox file at maxBytes (soft cap, ~1.1× headroom)", async () => {
|
|
129
|
+
const capped = new PushNotifier(
|
|
130
|
+
{ inboxDir: capDir, maxBytesPerInbox: 400, maxEntriesPerInbox: 0 },
|
|
131
|
+
capStorage,
|
|
132
|
+
capEvents
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < 20; i++) {
|
|
136
|
+
await capRouter.routeMessage({
|
|
137
|
+
from: "alice",
|
|
138
|
+
to: "bob",
|
|
139
|
+
payload: `m${i}`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const size = fs.statSync(capped.agentInboxPath("bob")).size;
|
|
144
|
+
expect(size).toBeLessThanOrEqual(Math.ceil(400 * 1.1));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should disable cap when both limits are 0", async () => {
|
|
148
|
+
const capped = new PushNotifier(
|
|
149
|
+
{ inboxDir: capDir, maxEntriesPerInbox: 0, maxBytesPerInbox: 0 },
|
|
150
|
+
capStorage,
|
|
151
|
+
capEvents
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < 50; i++) {
|
|
155
|
+
await capRouter.routeMessage({
|
|
156
|
+
from: "alice",
|
|
157
|
+
to: "bob",
|
|
158
|
+
payload: `m${i}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const raw = fs.readFileSync(capped.agentInboxPath("bob"), "utf-8");
|
|
163
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
164
|
+
expect(lines).toHaveLength(50);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
87
168
|
describe("formatInboxMarkdown", () => {
|
|
88
169
|
it("should format text messages as markdown", () => {
|
|
89
170
|
const entries: InboxFileEntry[] = [
|