@c9up/rover 0.1.12 → 0.1.14

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 (70) hide show
  1. package/README.md +4 -1
  2. package/dist/Mail.d.ts +14 -6
  3. package/dist/Mail.d.ts.map +1 -1
  4. package/dist/Mail.js +42 -25
  5. package/dist/Mail.js.map +1 -1
  6. package/dist/MessageBuilder.js +4 -4
  7. package/dist/MessageBuilder.js.map +1 -1
  8. package/dist/RoverProvider.js +1 -1
  9. package/dist/RoverProvider.js.map +1 -1
  10. package/dist/config.d.ts +8 -7
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +8 -7
  13. package/dist/config.js.map +1 -1
  14. package/dist/configure.d.ts +18 -0
  15. package/dist/configure.d.ts.map +1 -0
  16. package/dist/configure.js +39 -0
  17. package/dist/configure.js.map +1 -0
  18. package/dist/format.js +1 -1
  19. package/dist/format.js.map +1 -1
  20. package/dist/queue/MailJob.js +4 -4
  21. package/dist/queue/MailJob.js.map +1 -1
  22. package/dist/retry.d.ts +2 -2
  23. package/dist/retry.js +7 -7
  24. package/dist/retry.js.map +1 -1
  25. package/dist/templating/SimpleTemplate.js +8 -8
  26. package/dist/templating/SimpleTemplate.js.map +1 -1
  27. package/dist/templating/loadNapi.js +8 -8
  28. package/dist/templating/loadNapi.js.map +1 -1
  29. package/dist/transports/BrevoTransport.js +3 -3
  30. package/dist/transports/BrevoTransport.js.map +1 -1
  31. package/dist/transports/MailgunTransport.js +6 -6
  32. package/dist/transports/MailgunTransport.js.map +1 -1
  33. package/dist/transports/ResendTransport.js +3 -3
  34. package/dist/transports/ResendTransport.js.map +1 -1
  35. package/dist/transports/SendGridTransport.js +4 -4
  36. package/dist/transports/SendGridTransport.js.map +1 -1
  37. package/dist/transports/SesTransport.js +4 -4
  38. package/dist/transports/SesTransport.js.map +1 -1
  39. package/dist/transports/SparkPostTransport.js +3 -3
  40. package/dist/transports/SparkPostTransport.js.map +1 -1
  41. package/dist/transports/fetchError.d.ts +2 -2
  42. package/dist/transports/fetchError.js +4 -4
  43. package/dist/transports/fetchError.js.map +1 -1
  44. package/dist/webhooks/resend.d.ts.map +1 -1
  45. package/dist/webhooks/resend.js +21 -1
  46. package/dist/webhooks/resend.js.map +1 -1
  47. package/index.darwin-arm64.node +0 -0
  48. package/index.darwin-x64.node +0 -0
  49. package/index.linux-arm64-gnu.node +0 -0
  50. package/index.linux-x64-gnu.node +0 -0
  51. package/index.win32-x64-msvc.node +0 -0
  52. package/package.json +5 -1
  53. package/src/Mail.ts +66 -30
  54. package/src/MessageBuilder.ts +4 -4
  55. package/src/RoverProvider.ts +1 -1
  56. package/src/config.ts +8 -7
  57. package/src/configure.ts +53 -0
  58. package/src/format.ts +1 -1
  59. package/src/queue/MailJob.ts +4 -4
  60. package/src/retry.ts +7 -7
  61. package/src/templating/SimpleTemplate.ts +8 -8
  62. package/src/templating/loadNapi.ts +8 -8
  63. package/src/transports/BrevoTransport.ts +3 -3
  64. package/src/transports/MailgunTransport.ts +6 -6
  65. package/src/transports/ResendTransport.ts +3 -3
  66. package/src/transports/SendGridTransport.ts +4 -4
  67. package/src/transports/SesTransport.ts +4 -4
  68. package/src/transports/SparkPostTransport.ts +3 -3
  69. package/src/transports/fetchError.ts +4 -4
  70. package/src/webhooks/resend.ts +26 -1
package/src/config.ts CHANGED
@@ -22,7 +22,9 @@ function describe(transport: string) {
22
22
  }
23
23
 
24
24
  /**
25
- * Mailer descriptors for `defineConfig`, matching the AdonisJS call site:
25
+ * One helper per transport rover ships. Each returns the descriptor a
26
+ * `mailers` entry is made of — the transport name plus the settings that
27
+ * transport reads:
26
28
  *
27
29
  * defineConfig({
28
30
  * default: 'smtp',
@@ -30,13 +32,12 @@ function describe(transport: string) {
30
32
  * mailers: { smtp: transports.smtp({ host: env.get('SMTP_HOST') }) },
31
33
  * })
32
34
  *
33
- * Named deviation: upstream returns a config PROVIDER that lazily imports the
34
- * transport. Rover returns the plain descriptor its config can persist, and the
35
- * transport registers itself on import.
35
+ * The descriptor is plain data, so a config file can be serialised and
36
+ * inspected; the transport itself registers on import of the package entry.
36
37
  *
37
- * Postmark has no helper here on purpose: rover has no Postmark transport, so a
38
- * migrated config naming it fails to COMPILE which says so plainly instead
39
- * of throwing at boot.
38
+ * The list is exactly what rover can send through naming a transport that
39
+ * has no helper fails to COMPILE, which says so plainly, instead of throwing
40
+ * at boot.
40
41
  */
