agent-inbox 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -0
- package/CLAUDE.md +92 -1
- package/README.md +73 -6
- package/dist/federation/connection-manager.d.ts +8 -0
- package/dist/federation/connection-manager.d.ts.map +1 -1
- package/dist/federation/connection-manager.js +12 -0
- package/dist/federation/connection-manager.js.map +1 -1
- package/dist/federation/delivery-queue.d.ts +11 -3
- package/dist/federation/delivery-queue.d.ts.map +1 -1
- package/dist/federation/delivery-queue.js +38 -8
- package/dist/federation/delivery-queue.js.map +1 -1
- package/dist/federation/queue-store.d.ts +42 -0
- package/dist/federation/queue-store.d.ts.map +1 -0
- package/dist/federation/queue-store.js +87 -0
- package/dist/federation/queue-store.js.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +98 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mail/address-book.d.ts +43 -0
- package/dist/mail/address-book.d.ts.map +1 -0
- package/dist/mail/address-book.js +95 -0
- package/dist/mail/address-book.js.map +1 -0
- package/dist/mail/attachment-store.d.ts +31 -0
- package/dist/mail/attachment-store.d.ts.map +1 -0
- package/dist/mail/attachment-store.js +74 -0
- package/dist/mail/attachment-store.js.map +1 -0
- package/dist/mail/email-mapper.d.ts +41 -0
- package/dist/mail/email-mapper.d.ts.map +1 -0
- package/dist/mail/email-mapper.js +216 -0
- package/dist/mail/email-mapper.js.map +1 -0
- package/dist/mail/fs-attachment-store.d.ts +38 -0
- package/dist/mail/fs-attachment-store.d.ts.map +1 -0
- package/dist/mail/fs-attachment-store.js +165 -0
- package/dist/mail/fs-attachment-store.js.map +1 -0
- package/dist/mail/mail-gateway.d.ts +114 -0
- package/dist/mail/mail-gateway.d.ts.map +1 -0
- package/dist/mail/mail-gateway.js +402 -0
- package/dist/mail/mail-gateway.js.map +1 -0
- package/dist/mail/provider-transport.d.ts +138 -0
- package/dist/mail/provider-transport.d.ts.map +1 -0
- package/dist/mail/provider-transport.js +434 -0
- package/dist/mail/provider-transport.js.map +1 -0
- package/dist/mail/rate-limiter.d.ts +20 -0
- package/dist/mail/rate-limiter.d.ts.map +1 -0
- package/dist/mail/rate-limiter.js +56 -0
- package/dist/mail/rate-limiter.js.map +1 -0
- package/dist/mail/smtp-transport.d.ts +141 -0
- package/dist/mail/smtp-transport.d.ts.map +1 -0
- package/dist/mail/smtp-transport.js +415 -0
- package/dist/mail/smtp-transport.js.map +1 -0
- package/dist/mail/types.d.ts +177 -0
- package/dist/mail/types.d.ts.map +1 -0
- package/dist/mail/types.js +11 -0
- package/dist/mail/types.js.map +1 -0
- package/dist/router/destination.d.ts +69 -0
- package/dist/router/destination.d.ts.map +1 -0
- package/dist/router/destination.js +106 -0
- package/dist/router/destination.js.map +1 -0
- package/dist/router/message-router.d.ts +15 -0
- package/dist/router/message-router.d.ts.map +1 -1
- package/dist/router/message-router.js +25 -3
- package/dist/router/message-router.js.map +1 -1
- package/dist/storage/interface.d.ts +9 -0
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +4 -0
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +12 -0
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +6 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +28 -0
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/DESIGN.md +15 -0
- package/docs/MAIL-INTEROP-PLAN.md +660 -0
- package/package.json +28 -3
- package/renovate.json5 +6 -0
- package/rules/agent-inbox.md +1 -0
- package/src/federation/connection-manager.ts +12 -0
- package/src/federation/delivery-queue.ts +38 -8
- package/src/federation/queue-store.ts +124 -0
- package/src/index.ts +148 -0
- package/src/mail/address-book.ts +111 -0
- package/src/mail/attachment-store.ts +90 -0
- package/src/mail/email-mapper.ts +288 -0
- package/src/mail/fs-attachment-store.ts +163 -0
- package/src/mail/mail-gateway.ts +505 -0
- package/src/mail/provider-transport.ts +577 -0
- package/src/mail/rate-limiter.ts +51 -0
- package/src/mail/smtp-transport.ts +589 -0
- package/src/mail/types.ts +221 -0
- package/src/router/destination.ts +140 -0
- package/src/router/message-router.ts +41 -4
- package/src/storage/interface.ts +11 -0
- package/src/storage/memory.ts +15 -0
- package/src/storage/sqlite.ts +36 -0
- package/src/types.ts +73 -0
- package/test/federation/delivery-queue-sqlite.test.ts +158 -0
- package/test/load.test.ts +1 -1
- package/test/mail/address-book.test.ts +111 -0
- package/test/mail/attachment-store-contract.test.ts +92 -0
- package/test/mail/attachment-store.test.ts +69 -0
- package/test/mail/destination.test.ts +115 -0
- package/test/mail/dsn-parse.test.ts +239 -0
- package/test/mail/email-mapper.test.ts +341 -0
- package/test/mail/external-id.test.ts +43 -0
- package/test/mail/fs-attachment-store.test.ts +134 -0
- package/test/mail/full-flow-e2e.test.ts +200 -0
- package/test/mail/mail-gateway.test.ts +419 -0
- package/test/mail/mail-transport-contract.test.ts +134 -0
- package/test/mail/mock-mail.ts +161 -0
- package/test/mail/mock-postmark.ts +66 -0
- package/test/mail/provider-transport.test.ts +381 -0
- package/test/mail/rate-limiter.test.ts +48 -0
- package/test/mail/router-mail-integration.test.ts +138 -0
- package/test/mail/smtp-e2e.test.ts +98 -0
- package/test/mail/smtp-transport.test.ts +138 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { DeliveryQueue } from "../../src/federation/delivery-queue.js";
|
|
5
|
+
import { SqliteQueueStore } from "../../src/federation/queue-store.js";
|
|
6
|
+
import type { DeliveryQueueConfig, Message } from "../../src/types.js";
|
|
7
|
+
|
|
8
|
+
function makeMessage(id: string): Message {
|
|
9
|
+
return {
|
|
10
|
+
id,
|
|
11
|
+
scope: "default",
|
|
12
|
+
sender_id: "sender",
|
|
13
|
+
recipients: [{ agent_id: "remote@peer", kind: "to" }],
|
|
14
|
+
content: { type: "text", text: `msg-${id}` },
|
|
15
|
+
importance: "normal",
|
|
16
|
+
metadata: {},
|
|
17
|
+
created_at: new Date().toISOString(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const baseConfig: DeliveryQueueConfig = {
|
|
22
|
+
persistence: "sqlite",
|
|
23
|
+
maxTTL: 10_000,
|
|
24
|
+
maxQueueSize: 3,
|
|
25
|
+
retryStrategy: "exponential",
|
|
26
|
+
retryBaseInterval: 100,
|
|
27
|
+
retryMaxAttempts: 0,
|
|
28
|
+
flushOnReconnect: true,
|
|
29
|
+
overflow: "drop-oldest",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("DeliveryQueue (sqlite persistence)", () => {
|
|
33
|
+
let db: Database.Database;
|
|
34
|
+
let events: EventEmitter;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
db = new Database(":memory:");
|
|
38
|
+
events = new EventEmitter();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
db.close();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function makeQueue(config: Partial<DeliveryQueueConfig> = {}) {
|
|
46
|
+
return new DeliveryQueue(
|
|
47
|
+
events,
|
|
48
|
+
{ ...baseConfig, ...config },
|
|
49
|
+
{ store: new SqliteQueueStore(db) }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it("persists enqueued messages to the store", () => {
|
|
54
|
+
const q = makeQueue();
|
|
55
|
+
q.enqueue("peer-1", makeMessage("m1"));
|
|
56
|
+
q.enqueue("peer-1", makeMessage("m2"));
|
|
57
|
+
|
|
58
|
+
const rows = db.prepare("SELECT * FROM delivery_queue").all();
|
|
59
|
+
expect(rows).toHaveLength(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("survives a restart by hydrating from the store", () => {
|
|
63
|
+
const q1 = makeQueue();
|
|
64
|
+
q1.enqueue("peer-1", makeMessage("m1"));
|
|
65
|
+
q1.enqueue("peer-2", makeMessage("m2"));
|
|
66
|
+
expect(q1.totalSize()).toBe(2);
|
|
67
|
+
|
|
68
|
+
// Simulate restart: new queue + new store over the same db handle.
|
|
69
|
+
const q2 = new DeliveryQueue(events, baseConfig, {
|
|
70
|
+
store: new SqliteQueueStore(db),
|
|
71
|
+
});
|
|
72
|
+
expect(q2.totalSize()).toBe(2);
|
|
73
|
+
expect(q2.size("peer-1")).toBe(1);
|
|
74
|
+
expect(q2.size("peer-2")).toBe(1);
|
|
75
|
+
expect(q2.peers().sort()).toEqual(["peer-1", "peer-2"]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("removes entries from the store on removeEntry", () => {
|
|
79
|
+
const q = makeQueue();
|
|
80
|
+
const id = q.enqueue("peer-1", makeMessage("m1"))!;
|
|
81
|
+
q.removeEntry("peer-1", id);
|
|
82
|
+
|
|
83
|
+
expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("clears the store for a peer on flush", () => {
|
|
87
|
+
const q = makeQueue();
|
|
88
|
+
q.enqueue("peer-1", makeMessage("m1"));
|
|
89
|
+
q.enqueue("peer-1", makeMessage("m2"));
|
|
90
|
+
q.flush("peer-1");
|
|
91
|
+
|
|
92
|
+
expect(
|
|
93
|
+
db.prepare("SELECT * FROM delivery_queue WHERE peer_id = ?").all("peer-1")
|
|
94
|
+
).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("drops the oldest entry from the store on overflow", () => {
|
|
98
|
+
const q = makeQueue({ maxQueueSize: 2, overflow: "drop-oldest" });
|
|
99
|
+
q.enqueue("peer-1", makeMessage("m1"));
|
|
100
|
+
q.enqueue("peer-1", makeMessage("m2"));
|
|
101
|
+
q.enqueue("peer-1", makeMessage("m3")); // evicts m1
|
|
102
|
+
|
|
103
|
+
const messages = (
|
|
104
|
+
db.prepare("SELECT message FROM delivery_queue").all() as Array<{
|
|
105
|
+
message: string;
|
|
106
|
+
}>
|
|
107
|
+
).map((r) => JSON.parse(r.message).id);
|
|
108
|
+
expect(messages.sort()).toEqual(["m2", "m3"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("updates attempt/next-retry state in the store on recordAttempt", () => {
|
|
112
|
+
const q = makeQueue({ retryMaxAttempts: 5 });
|
|
113
|
+
const id = q.enqueue("peer-1", makeMessage("m1"))!;
|
|
114
|
+
q.recordAttempt("peer-1", id);
|
|
115
|
+
|
|
116
|
+
const row = db
|
|
117
|
+
.prepare("SELECT attempts, next_retry FROM delivery_queue WHERE id = ?")
|
|
118
|
+
.get(id) as { attempts: number; next_retry: string | null };
|
|
119
|
+
expect(row.attempts).toBe(1);
|
|
120
|
+
expect(row.next_retry).toBeTruthy();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("removes the entry from the store when max attempts are exceeded", () => {
|
|
124
|
+
const q = makeQueue({ retryMaxAttempts: 1 });
|
|
125
|
+
const id = q.enqueue("peer-1", makeMessage("m1"))!;
|
|
126
|
+
const kept = q.recordAttempt("peer-1", id);
|
|
127
|
+
expect(kept).toBe(false);
|
|
128
|
+
expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("removes expired entries from the store on tick", () => {
|
|
132
|
+
const q = makeQueue({ maxTTL: -1 }); // everything is immediately expired
|
|
133
|
+
q.enqueue("peer-1", makeMessage("m1"));
|
|
134
|
+
const expired = q.tick();
|
|
135
|
+
expect(expired).toBe(1);
|
|
136
|
+
expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("round-trips the full message payload through the store", () => {
|
|
140
|
+
const q1 = makeQueue();
|
|
141
|
+
const msg = makeMessage("m1");
|
|
142
|
+
msg.subject = "hello";
|
|
143
|
+
msg.thread_tag = "thread-xyz";
|
|
144
|
+
q1.enqueue("peer-1", msg);
|
|
145
|
+
|
|
146
|
+
const q2 = new DeliveryQueue(events, baseConfig, {
|
|
147
|
+
store: new SqliteQueueStore(db),
|
|
148
|
+
});
|
|
149
|
+
const flushed = q2.flush("peer-1");
|
|
150
|
+
expect(flushed).toHaveLength(1);
|
|
151
|
+
expect(flushed[0].message.subject).toBe("hello");
|
|
152
|
+
expect(flushed[0].message.thread_tag).toBe("thread-xyz");
|
|
153
|
+
expect(flushed[0].message.content).toEqual({
|
|
154
|
+
type: "text",
|
|
155
|
+
text: "msg-m1",
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
package/test/load.test.ts
CHANGED
|
@@ -252,7 +252,7 @@ describe("load: SQLite stays bounded", () => {
|
|
|
252
252
|
// 5000 messages × 11 recipients ≈ 55k recipient rows + 5k turns + FTS.
|
|
253
253
|
// 4 KiB per logical message is a generous ceiling.
|
|
254
254
|
expect(size).toBeLessThan(N * 4096);
|
|
255
|
-
});
|
|
255
|
+
}, 30_000); // generous timeout: this asserts bounded size, not speed
|
|
256
256
|
|
|
257
257
|
it("pruneMessagesOlderThan should remove old data and reduce row counts", async () => {
|
|
258
258
|
const storage = new SqliteStorage({ path: ":memory:" });
|
|
@@ -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
|
+
});
|