agent-inbox 0.2.4 → 0.2.5

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 (138) hide show
  1. package/CLAUDE.md +1 -92
  2. package/README.md +6 -73
  3. package/dist/federation/connection-manager.d.ts +0 -8
  4. package/dist/federation/connection-manager.d.ts.map +1 -1
  5. package/dist/federation/connection-manager.js +0 -12
  6. package/dist/federation/connection-manager.js.map +1 -1
  7. package/dist/federation/delivery-queue.d.ts +3 -11
  8. package/dist/federation/delivery-queue.d.ts.map +1 -1
  9. package/dist/federation/delivery-queue.js +8 -38
  10. package/dist/federation/delivery-queue.js.map +1 -1
  11. package/dist/index.d.ts +0 -17
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +0 -98
  14. package/dist/index.js.map +1 -1
  15. package/dist/jsonrpc/mail-push-types.d.ts +22 -2
  16. package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
  17. package/dist/jsonrpc/mail-push-types.js +18 -1
  18. package/dist/jsonrpc/mail-push-types.js.map +1 -1
  19. package/dist/jsonrpc/mail-push.d.ts +12 -1
  20. package/dist/jsonrpc/mail-push.d.ts.map +1 -1
  21. package/dist/jsonrpc/mail-push.js +13 -2
  22. package/dist/jsonrpc/mail-push.js.map +1 -1
  23. package/dist/jsonrpc/mail-server.d.ts.map +1 -1
  24. package/dist/jsonrpc/mail-server.js +42 -18
  25. package/dist/jsonrpc/mail-server.js.map +1 -1
  26. package/dist/router/message-router.d.ts +0 -15
  27. package/dist/router/message-router.d.ts.map +1 -1
  28. package/dist/router/message-router.js +3 -25
  29. package/dist/router/message-router.js.map +1 -1
  30. package/dist/storage/interface.d.ts +2 -9
  31. package/dist/storage/interface.d.ts.map +1 -1
  32. package/dist/storage/memory.d.ts +1 -4
  33. package/dist/storage/memory.d.ts.map +1 -1
  34. package/dist/storage/memory.js +6 -12
  35. package/dist/storage/memory.js.map +1 -1
  36. package/dist/storage/sqlite.d.ts +1 -6
  37. package/dist/storage/sqlite.d.ts.map +1 -1
  38. package/dist/storage/sqlite.js +6 -28
  39. package/dist/storage/sqlite.js.map +1 -1
  40. package/dist/types.d.ts +0 -79
  41. package/dist/types.d.ts.map +1 -1
  42. package/docs/DESIGN.md +0 -15
  43. package/package.json +3 -28
  44. package/rules/agent-inbox.md +0 -1
  45. package/src/federation/connection-manager.ts +0 -12
  46. package/src/federation/delivery-queue.ts +8 -38
  47. package/src/index.ts +0 -148
  48. package/src/jsonrpc/mail-push-types.ts +43 -2
  49. package/src/jsonrpc/mail-push.ts +34 -3
  50. package/src/jsonrpc/mail-server.ts +45 -26
  51. package/src/router/message-router.ts +4 -41
  52. package/src/storage/interface.ts +2 -11
  53. package/src/storage/memory.ts +8 -15
  54. package/src/storage/sqlite.ts +9 -36
  55. package/src/types.ts +0 -73
  56. package/test/load.test.ts +1 -1
  57. package/test/mail-push.test.ts +101 -1
  58. package/test/mail-server.test.ts +108 -0
  59. package/AGENTS.md +0 -18
  60. package/dist/federation/queue-store.d.ts +0 -42
  61. package/dist/federation/queue-store.d.ts.map +0 -1
  62. package/dist/federation/queue-store.js +0 -87
  63. package/dist/federation/queue-store.js.map +0 -1
  64. package/dist/index.d.mts +0 -2
  65. package/dist/index.mjs +0 -1
  66. package/dist/index.mjs.map +0 -1
  67. package/dist/mail/address-book.d.ts +0 -43
  68. package/dist/mail/address-book.d.ts.map +0 -1
  69. package/dist/mail/address-book.js +0 -95
  70. package/dist/mail/address-book.js.map +0 -1
  71. package/dist/mail/attachment-store.d.ts +0 -31
  72. package/dist/mail/attachment-store.d.ts.map +0 -1
  73. package/dist/mail/attachment-store.js +0 -74
  74. package/dist/mail/attachment-store.js.map +0 -1
  75. package/dist/mail/email-mapper.d.ts +0 -41
  76. package/dist/mail/email-mapper.d.ts.map +0 -1
  77. package/dist/mail/email-mapper.js +0 -216
  78. package/dist/mail/email-mapper.js.map +0 -1
  79. package/dist/mail/fs-attachment-store.d.ts +0 -38
  80. package/dist/mail/fs-attachment-store.d.ts.map +0 -1
  81. package/dist/mail/fs-attachment-store.js +0 -165
  82. package/dist/mail/fs-attachment-store.js.map +0 -1
  83. package/dist/mail/mail-gateway.d.ts +0 -114
  84. package/dist/mail/mail-gateway.d.ts.map +0 -1
  85. package/dist/mail/mail-gateway.js +0 -402
  86. package/dist/mail/mail-gateway.js.map +0 -1
  87. package/dist/mail/provider-transport.d.ts +0 -138
  88. package/dist/mail/provider-transport.d.ts.map +0 -1
  89. package/dist/mail/provider-transport.js +0 -434
  90. package/dist/mail/provider-transport.js.map +0 -1
  91. package/dist/mail/rate-limiter.d.ts +0 -20
  92. package/dist/mail/rate-limiter.d.ts.map +0 -1
  93. package/dist/mail/rate-limiter.js +0 -56
  94. package/dist/mail/rate-limiter.js.map +0 -1
  95. package/dist/mail/smtp-transport.d.ts +0 -141
  96. package/dist/mail/smtp-transport.d.ts.map +0 -1
  97. package/dist/mail/smtp-transport.js +0 -415
  98. package/dist/mail/smtp-transport.js.map +0 -1
  99. package/dist/mail/types.d.ts +0 -177
  100. package/dist/mail/types.d.ts.map +0 -1
  101. package/dist/mail/types.js +0 -11
  102. package/dist/mail/types.js.map +0 -1
  103. package/dist/router/destination.d.ts +0 -69
  104. package/dist/router/destination.d.ts.map +0 -1
  105. package/dist/router/destination.js +0 -106
  106. package/dist/router/destination.js.map +0 -1
  107. package/docs/MAIL-INTEROP-PLAN.md +0 -660
  108. package/renovate.json5 +0 -6
  109. package/src/federation/queue-store.ts +0 -124
  110. package/src/mail/address-book.ts +0 -111
  111. package/src/mail/attachment-store.ts +0 -90
  112. package/src/mail/email-mapper.ts +0 -288
  113. package/src/mail/fs-attachment-store.ts +0 -163
  114. package/src/mail/mail-gateway.ts +0 -505
  115. package/src/mail/provider-transport.ts +0 -577
  116. package/src/mail/rate-limiter.ts +0 -51
  117. package/src/mail/smtp-transport.ts +0 -589
  118. package/src/mail/types.ts +0 -221
  119. package/src/router/destination.ts +0 -140
  120. package/test/federation/delivery-queue-sqlite.test.ts +0 -158
  121. package/test/mail/address-book.test.ts +0 -111
  122. package/test/mail/attachment-store-contract.test.ts +0 -92
  123. package/test/mail/attachment-store.test.ts +0 -69
  124. package/test/mail/destination.test.ts +0 -115
  125. package/test/mail/dsn-parse.test.ts +0 -239
  126. package/test/mail/email-mapper.test.ts +0 -341
  127. package/test/mail/external-id.test.ts +0 -43
  128. package/test/mail/fs-attachment-store.test.ts +0 -134
  129. package/test/mail/full-flow-e2e.test.ts +0 -200
  130. package/test/mail/mail-gateway.test.ts +0 -419
  131. package/test/mail/mail-transport-contract.test.ts +0 -134
  132. package/test/mail/mock-mail.ts +0 -161
  133. package/test/mail/mock-postmark.ts +0 -66
  134. package/test/mail/provider-transport.test.ts +0 -381
  135. package/test/mail/rate-limiter.test.ts +0 -48
  136. package/test/mail/router-mail-integration.test.ts +0 -138
  137. package/test/mail/smtp-e2e.test.ts +0 -98
  138. package/test/mail/smtp-transport.test.ts +0 -138