41
42
  export const transports = {
42
43
  smtp: describe("smtp"),
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `ream configure @c9up/rover` — wire mail in one command.
3
+ *
4
+ * The provider alone is not enough: it reads `config/mail.ts`, and a package
5
+ * registered without one falls back to a default that is rarely the one an
6
+ * application wants. Writing both together is what makes `ream add` mean
7
+ * installed AND working.
8
+ */
9
+
10
+ interface Codemods {
11
+ addProvider(importPath: string): Promise<void>;
12
+ addEnvVars(vars: Record<string, string>): Promise<void>;
13
+ writeFile(
14
+ filePath: string,
15
+ content: string,
16
+ options?: { force?: boolean },
17
+ ): Promise<void>;
18
+ }
19
+
20
+ export async function configure(codemods: Codemods): Promise<void> {
21
+ // The config below reads these, so they are declared here. Writing the file
22
+ // without them leaves an application whose config asks the environment for
23
+ // something nothing ever put there.
24
+ await codemods.addEnvVars({
25
+ MAIL_MAILER: "log",
26
+ MAIL_FROM: "noreply@example.com",
27
+ SMTP_HOST: "",
28
+ SMTP_PORT: "587",
29
+ });
30
+
31
+ await codemods.addProvider("@c9up/rover/provider");
32
+ await codemods.writeFile(
33
+ "config/mail.ts",
34
+ `import { defineConfig, transports } from '@c9up/rover'
35
+ import env from '#start/env'
36
+
37
+ export default defineConfig({
38
+ // Has to name one of the mailers below, or the application refuses to boot.
39
+ default: env.get('MAIL_MAILER', 'log'),
40
+ from: env.get('MAIL_FROM', 'noreply@example.com'),
41
+
42
+ mailers: {
43
+ // Writes to the logger instead of sending. The development mailer.
44
+ log: transports.log(),
45
+
46
+ smtp: transports.smtp({
47
+ host: env.get('SMTP_HOST', ''),
48
+ port: Number(env.get('SMTP_PORT', '587')),
49
+ }),
50
+ },
51
+ })`,
52
+ );
53
+ }
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.",
@@ -44,7 +44,7 @@ export class MailgunTransport implements MailTransport {
44
44
  typeof config.domain === "string" ? normalizeConfig(config.domain) : "";
45
45
  if (!apiKey || !domain) {
46
46
  throw new RoverError(
47
- "MAIL_PROVIDER_CONFIG",
47
+ "E_MAIL_PROVIDER_CONFIG",
48
48
  "Mailgun transport requires apiKey and domain",
49
49
  { hint: "Set { apiKey, domain } in your mail config." },
50
50
  );
@@ -55,7 +55,7 @@ export class MailgunTransport implements MailTransport {
55
55
  // bug — defaulting to "us" under those conditions is a compliance risk).
56
56
  if (config.region !== undefined && typeof config.region !== "string") {
57
57
  throw new RoverError(
58
- "MAIL_PROVIDER_CONFIG",
58
+ "E_MAIL_PROVIDER_CONFIG",
59
59
  `Mailgun region must be a string ("us" or "eu"), got ${typeof config.region}`,
60
60
  { hint: "Set config.region explicitly as 'us' or 'eu'." },
61
61
  );
@@ -66,7 +66,7 @@ export class MailgunTransport implements MailTransport {
66
66
  : "us";
67
67
  if (rawRegion !== "us" && rawRegion !== "eu") {
68
68
  throw new RoverError(
69
- "MAIL_PROVIDER_CONFIG",
69
+ "E_MAIL_PROVIDER_CONFIG",
70
70
  `Mailgun region must be "us" or "eu", got "${rawRegion}"`,
71
71
  {
72
72
  hint: "Use { region: 'us' } or { region: 'eu' }. A typo silently routing EU traffic to US infrastructure is a compliance risk.",
@@ -105,7 +105,7 @@ export class MailgunTransport implements MailTransport {
105
105
  message.bcc.length === 0
106
106
  ) {
107
107
  throw new RoverError(
108
- "MAIL_PROVIDER_CONFIG",
108
+ "E_MAIL_PROVIDER_CONFIG",
109
109
  "Mail message has no recipients",
110
110
  { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
111
111
  );
@@ -160,7 +160,7 @@ export class MailgunTransport implements MailTransport {
160
160
 
161
161
  /**
162
162
  * mailgun.js's own error shape is `{ status, details | message, ... }`.
163
- * Map it to our uniform `MAIL_PROVIDER_ERROR` so retry + observability
163
+ * Map it to our uniform `E_MAIL_PROVIDER_ERROR` so retry + observability
164
164
  * consumers don't need to branch on provider.
165
165
  *
166
166
  * Bare `Error` (no `status`) — typical for network/ECONNRESET failures from
@@ -200,7 +200,7 @@ function wrapMailgunError(err: unknown): RoverError {
200
200
  : retryAfterHeader;
201
201
  }
202
202
  return new RoverError(
203
- "MAIL_PROVIDER_ERROR",
203
+ "E_MAIL_PROVIDER_ERROR",
204
204
  `Mailgun returned ${status || "unknown"}`,
205
205
  {
206
206
  hint: "Inspect `context.upstreamStatus` (HTTP) or `context.networkCode` (ECONNRESET/etc.) to decide retry eligibility.",
@@ -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.",
@@ -50,7 +50,7 @@ export class SendGridTransport implements MailTransport {
50
50
  typeof config.apiKey === "string" ? normalizeConfig(config.apiKey) : "";
51
51
  if (!apiKey) {
52
52
  throw new RoverError(
53
- "MAIL_PROVIDER_CONFIG",
53
+ "E_MAIL_PROVIDER_CONFIG",
54
54
  "SendGrid transport requires apiKey",
55
55
  { hint: "Set { apiKey } in your mail config." },
56
56
  );
@@ -148,7 +148,7 @@ function assertHasRecipients(message: MailMessage): void {
148
148
  message.bcc.length === 0
149
149
  ) {
150
150
  throw new RoverError(
151
- "MAIL_PROVIDER_CONFIG",
151
+ "E_MAIL_PROVIDER_CONFIG",
152
152
  "Mail message has no recipients",
153
153
  { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
154
154
  );
@@ -215,7 +215,7 @@ function wrapSendGridError(err: unknown): RoverError {
215
215
  const retryAfter = anyErr.response?.headers?.["retry-after"];
216
216
  if (retryAfter) ctx.retryAfter = retryAfter;
217
217
  return new RoverError(
218
- "MAIL_PROVIDER_ERROR",
218
+ "E_MAIL_PROVIDER_ERROR",
219
219
  `SendGrid returned ${status || "unknown"}`,
220
220
  {
221
221
  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.",
@@ -239,7 +239,7 @@ function resolveMailServiceCtor(mod: unknown): new () => MailService {
239
239
  }
240
240
  }
241
241
  throw new RoverError(
242
- "MAIL_PROVIDER_CONFIG",
242
+ "E_MAIL_PROVIDER_CONFIG",
243
243
  "@sendgrid/mail runtime does not expose `.MailService` — upgrade to v8+",
244
244
  {
245
245
  hint: "Expected `module.exports.MailService` to be the MailService class (index.js attaches it).",
@@ -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" },
@@ -57,6 +57,31 @@ const EVENT_MAP: Record<string, string> = {
57
57
  "email.bounced": "mail.bounced",
58
58
  };
59
59
 
60
+ /**
61
+ * Turn the configured signing secret into key bytes, or refuse.
62
+ *
63
+ * `Buffer.from(value, "base64")` never fails: it skips whatever it cannot read
64
+ * and hands back whatever is left, so a mistyped secret quietly becomes a
65
+ * short, deterministic key. Nothing then reports a configuration problem —
66
+ * every delivery just fails verification and looks like an attack, which is
67
+ * the one signal this endpoint exists to give.
68
+ */
69
+ function decodeSigningSecret(value: string): Buffer {
70
+ const bytes = Buffer.from(value, "base64");
71
+ const normalize = (v: string) => v.replace(/=+$/, "").replace(/\s/g, "");
72
+ if (normalize(bytes.toString("base64")) !== normalize(value)) {
73
+ throw new Error(
74
+ "createResendWebhookHandler: secret is not valid base64. Copy it from the Resend dashboard — it looks like `whsec_<base64>`.",
75
+ );
76
+ }
77
+ if (bytes.length < 16) {
78
+ throw new Error(
79
+ `createResendWebhookHandler: secret decodes to ${bytes.length} bytes, which is too short to sign with. Expected at least 16.`,
80
+ );
81
+ }
82
+ return bytes;
83
+ }
84
+
60
85
  export function createResendWebhookHandler(
61
86
  options: ResendWebhookOptions,
62
87
  ): WebhookMiddleware {
@@ -68,7 +93,7 @@ export function createResendWebhookHandler(
68
93
  const secretRaw = options.secret.startsWith("whsec_")
69
94
  ? options.secret.slice(6)
70
95
  : options.secret;
71
- const keyBytes = Buffer.from(secretRaw, "base64");
96
+ const keyBytes = decodeSigningSecret(secretRaw);
72
97
  const emitter = options.emitter;
73
98
  const maxAgeSeconds = options.maxAgeSeconds ?? 300;
74
99