@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
@@ -0,0 +1,101 @@
1
+ import { renderFile as renderTemplateFile } from "./templating/SimpleTemplate.js";
2
+
3
+ export interface MailMessage {
4
+ from: string;
5
+ to: string[];
6
+ cc: string[];
7
+ bcc: string[];
8
+ replyTo?: string;
9
+ subject: string;
10
+ html?: string;
11
+ text?: string;
12
+ attachments: MailAttachment[];
13
+ headers: Record<string, string>;
14
+ }
15
+
16
+ export interface MailAttachment {
17
+ filename: string;
18
+ content: Buffer | string;
19
+ contentType?: string;
20
+ }
21
+
22
+ export class MessageBuilder {
23
+ #msg: MailMessage = {
24
+ from: "",
25
+ to: [],
26
+ cc: [],
27
+ bcc: [],
28
+ subject: "",
29
+ attachments: [],
30
+ headers: {},
31
+ };
32
+ #pendingView: { path: string; data: Record<string, unknown> } | null = null;
33
+
34
+ from(address: string): this {
35
+ this.#msg.from = address;
36
+ return this;
37
+ }
38
+ to(address: string): this {
39
+ this.#msg.to.push(address);
40
+ return this;
41
+ }
42
+ cc(address: string): this {
43
+ this.#msg.cc.push(address);
44
+ return this;
45
+ }
46
+ bcc(address: string): this {
47
+ this.#msg.bcc.push(address);
48
+ return this;
49
+ }
50
+ replyTo(address: string): this {
51
+ this.#msg.replyTo = address;
52
+ return this;
53
+ }
54
+ subject(text: string): this {
55
+ this.#msg.subject = text;
56
+ return this;
57
+ }
58
+ html(content: string): this {
59
+ this.#msg.html = content;
60
+ return this;
61
+ }
62
+ text(content: string): this {
63
+ this.#msg.text = content;
64
+ return this;
65
+ }
66
+
67
+ attach(
68
+ filename: string,
69
+ content: Buffer | string,
70
+ contentType?: string,
71
+ ): this {
72
+ this.#msg.attachments.push({ filename, content, contentType });
73
+ return this;
74
+ }
75
+
76
+ header(key: string, value: string): this {
77
+ this.#msg.headers[key] = value;
78
+ return this;
79
+ }
80
+
81
+ /**
82
+ * Queue an HTML template render. The render happens lazily at `build()` time
83
+ * so the fluent chain stays synchronous; `build()` is async and awaits the
84
+ * render before returning the finalised `MailMessage`.
85
+ */
86
+ htmlView(viewPath: string, data?: Record<string, unknown>): this {
87
+ this.#pendingView = { path: viewPath, data: data ?? {} };
88
+ return this;
89
+ }
90
+
91
+ async build(): Promise<MailMessage> {
92
+ if (this.#pendingView !== null) {
93
+ this.#msg.html = await renderTemplateFile(
94
+ this.#pendingView.path,
95
+ this.#pendingView.data,
96
+ );
97
+ this.#pendingView = null;
98
+ }
99
+ return this.#msg;
100
+ }
101
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Rover's own structured error — keeps the package framework-agnostic (no
3
+ * dependency on `@c9up/ream`'s `ReamError`). Mirrors the error shape Rover
4
+ * carries through its transports and retry logic.
5
+ */
6
+ export class RoverError extends Error {
7
+ /** Error code (e.g. "ROVER_SMTP_ERROR"). */
8
+ readonly code: string;
9
+ /** Additional context key-value pairs. */
10
+ readonly context: Record<string, string>;
11
+ /** Actionable hint for the developer. */
12
+ readonly hint?: string;
13
+ /** Source file where the error originated. */
14
+ readonly sourceFile?: string;
15
+ /** Line number in the source file. */
16
+ readonly sourceLine?: number;
17
+ /** URL to the error documentation page. */
18
+ readonly docsUrl?: string;
19
+ /** Pipeline stage where the error occurred (if applicable). */
20
+ readonly pipelineStage?: string;
21
+
22
+ constructor(
23
+ code: string,
24
+ message: string,
25
+ options?: {
26
+ context?: Record<string, string>;
27
+ hint?: string;
28
+ sourceFile?: string;
29
+ sourceLine?: number;
30
+ docsUrl?: string;
31
+ pipelineStage?: string;
32
+ },
33
+ ) {
34
+ super(message);
35
+ this.name = "RoverError";
36
+ this.code = code;
37
+ this.context = options?.context ?? {};
38
+ this.hint = options?.hint;
39
+ this.sourceFile = options?.sourceFile;
40
+ this.sourceLine = options?.sourceLine;
41
+ this.docsUrl = options?.docsUrl;
42
+ this.pipelineStage = options?.pipelineStage;
43
+ }
44
+ }
@@ -0,0 +1,72 @@
1
+ import type { EmitterLike, MailConfig } from "./Mail.js";
2
+ import { Mail } from "./Mail.js";
3
+ import type { BayQueueLike } from "./queue/MailJob.js";
4
+ import { setMail } from "./services/main.js";
5
+
6
+ /**
7
+ * Duck-typed slice of the host's IoC container — rover does NOT import
8
+ * `@c9up/ream` so it stays publishable as a standalone package.
9
+ */
10
+ interface RoverContainer {
11
+ singleton(token: unknown, factory: () => unknown): void;
12
+ resolve<T = unknown>(token: unknown): T;
13
+ }
14
+ interface RoverConfigStore {
15
+ get<T = unknown>(key: string): T | undefined;
16
+ }
17
+ export interface RoverAppContext {
18
+ container: RoverContainer;
19
+ config: RoverConfigStore;
20
+ }
21
+
22
+ export default class RoverProvider {
23
+ constructor(protected app: RoverAppContext) {}
24
+
25
+ register() {
26
+ this.app.container.singleton(Mail, () => {
27
+ const config = this.app.config.get<MailConfig>("mail");
28
+ return new Mail(
29
+ config ?? {
30
+ default: "log",
31
+ from: "noreply@localhost",
32
+ transports: { log: { transport: "log" } },
33
+ },
34
+ {
35
+ // Optional-peer wiring: if `@c9up/bay` is installed and its
36
+ // `QueueManager` is registered in the container, Mail gets
37
+ // queue support for `sendLater()`. If not, `sendLater()`
38
+ // throws `MAIL_QUEUE_REQUIRED` at call time (by design).
39
+ queue: tryResolve<BayQueueLike>(this.app, "QueueManager"),
40
+ // Same pattern for the event bus `Emitter` — enables `mail.sent`
41
+ // / `mail.failed` emission when available.
42
+ emitter: tryResolve<EmitterLike>(this.app, "Emitter"),
43
+ },
44
+ );
45
+ });
46
+ this.app.container.singleton("mail", () => {
47
+ return this.app.container.resolve<Mail>(Mail);
48
+ });
49
+ }
50
+
51
+ async boot() {
52
+ // Populate the `@c9up/rover/services/main` singleton with the
53
+ // container-resolved Mail instance so apps can
54
+ // `import mail from '@c9up/rover/services/main'` from anywhere.
55
+ setMail(this.app.container.resolve<Mail>(Mail));
56
+ }
57
+
58
+ async shutdown() {}
59
+ }
60
+
61
+ /**
62
+ * Resolve a token from the container without throwing when it's not
63
+ * registered. Rover never hard-depends on Bay or the event bus — both wire-points
64
+ * are purely opt-in.
65
+ */
66
+ function tryResolve<T>(app: RoverAppContext, token: string): T | undefined {
67
+ try {
68
+ return app.container.resolve<T>(token);
69
+ } catch {
70
+ return undefined;
71
+ }
72
+ }
package/src/config.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { MailConfig } from "./Mail.js";
2
+
3
+ export function defineConfig(config: MailConfig): MailConfig {
4
+ return config;
5
+ }
6
+
7
+ export type { MailConfig };
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ export type { MailAddress } from "./BaseMail.js";
2
+ export { BaseMail } from "./BaseMail.js";
3
+ export { defineConfig } from "./config.js";
4
+ export { RoverError } from "./RoverError.js";
5
+ export type {
6
+ EmitterLike,
7
+ MailAttachment,
8
+ MailConfig,
9
+ MailFailedEvent,
10
+ MailHooks,
11
+ MailMessage,
12
+ MailSendOutcome,
13
+ MailSendResult,
14
+ MailSentEvent,
15
+ MailTransport,
16
+ MailTransportFactory,
17
+ } from "./Mail.js";
18
+ export {
19
+ LogTransport,
20
+ Mail,
21
+ MessageBuilder,
22
+ registerTransport,
23
+ SmtpTransport,
24
+ } from "./Mail.js";
25
+ export { default as RoverProvider } from "./RoverProvider.js";
26
+ export {
27
+ DEFAULT_RETRY_CONFIG,
28
+ isRetryableError,
29
+ type RetryConfig,
30
+ } from "./retry.js";
@@ -0,0 +1,131 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { RoverError } from "../RoverError.js";
3
+ import type { MailAttachment, MailMessage } from "../Mail.js";
4
+
5
+ export const MAIL_JOB_NAME = "mail.send";
6
+
7
+ export interface MailJobPayload {
8
+ message: MailMessage;
9
+ transport?: string;
10
+ }
11
+
12
+ /**
13
+ * Minimal structural interface for Bay's `QueueManager` — peer-dep friendly,
14
+ * matches `@c9up/bay`'s public API so `new QueueManager(driver)` is assignable.
15
+ */
16
+ export interface BayQueueLike {
17
+ register(name: string, handler: BayJobHandlerLike): void;
18
+ dispatch(
19
+ name: string,
20
+ payload: unknown,
21
+ options?: { maxAttempts?: number },
22
+ ): Promise<string>;
23
+ }
24
+
25
+ export interface BayJobHandlerLike {
26
+ handle(payload: unknown): Promise<void>;
27
+ }
28
+
29
+ /** Pared-down RetryConfig shape — avoids importing from `../retry.js` into queue/. */
30
+ interface RetryOverride {
31
+ maxAttempts: number;
32
+ baseDelayMs: number;
33
+ factor: number;
34
+ maxDelayMs?: number;
35
+ }
36
+
37
+ export interface MailDispatcher {
38
+ dispatchMessage(
39
+ message: MailMessage,
40
+ transport?: string,
41
+ overrideRetry?: RetryOverride,
42
+ ): Promise<void>;
43
+ }
44
+
45
+ /**
46
+ * Bay job handler for queued mail. Sets `maxAttempts: 1` so the in-process
47
+ * retry loop runs exactly once per job execution — Bay's own `maxAttempts`
48
+ * handles re-dispatch on throw. Prevents sync × queue retry compounding.
49
+ */
50
+ export class MailJobHandler implements BayJobHandlerLike {
51
+ #mail: MailDispatcher;
52
+
53
+ constructor(mail: MailDispatcher) {
54
+ this.#mail = mail;
55
+ }
56
+
57
+ async handle(payload: unknown): Promise<void> {
58
+ const parsed = validatePayload(payload);
59
+ // `{ maxAttempts: 1 }` alone is sufficient — the loop runs exactly once
60
+ // and `computeBackoffMs` is never reached; the other RetryConfig fields
61
+ // resolve to defaults via `resolveRetryConfig` but don't affect control
62
+ // flow. Matches AC 8 wording exactly.
63
+ await this.#mail.dispatchMessage(revive(parsed.message), parsed.transport, {
64
+ maxAttempts: 1,
65
+ baseDelayMs: 0,
66
+ factor: 1,
67
+ });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Narrow an `unknown` payload from the queue driver into a `MailJobPayload`.
73
+ * Throws `MAIL_JOB_MALFORMED` when the shape is unrecognisable so Bay records
74
+ * a clean failure rather than crashing inside `dispatchMessage` with a raw
75
+ * `TypeError`.
76
+ */
77
+ function validatePayload(payload: unknown): MailJobPayload {
78
+ if (!payload || typeof payload !== "object") {
79
+ throw new RoverError(
80
+ "MAIL_JOB_MALFORMED",
81
+ "Mail job payload is missing or not an object",
82
+ { hint: "Queue driver returned a non-object — check serialisation." },
83
+ );
84
+ }
85
+ const asObj = payload as Record<string, unknown>;
86
+ const message = asObj.message;
87
+ if (!message || typeof message !== "object") {
88
+ throw new RoverError(
89
+ "MAIL_JOB_MALFORMED",
90
+ "Mail job payload.message is missing or not an object",
91
+ {
92
+ hint: "Payload shape must be { message: MailMessage, transport?: string }.",
93
+ },
94
+ );
95
+ }
96
+ const m = message as Record<string, unknown>;
97
+ if (
98
+ typeof m.from !== "string" ||
99
+ !Array.isArray(m.to) ||
100
+ !Array.isArray(m.attachments)
101
+ ) {
102
+ throw new RoverError(
103
+ "MAIL_JOB_MALFORMED",
104
+ "Mail job payload.message does not match MailMessage shape",
105
+ {
106
+ hint: "Expected `{ from: string, to: string[], ..., attachments: [] }`.",
107
+ },
108
+ );
109
+ }
110
+ const transport =
111
+ typeof asObj.transport === "string" ? asObj.transport : undefined;
112
+ return { message: message as MailMessage, transport };
113
+ }
114
+
115
+ /**
116
+ * Revive Buffer attachments after JSON round-trip. Node serialises `Buffer` as
117
+ * `{ type: "Buffer", data: [...bytes] }`; we reconstruct to real `Buffer`.
118
+ */
119
+ function revive(message: MailMessage): MailMessage {
120
+ if (!message.attachments || message.attachments.length === 0) return message;
121
+ const attachments: MailAttachment[] = message.attachments.map((att) => {
122
+ if (typeof att.content === "string") return att;
123
+ if (Buffer.isBuffer(att.content)) return att;
124
+ const serialised = att.content as { type?: string; data?: number[] };
125
+ if (serialised?.type === "Buffer" && Array.isArray(serialised.data)) {
126
+ return { ...att, content: Buffer.from(serialised.data) };
127
+ }
128
+ return att;
129
+ });
130
+ return { ...message, attachments };
131
+ }
package/src/retry.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { RoverError } from "./RoverError.js";
2
+
3
+ export interface RetryConfig {
4
+ maxAttempts: number;
5
+ baseDelayMs: number;
6
+ factor: number;
7
+ maxDelayMs?: number;
8
+ }
9
+
10
+ export const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {
11
+ maxAttempts: 3,
12
+ baseDelayMs: 500,
13
+ factor: 2,
14
+ maxDelayMs: 10_000,
15
+ };
16
+
17
+ const RETRYABLE_ERRNO = new Set([
18
+ "ETIMEDOUT",
19
+ "ECONNRESET",
20
+ "ECONNREFUSED",
21
+ "ENOTFOUND",
22
+ "EAI_AGAIN",
23
+ ]);
24
+
25
+ /**
26
+ * A failure is retryable when:
27
+ * - it's a `MAIL_PROVIDER_ERROR` with `upstreamStatus` 429 or ≥ 500
28
+ * - or it's a `MAIL_PROVIDER_ERROR` whose `context.networkCode` is a known
29
+ * retryable Node errno (SDK wrappers surface the original errno this way
30
+ * when the HTTP layer never produced a status)
31
+ * - or it's a raw Error with a matching Node networking errno on `.code`
32
+ * 4xx (non-429) is treated as permanent: retrying won't help.
33
+ */
34
+ export function isRetryableError(err: unknown): boolean {
35
+ if (err instanceof RoverError && err.code === "MAIL_PROVIDER_ERROR") {
36
+ const statusStr = err.context.upstreamStatus;
37
+ const status = Number(statusStr);
38
+ if (Number.isFinite(status)) {
39
+ if (status === 429) return true;
40
+ if (status >= 500 && status < 600) return true;
41
+ }
42
+ const networkCode = err.context.networkCode;
43
+ if (networkCode && RETRYABLE_ERRNO.has(networkCode)) return true;
44
+ return false;
45
+ }
46
+ if (err instanceof Error) {
47
+ const code = (err as { code?: unknown }).code;
48
+ if (typeof code === "string" && RETRYABLE_ERRNO.has(code)) return true;
49
+ // Node 18+ `fetch` (undici) wraps the underlying socket error in
50
+ // `cause` and surfaces `TypeError: fetch failed` at the top level —
51
+ // the original errno is on `err.cause.code`, not `err.code`. Without
52
+ // this branch transient ECONNRESET/ENOTFOUND/etc. from raw fetch
53
+ // look indistinguishable from a permanent failure and never retry.
54
+ const cause = (err as { cause?: unknown }).cause;
55
+ if (cause && typeof cause === "object") {
56
+ const causeCode = (cause as { code?: unknown }).code;
57
+ if (typeof causeCode === "string" && RETRYABLE_ERRNO.has(causeCode))
58
+ return true;
59
+ }
60
+ }
61
+ return false;
62
+ }
63
+
64
+ export function computeBackoffMs(
65
+ attempt: number,
66
+ config: Required<RetryConfig>,
67
+ err?: unknown,
68
+ ): number {
69
+ // Honour the provider's Retry-After hint when present (Mailgun/SendGrid/
70
+ // SES/Resend transports capture it into `context.retryAfter`). Value is
71
+ // seconds per HTTP RFC 7231 §7.1.3; we cap at `maxDelayMs` so a malicious
72
+ // or mistaken provider can't force a very-long wait.
73
+ if (err instanceof RoverError && err.code === "MAIL_PROVIDER_ERROR") {
74
+ const hint = err.context.retryAfter;
75
+ if (hint) {
76
+ const seconds = Number(hint);
77
+ if (Number.isFinite(seconds) && seconds >= 0) {
78
+ return Math.min(config.maxDelayMs, Math.max(50, seconds * 1000));
79
+ }
80
+ }
81
+ }
82
+ const raw = config.baseDelayMs * config.factor ** (attempt - 1);
83
+ return Math.min(config.maxDelayMs, Math.max(50, raw));
84
+ }
85
+
86
+ export function resolveRetryConfig(
87
+ globalConfig: RetryConfig | undefined,
88
+ transportConfig: RetryConfig | undefined,
89
+ ): Required<RetryConfig> {
90
+ // Skip entries whose value is `undefined` so a partial override doesn't
91
+ // clobber a default or global value. Pre-spread filter is required because
92
+ // `{ ...obj }` propagates explicit-undefined keys.
93
+ const strip = <T extends object>(x: T | undefined): Partial<T> =>
94
+ x
95
+ ? (Object.fromEntries(
96
+ Object.entries(x).filter(([, v]) => v !== undefined),
97
+ ) as Partial<T>)
98
+ : {};
99
+
100
+ const merged: Required<RetryConfig> = {
101
+ ...DEFAULT_RETRY_CONFIG,
102
+ ...strip(globalConfig),
103
+ ...strip(transportConfig),
104
+ };
105
+ if (!Number.isInteger(merged.maxAttempts) || merged.maxAttempts < 1) {
106
+ throw new RoverError(
107
+ "MAIL_RETRY_CONFIG",
108
+ `RetryConfig.maxAttempts must be an integer >= 1 (got ${merged.maxAttempts})`,
109
+ { hint: "Set retry.maxAttempts to a positive integer." },
110
+ );
111
+ }
112
+ if (
113
+ typeof merged.baseDelayMs !== "number" ||
114
+ !Number.isFinite(merged.baseDelayMs) ||
115
+ merged.baseDelayMs < 0
116
+ ) {
117
+ throw new RoverError(
118
+ "MAIL_RETRY_CONFIG",
119
+ `RetryConfig.baseDelayMs must be a non-negative finite number (got ${merged.baseDelayMs})`,
120
+ { hint: "Use 0 or a positive number; the 50 ms floor kicks in anyway." },
121
+ );
122
+ }
123
+ if (
124
+ typeof merged.factor !== "number" ||
125
+ !Number.isFinite(merged.factor) ||
126
+ merged.factor <= 0
127
+ ) {
128
+ throw new RoverError(
129
+ "MAIL_RETRY_CONFIG",
130
+ `RetryConfig.factor must be a positive finite number (got ${merged.factor})`,
131
+ { hint: "Use >= 1 for exponential growth; 1 means constant delay." },
132
+ );
133
+ }
134
+ return merged;
135
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Default `Mail` singleton — mirror of Adonis's
3
+ * `import mail from '@adonisjs/mail/services/main'` shape.
4
+ *
5
+ * The instance is populated by `RoverProvider.boot()` once the
6
+ * container has resolved the `Mail` binding. Callers can `import mail`
7
+ * before boot — the proxy delays method access until `setMail` runs.
8
+ *
9
+ * import mail from '@c9up/rover/services/main'
10
+ *
11
+ * await mail.send((m) => m.to(user.email).subject('Hi').text('Hello'))
12
+ */
13
+
14
+ import type { Mail } from "../Mail.js";
15
+
16
+ let instance: Mail | undefined;
17
+
18
+ /** @internal Set the resolved singleton (called by RoverProvider.boot). */
19
+ export function setMail(mail: Mail): void {
20
+ instance = mail;
21
+ }
22
+
23
+ /** @internal Get the resolved singleton (or `undefined` pre-boot). */
24
+ export function getMail(): Mail | undefined {
25
+ return instance;
26
+ }
27
+
28
+ const mail: Mail = new Proxy({} as Mail, {
29
+ get(_target, prop) {
30
+ if (!instance) {
31
+ throw new Error(
32
+ "[rover] Mail singleton accessed before RoverProvider.boot() ran. " +
33
+ "Check that `@c9up/rover/provider` is listed in your reamrc.ts providers.",
34
+ );
35
+ }
36
+ const value = Reflect.get(instance, prop, instance);
37
+ return typeof value === "function" ? value.bind(instance) : value;
38
+ },
39
+ });
40
+
41
+ export default mail;