agent-inbox 0.2.2 → 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.
Files changed (149) hide show
  1. package/AGENTS.md +18 -0
  2. package/CLAUDE.md +92 -1
  3. package/README.md +73 -6
  4. package/bench/inbox-growth.bench.ts +224 -0
  5. package/dist/federation/connection-manager.d.ts +8 -0
  6. package/dist/federation/connection-manager.d.ts.map +1 -1
  7. package/dist/federation/connection-manager.js +12 -0
  8. package/dist/federation/connection-manager.js.map +1 -1
  9. package/dist/federation/delivery-queue.d.ts +11 -3
  10. package/dist/federation/delivery-queue.d.ts.map +1 -1
  11. package/dist/federation/delivery-queue.js +38 -8
  12. package/dist/federation/delivery-queue.js.map +1 -1
  13. package/dist/federation/queue-store.d.ts +42 -0
  14. package/dist/federation/queue-store.d.ts.map +1 -0
  15. package/dist/federation/queue-store.js +87 -0
  16. package/dist/federation/queue-store.js.map +1 -0
  17. package/dist/index.d.mts +2 -0
  18. package/dist/index.d.ts +29 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +124 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +1 -0
  23. package/dist/index.mjs.map +1 -0
  24. package/dist/jsonrpc/mail-push-types.d.ts +9 -0
  25. package/dist/jsonrpc/mail-push-types.d.ts.map +1 -1
  26. package/dist/jsonrpc/mail-push-types.js +1 -0
  27. package/dist/jsonrpc/mail-push-types.js.map +1 -1
  28. package/dist/jsonrpc/mail-server.d.ts +8 -1
  29. package/dist/jsonrpc/mail-server.d.ts.map +1 -1
  30. package/dist/jsonrpc/mail-server.js +42 -1
  31. package/dist/jsonrpc/mail-server.js.map +1 -1
  32. package/dist/mail/address-book.d.ts +43 -0
  33. package/dist/mail/address-book.d.ts.map +1 -0
  34. package/dist/mail/address-book.js +95 -0
  35. package/dist/mail/address-book.js.map +1 -0
  36. package/dist/mail/attachment-store.d.ts +31 -0
  37. package/dist/mail/attachment-store.d.ts.map +1 -0
  38. package/dist/mail/attachment-store.js +74 -0
  39. package/dist/mail/attachment-store.js.map +1 -0
  40. package/dist/mail/email-mapper.d.ts +41 -0
  41. package/dist/mail/email-mapper.d.ts.map +1 -0
  42. package/dist/mail/email-mapper.js +216 -0
  43. package/dist/mail/email-mapper.js.map +1 -0
  44. package/dist/mail/fs-attachment-store.d.ts +38 -0
  45. package/dist/mail/fs-attachment-store.d.ts.map +1 -0
  46. package/dist/mail/fs-attachment-store.js +165 -0
  47. package/dist/mail/fs-attachment-store.js.map +1 -0
  48. package/dist/mail/mail-gateway.d.ts +114 -0
  49. package/dist/mail/mail-gateway.d.ts.map +1 -0
  50. package/dist/mail/mail-gateway.js +402 -0
  51. package/dist/mail/mail-gateway.js.map +1 -0
  52. package/dist/mail/provider-transport.d.ts +138 -0
  53. package/dist/mail/provider-transport.d.ts.map +1 -0
  54. package/dist/mail/provider-transport.js +434 -0
  55. package/dist/mail/provider-transport.js.map +1 -0
  56. package/dist/mail/rate-limiter.d.ts +20 -0
  57. package/dist/mail/rate-limiter.d.ts.map +1 -0
  58. package/dist/mail/rate-limiter.js +56 -0
  59. package/dist/mail/rate-limiter.js.map +1 -0
  60. package/dist/mail/smtp-transport.d.ts +141 -0
  61. package/dist/mail/smtp-transport.d.ts.map +1 -0
  62. package/dist/mail/smtp-transport.js +415 -0
  63. package/dist/mail/smtp-transport.js.map +1 -0
  64. package/dist/mail/types.d.ts +177 -0
  65. package/dist/mail/types.d.ts.map +1 -0
  66. package/dist/mail/types.js +11 -0
  67. package/dist/mail/types.js.map +1 -0
  68. package/dist/push/notifier.d.ts +21 -0
  69. package/dist/push/notifier.d.ts.map +1 -1
  70. package/dist/push/notifier.js +84 -2
  71. package/dist/push/notifier.js.map +1 -1
  72. package/dist/router/destination.d.ts +69 -0
  73. package/dist/router/destination.d.ts.map +1 -0
  74. package/dist/router/destination.js +106 -0
  75. package/dist/router/destination.js.map +1 -0
  76. package/dist/router/message-router.d.ts +15 -0
  77. package/dist/router/message-router.d.ts.map +1 -1
  78. package/dist/router/message-router.js +25 -3
  79. package/dist/router/message-router.js.map +1 -1
  80. package/dist/storage/interface.d.ts +21 -0
  81. package/dist/storage/interface.d.ts.map +1 -1
  82. package/dist/storage/memory.d.ts +12 -0
  83. package/dist/storage/memory.d.ts.map +1 -1
  84. package/dist/storage/memory.js +50 -0
  85. package/dist/storage/memory.js.map +1 -1
  86. package/dist/storage/sqlite.d.ts +14 -0
  87. package/dist/storage/sqlite.d.ts.map +1 -1
  88. package/dist/storage/sqlite.js +79 -1
  89. package/dist/storage/sqlite.js.map +1 -1
  90. package/dist/traceability/traceability.d.ts.map +1 -1
  91. package/dist/traceability/traceability.js +7 -17
  92. package/dist/traceability/traceability.js.map +1 -1
  93. package/dist/types.d.ts +80 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/docs/DESIGN.md +15 -0
  96. package/docs/MAIL-INTEROP-PLAN.md +660 -0
  97. package/package.json +29 -3
  98. package/renovate.json5 +6 -0
  99. package/rules/agent-inbox.md +1 -0
  100. package/src/federation/connection-manager.ts +12 -0
  101. package/src/federation/delivery-queue.ts +38 -8
  102. package/src/federation/queue-store.ts +124 -0
  103. package/src/index.ts +186 -1
  104. package/src/jsonrpc/mail-push-types.ts +10 -0
  105. package/src/jsonrpc/mail-server.ts +48 -1
  106. package/src/mail/address-book.ts +111 -0
  107. package/src/mail/attachment-store.ts +90 -0
  108. package/src/mail/email-mapper.ts +288 -0
  109. package/src/mail/fs-attachment-store.ts +163 -0
  110. package/src/mail/mail-gateway.ts +505 -0
  111. package/src/mail/provider-transport.ts +577 -0
  112. package/src/mail/rate-limiter.ts +51 -0
  113. package/src/mail/smtp-transport.ts +589 -0
  114. package/src/mail/types.ts +221 -0
  115. package/src/push/notifier.ts +98 -2
  116. package/src/router/destination.ts +140 -0
  117. package/src/router/message-router.ts +41 -4
  118. package/src/storage/interface.ts +22 -0
  119. package/src/storage/memory.ts +59 -0
  120. package/src/storage/sqlite.ts +114 -1
  121. package/src/traceability/traceability.ts +7 -16
  122. package/src/types.ts +74 -0
  123. package/test/federation/delivery-queue-sqlite.test.ts +158 -0
  124. package/test/load.test.ts +288 -0
  125. package/test/mail/address-book.test.ts +111 -0
  126. package/test/mail/attachment-store-contract.test.ts +92 -0
  127. package/test/mail/attachment-store.test.ts +69 -0
  128. package/test/mail/destination.test.ts +115 -0
  129. package/test/mail/dsn-parse.test.ts +239 -0
  130. package/test/mail/email-mapper.test.ts +341 -0
  131. package/test/mail/external-id.test.ts +43 -0
  132. package/test/mail/fs-attachment-store.test.ts +134 -0
  133. package/test/mail/full-flow-e2e.test.ts +200 -0
  134. package/test/mail/mail-gateway.test.ts +419 -0
  135. package/test/mail/mail-transport-contract.test.ts +134 -0
  136. package/test/mail/mock-mail.ts +161 -0
  137. package/test/mail/mock-postmark.ts +66 -0
  138. package/test/mail/provider-transport.test.ts +381 -0
  139. package/test/mail/rate-limiter.test.ts +48 -0
  140. package/test/mail/router-mail-integration.test.ts +138 -0
  141. package/test/mail/smtp-e2e.test.ts +98 -0
  142. package/test/mail/smtp-transport.test.ts +138 -0
  143. package/test/mail-presence.test.ts +149 -0
  144. package/test/mail-push.test.ts +44 -0
  145. package/test/mail-server.test.ts +25 -0
  146. package/test/push-notifier.test.ts +81 -0
  147. package/test/sqlite-storage.test.ts +106 -0
  148. package/test/storage.test.ts +92 -0
  149. package/vitest.bench.config.ts +8 -0
