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,288 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
import { InMemoryStorage } from "../src/storage/memory.js";
|
|
7
|
+
import { SqliteStorage } from "../src/storage/sqlite.js";
|
|
8
|
+
import { MessageRouter } from "../src/router/message-router.js";
|
|
9
|
+
import { TraceabilityLayer } from "../src/traceability/traceability.js";
|
|
10
|
+
import { PushNotifier } from "../src/push/notifier.js";
|
|
11
|
+
import type { Storage } from "../src/storage/interface.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load tests verifying the file-growth and write-amplification fixes.
|
|
15
|
+
*
|
|
16
|
+
* These tests run larger volumes (1k-10k messages) so they're slower than
|
|
17
|
+
* the unit suite but still finish in seconds. They assert on counts of
|
|
18
|
+
* side-effects (writes, rows, file size) — not wall-clock time — so they
|
|
19
|
+
* are deterministic.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface CallCounts {
|
|
23
|
+
putMessage: number;
|
|
24
|
+
setMessageConversationId: number;
|
|
25
|
+
putConversation: number;
|
|
26
|
+
touchConversation: number;
|
|
27
|
+
addParticipant: number;
|
|
28
|
+
addTurn: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function instrumentStorage(inner: Storage): { storage: Storage; counts: CallCounts } {
|
|
32
|
+
const counts: CallCounts = {
|
|
33
|
+
putMessage: 0,
|
|
34
|
+
setMessageConversationId: 0,
|
|
35
|
+
putConversation: 0,
|
|
36
|
+
touchConversation: 0,
|
|
37
|
+
addParticipant: 0,
|
|
38
|
+
addTurn: 0,
|
|
39
|
+
};
|
|
40
|
+
const storage = new Proxy(inner, {
|
|
41
|
+
get(target, prop, receiver) {
|
|
42
|
+
const value = Reflect.get(target, prop, receiver);
|
|
43
|
+
if (typeof value !== "function") return value;
|
|
44
|
+
if (prop in counts) {
|
|
45
|
+
return (...args: unknown[]) => {
|
|
46
|
+
counts[prop as keyof CallCounts]++;
|
|
47
|
+
return (value as Function).apply(target, args);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return (value as Function).bind(target);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return { storage, counts };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("load: inbox file caps stay bounded under heavy writes", () => {
|
|
57
|
+
let tmpDir: string;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "inbox-load-"));
|
|
61
|
+
});
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should stay near maxEntries (soft cap ~1.1×) after 10000 writes", async () => {
|
|
67
|
+
const cap = 500;
|
|
68
|
+
const storage = new InMemoryStorage();
|
|
69
|
+
const events = new EventEmitter();
|
|
70
|
+
const router = new MessageRouter(storage, events, "default");
|
|
71
|
+
const notifier = new PushNotifier(
|
|
72
|
+
{ inboxDir: tmpDir, maxEntriesPerInbox: cap, maxBytesPerInbox: 0 },
|
|
73
|
+
storage,
|
|
74
|
+
events
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < 10_000; i++) {
|
|
78
|
+
await router.routeMessage({
|
|
79
|
+
from: "alice",
|
|
80
|
+
to: "bob",
|
|
81
|
+
payload: `msg-${i}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const filePath = notifier.agentInboxPath("bob");
|
|
86
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
87
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
88
|
+
expect(lines.length).toBeLessThanOrEqual(Math.ceil(cap * 1.1));
|
|
89
|
+
expect(lines.length).toBeGreaterThanOrEqual(cap);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should stay near maxBytes (soft cap ~1.1×) after 10000 writes", async () => {
|
|
93
|
+
const cap = 64 * 1024;
|
|
94
|
+
const storage = new InMemoryStorage();
|
|
95
|
+
const events = new EventEmitter();
|
|
96
|
+
const router = new MessageRouter(storage, events, "default");
|
|
97
|
+
const notifier = new PushNotifier(
|
|
98
|
+
{ inboxDir: tmpDir, maxEntriesPerInbox: 0, maxBytesPerInbox: cap },
|
|
99
|
+
storage,
|
|
100
|
+
events
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < 10_000; i++) {
|
|
104
|
+
await router.routeMessage({
|
|
105
|
+
from: "alice",
|
|
106
|
+
to: "bob",
|
|
107
|
+
payload: `msg-${i} ${"x".repeat(50)}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const size = fs.statSync(notifier.agentInboxPath("bob")).size;
|
|
112
|
+
expect(size).toBeLessThanOrEqual(Math.ceil(cap * 1.1));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("would grow unboundedly with caps disabled (control)", async () => {
|
|
116
|
+
const storage = new InMemoryStorage();
|
|
117
|
+
const events = new EventEmitter();
|
|
118
|
+
const router = new MessageRouter(storage, events, "default");
|
|
119
|
+
const notifier = new PushNotifier(
|
|
120
|
+
{ inboxDir: tmpDir, maxEntriesPerInbox: 0, maxBytesPerInbox: 0 },
|
|
121
|
+
storage,
|
|
122
|
+
events
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const N = 2000;
|
|
126
|
+
for (let i = 0; i < N; i++) {
|
|
127
|
+
await router.routeMessage({
|
|
128
|
+
from: "alice",
|
|
129
|
+
to: "bob",
|
|
130
|
+
payload: `msg-${i}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const raw = fs.readFileSync(notifier.agentInboxPath("bob"), "utf-8");
|
|
135
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
136
|
+
expect(lines).toHaveLength(N);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("load: traceability does not amplify writes", () => {
|
|
141
|
+
it("should call setMessageConversationId once per message and never re-putMessage", async () => {
|
|
142
|
+
const inner = new InMemoryStorage();
|
|
143
|
+
const { storage, counts } = instrumentStorage(inner);
|
|
144
|
+
const events = new EventEmitter();
|
|
145
|
+
const router = new MessageRouter(storage, events, "default");
|
|
146
|
+
new TraceabilityLayer(storage, events);
|
|
147
|
+
|
|
148
|
+
const N = 1000;
|
|
149
|
+
for (let i = 0; i < N; i++) {
|
|
150
|
+
await router.routeMessage({
|
|
151
|
+
from: "alice",
|
|
152
|
+
to: "bob",
|
|
153
|
+
payload: `msg-${i}`,
|
|
154
|
+
threadTag: "load-test",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Router does the only putMessage. Traceability now uses the targeted
|
|
159
|
+
// setMessageConversationId path, so total putMessage calls === N.
|
|
160
|
+
expect(counts.putMessage).toBe(N);
|
|
161
|
+
expect(counts.setMessageConversationId).toBe(N);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should call touchConversation per message instead of full putConversation", async () => {
|
|
165
|
+
const inner = new InMemoryStorage();
|
|
166
|
+
const { storage, counts } = instrumentStorage(inner);
|
|
167
|
+
const events = new EventEmitter();
|
|
168
|
+
const router = new MessageRouter(storage, events, "default");
|
|
169
|
+
new TraceabilityLayer(storage, events);
|
|
170
|
+
|
|
171
|
+
const N = 1000;
|
|
172
|
+
for (let i = 0; i < N; i++) {
|
|
173
|
+
await router.routeMessage({
|
|
174
|
+
from: "alice",
|
|
175
|
+
to: "bob",
|
|
176
|
+
payload: `msg-${i}`,
|
|
177
|
+
threadTag: "load-test",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Exactly one putConversation (the create), then N touchConversation bumps.
|
|
182
|
+
expect(counts.putConversation).toBe(1);
|
|
183
|
+
expect(counts.touchConversation).toBe(N);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("should keep participant row count stable for a long-lived broadcast conversation", async () => {
|
|
187
|
+
const storage = new SqliteStorage({ path: ":memory:" });
|
|
188
|
+
const events = new EventEmitter();
|
|
189
|
+
const router = new MessageRouter(storage, events, "default");
|
|
190
|
+
new TraceabilityLayer(storage, events);
|
|
191
|
+
|
|
192
|
+
const recipients = Array.from({ length: 10 }, (_, i) => `agent-${i}`);
|
|
193
|
+
const N = 1000;
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < N; i++) {
|
|
196
|
+
await router.routeMessage({
|
|
197
|
+
from: "alice",
|
|
198
|
+
to: recipients,
|
|
199
|
+
payload: `msg-${i}`,
|
|
200
|
+
threadTag: "broadcast",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const conversations = storage.listConversations("default");
|
|
205
|
+
expect(conversations).toHaveLength(1);
|
|
206
|
+
|
|
207
|
+
// alice + 10 recipients = 11 participants — must not balloon with N.
|
|
208
|
+
expect(conversations[0].participants).toHaveLength(11);
|
|
209
|
+
|
|
210
|
+
// Sanity: turns table grows linearly with messages (expected — that's the audit log).
|
|
211
|
+
expect(storage.getTurns(conversations[0].id)).toHaveLength(N);
|
|
212
|
+
|
|
213
|
+
storage.close();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("load: SQLite stays bounded", () => {
|
|
218
|
+
let dbPath: string;
|
|
219
|
+
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "inbox-sqlite-load-"));
|
|
222
|
+
dbPath = path.join(tmp, "inbox.db");
|
|
223
|
+
});
|
|
224
|
+
afterEach(() => {
|
|
225
|
+
const dir = path.dirname(dbPath);
|
|
226
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("should keep DB file growth bounded under heavy load", async () => {
|
|
230
|
+
const storage = new SqliteStorage({ path: dbPath });
|
|
231
|
+
const events = new EventEmitter();
|
|
232
|
+
const router = new MessageRouter(storage, events, "default");
|
|
233
|
+
new TraceabilityLayer(storage, events);
|
|
234
|
+
|
|
235
|
+
const recipients = Array.from({ length: 10 }, (_, i) => `agent-${i}`);
|
|
236
|
+
const N = 5000;
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i < N; i++) {
|
|
239
|
+
await router.routeMessage({
|
|
240
|
+
from: "alice",
|
|
241
|
+
to: recipients,
|
|
242
|
+
payload: `msg-${i}`,
|
|
243
|
+
threadTag: "broadcast",
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// After close, WAL checkpoints into the main DB. The fix's main impact
|
|
248
|
+
// is on write *churn* (no per-message participant resyncs); this is a
|
|
249
|
+
// smoke test that the on-disk size scales reasonably with the data.
|
|
250
|
+
storage.close();
|
|
251
|
+
const size = fs.statSync(dbPath).size;
|
|
252
|
+
// 5000 messages × 11 recipients ≈ 55k recipient rows + 5k turns + FTS.
|
|
253
|
+
// 4 KiB per logical message is a generous ceiling.
|
|
254
|
+
expect(size).toBeLessThan(N * 4096);
|
|
255
|
+
}, 30_000); // generous timeout: this asserts bounded size, not speed
|
|
256
|
+
|
|
257
|
+
it("pruneMessagesOlderThan should remove old data and reduce row counts", async () => {
|
|
258
|
+
const storage = new SqliteStorage({ path: ":memory:" });
|
|
259
|
+
const events = new EventEmitter();
|
|
260
|
+
const router = new MessageRouter(storage, events, "default");
|
|
261
|
+
new TraceabilityLayer(storage, events);
|
|
262
|
+
|
|
263
|
+
const N = 1000;
|
|
264
|
+
for (let i = 0; i < N; i++) {
|
|
265
|
+
const old = i < N / 2;
|
|
266
|
+
const ts = old ? "2024-01-01T00:00:00Z" : "2026-12-01T00:00:00Z";
|
|
267
|
+
const msg = await router.routeMessage({
|
|
268
|
+
from: "alice",
|
|
269
|
+
to: "bob",
|
|
270
|
+
payload: `msg-${i}`,
|
|
271
|
+
threadTag: "load",
|
|
272
|
+
});
|
|
273
|
+
// Backdate by overriding via storage to simulate aged messages
|
|
274
|
+
const stored = storage.getMessage(msg.id)!;
|
|
275
|
+
stored.created_at = ts;
|
|
276
|
+
storage.putMessage(stored);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const removed = storage.pruneMessagesOlderThan("2025-01-01T00:00:00Z");
|
|
280
|
+
expect(removed).toBe(N / 2);
|
|
281
|
+
|
|
282
|
+
// Recent half remains
|
|
283
|
+
const conv = storage.listConversations("default")[0];
|
|
284
|
+
expect(storage.getTurns(conv.id)).toHaveLength(N / 2);
|
|
285
|
+
|
|
286
|
+
storage.close();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { AddressBook } from "../../src/mail/address-book.js";
|
|
3
|
+
|
|
4
|
+
function book(): AddressBook {
|
|
5
|
+
return new AddressBook({
|
|
6
|
+
localDomains: ["example.com", "agents.example.com"],
|
|
7
|
+
mappings: [
|
|
8
|
+
{ agentId: "alice", address: "alice@example.com" },
|
|
9
|
+
{ agentId: "bob", address: "bob@example.com" },
|
|
10
|
+
],
|
|
11
|
+
catchAllAgentId: undefined,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("AddressBook", () => {
|
|
16
|
+
it("reports the primary domain", () => {
|
|
17
|
+
expect(book().primaryDomain()).toBe("example.com");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("recognizes owned domains and subdomains", () => {
|
|
21
|
+
const b = book();
|
|
22
|
+
expect(b.ownsDomain("example.com")).toBe(true);
|
|
23
|
+
expect(b.ownsDomain("mail.example.com")).toBe(true);
|
|
24
|
+
expect(b.ownsDomain("agents.example.com")).toBe(true);
|
|
25
|
+
expect(b.ownsDomain("notexample.com")).toBe(false);
|
|
26
|
+
expect(b.ownsDomain("other.org")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("resolves a known inbound mailbox to its agent", () => {
|
|
30
|
+
expect(book().resolveInbound("bob@example.com")).toEqual({
|
|
31
|
+
agentId: "bob",
|
|
32
|
+
scope: undefined,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("is case-insensitive on the mailbox", () => {
|
|
37
|
+
expect(book().resolveInbound("ALICE@Example.com")?.agentId).toBe("alice");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("extracts scope from plus-addressing", () => {
|
|
41
|
+
expect(book().resolveInbound("alice+urgent@example.com")).toEqual({
|
|
42
|
+
agentId: "alice",
|
|
43
|
+
scope: "urgent",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns undefined for a foreign domain", () => {
|
|
48
|
+
expect(book().resolveInbound("alice@other.org")).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns undefined for an unknown mailbox without catch-all", () => {
|
|
52
|
+
expect(book().resolveInbound("ghost@example.com")).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("routes unknown mailboxes to catch-all when configured", () => {
|
|
56
|
+
const b = new AddressBook({
|
|
57
|
+
localDomains: ["example.com"],
|
|
58
|
+
mappings: [],
|
|
59
|
+
catchAllAgentId: "triage",
|
|
60
|
+
});
|
|
61
|
+
expect(b.resolveInbound("anyone@example.com")).toEqual({
|
|
62
|
+
agentId: "triage",
|
|
63
|
+
scope: undefined,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("resolves a mapped agent's from-address", () => {
|
|
68
|
+
expect(book().resolveFrom("alice")).toEqual({ address: "alice@example.com" });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("falls back to agentId@primaryDomain for an unmapped agent", () => {
|
|
72
|
+
expect(book().resolveFrom("ghost")).toEqual({
|
|
73
|
+
address: "ghost@example.com",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("ignores malformed addresses", () => {
|
|
78
|
+
expect(book().resolveInbound("not-an-email")).toBeUndefined();
|
|
79
|
+
expect(book().resolveInbound("@example.com")).toBeUndefined();
|
|
80
|
+
expect(book().resolveInbound("foo@")).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects addresses with multiple @ (consistent with parseAddress)", () => {
|
|
84
|
+
expect(book().resolveInbound("a@b@example.com")).toBeUndefined();
|
|
85
|
+
expect(book().resolveInbound("alice@example.com@evil.com")).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("records duplicate-address mapping conflicts and keeps the first", () => {
|
|
89
|
+
const b = new AddressBook({
|
|
90
|
+
localDomains: ["example.com"],
|
|
91
|
+
mappings: [
|
|
92
|
+
{ agentId: "alice", address: "shared@example.com" },
|
|
93
|
+
{ agentId: "bob", address: "shared@example.com" },
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
expect(b.resolveInbound("shared@example.com")?.agentId).toBe("alice");
|
|
97
|
+
expect(b.conflicts).toHaveLength(1);
|
|
98
|
+
expect(b.conflicts[0]).toContain("shared@example.com");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not flag a repeated identical (agent,address) pair as a conflict", () => {
|
|
102
|
+
const b = new AddressBook({
|
|
103
|
+
localDomains: ["example.com"],
|
|
104
|
+
mappings: [
|
|
105
|
+
{ agentId: "alice", address: "alice@example.com" },
|
|
106
|
+
{ agentId: "alice", address: "alice@example.com" },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
expect(b.conflicts).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared AttachmentStore contract — both backends (SQLite BLOB + filesystem)
|
|
3
|
+
* must satisfy the same behavior, so they are interchangeable behind the
|
|
4
|
+
* opaque `contentRef`. Mirrors the mail-transport contract pattern.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
8
|
+
import * as fs from "node:fs/promises";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import Database from "better-sqlite3";
|
|
12
|
+
import type { AttachmentStore } from "../../src/mail/types.js";
|
|
13
|
+
import { SqliteAttachmentStore } from "../../src/mail/attachment-store.js";
|
|
14
|
+
import { FsAttachmentStore } from "../../src/mail/fs-attachment-store.js";
|
|
15
|
+
|
|
16
|
+
interface Harness {
|
|
17
|
+
store: AttachmentStore;
|
|
18
|
+
cleanup: () => Promise<void> | void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function runAttachmentStoreContract(
|
|
22
|
+
name: string,
|
|
23
|
+
makeHarness: () => Promise<Harness>
|
|
24
|
+
): void {
|
|
25
|
+
describe(`AttachmentStore contract: ${name}`, () => {
|
|
26
|
+
let h: Harness;
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
h = await makeHarness();
|
|
29
|
+
});
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await h.cleanup();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("round-trips stored bytes by ref", async () => {
|
|
35
|
+
const bytes = Buffer.from("contract bytes");
|
|
36
|
+
const ref = await h.store.put(bytes, { contentType: "text/plain" });
|
|
37
|
+
expect((await h.store.get(ref)).equals(bytes)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("produces sha256-based content-addressed refs", async () => {
|
|
41
|
+
const ref = await h.store.put(Buffer.from("x"), { contentType: "text/plain" });
|
|
42
|
+
expect(ref).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("dedups identical bytes to the same ref", async () => {
|
|
46
|
+
const a = await h.store.put(Buffer.from("same"), { contentType: "text/plain" });
|
|
47
|
+
const b = await h.store.put(Buffer.from("same"), { contentType: "text/plain" });
|
|
48
|
+
expect(a).toBe(b);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("gives different refs for different bytes", async () => {
|
|
52
|
+
const a = await h.store.put(Buffer.from("one"), { contentType: "text/plain" });
|
|
53
|
+
const b = await h.store.put(Buffer.from("two"), { contentType: "text/plain" });
|
|
54
|
+
expect(a).not.toBe(b);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("throws on a missing ref", async () => {
|
|
58
|
+
await expect(h.store.get("sha256:" + "a".repeat(64))).rejects.toThrow(/not found/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("rejects a malformed ref on get and delete (both backends)", async () => {
|
|
62
|
+
await expect(h.store.get("garbage")).rejects.toThrow(/Invalid attachment ref/);
|
|
63
|
+
await expect(h.store.get("sha256:../../etc/passwd")).rejects.toThrow(/Invalid attachment ref/);
|
|
64
|
+
await expect(h.store.delete("sha256:short")).rejects.toThrow(/Invalid attachment ref/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("deletes by ref", async () => {
|
|
68
|
+
const ref = await h.store.put(Buffer.from("gone"), { contentType: "text/plain" });
|
|
69
|
+
await h.store.delete(ref);
|
|
70
|
+
await expect(h.store.get(ref)).rejects.toThrow(/not found/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("round-trips arbitrary binary content exactly", async () => {
|
|
74
|
+
const bytes = Buffer.from([0x00, 0xff, 0x7f, 0x80, 0x01, 0x00]);
|
|
75
|
+
const ref = await h.store.put(bytes, { contentType: "application/octet-stream" });
|
|
76
|
+
expect((await h.store.get(ref)).equals(bytes)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
runAttachmentStoreContract("SqliteAttachmentStore", async () => {
|
|
82
|
+
const db = new Database(":memory:");
|
|
83
|
+
return { store: new SqliteAttachmentStore(db), cleanup: () => db.close() };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
runAttachmentStoreContract("FsAttachmentStore", async () => {
|
|
87
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "att-contract-"));
|
|
88
|
+
return {
|
|
89
|
+
store: new FsAttachmentStore(dir),
|
|
90
|
+
cleanup: () => fs.rm(dir, { recursive: true, force: true }),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { SqliteAttachmentStore } from "../../src/mail/attachment-store.js";
|
|
4
|
+
|
|
5
|
+
describe("SqliteAttachmentStore", () => {
|
|
6
|
+
let db: Database.Database;
|
|
7
|
+
let store: SqliteAttachmentStore;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
db = new Database(":memory:");
|
|
11
|
+
store = new SqliteAttachmentStore(db);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => db.close());
|
|
15
|
+
|
|
16
|
+
it("stores and retrieves bytes by ref", async () => {
|
|
17
|
+
const bytes = Buffer.from("hello attachment");
|
|
18
|
+
const ref = await store.put(bytes, { contentType: "text/plain" });
|
|
19
|
+
const got = await store.get(ref);
|
|
20
|
+
expect(got.equals(bytes)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("produces content-addressed (sha256:) refs", async () => {
|
|
24
|
+
const ref = await store.put(Buffer.from("x"), { contentType: "text/plain" });
|
|
25
|
+
expect(ref).toMatch(/^sha256:[0-9a-f]{64}$/);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("dedups identical bytes to the same ref and single row", async () => {
|
|
29
|
+
const a = await store.put(Buffer.from("same"), { contentType: "text/plain" });
|
|
30
|
+
const b = await store.put(Buffer.from("same"), { contentType: "text/plain" });
|
|
31
|
+
expect(a).toBe(b);
|
|
32
|
+
const count = db
|
|
33
|
+
.prepare("SELECT COUNT(*) AS n FROM mail_attachments")
|
|
34
|
+
.get() as { n: number };
|
|
35
|
+
expect(count.n).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("gives different refs for different bytes", async () => {
|
|
39
|
+
const a = await store.put(Buffer.from("one"), { contentType: "text/plain" });
|
|
40
|
+
const b = await store.put(Buffer.from("two"), { contentType: "text/plain" });
|
|
41
|
+
expect(a).not.toBe(b);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("deletes by ref", async () => {
|
|
45
|
+
const ref = await store.put(Buffer.from("gone"), { contentType: "text/plain" });
|
|
46
|
+
await store.delete(ref);
|
|
47
|
+
await expect(store.get(ref)).rejects.toThrow(/not found/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("throws on missing ref", async () => {
|
|
51
|
+
await expect(store.get("sha256:" + "0".repeat(64))).rejects.toThrow(/not found/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("persists filename and size metadata", async () => {
|
|
55
|
+
const bytes = Buffer.from("with-meta");
|
|
56
|
+
const ref = await store.put(bytes, {
|
|
57
|
+
contentType: "application/pdf",
|
|
58
|
+
filename: "doc.pdf",
|
|
59
|
+
});
|
|
60
|
+
const row = db
|
|
61
|
+
.prepare(
|
|
62
|
+
"SELECT content_type, filename, size_bytes FROM mail_attachments WHERE ref = ?"
|
|
63
|
+
)
|
|
64
|
+
.get(ref) as { content_type: string; filename: string; size_bytes: number };
|
|
65
|
+
expect(row.content_type).toBe("application/pdf");
|
|
66
|
+
expect(row.filename).toBe("doc.pdf");
|
|
67
|
+
expect(row.size_bytes).toBe(bytes.length);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
classifyDestination,
|
|
4
|
+
findDomainConflicts,
|
|
5
|
+
UnknownDestinationError,
|
|
6
|
+
type DestinationContext,
|
|
7
|
+
} from "../../src/router/destination.js";
|
|
8
|
+
|
|
9
|
+
function ctx(overrides: Partial<DestinationContext> = {}): DestinationContext {
|
|
10
|
+
return {
|
|
11
|
+
isLocalAgent: (id) => ["alice", "bob"].includes(id),
|
|
12
|
+
federationPeers: [{ systemId: "system-2" }],
|
|
13
|
+
mailDomains: ["example.com"],
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("classifyDestination", () => {
|
|
19
|
+
it("classifies a bare id as local", () => {
|
|
20
|
+
expect(classifyDestination("alice", ctx())).toEqual({
|
|
21
|
+
class: "local",
|
|
22
|
+
agentId: "alice",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("classifies an unknown bare id as local (no domain to disambiguate)", () => {
|
|
27
|
+
// No @ means there is no domain to route on — it is a local-namespace id.
|
|
28
|
+
expect(classifyDestination("charlie", ctx())).toEqual({
|
|
29
|
+
class: "local",
|
|
30
|
+
agentId: "charlie",
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("classifies a configured federation peer system as federation", () => {
|
|
35
|
+
const d = classifyDestination("bob@system-2", ctx());
|
|
36
|
+
expect(d.class).toBe("federation");
|
|
37
|
+
if (d.class === "federation") {
|
|
38
|
+
expect(d.address).toEqual({ agent: "bob", system: "system-2" });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("classifies a configured mail domain as mail", () => {
|
|
43
|
+
expect(classifyDestination("bob@example.com", ctx())).toEqual({
|
|
44
|
+
class: "mail",
|
|
45
|
+
email: "bob@example.com",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("matches subdomains of a configured mail domain", () => {
|
|
50
|
+
expect(classifyDestination("bob@mail.example.com", ctx())).toEqual({
|
|
51
|
+
class: "mail",
|
|
52
|
+
email: "bob@mail.example.com",
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("does not match a domain that merely shares a suffix string", () => {
|
|
57
|
+
// notexample.com must NOT match example.com
|
|
58
|
+
expect(() => classifyDestination("bob@notexample.com", ctx())).toThrow(
|
|
59
|
+
UnknownDestinationError
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("throws UnknownDestinationError for an unlisted domain", () => {
|
|
64
|
+
expect(() => classifyDestination("bob@gmail.com", ctx())).toThrow(
|
|
65
|
+
UnknownDestinationError
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("lets a federation peer's declared domain win over mail config", () => {
|
|
70
|
+
// corp.internal is claimed by BOTH a peer and mail config — federation wins.
|
|
71
|
+
const c = ctx({
|
|
72
|
+
federationPeers: [{ systemId: "system-2", domains: ["corp.internal"] }],
|
|
73
|
+
mailDomains: ["example.com", "corp.internal"],
|
|
74
|
+
});
|
|
75
|
+
const d = classifyDestination("team@corp.internal", c);
|
|
76
|
+
expect(d.class).toBe("federation");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("routes a dotted federation domain that is not in mail config", () => {
|
|
80
|
+
const c = ctx({
|
|
81
|
+
federationPeers: [{ systemId: "hub", domains: ["team.corp.internal"] }],
|
|
82
|
+
mailDomains: ["example.com"],
|
|
83
|
+
});
|
|
84
|
+
expect(classifyDestination("svc@team.corp.internal", c).class).toBe(
|
|
85
|
+
"federation"
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("strips a trailing /scope from a mail address", () => {
|
|
90
|
+
expect(classifyDestination("bob@example.com/work", ctx())).toEqual({
|
|
91
|
+
class: "mail",
|
|
92
|
+
email: "bob@example.com",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("is case-insensitive on domain matching", () => {
|
|
97
|
+
expect(classifyDestination("bob@Example.COM", ctx()).class).toBe("mail");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("findDomainConflicts", () => {
|
|
102
|
+
it("reports domains claimed by both a peer and mail config", () => {
|
|
103
|
+
const conflicts = findDomainConflicts(
|
|
104
|
+
ctx({
|
|
105
|
+
federationPeers: [{ systemId: "s", domains: ["corp.internal", "ok.net"] }],
|
|
106
|
+
mailDomains: ["example.com", "corp.internal"],
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
expect(conflicts).toEqual(["corp.internal"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns empty when there is no overlap", () => {
|
|
113
|
+
expect(findDomainConflicts(ctx())).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|