@c9up/rover 0.1.13 → 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.
- package/dist/Mail.js +14 -14
- package/dist/Mail.js.map +1 -1
- package/dist/MessageBuilder.js +4 -4
- package/dist/MessageBuilder.js.map +1 -1
- package/dist/format.js +1 -1
- package/dist/format.js.map +1 -1
- package/dist/queue/MailJob.js +4 -4
- package/dist/queue/MailJob.js.map +1 -1
- package/dist/retry.d.ts +2 -2
- package/dist/retry.js +7 -7
- package/dist/retry.js.map +1 -1
- package/dist/templating/SimpleTemplate.js +8 -8
- package/dist/templating/SimpleTemplate.js.map +1 -1
- package/dist/templating/loadNapi.js +8 -8
- package/dist/templating/loadNapi.js.map +1 -1
- package/dist/transports/BrevoTransport.js +3 -3
- package/dist/transports/BrevoTransport.js.map +1 -1
- package/dist/transports/MailgunTransport.js +6 -6
- package/dist/transports/MailgunTransport.js.map +1 -1
- package/dist/transports/ResendTransport.js +3 -3
- package/dist/transports/ResendTransport.js.map +1 -1
- package/dist/transports/SendGridTransport.js +4 -4
- package/dist/transports/SendGridTransport.js.map +1 -1
- package/dist/transports/SesTransport.js +4 -4
- package/dist/transports/SesTransport.js.map +1 -1
- package/dist/transports/SparkPostTransport.js +3 -3
- package/dist/transports/SparkPostTransport.js.map +1 -1
- package/dist/transports/fetchError.d.ts +2 -2
- package/dist/transports/fetchError.js +4 -4
- package/dist/transports/fetchError.js.map +1 -1
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/src/Mail.ts +14 -14
- package/src/MessageBuilder.ts +4 -4
- package/src/format.ts +1 -1
- package/src/queue/MailJob.ts +4 -4
- package/src/retry.ts +7 -7
- package/src/templating/SimpleTemplate.ts +8 -8
- package/src/templating/loadNapi.ts +8 -8
- package/src/transports/BrevoTransport.ts +3 -3
- package/src/transports/MailgunTransport.ts +6 -6
- package/src/transports/ResendTransport.ts +3 -3
- package/src/transports/SendGridTransport.ts +4 -4
- package/src/transports/SesTransport.ts +4 -4
- package/src/transports/SparkPostTransport.ts +3 -3
- 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
|
-
"
|
|
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("
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 `
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.`,
|
|
@@ -812,7 +812,7 @@ export class Mail {
|
|
|
812
812
|
const transport = this.#transports.get(name);
|
|
813
813
|
if (transport) return transport;
|
|
814
814
|
throw new RoverError(
|
|
815
|
-
"
|
|
815
|
+
"E_MAIL_UNKNOWN_MAILER",
|
|
816
816
|
`Mail mailer '${name}' is not declared in config.mailers`,
|
|
817
817
|
{ hint: knownMailers(this.#transports) },
|
|
818
818
|
);
|
|
@@ -911,7 +911,7 @@ function validateMailMessage(message: MailMessage): void {
|
|
|
911
911
|
// invalid per RFC 5321 reverse-path semantics.
|
|
912
912
|
if (typeof message.from !== "string" || message.from.trim() === "") {
|
|
913
913
|
throw new RoverError(
|
|
914
|
-
"
|
|
914
|
+
"E_MAIL_INVALID_MESSAGE",
|
|
915
915
|
"Mail message has no `from` address",
|
|
916
916
|
{
|
|
917
917
|
hint: "Set `config.from`, an instance `from`, or call `message.from(...)` in the builder.",
|
|
@@ -922,14 +922,14 @@ function validateMailMessage(message: MailMessage): void {
|
|
|
922
922
|
// `to` to be an array (cc/bcc are optional at the queue boundary), so a
|
|
923
923
|
// deserialised job payload may reach here with `cc`/`bcc` as `undefined`.
|
|
924
924
|
// `.some(...)` on `undefined` throws `TypeError` instead of the
|
|
925
|
-
// structured `
|
|
925
|
+
// structured `E_MAIL_INVALID_MESSAGE` this validator is supposed to surface.
|
|
926
926
|
const hasRecipient =
|
|
927
927
|
(Array.isArray(message.to) && message.to.some(isNonEmptyAddress)) ||
|
|
928
928
|
(Array.isArray(message.cc) && message.cc.some(isNonEmptyAddress)) ||
|
|
929
929
|
(Array.isArray(message.bcc) && message.bcc.some(isNonEmptyAddress));
|
|
930
930
|
if (!hasRecipient) {
|
|
931
931
|
throw new RoverError(
|
|
932
|
-
"
|
|
932
|
+
"E_MAIL_INVALID_MESSAGE",
|
|
933
933
|
"Mail message has no recipients",
|
|
934
934
|
{
|
|
935
935
|
hint: "Call `message.to(...)`, `cc(...)`, or `bcc(...)` with a non-empty address before sending.",
|
|
@@ -960,12 +960,12 @@ function errorDescriptor(
|
|
|
960
960
|
if (err instanceof Error) {
|
|
961
961
|
const errnoCode = (err as { code?: unknown }).code;
|
|
962
962
|
return {
|
|
963
|
-
code: typeof errnoCode === "string" ? errnoCode : "
|
|
963
|
+
code: typeof errnoCode === "string" ? errnoCode : "E_ROVER_UNKNOWN",
|
|
964
964
|
message: err.message,
|
|
965
965
|
attempts,
|
|
966
966
|
};
|
|
967
967
|
}
|
|
968
|
-
return { code: "
|
|
968
|
+
return { code: "E_ROVER_UNKNOWN", message: String(err), attempts };
|
|
969
969
|
}
|
|
970
970
|
|
|
971
971
|
/** "Declared mailers: a, b." — the half of an unknown-mailer error that helps. */
|
package/src/MessageBuilder.ts
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.",
|
package/src/queue/MailJob.ts
CHANGED
|
@@ -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 `
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 `
|
|
28
|
-
* - or it's a `
|
|
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 === "
|
|
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 === "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 `
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
201
|
+
// at render time the Rust renderer raises E_MAIL_TEMPLATE_NOT_FOUND.
|
|
202
202
|
if (
|
|
203
203
|
err instanceof RoverError &&
|
|
204
|
-
err.code === "
|
|
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
|
-
"
|
|
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
|
-
// `
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
159
|
+
"E_MAIL_TEMPLATE_SYNTAX",
|
|
160
160
|
`Native template call failed: ${err.message}`,
|
|
161
161
|
);
|
|
162
162
|
}
|
|
163
163
|
return new RoverError(
|
|
164
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 `
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 `
|
|
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
|
-
"
|
|
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 `
|
|
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
|
-
"
|
|
71
|
+
"E_MAIL_PROVIDER_ERROR",
|
|
72
72
|
`${provider} did not answer within ${timeoutMs}ms.`,
|
|
73
73
|
{
|
|
74
74
|
context: { provider, upstreamStatus: "0", networkCode: "ETIMEDOUT" },
|