@c9up/rover 0.1.3
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/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/BaseMail.d.ts +28 -0
- package/dist/BaseMail.d.ts.map +1 -0
- package/dist/BaseMail.js +42 -0
- package/dist/BaseMail.js.map +1 -0
- package/dist/Mail.d.ts +166 -0
- package/dist/Mail.d.ts.map +1 -0
- package/dist/Mail.js +451 -0
- package/dist/Mail.js.map +1 -0
- package/dist/MessageBuilder.d.ts +38 -0
- package/dist/MessageBuilder.d.ts.map +1 -0
- package/dist/MessageBuilder.js +70 -0
- package/dist/MessageBuilder.js.map +1 -0
- package/dist/RoverError.d.ts +30 -0
- package/dist/RoverError.d.ts.map +1 -0
- package/dist/RoverError.js +33 -0
- package/dist/RoverError.js.map +1 -0
- package/dist/RoverProvider.d.ts +24 -0
- package/dist/RoverProvider.d.ts.map +1 -0
- package/dist/RoverProvider.js +51 -0
- package/dist/RoverProvider.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/queue/MailJob.d.ts +41 -0
- package/dist/queue/MailJob.d.ts.map +1 -0
- package/dist/queue/MailJob.js +75 -0
- package/dist/queue/MailJob.js.map +1 -0
- package/dist/retry.d.ts +20 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +101 -0
- package/dist/retry.js.map +1 -0
- package/dist/services/main.d.ts +20 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +33 -0
- package/dist/services/main.js.map +1 -0
- package/dist/templating/SimpleTemplate.d.ts +7 -0
- package/dist/templating/SimpleTemplate.d.ts.map +1 -0
- package/dist/templating/SimpleTemplate.js +192 -0
- package/dist/templating/SimpleTemplate.js.map +1 -0
- package/dist/templating/loadNapi.d.ts +21 -0
- package/dist/templating/loadNapi.d.ts.map +1 -0
- package/dist/templating/loadNapi.js +115 -0
- package/dist/templating/loadNapi.js.map +1 -0
- package/dist/testing/FakeMail.d.ts +31 -0
- package/dist/testing/FakeMail.d.ts.map +1 -0
- package/dist/testing/FakeMail.js +90 -0
- package/dist/testing/FakeMail.js.map +1 -0
- package/dist/transports/MailgunTransport.d.ts +7 -0
- package/dist/transports/MailgunTransport.d.ts.map +1 -0
- package/dist/transports/MailgunTransport.js +149 -0
- package/dist/transports/MailgunTransport.js.map +1 -0
- package/dist/transports/ResendTransport.d.ts +7 -0
- package/dist/transports/ResendTransport.d.ts.map +1 -0
- package/dist/transports/ResendTransport.js +111 -0
- package/dist/transports/ResendTransport.js.map +1 -0
- package/dist/transports/SendGridTransport.d.ts +7 -0
- package/dist/transports/SendGridTransport.d.ts.map +1 -0
- package/dist/transports/SendGridTransport.js +165 -0
- package/dist/transports/SendGridTransport.js.map +1 -0
- package/dist/transports/SesTransport.d.ts +7 -0
- package/dist/transports/SesTransport.d.ts.map +1 -0
- package/dist/transports/SesTransport.js +334 -0
- package/dist/transports/SesTransport.js.map +1 -0
- package/dist/transports/fetchError.d.ts +13 -0
- package/dist/transports/fetchError.d.ts.map +1 -0
- package/dist/transports/fetchError.js +35 -0
- package/dist/transports/fetchError.js.map +1 -0
- package/dist/webhooks/context.d.ts +35 -0
- package/dist/webhooks/context.d.ts.map +1 -0
- package/dist/webhooks/context.js +7 -0
- package/dist/webhooks/context.js.map +1 -0
- package/dist/webhooks/mailgun.d.ts +13 -0
- package/dist/webhooks/mailgun.d.ts.map +1 -0
- package/dist/webhooks/mailgun.js +102 -0
- package/dist/webhooks/mailgun.js.map +1 -0
- package/dist/webhooks/resend.d.ts +16 -0
- package/dist/webhooks/resend.d.ts.map +1 -0
- package/dist/webhooks/resend.js +121 -0
- package/dist/webhooks/resend.js.map +1 -0
- package/dist/webhooks/sendgrid.d.ts +13 -0
- package/dist/webhooks/sendgrid.d.ts.map +1 -0
- package/dist/webhooks/sendgrid.js +104 -0
- package/dist/webhooks/sendgrid.js.map +1 -0
- 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 +97 -0
- package/scripts/copy-napi.mjs +62 -0
- package/src/BaseMail.ts +52 -0
- package/src/Mail.ts +663 -0
- package/src/MessageBuilder.ts +101 -0
- package/src/RoverError.ts +44 -0
- package/src/RoverProvider.ts +72 -0
- package/src/config.ts +7 -0
- package/src/index.ts +30 -0
- package/src/queue/MailJob.ts +131 -0
- package/src/retry.ts +135 -0
- package/src/services/main.ts +41 -0
- package/src/templating/SimpleTemplate.ts +240 -0
- package/src/templating/loadNapi.ts +169 -0
- package/src/testing/FakeMail.ts +112 -0
- package/src/transports/MailgunTransport.ts +204 -0
- package/src/transports/ResendTransport.ts +147 -0
- package/src/transports/SendGridTransport.ts +246 -0
- package/src/transports/SesTransport.ts +394 -0
- package/src/transports/fetchError.ts +42 -0
- package/src/webhooks/context.ts +41 -0
- package/src/webhooks/mailgun.ts +134 -0
- package/src/webhooks/resend.ts +164 -0
- package/src/webhooks/sendgrid.ts +133 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { RoverError } from "../RoverError.js";
|
|
2
|
+
import formData from "form-data";
|
|
3
|
+
// mailgun.js is UMD-bundled; the class lives on `.default` under NodeNext.
|
|
4
|
+
import MailgunModule from "mailgun.js";
|
|
5
|
+
import {
|
|
6
|
+
type MailMessage,
|
|
7
|
+
type MailSendOutcome,
|
|
8
|
+
type MailTransport,
|
|
9
|
+
registerTransport,
|
|
10
|
+
} from "../Mail.js";
|
|
11
|
+
|
|
12
|
+
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
13
|
+
const normalizeConfig = (v: string): string => stripCrlf(v).trim();
|
|
14
|
+
const MAX_PROVIDER_MESSAGE = 16 * 1024;
|
|
15
|
+
const capMessage = (s: string): string =>
|
|
16
|
+
s.length <= MAX_PROVIDER_MESSAGE
|
|
17
|
+
? s
|
|
18
|
+
: `${s.slice(0, MAX_PROVIDER_MESSAGE)}...[truncated]`;
|
|
19
|
+
|
|
20
|
+
const redactSecrets = (s: string): string =>
|
|
21
|
+
s
|
|
22
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
|
|
23
|
+
.replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
|
|
24
|
+
|
|
25
|
+
/** Minimal mailgun.js client surface — matches the subset we need. */
|
|
26
|
+
interface MailgunClientLike {
|
|
27
|
+
messages: {
|
|
28
|
+
create(
|
|
29
|
+
domain: string,
|
|
30
|
+
data: Record<string, unknown>,
|
|
31
|
+
): Promise<{ id?: string; message?: string; status?: number }>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class MailgunTransport implements MailTransport {
|
|
36
|
+
#client: MailgunClientLike;
|
|
37
|
+
#domain: string;
|
|
38
|
+
|
|
39
|
+
constructor(config: Record<string, unknown>) {
|
|
40
|
+
const apiKey =
|
|
41
|
+
typeof config.apiKey === "string" ? normalizeConfig(config.apiKey) : "";
|
|
42
|
+
const domain =
|
|
43
|
+
typeof config.domain === "string" ? normalizeConfig(config.domain) : "";
|
|
44
|
+
if (!apiKey || !domain) {
|
|
45
|
+
throw new RoverError(
|
|
46
|
+
"MAIL_PROVIDER_CONFIG",
|
|
47
|
+
"Mailgun transport requires apiKey and domain",
|
|
48
|
+
{ hint: "Set { apiKey, domain } in your mail config." },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
this.#domain = domain;
|
|
52
|
+
|
|
53
|
+
// Region: reject non-string when *defined* (wrong type signals a config
|
|
54
|
+
// bug — defaulting to "us" under those conditions is a compliance risk).
|
|
55
|
+
if (config.region !== undefined && typeof config.region !== "string") {
|
|
56
|
+
throw new RoverError(
|
|
57
|
+
"MAIL_PROVIDER_CONFIG",
|
|
58
|
+
`Mailgun region must be a string ("us" or "eu"), got ${typeof config.region}`,
|
|
59
|
+
{ hint: "Set config.region explicitly as 'us' or 'eu'." },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const rawRegion =
|
|
63
|
+
typeof config.region === "string"
|
|
64
|
+
? normalizeConfig(config.region).toLowerCase()
|
|
65
|
+
: "us";
|
|
66
|
+
if (rawRegion !== "us" && rawRegion !== "eu") {
|
|
67
|
+
throw new RoverError(
|
|
68
|
+
"MAIL_PROVIDER_CONFIG",
|
|
69
|
+
`Mailgun region must be "us" or "eu", got "${rawRegion}"`,
|
|
70
|
+
{
|
|
71
|
+
hint: "Use { region: 'us' } or { region: 'eu' }. A typo silently routing EU traffic to US infrastructure is a compliance risk.",
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const url =
|
|
76
|
+
rawRegion === "eu"
|
|
77
|
+
? "https://api.eu.mailgun.net"
|
|
78
|
+
: "https://api.mailgun.net";
|
|
79
|
+
|
|
80
|
+
// Dependency injection for tests: `_client` wins over real SDK. Guarded
|
|
81
|
+
// against non-object and non-shape inputs so a typo'd config can't
|
|
82
|
+
// silently bypass the real client.
|
|
83
|
+
const injected = config._client;
|
|
84
|
+
if (
|
|
85
|
+
injected &&
|
|
86
|
+
typeof injected === "object" &&
|
|
87
|
+
"messages" in (injected as object) &&
|
|
88
|
+
typeof (injected as MailgunClientLike).messages?.create === "function"
|
|
89
|
+
) {
|
|
90
|
+
this.#client = injected as MailgunClientLike;
|
|
91
|
+
} else {
|
|
92
|
+
// mailgun.js ships a UMD-style default export; the class constructor
|
|
93
|
+
// lives on `.default` in the typings (`static get default`).
|
|
94
|
+
const MailgunCtor = MailgunModule.default;
|
|
95
|
+
const mailgun = new MailgunCtor(formData);
|
|
96
|
+
this.#client = mailgun.client({ username: "api", key: apiKey, url });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async send(message: MailMessage): Promise<MailSendOutcome> {
|
|
101
|
+
if (
|
|
102
|
+
message.to.length === 0 &&
|
|
103
|
+
message.cc.length === 0 &&
|
|
104
|
+
message.bcc.length === 0
|
|
105
|
+
) {
|
|
106
|
+
throw new RoverError(
|
|
107
|
+
"MAIL_PROVIDER_CONFIG",
|
|
108
|
+
"Mail message has no recipients",
|
|
109
|
+
{ hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// CRLF stripping is applied at the wire boundary regardless of SDK
|
|
114
|
+
// promises — defence-in-depth. Anti-pattern from spec Dev Notes:
|
|
115
|
+
// "never skip CRLF sanitisation because the provider will handle it".
|
|
116
|
+
const data: Record<string, unknown> = {
|
|
117
|
+
from: stripCrlf(message.from),
|
|
118
|
+
to: message.to.map(stripCrlf),
|
|
119
|
+
subject: stripCrlf(message.subject),
|
|
120
|
+
};
|
|
121
|
+
if (message.cc.length) data.cc = message.cc.map(stripCrlf);
|
|
122
|
+
if (message.bcc.length) data.bcc = message.bcc.map(stripCrlf);
|
|
123
|
+
if (message.text) data.text = message.text;
|
|
124
|
+
if (message.html) data.html = message.html;
|
|
125
|
+
if (message.replyTo) data["h:Reply-To"] = stripCrlf(message.replyTo);
|
|
126
|
+
for (const [k, v] of Object.entries(message.headers)) {
|
|
127
|
+
data[`h:${stripCrlf(k)}`] = stripCrlf(v);
|
|
128
|
+
}
|
|
129
|
+
if (message.attachments.length > 0) {
|
|
130
|
+
data.attachment = message.attachments.map((att) => {
|
|
131
|
+
const entry: { filename: string; data: Buffer; contentType?: string } =
|
|
132
|
+
{
|
|
133
|
+
filename: stripCrlf(att.filename),
|
|
134
|
+
data: Buffer.from(att.content as Buffer | string),
|
|
135
|
+
};
|
|
136
|
+
if (att.contentType) {
|
|
137
|
+
entry.contentType = stripCrlf(att.contentType);
|
|
138
|
+
}
|
|
139
|
+
return entry;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const res = await this.#client.messages.create(this.#domain, data);
|
|
145
|
+
if (res.id) return { providerId: res.id };
|
|
146
|
+
return undefined;
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw wrapMailgunError(err);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* mailgun.js's own error shape is `{ status, details | message, ... }`.
|
|
155
|
+
* Map it to our uniform `MAIL_PROVIDER_ERROR` so retry + observability
|
|
156
|
+
* consumers don't need to branch on provider.
|
|
157
|
+
*
|
|
158
|
+
* Bare `Error` (no `status`) — typical for network/ECONNRESET failures from
|
|
159
|
+
* mailgun.js — surface the original errno (`code`) in context so the retry
|
|
160
|
+
* predicate can still classify it as transient.
|
|
161
|
+
*/
|
|
162
|
+
function wrapMailgunError(err: unknown): RoverError {
|
|
163
|
+
if (err instanceof RoverError) return err;
|
|
164
|
+
const anyErr = err as {
|
|
165
|
+
status?: unknown;
|
|
166
|
+
details?: string;
|
|
167
|
+
message?: string;
|
|
168
|
+
code?: string;
|
|
169
|
+
headers?: Record<string, string | string[]>;
|
|
170
|
+
};
|
|
171
|
+
// Coerce string-typed status ("401") into number — mailgun.js is
|
|
172
|
+
// inconsistent across versions.
|
|
173
|
+
const statusNum = Number(anyErr.status);
|
|
174
|
+
const status = Number.isFinite(statusNum) ? statusNum : 0;
|
|
175
|
+
const providerMessage = redactSecrets(
|
|
176
|
+
capMessage(anyErr.details ?? anyErr.message ?? "unknown"),
|
|
177
|
+
);
|
|
178
|
+
const ctx: Record<string, string> = {
|
|
179
|
+
provider: "mailgun",
|
|
180
|
+
upstreamStatus: String(status),
|
|
181
|
+
providerMessage,
|
|
182
|
+
};
|
|
183
|
+
if (typeof anyErr.code === "string") {
|
|
184
|
+
ctx.networkCode = anyErr.code;
|
|
185
|
+
}
|
|
186
|
+
// Retry-After may arrive either as a header (`retry-after`) or as a JSON
|
|
187
|
+
// body field — pass both upstream so `computeBackoffMs` can honour it.
|
|
188
|
+
const retryAfterHeader = anyErr.headers?.["retry-after"];
|
|
189
|
+
if (retryAfterHeader) {
|
|
190
|
+
ctx.retryAfter = Array.isArray(retryAfterHeader)
|
|
191
|
+
? (retryAfterHeader[0] ?? "")
|
|
192
|
+
: retryAfterHeader;
|
|
193
|
+
}
|
|
194
|
+
return new RoverError(
|
|
195
|
+
"MAIL_PROVIDER_ERROR",
|
|
196
|
+
`Mailgun returned ${status || "unknown"}`,
|
|
197
|
+
{
|
|
198
|
+
hint: "Inspect `context.upstreamStatus` (HTTP) or `context.networkCode` (ECONNRESET/etc.) to decide retry eligibility.",
|
|
199
|
+
context: ctx,
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
registerTransport("mailgun", (config) => new MailgunTransport(config));
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { RoverError } from "../RoverError.js";
|
|
3
|
+
import {
|
|
4
|
+
type MailMessage,
|
|
5
|
+
type MailSendOutcome,
|
|
6
|
+
type MailTransport,
|
|
7
|
+
registerTransport,
|
|
8
|
+
} from "../Mail.js";
|
|
9
|
+
import { wrapFetchNetworkError } from "./fetchError.js";
|
|
10
|
+
|
|
11
|
+
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
12
|
+
const normalizeConfig = (v: string): string => stripCrlf(v).trim();
|
|
13
|
+
const MAX_PROVIDER_MESSAGE = 16 * 1024;
|
|
14
|
+
const capMessage = (s: string): string =>
|
|
15
|
+
s.length <= MAX_PROVIDER_MESSAGE
|
|
16
|
+
? s
|
|
17
|
+
: `${s.slice(0, MAX_PROVIDER_MESSAGE)}...[truncated]`;
|
|
18
|
+
/** Redact Basic/Bearer tokens if the upstream echoes our own request headers. */
|
|
19
|
+
const redactSecrets = (s: string): string =>
|
|
20
|
+
s
|
|
21
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
|
|
22
|
+
.replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
|
|
23
|
+
|
|
24
|
+
interface ResendBody {
|
|
25
|
+
from: string;
|
|
26
|
+
to: string[];
|
|
27
|
+
cc?: string[];
|
|
28
|
+
bcc?: string[];
|
|
29
|
+
reply_to?: string;
|
|
30
|
+
subject: string;
|
|
31
|
+
html?: string;
|
|
32
|
+
text?: string;
|
|
33
|
+
attachments?: Array<{
|
|
34
|
+
filename: string;
|
|
35
|
+
content: string;
|
|
36
|
+
content_type?: string;
|
|
37
|
+
}>;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ResendTransport implements MailTransport {
|
|
42
|
+
#apiKey: string;
|
|
43
|
+
|
|
44
|
+
constructor(config: Record<string, unknown>) {
|
|
45
|
+
const apiKey =
|
|
46
|
+
typeof config.apiKey === "string" ? normalizeConfig(config.apiKey) : "";
|
|
47
|
+
if (!apiKey) {
|
|
48
|
+
throw new RoverError(
|
|
49
|
+
"MAIL_PROVIDER_CONFIG",
|
|
50
|
+
"Resend transport requires apiKey",
|
|
51
|
+
{ hint: "Set { apiKey } in your mail config." },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
this.#apiKey = apiKey;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async send(message: MailMessage): Promise<MailSendOutcome> {
|
|
58
|
+
if (
|
|
59
|
+
message.to.length === 0 &&
|
|
60
|
+
message.cc.length === 0 &&
|
|
61
|
+
message.bcc.length === 0
|
|
62
|
+
) {
|
|
63
|
+
throw new RoverError(
|
|
64
|
+
"MAIL_PROVIDER_CONFIG",
|
|
65
|
+
"Mail message has no recipients",
|
|
66
|
+
{ hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const body: ResendBody = {
|
|
70
|
+
from: stripCrlf(message.from),
|
|
71
|
+
to: message.to.map(stripCrlf),
|
|
72
|
+
subject: stripCrlf(message.subject),
|
|
73
|
+
};
|
|
74
|
+
if (message.cc.length) body.cc = message.cc.map(stripCrlf);
|
|
75
|
+
if (message.bcc.length) body.bcc = message.bcc.map(stripCrlf);
|
|
76
|
+
if (message.replyTo) body.reply_to = stripCrlf(message.replyTo);
|
|
77
|
+
if (message.html) body.html = message.html;
|
|
78
|
+
if (message.text) body.text = message.text;
|
|
79
|
+
if (message.attachments.length > 0) {
|
|
80
|
+
body.attachments = message.attachments.map((att) => {
|
|
81
|
+
const buf = Buffer.from(att.content as Buffer | string);
|
|
82
|
+
return {
|
|
83
|
+
filename: stripCrlf(att.filename),
|
|
84
|
+
content: buf.toString("base64"),
|
|
85
|
+
content_type: att.contentType
|
|
86
|
+
? stripCrlf(att.contentType)
|
|
87
|
+
: undefined,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const customHeaders = Object.entries(message.headers);
|
|
92
|
+
if (customHeaders.length > 0) {
|
|
93
|
+
body.headers = {};
|
|
94
|
+
for (const [k, v] of customHeaders) {
|
|
95
|
+
body.headers[stripCrlf(k)] = stripCrlf(v);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// fetch() rejections (DNS / TCP / TLS / socket reset) bypass the
|
|
100
|
+
// !res.ok branch entirely. Wrap them so retry classification can
|
|
101
|
+
// pick up the underlying errno (top-level `.code` for legacy
|
|
102
|
+
// shims, `.cause.code` for Node's built-in undici).
|
|
103
|
+
let res: Response;
|
|
104
|
+
try {
|
|
105
|
+
res = await fetch("https://api.resend.com/emails", {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: {
|
|
108
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw wrapFetchNetworkError("resend", err);
|
|
115
|
+
}
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
const providerMessage = redactSecrets(capMessage(await res.text()));
|
|
118
|
+
const retryAfter = res.headers.get("retry-after") ?? undefined;
|
|
119
|
+
const ctx: Record<string, string> = {
|
|
120
|
+
provider: "resend",
|
|
121
|
+
upstreamStatus: String(res.status),
|
|
122
|
+
providerMessage,
|
|
123
|
+
};
|
|
124
|
+
if (retryAfter) ctx.retryAfter = retryAfter;
|
|
125
|
+
throw new RoverError(
|
|
126
|
+
"MAIL_PROVIDER_ERROR",
|
|
127
|
+
`Resend returned ${res.status}`,
|
|
128
|
+
{
|
|
129
|
+
hint: "Inspect `context.upstreamStatus` to decide retry eligibility. `context.retryAfter` (when set) carries the provider's backoff hint in seconds.",
|
|
130
|
+
context: ctx,
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
// Success: Resend returns `{ id: "<uuid>" }` per API docs.
|
|
135
|
+
try {
|
|
136
|
+
const body = (await res.json()) as { id?: string };
|
|
137
|
+
if (typeof body.id === "string" && body.id.length > 0) {
|
|
138
|
+
return { providerId: body.id };
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// Empty body or non-JSON — fall back to generated id.
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
registerTransport("resend", (config) => new ResendTransport(config));
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { RoverError } from "../RoverError.js";
|
|
2
|
+
import sgMail, {
|
|
3
|
+
type MailDataRequired,
|
|
4
|
+
type MailService,
|
|
5
|
+
} from "@sendgrid/mail";
|
|
6
|
+
import {
|
|
7
|
+
type MailMessage,
|
|
8
|
+
type MailSendOutcome,
|
|
9
|
+
type MailTransport,
|
|
10
|
+
registerTransport,
|
|
11
|
+
} from "../Mail.js";
|
|
12
|
+
|
|
13
|
+
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
14
|
+
const normalizeConfig = (v: string): string => stripCrlf(v).trim();
|
|
15
|
+
const MAX_PROVIDER_MESSAGE = 16 * 1024;
|
|
16
|
+
const capMessage = (s: string): string =>
|
|
17
|
+
s.length <= MAX_PROVIDER_MESSAGE
|
|
18
|
+
? s
|
|
19
|
+
: `${s.slice(0, MAX_PROVIDER_MESSAGE)}...[truncated]`;
|
|
20
|
+
|
|
21
|
+
const redactSecrets = (s: string): string =>
|
|
22
|
+
s
|
|
23
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
|
|
24
|
+
.replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Minimal slice of the SendGrid client we depend on. A per-transport client
|
|
28
|
+
* instance is created in the constructor so concurrent transports with
|
|
29
|
+
* different API keys cannot race each other — the original module-level
|
|
30
|
+
* `sgMail.setApiKey()` would have been a multi-tenant foot-gun.
|
|
31
|
+
*/
|
|
32
|
+
interface SendGridClientLike {
|
|
33
|
+
setApiKey(apiKey: string): void;
|
|
34
|
+
send(
|
|
35
|
+
data: MailDataRequired,
|
|
36
|
+
): Promise<
|
|
37
|
+
[
|
|
38
|
+
{ statusCode: number; headers: Record<string, string | string[]> },
|
|
39
|
+
unknown,
|
|
40
|
+
]
|
|
41
|
+
>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class SendGridTransport implements MailTransport {
|
|
45
|
+
#client: SendGridClientLike;
|
|
46
|
+
|
|
47
|
+
constructor(config: Record<string, unknown>) {
|
|
48
|
+
const apiKey =
|
|
49
|
+
typeof config.apiKey === "string" ? normalizeConfig(config.apiKey) : "";
|
|
50
|
+
if (!apiKey) {
|
|
51
|
+
throw new RoverError(
|
|
52
|
+
"MAIL_PROVIDER_CONFIG",
|
|
53
|
+
"SendGrid transport requires apiKey",
|
|
54
|
+
{ hint: "Set { apiKey } in your mail config." },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Dependency injection for tests — stronger guard than the old version
|
|
59
|
+
// (require both `send` AND `setApiKey` to pass through).
|
|
60
|
+
const injected = config._client;
|
|
61
|
+
if (
|
|
62
|
+
injected &&
|
|
63
|
+
typeof injected === "object" &&
|
|
64
|
+
typeof (injected as SendGridClientLike).send === "function" &&
|
|
65
|
+
typeof (injected as SendGridClientLike).setApiKey === "function"
|
|
66
|
+
) {
|
|
67
|
+
this.#client = injected as SendGridClientLike;
|
|
68
|
+
} else {
|
|
69
|
+
// Per-instance MailService (not the shared `sgMail` singleton) so
|
|
70
|
+
// `setApiKey` can't race across multiple transports.
|
|
71
|
+
this.#client = new (resolveMailServiceCtor(sgMail))();
|
|
72
|
+
}
|
|
73
|
+
this.#client.setApiKey(apiKey);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async send(message: MailMessage): Promise<MailSendOutcome> {
|
|
77
|
+
assertHasRecipients(message);
|
|
78
|
+
const content = buildSendGridContent(message);
|
|
79
|
+
|
|
80
|
+
// CRLF stripping at the wire boundary (defence-in-depth, matches the
|
|
81
|
+
// Dev Notes anti-pattern: never trust the SDK to handle it).
|
|
82
|
+
const data: MailDataRequired = {
|
|
83
|
+
from: stripCrlf(message.from),
|
|
84
|
+
to: message.to.map(stripCrlf),
|
|
85
|
+
subject: stripCrlf(message.subject),
|
|
86
|
+
content,
|
|
87
|
+
...(message.cc.length ? { cc: message.cc.map(stripCrlf) } : {}),
|
|
88
|
+
...(message.bcc.length ? { bcc: message.bcc.map(stripCrlf) } : {}),
|
|
89
|
+
...(message.replyTo ? { replyTo: stripCrlf(message.replyTo) } : {}),
|
|
90
|
+
...(Object.keys(message.headers).length
|
|
91
|
+
? {
|
|
92
|
+
headers: Object.fromEntries(
|
|
93
|
+
Object.entries(message.headers).map(([k, v]) => [
|
|
94
|
+
stripCrlf(k),
|
|
95
|
+
stripCrlf(v),
|
|
96
|
+
]),
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
: {}),
|
|
100
|
+
...(message.attachments.length
|
|
101
|
+
? {
|
|
102
|
+
attachments: message.attachments.map((att) => {
|
|
103
|
+
const entry: {
|
|
104
|
+
filename: string;
|
|
105
|
+
content: string;
|
|
106
|
+
type?: string;
|
|
107
|
+
disposition: "attachment";
|
|
108
|
+
} = {
|
|
109
|
+
filename: stripCrlf(att.filename),
|
|
110
|
+
content: Buffer.from(att.content as Buffer | string).toString(
|
|
111
|
+
"base64",
|
|
112
|
+
),
|
|
113
|
+
disposition: "attachment" as const,
|
|
114
|
+
};
|
|
115
|
+
if (att.contentType) entry.type = stripCrlf(att.contentType);
|
|
116
|
+
return entry;
|
|
117
|
+
}),
|
|
118
|
+
}
|
|
119
|
+
: {}),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const result = await this.#client.send(data);
|
|
124
|
+
// Guard against non-standard SDK responses: `[]`, `[undefined]`, etc.
|
|
125
|
+
const response = Array.isArray(result) ? result[0] : undefined;
|
|
126
|
+
const msgId = response?.headers?.["x-message-id"];
|
|
127
|
+
const idStr = Array.isArray(msgId) ? msgId[0] : msgId;
|
|
128
|
+
if (idStr && typeof idStr === "string" && idStr.length > 0) {
|
|
129
|
+
return { providerId: idStr };
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
throw wrapSendGridError(err);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type SendGridContent = { type: string; value: string };
|
|
139
|
+
|
|
140
|
+
function assertHasRecipients(message: MailMessage): void {
|
|
141
|
+
if (
|
|
142
|
+
message.to.length === 0 &&
|
|
143
|
+
message.cc.length === 0 &&
|
|
144
|
+
message.bcc.length === 0
|
|
145
|
+
) {
|
|
146
|
+
throw new RoverError(
|
|
147
|
+
"MAIL_PROVIDER_CONFIG",
|
|
148
|
+
"Mail message has no recipients",
|
|
149
|
+
{ hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Build the SendGrid v3 `content[]`: text/plain when text is set, text/html when
|
|
156
|
+
* html is set, always at least one entry (SendGrid rejects empty content). The
|
|
157
|
+
* non-empty tuple type lets the SDK's MailDataRequired see `content[0]` exists.
|
|
158
|
+
*/
|
|
159
|
+
function buildSendGridContent(
|
|
160
|
+
message: MailMessage,
|
|
161
|
+
): [SendGridContent, ...SendGridContent[]] {
|
|
162
|
+
const first: SendGridContent = message.text
|
|
163
|
+
? { type: "text/plain", value: message.text }
|
|
164
|
+
: message.html
|
|
165
|
+
? { type: "text/html", value: message.html }
|
|
166
|
+
: { type: "text/plain", value: "" };
|
|
167
|
+
const rest: SendGridContent[] = [];
|
|
168
|
+
if (message.text && message.html) {
|
|
169
|
+
rest.push({ type: "text/html", value: message.html });
|
|
170
|
+
}
|
|
171
|
+
return [first, ...rest];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function wrapSendGridError(err: unknown): RoverError {
|
|
175
|
+
if (err instanceof RoverError) return err;
|
|
176
|
+
// @sendgrid/mail throws `{ code, message, response: { body, headers, statusCode } }`
|
|
177
|
+
const anyErr = err as {
|
|
178
|
+
code?: number | string;
|
|
179
|
+
message?: string;
|
|
180
|
+
response?: {
|
|
181
|
+
statusCode?: number;
|
|
182
|
+
body?: unknown;
|
|
183
|
+
headers?: Record<string, string>;
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
const statusFromResponse = anyErr.response?.statusCode;
|
|
187
|
+
const statusFromCode = Number(anyErr.code);
|
|
188
|
+
const status = Number.isFinite(statusFromResponse as number)
|
|
189
|
+
? (statusFromResponse as number)
|
|
190
|
+
: Number.isFinite(statusFromCode)
|
|
191
|
+
? statusFromCode
|
|
192
|
+
: 0;
|
|
193
|
+
const body = anyErr.response?.body;
|
|
194
|
+
const providerMessage = redactSecrets(
|
|
195
|
+
capMessage(
|
|
196
|
+
typeof body === "string"
|
|
197
|
+
? body
|
|
198
|
+
: body !== undefined
|
|
199
|
+
? JSON.stringify(body)
|
|
200
|
+
: (anyErr.message ?? "unknown"),
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
const ctx: Record<string, string> = {
|
|
204
|
+
provider: "sendgrid",
|
|
205
|
+
upstreamStatus: String(status),
|
|
206
|
+
providerMessage,
|
|
207
|
+
};
|
|
208
|
+
if (typeof anyErr.code === "string") {
|
|
209
|
+
ctx.networkCode = anyErr.code;
|
|
210
|
+
}
|
|
211
|
+
const retryAfter = anyErr.response?.headers?.["retry-after"];
|
|
212
|
+
if (retryAfter) ctx.retryAfter = retryAfter;
|
|
213
|
+
return new RoverError(
|
|
214
|
+
"MAIL_PROVIDER_ERROR",
|
|
215
|
+
`SendGrid returned ${status || "unknown"}`,
|
|
216
|
+
{
|
|
217
|
+
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.",
|
|
218
|
+
context: ctx,
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Type-guard resolver for the `MailService` constructor attached to the
|
|
225
|
+
* module's default export at runtime. The cerebrum forbids `as unknown as T`;
|
|
226
|
+
* here we receive `sgMail` through a parameter typed `unknown`, narrow with
|
|
227
|
+
* runtime `typeof` checks, and return a single cast to a precise callable
|
|
228
|
+
* type. No double-cast chain, no `as unknown` anchor.
|
|
229
|
+
*/
|
|
230
|
+
function resolveMailServiceCtor(mod: unknown): new () => MailService {
|
|
231
|
+
if (mod && typeof mod === "object" && "MailService" in mod) {
|
|
232
|
+
const candidate = (mod as { MailService: unknown }).MailService;
|
|
233
|
+
if (typeof candidate === "function") {
|
|
234
|
+
return candidate as new () => MailService;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
throw new RoverError(
|
|
238
|
+
"MAIL_PROVIDER_CONFIG",
|
|
239
|
+
"@sendgrid/mail runtime does not expose `.MailService` — upgrade to v8+",
|
|
240
|
+
{
|
|
241
|
+
hint: "Expected `module.exports.MailService` to be the MailService class (index.js attaches it).",
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
registerTransport("sendgrid", (config) => new SendGridTransport(config));
|