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.
Files changed (122) hide show
  1. package/AGENTS.md +18 -0
  2. package/CLAUDE.md +92 -1
  3. package/README.md +73 -6
  4. package/dist/federation/connection-manager.d.ts +8 -0
  5. package/dist/federation/connection-manager.d.ts.map +1 -1
  6. package/dist/federation/connection-manager.js +12 -0
  7. package/dist/federation/connection-manager.js.map +1 -1
  8. package/dist/federation/delivery-queue.d.ts +11 -3
  9. package/dist/federation/delivery-queue.d.ts.map +1 -1
  10. package/dist/federation/delivery-queue.js +38 -8
  11. package/dist/federation/delivery-queue.js.map +1 -1
  12. package/dist/federation/queue-store.d.ts +42 -0
  13. package/dist/federation/queue-store.d.ts.map +1 -0
  14. package/dist/federation/queue-store.js +87 -0
  15. package/dist/federation/queue-store.js.map +1 -0
  16. package/dist/index.d.mts +2 -0
  17. package/dist/index.d.ts +17 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +98 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs +1 -0
  22. package/dist/index.mjs.map +1 -0
  23. package/dist/mail/address-book.d.ts +43 -0
  24. package/dist/mail/address-book.d.ts.map +1 -0
  25. package/dist/mail/address-book.js +95 -0
  26. package/dist/mail/address-book.js.map +1 -0
  27. package/dist/mail/attachment-store.d.ts +31 -0
  28. package/dist/mail/attachment-store.d.ts.map +1 -0
  29. package/dist/mail/attachment-store.js +74 -0
  30. package/dist/mail/attachment-store.js.map +1 -0
  31. package/dist/mail/email-mapper.d.ts +41 -0
  32. package/dist/mail/email-mapper.d.ts.map +1 -0
  33. package/dist/mail/email-mapper.js +216 -0
  34. package/dist/mail/email-mapper.js.map +1 -0
  35. package/dist/mail/fs-attachment-store.d.ts +38 -0
  36. package/dist/mail/fs-attachment-store.d.ts.map +1 -0
  37. package/dist/mail/fs-attachment-store.js +165 -0
  38. package/dist/mail/fs-attachment-store.js.map +1 -0
  39. package/dist/mail/mail-gateway.d.ts +114 -0
  40. package/dist/mail/mail-gateway.d.ts.map +1 -0
  41. package/dist/mail/mail-gateway.js +402 -0
  42. package/dist/mail/mail-gateway.js.map +1 -0
  43. package/dist/mail/provider-transport.d.ts +138 -0
  44. package/dist/mail/provider-transport.d.ts.map +1 -0
  45. package/dist/mail/provider-transport.js +434 -0
  46. package/dist/mail/provider-transport.js.map +1 -0
  47. package/dist/mail/rate-limiter.d.ts +20 -0
  48. package/dist/mail/rate-limiter.d.ts.map +1 -0
  49. package/dist/mail/rate-limiter.js +56 -0
  50. package/dist/mail/rate-limiter.js.map +1 -0
  51. package/dist/mail/smtp-transport.d.ts +141 -0
  52. package/dist/mail/smtp-transport.d.ts.map +1 -0
  53. package/dist/mail/smtp-transport.js +415 -0
  54. package/dist/mail/smtp-transport.js.map +1 -0
  55. package/dist/mail/types.d.ts +177 -0
  56. package/dist/mail/types.d.ts.map +1 -0
  57. package/dist/mail/types.js +11 -0
  58. package/dist/mail/types.js.map +1 -0
  59. package/dist/router/destination.d.ts +69 -0
  60. package/dist/router/destination.d.ts.map +1 -0
  61. package/dist/router/destination.js +106 -0
  62. package/dist/router/destination.js.map +1 -0
  63. package/dist/router/message-router.d.ts +15 -0
  64. package/dist/router/message-router.d.ts.map +1 -1
  65. package/dist/router/message-router.js +25 -3
  66. package/dist/router/message-router.js.map +1 -1
  67. package/dist/storage/interface.d.ts +9 -0
  68. package/dist/storage/interface.d.ts.map +1 -1
  69. package/dist/storage/memory.d.ts +4 -0
  70. package/dist/storage/memory.d.ts.map +1 -1
  71. package/dist/storage/memory.js +12 -0
  72. package/dist/storage/memory.js.map +1 -1
  73. package/dist/storage/sqlite.d.ts +6 -0
  74. package/dist/storage/sqlite.d.ts.map +1 -1
  75. package/dist/storage/sqlite.js +28 -0
  76. package/dist/storage/sqlite.js.map +1 -1
  77. package/dist/types.d.ts +79 -0
  78. package/dist/types.d.ts.map +1 -1
  79. package/docs/DESIGN.md +15 -0
  80. package/docs/MAIL-INTEROP-PLAN.md +660 -0
  81. package/package.json +28 -3
  82. package/renovate.json5 +6 -0
  83. package/rules/agent-inbox.md +1 -0
  84. package/src/federation/connection-manager.ts +12 -0
  85. package/src/federation/delivery-queue.ts +38 -8
  86. package/src/federation/queue-store.ts +124 -0
  87. package/src/index.ts +148 -0
  88. package/src/mail/address-book.ts +111 -0
  89. package/src/mail/attachment-store.ts +90 -0
  90. package/src/mail/email-mapper.ts +288 -0
  91. package/src/mail/fs-attachment-store.ts +163 -0
  92. package/src/mail/mail-gateway.ts +505 -0
  93. package/src/mail/provider-transport.ts +577 -0
  94. package/src/mail/rate-limiter.ts +51 -0
  95. package/src/mail/smtp-transport.ts +589 -0
  96. package/src/mail/types.ts +221 -0
  97. package/src/router/destination.ts +140 -0
  98. package/src/router/message-router.ts +41 -4
  99. package/src/storage/interface.ts +11 -0
  100. package/src/storage/memory.ts +15 -0
  101. package/src/storage/sqlite.ts +36 -0
  102. package/src/types.ts +73 -0
  103. package/test/federation/delivery-queue-sqlite.test.ts +158 -0
  104. package/test/load.test.ts +1 -1
  105. package/test/mail/address-book.test.ts +111 -0
  106. package/test/mail/attachment-store-contract.test.ts +92 -0
  107. package/test/mail/attachment-store.test.ts +69 -0
  108. package/test/mail/destination.test.ts +115 -0
  109. package/test/mail/dsn-parse.test.ts +239 -0
  110. package/test/mail/email-mapper.test.ts +341 -0
  111. package/test/mail/external-id.test.ts +43 -0
  112. package/test/mail/fs-attachment-store.test.ts +134 -0
  113. package/test/mail/full-flow-e2e.test.ts +200 -0
  114. package/test/mail/mail-gateway.test.ts +419 -0
  115. package/test/mail/mail-transport-contract.test.ts +134 -0
  116. package/test/mail/mock-mail.ts +161 -0
  117. package/test/mail/mock-postmark.ts +66 -0
  118. package/test/mail/provider-transport.test.ts +381 -0
  119. package/test/mail/rate-limiter.test.ts +48 -0
  120. package/test/mail/router-mail-integration.test.ts +138 -0
  121. package/test/mail/smtp-e2e.test.ts +98 -0
  122. 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",
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
@@ -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
  }
@@ -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
+ }