@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,204 @@
1
+ import { RoverError } from "../RoverError.js";
2
+ import formData from "form-data";
3
+ // mailgun.js is UMD-bundled; the class lives on `.default` under NodeNext.
4
+ import MailgunModule from "mailgun.js";
5
+ import {
6
+ type MailMessage,
7
+ type MailSendOutcome,
8
+ type MailTransport,
9
+ registerTransport,
10
+ } from "../Mail.js";
11
+
12
+ const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
13
+ const normalizeConfig = (v: string): string => stripCrlf(v).trim();
14
+ const MAX_PROVIDER_MESSAGE = 16 * 1024;
15
+ const capMessage = (s: string): string =>
16
+ s.length <= MAX_PROVIDER_MESSAGE
17
+ ? s
18
+ : `${s.slice(0, MAX_PROVIDER_MESSAGE)}...[truncated]`;
19
+
20
+ const redactSecrets = (s: string): string =>
21
+ s
22
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
23
+ .replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
24
+
25
+ /** Minimal mailgun.js client surface — matches the subset we need. */
26
+ interface MailgunClientLike {
27
+ messages: {
28
+ create(
29
+ domain: string,
30
+ data: Record<string, unknown>,
31
+ ): Promise<{ id?: string; message?: string; status?: number }>;
32
+ };
33
+ }
34
+
35
+ export class MailgunTransport implements MailTransport {
36
+ #client: MailgunClientLike;
37
+ #domain: string;
38
+
39
+ constructor(config: Record<string, unknown>) {
40
+ const apiKey =
41
+ typeof config.apiKey === "string" ? normalizeConfig(config.apiKey) : "";
42
+ const domain =
43
+ typeof config.domain === "string" ? normalizeConfig(config.domain) : "";
44
+ if (!apiKey || !domain) {
45
+ throw new RoverError(
46
+ "MAIL_PROVIDER_CONFIG",
47
+ "Mailgun transport requires apiKey and domain",
48
+ { hint: "Set { apiKey, domain } in your mail config." },
49
+ );
50
+ }
51
+ this.#domain = domain;
52
+
53
+ // Region: reject non-string when *defined* (wrong type signals a config
54
+ // bug — defaulting to "us" under those conditions is a compliance risk).
55
+ if (config.region !== undefined && typeof config.region !== "string") {
56
+ throw new RoverError(
57
+ "MAIL_PROVIDER_CONFIG",
58
+ `Mailgun region must be a string ("us" or "eu"), got ${typeof config.region}`,
59
+ { hint: "Set config.region explicitly as 'us' or 'eu'." },
60
+ );
61
+ }
62
+ const rawRegion =
63
+ typeof config.region === "string"
64
+ ? normalizeConfig(config.region).toLowerCase()
65
+ : "us";
66
+ if (rawRegion !== "us" && rawRegion !== "eu") {
67
+ throw new RoverError(
68
+ "MAIL_PROVIDER_CONFIG",
69
+ `Mailgun region must be "us" or "eu", got "${rawRegion}"`,
70
+ {
71
+ hint: "Use { region: 'us' } or { region: 'eu' }. A typo silently routing EU traffic to US infrastructure is a compliance risk.",
72
+ },
73
+ );
74
+ }
75
+ const url =
76
+ rawRegion === "eu"
77
+ ? "https://api.eu.mailgun.net"
78
+ : "https://api.mailgun.net";
79
+
80
+ // Dependency injection for tests: `_client` wins over real SDK. Guarded
81
+ // against non-object and non-shape inputs so a typo'd config can't
82
+ // silently bypass the real client.
83
+ const injected = config._client;
84
+ if (
85
+ injected &&
86
+ typeof injected === "object" &&
87
+ "messages" in (injected as object) &&
88
+ typeof (injected as MailgunClientLike).messages?.create === "function"
89
+ ) {
90
+ this.#client = injected as MailgunClientLike;
91
+ } else {
92
+ // mailgun.js ships a UMD-style default export; the class constructor
93
+ // lives on `.default` in the typings (`static get default`).
94
+ const MailgunCtor = MailgunModule.default;
95
+ const mailgun = new MailgunCtor(formData);
96
+ this.#client = mailgun.client({ username: "api", key: apiKey, url });
97
+ }
98
+ }
99
+
100
+ async send(message: MailMessage): Promise<MailSendOutcome> {
101
+ if (
102
+ message.to.length === 0 &&
103
+ message.cc.length === 0 &&
104
+ message.bcc.length === 0
105
+ ) {
106
+ throw new RoverError(
107
+ "MAIL_PROVIDER_CONFIG",
108
+ "Mail message has no recipients",
109
+ { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
110
+ );
111
+ }
112
+
113
+ // CRLF stripping is applied at the wire boundary regardless of SDK
114
+ // promises — defence-in-depth. Anti-pattern from spec Dev Notes:
115
+ // "never skip CRLF sanitisation because the provider will handle it".
116
+ const data: Record<string, unknown> = {
117
+ from: stripCrlf(message.from),
118
+ to: message.to.map(stripCrlf),
119
+ subject: stripCrlf(message.subject),
120
+ };
121
+ if (message.cc.length) data.cc = message.cc.map(stripCrlf);
122
+ if (message.bcc.length) data.bcc = message.bcc.map(stripCrlf);
123
+ if (message.text) data.text = message.text;
124
+ if (message.html) data.html = message.html;
125
+ if (message.replyTo) data["h:Reply-To"] = stripCrlf(message.replyTo);
126
+ for (const [k, v] of Object.entries(message.headers)) {
127
+ data[`h:${stripCrlf(k)}`] = stripCrlf(v);
128
+ }
129
+ if (message.attachments.length > 0) {
130
+ data.attachment = message.attachments.map((att) => {
131
+ const entry: { filename: string; data: Buffer; contentType?: string } =
132
+ {
133
+ filename: stripCrlf(att.filename),
134
+ data: Buffer.from(att.content as Buffer | string),
135
+ };
136
+ if (att.contentType) {
137
+ entry.contentType = stripCrlf(att.contentType);
138
+ }
139
+ return entry;
140
+ });
141
+ }
142
+
143
+ try {
144
+ const res = await this.#client.messages.create(this.#domain, data);
145
+ if (res.id) return { providerId: res.id };
146
+ return undefined;
147
+ } catch (err) {
148
+ throw wrapMailgunError(err);
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * mailgun.js's own error shape is `{ status, details | message, ... }`.
155
+ * Map it to our uniform `MAIL_PROVIDER_ERROR` so retry + observability
156
+ * consumers don't need to branch on provider.
157
+ *
158
+ * Bare `Error` (no `status`) — typical for network/ECONNRESET failures from
159
+ * mailgun.js — surface the original errno (`code`) in context so the retry
160
+ * predicate can still classify it as transient.
161
+ */
162
+ function wrapMailgunError(err: unknown): RoverError {
163
+ if (err instanceof RoverError) return err;
164
+ const anyErr = err as {
165
+ status?: unknown;
166
+ details?: string;
167
+ message?: string;
168
+ code?: string;
169
+ headers?: Record<string, string | string[]>;
170
+ };
171
+ // Coerce string-typed status ("401") into number — mailgun.js is
172
+ // inconsistent across versions.
173
+ const statusNum = Number(anyErr.status);
174
+ const status = Number.isFinite(statusNum) ? statusNum : 0;
175
+ const providerMessage = redactSecrets(
176
+ capMessage(anyErr.details ?? anyErr.message ?? "unknown"),
177
+ );
178
+ const ctx: Record<string, string> = {
179
+ provider: "mailgun",
180
+ upstreamStatus: String(status),
181
+ providerMessage,
182
+ };
183
+ if (typeof anyErr.code === "string") {
184
+ ctx.networkCode = anyErr.code;
185
+ }
186
+ // Retry-After may arrive either as a header (`retry-after`) or as a JSON
187
+ // body field — pass both upstream so `computeBackoffMs` can honour it.
188
+ const retryAfterHeader = anyErr.headers?.["retry-after"];
189
+ if (retryAfterHeader) {
190
+ ctx.retryAfter = Array.isArray(retryAfterHeader)
191
+ ? (retryAfterHeader[0] ?? "")
192
+ : retryAfterHeader;
193
+ }
194
+ return new RoverError(
195
+ "MAIL_PROVIDER_ERROR",
196
+ `Mailgun returned ${status || "unknown"}`,
197
+ {
198
+ hint: "Inspect `context.upstreamStatus` (HTTP) or `context.networkCode` (ECONNRESET/etc.) to decide retry eligibility.",
199
+ context: ctx,
200
+ },
201
+ );
202
+ }
203
+
204
+ registerTransport("mailgun", (config) => new MailgunTransport(config));
@@ -0,0 +1,147 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { RoverError } from "../RoverError.js";
3
+ import {
4
+ type MailMessage,
5
+ type MailSendOutcome,
6
+ type MailTransport,
7
+ registerTransport,
8
+ } from "../Mail.js";
9
+ import { wrapFetchNetworkError } from "./fetchError.js";
10
+
11
+ const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
12
+ const normalizeConfig = (v: string): string => stripCrlf(v).trim();
13
+ const MAX_PROVIDER_MESSAGE = 16 * 1024;
14
+ const capMessage = (s: string): string =>
15
+ s.length <= MAX_PROVIDER_MESSAGE
16
+ ? s
17
+ : `${s.slice(0, MAX_PROVIDER_MESSAGE)}...[truncated]`;
18
+ /** Redact Basic/Bearer tokens if the upstream echoes our own request headers. */
19
+ const redactSecrets = (s: string): string =>
20
+ s
21
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
22
+ .replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
23
+
24
+ interface ResendBody {
25
+ from: string;
26
+ to: string[];
27
+ cc?: string[];
28
+ bcc?: string[];
29
+ reply_to?: string;
30
+ subject: string;
31
+ html?: string;
32
+ text?: string;
33
+ attachments?: Array<{
34
+ filename: string;
35
+ content: string;
36
+ content_type?: string;
37
+ }>;
38
+ headers?: Record<string, string>;
39
+ }
40
+
41
+ export class ResendTransport implements MailTransport {
42
+ #apiKey: string;
43
+
44
+ constructor(config: Record<string, unknown>) {
45
+ const apiKey =
46
+ typeof config.apiKey === "string" ? normalizeConfig(config.apiKey) : "";
47
+ if (!apiKey) {
48
+ throw new RoverError(
49
+ "MAIL_PROVIDER_CONFIG",
50
+ "Resend transport requires apiKey",
51
+ { hint: "Set { apiKey } in your mail config." },
52
+ );
53
+ }
54
+ this.#apiKey = apiKey;
55
+ }
56
+
57
+ async send(message: MailMessage): Promise<MailSendOutcome> {
58
+ if (
59
+ message.to.length === 0 &&
60
+ message.cc.length === 0 &&
61
+ message.bcc.length === 0
62
+ ) {
63
+ throw new RoverError(
64
+ "MAIL_PROVIDER_CONFIG",
65
+ "Mail message has no recipients",
66
+ { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
67
+ );
68
+ }
69
+ const body: ResendBody = {
70
+ from: stripCrlf(message.from),
71
+ to: message.to.map(stripCrlf),
72
+ subject: stripCrlf(message.subject),
73
+ };
74
+ if (message.cc.length) body.cc = message.cc.map(stripCrlf);
75
+ if (message.bcc.length) body.bcc = message.bcc.map(stripCrlf);
76
+ if (message.replyTo) body.reply_to = stripCrlf(message.replyTo);
77
+ if (message.html) body.html = message.html;
78
+ if (message.text) body.text = message.text;
79
+ if (message.attachments.length > 0) {
80
+ body.attachments = message.attachments.map((att) => {
81
+ const buf = Buffer.from(att.content as Buffer | string);
82
+ return {
83
+ filename: stripCrlf(att.filename),
84
+ content: buf.toString("base64"),
85
+ content_type: att.contentType
86
+ ? stripCrlf(att.contentType)
87
+ : undefined,
88
+ };
89
+ });
90
+ }
91
+ const customHeaders = Object.entries(message.headers);
92
+ if (customHeaders.length > 0) {
93
+ body.headers = {};
94
+ for (const [k, v] of customHeaders) {
95
+ body.headers[stripCrlf(k)] = stripCrlf(v);
96
+ }
97
+ }
98
+
99
+ // fetch() rejections (DNS / TCP / TLS / socket reset) bypass the
100
+ // !res.ok branch entirely. Wrap them so retry classification can
101
+ // pick up the underlying errno (top-level `.code` for legacy
102
+ // shims, `.cause.code` for Node's built-in undici).
103
+ let res: Response;
104
+ try {
105
+ res = await fetch("https://api.resend.com/emails", {
106
+ method: "POST",
107
+ headers: {
108
+ Authorization: `Bearer ${this.#apiKey}`,
109
+ "Content-Type": "application/json",
110
+ },
111
+ body: JSON.stringify(body),
112
+ });
113
+ } catch (err) {
114
+ throw wrapFetchNetworkError("resend", err);
115
+ }
116
+ if (!res.ok) {
117
+ const providerMessage = redactSecrets(capMessage(await res.text()));
118
+ const retryAfter = res.headers.get("retry-after") ?? undefined;
119
+ const ctx: Record<string, string> = {
120
+ provider: "resend",
121
+ upstreamStatus: String(res.status),
122
+ providerMessage,
123
+ };
124
+ if (retryAfter) ctx.retryAfter = retryAfter;
125
+ throw new RoverError(
126
+ "MAIL_PROVIDER_ERROR",
127
+ `Resend returned ${res.status}`,
128
+ {
129
+ hint: "Inspect `context.upstreamStatus` to decide retry eligibility. `context.retryAfter` (when set) carries the provider's backoff hint in seconds.",
130
+ context: ctx,
131
+ },
132
+ );
133
+ }
134
+ // Success: Resend returns `{ id: "<uuid>" }` per API docs.
135
+ try {
136
+ const body = (await res.json()) as { id?: string };
137
+ if (typeof body.id === "string" && body.id.length > 0) {
138
+ return { providerId: body.id };
139
+ }
140
+ } catch {
141
+ // Empty body or non-JSON — fall back to generated id.
142
+ }
143
+ return;
144
+ }
145
+ }
146
+
147
+ registerTransport("resend", (config) => new ResendTransport(config));
@@ -0,0 +1,246 @@
1
+ import { RoverError } from "../RoverError.js";
2
+ import sgMail, {
3
+ type MailDataRequired,
4
+ type MailService,
5
+ } from "@sendgrid/mail";
6
+ import {
7
+ type MailMessage,
8
+ type MailSendOutcome,
9
+ type MailTransport,
10
+ registerTransport,
11
+ } from "../Mail.js";
12
+
13
+ const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
14
+ const normalizeConfig = (v: string): string => stripCrlf(v).trim();
15
+ const MAX_PROVIDER_MESSAGE = 16 * 1024;
16
+ const capMessage = (s: string): string =>
17
+ s.length <= MAX_PROVIDER_MESSAGE
18
+ ? s
19
+ : `${s.slice(0, MAX_PROVIDER_MESSAGE)}...[truncated]`;
20
+
21
+ const redactSecrets = (s: string): string =>
22
+ s
23
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
24
+ .replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
25
+
26
+ /**
27
+ * Minimal slice of the SendGrid client we depend on. A per-transport client
28
+ * instance is created in the constructor so concurrent transports with
29
+ * different API keys cannot race each other — the original module-level
30
+ * `sgMail.setApiKey()` would have been a multi-tenant foot-gun.
31
+ */
32
+ interface SendGridClientLike {
33
+ setApiKey(apiKey: string): void;
34
+ send(
35
+ data: MailDataRequired,
36
+ ): Promise<
37
+ [
38
+ { statusCode: number; headers: Record<string, string | string[]> },
39
+ unknown,
40
+ ]
41
+ >;
42
+ }
43
+
44
+ export class SendGridTransport implements MailTransport {
45
+ #client: SendGridClientLike;
46
+
47
+ constructor(config: Record<string, unknown>) {
48
+ const apiKey =
49
+ typeof config.apiKey === "string" ? normalizeConfig(config.apiKey) : "";
50
+ if (!apiKey) {
51
+ throw new RoverError(
52
+ "MAIL_PROVIDER_CONFIG",
53
+ "SendGrid transport requires apiKey",
54
+ { hint: "Set { apiKey } in your mail config." },
55
+ );
56
+ }
57
+
58
+ // Dependency injection for tests — stronger guard than the old version
59
+ // (require both `send` AND `setApiKey` to pass through).
60
+ const injected = config._client;
61
+ if (
62
+ injected &&
63
+ typeof injected === "object" &&
64
+ typeof (injected as SendGridClientLike).send === "function" &&
65
+ typeof (injected as SendGridClientLike).setApiKey === "function"
66
+ ) {
67
+ this.#client = injected as SendGridClientLike;
68
+ } else {
69
+ // Per-instance MailService (not the shared `sgMail` singleton) so
70
+ // `setApiKey` can't race across multiple transports.
71
+ this.#client = new (resolveMailServiceCtor(sgMail))();
72
+ }
73
+ this.#client.setApiKey(apiKey);
74
+ }
75
+
76
+ async send(message: MailMessage): Promise<MailSendOutcome> {
77
+ assertHasRecipients(message);
78
+ const content = buildSendGridContent(message);
79
+
80
+ // CRLF stripping at the wire boundary (defence-in-depth, matches the
81
+ // Dev Notes anti-pattern: never trust the SDK to handle it).
82
+ const data: MailDataRequired = {
83
+ from: stripCrlf(message.from),
84
+ to: message.to.map(stripCrlf),
85
+ subject: stripCrlf(message.subject),
86
+ content,
87
+ ...(message.cc.length ? { cc: message.cc.map(stripCrlf) } : {}),
88
+ ...(message.bcc.length ? { bcc: message.bcc.map(stripCrlf) } : {}),
89
+ ...(message.replyTo ? { replyTo: stripCrlf(message.replyTo) } : {}),
90
+ ...(Object.keys(message.headers).length
91
+ ? {
92
+ headers: Object.fromEntries(
93
+ Object.entries(message.headers).map(([k, v]) => [
94
+ stripCrlf(k),
95
+ stripCrlf(v),
96
+ ]),
97
+ ),
98
+ }
99
+ : {}),
100
+ ...(message.attachments.length
101
+ ? {
102
+ attachments: message.attachments.map((att) => {
103
+ const entry: {
104
+ filename: string;
105
+ content: string;
106
+ type?: string;
107
+ disposition: "attachment";
108
+ } = {
109
+ filename: stripCrlf(att.filename),
110
+ content: Buffer.from(att.content as Buffer | string).toString(
111
+ "base64",
112
+ ),
113
+ disposition: "attachment" as const,
114
+ };
115
+ if (att.contentType) entry.type = stripCrlf(att.contentType);
116
+ return entry;
117
+ }),
118
+ }
119
+ : {}),
120
+ };
121
+
122
+ try {
123
+ const result = await this.#client.send(data);
124
+ // Guard against non-standard SDK responses: `[]`, `[undefined]`, etc.
125
+ const response = Array.isArray(result) ? result[0] : undefined;
126
+ const msgId = response?.headers?.["x-message-id"];
127
+ const idStr = Array.isArray(msgId) ? msgId[0] : msgId;
128
+ if (idStr && typeof idStr === "string" && idStr.length > 0) {
129
+ return { providerId: idStr };
130
+ }
131
+ return undefined;
132
+ } catch (err) {
133
+ throw wrapSendGridError(err);
134
+ }
135
+ }
136
+ }
137
+
138
+ type SendGridContent = { type: string; value: string };
139
+
140
+ function assertHasRecipients(message: MailMessage): void {
141
+ if (
142
+ message.to.length === 0 &&
143
+ message.cc.length === 0 &&
144
+ message.bcc.length === 0
145
+ ) {
146
+ throw new RoverError(
147
+ "MAIL_PROVIDER_CONFIG",
148
+ "Mail message has no recipients",
149
+ { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
150
+ );
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Build the SendGrid v3 `content[]`: text/plain when text is set, text/html when
156
+ * html is set, always at least one entry (SendGrid rejects empty content). The
157
+ * non-empty tuple type lets the SDK's MailDataRequired see `content[0]` exists.
158
+ */
159
+ function buildSendGridContent(
160
+ message: MailMessage,
161
+ ): [SendGridContent, ...SendGridContent[]] {
162
+ const first: SendGridContent = message.text
163
+ ? { type: "text/plain", value: message.text }
164
+ : message.html
165
+ ? { type: "text/html", value: message.html }
166
+ : { type: "text/plain", value: "" };
167
+ const rest: SendGridContent[] = [];
168
+ if (message.text && message.html) {
169
+ rest.push({ type: "text/html", value: message.html });
170
+ }
171
+ return [first, ...rest];
172
+ }
173
+
174
+ function wrapSendGridError(err: unknown): RoverError {
175
+ if (err instanceof RoverError) return err;
176
+ // @sendgrid/mail throws `{ code, message, response: { body, headers, statusCode } }`
177
+ const anyErr = err as {
178
+ code?: number | string;
179
+ message?: string;
180
+ response?: {
181
+ statusCode?: number;
182
+ body?: unknown;
183
+ headers?: Record<string, string>;
184
+ };
185
+ };
186
+ const statusFromResponse = anyErr.response?.statusCode;
187
+ const statusFromCode = Number(anyErr.code);
188
+ const status = Number.isFinite(statusFromResponse as number)
189
+ ? (statusFromResponse as number)
190
+ : Number.isFinite(statusFromCode)
191
+ ? statusFromCode
192
+ : 0;
193
+ const body = anyErr.response?.body;
194
+ const providerMessage = redactSecrets(
195
+ capMessage(
196
+ typeof body === "string"
197
+ ? body
198
+ : body !== undefined
199
+ ? JSON.stringify(body)
200
+ : (anyErr.message ?? "unknown"),
201
+ ),
202
+ );
203
+ const ctx: Record<string, string> = {
204
+ provider: "sendgrid",
205
+ upstreamStatus: String(status),
206
+ providerMessage,
207
+ };
208
+ if (typeof anyErr.code === "string") {
209
+ ctx.networkCode = anyErr.code;
210
+ }
211
+ const retryAfter = anyErr.response?.headers?.["retry-after"];
212
+ if (retryAfter) ctx.retryAfter = retryAfter;
213
+ return new RoverError(
214
+ "MAIL_PROVIDER_ERROR",
215
+ `SendGrid returned ${status || "unknown"}`,
216
+ {
217
+ hint: "Inspect `context.upstreamStatus` (HTTP) or `context.networkCode` (ECONNRESET/etc.) to decide retry eligibility. `context.retryAfter` (when set) carries the provider's backoff hint in seconds.",
218
+ context: ctx,
219
+ },
220
+ );
221
+ }
222
+
223
+ /**
224
+ * Type-guard resolver for the `MailService` constructor attached to the
225
+ * module's default export at runtime. The cerebrum forbids `as unknown as T`;
226
+ * here we receive `sgMail` through a parameter typed `unknown`, narrow with
227
+ * runtime `typeof` checks, and return a single cast to a precise callable
228
+ * type. No double-cast chain, no `as unknown` anchor.
229
+ */
230
+ function resolveMailServiceCtor(mod: unknown): new () => MailService {
231
+ if (mod && typeof mod === "object" && "MailService" in mod) {
232
+ const candidate = (mod as { MailService: unknown }).MailService;
233
+ if (typeof candidate === "function") {
234
+ return candidate as new () => MailService;
235
+ }
236
+ }
237
+ throw new RoverError(
238
+ "MAIL_PROVIDER_CONFIG",
239
+ "@sendgrid/mail runtime does not expose `.MailService` — upgrade to v8+",
240
+ {
241
+ hint: "Expected `module.exports.MailService` to be the MailService class (index.js attaches it).",
242
+ },
243
+ );
244
+ }
245
+
246
+ registerTransport("sendgrid", (config) => new SendGridTransport(config));