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.
Files changed (149) hide show
  1. package/AGENTS.md +18 -0
  2. package/CLAUDE.md +92 -1
  3. package/README.md +73 -6
  4. package/bench/inbox-growth.bench.ts +224 -0
  5. package/dist/federation/connection-manager.d.ts +8 -0
  6. package/dist/federation/connection-manager.d.ts.map +1 -1
  7. package/dist/federation/connection-manager.js +12 -0
  8. package/dist/federation/connection-manager.js.map +1 -1
  9. package/dist/federation/delivery-queue.d.ts +11 -3
  10. package/dist/federation/delivery-queue.d.ts.map +1 -1
  11. package/dist/federation/delivery-queue.js +38 -8
  12. package/dist/federation/delivery-queue.js.map +1 -1
  13. package/dist/federation/queue-store.d.ts +42 -0
  14. package/dist/federation/queue-store.d.ts.map +1 -0
  15. package/dist/federation/queue-store.js +87 -0
  16. package/dist/federation/queue-store.js.map +1 -0
  17. package/dist/index.d.mts +2 -0
  18. package/dist/index.d.ts +29 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +124 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +1 -0
  23. package/dist/index.mjs.map +1 -0
  24. package/dist/jsonrpc/mail-push-types.d.ts +9 -0
  25. package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
  26. package/dist/jsonrpc/mail-push-types.js +1 -0
  27. package/dist/jsonrpc/mail-push-types.js.map +1 -1
  28. package/dist/jsonrpc/mail-server.d.ts +8 -1
  29. package/dist/jsonrpc/mail-server.d.ts.map +1 -1
  30. package/dist/jsonrpc/mail-server.js +42 -1
  31. package/dist/jsonrpc/mail-server.js.map +1 -1
  32. package/dist/mail/address-book.d.ts +43 -0
  33. package/dist/mail/address-book.d.ts.map +1 -0
  34. package/dist/mail/address-book.js +95 -0
  35. package/dist/mail/address-book.js.map +1 -0
  36. package/dist/mail/attachment-store.d.ts +31 -0
  37. package/dist/mail/attachment-store.d.ts.map +1 -0
  38. package/dist/mail/attachment-store.js +74 -0
  39. package/dist/mail/attachment-store.js.map +1 -0
  40. package/dist/mail/email-mapper.d.ts +41 -0
  41. package/dist/mail/email-mapper.d.ts.map +1 -0
  42. package/dist/mail/email-mapper.js +216 -0
  43. package/dist/mail/email-mapper.js.map +1 -0
  44. package/dist/mail/fs-attachment-store.d.ts +38 -0
  45. package/dist/mail/fs-attachment-store.d.ts.map +1 -0
  46. package/dist/mail/fs-attachment-store.js +165 -0
  47. package/dist/mail/fs-attachment-store.js.map +1 -0
  48. package/dist/mail/mail-gateway.d.ts +114 -0
  49. package/dist/mail/mail-gateway.d.ts.map +1 -0
  50. package/dist/mail/mail-gateway.js +402 -0
  51. package/dist/mail/mail-gateway.js.map +1 -0
  52. package/dist/mail/provider-transport.d.ts +138 -0
  53. package/dist/mail/provider-transport.d.ts.map +1 -0
  54. package/dist/mail/provider-transport.js +434 -0
  55. package/dist/mail/provider-transport.js.map +1 -0
  56. package/dist/mail/rate-limiter.d.ts +20 -0
  57. package/dist/mail/rate-limiter.d.ts.map +1 -0
  58. package/dist/mail/rate-limiter.js +56 -0
  59. package/dist/mail/rate-limiter.js.map +1 -0
  60. package/dist/mail/smtp-transport.d.ts +141 -0
  61. package/dist/mail/smtp-transport.d.ts.map +1 -0
  62. package/dist/mail/smtp-transport.js +415 -0
  63. package/dist/mail/smtp-transport.js.map +1 -0
  64. package/dist/mail/types.d.ts +177 -0
  65. package/dist/mail/types.d.ts.map +1 -0
  66. package/dist/mail/types.js +11 -0
  67. package/dist/mail/types.js.map +1 -0
  68. package/dist/push/notifier.d.ts +21 -0
  69. package/dist/push/notifier.d.ts.map +1 -1
  70. package/dist/push/notifier.js +84 -2
  71. package/dist/push/notifier.js.map +1 -1
  72. package/dist/router/destination.d.ts +69 -0
  73. package/dist/router/destination.d.ts.map +1 -0
  74. package/dist/router/destination.js +106 -0
  75. package/dist/router/destination.js.map +1 -0
  76. package/dist/router/message-router.d.ts +15 -0
  77. package/dist/router/message-router.d.ts.map +1 -1
  78. package/dist/router/message-router.js +25 -3
  79. package/dist/router/message-router.js.map +1 -1
  80. package/dist/storage/interface.d.ts +21 -0
  81. package/dist/storage/interface.d.ts.map +1 -1
  82. package/dist/storage/memory.d.ts +12 -0
  83. package/dist/storage/memory.d.ts.map +1 -1
  84. package/dist/storage/memory.js +50 -0
  85. package/dist/storage/memory.js.map +1 -1
  86. package/dist/storage/sqlite.d.ts +14 -0
  87. package/dist/storage/sqlite.d.ts.map +1 -1
  88. package/dist/storage/sqlite.js +79 -1
  89. package/dist/storage/sqlite.js.map +1 -1
  90. package/dist/traceability/traceability.d.ts.map +1 -1
  91. package/dist/traceability/traceability.js +7 -17
  92. package/dist/traceability/traceability.js.map +1 -1
  93. package/dist/types.d.ts +80 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/docs/DESIGN.md +15 -0
  96. package/docs/MAIL-INTEROP-PLAN.md +660 -0
  97. package/package.json +29 -3
  98. package/renovate.json5 +6 -0
  99. package/rules/agent-inbox.md +1 -0
  100. package/src/federation/connection-manager.ts +12 -0
  101. package/src/federation/delivery-queue.ts +38 -8
  102. package/src/federation/queue-store.ts +124 -0
  103. package/src/index.ts +186 -1
  104. package/src/jsonrpc/mail-push-types.ts +10 -0
  105. package/src/jsonrpc/mail-server.ts +48 -1
  106. package/src/mail/address-book.ts +111 -0
  107. package/src/mail/attachment-store.ts +90 -0
  108. package/src/mail/email-mapper.ts +288 -0
  109. package/src/mail/fs-attachment-store.ts +163 -0
  110. package/src/mail/mail-gateway.ts +505 -0
  111. package/src/mail/provider-transport.ts +577 -0
  112. package/src/mail/rate-limiter.ts +51 -0
  113. package/src/mail/smtp-transport.ts +589 -0
  114. package/src/mail/types.ts +221 -0
  115. package/src/push/notifier.ts +98 -2
  116. package/src/router/destination.ts +140 -0
  117. package/src/router/message-router.ts +41 -4
  118. package/src/storage/interface.ts +22 -0
  119. package/src/storage/memory.ts +59 -0
  120. package/src/storage/sqlite.ts +114 -1
  121. package/src/traceability/traceability.ts +7 -16
  122. package/src/types.ts +74 -0
  123. package/test/federation/delivery-queue-sqlite.test.ts +158 -0
  124. package/test/load.test.ts +288 -0
  125. package/test/mail/address-book.test.ts +111 -0
  126. package/test/mail/attachment-store-contract.test.ts +92 -0
  127. package/test/mail/attachment-store.test.ts +69 -0
  128. package/test/mail/destination.test.ts +115 -0
  129. package/test/mail/dsn-parse.test.ts +239 -0
  130. package/test/mail/email-mapper.test.ts +341 -0
  131. package/test/mail/external-id.test.ts +43 -0
  132. package/test/mail/fs-attachment-store.test.ts +134 -0
  133. package/test/mail/full-flow-e2e.test.ts +200 -0
  134. package/test/mail/mail-gateway.test.ts +419 -0
  135. package/test/mail/mail-transport-contract.test.ts +134 -0
  136. package/test/mail/mock-mail.ts +161 -0
  137. package/test/mail/mock-postmark.ts +66 -0
  138. package/test/mail/provider-transport.test.ts +381 -0
  139. package/test/mail/rate-limiter.test.ts +48 -0
  140. package/test/mail/router-mail-integration.test.ts +138 -0
  141. package/test/mail/smtp-e2e.test.ts +98 -0
  142. package/test/mail/smtp-transport.test.ts +138 -0
  143. package/test/mail-presence.test.ts +149 -0
  144. package/test/mail-push.test.ts +44 -0
  145. package/test/mail-server.test.ts +25 -0
  146. package/test/push-notifier.test.ts +81 -0
  147. package/test/sqlite-storage.test.ts +106 -0
  148. package/test/storage.test.ts +92 -0
  149. 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.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
@@ -0,0 +1,6 @@
1
+ {
2
+ // Dependency-update policy is shared across the swarm/openhive ecosystem.
3
+ // See github.com/alexngai/swarm-renovate-config.
4
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
5
+ "extends": ["github>alexngai/swarm-renovate-config"]
6
+ }
@@ -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
- * Currently memory-only; SQLite persistence is a future enhancement.
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 = queue.filter((entry) => {
178
+ const remaining: QueuedMessage[] = [];
179
+ for (const entry of queue) {
154
180
  const age = now - new Date(entry.enqueuedAt).getTime();
155
- return age < this.config.maxTTL;
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
- { inboxDir: defaultInboxDir(), webhooks: opts.webhooks },
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
  }