@@ -60,7 +60,6 @@ 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.
64
63
 
65
64
  ## Discovering other agents
66
65
 
@@ -59,8 +59,6 @@ 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;
64
62
  readonly routing: RoutingEngine;
65
63
  readonly queue: DeliveryQueue;
66
64
  readonly trust: TrustManager;
@@ -112,15 +110,6 @@ export class ConnectionManager {
112
110
  return this.systemId;
113
111
  }
114
112
 
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
-
124
113
  /**
125
114
  * Establish federation with a peer. Uses MAP federation/connect protocol.
126
115
  * If an SDK class was injected, opens a real MAP connection to the peer.
@@ -275,7 +264,6 @@ export class ConnectionManager {
275
264
  async route(message: Message): Promise<DeliveryResult> {
276
265
  // Determine target for each recipient
277
266
  for (const recipient of message.recipients) {
278
- if (this.mailFilter?.(recipient.agent_id)) continue; // handled by mail gateway
279
267
  const addr = parseAddress(recipient.agent_id);
280
268
  if (!isRemoteAddress(addr)) continue;
281
269
 
@@ -1,7 +1,6 @@
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";
5
4
 
6
5
  const DEFAULT_CONFIG: DeliveryQueueConfig = {
7
6
  persistence: "memory",
@@ -17,37 +16,19 @@ const DEFAULT_CONFIG: DeliveryQueueConfig = {
17
16
  /**
18
17
  * Delivery queue for messages to offline or unreachable federation peers.
19
18
  *
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.
19
+ * Supports configurable TTL, overflow policy, and retry strategy.
20
+ * Currently memory-only; SQLite persistence is a future enhancement.
24
21
  */
