@c9up/rover 0.1.3

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 (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/dist/BaseMail.d.ts +28 -0
  4. package/dist/BaseMail.d.ts.map +1 -0
  5. package/dist/BaseMail.js +42 -0
  6. package/dist/BaseMail.js.map +1 -0
  7. package/dist/Mail.d.ts +166 -0
  8. package/dist/Mail.d.ts.map +1 -0
  9. package/dist/Mail.js +451 -0
  10. package/dist/Mail.js.map +1 -0
  11. package/dist/MessageBuilder.d.ts +38 -0
  12. package/dist/MessageBuilder.d.ts.map +1 -0
  13. package/dist/MessageBuilder.js +70 -0
  14. package/dist/MessageBuilder.js.map +1 -0
  15. package/dist/RoverError.d.ts +30 -0
  16. package/dist/RoverError.d.ts.map +1 -0
  17. package/dist/RoverError.js +33 -0
  18. package/dist/RoverError.js.map +1 -0
  19. package/dist/RoverProvider.d.ts +24 -0
  20. package/dist/RoverProvider.d.ts.map +1 -0
  21. package/dist/RoverProvider.js +51 -0
  22. package/dist/RoverProvider.js.map +1 -0
  23. package/dist/config.d.ts +4 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +4 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/index.d.ts +9 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +7 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/queue/MailJob.d.ts +41 -0
  32. package/dist/queue/MailJob.d.ts.map +1 -0
  33. package/dist/queue/MailJob.js +75 -0
  34. package/dist/queue/MailJob.js.map +1 -0
  35. package/dist/retry.d.ts +20 -0
  36. package/dist/retry.d.ts.map +1 -0
  37. package/dist/retry.js +101 -0
  38. package/dist/retry.js.map +1 -0
  39. package/dist/services/main.d.ts +20 -0
  40. package/dist/services/main.d.ts.map +1 -0
  41. package/dist/services/main.js +33 -0
  42. package/dist/services/main.js.map +1 -0
  43. package/dist/templating/SimpleTemplate.d.ts +7 -0
  44. package/dist/templating/SimpleTemplate.d.ts.map +1 -0
  45. package/dist/templating/SimpleTemplate.js +192 -0
  46. package/dist/templating/SimpleTemplate.js.map +1 -0
  47. package/dist/templating/loadNapi.d.ts +21 -0
  48. package/dist/templating/loadNapi.d.ts.map +1 -0
  49. package/dist/templating/loadNapi.js +115 -0
  50. package/dist/templating/loadNapi.js.map +1 -0
  51. package/dist/testing/FakeMail.d.ts +31 -0
  52. package/dist/testing/FakeMail.d.ts.map +1 -0
  53. package/dist/testing/FakeMail.js +90 -0
  54. package/dist/testing/FakeMail.js.map +1 -0
  55. package/dist/transports/MailgunTransport.d.ts +7 -0
  56. package/dist/transports/MailgunTransport.d.ts.map +1 -0
  57. package/dist/transports/MailgunTransport.js +149 -0
  58. package/dist/transports/MailgunTransport.js.map +1 -0
  59. package/dist/transports/ResendTransport.d.ts +7 -0
  60. package/dist/transports/ResendTransport.d.ts.map +1 -0
  61. package/dist/transports/ResendTransport.js +111 -0
  62. package/dist/transports/ResendTransport.js.map +1 -0
  63. package/dist/transports/SendGridTransport.d.ts +7 -0
  64. package/dist/transports/SendGridTransport.d.ts.map +1 -0
  65. package/dist/transports/SendGridTransport.js +165 -0
  66. package/dist/transports/SendGridTransport.js.map +1 -0
  67. package/dist/transports/SesTransport.d.ts +7 -0
  68. package/dist/transports/SesTransport.d.ts.map +1 -0
  69. package/dist/transports/SesTransport.js +334 -0
  70. package/dist/transports/SesTransport.js.map +1 -0
  71. package/dist/transports/fetchError.d.ts +13 -0
  72. package/dist/transports/fetchError.d.ts.map +1 -0
  73. package/dist/transports/fetchError.js +35 -0
  74. package/dist/transports/fetchError.js.map +1 -0
  75. package/dist/webhooks/context.d.ts +35 -0
  76. package/dist/webhooks/context.d.ts.map +1 -0
  77. package/dist/webhooks/context.js +7 -0
  78. package/dist/webhooks/context.js.map +1 -0
  79. package/dist/webhooks/mailgun.d.ts +13 -0
  80. package/dist/webhooks/mailgun.d.ts.map +1 -0
  81. package/dist/webhooks/mailgun.js +102 -0
  82. package/dist/webhooks/mailgun.js.map +1 -0
  83. package/dist/webhooks/resend.d.ts +16 -0
  84. package/dist/webhooks/resend.d.ts.map +1 -0
  85. package/dist/webhooks/resend.js +121 -0
  86. package/dist/webhooks/resend.js.map +1 -0
  87. package/dist/webhooks/sendgrid.d.ts +13 -0
  88. package/dist/webhooks/sendgrid.d.ts.map +1 -0
  89. package/dist/webhooks/sendgrid.js +104 -0
  90. package/dist/webhooks/sendgrid.js.map +1 -0
  91. package/index.darwin-arm64.node +0 -0
  92. package/index.darwin-x64.node +0 -0
  93. package/index.linux-arm64-gnu.node +0 -0
  94. package/index.linux-x64-gnu.node +0 -0
  95. package/index.win32-x64-msvc.node +0 -0
  96. package/package.json +97 -0
  97. package/scripts/copy-napi.mjs +62 -0
  98. package/src/BaseMail.ts +52 -0
  99. package/src/Mail.ts +663 -0
  100. package/src/MessageBuilder.ts +101 -0
  101. package/src/RoverError.ts +44 -0
  102. package/src/RoverProvider.ts +72 -0
  103. package/src/config.ts +7 -0
  104. package/src/index.ts +30 -0
  105. package/src/queue/MailJob.ts +131 -0
  106. package/src/retry.ts +135 -0
  107. package/src/services/main.ts +41 -0
  108. package/src/templating/SimpleTemplate.ts +240 -0
  109. package/src/templating/loadNapi.ts +169 -0
  110. package/src/testing/FakeMail.ts +112 -0
  111. package/src/transports/MailgunTransport.ts +204 -0
  112. package/src/transports/ResendTransport.ts +147 -0
  113. package/src/transports/SendGridTransport.ts +246 -0
  114. package/src/transports/SesTransport.ts +394 -0
  115. package/src/transports/fetchError.ts +42 -0
  116. package/src/webhooks/context.ts +41 -0
  117. package/src/webhooks/mailgun.ts +134 -0
  118. package/src/webhooks/resend.ts +164 -0
  119. package/src/webhooks/sendgrid.ts +133 -0
package/src/Mail.ts ADDED
@@ -0,0 +1,663 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { RoverError } from "./RoverError.js";
3
+ import nodemailer, { type Transporter } from "nodemailer";
4
+ import { BaseMail } from "./BaseMail.js";
5
+ import {
6
+ type MailAttachment,
7
+ type MailMessage,
8
+ MessageBuilder,
9
+ } from "./MessageBuilder.js";
10
+ import {
11
+ type BayQueueLike,
12
+ MAIL_JOB_NAME,
13
+ MailJobHandler,
14
+ } from "./queue/MailJob.js";
15
+ import {
16
+ computeBackoffMs,
17
+ isRetryableError,
18
+ type RetryConfig,
19
+ resolveRetryConfig,
20
+ } from "./retry.js";
21
+ import { setViewsRoot } from "./templating/SimpleTemplate.js";
22
+ import { FakeMail } from "./testing/FakeMail.js";
23
+
24
+ // `MailMessage` / `MailAttachment` / `MessageBuilder` live in MessageBuilder.ts
25
+ // so `BaseMail` can import the builder without importing this module — breaking
26
+ // the BaseMail ↔ Mail value cycle. Re-exported here to keep the public surface
27
+ // (`@c9up/rover` → these symbols come from `./Mail.js`) unchanged.
28
+ export type { MailAttachment, MailMessage };
29
+ export { MessageBuilder };
30
+
31
+ /**
32
+ * Mail — send emails via pluggable transports.
33
+ *
34
+ * Like AdonisJS Mail:
35
+ * await mail.send((message) => {
36
+ * message.to('user@example.com')
37
+ * message.subject('Welcome')
38
+ * message.html('<h1>Hello</h1>')
39
+ * })
40
+ *
41
+ * Transports: SMTP, log (dev), custom.
42
+ * Configured via config/mail.ts.
43
+ */
44
+
45
+ export interface MailSendResult {
46
+ /** Provider-returned message id (Mailgun `mg-abc`, SendGrid `X-Message-Id`, etc.). */
47
+ providerId?: string;
48
+ }
49
+
50
+ /**
51
+ * Return shape of `MailTransport.send`. Transports that don't surface a
52
+ * provider id may `return` nothing (implicit `undefined`); transports that
53
+ * do return `{ providerId }`. `undefined` rather than `void` in the union
54
+ * keeps the linter happy (`noConfusingVoidType`) while preserving the
55
+ * "no result" semantics.
56
+ */
57
+ export type MailSendOutcome = MailSendResult | undefined;
58
+
59
+ export interface MailTransport {
60
+ send(message: MailMessage): Promise<MailSendOutcome>;
61
+ }
62
+
63
+ /**
64
+ * Structural interface for the event bus `Emitter` — peer-dep friendly.
65
+ * Rover never hard-imports the event bus.
66
+ */
67
+ export interface EmitterLike {
68
+ emit(event: string, data: unknown): void;
69
+ }
70
+
71
+ export interface MailSentEvent {
72
+ messageId: string;
73
+ to: string[];
74
+ cc: string[];
75
+ bcc: string[];
76
+ transportName: string;
77
+ timestamp: number;
78
+ }
79
+
80
+ export interface MailFailedEvent {
81
+ messageId: string;
82
+ to: string[];
83
+ cc: string[];
84
+ bcc: string[];
85
+ transportName: string;
86
+ error: {
87
+ code: string;
88
+ message: string;
89
+ upstreamStatus?: number;
90
+ upstreamStatusRaw?: string;
91
+ attempts: number;
92
+ };
93
+ timestamp: number;
94
+ }
95
+
96
+ export interface MailConfig {
97
+ default: string;
98
+ from: string;
99
+ transports: Record<
100
+ string,
101
+ { transport: string; retry?: RetryConfig; [key: string]: unknown }
102
+ >;
103
+ /** Root directory for `htmlView(path, data)` template lookups. Default: `"resources/views/emails"`. */
104
+ viewsRoot?: string;
105
+ /** Optional Bay queue tuning for `sendLater()`. */
106
+ queue?: { name?: string; maxAttempts?: number };
107
+ /** Process-wide retry defaults. Overridden per-transport via `transports[name].retry`. */
108
+ retry?: RetryConfig;
109
+ }
110
+
111
+ /**
112
+ * Event hooks invoked by the internal dispatch loop. Default implementations
113
+ * are no-ops; when an event-bus `EmitterLike` is wired, the hooks emit
114
+ * `mail.sent` / `mail.failed`. Tests inject spies.
115
+ */
116
+ export interface MailHooks {
117
+ onSent?(event: MailSentEvent): void;
118
+ onFailed?(event: MailFailedEvent): void;
119
+ }
120
+
121
+ /**
122
+ * Message builder — fluent API for composing an email.
123
+ */
124
+ /**
125
+ * SMTP transport — thin wrapper around `nodemailer`. Nodemailer handles
126
+ * the SMTP state machine, TLS / STARTTLS negotiation, AUTH LOGIN /
127
+ * PLAIN / XOAUTH2, dot-stuffing, MIME assembly, attachments, and address
128
+ * encoding for 10+ years. We forward the `MailMessage` shape and surface
129
+ * the `messageId` returned by the server.
130
+ */
131
+ export class SmtpTransport implements MailTransport {
132
+ #transporter: Transporter;
133
+
134
+ constructor(config: Record<string, unknown>) {
135
+ // Host is required — defaulting to "localhost" silently hid env-var
136
+ // misconfigurations (SMTP_HOST unset) until the SMTP connection
137
+ // timed out against a nonexistent local server. Fail at
138
+ // construction with an actionable message instead.
139
+ if (config.host !== undefined && typeof config.host !== "string") {
140
+ throw new RoverError(
141
+ "MAIL_PROVIDER_CONFIG",
142
+ `SMTP host must be a string, got ${typeof config.host}`,
143
+ { hint: "Set config.host to your SMTP server hostname." },
144
+ );
145
+ }
146
+ if (typeof config.host !== "string" || config.host.length === 0) {
147
+ throw new RoverError("MAIL_PROVIDER_CONFIG", "SMTP host is required", {
148
+ hint: "Set config.host (e.g. process.env.SMTP_HOST). Use the fake / log transports for local development.",
149
+ });
150
+ }
151
+ const host = config.host;
152
+ const port = typeof config.port === "number" ? config.port : 587;
153
+ const secure = typeof config.secure === "boolean" ? config.secure : false;
154
+ const user = typeof config.user === "string" ? config.user : undefined;
155
+ const pass = typeof config.pass === "string" ? config.pass : undefined;
156
+ const requireTLS =
157
+ typeof config.requireTLS === "boolean" ? config.requireTLS : undefined;
158
+ // Partial auth config (only user OR only pass) is almost always a typo —
159
+ // fail fast rather than connect anonymously and let the server reject.
160
+ if ((user && !pass) || (!user && pass)) {
161
+ throw new RoverError(
162
+ "MAIL_PROVIDER_CONFIG",
163
+ "SMTP auth requires both `user` and `pass` or neither",
164
+ {
165
+ hint: "Check your env vars — one half of the credential pair is missing.",
166
+ },
167
+ );
168
+ }
169
+ // Allow DI for tests: if the config carries a pre-built transporter,
170
+ // use it instead of nodemailer.createTransport.
171
+ const injected = config._transporter;
172
+ if (injected && typeof injected === "object" && "sendMail" in injected) {
173
+ this.#transporter = injected as Transporter;
174
+ } else {
175
+ this.#transporter = nodemailer.createTransport({
176
+ host,
177
+ port,
178
+ secure,
179
+ requireTLS,
180
+ auth: user && pass ? { user, pass } : undefined,
181
+ });
182
+ }
183
+ }
184
+
185
+ async send(message: MailMessage): Promise<MailSendOutcome> {
186
+ if (
187
+ message.to.length === 0 &&
188
+ message.cc.length === 0 &&
189
+ message.bcc.length === 0
190
+ ) {
191
+ throw new RoverError(
192
+ "MAIL_PROVIDER_CONFIG",
193
+ "Mail message has no recipients",
194
+ { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
195
+ );
196
+ }
197
+ try {
198
+ const info = await this.#transporter.sendMail({
199
+ from: message.from,
200
+ to: message.to,
201
+ cc: message.cc.length ? message.cc : undefined,
202
+ bcc: message.bcc.length ? message.bcc : undefined,
203
+ replyTo: message.replyTo,
204
+ subject: message.subject,
205
+ html: message.html,
206
+ text: message.text,
207
+ headers: Object.keys(message.headers).length
208
+ ? message.headers
209
+ : undefined,
210
+ attachments: message.attachments.length
211
+ ? message.attachments.map((att) => ({
212
+ filename: att.filename,
213
+ content: att.content,
214
+ contentType: att.contentType,
215
+ }))
216
+ : undefined,
217
+ });
218
+ if (info.messageId) return { providerId: info.messageId };
219
+ return undefined;
220
+ } catch (err) {
221
+ throw wrapSmtpError(err);
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Normalise nodemailer / socket errors into the uniform `MAIL_PROVIDER_ERROR`
228
+ * shape used by the rest of the library. Preserves the `code` field (errno)
229
+ * in `context.networkCode` so `isRetryableError` can classify transient
230
+ * network failures without losing the root cause.
231
+ */
232
+ function wrapSmtpError(err: unknown): RoverError {
233
+ if (err instanceof RoverError) return err;
234
+ const anyErr = err as {
235
+ code?: string | number;
236
+ responseCode?: number;
237
+ message?: string;
238
+ };
239
+ const status =
240
+ typeof anyErr.responseCode === "number" ? anyErr.responseCode : 0;
241
+ const ctx: Record<string, string> = {
242
+ provider: "smtp",
243
+ upstreamStatus: String(status),
244
+ providerMessage: anyErr.message ?? "unknown",
245
+ };
246
+ if (typeof anyErr.code === "string") {
247
+ ctx.networkCode = anyErr.code;
248
+ }
249
+ return new RoverError(
250
+ "MAIL_PROVIDER_ERROR",
251
+ `SMTP failed: ${anyErr.message ?? "unknown"}`,
252
+ {
253
+ hint: "Inspect `context.networkCode` (ECONNRESET/ETIMEDOUT/...) or `context.upstreamStatus` (SMTP response code) to decide retry eligibility.",
254
+ context: ctx,
255
+ },
256
+ );
257
+ }
258
+
259
+ /**
260
+ * Log transport — logs emails to console (development).
261
+ */
262
+ export class LogTransport implements MailTransport {
263
+ async send(message: MailMessage): Promise<MailSendOutcome> {
264
+ console.log(
265
+ `[MAIL] To: ${message.to.join(", ")} | Subject: ${message.subject}`,
266
+ );
267
+ if (message.text) console.log(` Body: ${message.text.slice(0, 200)}`);
268
+ return undefined;
269
+ }
270
+ }
271
+
272
+ export type MailTransportFactory = (
273
+ config: Record<string, unknown>,
274
+ ) => MailTransport;
275
+
276
+ const transportFactories: Record<string, MailTransportFactory> = {
277
+ smtp: (config) => new SmtpTransport(config),
278
+ log: () => new LogTransport(),
279
+ };
280
+
281
+ /**
282
+ * Register a custom transport factory globally.
283
+ *
284
+ * @example
285
+ * registerTransport('mailgun', (config) => new MailgunTransport(config))
286
+ * // Now config/mail.ts can reference `transport: 'mailgun'`
287
+ */
288
+ export function registerTransport(
289
+ name: string,
290
+ factory: MailTransportFactory,
291
+ ): void {
292
+ transportFactories[name] = factory;
293
+ }
294
+
295
+ /**
296
+ * Mail manager — send emails via configured transport.
297
+ */
298
+ export class Mail {
299
+ #transports: Map<string, MailTransport> = new Map();
300
+ #defaultTransport: string;
301
+ #defaultFrom: string;
302
+ #fakeSnapshot: { transportName: string; original: MailTransport } | null =
303
+ null;
304
+ #queue: BayQueueLike | null = null;
305
+ #queueName: string;
306
+ #queueMaxAttempts: number;
307
+ #globalRetry: RetryConfig | undefined;
308
+ #transportRetry: Map<string, RetryConfig> = new Map();
309
+ #hooks: MailHooks;
310
+ #emitter: EmitterLike | null;
311
+
312
+ constructor(
313
+ config: MailConfig,
314
+ options?: {
315
+ queue?: BayQueueLike;
316
+ hooks?: MailHooks;
317
+ emitter?: EmitterLike;
318
+ },
319
+ ) {
320
+ this.#defaultTransport = config.default;
321
+ this.#defaultFrom = config.from;
322
+ // Bay uses a single `name` for both `register(name, handler)` and
323
+ // `dispatch(name, payload)`. Default to the canonical mail job name.
324
+ this.#queueName = config.queue?.name ?? MAIL_JOB_NAME;
325
+ this.#queueMaxAttempts = config.queue?.maxAttempts ?? 3;
326
+ this.#globalRetry = config.retry;
327
+ this.#hooks = options?.hooks ?? {};
328
+ this.#emitter = options?.emitter ?? null;
329
+ if (config.viewsRoot !== undefined) {
330
+ setViewsRoot(config.viewsRoot);
331
+ }
332
+
333
+ for (const [name, transportConfig] of Object.entries(config.transports)) {
334
+ const factory = transportFactories[transportConfig.transport];
335
+ if (!factory) {
336
+ throw new RoverError(
337
+ "MAIL_UNKNOWN_TRANSPORT",
338
+ `Unknown mail transport type '${transportConfig.transport}' (configured under name '${name}')`,
339
+ {
340
+ hint: "Register the transport with registerTransport() before constructing Mail, or fix the typo in config.mail.transports[*].transport.",
341
+ },
342
+ );
343
+ }
344
+ this.#transports.set(name, factory(transportConfig));
345
+ if (transportConfig.retry) {
346
+ this.#transportRetry.set(name, transportConfig.retry);
347
+ }
348
+ }
349
+
350
+ if (options?.queue) {
351
+ this.#queue = options.queue;
352
+ this.#queue.register(this.#queueName, new MailJobHandler(this));
353
+ }
354
+ }
355
+
356
+ /** Send an email using the fluent message builder. */
357
+ async send(
358
+ callback: (message: MessageBuilder) => void,
359
+ transport?: string,
360
+ ): Promise<void>;
361
+ /** Send an email using a class-based `BaseMail` instance. */
362
+ async send(instance: BaseMail, transport?: string): Promise<void>;
363
+ async send(
364
+ arg: ((message: MessageBuilder) => void) | BaseMail,
365
+ transport?: string,
366
+ ): Promise<void> {
367
+ const transportName = transport ?? this.#defaultTransport;
368
+ // Validate transport up-front before running any callback / prepare() side effects.
369
+ if (!this.#transports.has(transportName)) {
370
+ throw new Error(`Mail transport '${transportName}' not configured`);
371
+ }
372
+
373
+ const message = await this.#buildMessage(arg);
374
+ await this.dispatchMessage(message, transportName);
375
+ }
376
+
377
+ /**
378
+ * Enqueue a send onto the Bay queue. Returns the job id. Throws
379
+ * `MAIL_QUEUE_REQUIRED` if no `QueueManager` was wired through the
380
+ * constructor options.
381
+ */
382
+ async sendLater(
383
+ arg: ((message: MessageBuilder) => void) | BaseMail,
384
+ options?: { transport?: string; queue?: string },
385
+ ): Promise<string> {
386
+ if (this.#queue === null) {
387
+ throw new RoverError(
388
+ "MAIL_QUEUE_REQUIRED",
389
+ "mail.sendLater() requires @c9up/bay QueueManager",
390
+ {
391
+ hint: "Register @c9up/bay and pass the QueueManager to Mail via RoverProvider, or use mail.send() for synchronous delivery.",
392
+ },
393
+ );
394
+ }
395
+ const message = await this.#buildMessage(arg);
396
+ const queueName = options?.queue ?? this.#queueName;
397
+ return this.#queue.dispatch(
398
+ queueName,
399
+ { message, transport: options?.transport },
400
+ { maxAttempts: this.#queueMaxAttempts },
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Shared transport-resolve-then-send helper used by `send()` and `MailJobHandler`.
406
+ * Applies in-process retry with exponential backoff per `RetryConfig` resolution
407
+ * (per-transport > global > library default). Fires `onSent` / `onFailed` hooks.
408
+ *
409
+ * `overrideRetry` (optional) wins over config-level retry; `MailJobHandler` passes
410
+ * `{ maxAttempts: 1 }` so queue-level and sync-level retries don't compound.
411
+ */
412
+ async dispatchMessage(
413
+ message: MailMessage,
414
+ transportName?: string,
415
+ overrideRetry?: RetryConfig,
416
+ ): Promise<void> {
417
+ // Defense-in-depth: queue payloads bypass `#buildMessage`, so a
418
+ // malformed message deserialised from storage would otherwise reach
419
+ // the transport with the cryptic provider error A2 was meant to
420
+ // eliminate. Cheap re-validation on every dispatch keeps the
421
+ // failure mode uniform across send() and queue dequeue.
422
+ validateMailMessage(message);
423
+ const name = transportName ?? this.#defaultTransport;
424
+ const t = this.#transports.get(name);
425
+ if (!t) throw new Error(`Mail transport '${name}' not configured`);
426
+
427
+ const retry = overrideRetry
428
+ ? resolveRetryConfig(undefined, overrideRetry)
429
+ : resolveRetryConfig(this.#globalRetry, this.#transportRetry.get(name));
430
+
431
+ const generatedId = randomBytes(16).toString("hex");
432
+ let lastError: unknown;
433
+
434
+ for (let attempt = 1; attempt <= retry.maxAttempts; attempt += 1) {
435
+ let sendResult: MailSendOutcome;
436
+ try {
437
+ sendResult = await t.send(message);
438
+ } catch (err) {
439
+ lastError = err;
440
+ const retryable = isRetryableError(err);
441
+ if (!retryable || attempt === retry.maxAttempts) {
442
+ const annotated = this.#withAttempts(err, attempt);
443
+ this.#fireFailed({
444
+ messageId: generatedId,
445
+ to: message.to.slice(),
446
+ cc: message.cc.slice(),
447
+ bcc: message.bcc.slice(),
448
+ transportName: name,
449
+ error: errorDescriptor(annotated, attempt),
450
+ timestamp: Date.now(),
451
+ });
452
+ throw annotated;
453
+ }
454
+ const delay = computeBackoffMs(attempt, retry, err);
455
+ await new Promise((resolve) => setTimeout(resolve, delay));
456
+ continue;
457
+ }
458
+ // Success path lives outside the try/catch so a throw inside the
459
+ // success hook cannot be mistaken for a transport failure.
460
+ const providerIdRaw =
461
+ sendResult &&
462
+ typeof sendResult === "object" &&
463
+ "providerId" in sendResult
464
+ ? (sendResult.providerId as string | undefined)
465
+ : undefined;
466
+ // `??` would accept empty-string providerId; explicit truthy check
467
+ // so a provider returning `{ providerId: "" }` falls back to the
468
+ // internally generated correlation id.
469
+ const providerId =
470
+ providerIdRaw && providerIdRaw.length > 0 ? providerIdRaw : undefined;
471
+ this.#fireSent({
472
+ messageId: providerId ?? generatedId,
473
+ to: message.to.slice(),
474
+ cc: message.cc.slice(),
475
+ bcc: message.bcc.slice(),
476
+ transportName: name,
477
+ timestamp: Date.now(),
478
+ });
479
+ return;
480
+ }
481
+ // Unreachable — the loop always returns or throws — but TS wants a throw.
482
+ throw lastError;
483
+ }
484
+
485
+ /**
486
+ * Return a shallow clone of `err` with `context.attempts` annotated. We
487
+ * avoid mutating the original error so outer retry layers / shared error
488
+ * references don't see their attempt counter overwritten.
489
+ */
490
+ #withAttempts(err: unknown, attempts: number): unknown {
491
+ if (!(err instanceof RoverError)) return err;
492
+ const clone = new RoverError(err.code, err.message, {
493
+ hint: err.hint,
494
+ sourceFile: err.sourceFile,
495
+ sourceLine: err.sourceLine,
496
+ docsUrl: err.docsUrl,
497
+ pipelineStage: err.pipelineStage,
498
+ context: { ...err.context, attempts: String(attempts) },
499
+ });
500
+ if (err.stack) clone.stack = err.stack;
501
+ return clone;
502
+ }
503
+
504
+ #fireSent(event: MailSentEvent): void {
505
+ // Hooks are in user-land and may throw; their failure must not poison
506
+ // delivery outcome. Emitter errors are already defensively swallowed.
507
+ try {
508
+ this.#hooks.onSent?.(event);
509
+ } catch (err) {
510
+ process.stderr.write(
511
+ `[rover] onSent hook threw: ${err instanceof Error ? err.message : String(err)}\n`,
512
+ );
513
+ }
514
+ if (this.#emitter) {
515
+ try {
516
+ this.#emitter.emit("mail.sent", event);
517
+ } catch {
518
+ // Event bus failure ≠ mail delivery failure — swallow.
519
+ }
520
+ }
521
+ }
522
+
523
+ #fireFailed(event: MailFailedEvent): void {
524
+ try {
525
+ this.#hooks.onFailed?.(event);
526
+ } catch (err) {
527
+ process.stderr.write(
528
+ `[rover] onFailed hook threw: ${err instanceof Error ? err.message : String(err)}\n`,
529
+ );
530
+ }
531
+ if (this.#emitter) {
532
+ try {
533
+ this.#emitter.emit("mail.failed", event);
534
+ } catch {
535
+ // Event bus failure ≠ mail delivery failure — swallow.
536
+ }
537
+ }
538
+ }
539
+
540
+ async #buildMessage(
541
+ arg: ((message: MessageBuilder) => void) | BaseMail,
542
+ ): Promise<MailMessage> {
543
+ let result: MailMessage;
544
+ if (arg instanceof BaseMail) {
545
+ const built = await arg.build();
546
+ result = built.from ? built : { ...built, from: this.#defaultFrom };
547
+ } else {
548
+ const builder = new MessageBuilder();
549
+ builder.from(this.#defaultFrom);
550
+ arg(builder);
551
+ result = await builder.build();
552
+ }
553
+ validateMailMessage(result);
554
+ return result;
555
+ }
556
+
557
+ /** Get a specific transport. */
558
+ use(name: string): MailTransport {
559
+ const t = this.#transports.get(name);
560
+ if (!t) throw new Error(`Mail transport '${name}' not configured`);
561
+ return t;
562
+ }
563
+
564
+ /**
565
+ * Swap the default transport with a `FakeMail` that captures every send.
566
+ * Call `restore()` to re-install the original. Throws if a fake is already
567
+ * active — nested fakes always indicate a forgotten `restore()`.
568
+ */
569
+ fake(): FakeMail {
570
+ if (this.#fakeSnapshot !== null) {
571
+ throw new Error("Mail.fake() already active — call restore() first");
572
+ }
573
+ const transportName = this.#defaultTransport;
574
+ const original = this.#transports.get(transportName);
575
+ if (!original) {
576
+ throw new Error(
577
+ `Cannot fake default transport '${transportName}' — not configured`,
578
+ );
579
+ }
580
+ const fake = new FakeMail();
581
+ this.#fakeSnapshot = { transportName, original };
582
+ this.#transports.set(transportName, fake);
583
+ return fake;
584
+ }
585
+
586
+ /** Undo the swap installed by `fake()`. No-op if no fake is active. */
587
+ restore(): void {
588
+ if (this.#fakeSnapshot === null) return;
589
+ const { transportName, original } = this.#fakeSnapshot;
590
+ this.#transports.set(transportName, original);
591
+ this.#fakeSnapshot = null;
592
+ }
593
+ }
594
+
595
+ /**
596
+ * Reject empty `from` and empty recipients before any transport is reached —
597
+ * keeps the failure mode uniform across SMTP / Mailgun / SendGrid / SES /
598
+ * Resend / log instead of relying on each transport to surface a (possibly
599
+ * cryptic) "no recipients" provider error.
600
+ */
601
+ function validateMailMessage(message: MailMessage): void {
602
+ // `MailMessage.from` is typed `string` but config-loader paths
603
+ // (`app.config.get<MailConfig>(...)`) cast at runtime — a missing key
604
+ // can yield `undefined` despite the type. Whitespace-only is also
605
+ // invalid per RFC 5321 reverse-path semantics.
606
+ if (typeof message.from !== "string" || message.from.trim() === "") {
607
+ throw new RoverError(
608
+ "MAIL_INVALID_MESSAGE",
609
+ "Mail message has no `from` address",
610
+ {
611
+ hint: "Set `config.from`, an instance `from`, or call `message.from(...)` in the builder.",
612
+ },
613
+ );
614
+ }
615
+ // Defense-in-depth on each array: `MailJob.validatePayload` only requires
616
+ // `to` to be an array (cc/bcc are optional at the queue boundary), so a
617
+ // deserialised job payload may reach here with `cc`/`bcc` as `undefined`.
618
+ // `.some(...)` on `undefined` throws `TypeError` instead of the
619
+ // structured `MAIL_INVALID_MESSAGE` this validator is supposed to surface.
620
+ const hasRecipient =
621
+ (Array.isArray(message.to) && message.to.some(isNonEmptyAddress)) ||
622
+ (Array.isArray(message.cc) && message.cc.some(isNonEmptyAddress)) ||
623
+ (Array.isArray(message.bcc) && message.bcc.some(isNonEmptyAddress));
624
+ if (!hasRecipient) {
625
+ throw new RoverError(
626
+ "MAIL_INVALID_MESSAGE",
627
+ "Mail message has no recipients",
628
+ {
629
+ hint: "Call `message.to(...)`, `cc(...)`, or `bcc(...)` with a non-empty address before sending.",
630
+ },
631
+ );
632
+ }
633
+ }
634
+
635
+ function isNonEmptyAddress(addr: unknown): boolean {
636
+ return typeof addr === "string" && addr.trim() !== "";
637
+ }
638
+
639
+ function errorDescriptor(
640
+ err: unknown,
641
+ attempts: number,
642
+ ): MailFailedEvent["error"] {
643
+ if (err instanceof RoverError) {
644
+ const statusStr = err.context.upstreamStatus;
645
+ const upstream = Number(statusStr);
646
+ return {
647
+ code: err.code,
648
+ message: err.message,
649
+ upstreamStatus: Number.isFinite(upstream) ? upstream : undefined,
650
+ upstreamStatusRaw: statusStr,
651
+ attempts,
652
+ };
653
+ }
654
+ if (err instanceof Error) {
655
+ const errnoCode = (err as { code?: unknown }).code;
656
+ return {
657
+ code: typeof errnoCode === "string" ? errnoCode : "UNKNOWN",
658
+ message: err.message,
659
+ attempts,
660
+ };
661
+ }
662
+ return { code: "UNKNOWN", message: String(err), attempts };
663
+ }