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.
- package/CLAUDE.md +1 -92
- package/README.md +6 -73
- package/dist/federation/connection-manager.d.ts +0 -8
- package/dist/federation/connection-manager.d.ts.map +1 -1
- package/dist/federation/connection-manager.js +0 -12
- package/dist/federation/connection-manager.js.map +1 -1
- package/dist/federation/delivery-queue.d.ts +3 -11
- package/dist/federation/delivery-queue.d.ts.map +1 -1
- package/dist/federation/delivery-queue.js +8 -38
- package/dist/federation/delivery-queue.js.map +1 -1
- package/dist/index.d.ts +0 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -98
- package/dist/index.js.map +1 -1
- package/dist/jsonrpc/mail-push-types.d.ts +22 -2
- package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push-types.js +18 -1
- package/dist/jsonrpc/mail-push-types.js.map +1 -1
- package/dist/jsonrpc/mail-push.d.ts +12 -1
- package/dist/jsonrpc/mail-push.d.ts.map +1 -1
- package/dist/jsonrpc/mail-push.js +13 -2
- package/dist/jsonrpc/mail-push.js.map +1 -1
- package/dist/jsonrpc/mail-server.d.ts.map +1 -1
- package/dist/jsonrpc/mail-server.js +42 -18
- package/dist/jsonrpc/mail-server.js.map +1 -1
- package/dist/router/message-router.d.ts +0 -15
- package/dist/router/message-router.d.ts.map +1 -1
- package/dist/router/message-router.js +3 -25
- package/dist/router/message-router.js.map +1 -1
- package/dist/storage/interface.d.ts +2 -9
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +1 -4
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +6 -12
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +1 -6
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +6 -28
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types.d.ts +0 -79
- package/dist/types.d.ts.map +1 -1
- package/docs/DESIGN.md +0 -15
- package/package.json +3 -28
- package/rules/agent-inbox.md +0 -1
- package/src/federation/connection-manager.ts +0 -12
- package/src/federation/delivery-queue.ts +8 -38
- package/src/index.ts +0 -148
- package/src/jsonrpc/mail-push-types.ts +43 -2
- package/src/jsonrpc/mail-push.ts +34 -3
- package/src/jsonrpc/mail-server.ts +45 -26
- package/src/router/message-router.ts +4 -41
- package/src/storage/interface.ts +2 -11
- package/src/storage/memory.ts +8 -15
- package/src/storage/sqlite.ts +9 -36
- package/src/types.ts +0 -73
- package/test/load.test.ts +1 -1
- package/test/mail-push.test.ts +101 -1
- package/test/mail-server.test.ts +108 -0
- package/AGENTS.md +0 -18
- package/dist/federation/queue-store.d.ts +0 -42
- package/dist/federation/queue-store.d.ts.map +0 -1
- package/dist/federation/queue-store.js +0 -87
- package/dist/federation/queue-store.js.map +0 -1
- package/dist/index.d.mts +0 -2
- package/dist/index.mjs +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/mail/address-book.d.ts +0 -43
- package/dist/mail/address-book.d.ts.map +0 -1
- package/dist/mail/address-book.js +0 -95
- package/dist/mail/address-book.js.map +0 -1
- package/dist/mail/attachment-store.d.ts +0 -31
- package/dist/mail/attachment-store.d.ts.map +0 -1
- package/dist/mail/attachment-store.js +0 -74
- package/dist/mail/attachment-store.js.map +0 -1
- package/dist/mail/email-mapper.d.ts +0 -41
- package/dist/mail/email-mapper.d.ts.map +0 -1
- package/dist/mail/email-mapper.js +0 -216
- package/dist/mail/email-mapper.js.map +0 -1
- package/dist/mail/fs-attachment-store.d.ts +0 -38
- package/dist/mail/fs-attachment-store.d.ts.map +0 -1
- package/dist/mail/fs-attachment-store.js +0 -165
- package/dist/mail/fs-attachment-store.js.map +0 -1
- package/dist/mail/mail-gateway.d.ts +0 -114
- package/dist/mail/mail-gateway.d.ts.map +0 -1
- package/dist/mail/mail-gateway.js +0 -402
- package/dist/mail/mail-gateway.js.map +0 -1
- package/dist/mail/provider-transport.d.ts +0 -138
- package/dist/mail/provider-transport.d.ts.map +0 -1
- package/dist/mail/provider-transport.js +0 -434
- package/dist/mail/provider-transport.js.map +0 -1
- package/dist/mail/rate-limiter.d.ts +0 -20
- package/dist/mail/rate-limiter.d.ts.map +0 -1
- package/dist/mail/rate-limiter.js +0 -56
- package/dist/mail/rate-limiter.js.map +0 -1
- package/dist/mail/smtp-transport.d.ts +0 -141
- package/dist/mail/smtp-transport.d.ts.map +0 -1
- package/dist/mail/smtp-transport.js +0 -415
- package/dist/mail/smtp-transport.js.map +0 -1
- package/dist/mail/types.d.ts +0 -177
- package/dist/mail/types.d.ts.map +0 -1
- package/dist/mail/types.js +0 -11
- package/dist/mail/types.js.map +0 -1
- package/dist/router/destination.d.ts +0 -69
- package/dist/router/destination.d.ts.map +0 -1
- package/dist/router/destination.js +0 -106
- package/dist/router/destination.js.map +0 -1
- package/docs/MAIL-INTEROP-PLAN.md +0 -660
- package/renovate.json5 +0 -6
- package/src/federation/queue-store.ts +0 -124
- package/src/mail/address-book.ts +0 -111
- package/src/mail/attachment-store.ts +0 -90
- package/src/mail/email-mapper.ts +0 -288
- package/src/mail/fs-attachment-store.ts +0 -163
- package/src/mail/mail-gateway.ts +0 -505
- package/src/mail/provider-transport.ts +0 -577
- package/src/mail/rate-limiter.ts +0 -51
- package/src/mail/smtp-transport.ts +0 -589
- package/src/mail/types.ts +0 -221
- package/src/router/destination.ts +0 -140
- package/test/federation/delivery-queue-sqlite.test.ts +0 -158
- package/test/mail/address-book.test.ts +0 -111
- package/test/mail/attachment-store-contract.test.ts +0 -92
- package/test/mail/attachment-store.test.ts +0 -69
- package/test/mail/destination.test.ts +0 -115
- package/test/mail/dsn-parse.test.ts +0 -239
- package/test/mail/email-mapper.test.ts +0 -341
- package/test/mail/external-id.test.ts +0 -43
- package/test/mail/fs-attachment-store.test.ts +0 -134
- package/test/mail/full-flow-e2e.test.ts +0 -200
- package/test/mail/mail-gateway.test.ts +0 -419
- package/test/mail/mail-transport-contract.test.ts +0 -134
- package/test/mail/mock-mail.ts +0 -161
- package/test/mail/mock-postmark.ts +0 -66
- package/test/mail/provider-transport.test.ts +0 -381
- package/test/mail/rate-limiter.test.ts +0 -48
- package/test/mail/router-mail-integration.test.ts +0 -138
- package/test/mail/smtp-e2e.test.ts +0 -98
- package/test/mail/smtp-transport.test.ts +0 -138
package/rules/agent-inbox.md
CHANGED
|
@@ -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.
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
|
179
|
-
for (const entry of queue) {
|
|
153
|
+
const remaining = queue.filter((entry) => {
|
|
180
154
|
const age = now - new Date(entry.enqueuedAt).getTime();
|
|
181
|
-
|
|
182
|
-
|
|
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 {
|
|
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(
|
|
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
|
}
|
package/src/jsonrpc/mail-push.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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
|
|
125
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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:
|
|
152
|
+
joined_at: now,
|
|
133
153
|
});
|
|
134
|
-
|
|
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
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
162
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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:
|
|
190
|
+
joined_at: now,
|
|
171
191
|
});
|
|
172
|
-
|
|
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
|
-
//
|
|
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
|
|
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)
|