25
22
  export class DeliveryQueue {
26
23
  private queues = new Map<string, QueuedMessage[]>();
27
24
  private config: DeliveryQueueConfig;
28
25
  private tickTimer?: ReturnType<typeof setInterval>;
29
- private store: QueueStore | null;
30
26
 
31
27
  constructor(
32
28
  private events: EventEmitter,
33
- config?: Partial<DeliveryQueueConfig>,
34
- opts?: { store?: QueueStore }
29
+ config?: Partial<DeliveryQueueConfig>
35
30
  ) {
36
31
  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
- }
51
32
  }
52
33
 
53
34
  /**
@@ -63,11 +44,9 @@ export class DeliveryQueue {
63
44
  // Check queue size limit
64
45
  if (queue.length >= this.config.maxQueueSize) {
65
46
  switch (this.config.overflow) {
66
- case "drop-oldest": {
67
- const dropped = queue.shift();
68
- if (dropped) this.store?.remove(peerId, dropped.id);
47
+ case "drop-oldest":
48
+ queue.shift();
69
49
  break;
70
- }
71
50
  case "drop-newest":
72
51
  return null; // Don't enqueue
73
52
  case "reject-new":
@@ -86,7 +65,6 @@ export class DeliveryQueue {
86
65
  };
87
66
 
88
67
  queue.push(entry);
89
- this.store?.upsert(entry);
90
68
  this.events.emit("queue.enqueued", { peerId, messageId: entry.id });
91
69
  return entry.id;
92
70
  }
@@ -101,7 +79,6 @@ export class DeliveryQueue {
101
79
 
102
80
  const messages = [...queue];
103
81
  this.queues.delete(peerId);
104
- this.store?.removePeer(peerId);
105
82
  this.events.emit("queue.flushed", { peerId, count: messages.length });
106
83
  return messages;
107
84
  }
@@ -146,7 +123,6 @@ export class DeliveryQueue {
146
123
  ? this.config.retryBaseInterval * Math.pow(2, entry.attempts - 1)
147
124
  : this.config.retryBaseInterval;
148
125
  entry.nextRetry = new Date(Date.now() + delay).toISOString();
149
- this.store?.upsert(entry);
150
126
  return true;
151
127
  }
152
128
 
@@ -161,7 +137,6 @@ export class DeliveryQueue {
161
137
  if (idx === -1) return false;
162
138
 
163
139
  queue.splice(idx, 1);
164
- this.store?.remove(peerId, messageId);
165
140
  if (queue.length === 0) this.queues.delete(peerId);
166
141
  return true;
167
142
  }
@@ -175,15 +150,10 @@ export class DeliveryQueue {
175
150
 
176
151
  for (const [peerId, queue] of this.queues.entries()) {
177
152
  const before = queue.length;
178
- const remaining: QueuedMessage[] = [];
179
- for (const entry of queue) {
153
+ const remaining = queue.filter((entry) => {
180
154
  const age = now - new Date(entry.enqueuedAt).getTime();
181
- if (age < this.config.maxTTL) {
182
- remaining.push(entry);
183
- } else {
184
- this.store?.remove(peerId, entry.id);
185
- }
186
- }
155
+ return age < this.config.maxTTL;
156
+ });
187
157
  expired += before - remaining.length;
188
158
 
189
159
  if (remaining.length === 0) {
package/src/index.ts CHANGED
@@ -16,14 +16,6 @@ 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";
27
19
  import type { InboxConfig } from "./types.js";
28
20
  import type { Storage } from "./storage/interface.js";
29
21
 
@@ -70,52 +62,6 @@ export type { MeshDeliveryHandler } from "./mesh/delivery-bridge.js";
70
62
  export { mapMessageToInbox, inboxMessageToMap } from "./mesh/type-mapper.js";
71
63
  export type { MapMessage as MeshMapMessage, MapAddress as MeshMapAddress } from "./mesh/type-mapper.js";
72
64
  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";
119
65
  export type * from "./types.js";
120
66
  export type { Storage, InboxQuery, ThreadQuery } from "./storage/interface.js";
121
67
 
@@ -157,7 +103,6 @@ export interface AgentInbox {
157
103
  notifier: PushNotifier;
158
104
  federation: ConnectionManager | null;
159
105
  registry: WarmRegistry | null;
160
- mail: MailGateway | null;
161
106
  events: EventEmitter;
162
107
  stop(): Promise<void>;
163
108
  }
@@ -383,97 +328,6 @@ export async function createAgentInbox(
383
328
  federation.queue.startTicking();
384
329
  }
385
330
 
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
-
477
331
  // 8. JSON-RPC server (mail/* methods)
478
332
  const jsonRpc = new MailJsonRpcServer(storage, router, events);
479
333
  if (opts.httpPort && opts.httpPort > 0) {
@@ -509,7 +363,6 @@ export async function createAgentInbox(
509
363
  if (retentionTimer) clearInterval(retentionTimer);
510
364
  await ipcServer.stop();
511
365
  await jsonRpc.stopHttp();
512
- if (mail) await mail.stop();
513
366
  await mapClient.disconnect();
514
367
  if (federation) await federation.destroy();
515
368
  if (registry) registry.destroy();
@@ -528,7 +381,6 @@ export async function createAgentInbox(
528
381
  notifier,
529
382
  federation,
530
383
  registry,
531
- mail,
532
384
  events,
533
385
  stop,
534
386
  };
@@ -13,7 +13,12 @@
13
13
  * @module agent-inbox/jsonrpc/mail-push-types
14
14
  */