@@ -0,0 +1,589 @@
1
+ /**
2
+ * Self-hosted SMTP MailTransport — the lightweight default backend.
3
+ *
4
+ * Inbound: `smtp-server` listener. On DATA end the message is parsed, auth-
5
+ * verified, attachments are stored, and the inbound handler is awaited
6
+ * BEFORE the SMTP 250 is returned (commit-before-ACK). A handler throw
7
+ * returns a 4xx/5xx so the peer MTA retries.
8
+ * Outbound: `nodemailer` (smarthost relay; direct-MX is future). SMTP responses
9
+ * map to delivered/transient/permanent.
10
+ *
11
+ * smtp-server / nodemailer / mailparser / mailauth are OPTIONAL peer deps,
12
+ * loaded via dynamic import (mirrors src/map/map-client.ts). The transport
13
+ * throws a clear error from start()/send() if they are not installed.
14
+ *
15
+ * The pure classification/parsing helpers are exported for unit testing without
16
+ * the libraries or real sockets.
17
+ */
18
+
19
+ import { ulid } from "ulid";
20
+ import type {
21
+ MailTransport,
22
+ MailCapabilities,
23
+ MailTransportState,
24
+ MailHealth,
25
+ OutboundMail,
26
+ MailSendResult,
27
+ InboundMail,
28
+ InboundHandler,
29
+ InboundAuthResults,
30
+ InboundBounce,
31
+ AttachmentStore,
32
+ } from "./types.js";
33
+
34
+ export interface SmtpTransportOptions {
35
+ /** Inbound listener port (default 25). */
36
+ listenPort?: number;
37
+ /** Inbound bind host (default "0.0.0.0"). */
38
+ listenHost?: string;
39
+ /** Outbound smarthost relay. Required for sending in this phase. */
40
+ relay?: {
41
+ host: string;
42
+ port: number;
43
+ secure?: boolean;
44
+ auth?: { user: string; pass: string };
45
+ };
46
+ /** DKIM signing for outbound. */
47
+ dkim?: { domainName: string; keySelector: string; privateKey: string };
48
+ /** Max accepted inbound size, bytes (default 25 MiB). */
49
+ maxMessageBytes?: number;
50
+ /** Where to persist inbound attachment bytes. */
51
+ attachmentStore?: AttachmentStore;
52
+ }
53
+
54
+ const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
55
+
56
+ // Minimal structural stubs for the optional libs (avoid hard type deps).
57
+ type SmtpServerLike = {
58
+ listen(port: number, host: string, cb: () => void): void;
59
+ close(cb: () => void): void;
60
+ };
61
+ type NodemailerLike = {
62
+ createTransport(opts: unknown): {
63
+ sendMail(msg: unknown): Promise<{ messageId?: string; response?: string }>;
64
+ };
65
+ };
66
+
67
+ export class SmtpTransport implements MailTransport {
68
+ readonly capabilities: MailCapabilities;
69
+ private _state: MailTransportState = "stopped";
70
+ private handler?: InboundHandler;
71
+ private server?: SmtpServerLike;
72
+ private mailer?: ReturnType<NodemailerLike["createTransport"]>;
73
+ /** Best-effort in-process idempotency for delivered sends (bounded). */
74
+ private delivered = new Set<string>();
75
+ private static readonly MAX_DELIVERED = 50_000;
76
+
77
+ constructor(private opts: SmtpTransportOptions) {
78
+ this.capabilities = {
79
+ outbound: opts.relay ? "relay" : "mx",
80
+ signsDkim: !!opts.dkim,
81
+ verifiesInboundAuth: true,
82
+ inbound: "listener",
83
+ maxMessageBytes: opts.maxMessageBytes ?? DEFAULT_MAX_BYTES,
84
+ };
85
+ }
86
+
87
+ get state(): MailTransportState {
88
+ return this._state;
89
+ }
90
+
91
+ onReceive(handler: InboundHandler): void {
92
+ this.handler = handler;
93
+ }
94
+
95
+ async health(): Promise<MailHealth> {
96
+ return { state: this._state };
97
+ }
98
+
99
+ async start(): Promise<void> {
100
+ if (this._state === "ready") return;
101
+ this._state = "starting";
102
+
103
+ const { SMTPServer } = await loadSmtpServer();
104
+ const maxBytes = this.capabilities.maxMessageBytes;
105
+
106
+ const server = new SMTPServer({
107
+ authOptional: true,
108
+ // TLS is expected to be terminated upstream (tunnel/relay) for the
109
+ // lightweight default; don't advertise the built-in self-signed cert.
110
+ hideSTARTTLS: true,
111
+ size: maxBytes,
112
+ onData: (
113
+ stream: NodeJS.ReadableStream & { sizeExceeded?: boolean },
114
+ session: SmtpSession,
115
+ callback: (err?: Error | null) => void
116
+ ) => {
117
+ // Guard the SMTP callback so it can fire at most once — smtp-server
118
+ // corrupts its protocol state if the data callback is invoked twice.
119
+ let acked = false;
120
+ const ack = (err?: Error | null) => {
121
+ if (acked) return;
122
+ acked = true;
123
+ callback(err);
124
+ };
125
+ this.onData(stream, session, ack).catch((err) => ack(err));
126
+ },
127
+ });
128
+
129
+ await new Promise<void>((resolve) => {
130
+ this.server = server as unknown as SmtpServerLike;
131
+ this.server.listen(
132
+ this.opts.listenPort ?? 25,
133
+ this.opts.listenHost ?? "0.0.0.0",
134
+ () => resolve()
135
+ );
136
+ });
137
+
138
+ this._state = "ready";
139
+ }
140
+
141
+ async stop(): Promise<void> {
142
+ this._state = "stopping";
143
+ if (this.server) {
144
+ await new Promise<void>((resolve) => this.server!.close(() => resolve()));
145
+ this.server = undefined;
146
+ }
147
+ this._state = "stopped";
148
+ }
149
+
150
+ async send(envelope: OutboundMail): Promise<MailSendResult> {
151
+ if (this._state !== "ready") {
152
+ throw new Error(`send() called while transport state is "${this._state}"`);
153
+ }
154
+ if (this.delivered.has(envelope.idempotencyKey)) {
155
+ return {
156
+ disposition: "delivered",
157
+ remoteMessageId: envelope.headers.messageId,
158
+ detail: "idempotent replay",
159
+ };
160
+ }
161
+ if (!this.opts.relay) {
162
+ throw new Error(
163
+ "SmtpTransport.send requires a relay; direct-MX is not yet implemented"
164
+ );
165
+ }
166
+
167
+ const mailer = await this.getMailer();
168
+ try {
169
+ const info = await mailer.sendMail(toNodemailerMessage(envelope, this.opts.dkim));
170
+ this.rememberDelivered(envelope.idempotencyKey);
171
+ return {
172
+ disposition: "delivered",
173
+ remoteMessageId: info.messageId ?? envelope.headers.messageId,
174
+ detail: info.response,
175
+ };
176
+ } catch (err) {
177
+ return smtpErrorToResult(err);
178
+ }
179
+ }
180
+
181
+ /** Record a delivered idempotency key, evicting the oldest when over cap. */
182
+ private rememberDelivered(key: string): void {
183
+ if (this.delivered.size >= SmtpTransport.MAX_DELIVERED) {
184
+ const oldest = this.delivered.values().next().value;
185
+ if (oldest !== undefined) this.delivered.delete(oldest);
186
+ }
187
+ this.delivered.add(key);
188
+ }
189
+
190
+ // -- inbound DATA handling ----------------------------------------------
191
+
192
+ private async onData(
193
+ stream: NodeJS.ReadableStream & { sizeExceeded?: boolean },
194
+ session: SmtpSession,
195
+ callback: (err?: Error | null) => void
196
+ ): Promise<void> {
197
+ const { simpleParser } = await loadMailparser();
198
+ const chunks: Buffer[] = [];
199
+ for await (const chunk of stream) {
200
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
201
+ }
202
+ const raw = Buffer.concat(chunks);
203
+
204
+ if (stream.sizeExceeded) {
205
+ // Permanent: message too large.
206
+ callback(Object.assign(new Error("Message exceeds size limit"), { responseCode: 552 }));
207
+ return;
208
+ }
209
+
210
+ const parsed = await simpleParser(raw);
211
+ const auth = await this.verifyAuth(raw, session).catch(() => undefined);
212
+ const attachments = await this.storeAttachments(parsed);
213
+
214
+ const mailFrom = session.envelope?.mailFrom;
215
+ const mail = parsedToInboundMail(parsed, {
216
+ envelopeFrom: mailFrom ? mailFrom.address : "",
217
+ envelopeTo: (session.envelope?.rcptTo ?? []).map((r) => r.address),
218
+ remote: { ip: session.remoteAddress, reverseDns: session.clientHostname },
219
+ authResults: auth,
220
+ attachments,
221
+ sizeBytes: raw.length,
222
+ });
223
+
224
+ if (!this.handler) {
225
+ // No handler registered — refuse so the peer retries later.
226
+ callback(Object.assign(new Error("Not ready"), { responseCode: 451 }));
227
+ return;
228
+ }
229
+
230
+ try {
231
+ await this.handler(mail); // commit happens here
232
+ callback(); // ACK 250 only after the handler resolves
233
+ } catch (err) {
234
+ // NACK — transient by default so the sender retries.
235
+ callback(Object.assign(err instanceof Error ? err : new Error(String(err)), {
236
+ responseCode: 451,
237
+ }));
238
+ }
239
+ }
240
+
241
+ private async verifyAuth(
242
+ raw: Buffer,
243
+ session: SmtpSession
244
+ ): Promise<InboundAuthResults | undefined> {
245
+ const mailauth = await loadMailauth().catch(() => undefined);
246
+ if (!mailauth) return undefined;
247
+ const mailFrom = session.envelope?.mailFrom;
248
+ const res = await mailauth.authenticate(raw, {
249
+ ip: session.remoteAddress,
250
+ helo: session.hostNameAppearsAs,
251
+ sender: mailFrom ? mailFrom.address : undefined,
252
+ });
253
+ return mailauthToResults(res);
254
+ }
255
+
256
+ private async storeAttachments(parsed: ParsedMail): Promise<InboundMail["attachments"]> {
257
+ const store = this.opts.attachmentStore;
258
+ if (!store || !parsed.attachments?.length) return undefined;
259
+ const out: NonNullable<InboundMail["attachments"]> = [];
260
+ for (const a of parsed.attachments) {
261
+ const ref = await store.put(a.content, {
262
+ contentType: a.contentType,
263
+ filename: a.filename,
264
+ });
265
+ out.push({
266
+ filename: a.filename,
267
+ contentType: a.contentType,
268
+ contentId: a.cid,
269
+ contentRef: ref,
270
+ sizeBytes: a.size,
271
+ });
272
+ }
273
+ return out;
274
+ }
275
+
276
+ private async getMailer() {
277
+ if (this.mailer) return this.mailer;
278
+ const nodemailer = await loadNodemailer();
279
+ const secure = this.opts.relay!.secure ?? false;
280
+ this.mailer = nodemailer.createTransport({
281
+ host: this.opts.relay!.host,
282
+ port: this.opts.relay!.port,
283
+ secure,
284
+ // Plaintext relay (TLS terminated upstream): don't attempt a STARTTLS
285
+ // upgrade against a server that isn't offering a trusted cert.
286
+ ignoreTLS: !secure,
287
+ auth: this.opts.relay!.auth,
288
+ });
289
+ return this.mailer;
290
+ }
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Pure helpers (exported for unit tests)
295
+ // ---------------------------------------------------------------------------
296
+
297
+ interface SmtpSession {
298
+ remoteAddress?: string;
299
+ clientHostname?: string;
300
+ hostNameAppearsAs?: string;
301
+ envelope?: {
302
+ mailFrom?: { address: string } | false;
303
+ rcptTo?: Array<{ address: string }>;
304
+ };
305
+ }
306
+
307
+ interface ParsedAddress {
308
+ value: Array<{ address?: string; name?: string }>;
309
+ }
310
+ interface ParsedMail {
311
+ from?: ParsedAddress;
312
+ to?: ParsedAddress;
313
+ cc?: ParsedAddress;
314
+ subject?: string;
315
+ text?: string;
316
+ html?: string | false;
317
+ messageId?: string;
318
+ inReplyTo?: string;
319
+ references?: string | string[];
320
+ headers?: Map<string, unknown>;
321
+ date?: Date;
322
+ attachments?: Array<{
323
+ content: Buffer;
324
+ contentType: string;
325
+ filename?: string;
326
+ cid?: string;
327
+ size: number;
328
+ }>;
329
+ }
330
+
331
+ /** Map a nodemailer/SMTP send error to a classified result. */
332
+ export function smtpErrorToResult(err: unknown): MailSendResult {
333
+ const e = err as { responseCode?: number; message?: string; response?: string };
334
+ const code = e?.responseCode;
335
+ if (typeof code === "number") {
336
+ if (code >= 500) {
337
+ return { disposition: "permanent", code, detail: e.response ?? e.message };
338
+ }
339
+ if (code >= 400) {
340
+ return { disposition: "transient", code, detail: e.response ?? e.message };
341
+ }
342
+ }
343
+ // No SMTP code (DNS/connection error) — transient, worth retrying.
344
+ return { disposition: "transient", detail: e?.message ?? "send failed" };
345
+ }
346
+
347
+ /** Build an InboundMail from mailparser output + session/transport context. */
348
+ export function parsedToInboundMail(
349
+ parsed: ParsedMail,
350
+ ctx: {
351
+ envelopeFrom: string;
352
+ envelopeTo: string[];
353
+ remote?: { ip?: string; reverseDns?: string };
354
+ authResults?: InboundAuthResults;
355
+ attachments?: InboundMail["attachments"];
356
+ sizeBytes: number;
357
+ }
358
+ ): InboundMail {
359
+ const addrs = (a: ParsedAddress | undefined) =>
360
+ (a?.value ?? [])
361
+ .filter((v) => v.address)
362
+ .map((v) => ({ address: v.address!, name: v.name }));
363
+
364
+ const refs = parsed.references
365
+ ? Array.isArray(parsed.references)
366
+ ? parsed.references
367
+ : parsed.references.split(/\s+/).filter(Boolean)
368
+ : undefined;
369
+
370
+ const raw: Record<string, string | string[]> = {};
371
+ if (parsed.headers) {
372
+ for (const [k, v] of parsed.headers.entries()) {
373
+ raw[k.toLowerCase()] = typeof v === "string" ? v : String(v);
374
+ }
375
+ }
376
+
377
+ return {
378
+ envelopeFrom: ctx.envelopeFrom,
379
+ envelopeTo: ctx.envelopeTo,
380
+ from: addrs(parsed.from)[0] ?? { address: ctx.envelopeFrom },
381
+ to: addrs(parsed.to),
382
+ cc: addrs(parsed.cc),
383
+ subject: parsed.subject,
384
+ text: parsed.text,
385
+ html: typeof parsed.html === "string" ? parsed.html : undefined,
386
+ headers: {
387
+ messageId: parsed.messageId,
388
+ inReplyTo: parsed.inReplyTo,
389
+ references: refs,
390
+ raw,
391
+ },
392
+ attachments: ctx.attachments,
393
+ authResults: ctx.authResults,
394
+ remote: ctx.remote,
395
+ bounce: parseDsnFromParsed(parsed),
396
+ sizeBytes: ctx.sizeBytes,
397
+ receivedAt: (parsed.date ?? new Date()).toISOString(),
398
+ };
399
+ }
400
+
401
+ /**
402
+ * Detect and parse an RFC 3464 delivery-status notification (bounce) from
403
+ * mailparser output. A DSN is `multipart/report; report-type=delivery-status`,
404
+ * with a `message/delivery-status` part carrying per-recipient Action/Status/
405
+ * Diagnostic-Code, and usually a returned-message part bearing the original
406
+ * Message-ID. Returns undefined for ordinary mail.
407
+ */
408
+ export function parseDsnFromParsed(parsed: ParsedMail): InboundBounce | undefined {
409
+ const contentType = headerString(parsed.headers, "content-type").toLowerCase();
410
+ // A real RFC 3464 DSN is multipart/report at the TOP level. Detecting on a
411
+ // delivery-status part alone would misclassify an ordinary email that merely
412
+ // *carries* a forwarded/attached bounce (top-level multipart/mixed) — a very
413
+ // common support-inbox scenario. Require the top-level report content-type.
414
+ const isReport =
415
+ contentType.includes("multipart/report") ||
416
+ contentType.includes("report-type=delivery-status");
417
+ if (!isReport) return undefined;
418
+
419
+ // The per-recipient delivery-status fields live in a `message/delivery-status`
420
+ // MIME part — but mailparser only surfaces that as an attachment when it
421
+ // carries `Content-Disposition: attachment`. The common Postfix/Gmail form
422
+ // omits that, and mailparser folds the block into the text body instead. So
423
+ // read from the attachment when present, else fall back to the parsed text.
424
+ const statusPart = (parsed.attachments ?? []).find((a) =>
425
+ a.contentType?.toLowerCase().includes("delivery-status")
426
+ );
427
+ const statusText = statusPart
428
+ ? statusPart.content.toString("utf8")
429
+ : parsed.text ?? "";
430
+
431
+ const field = (re: RegExp, text: string): string | undefined => {
432
+ const m = text.match(re);
433
+ return m ? m[1].trim() : undefined;
434
+ };
435
+
436
+ const actionRaw = field(/^Action:\s*([^\r\n]+)/im, statusText)?.toLowerCase();
437
+ const status = field(/^Status:\s*([0-9.]+)/im, statusText);
438
+ const diagnostic = field(/^Diagnostic-Code:\s*([^\r\n]+)/im, statusText);
439
+ const recipient =
440
+ addrFromField(field(/^Final-Recipient:\s*([^\r\n]+)/im, statusText)) ??
441
+ addrFromField(field(/^Original-Recipient:\s*([^\r\n]+)/im, statusText));
442
+
443
+ // The original Message-ID may appear in the returned message part (surfaced as
444
+ // an attachment) or be folded into the text (text/rfc822-headers form), or as
445
+ // an Original-Envelope-Id / X-Original-Message-ID field.
446
+ const rfc822 = (parsed.attachments ?? []).find((a) =>
447
+ a.contentType?.toLowerCase().includes("rfc822")
448
+ );
449
+ const originalMessageId =
450
+ (rfc822 &&
451
+ field(/^Message-ID:\s*(<[^>\r\n]+>)/im, rfc822.content.toString("utf8"))) ??
452
+ field(/^(?:X-Original-Message-ID|Original-Envelope-Id):\s*(<[^>\r\n]+>)/im, statusText) ??
453
+ field(/^Message-ID:\s*(<[^>\r\n]+>)/im, statusText) ??
454
+ undefined;
455
+
456
+ // If nothing actionable parsed out, this isn't a usable DSN — don't synthesize
457
+ // a meaningless bounce.
458
+ if (!actionRaw && !status && !recipient && !originalMessageId && !diagnostic) {
459
+ return undefined;
460
+ }
461
+
462
+ // Map Action / Status to our coarse disposition.
463
+ let action: InboundBounce["action"] = "failed";
464
+ if (actionRaw === "delayed" || status?.startsWith("4")) action = "delayed";
465
+ else if (actionRaw === "delivered" || actionRaw === "relayed" || actionRaw === "expanded")
466
+ action = "delivered";
467
+
468
+ return { action, recipient: recipient ?? "", status, originalMessageId, diagnostic };
469
+ }
470
+
471
+ function headerString(
472
+ headers: Map<string, unknown> | undefined,
473
+ key: string
474
+ ): string {
475
+ if (!headers) return "";
476
+ const v = headers.get(key);
477
+ if (typeof v === "string") return v;
478
+ if (v && typeof v === "object" && "value" in v) {
479
+ return String((v as { value: unknown }).value);
480
+ }
481
+ return v ? String(v) : "";
482
+ }
483
+
484
+ /** Extract the address from a DSN recipient field like "rfc822; user@host". */
485
+ function addrFromField(value: string | undefined): string | undefined {
486
+ if (!value) return undefined;
487
+ const semi = value.indexOf(";");
488
+ return (semi === -1 ? value : value.slice(semi + 1)).trim() || undefined;
489
+ }
490
+
491
+ /** Convert an OutboundMail to a nodemailer message object. */
492
+ export function toNodemailerMessage(
493
+ env: OutboundMail,
494
+ dkim?: SmtpTransportOptions["dkim"]
495
+ ): Record<string, unknown> {
496
+ const addr = (a: { address: string; name?: string }) =>
497
+ a.name ? { name: a.name, address: a.address } : a.address;
498
+ return {
499
+ messageId: env.headers.messageId,
500
+ inReplyTo: env.headers.inReplyTo,
501
+ references: env.headers.references,
502
+ from: addr(env.from),
503
+ to: env.to.map(addr),
504
+ cc: env.cc?.map(addr),
505
+ bcc: env.bcc?.map(addr),
506
+ subject: env.subject,
507
+ text: env.text,
508
+ html: env.html,
509
+ attachments: env.attachments?.map((a) => ({
510
+ filename: a.filename,
511
+ contentType: a.contentType,
512
+ cid: a.contentId,
513
+ })),
514
+ ...(dkim ? { dkim } : {}),
515
+ };
516
+ }
517
+
518
+ interface MailauthResult {
519
+ spf?: { status?: { result?: string } };
520
+ dkim?: { results?: Array<{ status?: { result?: string } }> };
521
+ dmarc?: { status?: { result?: string } };
522
+ }
523
+
524
+ /** Normalize a mailauth authenticate() result into InboundAuthResults. */
525
+ export function mailauthToResults(res: MailauthResult): InboundAuthResults {
526
+ const spf = (res.spf?.status?.result ?? "none").toLowerCase();
527
+ const dkim = (res.dkim?.results?.[0]?.status?.result ?? "none").toLowerCase();
528
+ const dmarc = (res.dmarc?.status?.result ?? "none").toLowerCase();
529
+ const spfVal: InboundAuthResults["spf"] =
530
+ spf === "pass" || spf === "fail" || spf === "softfail" || spf === "neutral"
531
+ ? (spf as InboundAuthResults["spf"])
532
+ : "none";
533
+ return {
534
+ spf: spfVal,
535
+ dkim: dkim === "pass" ? "pass" : dkim === "fail" ? "fail" : "none",
536
+ dmarc: dmarc === "pass" ? "pass" : dmarc === "fail" ? "fail" : "none",
537
+ };
538
+ }
539
+
540
+ // ---------------------------------------------------------------------------
541
+ // Optional dynamic imports
542
+ // ---------------------------------------------------------------------------
543
+
544
+ async function dynImport<T>(spec: string): Promise<T> {
545
+ // Variable specifier keeps TypeScript from resolving the optional peer dep at
546
+ // compile time, while Node/the bundler resolves it at runtime.
547
+ return (await import(/* @vite-ignore */ spec)) as T;
548
+ }
549
+
550
+ async function loadSmtpServer(): Promise<{ SMTPServer: new (opts: unknown) => unknown }> {
551
+ try {
552
+ return await dynImport("smtp-server");
553
+ } catch {
554
+ throw new Error(
555
+ 'SmtpTransport requires the optional "smtp-server" package. Install it to enable inbound mail.'
556
+ );
557
+ }
558
+ }
559
+
560
+ async function loadNodemailer(): Promise<NodemailerLike> {
561
+ try {
562
+ const mod = await dynImport<{ default?: NodemailerLike } & NodemailerLike>(
563
+ "nodemailer"
564
+ );
565
+ return mod.default ?? mod;
566
+ } catch {
567
+ throw new Error(
568
+ 'SmtpTransport requires the optional "nodemailer" package. Install it to enable outbound mail.'
569
+ );
570
+ }
571
+ }
572
+
573
+ async function loadMailparser(): Promise<{
574
+ simpleParser: (input: Buffer) => Promise<ParsedMail>;
575
+ }> {
576
+ try {
577
+ return await dynImport("mailparser");
578
+ } catch {
579
+ throw new Error(
580
+ 'SmtpTransport requires the optional "mailparser" package. Install it to parse inbound mail.'
581
+ );
582
+ }
583
+ }
584
+
585
+ async function loadMailauth(): Promise<{
586
+ authenticate: (raw: Buffer, opts: unknown) => Promise<MailauthResult>;
587
+ }> {
588
+ return await dynImport("mailauth");
589
+ }