@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
package/src/Mail.ts CHANGED
@@ -199,13 +199,13 @@ export class SmtpTransport implements MailTransport {
199
199
  // construction with an actionable message instead.
200
200
  if (config.host !== undefined && typeof config.host !== "string") {
201
201
  throw new RoverError(
202
- "MAIL_PROVIDER_CONFIG",
202
+ "E_MAIL_PROVIDER_CONFIG",
203
203
  `SMTP host must be a string, got ${typeof config.host}`,
204
204
  { hint: "Set config.host to your SMTP server hostname." },
205
205
  );
206
206
  }
207
207
  if (typeof config.host !== "string" || config.host.length === 0) {
208
- throw new RoverError("MAIL_PROVIDER_CONFIG", "SMTP host is required", {
208
+ throw new RoverError("E_MAIL_PROVIDER_CONFIG", "SMTP host is required", {
209
209
  hint: "Set config.host (e.g. process.env.SMTP_HOST). Use the fake / log transports for local development.",
210
210
  });
211
211
  }
@@ -220,7 +220,7 @@ export class SmtpTransport implements MailTransport {
220
220
  // fail fast rather than connect anonymously and let the server reject.
221
221
  if ((user && !pass) || (!user && pass)) {
222
222
  throw new RoverError(
223
- "MAIL_PROVIDER_CONFIG",
223
+ "E_MAIL_PROVIDER_CONFIG",
224
224
  "SMTP auth requires both `user` and `pass` or neither",
225
225
  {
226
226
  hint: "Check your env vars — one half of the credential pair is missing.",
@@ -250,7 +250,7 @@ export class SmtpTransport implements MailTransport {
250
250
  message.bcc.length === 0
251
251
  ) {
252
252
  throw new RoverError(
253
- "MAIL_PROVIDER_CONFIG",
253
+ "E_MAIL_PROVIDER_CONFIG",
254
254
  "Mail message has no recipients",
255
255
  { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
256
256
  );
@@ -310,7 +310,7 @@ export class SmtpTransport implements MailTransport {
310
310
  }
311
311
 
312
312
  /**
313
- * Normalise nodemailer / socket errors into the uniform `MAIL_PROVIDER_ERROR`
313
+ * Normalise nodemailer / socket errors into the uniform `E_MAIL_PROVIDER_ERROR`
314
314
  * shape used by the rest of the library. Preserves the `code` field (errno)
315
315
  * in `context.networkCode` so `isRetryableError` can classify transient
316
316
  * network failures without losing the root cause.
@@ -333,7 +333,7 @@ function wrapSmtpError(err: unknown): RoverError {
333
333
  ctx.networkCode = anyErr.code;
334
334
  }
335
335
  return new RoverError(
336
- "MAIL_PROVIDER_ERROR",
336
+ "E_MAIL_PROVIDER_ERROR",
337
337
  `SMTP failed: ${anyErr.message ?? "unknown"}`,
338
338
  {
339
339
  hint: "Inspect `context.networkCode` (ECONNRESET/ETIMEDOUT/...) or `context.upstreamStatus` (SMTP response code) to decide retry eligibility.",
@@ -433,7 +433,7 @@ export class Mail {
433
433
  const factory = transportFactories[transportConfig.transport];
434
434
  if (!factory) {
435
435
  throw new RoverError(
436
- "MAIL_UNKNOWN_TRANSPORT",
436
+ "E_MAIL_UNKNOWN_TRANSPORT",
437
437
  `Unknown mail transport type '${transportConfig.transport}' (configured under name '${name}')`,
438
438
  {
439
439
  hint: "Register the transport with registerTransport() before constructing Mail, or fix the typo in config.mail.transports[*].transport.",
@@ -452,7 +452,7 @@ export class Mail {
452
452
  // production. Refuse at construction and name what was declared.
453
453
  if (!this.#transports.has(this.#defaultTransport)) {
454
454
  throw new RoverError(
455
- "MAIL_UNKNOWN_MAILER",
455
+ "E_MAIL_UNKNOWN_MAILER",
456
456
  `Mail default mailer '${this.#defaultTransport}' is not declared in config.mailers`,
457
457
  {
458
458
  hint: `${knownMailers(this.#transports)} Add it, or point \`default\` at one of them.`,
@@ -735,11 +735,17 @@ export class Mail {
735
735
  );
736
736
  }
737
737
  if (this.#emitter) {
738
- try {
739
- this.#emitter.emit(name, event);
740
- } catch {
741
- // Event bus failure mail delivery failure swallow.
742
- }
738
+ // Not just `try`: an Adonis-shaped emitter returns a promise, and a
739
+ // listener that failed asynchronously rejected it with nobody to
740
+ // catch — an unhandled rejection ending the process over a
741
+ // notification, long after the mail itself went out fine.
742
+ void (async () => this.#emitter?.emit(name, event))().catch(
743
+ (err: unknown) => {
744
+ process.stderr.write(
745
+ `[rover] ${name} listener failed: ${err instanceof Error ? err.message : String(err)}\n`,
746
+ );
747
+ },
748
+ );
743
749
  }
744
750
  }
745
751
 
@@ -812,7 +818,7 @@ export class Mail {
812
818
  const transport = this.#transports.get(name);
813
819
  if (transport) return transport;
814
820
  throw new RoverError(
815
- "MAIL_UNKNOWN_MAILER",
821
+ "E_MAIL_UNKNOWN_MAILER",
816
822
  `Mail mailer '${name}' is not declared in config.mailers`,
817
823
  { hint: knownMailers(this.#transports) },
818
824
  );
@@ -911,7 +917,7 @@ function validateMailMessage(message: MailMessage): void {
911
917
  // invalid per RFC 5321 reverse-path semantics.
912
918
  if (typeof message.from !== "string" || message.from.trim() === "") {
913
919
  throw new RoverError(
914
- "MAIL_INVALID_MESSAGE",
920
+ "E_MAIL_INVALID_MESSAGE",
915
921
  "Mail message has no `from` address",
916
922
  {
917
923
  hint: "Set `config.from`, an instance `from`, or call `message.from(...)` in the builder.",
@@ -922,14 +928,14 @@ function validateMailMessage(message: MailMessage): void {
922
928
  // `to` to be an array (cc/bcc are optional at the queue boundary), so a
923
929
  // deserialised job payload may reach here with `cc`/`bcc` as `undefined`.
924
930
  // `.some(...)` on `undefined` throws `TypeError` instead of the
925
- // structured `MAIL_INVALID_MESSAGE` this validator is supposed to surface.
931
+ // structured `E_MAIL_INVALID_MESSAGE` this validator is supposed to surface.
926
932
  const hasRecipient =
927
933
  (Array.isArray(message.to) && message.to.some(isNonEmptyAddress)) ||
928
934
  (Array.isArray(message.cc) && message.cc.some(isNonEmptyAddress)) ||
929
935
  (Array.isArray(message.bcc) && message.bcc.some(isNonEmptyAddress));
930
936
  if (!hasRecipient) {
931
937
  throw new RoverError(
932
- "MAIL_INVALID_MESSAGE",
938
+ "E_MAIL_INVALID_MESSAGE",
933
939
  "Mail message has no recipients",
934
940
  {
935
941
  hint: "Call `message.to(...)`, `cc(...)`, or `bcc(...)` with a non-empty address before sending.",
@@ -960,12 +966,12 @@ function errorDescriptor(
960
966
  if (err instanceof Error) {
961
967
  const errnoCode = (err as { code?: unknown }).code;
962
968
  return {
963
- code: typeof errnoCode === "string" ? errnoCode : "UNKNOWN",
969
+ code: typeof errnoCode === "string" ? errnoCode : "E_ROVER_UNKNOWN",
964
970
  message: err.message,
965
971
  attempts,
966
972
  };
967
973
  }
968
- return { code: "UNKNOWN", message: String(err), attempts };
974
+ return { code: "E_ROVER_UNKNOWN", message: String(err), attempts };
969
975
  }
970
976
 
971
977
  /** "Declared mailers: a, b." — the half of an unknown-mailer error that helps. */
@@ -183,7 +183,7 @@ export function attachmentsFor(message: MailMessage): MailAttachment[] {
183
183
  // SMTP. An HTTP provider takes the bytes, and silently dropping the
184
184
  // invitation would be worse than saying so.
185
185
  throw new RoverError(
186
- "ICAL_HREF_UNSUPPORTED",
186
+ "E_ICAL_HREF_UNSUPPORTED",
187
187
  "icalEventFromUrl() is only supported by the SMTP transport, which fetches the URL itself.",
188
188
  {
189
189
  hint: "Fetch the ICS yourself and pass it to icalEvent(contents), or use icalEventFromFile().",
@@ -208,7 +208,7 @@ async function readAttachment(path: string, label: string): Promise<Buffer> {
208
208
  return await readFile(path);
209
209
  } catch (err) {
210
210
  throw new RoverError(
211
- "ATTACHMENT_UNREADABLE",
211
+ "E_ATTACHMENT_UNREADABLE",
212
212
  `Could not read ${label} from "${path}": ${err instanceof Error ? err.message : String(err)}`,
213
213
  {
214
214
  hint: "Give an absolute path, or attach the bytes with attachData() / embedData().",
@@ -246,7 +246,7 @@ function renderListHeader(
246
246
  function expect(passed: boolean, expectation: string, actual: unknown): void {
247
247
  if (passed) return;
248
248
  throw new RoverError(
249
- "ASSERTION_FAILED",
249
+ "E_ASSERTION_FAILED",
250
250
  `Expected the message ${expectation}, got ${JSON.stringify(actual)}`,
251
251
  );
252
252
  }
@@ -760,7 +760,7 @@ export class MessageBuilder {
760
760
  for (const url of listUrls(value)) {
761
761
  if (!url.toLowerCase().startsWith("http")) {
762
762
  throw new RoverError(
763
- "INVALID_LIST_HEADER",
763
+ "E_INVALID_LIST_HEADER",
764
764
  `listUnsubscribe({ oneClick: true }) needs an https URL that can answer a POST, got "${url}".`,
765
765
  {
766
766
  hint: "Keep the mailto: form without oneClick, or add an https endpoint alongside it.",
package/src/format.ts CHANGED
@@ -37,7 +37,7 @@ export function formatAddress(address: string, name?: string): string {
37
37
  function assertHeaderSafe(value: string, what: string): void {
38
38
  if (!HEADER_BREAKERS.test(value)) return;
39
39
  throw new RoverError(
40
- "MAIL_HEADER_INJECTION",
40
+ "E_MAIL_HEADER_INJECTION",
41
41
  `The ${what} contains a line break or NUL, which cannot appear in a mail header.`,
42
42
  {
43
43
  hint: "Strip CR/LF/NUL from user-supplied names and addresses before building the message.",
@@ -70,14 +70,14 @@ export class MailJobHandler implements BayJobHandlerLike {
70
70
 
71
71
  /**
72
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
73
+ * Throws `E_MAIL_JOB_MALFORMED` when the shape is unrecognisable so Bay records
74
74
  * a clean failure rather than crashing inside `dispatchMessage` with a raw
75
75
  * `TypeError`.
76
76
  */
77
77
  function validatePayload(payload: unknown): MailJobPayload {
78
78
  if (!payload || typeof payload !== "object") {
79
79
  throw new RoverError(
80
- "MAIL_JOB_MALFORMED",
80
+ "E_MAIL_JOB_MALFORMED",
81
81
  "Mail job payload is missing or not an object",
82
82
  { hint: "Queue driver returned a non-object — check serialisation." },
83
83
  );
@@ -86,7 +86,7 @@ function validatePayload(payload: unknown): MailJobPayload {
86
86
  const message = asObj.message;
87
87
  if (!message || typeof message !== "object") {
88
88
  throw new RoverError(
89
- "MAIL_JOB_MALFORMED",
89
+ "E_MAIL_JOB_MALFORMED",
90
90
  "Mail job payload.message is missing or not an object",
91
91
  {
92
92
  hint: "Payload shape must be { message: MailMessage, transport?: string }.",
@@ -100,7 +100,7 @@ function validatePayload(payload: unknown): MailJobPayload {
100
100
  !Array.isArray(m.attachments)
101
101
  ) {
102
102
  throw new RoverError(
103
- "MAIL_JOB_MALFORMED",
103
+ "E_MAIL_JOB_MALFORMED",
104
104
  "Mail job payload.message does not match MailMessage shape",
105
105
  {
106
106
  hint: "Expected `{ from: string, to: string[], ..., attachments: [] }`.",
package/src/retry.ts CHANGED
@@ -24,15 +24,15 @@ const RETRYABLE_ERRNO = new Set([
24
24
 
25
25
  /**
26
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
27
+ * - it's a `E_MAIL_PROVIDER_ERROR` with `upstreamStatus` 429 or ≥ 500
28
+ * - or it's a `E_MAIL_PROVIDER_ERROR` whose `context.networkCode` is a known
29
29
  * retryable Node errno (SDK wrappers surface the original errno this way
30
30
  * when the HTTP layer never produced a status)
31
31
  * - or it's a raw Error with a matching Node networking errno on `.code`
32
32
  * 4xx (non-429) is treated as permanent: retrying won't help.
33
33
  */
34
34
  export function isRetryableError(err: unknown): boolean {
35
- if (err instanceof RoverError && err.code === "MAIL_PROVIDER_ERROR") {
35
+ if (err instanceof RoverError && err.code === "E_MAIL_PROVIDER_ERROR") {
36
36
  const statusStr = err.context.upstreamStatus;
37
37
  const status = Number(statusStr);
38
38
  if (Number.isFinite(status)) {
@@ -70,7 +70,7 @@ export function computeBackoffMs(
70
70
  // SES/Resend transports capture it into `context.retryAfter`). Value is
71
71
  // seconds per HTTP RFC 7231 §7.1.3; we cap at `maxDelayMs` so a malicious
72
72
  // or mistaken provider can't force a very-long wait.
73
- if (err instanceof RoverError && err.code === "MAIL_PROVIDER_ERROR") {
73
+ if (err instanceof RoverError && err.code === "E_MAIL_PROVIDER_ERROR") {
74
74
  const hint = err.context.retryAfter;
75
75
  if (hint) {
76
76
  const seconds = Number(hint);
@@ -104,7 +104,7 @@ export function resolveRetryConfig(
104
104
  };
105
105
  if (!Number.isInteger(merged.maxAttempts) || merged.maxAttempts < 1) {
106
106
  throw new RoverError(
107
- "MAIL_RETRY_CONFIG",
107
+ "E_MAIL_RETRY_CONFIG",
108
108
  `RetryConfig.maxAttempts must be an integer >= 1 (got ${merged.maxAttempts})`,
109
109
  { hint: "Set retry.maxAttempts to a positive integer." },
110
110
  );
@@ -115,7 +115,7 @@ export function resolveRetryConfig(
115
115
  merged.baseDelayMs < 0
116
116
  ) {
117
117
  throw new RoverError(
118
- "MAIL_RETRY_CONFIG",
118
+ "E_MAIL_RETRY_CONFIG",
119
119
  `RetryConfig.baseDelayMs must be a non-negative finite number (got ${merged.baseDelayMs})`,
120
120
  { hint: "Use 0 or a positive number; the 50 ms floor kicks in anyway." },
121
121
  );
@@ -126,7 +126,7 @@ export function resolveRetryConfig(
126
126
  merged.factor <= 0
127
127
  ) {
128
128
  throw new RoverError(
129
- "MAIL_RETRY_CONFIG",
129
+ "E_MAIL_RETRY_CONFIG",
130
130
  `RetryConfig.factor must be a positive finite number (got ${merged.factor})`,
131
131
  { hint: "Use >= 1 for exponential growth; 1 means constant delay." },
132
132
  );
@@ -84,13 +84,13 @@ function dataReplacer(_key: string, value: unknown): unknown {
84
84
  return Number(value);
85
85
  }
86
86
  throw new RoverError(
87
- "MAIL_TEMPLATE_SYNTAX",
87
+ "E_MAIL_TEMPLATE_SYNTAX",
88
88
  `Cannot render bigint ${value} — it exceeds Number.MAX_SAFE_INTEGER and cannot cross the template engine boundary without precision loss; format it to a string before rendering`,
89
89
  );
90
90
  }
91
91
  if (typeof value === "number" && !Number.isFinite(value)) {
92
92
  throw new RoverError(
93
- "MAIL_TEMPLATE_SYNTAX",
93
+ "E_MAIL_TEMPLATE_SYNTAX",
94
94
  `Cannot render non-finite number ${value} — NaN and Infinity have no representation across the template engine boundary; format it to a string before rendering`,
95
95
  );
96
96
  }
@@ -129,7 +129,7 @@ function errnoCode(err: unknown): string {
129
129
 
130
130
  /**
131
131
  * Resolve + read + compile one template path, caching the compiled IR by its
132
- * resolved absolute path. Owns the `MAIL_TEMPLATE_NOT_FOUND` / `_READ_ERROR`
132
+ * resolved absolute path. Owns the `E_MAIL_TEMPLATE_NOT_FOUND` / `_READ_ERROR`
133
133
  * raising (unchanged from the pre-migration engine).
134
134
  */
135
135
  async function loadIr(viewPath: string, root: string): Promise<NativeRoverIr> {
@@ -145,7 +145,7 @@ async function loadIr(viewPath: string, root: string): Promise<NativeRoverIr> {
145
145
  // Only ENOENT maps to NOT_FOUND; permission/io errors surface distinctly.
146
146
  if (code !== "ENOENT") {
147
147
  throw new RoverError(
148
- "MAIL_TEMPLATE_READ_ERROR",
148
+ "E_MAIL_TEMPLATE_READ_ERROR",
149
149
  `Template read failed at ${resolved} (${code || "unknown"})`,
150
150
  {
151
151
  hint: "Check filesystem permissions and file descriptor limits.",
@@ -154,7 +154,7 @@ async function loadIr(viewPath: string, root: string): Promise<NativeRoverIr> {
154
154
  );
155
155
  }
156
156
  throw new RoverError(
157
- "MAIL_TEMPLATE_NOT_FOUND",
157
+ "E_MAIL_TEMPLATE_NOT_FOUND",
158
158
  `Template not found at ${resolved}`,
159
159
  {
160
160
  hint: "Create the file or update config.mail.viewsRoot.",
@@ -198,10 +198,10 @@ async function buildPartialMap(
198
198
  // A partial referenced only inside a falsy `{{#if}}` is never
199
199
  // rendered. The pre-migration engine loaded partials lazily, so a
200
200
  // missing such partial did not error. Skip it here; if it IS reached
201
- // at render time the Rust renderer raises MAIL_TEMPLATE_NOT_FOUND.
201
+ // at render time the Rust renderer raises E_MAIL_TEMPLATE_NOT_FOUND.
202
202
  if (
203
203
  err instanceof RoverError &&
204
- err.code === "MAIL_TEMPLATE_NOT_FOUND"
204
+ err.code === "E_MAIL_TEMPLATE_NOT_FOUND"
205
205
  ) {
206
206
  continue;
207
207
  }
@@ -238,7 +238,7 @@ function resolveTemplatePath(
238
238
  const rel = path.relative(rootAbs, candidate);
239
239
  if (rel.startsWith("..") || path.isAbsolute(rel)) {
240
240
  throw new RoverError(
241
- "MAIL_TEMPLATE_NOT_FOUND",
241
+ "E_MAIL_TEMPLATE_NOT_FOUND",
242
242
  `Template path "${viewPath}" resolves outside of viewsRoot`,
243
243
  {
244
244
  hint: "Template names must stay under viewsRoot. Absolute paths and `..` traversals are rejected.",
@@ -1,6 +1,6 @@
1
1
  // Loads the native `rover-template-engine-napi` binary built by
2
2
  // `scripts/copy-napi.mjs` and re-throws load failures as
3
- // `MAIL_TEMPLATE_NAPI_REQUIRED` (D55.2.4) — actionable hint points at
3
+ // `E_MAIL_TEMPLATE_NAPI_REQUIRED` (D55.2.4) — actionable hint points at
4
4
  // `pnpm --filter @c9up/rover build:napi`.
5
5
  //
6
6
  // Per cerebrum 2026-04-15 there is NO JS fallback. If the binary fails to load,
@@ -25,7 +25,7 @@ function platformSuffix(): string {
25
25
  const suffix = SUFFIX_MAP[key];
26
26
  if (typeof suffix !== "string") {
27
27
  throw new RoverError(
28
- "MAIL_TEMPLATE_NAPI_REQUIRED",
28
+ "E_MAIL_TEMPLATE_NAPI_REQUIRED",
29
29
  `Unsupported platform/arch '${key}' for @c9up/rover native binary. Supported: ${Object.keys(SUFFIX_MAP).join(", ")}.`,
30
30
  {
31
31
  hint: "Build the native binary on a supported platform with 'pnpm --filter @c9up/rover build:napi'.",
@@ -87,7 +87,7 @@ export function getNative(): NativeExports {
87
87
  ? " If you are on Alpine/musl, note the prebuilt binaries target glibc (musl is not a supported target)."
88
88
  : "";
89
89
  throw new RoverError(
90
- "MAIL_TEMPLATE_NAPI_REQUIRED",
90
+ "E_MAIL_TEMPLATE_NAPI_REQUIRED",
91
91
  `@c9up/rover native binary 'index.${suffix}.node' not found or failed to load near ${here} — run 'pnpm --filter @c9up/rover build:napi' to build it.${muslHint} Cause: ${causeMessage}`,
92
92
  {
93
93
  hint: "Run 'pnpm --filter @c9up/rover build:napi' to compile the native template engine.",
@@ -96,7 +96,7 @@ export function getNative(): NativeExports {
96
96
  }
97
97
  if (!isNativeExports(loaded)) {
98
98
  throw new RoverError(
99
- "MAIL_TEMPLATE_NAPI_REQUIRED",
99
+ "E_MAIL_TEMPLATE_NAPI_REQUIRED",
100
100
  "@c9up/rover native binary loaded but missing expected exports (engineVersion / compile / renderIr). Rebuild with 'pnpm --filter @c9up/rover build:napi'.",
101
101
  {
102
102
  hint: "The native binary is stale — rebuild with 'pnpm --filter @c9up/rover build:napi'.",
@@ -126,11 +126,11 @@ function isNapiErrorPayload(value: unknown): value is NapiErrorPayload {
126
126
 
127
127
  /** Codes the Rust engine legitimately emits, with their actionable hints. */
128
128
  const CODE_HINTS: Readonly<Record<string, string>> = {
129
- MAIL_TEMPLATE_SYNTAX:
129
+ E_MAIL_TEMPLATE_SYNTAX:
130
130
  "Check the template grammar: {{ var }}, {{{ raw }}}, {{#if x}}...{{/if}}, {{> partial}}.",
131
131
  MAIL_TEMPLATE_RECURSION:
132
132
  "Break the cycle — a template cannot include itself (directly or via partials).",
133
- MAIL_TEMPLATE_NOT_FOUND:
133
+ E_MAIL_TEMPLATE_NOT_FOUND:
134
134
  "Create the referenced partial file or remove the {{> name}} reference.",
135
135
  };
136
136
 
@@ -156,12 +156,12 @@ export function toReamError(err: unknown): RoverError {
156
156
  }
157
157
  }
158
158
  return new RoverError(
159
- "MAIL_TEMPLATE_SYNTAX",
159
+ "E_MAIL_TEMPLATE_SYNTAX",
160
160
  `Native template call failed: ${err.message}`,
161
161
  );
162
162
  }
163
163
  return new RoverError(
164
- "MAIL_TEMPLATE_SYNTAX",
164
+ "E_MAIL_TEMPLATE_SYNTAX",
165
165
  `Native template call failed with non-Error: ${String(err)}`,
166
166
  );
167
167
  }
@@ -60,7 +60,7 @@ export class BrevoTransport implements MailTransport {
60
60
  : "";
61
61
  if (!apiKey) {
62
62
  throw new RoverError(
63
- "MAIL_PROVIDER_CONFIG",
63
+ "E_MAIL_PROVIDER_CONFIG",
64
64
  "Brevo transport requires apiKey",
65
65
  { hint: "Set { apiKey } in your mail config." },
66
66
  );
@@ -79,7 +79,7 @@ export class BrevoTransport implements MailTransport {
79
79
  message.bcc.length === 0
80
80
  ) {
81
81
  throw new RoverError(
82
- "MAIL_PROVIDER_CONFIG",
82
+ "E_MAIL_PROVIDER_CONFIG",
83
83
  "Mail message has no recipients",
84
84
  { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
85
85
  );
@@ -137,7 +137,7 @@ export class BrevoTransport implements MailTransport {
137
137
  };
138
138
  if (retryAfter) ctx.retryAfter = retryAfter;
139
139
  throw new RoverError(
140
- "MAIL_PROVIDER_ERROR",
140
+ "E_MAIL_PROVIDER_ERROR",
141
141
  `Brevo returned ${res.status}`,
142
142
  {
143
143
  hint: "Inspect `context.upstreamStatus` to decide retry eligibility. `context.retryAfter` (when set) carries the provider's backoff hint in seconds.",
@@ -1,6 +1,3 @@
1
- import formData from "form-data";
2
- // mailgun.js is UMD-bundled; the class lives on `.default` under NodeNext.
3
- import MailgunModule from "mailgun.js";
4
1
  import {
5
2
  type MailMessage,
6
3
  type MailSendOutcome,
@@ -9,6 +6,7 @@ import {
9
6
  } from "../Mail.js";
10
7
  import { attachmentsFor, headerValue } from "../MessageBuilder.js";
11
8
  import { RoverError } from "../RoverError.js";
9
+ import { fetchWithTimeout, wrapFetchNetworkError } from "./fetchError.js";
12
10
 
13
11
  const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
14
12
  const normalizeConfig = (v: string): string => stripCrlf(v).trim();
@@ -23,19 +21,10 @@ const redactSecrets = (s: string): string =>
23
21
  .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
24
22
  .replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
25
23
 
26
- /** Minimal mailgun.js client surface — matches the subset we need. */
27
- interface MailgunClientLike {
28
- messages: {
29
- create(
30
- domain: string,
31
- data: Record<string, unknown>,
32
- ): Promise<{ id?: string; message?: string; status?: number }>;
33
- };
34
- }
35
-
36
24
  export class MailgunTransport implements MailTransport {
37
- #client: MailgunClientLike;
25
+ #apiKey: string;
38
26
  #domain: string;
27
+ #baseUrl: string;
39
28
 
40
29
  constructor(config: Record<string, unknown>) {
41
30
  const apiKey =
@@ -44,7 +33,7 @@ export class MailgunTransport implements MailTransport {
44
33
  typeof config.domain === "string" ? normalizeConfig(config.domain) : "";
45
34
  if (!apiKey || !domain) {
46
35
  throw new RoverError(
47
- "MAIL_PROVIDER_CONFIG",
36
+ "E_MAIL_PROVIDER_CONFIG",
48
37
  "Mailgun transport requires apiKey and domain",
49
38
  { hint: "Set { apiKey, domain } in your mail config." },
50
39
  );
@@ -55,7 +44,7 @@ export class MailgunTransport implements MailTransport {
55
44
  // bug — defaulting to "us" under those conditions is a compliance risk).
56
45
  if (config.region !== undefined && typeof config.region !== "string") {
57
46
  throw new RoverError(
58
- "MAIL_PROVIDER_CONFIG",
47
+ "E_MAIL_PROVIDER_CONFIG",
59
48
  `Mailgun region must be a string ("us" or "eu"), got ${typeof config.region}`,
60
49
  { hint: "Set config.region explicitly as 'us' or 'eu'." },
61
50
  );
@@ -66,7 +55,7 @@ export class MailgunTransport implements MailTransport {
66
55
  : "us";
67
56
  if (rawRegion !== "us" && rawRegion !== "eu") {
68
57
  throw new RoverError(
69
- "MAIL_PROVIDER_CONFIG",
58
+ "E_MAIL_PROVIDER_CONFIG",
70
59
  `Mailgun region must be "us" or "eu", got "${rawRegion}"`,
71
60
  {
72
61
  hint: "Use { region: 'us' } or { region: 'eu' }. A typo silently routing EU traffic to US infrastructure is a compliance risk.",
@@ -78,24 +67,11 @@ export class MailgunTransport implements MailTransport {
78
67
  ? "https://api.eu.mailgun.net"
79
68
  : "https://api.mailgun.net";
80
69
 
81
- // Dependency injection for tests: `_client` wins over real SDK. Guarded
82
- // against non-object and non-shape inputs so a typo'd config can't
83
- // silently bypass the real client.
84
- const injected = config._client;
85
- if (
86
- injected &&
87
- typeof injected === "object" &&
88
- "messages" in (injected as object) &&
89
- typeof (injected as MailgunClientLike).messages?.create === "function"
90
- ) {
91
- this.#client = injected as MailgunClientLike;
92
- } else {
93
- // mailgun.js ships a UMD-style default export; the class constructor
94
- // lives on `.default` in the typings (`static get default`).
95
- const MailgunCtor = MailgunModule.default;
96
- const mailgun = new MailgunCtor(formData);
97
- this.#client = mailgun.client({ username: "api", key: apiKey, url });
98
- }
70
+ this.#apiKey = apiKey;
71
+ this.#baseUrl =
72
+ typeof config.baseUrl === "string" && config.baseUrl.length > 0
73
+ ? normalizeConfig(config.baseUrl).replace(/\/+$/, "")
74
+ : url;
99
75
  }
100
76
 
101
77
  async send(message: MailMessage): Promise<MailSendOutcome> {
@@ -105,7 +81,7 @@ export class MailgunTransport implements MailTransport {
105
81
  message.bcc.length === 0
106
82
  ) {
107
83
  throw new RoverError(
108
- "MAIL_PROVIDER_CONFIG",
84
+ "E_MAIL_PROVIDER_CONFIG",
109
85
  "Mail message has no recipients",
110
86
  { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
111
87
  );
@@ -134,38 +110,70 @@ export class MailgunTransport implements MailTransport {
134
110
  ? v.map(stripCrlf).join(", ")
135
111
  : stripCrlf(v);
136
112
  }
137
- if (attachmentsFor(message).length > 0) {
138
- data.attachment = attachmentsFor(message).map((att) => {
139
- const entry: { filename: string; data: Buffer; contentType?: string } =
140
- {
141
- filename: stripCrlf(att.filename),
142
- data: Buffer.from(att.content as Buffer | string),
143
- };
144
- if (att.contentType) {
145
- entry.contentType = stripCrlf(att.contentType);
146
- }
147
- return entry;
148
- });
113
+ // Mailgun takes `multipart/form-data`; Node builds it, and `fetch` sets
114
+ // the boundary. Scalars first, then one file part per attachment.
115
+ const form = new FormData();
116
+ for (const [key, value] of Object.entries(data)) {
117
+ form.append(key, Array.isArray(value) ? value.join(", ") : String(value));
118
+ }
119
+ for (const att of attachmentsFor(message)) {
120
+ const bytes = Buffer.from(att.content as Buffer | string);
121
+ const blob = att.contentType
122
+ ? new Blob([bytes], { type: stripCrlf(att.contentType) })
123
+ : new Blob([bytes]);
124
+ form.append("attachment", blob, stripCrlf(att.filename));
149
125
  }
150
126
 
127
+ let res: Response;
151
128
  try {
152
- const res = await this.#client.messages.create(this.#domain, data);
153
- if (res.id) return { providerId: res.id };
154
- return undefined;
129
+ res = await fetchWithTimeout(
130
+ "Mailgun",
131
+ `${this.#baseUrl}/v3/${encodeURIComponent(this.#domain)}/messages`,
132
+ {
133
+ method: "POST",
134
+ headers: {
135
+ // HTTP Basic, the username is literally `api`.
136
+ Authorization: `Basic ${Buffer.from(`api:${this.#apiKey}`).toString("base64")}`,
137
+ Accept: "application/json",
138
+ },
139
+ body: form,
140
+ },
141
+ );
155
142
  } catch (err) {
156
- throw wrapMailgunError(err);
143
+ throw wrapFetchNetworkError("mailgun", err);
144
+ }
145
+
146
+ const raw = await res.text();
147
+ if (!res.ok) {
148
+ throw wrapMailgunError({
149
+ status: res.status,
150
+ message: raw,
151
+ headers: Object.fromEntries(res.headers.entries()),
152
+ });
153
+ }
154
+ // A 200 carries `{ id, message }`; anything unparseable is still a
155
+ // success on the wire, so it is not turned into an error.
156
+ try {
157
+ const parsed: unknown = JSON.parse(raw);
158
+ const id =
159
+ typeof parsed === "object" && parsed !== null
160
+ ? Reflect.get(parsed, "id")
161
+ : undefined;
162
+ return typeof id === "string" ? { providerId: id } : undefined;
163
+ } catch {
164
+ return undefined;
157
165
  }
158
166
  }
159
167
  }
160
168
 
161
169
  /**
162
- * mailgun.js's own error shape is `{ status, details | message, ... }`.
163
- * Map it to our uniform `MAIL_PROVIDER_ERROR` so retry + observability
164
- * consumers don't need to branch on provider.
170
+ * Map an upstream refusal onto the uniform `E_MAIL_PROVIDER_ERROR` every
171
+ * transport raises, so retry and observability never branch on the provider.
165
172
  *
166
- * Bare `Error` (no `status`) typical for network/ECONNRESET failures from
167
- * mailgun.js surface the original errno (`code`) in context so the retry
168
- * predicate can still classify it as transient.
173
+ * The shape is what the HTTP response gives: a status, the body as the
174
+ * message, and the response headers `Retry-After` among them, which the
175
+ * backoff honours. A network failure never reaches here; `wrapFetchNetworkError`
176
+ * carries the errno so the retry predicate can still see it.
169
177
  */
170
178
  function wrapMailgunError(err: unknown): RoverError {
171
179
  if (err instanceof RoverError) return err;
@@ -176,8 +184,8 @@ function wrapMailgunError(err: unknown): RoverError {
176
184
  code?: string;
177
185
  headers?: Record<string, string | string[]>;
178
186
  };
179
- // Coerce string-typed status ("401") into number mailgun.js is
180
- // inconsistent across versions.
187
+ // Coerce a string-typed status into a number: the field is built here, but
188
+ // a caller injecting a fake response may still hand one over as text.
181
189
  const statusNum = Number(anyErr.status);
182
190
  const status = Number.isFinite(statusNum) ? statusNum : 0;
183
191
  const providerMessage = redactSecrets(
@@ -200,7 +208,7 @@ function wrapMailgunError(err: unknown): RoverError {
200
208
  : retryAfterHeader;
201
209
  }
202
210
  return new RoverError(
203
- "MAIL_PROVIDER_ERROR",
211
+ "E_MAIL_PROVIDER_ERROR",
204
212
  `Mailgun returned ${status || "unknown"}`,
205
213
  {
206
214
  hint: "Inspect `context.upstreamStatus` (HTTP) or `context.networkCode` (ECONNRESET/etc.) to decide retry eligibility.",