15
15
 
16
- import type { Turn, ContentType, MessageContent } from "../types.js";
16
+ import type {
17
+ Turn,
18
+ ContentType,
19
+ MessageContent,
20
+ Conversation,
21
+ } from "../types.js";
17
22
 
18
23
  /**
19
24
  * The canonical JSON-RPC method name for server-pushed turn delivery.
@@ -47,14 +52,38 @@ export interface MailTurnReceivedParams {
47
52
  * `"low"` | `"normal"` | `"high"` | `"urgent"`
48
53
  */
49
54
  importance?: string;
55
+
56
+ /**
57
+ * Optional parent-conversation context. Populated when the fan-out site
58
+ * can resolve the turn's conversation (see `resolveConversation` in
59
+ * {@link MailPushBridgeConfig}). Lets receivers filter/route the push —
60
+ * e.g. "is a local agent a participant?", "is this a spec-thread vs a
61
+ * dispatch delivery channel?" — without a round-trip back to the hub.
62
+ * Omitted when the fan-out site only has the turn in scope.
63
+ */
64
+ conversation?: {
65
+ scope: string;
66
+ subject?: string;
67
+ metadata?: Record<string, unknown>;
68
+ /** Participant agent ids (roles omitted; use `mail/presence` for detail). */
69
+ participants?: string[];
70
+ };
50
71
  }
51
72
 
