@c9up/rover 0.1.13 → 0.1.15

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 (53) hide show
  1. package/dist/Mail.d.ts.map +1 -1
  2. package/dist/Mail.js +21 -20
  3. package/dist/Mail.js.map +1 -1
  4. package/dist/MessageBuilder.js +4 -4
  5. package/dist/MessageBuilder.js.map +1 -1
  6. package/dist/format.js +1 -1
  7. package/dist/format.js.map +1 -1
  8. package/dist/queue/MailJob.js +4 -4
  9. package/dist/queue/MailJob.js.map +1 -1
  10. package/dist/retry.d.ts +2 -2
  11. package/dist/retry.js +7 -7
  12. package/dist/retry.js.map +1 -1
  13. package/dist/templating/SimpleTemplate.js +8 -8
  14. package/dist/templating/SimpleTemplate.js.map +1 -1
  15. package/dist/templating/loadNapi.js +8 -8
  16. package/dist/templating/loadNapi.js.map +1 -1
  17. package/dist/transports/BrevoTransport.js +3 -3
  18. package/dist/transports/BrevoTransport.js.map +1 -1
  19. package/dist/transports/MailgunTransport.d.ts.map +1 -1
  20. package/dist/transports/MailgunTransport.js +64 -50
  21. package/dist/transports/MailgunTransport.js.map +1 -1
  22. package/dist/transports/ResendTransport.js +3 -3
  23. package/dist/transports/ResendTransport.js.map +1 -1
  24. package/dist/transports/SendGridTransport.d.ts.map +1 -1
  25. package/dist/transports/SendGridTransport.js +83 -85
  26. package/dist/transports/SendGridTransport.js.map +1 -1
  27. package/dist/transports/SesTransport.js +4 -4
  28. package/dist/transports/SesTransport.js.map +1 -1
  29. package/dist/transports/SparkPostTransport.js +3 -3
  30. package/dist/transports/SparkPostTransport.js.map +1 -1
  31. package/dist/transports/fetchError.d.ts +2 -2
  32. package/dist/transports/fetchError.js +4 -4
  33. package/dist/transports/fetchError.js.map +1 -1
  34. package/index.darwin-arm64.node +0 -0
  35. package/index.darwin-x64.node +0 -0
  36. package/index.linux-arm64-gnu.node +0 -0
  37. package/index.linux-x64-gnu.node +0 -0
  38. package/index.win32-x64-msvc.node +0 -0
  39. package/package.json +2 -5
  40. package/src/Mail.ts +25 -19
  41. package/src/MessageBuilder.ts +4 -4
  42. package/src/format.ts +1 -1
  43. package/src/queue/MailJob.ts +4 -4
  44. package/src/retry.ts +7 -7
  45. package/src/templating/SimpleTemplate.ts +8 -8
  46. package/src/templating/loadNapi.ts +8 -8
  47. package/src/transports/BrevoTransport.ts +3 -3
  48. package/src/transports/MailgunTransport.ts +69 -61
  49. package/src/transports/ResendTransport.ts +3 -3
  50. package/src/transports/SendGridTransport.ts +89 -120
  51. package/src/transports/SesTransport.ts +4 -4
  52. package/src/transports/SparkPostTransport.ts +3 -3
  53. package/src/transports/fetchError.ts +4 -4
