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