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
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",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"bench:growth": "vitest run --config vitest.bench.config.ts",
|
|
17
17
|
"start": "node dist/index.js",
|
|
18
18
|
"publish:npm": "npm publish --access public",
|
|
19
|
-
"prepublishOnly": "npm run build",
|
|
19
|
+
"prepublishOnly": "npm run build && publint",
|
|
20
20
|
"version:patch": "npm version patch && git push && git push --tags",
|
|
21
21
|
"version:minor": "npm version minor && git push && git push --tags",
|
|
22
22
|
"version:major": "npm version major && git push && git push --tags"
|
|
@@ -42,7 +42,12 @@
|
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@multi-agent-protocol/sdk": "*",
|
|
45
|
-
"agentic-mesh": "*"
|
|
45
|
+
"agentic-mesh": "*",
|
|
46
|
+
"mailauth": "*",
|
|
47
|
+
"mailparser": "*",
|
|
48
|
+
"nodemailer": "*",
|
|
49
|
+
"postmark": "*",
|
|
50
|
+
"smtp-server": "*"
|
|
46
51
|
},
|
|
47
52
|
"peerDependenciesMeta": {
|
|
48
53
|
"@multi-agent-protocol/sdk": {
|
|
@@ -50,12 +55,32 @@
|
|
|
50
55
|
},
|
|
51
56
|
"agentic-mesh": {
|
|
52
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
|
|
53
73
|
}
|
|
54
74
|
},
|
|
55
75
|
"devDependencies": {
|
|
56
76
|
"@types/better-sqlite3": "^7.6.13",
|
|
57
77
|
"@types/node": "^22.0.0",
|
|
58
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",
|
|
59
84
|
"typescript": "^5.7.0",
|
|
60
85
|
"vitest": "^3.0.0"
|
|
61
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
|
}
|
|
@@ -328,6 +383,97 @@ export async function createAgentInbox(
|
|
|
328
383
|
federation.queue.startTicking();
|
|
329
384
|
}
|
|
330
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
|
+
|
|
331
477
|
// 8. JSON-RPC server (mail/* methods)
|
|
332
478
|
const jsonRpc = new MailJsonRpcServer(storage, router, events);
|
|
333
479
|
if (opts.httpPort && opts.httpPort > 0) {
|
|
@@ -363,6 +509,7 @@ export async function createAgentInbox(
|
|
|
363
509
|
if (retentionTimer) clearInterval(retentionTimer);
|
|
364
510
|
await ipcServer.stop();
|
|
365
511
|
await jsonRpc.stopHttp();
|
|
512
|
+
if (mail) await mail.stop();
|
|
366
513
|
await mapClient.disconnect();
|
|
367
514
|
if (federation) await federation.destroy();
|
|
368
515
|
if (registry) registry.destroy();
|
|
@@ -381,6 +528,7 @@ export async function createAgentInbox(
|
|
|
381
528
|
notifier,
|
|
382
529
|
federation,
|
|
383
530
|
registry,
|
|
531
|
+
mail,
|
|
384
532
|
events,
|
|
385
533
|
stop,
|
|
386
534
|
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail identity / address book.
|
|
3
|
+
*
|
|
4
|
+
* Maps between agent ids and external email addresses, and declares which
|
|
5
|
+
* domains we own (for inbound RCPT acceptance). Plus-addressing aware:
|
|
6
|
+
* `agent+scope@domain` resolves to agent `agent` in inbox scope `scope`.
|
|
7
|
+
*
|
|
8
|
+
* See docs/MAIL-INTEROP-PLAN.md §6.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MailIdentityConfig } from "../types.js";
|
|
12
|
+
import type { MailAddress } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export interface ResolvedInbound {
|
|
15
|
+
agentId: string;
|
|
16
|
+
/** Inbox scope from plus-addressing, if present. */
|
|
17
|
+
scope?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class AddressBook {
|
|
21
|
+
private localDomains: string[];
|
|
22
|
+
/** lower-cased address → agentId */
|
|
23
|
+
private byAddress = new Map<string, string>();
|
|
24
|
+
/** agentId → canonical address */
|
|
25
|
+
private byAgent = new Map<string, string>();
|
|
26
|
+
private catchAllAgentId?: string;
|
|
27
|
+
/** Address mappings that collided (same address → multiple agents). */
|
|
28
|
+
readonly conflicts: string[] = [];
|
|
29
|
+
|
|
30
|
+
constructor(config: MailIdentityConfig) {
|
|
31
|
+
this.localDomains = config.localDomains.map((d) => d.toLowerCase());
|
|
32
|
+
this.catchAllAgentId = config.catchAllAgentId;
|
|
33
|
+
for (const { agentId, address } of config.mappings) {
|
|
34
|
+
const lower = address.toLowerCase();
|
|
35
|
+
const existing = this.byAddress.get(lower);
|
|
36
|
+
if (existing !== undefined && existing !== agentId) {
|
|
37
|
+
// Two agents mapped to the same address — inbound routing would be
|
|
38
|
+
// ambiguous. Keep the first and record the collision for surfacing.
|
|
39
|
+
this.conflicts.push(`${address} → ${existing} & ${agentId}`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
this.byAddress.set(lower, agentId);
|
|
43
|
+
// First mapping for an agent is its canonical from-address.
|
|
44
|
+
if (!this.byAgent.has(agentId)) this.byAgent.set(agentId, address);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Primary local domain — used to mint Message-IDs and default from-addresses. */
|
|
49
|
+
primaryDomain(): string {
|
|
50
|
+
return this.localDomains[0] ?? "localhost";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True if `domain` (or a parent of it) is one we receive mail for. */
|
|
54
|
+
ownsDomain(domain: string): boolean {
|
|
55
|
+
const d = domain.toLowerCase();
|
|
56
|
+
return this.localDomains.some(
|
|
57
|
+
(own) => d === own || d.endsWith(`.${own}`)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve an inbound recipient email to a local agent (+ scope).
|
|
63
|
+
* Returns undefined if the domain is not ours, or the mailbox is unknown and
|
|
64
|
+
* no catch-all is configured.
|
|
65
|
+
*/
|
|
66
|
+
resolveInbound(address: string): ResolvedInbound | undefined {
|
|
67
|
+
const parsed = splitAddress(address);
|
|
68
|
+
if (!parsed) return undefined;
|
|
69
|
+
const { local, domain } = parsed;
|
|
70
|
+
|
|
71
|
+
if (!this.ownsDomain(domain)) return undefined;
|
|
72
|
+
|
|
73
|
+
// Plus-addressing: agent+scope → base agent, scope.
|
|
74
|
+
const plusIdx = local.indexOf("+");
|
|
75
|
+
const base = plusIdx === -1 ? local : local.slice(0, plusIdx);
|
|
76
|
+
const scope = plusIdx === -1 ? undefined : local.slice(plusIdx + 1) || undefined;
|
|
77
|
+
|
|
78
|
+
const canonical = `${base}@${domain}`.toLowerCase();
|
|
79
|
+
const agentId = this.byAddress.get(canonical);
|
|
80
|
+
if (agentId) return { agentId, scope };
|
|
81
|
+
|
|
82
|
+
if (this.catchAllAgentId) return { agentId: this.catchAllAgentId, scope };
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the from-address for an outbound sender agent. Falls back to
|
|
88
|
+
* `<agentId>@<primaryDomain>` when the agent has no explicit mapping.
|
|
89
|
+
*/
|
|
90
|
+
resolveFrom(agentId: string): MailAddress {
|
|
91
|
+
const mapped = this.byAgent.get(agentId);
|
|
92
|
+
if (mapped) return { address: mapped };
|
|
93
|
+
return { address: `${agentId}@${this.primaryDomain()}` };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Split an addr-spec into local-part and domain. Returns undefined if malformed. */
|
|
98
|
+
function splitAddress(
|
|
99
|
+
address: string
|
|
100
|
+
): { local: string; domain: string } | undefined {
|
|
101
|
+
const at = address.indexOf("@");
|
|
102
|
+
if (at <= 0 || at !== address.lastIndexOf("@") || at === address.length - 1) {
|
|
103
|
+
// Reject empty local/domain and unquoted multiple "@" (e.g. "a@b@c"), so
|
|
104
|
+
// this layer agrees with parseAddress (which splits on the first "@").
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const domain = address.slice(at + 1);
|
|
108
|
+
// A domain must not contain "/" (a federation scope separator) — defensive.
|
|
109
|
+
if (domain.includes("/")) return undefined;
|
|
110
|
+
return { local: address.slice(0, at), domain };
|
|
111
|
+
}
|