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
@@ -1,163 +0,0 @@
1
- /**
2
- * Filesystem-backed AttachmentStore — opt-in for large/high-volume deployments.
3
- *
4
- * Keeps attachment bytes out of the SQLite DB (no WAL bloat, no synchronous
5
- * blob I/O blocking the event loop). Content-addressed by sha256, so identical
6
- * bytes dedup. Files live at `<dir>/<aa>/<sha256>` (sharded by the first byte
7
- * to avoid one enormous directory).
8
- *
9
- * Durability: writes go to a unique temp file, are fsync'd, then atomically
10
- * renamed into place. Because the caller stores the attachment (this `put`)
11
- * BEFORE committing the message row that references it, a crash can only ever
12
- * leave an orphan file (reclaimable by GC) — never a row pointing at missing
13
- * bytes. See docs/MAIL-INTEROP-PLAN.md §4c.
14
- */
15
-
16
- import { createHash } from "node:crypto";
17
- import * as fs from "node:fs/promises";
18
- import * as path from "node:path";
19
- import type { AttachmentStore } from "./types.js";
20
-
21
- const REF_RE = /^sha256:([0-9a-f]{64})$/;
22
-
23
- export class FsAttachmentStore implements AttachmentStore {
24
- constructor(private dir: string) {}
25
-
26
- async put(
27
- bytes: Buffer,
28
- _meta: { contentType: string; filename?: string }
29
- ): Promise<string> {
30
- const hex = createHash("sha256").update(bytes).digest("hex");
31
- const ref = `sha256:${hex}`;
32
- const full = this.pathForHex(hex);
33
-
34
- // Content-addressed: if the bytes are already stored, we're done.
35
- if (await exists(full)) return ref;
36
-
37
- await fs.mkdir(path.dirname(full), { recursive: true });
38
-
39
- // Atomic durable write: unique temp file → fsync → rename into place.
40
- const tmp = `${full}.tmp-${process.pid}-${Date.now()}-${Math.random()
41
- .toString(36)
42
- .slice(2)}`;
43
- let handle: fs.FileHandle | undefined;
44
- try {
45
- handle = await fs.open(tmp, "wx");
46
- await handle.writeFile(bytes);
47
- await handle.sync();
48
- } finally {
49
- await handle?.close();
50
- }
51
- try {
52
- await fs.rename(tmp, full);
53
- } catch (err) {
54
- // A concurrent put of identical bytes may have won the race — that's fine
55
- // (same content). Clean up our temp file and return the ref.
56
- await fs.rm(tmp, { force: true });
57
- if (!(await exists(full))) throw err;
58
- }
59
- // Durability: fsync the containing directory so the rename survives a crash
60
- // (atomic rename alone is not crash-durable without a parent-dir fsync).
61
- await fsyncDir(path.dirname(full));
62
- return ref;
63
- }
64
-
65
- async get(ref: string): Promise<Buffer> {
66
- const full = this.pathForRef(ref);
67
- try {
68
- return await fs.readFile(full);
69
- } catch (err) {
70
- if ((err as NodeJS.ErrnoException).code === "ENOENT") {
71
- throw new Error(`Attachment not found: ${ref}`);
72
- }
73
- throw err;
74
- }
75
- }
76
-
77
- async delete(ref: string): Promise<void> {
78
- await fs.rm(this.pathForRef(ref), { force: true });
79
- }
80
-
81
- /**
82
- * Garbage-collect orphan files: delete any stored attachment whose ref is not
83
- * in `liveRefs` AND that is older than `minAgeMs` (default 1h).
84
- *
85
- * The age guard prevents a TOCTOU race: an attachment is `put` (file lands on
86
- * disk) before the message row that references it is committed, so a just-
87
- * written file may legitimately not appear in a `liveRefs` snapshot yet.
88
- * Skipping recently-written files avoids deleting such in-flight attachments.
89
- */
90
- async gc(liveRefs: Set<string>, minAgeMs: number = 3_600_000): Promise<number> {
91
- let removed = 0;
92
- const cutoff = Date.now() - minAgeMs;
93
- let shards: Array<{ name: string; isDirectory(): boolean }>;
94
- try {
95
- shards = await fs.readdir(this.dir, { withFileTypes: true });
96
- } catch (err) {
97
- if ((err as NodeJS.ErrnoException).code === "ENOENT") return 0;
98
- throw err;
99
- }
100
- for (const shard of shards) {
101
- if (!shard.isDirectory()) continue; // ignore stray files at the root
102
- const shardDir = path.join(this.dir, shard.name);
103
- let files: string[];
104
- try {
105
- files = await fs.readdir(shardDir);
106
- } catch {
107
- continue;
108
- }
109
- for (const name of files) {
110
- if (name.includes(".tmp-")) continue; // leave in-flight temp files
111
- if (liveRefs.has(`sha256:${name}`)) continue;
112
- const filePath = path.join(shardDir, name);
113
- try {
114
- const st = await fs.stat(filePath);
115
- if (st.mtimeMs > cutoff) continue; // too new — may be mid-ingestion
116
- } catch {
117
- continue;
118
- }
119
- await fs.rm(filePath, { force: true });
120
- removed++;
121
- }
122
- }
123
- return removed;
124
- }
125
-
126
- private pathForHex(hex: string): string {
127
- // Shard by the first byte of the hash to bound directory fan-out.
128
- return path.join(this.dir, hex.slice(0, 2), hex);
129
- }
130
-
131
- private pathForRef(ref: string): string {
132
- const m = REF_RE.exec(ref);
133
- if (!m) {
134
- // Strict validation prevents path traversal via a crafted ref (refs are
135
- // always sha256-of-content, never caller-chosen).
136
- throw new Error(`Invalid attachment ref: ${ref}`);
137
- }
138
- return this.pathForHex(m[1]);
139
- }
140
- }
141
-
142
- async function exists(p: string): Promise<boolean> {
143
- try {
144
- await fs.access(p);
145
- return true;
146
- } catch {
147
- return false;
148
- }
149
- }
150
-
151
- /** fsync a directory so a rename into it is durable. Best-effort across platforms. */
152
- async function fsyncDir(dir: string): Promise<void> {
153
- let handle: fs.FileHandle | undefined;
154
- try {
155
- handle = await fs.open(dir, "r");
156
- await handle.sync();
157
- } catch {
158
- // Some platforms (e.g. Windows) don't permit fsync on a directory handle.
159
- // Atomic rename still holds; this only weakens the durability guarantee.
160
- } finally {
161
- await handle?.close().catch(() => {});
162
- }
163
- }
@@ -1,505 +0,0 @@
1
- /**
2
- * MailGateway — the only mail-aware component the rest of the system touches.
3
- *
4
- * Ingress: transport.onReceive → policy (size / allow-list / DMARC) → dedup →
5
- * bounce-detect → map → commit via router.routeMessage (single ingestion path).
6
- * The transport ACKs only after this resolves (commit-before-ACK); a throw
7
- * NACKs so the sender retries.
8
- *
9
- * Egress: router calls send() for mail-class recipients. The message is rendered
10
- * and attempted; transient failures are queued durably and retried; permanent
11
- * failures (and inbound DSNs) converge on one config-gated bounce handler.
12
- *
13
- * See docs/MAIL-INTEROP-PLAN.md §7, §8.
14
- */
15
-
16
- import { EventEmitter } from "node:events";
17
- import { createHash } from "node:crypto";
18
- import type { Storage } from "../storage/interface.js";
19
- import type { MessageRouter, MailEgress } from "../router/message-router.js";
20
- import { parseAddress } from "../federation/address.js";
21
- import { domainMatches } from "../router/destination.js";
22
- import { DeliveryQueue } from "../federation/delivery-queue.js";
23
- import type { QueueStore } from "../federation/queue-store.js";
24
- import { AddressBook } from "./address-book.js";
25
- import { RateLimiter } from "./rate-limiter.js";
26
- import {
27
- inboundMailToMessage,
28
- messageToOutboundMail,
29
- UnknownRecipientError,
30
- } from "./email-mapper.js";
31
- import type { Message, MailIdentityConfig, DeliveryQueueConfig } from "../types.js";
32
- import type { MailTransport, InboundMail } from "./types.js";
33
-
34
- export interface MailGatewayConfig {
35
- identity: MailIdentityConfig;
36
- /** Domains we send to without erroring; combined with identity.localDomains. */
37
- routableDomains?: string[];
38
- /** Override the transport's maxMessageBytes for inbound rejection. */
39
- maxMessageBytes?: number;
40
- /** Inbound sender allow-list (domains). Empty/undefined = allow all. */
41
- allowedSenderDomains?: string[];
42
- /** Drop inbound mail whose DMARC verdict is "fail". */
43
- rejectDmarcFail?: boolean;
44
- /** Max attachments on an inbound message before it is rejected. */
45
- maxAttachments?: number;
46
- /**
47
- * Inbound rate limiting (abuse control). Over-limit inbound is NACKed
48
- * (transient) so legitimate senders back off and retry. Disabled if unset.
49
- */
50
- rateLimit?: {
51
- /** Sliding window, ms (default 60000). */
52
- windowMs?: number;
53
- /** Max inbound per sender domain per window. */
54
- perSenderDomain?: number;
55
- /** Max inbound across all senders per window. */
56
- global?: number;
57
- };
58
- /** Bounce behavior; both paths default on. */
59
- bounce?: { emitEvent?: boolean; synthesizeInboxMessage?: boolean };
60
- /** Domain used to mint outbound Message-IDs (defaults to primary local domain). */
61
- messageIdDomain?: string;
62
- /** Default inbox scope for inbound mail without plus-addressing. */
63
- defaultScope?: string;
64
- /** Delivery queue config for outbound retries. */
65
- queue?: Partial<DeliveryQueueConfig>;
66
- }
67
-
68
- /** Single queue bucket for outbound mail retries. */
69
- const MAIL_QUEUE = "mail";
70
-
71
- /** Thrown to NACK over-limit inbound so the sender backs off and retries. */
72
- export class RateLimitedError extends Error {
73
- constructor(public readonly scope: string) {
74
- super(`Inbound rate limit exceeded (${scope})`);
75
- this.name = "RateLimitedError";
76
- }
77
- }
78
-
79
- export class MailGateway implements MailEgress {
80
- private transport: MailTransport;
81
- private storage: Storage;
82
- private router: MessageRouter;
83
- private events: EventEmitter;
84
- private config: MailGatewayConfig;
85
- private addressBook: AddressBook;
86
- private mailDomains: string[];
87
- private senderLimiter: RateLimiter | null = null;
88
- private globalLimiter: RateLimiter | null = null;
89
- readonly queue: DeliveryQueue;
90
- private retryTimer?: ReturnType<typeof setInterval>;
91
- private retrying = false;
92
-
93
- constructor(opts: {
94
- transport: MailTransport;
95
- storage: Storage;
96
- router: MessageRouter;
97
- events: EventEmitter;
98
- config: MailGatewayConfig;
99
- /** Durable queue store for outbound retries (sqlite). Memory if omitted. */
100
- queueStore?: QueueStore;
101
- }) {
102
- this.transport = opts.transport;
103
- this.storage = opts.storage;
104
- this.router = opts.router;
105
- this.events = opts.events;
106
- this.config = opts.config;
107
- this.addressBook = new AddressBook(opts.config.identity);
108
- if (this.addressBook.conflicts.length > 0) {
109
- this.events.emit("mail.config.conflict", {
110
- kind: "address-mapping",
111
- conflicts: this.addressBook.conflicts,
112
- });
113
- }
114
- this.mailDomains = [
115
- ...opts.config.identity.localDomains,
116
- ...(opts.config.routableDomains ?? []),
117
- ];
118
- this.queue = new DeliveryQueue(
119
- opts.events,
120
- opts.config.queue,
121
- opts.queueStore ? { store: opts.queueStore } : undefined
122
- );
123
-
124
- const rl = opts.config.rateLimit;
125
- if (rl) {
126
- const windowMs = rl.windowMs ?? 60_000;
127
- if (rl.perSenderDomain && rl.perSenderDomain > 0) {
128
- this.senderLimiter = new RateLimiter(windowMs, rl.perSenderDomain);
129
- }
130
- if (rl.global && rl.global > 0) {
131
- this.globalLimiter = new RateLimiter(windowMs, rl.global);
132
- }
133
- }
134
-
135
- this.transport.onReceive((mail) => this.handleInbound(mail));
136
- }
137
-
138
- /** Start the underlying transport and the retry/TTL timers. */
139
- async start(intervalMs: number = 30_000): Promise<void> {
140
- await this.transport.start();
141
- this.queue.startTicking();
142
- this.retryTimer = setInterval(() => {
143
- void this.processRetries();
144
- }, intervalMs);
145
- }
146
-
147
- async stop(): Promise<void> {
148
- if (this.retryTimer) clearInterval(this.retryTimer);
149
- this.retryTimer = undefined;
150
- this.queue.stopTicking();
151
- await this.transport.stop();
152
- }
153
-
154
- // -- MailEgress ----------------------------------------------------------
155
-
156
- /** True if this recipient's domain is one we route mail for. */
157
- ownsRecipient(agentId: string): boolean {
158
- if (agentId.indexOf("@") === -1) return false;
159
- const addr = parseAddress(agentId);
160
- return addr.system ? domainMatches(addr.system, this.mailDomains) : false;
161
- }
162
-
163
- /** Render and deliver the mail-class recipients of a message. */
164
- async send(message: Message): Promise<void> {
165
- const mailRecipients = message.recipients.filter((r) =>
166
- this.ownsRecipient(r.agent_id)
167
- );
168
- if (mailRecipients.length === 0) return;
169
- await this.attemptDelivery({ ...message, recipients: mailRecipients });
170
- }
171
-
172
- // -- Ingress -------------------------------------------------------------
173
-
174
- /**
175
- * Handle one inbound message. Resolves on ACK; throws on NACK (transient
176
- * failure the sender should retry). Policy rejections ACK-and-drop so the
177
- * sender does not retry forever.
178
- */
179
- async handleInbound(mail: InboundMail): Promise<void> {
180
- const cap =
181
- this.config.maxMessageBytes ?? this.transport.capabilities.maxMessageBytes;
182
- if (mail.sizeBytes > cap) {
183
- this.reject(mail, "size-exceeded");
184
- return;
185
- }
186
-
187
- // Rate limiting (abuse control) — applies to all inbound including bounces.
188
- // Over-limit NACKs (throws) so legitimate senders back off and retry.
189
- this.enforceRateLimits(mail);
190
-
191
- // Bounces/DSNs correlate to a prior send rather than routing as new mail.
192
- if (mail.bounce) {
193
- await this.handleInboundBounce(mail);
194
- return;
195
- }
196
-
197
- if (!this.senderAllowed(mail)) {
198
- this.reject(mail, "sender-not-allowed");
199
- return;
200
- }
201
-
202
- if (this.config.rejectDmarcFail && mail.authResults?.dmarc === "fail") {
203
- this.reject(mail, "dmarc-fail");
204
- return;
205
- }
206
-
207
- const maxAtt = this.config.maxAttachments;
208
- if (maxAtt !== undefined && (mail.attachments?.length ?? 0) > maxAtt) {
209
- this.reject(mail, "too-many-attachments");
210
- return;
211
- }
212
-
213
- const externalId = this.externalIdFor(mail);
214
- if (this.storage.hasSeenExternalId(externalId)) {
215
- this.events.emit("mail.duplicate", { externalId });
216
- return; // already ingested — ACK without re-injecting
217
- }
218
-
219
- let mapped: Message;
220
- try {
221
- mapped = inboundMailToMessage(mail, {
222
- addressBook: this.addressBook,
223
- defaultScope: this.config.defaultScope,
224
- lookupInboxIdByRfcMessageId: (id) =>
225
- this.storage.getMessageIdByExternalId(id),
226
- });
227
- } catch (err) {
228
- if (err instanceof UnknownRecipientError) {
229
- this.reject(mail, "no-local-recipient");
230
- return;
231
- }
232
- throw err; // unexpected — NACK
233
- }
234
-
235
- // Single ingestion path: routeMessage stores, marks delivery, fires events.
236
- // When the reply resolves to a known parent, inherit the parent's thread_tag
237
- // (keeps threads consistent even for clients that send In-Reply-To without a
238
- // full References chain). Fall back to our derived tag if the parent is gone.
239
- const parentThreadTag = mapped.in_reply_to
240
- ? this.storage.getMessage(mapped.in_reply_to)?.thread_tag
241
- : undefined;
242
- const stored = await this.router.routeMessage({
243
- from: mapped.sender_id,
244
- to: mapped.recipients.map((r) => ({ agent_id: r.agent_id, kind: r.kind })),
245
- payload: mapped.content,
246
- scope: mapped.scope,
247
- subject: mapped.subject,
248
- threadTag: parentThreadTag ?? mapped.thread_tag,
249
- inReplyTo: mapped.in_reply_to,
250
- importance: mapped.importance,
251
- metadata: mapped.metadata,
252
- });
253
-
254
- this.storage.recordExternalId(externalId, stored.id);
255
- this.events.emit("mail.received", { messageId: stored.id, externalId });
256
- }
257
-
258
- // -- Egress delivery -----------------------------------------------------
259
-
260
- /** Attempt delivery; classify the result and queue/bounce accordingly. */
261
- private async attemptDelivery(
262
- message: Message,
263
- queuedId?: string
264
- ): Promise<void> {
265
- const outbound = messageToOutboundMail(message, {
266
- addressBook: this.addressBook,
267
- messageIdDomain: this.config.messageIdDomain,
268
- lookupRfcMessageId: (id) =>
269
- this.storage.getMessage(id)?.metadata.rfcMessageId as
270
- | string
271
- | undefined,
272
- lookupReferences: (id) =>
273
- this.storage.getMessage(id)?.metadata.rfcReferences as
274
- | string[]
275
- | undefined,
276
- });
277
-
278
- let disposition: "delivered" | "transient" | "permanent";
279
- let detail: string | undefined;
280
- let code: number | undefined;
281
- let remoteMessageId: string | undefined;
282
- try {
283
- const res = await this.transport.send(outbound);
284
- disposition = res.disposition;
285
- detail = res.detail;
286
- code = res.code;
287
- remoteMessageId = res.remoteMessageId;
288
- } catch (err) {
289
- // Transport threw (e.g. not ready) — treat as transient, keep retrying.
290
- disposition = "transient";
291
- detail = err instanceof Error ? err.message : String(err);
292
- }
293
-
294
- if (disposition === "delivered") {
295
- // Record our Message-ID so an inbound DSN or reply can correlate back.
296
- this.storage.recordExternalId(outbound.headers.messageId, message.id);
297
- // Also record the provider's id — some backends (Postmark) reference their
298
- // own message id in bounce webhooks rather than the RFC Message-ID.
299
- if (remoteMessageId) {
300
- this.storage.recordExternalId(remoteMessageId, message.id);
301
- }
302
- this.markDelivered(message);
303
- if (queuedId) this.queue.removeEntry(MAIL_QUEUE, queuedId);
304
- this.events.emit("mail.sent", { messageId: message.id, remoteMessageId });
305
- return;
306
- }
307
-
308
- if (disposition === "transient") {
309
- if (queuedId) {
310
- // recordAttempt returns false once retryMaxAttempts is exhausted (the
311
- // entry is then removed). Bounce instead of silently dropping it.
312
- const kept = this.queue.recordAttempt(MAIL_QUEUE, queuedId);
313
- if (!kept) {
314
- await this.bounce({
315
- message,
316
- recipient: outbound.to.map((t) => t.address).join(", "),
317
- status: code ? String(code) : undefined,
318
- diagnostic: detail ?? "retries exhausted",
319
- action: "failed",
320
- });
321
- return;
322
- }
323
- } else {
324
- this.queue.enqueue(MAIL_QUEUE, message);
325
- }
326
- this.events.emit("mail.queued", { messageId: message.id, detail });
327
- return;
328
- }
329
-
330
- // permanent
331
- if (queuedId) this.queue.removeEntry(MAIL_QUEUE, queuedId);
332
- await this.bounce({
333
- message,
334
- recipient: outbound.to.map((t) => t.address).join(", "),
335
- status: code ? String(code) : undefined,
336
- diagnostic: detail,
337
- action: "failed",
338
- });
339
- }
340
-
341
- /** Re-attempt all retryable queued mail. Call from a timer or deterministically. */
342
- async processRetries(): Promise<void> {
343
- // Guard against overlapping ticks: a slow send must not be re-selected and
344
- // re-sent by the next interval while it is still in flight.
345
- if (this.retrying) return;
346
- this.retrying = true;
347
- // Opportunistically reclaim idle rate-limiter keys (bounded memory).
348
- this.senderLimiter?.prune();
349
- this.globalLimiter?.prune();
350
- try {
351
- for (const entry of this.queue.getRetryable(MAIL_QUEUE)) {
352
- await this.attemptDelivery(entry.message, entry.id);
353
- }
354
- } finally {
355
- this.retrying = false;
356
- }
357
- }
358
-
359
- private markDelivered(message: Message): void {
360
- const stored = this.storage.getMessage(message.id);
361
- if (!stored) return;
362
- const now = new Date().toISOString();
363
- for (const r of stored.recipients) {
364
- if (this.ownsRecipient(r.agent_id)) r.delivered_at = now;
365
- }
366
- this.storage.putMessage(stored);
367
- }
368
-
369
- // -- Bounce (shared by outbound permanent + inbound DSN) -----------------
370
-
371
- private async handleInboundBounce(mail: InboundMail): Promise<void> {
372
- const b = mail.bounce!;
373
- await this.bounce({
374
- originalMessageId: b.originalMessageId,
375
- recipient: b.recipient,
376
- status: b.status,
377
- diagnostic: b.diagnostic,
378
- action: b.action,
379
- });
380
- }
381
-
382
- private async bounce(opts: {
383
- message?: Message;
384
- originalMessageId?: string;
385
- recipient: string;
386
- status?: string;
387
- diagnostic?: string;
388
- action: "failed" | "delayed" | "delivered";
389
- }): Promise<void> {
390
- let original = opts.message;
391
- if (!original && opts.originalMessageId) {
392
- const inboxId = this.storage.getMessageIdByExternalId(
393
- opts.originalMessageId
394
- );
395
- if (inboxId) original = this.storage.getMessage(inboxId);
396
- }
397
-
398
- // A delayed DSN means the peer MTA is still retrying — surface, don't bounce.
399
- if (opts.action === "delayed") {
400
- this.events.emit("mail.queued", {
401
- messageId: original?.id,
402
- recipient: opts.recipient,
403
- delayed: true,
404
- });
405
- return;
406
- }
407
- // A positive DSN ("delivered") is informational only.
408
- if (opts.action === "delivered") return;
409
-
410
- // Dedup: a replayed/spoofed DSN echoing a known message id must not
411
- // repeatedly emit events or synthesize inbox notices (flood protection).
412
- const bounceKey = `bounce:${original?.id ?? opts.originalMessageId ?? "?"}:${opts.recipient}`;
413
- if (this.storage.hasSeenExternalId(bounceKey)) return;
414
- this.storage.recordExternalId(bounceKey, original?.id ?? bounceKey);
415
-
416
- if (this.config.bounce?.emitEvent !== false) {
417
- this.events.emit("mail.bounced", {
418
- messageId: original?.id,
419
- recipient: opts.recipient,
420
- status: opts.status,
421
- diagnostic: opts.diagnostic,
422
- });
423
- }
424
-
425
- if (this.config.bounce?.synthesizeInboxMessage !== false && original) {
426
- await this.synthesizeBounce(original, opts);
427
- }
428
- }
429
-
430
- /** Inject a bounce notification into the original sender's inbox. */
431
- private async synthesizeBounce(
432
- original: Message,
433
- opts: { recipient: string; status?: string; diagnostic?: string }
434
- ): Promise<void> {
435
- const parts = [`Delivery to ${opts.recipient} failed`];
436
- if (opts.status) parts.push(`(${opts.status})`);
437
- if (opts.diagnostic) parts.push(`- ${opts.diagnostic}`);
438
-
439
- await this.router.routeMessage({
440
- from: "mailer-daemon",
441
- to: [{ agent_id: original.sender_id, kind: "to" }],
442
- subject: `Delivery failed: ${original.subject ?? "(no subject)"}`,
443
- payload: { type: "text", text: parts.join(" ") },
444
- inReplyTo: original.id,
445
- importance: "high",
446
- scope: original.scope,
447
- metadata: {
448
- bounce: {
449
- recipient: opts.recipient,
450
- status: opts.status,
451
- diagnostic: opts.diagnostic,
452
- },
453
- },
454
- });
455
- }
456
-
457
- // -- Helpers -------------------------------------------------------------
458
-
459
- private reject(mail: InboundMail, reason: string): void {
460
- this.events.emit("mail.rejected", {
461
- reason,
462
- from: mail.from.address,
463
- to: mail.to.map((t) => t.address),
464
- });
465
- }
466
-
467
- /** Throw RateLimitedError (→ NACK) when an inbound limiter is exceeded. */
468
- private enforceRateLimits(mail: InboundMail): void {
469
- if (this.globalLimiter && !this.globalLimiter.tryAcquire("*")) {
470
- this.events.emit("mail.rate_limited", { scope: "global" });
471
- throw new RateLimitedError("global");
472
- }
473
- if (this.senderLimiter) {
474
- const from = mail.from.address || mail.envelopeFrom || "unknown";
475
- const at = from.lastIndexOf("@");
476
- const domain = at === -1 ? from : from.slice(at + 1).toLowerCase();
477
- if (!this.senderLimiter.tryAcquire(domain)) {
478
- this.events.emit("mail.rate_limited", { scope: "sender", domain });
479
- throw new RateLimitedError(`sender:${domain}`);
480
- }
481
- }
482
- }
483
-
484
- private senderAllowed(mail: InboundMail): boolean {
485
- const allow = this.config.allowedSenderDomains;
486
- if (!allow || allow.length === 0) return true;
487
- const at = mail.from.address.lastIndexOf("@");
488
- if (at === -1) return false;
489
- return domainMatches(mail.from.address.slice(at + 1), allow);
490
- }
491
-
492
- /** Dedup key: RFC Message-ID when present, else a synthetic content hash. */
493
- private externalIdFor(mail: InboundMail): string {
494
- if (mail.headers.messageId) return mail.headers.messageId;
495
- const h = createHash("sha256");
496
- h.update(mail.from.address);
497
- h.update("\0");
498
- h.update(mail.subject ?? "");
499
- h.update("\0");
500
- h.update(mail.receivedAt);
501
- h.update("\0");
502
- h.update((mail.text ?? mail.html ?? "").slice(0, 4096));
503
- return "syn:" + h.digest("hex");
504
- }
505
- }