52
73
  /**
53
74
  * Build a `MailTurnReceivedParams` payload from a stored {@link Turn}.
54
75
  * Centralizes field-mapping so consumers can't drift from the canonical
55
76
  * shape.
77
+ *
78
+ * When `conversation` is supplied, its scope/subject/metadata/participant
79
+ * ids are folded into an optional `conversation` block so receivers can
80
+ * filter/route without a round-trip. Omit it to send turn fields only
81
+ * (backward-compatible with pre-0.2.4 receivers).
56
82
  */
57
- export function buildMailTurnReceivedParams(turn: Turn): MailTurnReceivedParams {
83
+ export function buildMailTurnReceivedParams(
84
+ turn: Turn,
85
+ conversation?: Conversation
86
+ ): MailTurnReceivedParams {
58
87
  return {
59
88
  conversation_id: turn.conversation_id,
60
89
  turn_id: turn.id,
@@ -64,5 +93,17 @@ export function buildMailTurnReceivedParams(turn: Turn): MailTurnReceivedParams
64
93
  ...(turn.thread_id ? { thread_id: turn.thread_id } : {}),
65
94
  created_at: turn.created_at,
66
95
  ...(turn.importance ? { importance: turn.importance } : {}),
96
+ ...(conversation
97
+ ? {
98
+ conversation: {
99
+ scope: conversation.scope,
100
+ ...(conversation.subject
101
+ ? { subject: conversation.subject }
102
+ : {}),
103
+ metadata: conversation.metadata,
104
+ participants: conversation.participants.map((p) => p.agent_id),
105
+ },
106
+ }
107
+ : {}),
67
108
  };
68
109
  }
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { EventEmitter } from "node:events";
25
- import type { Turn } from "../types.js";
25
+ import type { Turn, Conversation } from "../types.js";
26
26
  import {
27
27
  MAIL_TURN_RECEIVED_METHOD,
28
28
  buildMailTurnReceivedParams,
@@ -76,6 +76,18 @@ export interface MailPushBridgeConfig<
76
76
  params: MailTurnReceivedParams,
77
77
  ) => void | Promise<void>;
78
78
 
79
+ /**
80
+ * Optional resolver for the turn's parent conversation. When provided,
81
+ * the resolved conversation's scope/subject/metadata/participants are
82
+ * folded into the notification `params.conversation` block so receivers
83
+ * can filter/route without a round-trip back to the hub. Return
84
+ * `undefined` to omit context for a given turn (the notification still
85
+ * carries all turn fields). Kept as an injected resolver rather than a
86
+ * storage dependency so the factory stays transport- and
87
+ * storage-agnostic.
88
+ */
89
+ resolveConversation?: (turn: Turn) => Conversation | undefined;
90
+
79
91
  /** Optional logger. Defaults to console.log. */
80
92
  log?: (msg: string) => void;
81
93
  }
