@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,394 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
3
|
+
import { RoverError } from "../RoverError.js";
|
|
4
|
+
import {
|
|
5
|
+
type MailAttachment,
|
|
6
|
+
type MailMessage,
|
|
7
|
+
type MailSendOutcome,
|
|
8
|
+
type MailTransport,
|
|
9
|
+
registerTransport,
|
|
10
|
+
} from "../Mail.js";
|
|
11
|
+
import { wrapFetchNetworkError } from "./fetchError.js";
|
|
12
|
+
|
|
13
|
+
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
14
|
+
const normalizeConfig = (v: string): string => stripCrlf(v).trim();
|
|
15
|
+
/** RFC 2183: quote `"` and `\` inside filename="..." / name="..." parameters. */
|
|
16
|
+
const mimeQuote = (v: string): string =>
|
|
17
|
+
stripCrlf(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
18
|
+
/**
|
|
19
|
+
* RFC 2047 encoded-word wrapping for non-ASCII header values.
|
|
20
|
+
* Returns the input as-is when it contains only ASCII; otherwise returns a
|
|
21
|
+
* base64 encoded-word (`=?UTF-8?B?...?=`). Safe for Subject, From, To display
|
|
22
|
+
* names, etc. — raw 8-bit in headers is non-conformant under RFC 5322.
|
|
23
|
+
*/
|
|
24
|
+
function encodeHeaderWord(v: string): string {
|
|
25
|
+
const stripped = stripCrlf(v);
|
|
26
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching non-ASCII explicitly (any byte > 0x7F is non-ASCII)
|
|
27
|
+
if (!/[^\x00-\x7F]/.test(stripped)) return stripped;
|
|
28
|
+
const b64 = Buffer.from(stripped, "utf8").toString("base64");
|
|
29
|
+
return `=?UTF-8?B?${b64}?=`;
|
|
30
|
+
}
|
|
31
|
+
const REGION_RE = /^[a-z0-9-]+$/;
|
|
32
|
+
const MAX_PROVIDER_MESSAGE = 16 * 1024;
|
|
33
|
+
const capMessage = (s: string): string =>
|
|
34
|
+
s.length <= MAX_PROVIDER_MESSAGE
|
|
35
|
+
? s
|
|
36
|
+
: `${s.slice(0, MAX_PROVIDER_MESSAGE)}...[truncated]`;
|
|
37
|
+
/** Redact Basic/Bearer tokens if the upstream echoes our own request headers. */
|
|
38
|
+
const redactSecrets = (s: string): string =>
|
|
39
|
+
s
|
|
40
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
|
|
41
|
+
.replace(
|
|
42
|
+
/AWS4-HMAC-SHA256\s+Credential=[^,]+/g,
|
|
43
|
+
"AWS4-HMAC-SHA256 [REDACTED]",
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export class SesTransport implements MailTransport {
|
|
47
|
+
#accessKeyId: string;
|
|
48
|
+
#secretAccessKey: string;
|
|
49
|
+
#region: string;
|
|
50
|
+
#host: string;
|
|
51
|
+
|
|
52
|
+
constructor(config: Record<string, unknown>) {
|
|
53
|
+
const accessKeyId =
|
|
54
|
+
typeof config.accessKeyId === "string"
|
|
55
|
+
? normalizeConfig(config.accessKeyId)
|
|
56
|
+
: "";
|
|
57
|
+
const secretAccessKey =
|
|
58
|
+
typeof config.secretAccessKey === "string"
|
|
59
|
+
? normalizeConfig(config.secretAccessKey)
|
|
60
|
+
: "";
|
|
61
|
+
const rawRegion =
|
|
62
|
+
typeof config.region === "string"
|
|
63
|
+
? normalizeConfig(config.region).toLowerCase()
|
|
64
|
+
: "";
|
|
65
|
+
if (!accessKeyId || !secretAccessKey || !rawRegion) {
|
|
66
|
+
throw new RoverError(
|
|
67
|
+
"MAIL_PROVIDER_CONFIG",
|
|
68
|
+
"SES transport requires accessKeyId, secretAccessKey, and region",
|
|
69
|
+
{ hint: "Set all three in your mail config." },
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!REGION_RE.test(rawRegion)) {
|
|
73
|
+
throw new RoverError(
|
|
74
|
+
"MAIL_PROVIDER_CONFIG",
|
|
75
|
+
`SES region "${rawRegion}" is not a valid AWS region identifier`,
|
|
76
|
+
{
|
|
77
|
+
hint: "Use the lowercase canonical form, e.g. 'us-east-1'. Uppercase breaks the SigV4 signing scope.",
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
this.#accessKeyId = accessKeyId;
|
|
82
|
+
this.#secretAccessKey = secretAccessKey;
|
|
83
|
+
this.#region = rawRegion;
|
|
84
|
+
this.#host = `email.${rawRegion}.amazonaws.com`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async send(message: MailMessage): Promise<MailSendOutcome> {
|
|
88
|
+
if (
|
|
89
|
+
message.to.length === 0 &&
|
|
90
|
+
message.cc.length === 0 &&
|
|
91
|
+
message.bcc.length === 0
|
|
92
|
+
) {
|
|
93
|
+
throw new RoverError(
|
|
94
|
+
"MAIL_PROVIDER_CONFIG",
|
|
95
|
+
"Mail message has no recipients",
|
|
96
|
+
{ hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Custom headers are only honoured on the SendRawEmail path (where we
|
|
101
|
+
// compose the MIME ourselves), so flip to raw whenever the message
|
|
102
|
+
// carries headers, even without attachments.
|
|
103
|
+
const useRaw =
|
|
104
|
+
message.attachments.length > 0 || Object.keys(message.headers).length > 0;
|
|
105
|
+
const form = useRaw
|
|
106
|
+
? buildRawEmailForm(message)
|
|
107
|
+
: buildSendEmailForm(message);
|
|
108
|
+
|
|
109
|
+
const url = `https://${this.#host}/`;
|
|
110
|
+
const headers = this.#signRequest("POST", form);
|
|
111
|
+
// Same network-error wrap as ResendTransport — see retry.ts for
|
|
112
|
+
// why undici's `cause.code` matters for retry classification.
|
|
113
|
+
let res: Response;
|
|
114
|
+
try {
|
|
115
|
+
res = await fetch(url, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers,
|
|
118
|
+
body: form,
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
throw wrapFetchNetworkError("ses", err);
|
|
122
|
+
}
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
const providerMessage = redactSecrets(capMessage(await res.text()));
|
|
125
|
+
const retryAfter = res.headers.get("retry-after") ?? undefined;
|
|
126
|
+
const ctx: Record<string, string> = {
|
|
127
|
+
provider: "ses",
|
|
128
|
+
upstreamStatus: String(res.status),
|
|
129
|
+
providerMessage,
|
|
130
|
+
};
|
|
131
|
+
if (retryAfter) ctx.retryAfter = retryAfter;
|
|
132
|
+
throw new RoverError("MAIL_PROVIDER_ERROR", `SES returned ${res.status}`, {
|
|
133
|
+
hint: "Inspect `context.upstreamStatus` to decide retry eligibility. `context.retryAfter` (when set) carries the provider's backoff hint in seconds.",
|
|
134
|
+
context: ctx,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// SES XML success shape: <SendEmailResponse><SendEmailResult><MessageId>...
|
|
138
|
+
// or <SendRawEmailResult><MessageId>... — extract via a scoped regex.
|
|
139
|
+
try {
|
|
140
|
+
const xml = await res.text();
|
|
141
|
+
const match = xml.match(/<MessageId>([^<]+)<\/MessageId>/);
|
|
142
|
+
if (match?.[1]) return { providerId: match[1] };
|
|
143
|
+
} catch {
|
|
144
|
+
// Network truncation on success response is rare; fall back to generated id.
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** SigV4 header signer, scoped to `ses`. Canonical request: POST / with form body. */
|
|
150
|
+
#signRequest(method: string, body: string): Record<string, string> {
|
|
151
|
+
const dateStamp = amzDateNow();
|
|
152
|
+
const shortDate = dateStamp.slice(0, 8);
|
|
153
|
+
const service = "ses";
|
|
154
|
+
const payloadHash = createHash("sha256").update(body).digest("hex");
|
|
155
|
+
|
|
156
|
+
const headers: Record<string, string> = {
|
|
157
|
+
host: this.#host,
|
|
158
|
+
"x-amz-date": dateStamp,
|
|
159
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
160
|
+
};
|
|
161
|
+
const sortedKeys = Object.keys(headers).sort();
|
|
162
|
+
const signedHeaders = sortedKeys.join(";");
|
|
163
|
+
const canonicalHeaders = sortedKeys
|
|
164
|
+
.map((k) => `${k}:${headers[k]}\n`)
|
|
165
|
+
.join("");
|
|
166
|
+
const canonicalRequest = [
|
|
167
|
+
method,
|
|
168
|
+
"/",
|
|
169
|
+
"",
|
|
170
|
+
canonicalHeaders,
|
|
171
|
+
signedHeaders,
|
|
172
|
+
payloadHash,
|
|
173
|
+
].join("\n");
|
|
174
|
+
|
|
175
|
+
const scope = `${shortDate}/${this.#region}/${service}/aws4_request`;
|
|
176
|
+
const stringToSign = [
|
|
177
|
+
"AWS4-HMAC-SHA256",
|
|
178
|
+
dateStamp,
|
|
179
|
+
scope,
|
|
180
|
+
createHash("sha256").update(canonicalRequest).digest("hex"),
|
|
181
|
+
].join("\n");
|
|
182
|
+
|
|
183
|
+
const signingKey = deriveSigningKey(
|
|
184
|
+
this.#secretAccessKey,
|
|
185
|
+
shortDate,
|
|
186
|
+
this.#region,
|
|
187
|
+
service,
|
|
188
|
+
);
|
|
189
|
+
const signature = createHmac("sha256", signingKey)
|
|
190
|
+
.update(stringToSign)
|
|
191
|
+
.digest("hex");
|
|
192
|
+
|
|
193
|
+
headers.authorization = `AWS4-HMAC-SHA256 Credential=${this.#accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
194
|
+
return headers;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildSendEmailForm(message: MailMessage): string {
|
|
199
|
+
const params: Array<[string, string]> = [];
|
|
200
|
+
params.push(["Action", "SendEmail"]);
|
|
201
|
+
params.push(["Source", stripCrlf(message.from)]);
|
|
202
|
+
message.to.forEach((addr, i) => {
|
|
203
|
+
params.push([`Destination.ToAddresses.member.${i + 1}`, stripCrlf(addr)]);
|
|
204
|
+
});
|
|
205
|
+
message.cc.forEach((addr, i) => {
|
|
206
|
+
params.push([`Destination.CcAddresses.member.${i + 1}`, stripCrlf(addr)]);
|
|
207
|
+
});
|
|
208
|
+
message.bcc.forEach((addr, i) => {
|
|
209
|
+
params.push([`Destination.BccAddresses.member.${i + 1}`, stripCrlf(addr)]);
|
|
210
|
+
});
|
|
211
|
+
if (message.replyTo) {
|
|
212
|
+
params.push(["ReplyToAddresses.member.1", stripCrlf(message.replyTo)]);
|
|
213
|
+
}
|
|
214
|
+
params.push(["Message.Subject.Data", stripCrlf(message.subject)]);
|
|
215
|
+
params.push(["Message.Subject.Charset", "UTF-8"]);
|
|
216
|
+
if (message.html) {
|
|
217
|
+
params.push(["Message.Body.Html.Data", message.html]);
|
|
218
|
+
params.push(["Message.Body.Html.Charset", "UTF-8"]);
|
|
219
|
+
}
|
|
220
|
+
if (message.text) {
|
|
221
|
+
params.push(["Message.Body.Text.Data", message.text]);
|
|
222
|
+
params.push(["Message.Body.Text.Charset", "UTF-8"]);
|
|
223
|
+
}
|
|
224
|
+
return params
|
|
225
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
226
|
+
.join("&");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function buildRawEmailForm(message: MailMessage): string {
|
|
230
|
+
const raw = buildRawMime(message);
|
|
231
|
+
const b64 = Buffer.from(raw).toString("base64");
|
|
232
|
+
// Targeted URL-escape: base64's alphabet is URL-safe *except* for `+` / `/`
|
|
233
|
+
// and the trailing `=` padding. Replacing only those three avoids walking
|
|
234
|
+
// the entire string character-by-character the way encodeURIComponent does.
|
|
235
|
+
const urlEncoded = b64.replace(/[+/=]/g, (c) =>
|
|
236
|
+
c === "+" ? "%2B" : c === "/" ? "%2F" : "%3D",
|
|
237
|
+
);
|
|
238
|
+
return `Action=SendRawEmail&RawMessage.Data=${urlEncoded}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildRawMime(message: MailMessage): string {
|
|
242
|
+
const parts: string[] = [];
|
|
243
|
+
parts.push(`From: ${encodeHeaderWord(message.from)}`);
|
|
244
|
+
parts.push(`To: ${message.to.map(encodeHeaderWord).join(", ")}`);
|
|
245
|
+
if (message.cc.length) {
|
|
246
|
+
parts.push(`Cc: ${message.cc.map(encodeHeaderWord).join(", ")}`);
|
|
247
|
+
}
|
|
248
|
+
if (message.replyTo)
|
|
249
|
+
parts.push(`Reply-To: ${encodeHeaderWord(message.replyTo)}`);
|
|
250
|
+
parts.push(`Subject: ${encodeHeaderWord(message.subject)}`);
|
|
251
|
+
parts.push("MIME-Version: 1.0");
|
|
252
|
+
const reserved = new Set([
|
|
253
|
+
"from",
|
|
254
|
+
"to",
|
|
255
|
+
"cc",
|
|
256
|
+
"bcc",
|
|
257
|
+
"subject",
|
|
258
|
+
"reply-to",
|
|
259
|
+
"mime-version",
|
|
260
|
+
"content-type",
|
|
261
|
+
"content-transfer-encoding",
|
|
262
|
+
"content-disposition",
|
|
263
|
+
]);
|
|
264
|
+
for (const [k, v] of Object.entries(message.headers)) {
|
|
265
|
+
if (reserved.has(k.toLowerCase())) continue;
|
|
266
|
+
parts.push(`${stripCrlf(k)}: ${encodeHeaderWord(v)}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const hasAttachments = message.attachments.length > 0;
|
|
270
|
+
const mixedBoundary = freshBoundary();
|
|
271
|
+
|
|
272
|
+
if (hasAttachments) {
|
|
273
|
+
parts.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
|
|
274
|
+
parts.push("");
|
|
275
|
+
parts.push(`--${mixedBoundary}`);
|
|
276
|
+
appendBodyBlock(parts, message);
|
|
277
|
+
for (const att of message.attachments) {
|
|
278
|
+
appendAttachmentPart(parts, mixedBoundary, att);
|
|
279
|
+
}
|
|
280
|
+
parts.push(`--${mixedBoundary}--`);
|
|
281
|
+
} else {
|
|
282
|
+
appendBodyHeadersInline(parts, message);
|
|
283
|
+
}
|
|
284
|
+
return parts.join("\r\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Emit the body block for the `multipart/mixed` first part. Handles three cases:
|
|
289
|
+
* - both html and text set → `multipart/alternative` wrapping both
|
|
290
|
+
* - html only → bare `text/html` part
|
|
291
|
+
* - text only → bare `text/plain` part
|
|
292
|
+
* - neither → bare `text/plain` empty part (keeps MIME valid when attachments only)
|
|
293
|
+
*/
|
|
294
|
+
function appendBodyBlock(parts: string[], message: MailMessage): void {
|
|
295
|
+
if (message.html && message.text) {
|
|
296
|
+
const altBoundary = freshBoundary();
|
|
297
|
+
parts.push(
|
|
298
|
+
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
|
|
299
|
+
);
|
|
300
|
+
parts.push("");
|
|
301
|
+
parts.push(`--${altBoundary}`);
|
|
302
|
+
parts.push("Content-Type: text/plain; charset=UTF-8");
|
|
303
|
+
parts.push("");
|
|
304
|
+
parts.push(message.text);
|
|
305
|
+
parts.push(`--${altBoundary}`);
|
|
306
|
+
parts.push("Content-Type: text/html; charset=UTF-8");
|
|
307
|
+
parts.push("");
|
|
308
|
+
parts.push(message.html);
|
|
309
|
+
parts.push(`--${altBoundary}--`);
|
|
310
|
+
} else if (message.html) {
|
|
311
|
+
parts.push("Content-Type: text/html; charset=UTF-8");
|
|
312
|
+
parts.push("");
|
|
313
|
+
parts.push(message.html);
|
|
314
|
+
} else if (message.text) {
|
|
315
|
+
parts.push("Content-Type: text/plain; charset=UTF-8");
|
|
316
|
+
parts.push("");
|
|
317
|
+
parts.push(message.text);
|
|
318
|
+
} else {
|
|
319
|
+
parts.push("Content-Type: text/plain; charset=UTF-8");
|
|
320
|
+
parts.push("");
|
|
321
|
+
parts.push("");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** No-attachment variant: pack body headers straight onto the outer message. */
|
|
326
|
+
function appendBodyHeadersInline(parts: string[], message: MailMessage): void {
|
|
327
|
+
if (message.html && message.text) {
|
|
328
|
+
const altBoundary = freshBoundary();
|
|
329
|
+
parts.push(
|
|
330
|
+
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
|
|
331
|
+
);
|
|
332
|
+
parts.push("");
|
|
333
|
+
parts.push(`--${altBoundary}`);
|
|
334
|
+
parts.push("Content-Type: text/plain; charset=UTF-8");
|
|
335
|
+
parts.push("");
|
|
336
|
+
parts.push(message.text);
|
|
337
|
+
parts.push(`--${altBoundary}`);
|
|
338
|
+
parts.push("Content-Type: text/html; charset=UTF-8");
|
|
339
|
+
parts.push("");
|
|
340
|
+
parts.push(message.html);
|
|
341
|
+
parts.push(`--${altBoundary}--`);
|
|
342
|
+
} else if (message.html) {
|
|
343
|
+
parts.push("Content-Type: text/html; charset=UTF-8");
|
|
344
|
+
parts.push("");
|
|
345
|
+
parts.push(message.html);
|
|
346
|
+
} else {
|
|
347
|
+
parts.push("Content-Type: text/plain; charset=UTF-8");
|
|
348
|
+
parts.push("");
|
|
349
|
+
parts.push(message.text ?? "");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function appendAttachmentPart(
|
|
354
|
+
parts: string[],
|
|
355
|
+
boundary: string,
|
|
356
|
+
att: MailAttachment,
|
|
357
|
+
): void {
|
|
358
|
+
parts.push(`--${boundary}`);
|
|
359
|
+
const contentType = stripCrlf(att.contentType ?? "application/octet-stream");
|
|
360
|
+
const quotedName = mimeQuote(att.filename);
|
|
361
|
+
parts.push(`Content-Type: ${contentType}; name="${quotedName}"`);
|
|
362
|
+
parts.push("Content-Transfer-Encoding: base64");
|
|
363
|
+
parts.push(`Content-Disposition: attachment; filename="${quotedName}"`);
|
|
364
|
+
parts.push("");
|
|
365
|
+
const buf = Buffer.from(att.content as Buffer | string);
|
|
366
|
+
const raw64 = buf.toString("base64");
|
|
367
|
+
for (let i = 0; i < raw64.length; i += 76) {
|
|
368
|
+
parts.push(raw64.slice(i, i + 76));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function freshBoundary(): string {
|
|
373
|
+
return `----ream_ses_${randomBytes(16).toString("hex")}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function amzDateNow(): string {
|
|
377
|
+
return `${new Date().toISOString().replace(/[-:]/g, "").split(".")[0]}Z`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function deriveSigningKey(
|
|
381
|
+
secret: string,
|
|
382
|
+
shortDate: string,
|
|
383
|
+
region: string,
|
|
384
|
+
service: string,
|
|
385
|
+
): Buffer {
|
|
386
|
+
const kDate = createHmac("sha256", `AWS4${secret}`)
|
|
387
|
+
.update(shortDate)
|
|
388
|
+
.digest();
|
|
389
|
+
const kRegion = createHmac("sha256", kDate).update(region).digest();
|
|
390
|
+
const kService = createHmac("sha256", kRegion).update(service).digest();
|
|
391
|
+
return createHmac("sha256", kService).update("aws4_request").digest();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
registerTransport("ses", (config) => new SesTransport(config));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { RoverError } from "../RoverError.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrap a `fetch()` rejection into the uniform `MAIL_PROVIDER_ERROR` shape
|
|
5
|
+
* so the retry classifier can recognise transient network failures
|
|
6
|
+
* (ECONNRESET / ENOTFOUND / EAI_AGAIN / ...).
|
|
7
|
+
*
|
|
8
|
+
* Node 18+ `fetch` (undici) rejects with `TypeError: fetch failed` and
|
|
9
|
+
* stashes the original errno on `cause.code` rather than top-level `.code`.
|
|
10
|
+
* Surface both shapes — older custom HTTP layers and SDK wrappers may set
|
|
11
|
+
* `code` directly.
|
|
12
|
+
*/
|
|
13
|
+
export function wrapFetchNetworkError(
|
|
14
|
+
provider: string,
|
|
15
|
+
err: unknown,
|
|
16
|
+
): RoverError {
|
|
17
|
+
if (err instanceof RoverError) return err;
|
|
18
|
+
const top = err as { code?: unknown; cause?: unknown; message?: unknown };
|
|
19
|
+
const cause = top.cause as { code?: unknown } | undefined;
|
|
20
|
+
const networkCode =
|
|
21
|
+
typeof top.code === "string"
|
|
22
|
+
? top.code
|
|
23
|
+
: typeof cause?.code === "string"
|
|
24
|
+
? cause.code
|
|
25
|
+
: undefined;
|
|
26
|
+
const message =
|
|
27
|
+
typeof top.message === "string" ? top.message : "fetch failed";
|
|
28
|
+
const ctx: Record<string, string> = {
|
|
29
|
+
provider,
|
|
30
|
+
upstreamStatus: "0",
|
|
31
|
+
providerMessage: message,
|
|
32
|
+
};
|
|
33
|
+
if (networkCode) ctx.networkCode = networkCode;
|
|
34
|
+
return new RoverError(
|
|
35
|
+
"MAIL_PROVIDER_ERROR",
|
|
36
|
+
`${provider} fetch failed${networkCode ? ` (${networkCode})` : ""}`,
|
|
37
|
+
{
|
|
38
|
+
hint: "Inspect `context.networkCode` (ECONNRESET / ENOTFOUND / ...) — the retry classifier treats known transient errnos as retryable.",
|
|
39
|
+
context: ctx,
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural HTTP context used by the webhook handlers. Mirrors the
|
|
3
|
+
* `SignedRouteHttpContext` pattern from `@c9up/archive/signed-route`
|
|
4
|
+
* so tests can build plain-object mocks without framework coupling.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface WebhookResponse {
|
|
8
|
+
status(code: number): WebhookResponse;
|
|
9
|
+
header(name: string, value: string): WebhookResponse;
|
|
10
|
+
json(data: unknown): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface WebhookHttpContext {
|
|
14
|
+
request: {
|
|
15
|
+
/**
|
|
16
|
+
* Header lookup. Adapters MUST be case-insensitive per RFC 7230.
|
|
17
|
+
* Providers send headers like `Svix-Id` or `X-Twilio-...` and the
|
|
18
|
+
* handler expects to read them via any case.
|
|
19
|
+
*/
|
|
20
|
+
header(name: string): string | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* Return the exact bytes the provider signed. This MUST be the
|
|
23
|
+
* raw request body before any parsing / re-serialisation — byte-
|
|
24
|
+
* for-byte stability is required for Ed25519 / HMAC verification.
|
|
25
|
+
* Frameworks that buffer must ensure the returned buffer is the
|
|
26
|
+
* original wire bytes, not a re-encoded copy.
|
|
27
|
+
*/
|
|
28
|
+
rawBody(): Promise<Buffer> | Buffer;
|
|
29
|
+
json?(): unknown;
|
|
30
|
+
};
|
|
31
|
+
response: WebhookResponse;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type WebhookMiddleware = (
|
|
35
|
+
ctx: WebhookHttpContext,
|
|
36
|
+
next: () => Promise<void>,
|
|
37
|
+
) => Promise<void>;
|
|
38
|
+
|
|
39
|
+
export interface WebhookEmitter {
|
|
40
|
+
emit(event: string, data: unknown): void;
|
|
41
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import type {
|
|
4
|
+
WebhookEmitter,
|
|
5
|
+
WebhookHttpContext,
|
|
6
|
+
WebhookMiddleware,
|
|
7
|
+
} from "./context.js";
|
|
8
|
+
|
|
9
|
+
export interface MailgunWebhookOptions {
|
|
10
|
+
signingKey: string;
|
|
11
|
+
emitter: WebhookEmitter;
|
|
12
|
+
/**
|
|
13
|
+
* Maximum age in seconds for a signed payload (default 300 = 5 minutes).
|
|
14
|
+
* Protects against replay of a legitimately-signed body captured elsewhere.
|
|
15
|
+
* Set to `Number.POSITIVE_INFINITY` to disable (not recommended).
|
|
16
|
+
*/
|
|
17
|
+
maxAgeSeconds?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface MailgunPayload {
|
|
21
|
+
signature?: { timestamp?: string; token?: string; signature?: string };
|
|
22
|
+
"event-data"?: {
|
|
23
|
+
event?: string;
|
|
24
|
+
recipient?: string;
|
|
25
|
+
reason?: string;
|
|
26
|
+
message?: { headers?: { "message-id"?: string } };
|
|
27
|
+
timestamp?: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const EVENT_MAP: Record<string, string> = {
|
|
32
|
+
delivered: "mail.delivered",
|
|
33
|
+
failed: "mail.failed",
|
|
34
|
+
bounced: "mail.bounced",
|
|
35
|
+
permanent_fail: "mail.bounced",
|
|
36
|
+
temporary_fail: "mail.failed",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function createMailgunWebhookHandler(
|
|
40
|
+
options: MailgunWebhookOptions,
|
|
41
|
+
): WebhookMiddleware {
|
|
42
|
+
if (!options.signingKey) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
"createMailgunWebhookHandler: signingKey is required for HMAC verification.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const signingKey = options.signingKey;
|
|
48
|
+
const emitter = options.emitter;
|
|
49
|
+
const maxAgeSeconds = options.maxAgeSeconds ?? 300;
|
|
50
|
+
|
|
51
|
+
return async (ctx: WebhookHttpContext, _next): Promise<void> => {
|
|
52
|
+
const raw = await Promise.resolve(ctx.request.rawBody());
|
|
53
|
+
let payload: MailgunPayload;
|
|
54
|
+
try {
|
|
55
|
+
payload = JSON.parse(raw.toString("utf8")) as MailgunPayload;
|
|
56
|
+
} catch {
|
|
57
|
+
// Collapse parse-failure and signature-failure to the same 401 so
|
|
58
|
+
// the response doesn't leak which branch rejected.
|
|
59
|
+
ctx.response.status(401).json({ error: "invalid_signature" });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const sig = payload.signature;
|
|
63
|
+
if (!sig?.timestamp || !sig?.token || !sig?.signature) {
|
|
64
|
+
ctx.response.status(401).json({ error: "invalid_signature" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Enforce a replay-window BEFORE touching the HMAC so a very old
|
|
69
|
+
// valid signature cannot replay forever.
|
|
70
|
+
const tsSeconds = Number(sig.timestamp);
|
|
71
|
+
if (!Number.isFinite(tsSeconds)) {
|
|
72
|
+
ctx.response.status(401).json({ error: "invalid_signature" });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const nowSeconds = Date.now() / 1000;
|
|
76
|
+
if (Math.abs(nowSeconds - tsSeconds) > maxAgeSeconds) {
|
|
77
|
+
ctx.response.status(401).json({ error: "stale_signature" });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const expected = createHmac("sha256", signingKey)
|
|
82
|
+
.update(`${sig.timestamp}${sig.token}`)
|
|
83
|
+
.digest("hex");
|
|
84
|
+
if (!constantTimeEqualHex(expected, sig.signature)) {
|
|
85
|
+
ctx.response.status(401).json({ error: "invalid_signature" });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const data = payload["event-data"];
|
|
90
|
+
if (!data?.event) {
|
|
91
|
+
ctx.response.status(400).json({ error: "missing_event" });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const eventName = EVENT_MAP[data.event];
|
|
95
|
+
if (eventName) {
|
|
96
|
+
try {
|
|
97
|
+
emitter.emit(eventName, {
|
|
98
|
+
messageId: data.message?.headers?.["message-id"] ?? "",
|
|
99
|
+
to: data.recipient ?? "",
|
|
100
|
+
reason: data.reason,
|
|
101
|
+
// Some Mailgun event types report `timestamp` already in
|
|
102
|
+
// milliseconds; detect magnitude rather than blindly *1000.
|
|
103
|
+
timestamp: normaliseTimestamp(data.timestamp),
|
|
104
|
+
});
|
|
105
|
+
} catch {
|
|
106
|
+
// Emitter failure must not fail the webhook response — providers
|
|
107
|
+
// will retry on non-2xx and flood the bus.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
ctx.response.status(200).json({ ok: true });
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function constantTimeEqualHex(a: string, b: string): boolean {
|
|
115
|
+
if (a.length !== b.length) return false;
|
|
116
|
+
const bufA = Buffer.from(a, "hex");
|
|
117
|
+
const bufB = Buffer.from(b, "hex");
|
|
118
|
+
if (bufA.length !== bufB.length) return false;
|
|
119
|
+
return timingSafeEqual(bufA, bufB);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Producers sometimes send seconds-since-epoch, sometimes ms. Values larger
|
|
124
|
+
* than 1e12 are ms (post-2001 in ms); anything smaller is treated as seconds.
|
|
125
|
+
*/
|
|
126
|
+
function normaliseTimestamp(ts: number | undefined): number {
|
|
127
|
+
// Guard non-finite values (NaN / ±Infinity): a malformed `timestamp`
|
|
128
|
+
// field in the webhook payload would otherwise multiply into NaN and
|
|
129
|
+
// leak into the emitted mail.* event, breaking consumers that sort
|
|
130
|
+
// / persist / compare timestamps numerically.
|
|
131
|
+
if (ts === undefined || !Number.isFinite(ts)) return Date.now();
|
|
132
|
+
if (ts > 1e12) return ts;
|
|
133
|
+
return ts * 1000;
|
|
134
|
+
}
|