@@ -47,7 +47,7 @@ export class ResendTransport implements MailTransport {
47
47
  typeof config.apiKey === "string" ? normalizeConfig(config.apiKey) : "";
48
48
  if (!apiKey) {
49
49
  throw new RoverError(
50
- "MAIL_PROVIDER_CONFIG",
50
+ "E_MAIL_PROVIDER_CONFIG",
51
51
  "Resend transport requires apiKey",
52
52
  { hint: "Set { apiKey } in your mail config." },
53
53
  );
@@ -62,7 +62,7 @@ export class ResendTransport implements MailTransport {
62
62
  message.bcc.length === 0
63
63
  ) {
64
64
  throw new RoverError(
65
- "MAIL_PROVIDER_CONFIG",
65
+ "E_MAIL_PROVIDER_CONFIG",
66
66
  "Mail message has no recipients",
67
67
  { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
68
68
  );
@@ -127,7 +127,7 @@ export class ResendTransport implements MailTransport {
127
127
  };
128
128
  if (retryAfter) ctx.retryAfter = retryAfter;
129
129
  throw new RoverError(
130
- "MAIL_PROVIDER_ERROR",
130
+ "E_MAIL_PROVIDER_ERROR",
131
131
  `Resend returned ${res.status}`,
132
132
  {
133
133
  hint: "Inspect `context.upstreamStatus` to decide retry eligibility. `context.retryAfter` (when set) carries the provider's backoff hint in seconds.",
@@ -1,7 +1,3 @@
1
- import sgMail, {
2
- type MailDataRequired,
3
- type MailService,
4
- } from "@sendgrid/mail";
5
1
  import {
6
2
  type MailMessage,
7
3
  type MailSendOutcome,
@@ -10,6 +6,7 @@ import {
10
6
  } from "../Mail.js";
11
7
  import { attachmentsFor, headerValue } from "../MessageBuilder.js";
12
8
  import { RoverError } from "../RoverError.js";
9
+ import { fetchWithTimeout, wrapFetchNetworkError } from "./fetchError.js";
13
10
 
14
11
  const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
15
12
  const normalizeConfig = (v: string): string => stripCrlf(v).trim();
@@ -24,118 +21,111 @@ const redactSecrets = (s: string): string =>
24
21
  .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
25
22
  .replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
26
23
 
27
- /**
28
- * Minimal slice of the SendGrid client we depend on. A per-transport client
29
- * instance is created in the constructor so concurrent transports with
30
- * different API keys cannot race each other — the original module-level
31
- * `sgMail.setApiKey()` would have been a multi-tenant foot-gun.
32
- */
33
- interface SendGridClientLike {
34
- setApiKey(apiKey: string): void;
35
- send(
36
- data: MailDataRequired,
37
- ): Promise<
38
- [
39
- { statusCode: number; headers: Record<string, string | string[]> },
40
- unknown,
41
- ]
42
- >;
43
- }
44
-
45
24
  export class SendGridTransport implements MailTransport {
46
- #client: SendGridClientLike;
25
+ #apiKey: string;
26
+ #baseUrl: string;
47
27
 
48
28
  constructor(config: Record<string, unknown>) {
49
29
  const apiKey =
50
30
  typeof config.apiKey === "string" ? normalizeConfig(config.apiKey) : "";
51
31
  if (!apiKey) {
52
32
  throw new RoverError(
53
- "MAIL_PROVIDER_CONFIG",
33
+ "E_MAIL_PROVIDER_CONFIG",
54
34
  "SendGrid transport requires apiKey",
55
35
  { hint: "Set { apiKey } in your mail config." },
56
36
  );
57
37
  }
58
38
 
59
- // Dependency injection for tests — stronger guard than the old version
60
- // (require both `send` AND `setApiKey` to pass through).
61
- const injected = config._client;
62
- if (
63
- injected &&
64
- typeof injected === "object" &&
65
- typeof (injected as SendGridClientLike).send === "function" &&
66
- typeof (injected as SendGridClientLike).setApiKey === "function"
67
- ) {
68
- this.#client = injected as SendGridClientLike;
69
- } else {
70
- // Per-instance MailService (not the shared `sgMail` singleton) so
71
- // `setApiKey` can't race across multiple transports.
72
- this.#client = new (resolveMailServiceCtor(sgMail))();
73
- }
74
- this.#client.setApiKey(apiKey);
39
+ this.#apiKey = apiKey;
40
+ this.#baseUrl =
41
+ typeof config.baseUrl === "string" && config.baseUrl.length > 0
42
+ ? normalizeConfig(config.baseUrl).replace(/\/+$/, "")
43
+ : "https://api.sendgrid.com";
75
44
  }
76
45
 
77
46
  async send(message: MailMessage): Promise<MailSendOutcome> {
78
47
  assertHasRecipients(message);
79
- const content = buildSendGridContent(message);
80
48
 
81
- // CRLF stripping at the wire boundary (defence-in-depth, matches the
82
- // Dev Notes anti-pattern: never trust the SDK to handle it).
83
- const data: MailDataRequired = {
84
- from: stripCrlf(message.from),
85
- to: message.to.map(stripCrlf),
49
+ // CRLF is stripped at the wire boundary regardless of what the provider
50
+ // promises a header injected through a recipient or a subject is the
51
+ // one thing a transport must never pass on.
52
+ const address = (value: string): { email: string } => ({
53
+ email: stripCrlf(value),
54
+ });
55
+ const personalization: Record<string, unknown> = {
56
+ // `to` may be empty when a message is bcc-only, which SendGrid allows.
57
+ to: message.to.map(address),
58
+ };
59
+ if (message.cc.length) personalization.cc = message.cc.map(address);
60
+ if (message.bcc.length) personalization.bcc = message.bcc.map(address);
61
+
62
+ const body: Record<string, unknown> = {
63
+ // The v3 API groups recipients under `personalizations`; the SDK's
64
+ // flat shape was its own, and this is what actually goes on the wire.
65
+ personalizations: [personalization],
66
+ from: address(message.from),
86
67
  subject: stripCrlf(message.subject),
87
- content,
88
- ...(message.cc.length ? { cc: message.cc.map(stripCrlf) } : {}),
89
- ...(message.bcc.length ? { bcc: message.bcc.map(stripCrlf) } : {}),
90
- ...(message.replyTo ? { replyTo: stripCrlf(message.replyTo) } : {}),
91
- ...(Object.keys(message.headers).length
92
- ? {
93
- headers: Object.fromEntries(
94
- Object.entries(message.headers).map(([k, raw]) => {
95
- const v = headerValue(raw);
96
- return [
97
- stripCrlf(k),
98
- Array.isArray(v) ? v.map(stripCrlf).join(", ") : stripCrlf(v),
99
- ];
100
- }),
101
- ),
102
- }
103
- : {}),
104
- ...(attachmentsFor(message).length
105
- ? {
106
- attachments: attachmentsFor(message).map((att) => {
107
- const entry: {
108
- filename: string;
109
- content: string;
110
- type?: string;
111
- disposition: "attachment";
112
- } = {
113
- filename: stripCrlf(att.filename),
114
- content: Buffer.from(att.content as Buffer | string).toString(
115
- "base64",
116
- ),
117
- disposition: "attachment" as const,
118
- };
119
- if (att.contentType) entry.type = stripCrlf(att.contentType);
120
- return entry;
121
- }),
122
- }
123
- : {}),
68
+ content: buildSendGridContent(message),
124
69
  };
70
+ if (message.replyTo) body.reply_to = address(message.replyTo);
71
+ if (Object.keys(message.headers).length) {
72
+ body.headers = Object.fromEntries(
73
+ Object.entries(message.headers).map(([k, raw]) => {
74
+ const v = headerValue(raw);
75
+ return [
76
+ stripCrlf(k),
77
+ Array.isArray(v) ? v.map(stripCrlf).join(", ") : stripCrlf(v),
78
+ ];
79
+ }),
80
+ );
81
+ }
82
+ const attachments = attachmentsFor(message);
83
+ if (attachments.length > 0) {
84
+ body.attachments = attachments.map((att) => {
85
+ const entry: Record<string, string> = {
86
+ filename: stripCrlf(att.filename),
87
+ content: Buffer.from(att.content as Buffer | string).toString(
88
+ "base64",
89
+ ),
90
+ disposition: "attachment",
91
+ };
92
+ if (att.contentType) entry.type = stripCrlf(att.contentType);
93
+ return entry;
94
+ });
95
+ }
125
96
 
97
+ let res: Response;
126
98
  try {
127
- const result = await this.#client.send(data);
128
- // Guard against non-standard SDK responses: `[]`, `[undefined]`, etc.
129
- const response = Array.isArray(result) ? result[0] : undefined;
130
- const msgId = response?.headers?.["x-message-id"];
131
- const idStr = Array.isArray(msgId) ? msgId[0] : msgId;
132
- if (idStr && typeof idStr === "string" && idStr.length > 0) {
133
- return { providerId: idStr };
134
- }
135
- return undefined;
99
+ res = await fetchWithTimeout(
100
+ "SendGrid",
101
+ `${this.#baseUrl}/v3/mail/send`,
102
+ {
103
+ method: "POST",
104
+ headers: {
105
+ Authorization: `Bearer ${this.#apiKey}`,
106
+ "Content-Type": "application/json",
107
+ },
108
+ body: JSON.stringify(body),
109
+ },
110
+ );
136
111
  } catch (err) {
137
- throw wrapSendGridError(err);
112
+ throw wrapFetchNetworkError("sendgrid", err);
138
113
  }
114
+
115
+ if (!res.ok) {
116
+ // Shaped as the mapper already reads it, so the mapping stays one
117
+ // piece of logic rather than two that can drift.
118
+ throw wrapSendGridError({
119
+ response: {
120
+ statusCode: res.status,
121
+ body: await res.text(),
122
+ headers: Object.fromEntries(res.headers.entries()),
123
+ },
124
+ });
125
+ }
126
+ // A 202 carries no body; the id is in the header.
127
+ const id = res.headers.get("x-message-id");
128
+ return id !== null && id.length > 0 ? { providerId: id } : undefined;
139
129
  }
140
130
  }
141
131
 
@@ -148,7 +138,7 @@ function assertHasRecipients(message: MailMessage): void {
148
138
  message.bcc.length === 0
149
139
  ) {
150
140
  throw new RoverError(
151
- "MAIL_PROVIDER_CONFIG",
141
+ "E_MAIL_PROVIDER_CONFIG",
152
142
  "Mail message has no recipients",
153
143
  { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
154
144
  );
@@ -158,7 +148,7 @@ function assertHasRecipients(message: MailMessage): void {
158
148
  /**
159
149
  * Build the SendGrid v3 `content[]`: text/plain when text is set, text/html when
160
150
  * html is set, always at least one entry (SendGrid rejects empty content). The
161
- * non-empty tuple type lets the SDK's MailDataRequired see `content[0]` exists.
151
+ * At least one entry always: SendGrid refuses a message with empty content.
162
152
  */
163
153
  function buildSendGridContent(
164
154
  message: MailMessage,
@@ -177,7 +167,9 @@ function buildSendGridContent(
177
167
 
178
168
  function wrapSendGridError(err: unknown): RoverError {
179
169
  if (err instanceof RoverError) return err;
180
- // @sendgrid/mail throws `{ code, message, response: { body, headers, statusCode } }`
170
+ // Built from the HTTP response here, but the older SDK shape
171
+ // (`{ code, message, response: { body, headers, statusCode } }`) is still
172
+ // accepted so an injected fake or a wrapped error maps the same way.
181
173
  const anyErr = err as {
182
174
  code?: number | string;
183
175
  message?: string;
@@ -215,7 +207,7 @@ function wrapSendGridError(err: unknown): RoverError {
215
207
  const retryAfter = anyErr.response?.headers?.["retry-after"];
216
208
  if (retryAfter) ctx.retryAfter = retryAfter;
217
209
  return new RoverError(
218
- "MAIL_PROVIDER_ERROR",
210
+ "E_MAIL_PROVIDER_ERROR",
219
211
  `SendGrid returned ${status || "unknown"}`,
220
212
  {
221
213
  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.",
@@ -224,27 +216,4 @@ function wrapSendGridError(err: unknown): RoverError {
224
216
  );
225
217
  }
226
218
 
227
- /**
228
- * Type-guard resolver for the `MailService` constructor attached to the
229
- * module's default export at runtime. The cerebrum forbids `as unknown as T`;
230
- * here we receive `sgMail` through a parameter typed `unknown`, narrow with
231
- * runtime `typeof` checks, and return a single cast to a precise callable
232
- * type. No double-cast chain, no `as unknown` anchor.
233
- */
234
- function resolveMailServiceCtor(mod: unknown): new () => MailService {
235
- if (mod && typeof mod === "object" && "MailService" in mod) {
236
- const candidate = (mod as { MailService: unknown }).MailService;
237
- if (typeof candidate === "function") {
238
- return candidate as new () => MailService;
239
- }
240
- }
241
- throw new RoverError(
242
- "MAIL_PROVIDER_CONFIG",
243
- "@sendgrid/mail runtime does not expose `.MailService` — upgrade to v8+",
244
- {
245
- hint: "Expected `module.exports.MailService` to be the MailService class (index.js attaches it).",
246
- },
247
- );
248
- }
249
-
250
219
  registerTransport("sendgrid", (config) => new SendGridTransport(config));
@@ -65,14 +65,14 @@ export class SesTransport implements MailTransport {
65
65
  : "";
66
66
  if (!accessKeyId || !secretAccessKey || !rawRegion) {
67
67
  throw new RoverError(
68
- "MAIL_PROVIDER_CONFIG",
68
+ "E_MAIL_PROVIDER_CONFIG",
69
69
  "SES transport requires accessKeyId, secretAccessKey, and region",
70
70
  { hint: "Set all three in your mail config." },
71
71
  );
72
72
  }
73
73
  if (!REGION_RE.test(rawRegion)) {
74
74
  throw new RoverError(
75
- "MAIL_PROVIDER_CONFIG",
75
+ "E_MAIL_PROVIDER_CONFIG",
76
76
  `SES region "${rawRegion}" is not a valid AWS region identifier`,
77
77
  {
78
78
  hint: "Use the lowercase canonical form, e.g. 'us-east-1'. Uppercase breaks the SigV4 signing scope.",
@@ -92,7 +92,7 @@ export class SesTransport implements MailTransport {
92
92
  message.bcc.length === 0
93
93
  ) {
94
94
  throw new RoverError(
95
- "MAIL_PROVIDER_CONFIG",
95
+ "E_MAIL_PROVIDER_CONFIG",
96
96
  "Mail message has no recipients",
97
97
  { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
98
98
  );
@@ -132,7 +132,7 @@ export class SesTransport implements MailTransport {
132
132
  };
133
133
  if (retryAfter) ctx.retryAfter = retryAfter;
134
134
  throw new RoverError(
135
- "MAIL_PROVIDER_ERROR",
135
+ "E_MAIL_PROVIDER_ERROR",
136
136
  `SES returned ${res.status}`,
137
137
  {
138
138
  hint: "Inspect `context.upstreamStatus` to decide retry eligibility. `context.retryAfter` (when set) carries the provider's backoff hint in seconds.",
@@ -59,7 +59,7 @@ export class SparkPostTransport implements MailTransport {
59
59
  : "";
60
60
  if (!apiKey) {
61
61
  throw new RoverError(
62
- "MAIL_PROVIDER_CONFIG",
62
+ "E_MAIL_PROVIDER_CONFIG",
63
63
  "SparkPost transport requires apiKey",
64
64
  { hint: "Set { apiKey } in your mail config." },
65
65
  );
@@ -78,7 +78,7 @@ export class SparkPostTransport implements MailTransport {
78
78
  message.bcc.length === 0
79
79
  ) {
80
80
  throw new RoverError(
81
- "MAIL_PROVIDER_CONFIG",
81
+ "E_MAIL_PROVIDER_CONFIG",
82
82
  "Mail message has no recipients",
83
83
  { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
84
84
  );
@@ -148,7 +148,7 @@ export class SparkPostTransport implements MailTransport {
148
148
  };
149
149
  if (retryAfter) ctx.retryAfter = retryAfter;
150
150
  throw new RoverError(
151
- "MAIL_PROVIDER_ERROR",
151
+ "E_MAIL_PROVIDER_ERROR",
152
152
  `SparkPost returned ${res.status}`,
153
153
  {
154
154
  hint: "Inspect `context.upstreamStatus` to decide retry eligibility. `context.retryAfter` (when set) carries the provider's backoff hint in seconds.",
@@ -1,7 +1,7 @@
1
1
  import { RoverError } from "../RoverError.js";
2
2
 
3
3
  /**
4
- * Wrap a `fetch()` rejection into the uniform `MAIL_PROVIDER_ERROR` shape
4
+ * Wrap a `fetch()` rejection into the uniform `E_MAIL_PROVIDER_ERROR` shape
5
5
  * so the retry classifier can recognise transient network failures
6
6
  * (ECONNRESET / ENOTFOUND / EAI_AGAIN / ...).
7
7
  *
@@ -32,7 +32,7 @@ export function wrapFetchNetworkError(
32
32
  };
33
33
  if (networkCode) ctx.networkCode = networkCode;
34
34
  return new RoverError(
35
- "MAIL_PROVIDER_ERROR",
35
+ "E_MAIL_PROVIDER_ERROR",
36
36
  `${provider} fetch failed${networkCode ? ` (${networkCode})` : ""}`,
37
37
  {
38
38
  hint: "Inspect `context.networkCode` (ECONNRESET / ENOTFOUND / ...) — the retry classifier treats known transient errnos as retryable.",
@@ -50,7 +50,7 @@ const DEFAULT_TIMEOUT_MS = 30_000;
50
50
  * Without one, a stalled provider connection never settles: the send hangs,
51
51
  * the queue worker holding it hangs with it, and enough of them stop mail going
52
52
  * out at all — with no error to explain the silence. A timeout surfaces as the
53
- * same `MAIL_PROVIDER_ERROR` shape the retry classifier already understands, so
53
+ * same `E_MAIL_PROVIDER_ERROR` shape the retry classifier already understands, so
54
54
  * it is retried like any other transient network failure.
55
55
  */
56
56
  export async function fetchWithTimeout(
@@ -68,7 +68,7 @@ export async function fetchWithTimeout(
68
68
  } catch (err) {
69
69
  if (err instanceof Error && err.name === "TimeoutError") {
70
70
  throw new RoverError(
71
- "MAIL_PROVIDER_ERROR",
71
+ "E_MAIL_PROVIDER_ERROR",
72
72
  `${provider} did not answer within ${timeoutMs}ms.`,
73
73
  {
74
74
  context: { provider, upstreamStatus: "0", networkCode: "ETIMEDOUT" },