agent-inbox 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -0
- package/CLAUDE.md +92 -1
- package/README.md +73 -6
- package/dist/federation/connection-manager.d.ts +8 -0
- package/dist/federation/connection-manager.d.ts.map +1 -1
- package/dist/federation/connection-manager.js +12 -0
- package/dist/federation/connection-manager.js.map +1 -1
- package/dist/federation/delivery-queue.d.ts +11 -3
- package/dist/federation/delivery-queue.d.ts.map +1 -1
- package/dist/federation/delivery-queue.js +38 -8
- package/dist/federation/delivery-queue.js.map +1 -1
- package/dist/federation/queue-store.d.ts +42 -0
- package/dist/federation/queue-store.d.ts.map +1 -0
- package/dist/federation/queue-store.js +87 -0
- package/dist/federation/queue-store.js.map +1 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +98 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mail/address-book.d.ts +43 -0
- package/dist/mail/address-book.d.ts.map +1 -0
- package/dist/mail/address-book.js +95 -0
- package/dist/mail/address-book.js.map +1 -0
- package/dist/mail/attachment-store.d.ts +31 -0
- package/dist/mail/attachment-store.d.ts.map +1 -0
- package/dist/mail/attachment-store.js +74 -0
- package/dist/mail/attachment-store.js.map +1 -0
- package/dist/mail/email-mapper.d.ts +41 -0
- package/dist/mail/email-mapper.d.ts.map +1 -0
- package/dist/mail/email-mapper.js +216 -0
- package/dist/mail/email-mapper.js.map +1 -0
- package/dist/mail/fs-attachment-store.d.ts +38 -0
- package/dist/mail/fs-attachment-store.d.ts.map +1 -0
- package/dist/mail/fs-attachment-store.js +165 -0
- package/dist/mail/fs-attachment-store.js.map +1 -0
- package/dist/mail/mail-gateway.d.ts +114 -0
- package/dist/mail/mail-gateway.d.ts.map +1 -0
- package/dist/mail/mail-gateway.js +402 -0
- package/dist/mail/mail-gateway.js.map +1 -0
- package/dist/mail/provider-transport.d.ts +138 -0
- package/dist/mail/provider-transport.d.ts.map +1 -0
- package/dist/mail/provider-transport.js +434 -0
- package/dist/mail/provider-transport.js.map +1 -0
- package/dist/mail/rate-limiter.d.ts +20 -0
- package/dist/mail/rate-limiter.d.ts.map +1 -0
- package/dist/mail/rate-limiter.js +56 -0
- package/dist/mail/rate-limiter.js.map +1 -0
- package/dist/mail/smtp-transport.d.ts +141 -0
- package/dist/mail/smtp-transport.d.ts.map +1 -0
- package/dist/mail/smtp-transport.js +415 -0
- package/dist/mail/smtp-transport.js.map +1 -0
- package/dist/mail/types.d.ts +177 -0
- package/dist/mail/types.d.ts.map +1 -0
- package/dist/mail/types.js +11 -0
- package/dist/mail/types.js.map +1 -0
- package/dist/router/destination.d.ts +69 -0
- package/dist/router/destination.d.ts.map +1 -0
- package/dist/router/destination.js +106 -0
- package/dist/router/destination.js.map +1 -0
- package/dist/router/message-router.d.ts +15 -0
- package/dist/router/message-router.d.ts.map +1 -1
- package/dist/router/message-router.js +25 -3
- package/dist/router/message-router.js.map +1 -1
- package/dist/storage/interface.d.ts +9 -0
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/memory.d.ts +4 -0
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +12 -0
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/sqlite.d.ts +6 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +28 -0
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/DESIGN.md +15 -0
- package/docs/MAIL-INTEROP-PLAN.md +660 -0
- package/package.json +28 -3
- package/renovate.json5 +6 -0
- package/rules/agent-inbox.md +1 -0
- package/src/federation/connection-manager.ts +12 -0
- package/src/federation/delivery-queue.ts +38 -8
- package/src/federation/queue-store.ts +124 -0
- package/src/index.ts +148 -0
- package/src/mail/address-book.ts +111 -0
- package/src/mail/attachment-store.ts +90 -0
- package/src/mail/email-mapper.ts +288 -0
- package/src/mail/fs-attachment-store.ts +163 -0
- package/src/mail/mail-gateway.ts +505 -0
- package/src/mail/provider-transport.ts +577 -0
- package/src/mail/rate-limiter.ts +51 -0
- package/src/mail/smtp-transport.ts +589 -0
- package/src/mail/types.ts +221 -0
- package/src/router/destination.ts +140 -0
- package/src/router/message-router.ts +41 -4
- package/src/storage/interface.ts +11 -0
- package/src/storage/memory.ts +15 -0
- package/src/storage/sqlite.ts +36 -0
- package/src/types.ts +73 -0
- package/test/federation/delivery-queue-sqlite.test.ts +158 -0
- package/test/load.test.ts +1 -1
- package/test/mail/address-book.test.ts +111 -0
- package/test/mail/attachment-store-contract.test.ts +92 -0
- package/test/mail/attachment-store.test.ts +69 -0
- package/test/mail/destination.test.ts +115 -0
- package/test/mail/dsn-parse.test.ts +239 -0
- package/test/mail/email-mapper.test.ts +341 -0
- package/test/mail/external-id.test.ts +43 -0
- package/test/mail/fs-attachment-store.test.ts +134 -0
- package/test/mail/full-flow-e2e.test.ts +200 -0
- package/test/mail/mail-gateway.test.ts +419 -0
- package/test/mail/mail-transport-contract.test.ts +134 -0
- package/test/mail/mock-mail.ts +161 -0
- package/test/mail/mock-postmark.ts +66 -0
- package/test/mail/provider-transport.test.ts +381 -0
- package/test/mail/rate-limiter.test.ts +48 -0
- package/test/mail/router-mail-integration.test.ts +138 -0
- package/test/mail/smtp-e2e.test.ts +98 -0
- package/test/mail/smtp-transport.test.ts +138 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail interop type contract.
|
|
3
|
+
*
|
|
4
|
+
* Defines the `MailTransport` interface and its envelope types — the backend-
|
|
5
|
+
* agnostic contract every mail backend (self-hosted SMTP, managed provider)
|
|
6
|
+
* must satisfy. See docs/MAIL-INTEROP-PLAN.md for the design rationale.
|
|
7
|
+
*
|
|
8
|
+
* Phase 1 lands the types only; no backend, gateway, or router wiring yet.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Capabilities & lifecycle
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** What a backend can do. The gateway adapts to these instead of assuming. */
|
|
16
|
+
export interface MailCapabilities {
|
|
17
|
+
/**
|
|
18
|
+
* How outbound mail leaves: direct to arbitrary public MX (self-hosted SMTP),
|
|
19
|
+
* via a configured smarthost relay, or via the backend's own API. Informational.
|
|
20
|
+
*/
|
|
21
|
+
outbound: "mx" | "relay" | "api";
|
|
22
|
+
/** Backend signs DKIM itself (provider) or we must sign before handing off. */
|
|
23
|
+
signsDkim: boolean;
|
|
24
|
+
/** Backend verifies SPF/DKIM/DMARC on inbound and reports results. */
|
|
25
|
+
verifiesInboundAuth: boolean;
|
|
26
|
+
/** Inbound arrives via a long-lived listener (SMTP) or a push webhook. */
|
|
27
|
+
inbound: "listener" | "webhook" | "none";
|
|
28
|
+
/** Max message size the backend will accept, bytes. */
|
|
29
|
+
maxMessageBytes: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type MailTransportState =
|
|
33
|
+
| "stopped"
|
|
34
|
+
| "starting"
|
|
35
|
+
| "ready"
|
|
36
|
+
| "degraded"
|
|
37
|
+
| "stopping";
|
|
38
|
+
|
|
39
|
+
export interface MailHealth {
|
|
40
|
+
state: MailTransportState;
|
|
41
|
+
/** Human-readable detail for health endpoints / logs. */
|
|
42
|
+
detail?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Addresses & attachments
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface MailAddress {
|
|
50
|
+
/** RFC 5322 addr-spec, e.g. "bob@example.com". */
|
|
51
|
+
address: string;
|
|
52
|
+
/** Optional display name. */
|
|
53
|
+
name?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MailAttachment {
|
|
57
|
+
filename?: string;
|
|
58
|
+
contentType: string;
|
|
59
|
+
/** Content-ID for inline html parts. */
|
|
60
|
+
contentId?: string;
|
|
61
|
+
/** Opaque handle into an AttachmentStore — the envelope carries a ref, not bytes. */
|
|
62
|
+
contentRef: string;
|
|
63
|
+
sizeBytes: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Outbound
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
export interface OutboundMail {
|
|
71
|
+
/** Stable key for idempotent send. Derived from the inbox Message.id. */
|
|
72
|
+
idempotencyKey: string;
|
|
73
|
+
from: MailAddress;
|
|
74
|
+
to: MailAddress[];
|
|
75
|
+
cc?: MailAddress[];
|
|
76
|
+
bcc?: MailAddress[];
|
|
77
|
+
subject?: string;
|
|
78
|
+
/** Rendered bodies. At least one of text/html should be present. */
|
|
79
|
+
text?: string;
|
|
80
|
+
html?: string;
|
|
81
|
+
/** RFC 5322 threading headers, already computed by the mapper. */
|
|
82
|
+
headers: {
|
|
83
|
+
messageId: string;
|
|
84
|
+
inReplyTo?: string;
|
|
85
|
+
references?: string[];
|
|
86
|
+
};
|
|
87
|
+
attachments?: MailAttachment[];
|
|
88
|
+
/** Opaque passthrough for backend-specific knobs (provider tags, etc.). */
|
|
89
|
+
backendHints?: Record<string, unknown>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type MailSendDisposition = "delivered" | "transient" | "permanent";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Result of a send attempt. Backends collapse their native failure modes into
|
|
96
|
+
* these three dispositions so the retry/queue layer needs no backend knowledge.
|
|
97
|
+
*/
|
|
98
|
+
export interface MailSendResult {
|
|
99
|
+
disposition: MailSendDisposition;
|
|
100
|
+
/** Provider/SMTP id when delivered, for correlating DSNs/bounces. */
|
|
101
|
+
remoteMessageId?: string;
|
|
102
|
+
/** Numeric SMTP-style code when available (e.g. 250, 451, 550). */
|
|
103
|
+
code?: number;
|
|
104
|
+
/** Human-readable, for logs only — never parsed for control flow. */
|
|
105
|
+
detail?: string;
|
|
106
|
+
/** For transient failures: hint for next attempt (e.g. greylist retry-after). */
|
|
107
|
+
retryAfterMs?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Inbound
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export interface InboundAuthResults {
|
|
115
|
+
spf: "pass" | "fail" | "softfail" | "neutral" | "none";
|
|
116
|
+
dkim: "pass" | "fail" | "none";
|
|
117
|
+
dmarc: "pass" | "fail" | "none";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Bounce/DSN classification, parsed from RFC 3464 delivery-status reports.
|
|
122
|
+
* Present when an inbound message is a bounce, so the gateway can correlate it
|
|
123
|
+
* to the failed outbound send instead of treating it as a fresh message.
|
|
124
|
+
*/
|
|
125
|
+
export interface InboundBounce {
|
|
126
|
+
/** "failed" (permanent), "delayed" (transient), "delivered" (positive DSN). */
|
|
127
|
+
action: "failed" | "delayed" | "delivered";
|
|
128
|
+
/** The address that bounced. */
|
|
129
|
+
recipient: string;
|
|
130
|
+
/** RFC 3463 status code, e.g. "5.1.1". */
|
|
131
|
+
status?: string;
|
|
132
|
+
/** Message-ID of the original outbound mail, when the DSN echoes it. */
|
|
133
|
+
originalMessageId?: string;
|
|
134
|
+
diagnostic?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface InboundMail {
|
|
138
|
+
/** Peer-asserted SMTP envelope (MAIL FROM / RCPT TO), distinct from headers. */
|
|
139
|
+
envelopeFrom: string;
|
|
140
|
+
envelopeTo: string[];
|
|
141
|
+
from: MailAddress;
|
|
142
|
+
to: MailAddress[];
|
|
143
|
+
cc?: MailAddress[];
|
|
144
|
+
subject?: string;
|
|
145
|
+
text?: string;
|
|
146
|
+
html?: string;
|
|
147
|
+
headers: {
|
|
148
|
+
messageId?: string;
|
|
149
|
+
inReplyTo?: string;
|
|
150
|
+
references?: string[];
|
|
151
|
+
/** Everything else, lower-cased keys, for the mapper to mine. */
|
|
152
|
+
raw: Record<string, string | string[]>;
|
|
153
|
+
};
|
|
154
|
+
attachments?: MailAttachment[];
|
|
155
|
+
/** Auth verdict — present iff capabilities.verifiesInboundAuth. */
|
|
156
|
+
authResults?: InboundAuthResults;
|
|
157
|
+
/** Peer IP / hostname, for allow-listing and abuse control. */
|
|
158
|
+
remote?: { ip?: string; reverseDns?: string };
|
|
159
|
+
/** Set when this inbound mail is a bounce/DSN (see InboundBounce). */
|
|
160
|
+
bounce?: InboundBounce;
|
|
161
|
+
sizeBytes: number;
|
|
162
|
+
receivedAt: string; // ISO
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// The transport contract
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Ingress handler. The transport awaits this; resolution means "durably
|
|
171
|
+
* accepted" and only then is the peer MTA / webhook ACKed. A thrown handler
|
|
172
|
+
* means NACK so the sender retries — never a silent drop.
|
|
173
|
+
*/
|
|
174
|
+
export type InboundHandler = (mail: InboundMail) => Promise<void>;
|
|
175
|
+
|
|
176
|
+
export interface MailTransport {
|
|
177
|
+
readonly capabilities: MailCapabilities;
|
|
178
|
+
readonly state: MailTransportState;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Idempotent. Resolves only when inbound is actually accepting (port bound /
|
|
182
|
+
* webhook registered) so callers don't ACK before we can receive.
|
|
183
|
+
*/
|
|
184
|
+
start(): Promise<void>;
|
|
185
|
+
|
|
186
|
+
/** Graceful: stop accepting new inbound, finish in-flight, then release. */
|
|
187
|
+
stop(): Promise<void>;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Send one already-rendered outbound message.
|
|
191
|
+
* MUST be idempotent on `envelope.idempotencyKey`.
|
|
192
|
+
* MUST NOT throw for delivery failures — return a classified result instead.
|
|
193
|
+
* MAY throw only for programmer error (e.g. called while stopped).
|
|
194
|
+
*/
|
|
195
|
+
send(envelope: OutboundMail): Promise<MailSendResult>;
|
|
196
|
+
|
|
197
|
+
/** Register the single ingress handler. See InboundHandler. */
|
|
198
|
+
onReceive(handler: InboundHandler): void;
|
|
199
|
+
|
|
200
|
+
/** Liveness for health endpoints and the degraded → ready transition. */
|
|
201
|
+
health(): Promise<MailHealth>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Attachment storage (opaque contentRef backing store)
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Swappable attachment byte store. `contentRef` values on MailAttachment are
|
|
210
|
+
* opaque handles produced by `put`. Mirrors the Storage two-impl pattern:
|
|
211
|
+
* SqliteAttachmentStore (default) and FsAttachmentStore (opt-in) land later.
|
|
212
|
+
*/
|
|
213
|
+
export interface AttachmentStore {
|
|
214
|
+
/** Returns a content-addressed ref (sha256-based). Dedups identical bytes. */
|
|
215
|
+
put(
|
|
216
|
+
bytes: Buffer,
|
|
217
|
+
meta: { contentType: string; filename?: string }
|
|
218
|
+
): Promise<string>;
|
|
219
|
+
get(ref: string): Promise<Buffer>;
|
|
220
|
+
delete(ref: string): Promise<void>;
|
|
221
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Destination classification.
|
|
3
|
+
*
|
|
4
|
+
* Email addresses are syntactically identical to federation addresses
|
|
5
|
+
* (`bob@example.com` vs `bob@system-2`), so before routing we must classify a
|
|
6
|
+
* recipient as one of: local agent, federation peer, or external mail.
|
|
7
|
+
*
|
|
8
|
+
* Classification is explicit-list driven — nothing is guessed from "looks like
|
|
9
|
+
* a domain". An unknown `@x` is a hard error rather than a silent misroute.
|
|
10
|
+
* Federation peers may declare their own domains, in which case they win over
|
|
11
|
+
* mail config for that domain. See docs/MAIL-INTEROP-PLAN.md §1.
|
|
12
|
+
*
|
|
13
|
+
* This layer sits *above* `parseAddress`, which is left untouched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { parseAddress } from "../federation/address.js";
|
|
17
|
+
import type { FederatedAddress } from "../types.js";
|
|
18
|
+
|
|
19
|
+
export type Destination =
|
|
20
|
+
| { class: "local"; agentId: string }
|
|
21
|
+
| { class: "federation"; agentId: string; address: FederatedAddress }
|
|
22
|
+
| { class: "mail"; email: string };
|
|
23
|
+
|
|
24
|
+
export interface DestinationPeer {
|
|
25
|
+
systemId: string;
|
|
26
|
+
/** Dotted addresses routed to this peer instead of being treated as mail. */
|
|
27
|
+
domains?: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DestinationContext {
|
|
31
|
+
/** Returns true if `id` is a known local agent. */
|
|
32
|
+
isLocalAgent: (id: string) => boolean;
|
|
33
|
+
/** Configured federation peers (system ids + optionally declared domains). */
|
|
34
|
+
federationPeers: DestinationPeer[];
|
|
35
|
+
/** Mail domains we recognize (identity.localDomains ∪ routableDomains). */
|
|
36
|
+
mailDomains: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Thrown when a recipient matches neither a federation peer nor a mail domain. */
|
|
40
|
+
export class UnknownDestinationError extends Error {
|
|
41
|
+
constructor(public readonly agentId: string) {
|
|
42
|
+
super(
|
|
43
|
+
`Cannot classify destination "${agentId}": its domain is not a configured ` +
|
|
44
|
+
`federation peer or mail domain. Declare it explicitly to route it.`
|
|
45
|
+
);
|
|
46
|
+
this.name = "UnknownDestinationError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Classify a recipient address into local / federation / mail.
|
|
52
|
+
*
|
|
53
|
+
* Priority order (deterministic):
|
|
54
|
+
* 1. No `@` → local
|
|
55
|
+
* 2. `@x` matches a peer systemId or domain → federation
|
|
56
|
+
* 3. `@x` (or parent domain) in mailDomains → mail
|
|
57
|
+
* 4. otherwise → throw UnknownDestinationError
|
|
58
|
+
*
|
|
59
|
+
* @throws UnknownDestinationError for rule-4 misses.
|
|
60
|
+
*/
|
|
61
|
+
export function classifyDestination(
|
|
62
|
+
agentId: string,
|
|
63
|
+
ctx: DestinationContext
|
|
64
|
+
): Destination {
|
|
65
|
+
const atIdx = agentId.indexOf("@");
|
|
66
|
+
|
|
67
|
+
// Rule 1: no @ — local agent (no domain to disambiguate).
|
|
68
|
+
if (atIdx === -1) {
|
|
69
|
+
return { class: "local", agentId };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const addr = parseAddress(agentId);
|
|
73
|
+
const system = addr.system; // the part after @ (before any /scope)
|
|
74
|
+
|
|
75
|
+
// Defensive: parseAddress yielded no system (e.g. trailing-only @) — treat
|
|
76
|
+
// the whole string as a local agent id rather than guessing a domain.
|
|
77
|
+
if (!system) {
|
|
78
|
+
return { class: "local", agentId };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Rule 2: federation peer by systemId or declared domain (wins over mail).
|
|
82
|
+
for (const peer of ctx.federationPeers) {
|
|
83
|
+
if (peer.systemId === system || domainMatches(system, peer.domains)) {
|
|
84
|
+
return { class: "federation", agentId, address: addr };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Rule 3: configured mail domain (exact or parent-domain suffix match).
|
|
89
|
+
if (domainMatches(system, ctx.mailDomains)) {
|
|
90
|
+
return { class: "mail", email: stripScope(agentId) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Rule 4: explicit-list miss — never guess.
|
|
94
|
+
throw new UnknownDestinationError(agentId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True if `domain` equals, or is a subdomain of, any entry in `candidates`.
|
|
99
|
+
* Matching is case-insensitive. `mail.example.com` matches a candidate of
|
|
100
|
+
* `example.com`; `notexample.com` does not match `example.com`.
|
|
101
|
+
*/
|
|
102
|
+
export function domainMatches(
|
|
103
|
+
domain: string,
|
|
104
|
+
candidates: string[] | undefined
|
|
105
|
+
): boolean {
|
|
106
|
+
if (!candidates || candidates.length === 0) return false;
|
|
107
|
+
const d = domain.toLowerCase();
|
|
108
|
+
for (const c of candidates) {
|
|
109
|
+
const cand = c.toLowerCase();
|
|
110
|
+
if (d === cand || d.endsWith(`.${cand}`)) return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Drop a trailing `/scope` qualifier from an address, leaving a bare email.
|
|
117
|
+
* `bob@example.com/work` → `bob@example.com`.
|
|
118
|
+
*/
|
|
119
|
+
function stripScope(agentId: string): string {
|
|
120
|
+
const atIdx = agentId.indexOf("@");
|
|
121
|
+
if (atIdx === -1) return agentId;
|
|
122
|
+
const slashIdx = agentId.indexOf("/", atIdx);
|
|
123
|
+
return slashIdx === -1 ? agentId : agentId.slice(0, slashIdx);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Detect domains claimed by both a federation peer and mail config. Federation
|
|
128
|
+
* wins (see classifyDestination), but callers should surface this at startup
|
|
129
|
+
* via a `mail.config.conflict` warning. Returns the conflicting domains.
|
|
130
|
+
*/
|
|
131
|
+
export function findDomainConflicts(ctx: DestinationContext): string[] {
|
|
132
|
+
const mail = new Set(ctx.mailDomains.map((d) => d.toLowerCase()));
|
|
133
|
+
const conflicts: string[] = [];
|
|
134
|
+
for (const peer of ctx.federationPeers) {
|
|
135
|
+
for (const dom of peer.domains ?? []) {
|
|
136
|
+
if (mail.has(dom.toLowerCase())) conflicts.push(dom);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return conflicts;
|
|
140
|
+
}
|
|
@@ -11,6 +11,17 @@ 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
|
+
|
|
14
25
|
export interface SendOptions {
|
|
15
26
|
from: string;
|
|
16
27
|
to: string | string[] | { agent_id: string; kind?: RecipientKind }[];
|
|
@@ -26,6 +37,7 @@ export interface SendOptions {
|
|
|
26
37
|
|
|
27
38
|
export class MessageRouter {
|
|
28
39
|
private federation: ConnectionManager | null = null;
|
|
40
|
+
private mail: MailEgress | null = null;
|
|
29
41
|
|
|
30
42
|
constructor(
|
|
31
43
|
private storage: Storage,
|
|
@@ -40,6 +52,13 @@ export class MessageRouter {
|
|
|
40
52
|
this.federation = federation;
|
|
41
53
|
}
|
|
42
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Attach a mail gateway for routing to external email recipients.
|
|
57
|
+
*/
|
|
58
|
+
setMail(mail: MailEgress): void {
|
|
59
|
+
this.mail = mail;
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
async routeMessage(opts: SendOptions): Promise<Message> {
|
|
44
63
|
const recipients = this.resolveRecipients(opts.to);
|
|
45
64
|
const content = normalizeContent(opts.payload);
|
|
@@ -72,8 +91,11 @@ export class MessageRouter {
|
|
|
72
91
|
created_at: now,
|
|
73
92
|
};
|
|
74
93
|
|
|
75
|
-
//
|
|
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.
|
|
76
97
|
for (const r of message.recipients) {
|
|
98
|
+
if (this.mail?.ownsRecipient(r.agent_id)) continue; // mail — see below
|
|
77
99
|
const addr = parseAddress(r.agent_id);
|
|
78
100
|
if (!isRemoteAddress(addr) && this.isLocal(r.agent_id)) {
|
|
79
101
|
r.delivered_at = now;
|
|
@@ -91,10 +113,25 @@ export class MessageRouter {
|
|
|
91
113
|
|
|
92
114
|
this.events.emit("message.created", message);
|
|
93
115
|
|
|
94
|
-
// Route
|
|
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).
|
|
95
130
|
if (this.federation) {
|
|
96
|
-
const hasRemote = message.recipients.some(
|
|
97
|
-
|
|
131
|
+
const hasRemote = message.recipients.some(
|
|
132
|
+
(r) =>
|
|
133
|
+
isRemoteAddress(parseAddress(r.agent_id)) &&
|
|
134
|
+
!this.mail?.ownsRecipient(r.agent_id)
|
|
98
135
|
);
|
|
99
136
|
if (hasRemote) {
|
|
100
137
|
// Fire-and-forget federation routing (results tracked via events)
|
package/src/storage/interface.ts
CHANGED
|
@@ -36,6 +36,17 @@ export interface Storage {
|
|
|
36
36
|
/** Delete messages older than `cutoff` (ISO timestamp). Returns the count removed. */
|
|
37
37
|
pruneMessagesOlderThan(cutoff: string): number;
|
|
38
38
|
|
|
39
|
+
// External-id mapping (mail dedup + bounce correlation)
|
|
40
|
+
/**
|
|
41
|
+
* Record that an external message id (e.g. an RFC 5322 Message-ID) maps to a
|
|
42
|
+
* stored inbox message. Idempotent: re-recording the same external id is a no-op.
|
|
43
|
+
*/
|
|
44
|
+
recordExternalId(externalId: string, messageId: string): void;
|
|
45
|
+
/** Look up the inbox message id previously recorded for an external id. */
|
|
46
|
+
getMessageIdByExternalId(externalId: string): string | undefined;
|
|
47
|
+
/** Convenience: whether an external id has already been recorded. */
|
|
48
|
+
hasSeenExternalId(externalId: string): boolean;
|
|
49
|
+
|
|
39
50
|
// Conversations
|
|
40
51
|
getConversation(id: string): Conversation | undefined;
|
|
41
52
|
putConversation(conversation: Conversation): Conversation;
|
package/src/storage/memory.ts
CHANGED
|
@@ -13,6 +13,7 @@ export class InMemoryStorage implements Storage {
|
|
|
13
13
|
private conversations = new Map<string, Conversation>();
|
|
14
14
|
private turns: Turn[] = [];
|
|
15
15
|
private threads = new Map<string, Thread>();
|
|
16
|
+
private externalIds = new Map<string, string>();
|
|
16
17
|
|
|
17
18
|
// --- Agents ---
|
|
18
19
|
|
|
@@ -50,6 +51,20 @@ export class InMemoryStorage implements Storage {
|
|
|
50
51
|
if (msg) msg.conversation_id = conversationId;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
recordExternalId(externalId: string, messageId: string): void {
|
|
55
|
+
if (!this.externalIds.has(externalId)) {
|
|
56
|
+
this.externalIds.set(externalId, messageId);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
getMessageIdByExternalId(externalId: string): string | undefined {
|
|
61
|
+
return this.externalIds.get(externalId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
hasSeenExternalId(externalId: string): boolean {
|
|
65
|
+
return this.externalIds.has(externalId);
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
pruneMessagesOlderThan(cutoff: string): number {
|
|
54
69
|
let removed = 0;
|
|
55
70
|
const removedIds = new Set<string>();
|
package/src/storage/sqlite.ts
CHANGED
|
@@ -128,6 +128,13 @@ export class SqliteStorage implements Storage {
|
|
|
128
128
|
FOREIGN KEY (conversation_id) REFERENCES ${t("conversations")}(id)
|
|
129
129
|
);
|
|
130
130
|
|
|
131
|
+
-- External-id mapping (mail dedup + bounce correlation)
|
|
132
|
+
CREATE TABLE IF NOT EXISTS ${t("external_ids")} (
|
|
133
|
+
external_id TEXT PRIMARY KEY,
|
|
134
|
+
message_id TEXT NOT NULL,
|
|
135
|
+
recorded_at TEXT NOT NULL
|
|
136
|
+
);
|
|
137
|
+
|
|
131
138
|
-- Indexes for common queries
|
|
132
139
|
CREATE INDEX IF NOT EXISTS idx_${t("messages")}_scope ON ${t("messages")}(scope);
|
|
133
140
|
CREATE INDEX IF NOT EXISTS idx_${t("messages")}_sender ON ${t("messages")}(sender_id);
|
|
@@ -183,6 +190,12 @@ export class SqliteStorage implements Storage {
|
|
|
183
190
|
`);
|
|
184
191
|
}
|
|
185
192
|
|
|
193
|
+
/** Expose the underlying handle so sibling features (mail queue/attachments)
|
|
194
|
+
* can co-locate their tables. Caller must not close it. */
|
|
195
|
+
getDatabase(): Database.Database {
|
|
196
|
+
return this.db;
|
|
197
|
+
}
|
|
198
|
+
|
|
186
199
|
// --- Agents ---
|
|
187
200
|
|
|
188
201
|
getAgent(agentId: string): Agent | undefined {
|
|
@@ -291,6 +304,29 @@ export class SqliteStorage implements Storage {
|
|
|
291
304
|
.run(conversationId, messageId);
|
|
292
305
|
}
|
|
293
306
|
|
|
307
|
+
recordExternalId(externalId: string, messageId: string): void {
|
|
308
|
+
this.db
|
|
309
|
+
.prepare(
|
|
310
|
+
`INSERT INTO ${this.p("external_ids")} (external_id, message_id, recorded_at)
|
|
311
|
+
VALUES (?, ?, ?)
|
|
312
|
+
ON CONFLICT(external_id) DO NOTHING`
|
|
313
|
+
)
|
|
314
|
+
.run(externalId, messageId, new Date().toISOString());
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
getMessageIdByExternalId(externalId: string): string | undefined {
|
|
318
|
+
const row = this.db
|
|
319
|
+
.prepare(
|
|
320
|
+
`SELECT message_id FROM ${this.p("external_ids")} WHERE external_id = ?`
|
|
321
|
+
)
|
|
322
|
+
.get(externalId) as { message_id: string } | undefined;
|
|
323
|
+
return row?.message_id;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
hasSeenExternalId(externalId: string): boolean {
|
|
327
|
+
return this.getMessageIdByExternalId(externalId) !== undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
294
330
|
pruneMessagesOlderThan(cutoff: string): number {
|
|
295
331
|
const m = this.p("messages");
|
|
296
332
|
const r = this.p("recipients");
|
package/src/types.ts
CHANGED
|
@@ -211,6 +211,11 @@ export interface FederationPeerConfig {
|
|
|
211
211
|
url?: string;
|
|
212
212
|
/** Mesh peer ID for agentic-mesh transport connections. */
|
|
213
213
|
meshPeerId?: string;
|
|
214
|
+
/**
|
|
215
|
+
* Dotted addresses (e.g. "team.corp.internal") routed to this peer rather
|
|
216
|
+
* than treated as external mail. See destination classification (§1).
|
|
217
|
+
*/
|
|
218
|
+
domains?: string[];
|
|
214
219
|
auth?: FederationAuth;
|
|
215
220
|
exposure?: ExposurePolicy;
|
|
216
221
|
}
|
|
@@ -311,6 +316,73 @@ export interface QueuedMessage {
|
|
|
311
316
|
nextRetry?: string;
|
|
312
317
|
}
|
|
313
318
|
|
|
319
|
+
// --- Mail interop ---
|
|
320
|
+
|
|
321
|
+
/** Maps between agent ids and email addresses; declares owned domains. */
|
|
322
|
+
export interface MailIdentityConfig {
|
|
323
|
+
/** Domains we receive for; inbound RCPT TO outside these is rejected. */
|
|
324
|
+
localDomains: string[];
|
|
325
|
+
/** agent_id ↔ primary email. Plus-addressing (agent+scope@domain) aware. */
|
|
326
|
+
mappings: Array<{ agentId: string; address: string }>;
|
|
327
|
+
/** Default mailbox for accepted-but-unmatched inbound (e.g. a triage agent). */
|
|
328
|
+
catchAllAgentId?: string;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export interface MailConfig {
|
|
332
|
+
enabled: boolean;
|
|
333
|
+
backend: "smtp" | "provider";
|
|
334
|
+
identity: MailIdentityConfig;
|
|
335
|
+
/**
|
|
336
|
+
* Domains we will SEND to without erroring (destination classification §1).
|
|
337
|
+
* Receiving uses identity.localDomains. Anything outside both → error.
|
|
338
|
+
*/
|
|
339
|
+
routableDomains?: string[];
|
|
340
|
+
smtp?: {
|
|
341
|
+
listenPort?: number;
|
|
342
|
+
relay?: {
|
|
343
|
+
host: string;
|
|
344
|
+
port: number;
|
|
345
|
+
auth?: { user: string; pass: string };
|
|
346
|
+
};
|
|
347
|
+
dkim?: { domain: string; selector: string; privateKeyRef: string };
|
|
348
|
+
};
|
|
349
|
+
provider?: {
|
|
350
|
+
name: "ses" | "postmark" | "mailgun";
|
|
351
|
+
apiKeyRef: string;
|
|
352
|
+
webhookPath?: string;
|
|
353
|
+
};
|
|
354
|
+
/** Delivery queue overrides; defaults to sqlite persistence when mail enabled. */
|
|
355
|
+
queue?: Partial<DeliveryQueueConfig>;
|
|
356
|
+
/**
|
|
357
|
+
* Attachment byte storage. Defaults to "sqlite" (content-addressed BLOBs in
|
|
358
|
+
* the DB). Use "fs" to keep bytes on disk for large/high-volume deployments.
|
|
359
|
+
*/
|
|
360
|
+
attachments?: {
|
|
361
|
+
backend?: "sqlite" | "fs";
|
|
362
|
+
/** Directory for the "fs" backend. Defaults to <home>/.claude/agent-inbox/attachments. */
|
|
363
|
+
dir?: string;
|
|
364
|
+
};
|
|
365
|
+
/** Inbound sender allow-list (domains). Empty/undefined = allow all. */
|
|
366
|
+
allowedSenderDomains?: string[];
|
|
367
|
+
/** Drop inbound whose DMARC verdict is "fail". */
|
|
368
|
+
rejectDmarcFail?: boolean;
|
|
369
|
+
/** Max attachments on an inbound message before rejection. */
|
|
370
|
+
maxAttachments?: number;
|
|
371
|
+
/** Inbound rate limiting (abuse control). Disabled if unset. */
|
|
372
|
+
rateLimit?: {
|
|
373
|
+
windowMs?: number;
|
|
374
|
+
perSenderDomain?: number;
|
|
375
|
+
global?: number;
|
|
376
|
+
};
|
|
377
|
+
/** Bounce handling (both default on). See §8. */
|
|
378
|
+
bounce?: {
|
|
379
|
+
/** Emit the mail.bounced event. */
|
|
380
|
+
emitEvent?: boolean;
|
|
381
|
+
/** Inject a synthetic bounce Message into the original sender's inbox. */
|
|
382
|
+
synthesizeInboxMessage?: boolean;
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
314
386
|
// --- Config ---
|
|
315
387
|
|
|
316
388
|
export interface InboxConfig {
|
|
@@ -327,4 +399,5 @@ export interface InboxConfig {
|
|
|
327
399
|
};
|
|
328
400
|
};
|
|
329
401
|
federation?: FederationConfig;
|
|
402
|
+
mail?: MailConfig;
|
|
330
403
|
}
|