@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.
- package/dist/Mail.d.ts.map +1 -1
- package/dist/Mail.js +21 -20
- 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.d.ts.map +1 -1
- package/dist/transports/MailgunTransport.js +64 -50
- 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.d.ts.map +1 -1
- package/dist/transports/SendGridTransport.js +83 -85
- 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 +2 -5
- package/src/Mail.ts +25 -19
- 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 +69 -61
- package/src/transports/ResendTransport.ts +3 -3
- package/src/transports/SendGridTransport.ts +89 -120
- 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.`,
|
|
@@ -735,11 +735,17 @@ export class Mail {
|
|
|
735
735
|
);
|
|
736
736
|
}
|
|
737
737
|
if (this.#emitter) {
|
|
738
|
-
try
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 `
|
|
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
|
-
"
|
|
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 : "
|
|
969
|
+
code: typeof errnoCode === "string" ? errnoCode : "E_ROVER_UNKNOWN",
|
|
964
970
|
message: err.message,
|
|
965
971
|
attempts,
|
|
966
972
|
};
|
|
967
973
|
}
|
|
968
|
-
return { code: "
|
|
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. */
|
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.",
|
|
@@ -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
|
-
#
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
*
|
|
163
|
-
*
|
|
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
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
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
|
|
180
|
-
//
|
|
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
|
-
"
|
|
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.",
|