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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-inbox",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Agent Inbox — message routing, traceability, and MCP tools for multi-agent systems",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
"dev": "tsc --watch",
|
|
14
14
|
"test": "vitest run",
|
|
15
15
|
"test:watch": "vitest",
|
|
16
|
+
"bench:growth": "vitest run --config vitest.bench.config.ts",
|
|
16
17
|
"start": "node dist/index.js",
|
|
17
18
|
"publish:npm": "npm publish --access public",
|
|
18
|
-
"prepublishOnly": "npm run build",
|
|
19
|
+
"prepublishOnly": "npm run build && publint",
|
|
19
20
|
"version:patch": "npm version patch && git push && git push --tags",
|
|
20
21
|
"version:minor": "npm version minor && git push && git push --tags",
|
|
21
22
|
"version:major": "npm version major && git push && git push --tags"
|
|
@@ -41,7 +42,12 @@
|
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|
|
43
44
|
"@multi-agent-protocol/sdk": "*",
|
|
44
|
-
"agentic-mesh": "*"
|
|
45
|
+
"agentic-mesh": "*",
|
|
46
|
+
"mailauth": "*",
|
|
47
|
+
"mailparser": "*",
|
|
48
|
+
"nodemailer": "*",
|
|
49
|
+
"postmark": "*",
|
|
50
|
+
"smtp-server": "*"
|
|
45
51
|
},
|
|
46
52
|
"peerDependenciesMeta": {
|
|
47
53
|
"@multi-agent-protocol/sdk": {
|
|
@@ -49,12 +55,32 @@
|
|
|
49
55
|
},
|
|
50
56
|
"agentic-mesh": {
|
|
51
57
|
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"mailauth": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"mailparser": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"nodemailer": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"postmark": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"smtp-server": {
|
|
72
|
+
"optional": true
|
|
52
73
|
}
|
|
53
74
|
},
|
|
54
75
|
"devDependencies": {
|
|
55
76
|
"@types/better-sqlite3": "^7.6.13",
|
|
56
77
|
"@types/node": "^22.0.0",
|
|
57
78
|
"agentic-mesh": "^0.2.0",
|
|
79
|
+
"mailauth": "^4.6.6",
|
|
80
|
+
"mailparser": "^3.6.6",
|
|
81
|
+
"nodemailer": "^6.9.0",
|
|
82
|
+
"publint": "^0.3.21",
|
|
83
|
+
"smtp-server": "^3.13.0",
|
|
58
84
|
"typescript": "^5.7.0",
|
|
59
85
|
"vitest": "^3.0.0"
|
|
60
86
|
}
|
package/renovate.json5
ADDED
package/rules/agent-inbox.md
CHANGED
|
@@ -60,6 +60,7 @@ read_thread({ threadTag: "deploy-v2" })
|
|
|
60
60
|
- `"bob"` — local agent on this system
|
|
61
61
|
- `"bob@system-2"` — agent on a federated remote system
|
|
62
62
|
- `"@system-2"` — broadcast to all agents on a remote system
|
|
63
|
+
- `"person@example.com"` — an external email address (only when email interop is enabled for that domain; otherwise the send is rejected). Replies from that person arrive back in your inbox like any other message, and threading is preserved.
|
|
63
64
|
|
|
64
65
|
## Discovering other agents
|
|
65
66
|
|
|
@@ -59,6 +59,8 @@ export class ConnectionManager {
|
|
|
59
59
|
private meshConnector: MeshConnector | null;
|
|
60
60
|
private meshPeer: MeshPeerLike | null;
|
|
61
61
|
private onIncoming: IncomingMessageHandler | null;
|
|
62
|
+
/** Optional predicate marking recipients owned by the mail gateway (skipped). */
|
|
63
|
+
private mailFilter: ((agentId: string) => boolean) | null = null;
|
|
62
64
|
readonly routing: RoutingEngine;
|
|
63
65
|
readonly queue: DeliveryQueue;
|
|
64
66
|
readonly trust: TrustManager;
|
|
@@ -110,6 +112,15 @@ export class ConnectionManager {
|
|
|
110
112
|
return this.systemId;
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Register a predicate identifying recipients handled by the mail gateway.
|
|
117
|
+
* Those recipients are skipped by route() so a `bob@example.com` address is
|
|
118
|
+
* not mistaken for a federation peer.
|
|
119
|
+
*/
|
|
120
|
+
setMailRecipientFilter(fn: (agentId: string) => boolean): void {
|
|
121
|
+
this.mailFilter = fn;
|
|
122
|
+
}
|
|
123
|
+
|
|
113
124
|
/**
|
|
114
125
|
* Establish federation with a peer. Uses MAP federation/connect protocol.
|
|
115
126
|
* If an SDK class was injected, opens a real MAP connection to the peer.
|
|
@@ -264,6 +275,7 @@ export class ConnectionManager {
|
|
|
264
275
|
async route(message: Message): Promise<DeliveryResult> {
|
|
265
276
|
// Determine target for each recipient
|
|
266
277
|
for (const recipient of message.recipients) {
|
|
278
|
+
if (this.mailFilter?.(recipient.agent_id)) continue; // handled by mail gateway
|
|
267
279
|
const addr = parseAddress(recipient.agent_id);
|
|
268
280
|
if (!isRemoteAddress(addr)) continue;
|
|
269
281
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { ulid } from "ulid";
|
|
3
3
|
import type { Message, DeliveryQueueConfig, QueuedMessage } from "../types.js";
|
|
4
|
+
import type { QueueStore } from "./queue-store.js";
|
|
4
5
|
|
|
5
6
|
const DEFAULT_CONFIG: DeliveryQueueConfig = {
|
|
6
7
|
persistence: "memory",
|
|
@@ -16,19 +17,37 @@ const DEFAULT_CONFIG: DeliveryQueueConfig = {
|
|
|
16
17
|
/**
|
|
17
18
|
* Delivery queue for messages to offline or unreachable federation peers.
|
|
18
19
|
*
|
|
19
|
-
* Supports configurable TTL, overflow policy, and retry strategy.
|
|
20
|
-
*
|
|
20
|
+
* Supports configurable TTL, overflow policy, and retry strategy. Keeps an
|
|
21
|
+
* in-memory index for fast reads; when a durable QueueStore is supplied, every
|
|
22
|
+
* mutation is write-through journaled and the index is hydrated from the store
|
|
23
|
+
* on construction so queued messages survive a restart.
|
|
21
24
|
*/
|
|
22
25
|
export class DeliveryQueue {
|
|
23
26
|
private queues = new Map<string, QueuedMessage[]>();
|
|
24
27
|
private config: DeliveryQueueConfig;
|
|
25
28
|
private tickTimer?: ReturnType<typeof setInterval>;
|
|
29
|
+
private store: QueueStore | null;
|
|
26
30
|
|
|
27
31
|
constructor(
|
|
28
32
|
private events: EventEmitter,
|
|
29
|
-
config?: Partial<DeliveryQueueConfig
|
|
33
|
+
config?: Partial<DeliveryQueueConfig>,
|
|
34
|
+
opts?: { store?: QueueStore }
|
|
30
35
|
) {
|
|
31
36
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
37
|
+
this.store = opts?.store ?? null;
|
|
38
|
+
if (this.store) this.hydrate(this.store);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Load persisted entries into the in-memory index (durable mode). */
|
|
42
|
+
private hydrate(store: QueueStore): void {
|
|
43
|
+
for (const entry of store.loadAll()) {
|
|
44
|
+
let queue = this.queues.get(entry.peerId);
|
|
45
|
+
if (!queue) {
|
|
46
|
+
queue = [];
|
|
47
|
+
this.queues.set(entry.peerId, queue);
|
|
48
|
+
}
|
|
49
|
+
queue.push(entry);
|
|
50
|
+
}
|
|
32
51
|
}
|
|
33
52
|
|
|
34
53
|
/**
|
|
@@ -44,9 +63,11 @@ export class DeliveryQueue {
|
|
|
44
63
|
// Check queue size limit
|
|
45
64
|
if (queue.length >= this.config.maxQueueSize) {
|
|
46
65
|
switch (this.config.overflow) {
|
|
47
|
-
case "drop-oldest":
|
|
48
|
-
queue.shift();
|
|
66
|
+
case "drop-oldest": {
|
|
67
|
+
const dropped = queue.shift();
|
|
68
|
+
if (dropped) this.store?.remove(peerId, dropped.id);
|
|
49
69
|
break;
|
|
70
|
+
}
|
|
50
71
|
case "drop-newest":
|
|
51
72
|
return null; // Don't enqueue
|
|
52
73
|
case "reject-new":
|
|
@@ -65,6 +86,7 @@ export class DeliveryQueue {
|
|
|
65
86
|
};
|
|
66
87
|
|
|
67
88
|
queue.push(entry);
|
|
89
|
+
this.store?.upsert(entry);
|
|
68
90
|
this.events.emit("queue.enqueued", { peerId, messageId: entry.id });
|
|
69
91
|
return entry.id;
|
|
70
92
|
}
|
|
@@ -79,6 +101,7 @@ export class DeliveryQueue {
|
|
|
79
101
|
|
|
80
102
|
const messages = [...queue];
|
|
81
103
|
this.queues.delete(peerId);
|
|
104
|
+
this.store?.removePeer(peerId);
|
|
82
105
|
this.events.emit("queue.flushed", { peerId, count: messages.length });
|
|
83
106
|
return messages;
|
|
84
107
|
}
|
|
@@ -123,6 +146,7 @@ export class DeliveryQueue {
|
|
|
123
146
|
? this.config.retryBaseInterval * Math.pow(2, entry.attempts - 1)
|
|
124
147
|
: this.config.retryBaseInterval;
|
|
125
148
|
entry.nextRetry = new Date(Date.now() + delay).toISOString();
|
|
149
|
+
this.store?.upsert(entry);
|
|
126
150
|
return true;
|
|
127
151
|
}
|
|
128
152
|
|
|
@@ -137,6 +161,7 @@ export class DeliveryQueue {
|
|
|
137
161
|
if (idx === -1) return false;
|
|
138
162
|
|
|
139
163
|
queue.splice(idx, 1);
|
|
164
|
+
this.store?.remove(peerId, messageId);
|
|
140
165
|
if (queue.length === 0) this.queues.delete(peerId);
|
|
141
166
|
return true;
|
|
142
167
|
}
|
|
@@ -150,10 +175,15 @@ export class DeliveryQueue {
|
|
|
150
175
|
|
|
151
176
|
for (const [peerId, queue] of this.queues.entries()) {
|
|
152
177
|
const before = queue.length;
|
|
153
|
-
const remaining =
|
|
178
|
+
const remaining: QueuedMessage[] = [];
|
|
179
|
+
for (const entry of queue) {
|
|
154
180
|
const age = now - new Date(entry.enqueuedAt).getTime();
|
|
155
|
-
|
|
156
|
-
|
|
181
|
+
if (age < this.config.maxTTL) {
|
|
182
|
+
remaining.push(entry);
|
|
183
|
+
} else {
|
|
184
|
+
this.store?.remove(peerId, entry.id);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
157
187
|
expired += before - remaining.length;
|
|
158
188
|
|
|
159
189
|
if (remaining.length === 0) {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable persistence for the delivery queue.
|
|
3
|
+
*
|
|
4
|
+
* The DeliveryQueue keeps an in-memory index for fast reads and write-through
|
|
5
|
+
* journals every mutation to a QueueStore. On construction it hydrates the
|
|
6
|
+
* in-memory index from the store, so queued messages survive a restart.
|
|
7
|
+
*
|
|
8
|
+
* Two impls: the default in-memory behavior needs no store (pass none), and
|
|
9
|
+
* SqliteQueueStore backs durable mode (DeliveryQueueConfig.persistence = "sqlite").
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type Database from "better-sqlite3";
|
|
13
|
+
import type { QueuedMessage } from "../types.js";
|
|
14
|
+
|
|
15
|
+
export interface QueueStore {
|
|
16
|
+
/** Insert or replace an entry. */
|
|
17
|
+
upsert(entry: QueuedMessage): void;
|
|
18
|
+
/** Remove a single entry by peer + message id. */
|
|
19
|
+
remove(peerId: string, messageId: string): void;
|
|
20
|
+
/** Remove all entries for a peer (e.g. on flush). */
|
|
21
|
+
removePeer(peerId: string): void;
|
|
22
|
+
/** Load all persisted entries (called once on hydrate). */
|
|
23
|
+
loadAll(): QueuedMessage[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** SQLite-backed durable queue store using a borrowed better-sqlite3 handle. */
|
|
27
|
+
export class SqliteQueueStore implements QueueStore {
|
|
28
|
+
private table: string;
|
|
29
|
+
private upsertStmt: Database.Statement;
|
|
30
|
+
private removeStmt: Database.Statement;
|
|
31
|
+
private removePeerStmt: Database.Statement;
|
|
32
|
+
private loadStmt: Database.Statement;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param db A better-sqlite3 handle (lifecycle owned by the caller).
|
|
36
|
+
* @param prefix Table name prefix to match the Storage convention. Default "".
|
|
37
|
+
*/
|
|
38
|
+
constructor(
|
|
39
|
+
private db: Database.Database,
|
|
40
|
+
prefix: string = ""
|
|
41
|
+
) {
|
|
42
|
+
this.table = `${prefix}delivery_queue`;
|
|
43
|
+
this.migrate();
|
|
44
|
+
this.upsertStmt = this.db.prepare(
|
|
45
|
+
`INSERT INTO ${this.table}
|
|
46
|
+
(id, peer_id, message, enqueued_at, attempts, last_attempt, next_retry)
|
|
47
|
+
VALUES (@id, @peer_id, @message, @enqueued_at, @attempts, @last_attempt, @next_retry)
|
|
48
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
49
|
+
peer_id = excluded.peer_id,
|
|
50
|
+
message = excluded.message,
|
|
51
|
+
enqueued_at = excluded.enqueued_at,
|
|
52
|
+
attempts = excluded.attempts,
|
|
53
|
+
last_attempt = excluded.last_attempt,
|
|
54
|
+
next_retry = excluded.next_retry`
|
|
55
|
+
);
|
|
56
|
+
this.removeStmt = this.db.prepare(
|
|
57
|
+
`DELETE FROM ${this.table} WHERE peer_id = ? AND id = ?`
|
|
58
|
+
);
|
|
59
|
+
this.removePeerStmt = this.db.prepare(
|
|
60
|
+
`DELETE FROM ${this.table} WHERE peer_id = ?`
|
|
61
|
+
);
|
|
62
|
+
this.loadStmt = this.db.prepare(
|
|
63
|
+
`SELECT id, peer_id, message, enqueued_at, attempts, last_attempt, next_retry
|
|
64
|
+
FROM ${this.table} ORDER BY enqueued_at ASC`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private migrate(): void {
|
|
69
|
+
this.db.exec(`
|
|
70
|
+
CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
71
|
+
id TEXT PRIMARY KEY,
|
|
72
|
+
peer_id TEXT NOT NULL,
|
|
73
|
+
message TEXT NOT NULL,
|
|
74
|
+
enqueued_at TEXT NOT NULL,
|
|
75
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
last_attempt TEXT,
|
|
77
|
+
next_retry TEXT
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS ${this.table}_peer_idx
|
|
80
|
+
ON ${this.table}(peer_id);
|
|
81
|
+
`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
upsert(entry: QueuedMessage): void {
|
|
85
|
+
this.upsertStmt.run({
|
|
86
|
+
id: entry.id,
|
|
87
|
+
peer_id: entry.peerId,
|
|
88
|
+
message: JSON.stringify(entry.message),
|
|
89
|
+
enqueued_at: entry.enqueuedAt,
|
|
90
|
+
attempts: entry.attempts,
|
|
91
|
+
last_attempt: entry.lastAttempt ?? null,
|
|
92
|
+
next_retry: entry.nextRetry ?? null,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
remove(peerId: string, messageId: string): void {
|
|
97
|
+
this.removeStmt.run(peerId, messageId);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
removePeer(peerId: string): void {
|
|
101
|
+
this.removePeerStmt.run(peerId);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
loadAll(): QueuedMessage[] {
|
|
105
|
+
const rows = this.loadStmt.all() as Array<{
|
|
106
|
+
id: string;
|
|
107
|
+
peer_id: string;
|
|
108
|
+
message: string;
|
|
109
|
+
enqueued_at: string;
|
|
110
|
+
attempts: number;
|
|
111
|
+
last_attempt: string | null;
|
|
112
|
+
next_retry: string | null;
|
|
113
|
+
}>;
|
|
114
|
+
return rows.map((r) => ({
|
|
115
|
+
id: r.id,
|
|
116
|
+
peerId: r.peer_id,
|
|
117
|
+
message: JSON.parse(r.message),
|
|
118
|
+
enqueuedAt: r.enqueued_at,
|
|
119
|
+
attempts: r.attempts,
|
|
120
|
+
lastAttempt: r.last_attempt ?? undefined,
|
|
121
|
+
nextRetry: r.next_retry ?? undefined,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,14 @@ import { DeliveryBridge } from "./mesh/delivery-bridge.js";
|
|
|
16
16
|
import type { MeshContextLike } from "./mesh/mesh-transport.js";
|
|
17
17
|
import type { MeshPeerLike, MeshMapServer } from "./map/map-client.js";
|
|
18
18
|
import { WarmRegistry } from "./registry/warm-registry.js";
|
|
19
|
+
import { MailGateway } from "./mail/mail-gateway.js";
|
|
20
|
+
import { SmtpTransport } from "./mail/smtp-transport.js";
|
|
21
|
+
import { PostmarkTransport } from "./mail/provider-transport.js";
|
|
22
|
+
import type { MailTransport } from "./mail/types.js";
|
|
23
|
+
import { SqliteAttachmentStore } from "./mail/attachment-store.js";
|
|
24
|
+
import { FsAttachmentStore } from "./mail/fs-attachment-store.js";
|
|
25
|
+
import { SqliteQueueStore } from "./federation/queue-store.js";
|
|
26
|
+
import { findDomainConflicts } from "./router/destination.js";
|
|
19
27
|
import type { InboxConfig } from "./types.js";
|
|
20
28
|
import type { Storage } from "./storage/interface.js";
|
|
21
29
|
|
|
@@ -62,6 +70,52 @@ export type { MeshDeliveryHandler } from "./mesh/delivery-bridge.js";
|
|
|
62
70
|
export { mapMessageToInbox, inboxMessageToMap } from "./mesh/type-mapper.js";
|
|
63
71
|
export type { MapMessage as MeshMapMessage, MapAddress as MeshMapAddress } from "./mesh/type-mapper.js";
|
|
64
72
|
export type { MeshPeerLike, MeshAgentConnection, MeshMapServer } from "./map/map-client.js";
|
|
73
|
+
// Mail interop
|
|
74
|
+
export { MailGateway, RateLimitedError } from "./mail/mail-gateway.js";
|
|
75
|
+
export type { MailGatewayConfig } from "./mail/mail-gateway.js";
|
|
76
|
+
export { RateLimiter } from "./mail/rate-limiter.js";
|
|
77
|
+
export { SmtpTransport, parseDsnFromParsed } from "./mail/smtp-transport.js";
|
|
78
|
+
export type { SmtpTransportOptions } from "./mail/smtp-transport.js";
|
|
79
|
+
export {
|
|
80
|
+
PostmarkTransport,
|
|
81
|
+
outboundToPostmark,
|
|
82
|
+
postmarkInboundToMail,
|
|
83
|
+
postmarkBounceToInbound,
|
|
84
|
+
postmarkResponseToResult,
|
|
85
|
+
postmarkErrorToResult,
|
|
86
|
+
} from "./mail/provider-transport.js";
|
|
87
|
+
export type {
|
|
88
|
+
PostmarkTransportOptions,
|
|
89
|
+
PostmarkClient,
|
|
90
|
+
PostmarkInbound,
|
|
91
|
+
PostmarkBounce,
|
|
92
|
+
} from "./mail/provider-transport.js";
|
|
93
|
+
export { AddressBook } from "./mail/address-book.js";
|
|
94
|
+
export { SqliteAttachmentStore } from "./mail/attachment-store.js";
|
|
95
|
+
export { FsAttachmentStore } from "./mail/fs-attachment-store.js";
|
|
96
|
+
export {
|
|
97
|
+
inboundMailToMessage,
|
|
98
|
+
messageToOutboundMail,
|
|
99
|
+
UnknownRecipientError,
|
|
100
|
+
} from "./mail/email-mapper.js";
|
|
101
|
+
export {
|
|
102
|
+
classifyDestination,
|
|
103
|
+
UnknownDestinationError,
|
|
104
|
+
domainMatches,
|
|
105
|
+
findDomainConflicts,
|
|
106
|
+
} from "./router/destination.js";
|
|
107
|
+
export type { Destination, DestinationContext } from "./router/destination.js";
|
|
108
|
+
export type {
|
|
109
|
+
MailTransport,
|
|
110
|
+
MailCapabilities,
|
|
111
|
+
OutboundMail,
|
|
112
|
+
InboundMail,
|
|
113
|
+
MailSendResult,
|
|
114
|
+
InboundBounce,
|
|
115
|
+
InboundAuthResults,
|
|
116
|
+
AttachmentStore,
|
|
117
|
+
} from "./mail/types.js";
|
|
118
|
+
export type { MailEgress } from "./router/message-router.js";
|
|
65
119
|
export type * from "./types.js";
|
|
66
120
|
export type { Storage, InboxQuery, ThreadQuery } from "./storage/interface.js";
|
|
67
121
|
|
|
@@ -103,6 +157,7 @@ export interface AgentInbox {
|
|
|
103
157
|
notifier: PushNotifier;
|
|
104
158
|
federation: ConnectionManager | null;
|
|
105
159
|
registry: WarmRegistry | null;
|
|
160
|
+
mail: MailGateway | null;
|
|
106
161
|
events: EventEmitter;
|
|
107
162
|
stop(): Promise<void>;
|
|
108
163
|
}
|
|
@@ -119,6 +174,18 @@ export interface CreateOptions {
|
|
|
119
174
|
httpPort?: number;
|
|
120
175
|
/** Webhook URLs for push notifications */
|
|
121
176
|
webhooks?: string[];
|
|
177
|
+
/** Per-agent inbox file (NDJSON) caps. Defaults: 1000 entries, 5 MiB. */
|
|
178
|
+
inboxFile?: {
|
|
179
|
+
maxEntries?: number;
|
|
180
|
+
maxBytes?: number;
|
|
181
|
+
};
|
|
182
|
+
/** Periodic retention sweep — deletes messages (and their turns) older
|
|
183
|
+
* than `maxAgeMs`. Disabled unless configured. */
|
|
184
|
+
retention?: {
|
|
185
|
+
maxAgeMs: number;
|
|
186
|
+
/** Sweep interval. Defaults to one hour. */
|
|
187
|
+
sweepIntervalMs?: number;
|
|
188
|
+
};
|
|
122
189
|
/** Enable federation with peer systems */
|
|
123
190
|
enableFederation?: boolean;
|
|
124
191
|
/** Use an externally-managed MAP connection instead of creating one.
|
|
@@ -173,7 +240,12 @@ export async function createAgentInbox(
|
|
|
173
240
|
|
|
174
241
|
// 5. Push notifier (per-agent inbox files + webhooks + event emission)
|
|
175
242
|
const notifier = new PushNotifier(
|
|
176
|
-
{
|
|
243
|
+
{
|
|
244
|
+
inboxDir: defaultInboxDir(),
|
|
245
|
+
webhooks: opts.webhooks,
|
|
246
|
+
maxEntriesPerInbox: opts.inboxFile?.maxEntries,
|
|
247
|
+
maxBytesPerInbox: opts.inboxFile?.maxBytes,
|
|
248
|
+
},
|
|
177
249
|
storage,
|
|
178
250
|
events
|
|
179
251
|
);
|
|
@@ -311,6 +383,97 @@ export async function createAgentInbox(
|
|
|
311
383
|
federation.queue.startTicking();
|
|
312
384
|
}
|
|
313
385
|
|
|
386
|
+
// 7b. Mail interop (inbound + outbound email)
|
|
387
|
+
let mail: MailGateway | null = null;
|
|
388
|
+
if (config.mail?.enabled) {
|
|
389
|
+
const mailConfig = config.mail;
|
|
390
|
+
|
|
391
|
+
// Surface domains claimed by both a federation peer and mail config —
|
|
392
|
+
// federation wins (see classifyDestination), but operators should know.
|
|
393
|
+
const mailDomains = [
|
|
394
|
+
...mailConfig.identity.localDomains,
|
|
395
|
+
...(mailConfig.routableDomains ?? []),
|
|
396
|
+
];
|
|
397
|
+
const domainConflicts = findDomainConflicts({
|
|
398
|
+
isLocalAgent: () => false,
|
|
399
|
+
federationPeers: config.federation?.peers ?? [],
|
|
400
|
+
mailDomains,
|
|
401
|
+
});
|
|
402
|
+
if (domainConflicts.length > 0) {
|
|
403
|
+
events.emit("mail.config.conflict", {
|
|
404
|
+
kind: "domain",
|
|
405
|
+
conflicts: domainConflicts,
|
|
406
|
+
});
|
|
407
|
+
console.error(
|
|
408
|
+
`Mail config warning: domain(s) claimed by both federation and mail — federation wins: ${domainConflicts.join(", ")}`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Durable queue + attachment storage when backed by SQLite.
|
|
413
|
+
const db =
|
|
414
|
+
storage instanceof SqliteStorage ? storage.getDatabase() : undefined;
|
|
415
|
+
const queueStore = db ? new SqliteQueueStore(db) : undefined;
|
|
416
|
+
|
|
417
|
+
// Attachment storage: filesystem (opt-in) or SQLite BLOBs (default).
|
|
418
|
+
let attachmentStore: SqliteAttachmentStore | FsAttachmentStore | undefined;
|
|
419
|
+
if (mailConfig.attachments?.backend === "fs") {
|
|
420
|
+
const dir =
|
|
421
|
+
mailConfig.attachments.dir ??
|
|
422
|
+
path.join(os.homedir(), ".claude", "agent-inbox", "attachments");
|
|
423
|
+
attachmentStore = new FsAttachmentStore(dir);
|
|
424
|
+
} else if (db) {
|
|
425
|
+
attachmentStore = new SqliteAttachmentStore(db);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const transport: MailTransport =
|
|
429
|
+
mailConfig.backend === "provider"
|
|
430
|
+
? new PostmarkTransport({
|
|
431
|
+
serverToken: mailConfig.provider?.apiKeyRef,
|
|
432
|
+
webhook: mailConfig.provider?.webhookPath
|
|
433
|
+
? { port: 0, inboundPath: mailConfig.provider.webhookPath }
|
|
434
|
+
: undefined,
|
|
435
|
+
attachmentStore,
|
|
436
|
+
})
|
|
437
|
+
: new SmtpTransport({
|
|
438
|
+
listenPort: mailConfig.smtp?.listenPort,
|
|
439
|
+
relay: mailConfig.smtp?.relay,
|
|
440
|
+
dkim: mailConfig.smtp?.dkim
|
|
441
|
+
? {
|
|
442
|
+
domainName: mailConfig.smtp.dkim.domain,
|
|
443
|
+
keySelector: mailConfig.smtp.dkim.selector,
|
|
444
|
+
privateKey: mailConfig.smtp.dkim.privateKeyRef,
|
|
445
|
+
}
|
|
446
|
+
: undefined,
|
|
447
|
+
attachmentStore,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
mail = new MailGateway({
|
|
451
|
+
transport,
|
|
452
|
+
storage,
|
|
453
|
+
router,
|
|
454
|
+
events,
|
|
455
|
+
queueStore,
|
|
456
|
+
config: {
|
|
457
|
+
identity: mailConfig.identity,
|
|
458
|
+
routableDomains: mailConfig.routableDomains,
|
|
459
|
+
allowedSenderDomains: mailConfig.allowedSenderDomains,
|
|
460
|
+
rejectDmarcFail: mailConfig.rejectDmarcFail,
|
|
461
|
+
maxAttachments: mailConfig.maxAttachments,
|
|
462
|
+
rateLimit: mailConfig.rateLimit,
|
|
463
|
+
bounce: mailConfig.bounce,
|
|
464
|
+
defaultScope: config.scope,
|
|
465
|
+
queue: mailConfig.queue,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
router.setMail(mail);
|
|
470
|
+
federation?.setMailRecipientFilter((id) => mail!.ownsRecipient(id));
|
|
471
|
+
await mail.start();
|
|
472
|
+
console.error(
|
|
473
|
+
`Mail interop enabled (${mailConfig.backend}) for domains: ${mailConfig.identity.localDomains.join(", ")}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
314
477
|
// 8. JSON-RPC server (mail/* methods)
|
|
315
478
|
const jsonRpc = new MailJsonRpcServer(storage, router, events);
|
|
316
479
|
if (opts.httpPort && opts.httpPort > 0) {
|
|
@@ -323,9 +486,30 @@ export async function createAgentInbox(
|
|
|
323
486
|
const ipcServer = new IpcServer(socketPath, router, storage, jsonRpc);
|
|
324
487
|
await ipcServer.start();
|
|
325
488
|
|
|
489
|
+
// 10. Retention sweep (opt-in)
|
|
490
|
+
let retentionTimer: NodeJS.Timeout | null = null;
|
|
491
|
+
if (opts.retention && opts.retention.maxAgeMs > 0) {
|
|
492
|
+
const interval = opts.retention.sweepIntervalMs ?? 60 * 60 * 1000;
|
|
493
|
+
const maxAgeMs = opts.retention.maxAgeMs;
|
|
494
|
+
const sweep = () => {
|
|
495
|
+
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
|
496
|
+
try {
|
|
497
|
+
storage.pruneMessagesOlderThan(cutoff);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
console.error(
|
|
500
|
+
`Retention sweep failed: ${err instanceof Error ? err.message : err}`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
retentionTimer = setInterval(sweep, interval);
|
|
505
|
+
if (typeof retentionTimer.unref === "function") retentionTimer.unref();
|
|
506
|
+
}
|
|
507
|
+
|
|
326
508
|
const stop = async () => {
|
|
509
|
+
if (retentionTimer) clearInterval(retentionTimer);
|
|
327
510
|
await ipcServer.stop();
|
|
328
511
|
await jsonRpc.stopHttp();
|
|
512
|
+
if (mail) await mail.stop();
|
|
329
513
|
await mapClient.disconnect();
|
|
330
514
|
if (federation) await federation.destroy();
|
|
331
515
|
if (registry) registry.destroy();
|
|
@@ -344,6 +528,7 @@ export async function createAgentInbox(
|
|
|
344
528
|
notifier,
|
|
345
529
|
federation,
|
|
346
530
|
registry,
|
|
531
|
+
mail,
|
|
347
532
|
events,
|
|
348
533
|
stop,
|
|
349
534
|
};
|
|
@@ -38,6 +38,15 @@ export interface MailTurnReceivedParams {
|
|
|
38
38
|
content: MessageContent;
|
|
39
39
|
thread_id?: string;
|
|
40
40
|
created_at: string;
|
|
41
|
+
/**
|
|
42
|
+
* Optional importance hint for dispatch-thread turns. When present,
|
|
43
|
+
* receivers should use this to drive wake/interrupt decisions rather
|
|
44
|
+
* than falling back to a static default.
|
|
45
|
+
*
|
|
46
|
+
* Values follow the agent-inbox Importance type:
|
|
47
|
+
* `"low"` | `"normal"` | `"high"` | `"urgent"`
|
|
48
|
+
*/
|
|
49
|
+
importance?: string;
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
/**
|
|
@@ -54,5 +63,6 @@ export function buildMailTurnReceivedParams(turn: Turn): MailTurnReceivedParams
|
|
|
54
63
|
content: turn.content,
|
|
55
64
|
...(turn.thread_id ? { thread_id: turn.thread_id } : {}),
|
|
56
65
|
created_at: turn.created_at,
|
|
66
|
+
...(turn.importance ? { importance: turn.importance } : {}),
|
|
57
67
|
};
|
|
58
68
|
}
|