@@ -93,7 +105,13 @@ export interface MailPushBridge {
93
105
  export function createMailPushBridge<
94
106
  TSubscriber extends MailPushSubscriber = MailPushSubscriber,
95
107
  >(config: MailPushBridgeConfig<TSubscriber>): MailPushBridge {
96
- const { mailEvents, getSubscribers, sendNotification, log = () => {} } = config;
108
+ const {
109
+ mailEvents,
110
+ getSubscribers,
111
+ sendNotification,
112
+ resolveConversation,
113
+ log = () => {},
114
+ } = config;
97
115
 
98
116
  const onTurnAdded = (turn: unknown): void => {
99
117
  if (!turn || typeof turn !== "object") return;
@@ -116,7 +134,20 @@ export function createMailPushBridge<
116
134
 
117
135
  if (subscribers.length === 0) return;
118
136
 
119
- const params = buildMailTurnReceivedParams(t);
137
+ let conversation: Conversation | undefined;
138
+ if (resolveConversation) {
139
+ try {
140
+ conversation = resolveConversation(t);
141
+ } catch (err) {
142
+ // Non-fatal: fall back to a context-less notification rather than
143
+ // dropping the turn. Receivers degrade to a hub round-trip.
144
+ log(
145
+ `[mail-push] resolveConversation threw for turn ${t.id}: ${(err as Error).message}`,
146
+ );
147
+ }
148
+ }
149
+
150
+ const params = buildMailTurnReceivedParams(t, conversation);
120
151
 
121
152
  for (const subscriber of subscribers) {
122
153
  try {
@@ -56,11 +56,24 @@ export class MailJsonRpcServer {
56
56
  }
57
57
 
58
58
  private registerMethods(): void {
59
- // mail/create — create a conversation
59
+ // mail/create — create a conversation (create-or-get)
60
+ //
61
+ // When an explicit `id` is supplied and already exists, return the
62
+ // existing conversation untouched instead of overwriting it. Callers
63
+ // use `mail/create` as an idempotent lazy factory by passing a
64
+ // deterministic id (dispatch threads, spec threads); because
65
+ // `putConversation` is a destructive upsert that re-syncs participants
66
+ // from the passed object (built here with `participants: []`), a blind
67
+ // re-create would silently wipe participants and overwrite metadata.
60
68
  this.methods.set("mail/create", (params) => {
69
+ const explicitId = params.id as string | undefined;
70
+ if (explicitId) {
71
+ const existing = this.storage.getConversation(explicitId);
72
+ if (existing) return existing;
73
+ }
61
74
  const now = new Date().toISOString();
62
75
  const conv: Conversation = {
63
- id: (params.id as string) ?? `conv-${ulid()}`,
76
+ id: explicitId ?? `conv-${ulid()}`,
64
77
  scope: (params.scope as string) ?? "default",
65
78
  subject: params.subject as string | undefined,
66
79
  status: "active",
@@ -120,19 +133,25 @@ export class MailJsonRpcServer {
120
133
  });
121
134
 
122
135
  // mail/join — add self as participant
136
+ //
137
+ // Uses the storage `addParticipant` helper (atomic INSERT OR IGNORE)
138
+ // rather than read-modify-write via `putConversation`, which rewrites
139
+ // the entire participant list and can drop a concurrent join/invite.
123
140
  this.methods.set("mail/join", (params) => {
124
- const conv = this.storage.getConversation(
125
- params.conversationId as string
126
- );
141
+ const conversationId = params.conversationId as string;
142
+ const conv = this.storage.getConversation(conversationId);
127
143
  if (!conv) throw rpcError(-32001, "Conversation not found");
128
144
  const agentId = params.agentId as string;
129
- if (!conv.participants.some((p) => p.agent_id === agentId)) {
130
- conv.participants.push({
145
+ const alreadyMember = conv.participants.some(
146
+ (p) => p.agent_id === agentId
147
+ );
148
+ if (!alreadyMember) {
149
+ const now = new Date().toISOString();
150
+ this.storage.addParticipant(conversationId, {
131
151
  agent_id: agentId,
132
- joined_at: new Date().toISOString(),
152
+ joined_at: now,
133
153
  });
134
- conv.updated_at = new Date().toISOString();
135
- this.storage.putConversation(conv);
154
+ this.storage.touchConversation(conversationId, now);
136
155
  this.events.emit("mail.participant.joined", {
137
156
  conversation_id: conv.id,
138
157
  agent_id: agentId,
@@ -143,34 +162,34 @@ export class MailJsonRpcServer {
143
162
 
144
163
  // mail/leave — remove self from conversation
145
164
  this.methods.set("mail/leave", (params) => {
146
- const conv = this.storage.getConversation(
147
- params.conversationId as string
148
- );
165
+ const conversationId = params.conversationId as string;
166
+ const conv = this.storage.getConversation(conversationId);
149
167
  if (!conv) throw rpcError(-32001, "Conversation not found");
150
168
  const agentId = params.agentId as string;
151
- conv.participants = conv.participants.filter(
152
- (p) => p.agent_id !== agentId
153
- );
154
- conv.updated_at = new Date().toISOString();
155
- this.storage.putConversation(conv);
169
+ this.storage.removeParticipant(conversationId, agentId);
170
+ this.storage.touchConversation(conversationId, new Date().toISOString());
156
171
  return { ok: true };
157
172
  });
158
173
 
159
174
  // mail/invite — add agent to conversation
175
+ //
176
+ // Same atomic-add rationale as mail/join; carries an optional role.
160
177
  this.methods.set("mail/invite", (params) => {
161
- const conv = this.storage.getConversation(
162
- params.conversationId as string
163
- );
178
+ const conversationId = params.conversationId as string;
179
+ const conv = this.storage.getConversation(conversationId);
164
180
  if (!conv) throw rpcError(-32001, "Conversation not found");
165
181
  const agentId = params.agentId as string;
166
- if (!conv.participants.some((p) => p.agent_id === agentId)) {
167
- conv.participants.push({
182
+ const alreadyMember = conv.participants.some(
183
+ (p) => p.agent_id === agentId
184
+ );
185
+ if (!alreadyMember) {
186
+ const now = new Date().toISOString();
187
+ this.storage.addParticipant(conversationId, {
168
188
  agent_id: agentId,
169
189
  role: params.role as string | undefined,
170
- joined_at: new Date().toISOString(),
190
+ joined_at: now,
171
191
  });
172
- conv.updated_at = new Date().toISOString();
173
- this.storage.putConversation(conv);
192
+ this.storage.touchConversation(conversationId, now);
174
193
  this.events.emit("mail.participant.joined", {
175
194
  conversation_id: conv.id,
176
195
  agent_id: agentId,
@@ -11,17 +11,6 @@ import type { Storage } from "../storage/interface.js";
11
11
  import type { ConnectionManager } from "../federation/connection-manager.js";
12
12
  import { parseAddress, isRemoteAddress } from "../federation/address.js";
13
13
 
14
- /**
15
- * Outbound mail sink. Implemented by MailGateway; kept as a local interface so
16
- * the router does not depend on the mail module (avoids a cycle).
17
- */
18
- export interface MailEgress {
19
- /** True if this recipient's domain is one the mail gateway routes for. */
20
- ownsRecipient(agentId: string): boolean;
21
- /** Render and deliver the mail-class recipients of a message. */
22
- send(message: Message): Promise<void>;
23
- }
24
-
25
14
  export interface SendOptions {
26
15
  from: string;
27
16
  to: string | string[] | { agent_id: string; kind?: RecipientKind }[];
@@ -37,7 +26,6 @@ export interface SendOptions {
37
26
 
38
27
  export class MessageRouter {
39
28
  private federation: ConnectionManager | null = null;
40
- private mail: MailEgress | null = null;
41
29
 
42
30
  constructor(
43
31
  private storage: Storage,
@@ -52,13 +40,6 @@ export class MessageRouter {
52
40
  this.federation = federation;
53
41
  }
54
42
 
55
- /**
56
- * Attach a mail gateway for routing to external email recipients.
57
- */
58
- setMail(mail: MailEgress): void {
59
- this.mail = mail;
60
- }
61
-
62
43
  async routeMessage(opts: SendOptions): Promise<Message> {
63
44
  const recipients = this.resolveRecipients(opts.to);
64
45
  const content = normalizeContent(opts.payload);
@@ -91,11 +72,8 @@ export class MessageRouter {
91
72
  created_at: now,
92
73
  };
93
74
 
94
- // Classify recipients: mail (external email) takes precedence over the
95
- // federation interpretation of "agent@domain"; remaining remote recipients
96
- // go to federation; everything else local.
75
+ // Mark local recipients as delivered; route remote ones via federation
97
76
  for (const r of message.recipients) {
98
- if (this.mail?.ownsRecipient(r.agent_id)) continue; // mail — see below
99
77
  const addr = parseAddress(r.agent_id);
100
78
  if (!isRemoteAddress(addr) && this.isLocal(r.agent_id)) {
101
79
  r.delivered_at = now;
@@ -113,25 +91,10 @@ export class MessageRouter {
113
91
 
114
92
  this.events.emit("message.created", message);
115
93
 
116
- // Route external mail recipients via the mail gateway.
117
- if (this.mail) {
118
- const hasMail = message.recipients.some((r) =>
119
- this.mail!.ownsRecipient(r.agent_id)
120
- );
121
- if (hasMail) {
122
- // Fire-and-forget; transient failures are queued inside the gateway.
123
- this.mail.send(message).catch(() => {
124
- // Mail delivery failures are handled by the gateway's retry queue
125
- });
126
- }
127
- }
128
-
129
- // Route remaining remote recipients via federation (excluding mail).
94
+ // Route remote recipients via federation
130
95
  if (this.federation) {
131
- const hasRemote = message.recipients.some(
132
- (r) =>
133
- isRemoteAddress(parseAddress(r.agent_id)) &&
134
- !this.mail?.ownsRecipient(r.agent_id)
96
+ const hasRemote = message.recipients.some((r) =>
97
+ isRemoteAddress(parseAddress(r.agent_id))
135
98
  );
136
99
  if (hasRemote) {
137
100
  // Fire-and-forget federation routing (results tracked via events)