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
package/src/mail/types.ts DELETED
@@ -1,221 +0,0 @@
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
- }
@@ -1,140 +0,0 @@
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
- }
@@ -1,158 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import { EventEmitter } from "node:events";
3
- import Database from "better-sqlite3";
4
- import { DeliveryQueue } from "../../src/federation/delivery-queue.js";
5
- import { SqliteQueueStore } from "../../src/federation/queue-store.js";
6
- import type { DeliveryQueueConfig, Message } from "../../src/types.js";
7
-
8
- function makeMessage(id: string): Message {
9
- return {
10
- id,
11
- scope: "default",
12
- sender_id: "sender",
13
- recipients: [{ agent_id: "remote@peer", kind: "to" }],
14
- content: { type: "text", text: `msg-${id}` },
15
- importance: "normal",
16
- metadata: {},
17
- created_at: new Date().toISOString(),
18
- };
19
- }
20
-
21
- const baseConfig: DeliveryQueueConfig = {
22
- persistence: "sqlite",
23
- maxTTL: 10_000,
24
- maxQueueSize: 3,
25
- retryStrategy: "exponential",
26
- retryBaseInterval: 100,
27
- retryMaxAttempts: 0,
28
- flushOnReconnect: true,
29
- overflow: "drop-oldest",
30
- };
31
-
32
- describe("DeliveryQueue (sqlite persistence)", () => {
33
- let db: Database.Database;
34
- let events: EventEmitter;
35
-
36
- beforeEach(() => {
37
- db = new Database(":memory:");
38
- events = new EventEmitter();
39
- });
40
-
41
- afterEach(() => {
42
- db.close();
43
- });
44
-
45
- function makeQueue(config: Partial<DeliveryQueueConfig> = {}) {
46
- return new DeliveryQueue(
47
- events,
48
- { ...baseConfig, ...config },
49
- { store: new SqliteQueueStore(db) }
50
- );
51
- }
52
-
53
- it("persists enqueued messages to the store", () => {
54
- const q = makeQueue();
55
- q.enqueue("peer-1", makeMessage("m1"));
56
- q.enqueue("peer-1", makeMessage("m2"));
57
-
58
- const rows = db.prepare("SELECT * FROM delivery_queue").all();
59
- expect(rows).toHaveLength(2);
60
- });
61
-
62
- it("survives a restart by hydrating from the store", () => {
63
- const q1 = makeQueue();
64
- q1.enqueue("peer-1", makeMessage("m1"));
65
- q1.enqueue("peer-2", makeMessage("m2"));
66
- expect(q1.totalSize()).toBe(2);
67
-
68
- // Simulate restart: new queue + new store over the same db handle.
69
- const q2 = new DeliveryQueue(events, baseConfig, {
70
- store: new SqliteQueueStore(db),
71
- });
72
- expect(q2.totalSize()).toBe(2);
73
- expect(q2.size("peer-1")).toBe(1);
74
- expect(q2.size("peer-2")).toBe(1);
75
- expect(q2.peers().sort()).toEqual(["peer-1", "peer-2"]);
76
- });
77
-
78
- it("removes entries from the store on removeEntry", () => {
79
- const q = makeQueue();
80
- const id = q.enqueue("peer-1", makeMessage("m1"))!;
81
- q.removeEntry("peer-1", id);
82
-
83
- expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
84
- });
85
-
86
- it("clears the store for a peer on flush", () => {
87
- const q = makeQueue();
88
- q.enqueue("peer-1", makeMessage("m1"));
89
- q.enqueue("peer-1", makeMessage("m2"));
90
- q.flush("peer-1");
91
-
92
- expect(
93
- db.prepare("SELECT * FROM delivery_queue WHERE peer_id = ?").all("peer-1")
94
- ).toHaveLength(0);
95
- });
96
-
97
- it("drops the oldest entry from the store on overflow", () => {
98
- const q = makeQueue({ maxQueueSize: 2, overflow: "drop-oldest" });
99
- q.enqueue("peer-1", makeMessage("m1"));
100
- q.enqueue("peer-1", makeMessage("m2"));
101
- q.enqueue("peer-1", makeMessage("m3")); // evicts m1
102
-
103
- const messages = (
104
- db.prepare("SELECT message FROM delivery_queue").all() as Array<{
105
- message: string;
106
- }>
107
- ).map((r) => JSON.parse(r.message).id);
108
- expect(messages.sort()).toEqual(["m2", "m3"]);
109
- });
110
-
111
- it("updates attempt/next-retry state in the store on recordAttempt", () => {
112
- const q = makeQueue({ retryMaxAttempts: 5 });
113
- const id = q.enqueue("peer-1", makeMessage("m1"))!;
114
- q.recordAttempt("peer-1", id);
115
-
116
- const row = db
117
- .prepare("SELECT attempts, next_retry FROM delivery_queue WHERE id = ?")
118
- .get(id) as { attempts: number; next_retry: string | null };
119
- expect(row.attempts).toBe(1);
120
- expect(row.next_retry).toBeTruthy();
121
- });
122
-
123
- it("removes the entry from the store when max attempts are exceeded", () => {
124
- const q = makeQueue({ retryMaxAttempts: 1 });
125
- const id = q.enqueue("peer-1", makeMessage("m1"))!;
126
- const kept = q.recordAttempt("peer-1", id);
127
- expect(kept).toBe(false);
128
- expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
129
- });
130
-
131
- it("removes expired entries from the store on tick", () => {
132
- const q = makeQueue({ maxTTL: -1 }); // everything is immediately expired
133
- q.enqueue("peer-1", makeMessage("m1"));
134
- const expired = q.tick();
135
- expect(expired).toBe(1);
136
- expect(db.prepare("SELECT * FROM delivery_queue").all()).toHaveLength(0);
137
- });
138
-
139
- it("round-trips the full message payload through the store", () => {
140
- const q1 = makeQueue();
141
- const msg = makeMessage("m1");
142
- msg.subject = "hello";
143
- msg.thread_tag = "thread-xyz";
144
- q1.enqueue("peer-1", msg);
145
-
146
- const q2 = new DeliveryQueue(events, baseConfig, {
147
- store: new SqliteQueueStore(db),
148
- });
149
- const flushed = q2.flush("peer-1");
150
- expect(flushed).toHaveLength(1);
151
- expect(flushed[0].message.subject).toBe("hello");
152
- expect(flushed[0].message.thread_tag).toBe("thread-xyz");
153
- expect(flushed[0].message.content).toEqual({
154
- type: "text",
155
- text: "msg-m1",
156
- });
157
- });
158
- });
@@ -1,111 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { AddressBook } from "../../src/mail/address-book.js";
3
-
4
- function book(): AddressBook {
5
- return new AddressBook({
6
- localDomains: ["example.com", "agents.example.com"],
7
- mappings: [
8
- { agentId: "alice", address: "alice@example.com" },
9
- { agentId: "bob", address: "bob@example.com" },
10
- ],
11
- catchAllAgentId: undefined,
12
- });
13
- }
14
-
15
- describe("AddressBook", () => {
16
- it("reports the primary domain", () => {
17
- expect(book().primaryDomain()).toBe("example.com");
18
- });
19
-
20
- it("recognizes owned domains and subdomains", () => {
21
- const b = book();
22
- expect(b.ownsDomain("example.com")).toBe(true);
23
- expect(b.ownsDomain("mail.example.com")).toBe(true);
24
- expect(b.ownsDomain("agents.example.com")).toBe(true);
25
- expect(b.ownsDomain("notexample.com")).toBe(false);
26
- expect(b.ownsDomain("other.org")).toBe(false);
27
- });
28
-
29
- it("resolves a known inbound mailbox to its agent", () => {
30
- expect(book().resolveInbound("bob@example.com")).toEqual({
31
- agentId: "bob",
32
- scope: undefined,
33
- });
34
- });
35
-
36
- it("is case-insensitive on the mailbox", () => {
37
- expect(book().resolveInbound("ALICE@Example.com")?.agentId).toBe("alice");
38
- });
39
-
40
- it("extracts scope from plus-addressing", () => {
41
- expect(book().resolveInbound("alice+urgent@example.com")).toEqual({
42
- agentId: "alice",
43
- scope: "urgent",
44
- });
45
- });
46
-
47
- it("returns undefined for a foreign domain", () => {
48
- expect(book().resolveInbound("alice@other.org")).toBeUndefined();
49
- });
50
-
51
- it("returns undefined for an unknown mailbox without catch-all", () => {
52
- expect(book().resolveInbound("ghost@example.com")).toBeUndefined();
53
- });
54
-
55
- it("routes unknown mailboxes to catch-all when configured", () => {
56
- const b = new AddressBook({
57
- localDomains: ["example.com"],
58
- mappings: [],
59
- catchAllAgentId: "triage",
60
- });
61
- expect(b.resolveInbound("anyone@example.com")).toEqual({
62
- agentId: "triage",
63
- scope: undefined,
64
- });
65
- });
66
-
67
- it("resolves a mapped agent's from-address", () => {
68
- expect(book().resolveFrom("alice")).toEqual({ address: "alice@example.com" });
69
- });
70
-
71
- it("falls back to agentId@primaryDomain for an unmapped agent", () => {
72
- expect(book().resolveFrom("ghost")).toEqual({
73
- address: "ghost@example.com",
74
- });
75
- });
76
-
77
- it("ignores malformed addresses", () => {
78
- expect(book().resolveInbound("not-an-email")).toBeUndefined();
79
- expect(book().resolveInbound("@example.com")).toBeUndefined();
80
- expect(book().resolveInbound("foo@")).toBeUndefined();
81
- });
82
-
83
- it("rejects addresses with multiple @ (consistent with parseAddress)", () => {
84
- expect(book().resolveInbound("a@b@example.com")).toBeUndefined();
85
- expect(book().resolveInbound("alice@example.com@evil.com")).toBeUndefined();
86
- });
87
-
88
- it("records duplicate-address mapping conflicts and keeps the first", () => {
89
- const b = new AddressBook({
90
- localDomains: ["example.com"],
91
- mappings: [
92
- { agentId: "alice", address: "shared@example.com" },
93
- { agentId: "bob", address: "shared@example.com" },
94
- ],
95
- });
96
- expect(b.resolveInbound("shared@example.com")?.agentId).toBe("alice");
97
- expect(b.conflicts).toHaveLength(1);
98
- expect(b.conflicts[0]).toContain("shared@example.com");
99
- });
100
-
101
- it("does not flag a repeated identical (agent,address) pair as a conflict", () => {
102
- const b = new AddressBook({
103
- localDomains: ["example.com"],
104
- mappings: [
105
- { agentId: "alice", address: "alice@example.com" },
106
- { agentId: "alice", address: "alice@example.com" },
107
- ],
108
- });
109
- expect(b.conflicts).toHaveLength(0);
110
- });
111
- });