@c9up/rover 0.1.8 → 0.1.10
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/README.md +1 -1
- package/dist/BaseMail.d.ts +41 -1
- package/dist/BaseMail.d.ts.map +1 -1
- package/dist/BaseMail.js +48 -1
- package/dist/BaseMail.js.map +1 -1
- package/dist/Mail.d.ts +90 -24
- package/dist/Mail.d.ts.map +1 -1
- package/dist/Mail.js +123 -40
- package/dist/Mail.js.map +1 -1
- package/dist/MessageBuilder.d.ts +296 -8
- package/dist/MessageBuilder.d.ts.map +1 -1
- package/dist/MessageBuilder.js +537 -8
- package/dist/MessageBuilder.js.map +1 -1
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +34 -0
- package/dist/config.js.map +1 -1
- package/dist/format.d.ts +10 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +28 -1
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +19 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -1
- package/dist/index.js.map +1 -1
- package/dist/testing/FakeMail.d.ts +33 -0
- package/dist/testing/FakeMail.d.ts.map +1 -1
- package/dist/testing/FakeMail.js +28 -0
- package/dist/testing/FakeMail.js.map +1 -1
- package/dist/transports/BrevoTransport.d.ts.map +1 -1
- package/dist/transports/BrevoTransport.js +7 -5
- package/dist/transports/BrevoTransport.js.map +1 -1
- package/dist/transports/MailgunTransport.d.ts.map +1 -1
- package/dist/transports/MailgunTransport.js +5 -3
- package/dist/transports/MailgunTransport.js.map +1 -1
- package/dist/transports/ResendTransport.d.ts.map +1 -1
- package/dist/transports/ResendTransport.js +7 -5
- package/dist/transports/ResendTransport.js.map +1 -1
- package/dist/transports/SendGridTransport.d.ts.map +1 -1
- package/dist/transports/SendGridTransport.js +10 -6
- package/dist/transports/SendGridTransport.js.map +1 -1
- package/dist/transports/SesTransport.d.ts.map +1 -1
- package/dist/transports/SesTransport.js +16 -7
- package/dist/transports/SesTransport.js.map +1 -1
- package/dist/transports/SparkPostTransport.d.ts.map +1 -1
- package/dist/transports/SparkPostTransport.js +7 -5
- package/dist/transports/SparkPostTransport.js.map +1 -1
- package/dist/transports/fetchError.d.ts +10 -0
- package/dist/transports/fetchError.d.ts.map +1 -1
- package/dist/transports/fetchError.js +30 -0
- 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 +6 -1
- package/src/BaseMail.ts +59 -2
- package/src/Mail.ts +194 -65
- package/src/MessageBuilder.ts +756 -12
- package/src/config.ts +46 -0
- package/src/format.ts +33 -1
- package/src/index.ts +31 -2
- package/src/testing/FakeMail.ts +46 -0
- package/src/transports/BrevoTransport.ts +7 -5
- package/src/transports/MailgunTransport.ts +5 -3
- package/src/transports/ResendTransport.ts +7 -5
- package/src/transports/SendGridTransport.ts +10 -6
- package/src/transports/SesTransport.ts +16 -7
- package/src/transports/SparkPostTransport.ts +17 -11
- package/src/transports/fetchError.ts +39 -0
package/src/config.ts
CHANGED
|
@@ -1,7 +1,53 @@
|
|
|
1
1
|
import type { MailConfig } from "./Mail.js";
|
|
2
|
+
import type { RetryConfig } from "./retry.js";
|
|
2
3
|
|
|
3
4
|
export function defineConfig(config: MailConfig): MailConfig {
|
|
4
5
|
return config;
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
/** One mailer's settings — the transport name plus whatever it reads. */
|
|
9
|
+
export interface TransportDescriptor {
|
|
10
|
+
transport: string;
|
|
11
|
+
retry?: RetryConfig;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Options = Record<string, unknown> & { retry?: RetryConfig };
|
|
16
|
+
|
|
17
|
+
function describe(transport: string) {
|
|
18
|
+
return (config: Options = {}): TransportDescriptor => ({
|
|
19
|
+
...config,
|
|
20
|
+
transport,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mailer descriptors for `defineConfig`, matching the AdonisJS call site:
|
|
26
|
+
*
|
|
27
|
+
* defineConfig({
|
|
28
|
+
* default: 'smtp',
|
|
29
|
+
* from: 'noreply@acme.com',
|
|
30
|
+
* mailers: { smtp: transports.smtp({ host: env.get('SMTP_HOST') }) },
|
|
31
|
+
* })
|
|
32
|
+
*
|
|
33
|
+
* Named deviation: upstream returns a config PROVIDER that lazily imports the
|
|
34
|
+
* transport. Rover returns the plain descriptor its config can persist, and the
|
|
35
|
+
* transport registers itself on import.
|
|
36
|
+
*
|
|
37
|
+
* Postmark has no helper here on purpose: rover has no Postmark transport, so a
|
|
38
|
+
* migrated config naming it fails to COMPILE — which says so plainly — instead
|
|
39
|
+
* of throwing at boot.
|
|
40
|
+
*/
|
|
41
|
+
export const transports = {
|
|
42
|
+
smtp: describe("smtp"),
|
|
43
|
+
ses: describe("ses"),
|
|
44
|
+
mailgun: describe("mailgun"),
|
|
45
|
+
sparkpost: describe("sparkpost"),
|
|
46
|
+
resend: describe("resend"),
|
|
47
|
+
brevo: describe("brevo"),
|
|
48
|
+
sendgrid: describe("sendgrid"),
|
|
49
|
+
/** Writes to the logger instead of sending — the local-development mailer. */
|
|
50
|
+
log: describe("log"),
|
|
51
|
+
};
|
|
52
|
+
|
|
7
53
|
export type { MailConfig };
|
package/src/format.ts
CHANGED
|
@@ -4,11 +4,43 @@
|
|
|
4
4
|
* the BaseMail ↔ MessageBuilder value cycle.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { RoverError } from "./RoverError.js";
|
|
8
|
+
|
|
9
|
+
/** CR, LF and NUL — the characters that end a header line or a C string. */
|
|
10
|
+
const HEADER_BREAKERS = /[\r\n\0]/;
|
|
11
|
+
|
|
7
12
|
/**
|
|
8
13
|
* Format a recipient address with an optional display name into the
|
|
9
14
|
* `"Name" <address>` form (RFC 5322 quoted display name). A bare address —
|
|
10
15
|
* or an empty/whitespace-only name — is returned unchanged.
|
|
16
|
+
*
|
|
17
|
+
* The display name is a QUOTED string, so a `"` inside it would close the quote
|
|
18
|
+
* early: a name of `x" <evil@example.com>, "y` would otherwise forge a second
|
|
19
|
+
* recipient. Backslash and quote are escaped, which is what a quoted-string
|
|
20
|
+
* allows.
|
|
21
|
+
*
|
|
22
|
+
* A line break cannot be escaped in a header — it ENDS the header — so a value
|
|
23
|
+
* carrying CR, LF or NUL is rejected rather than mangled. Accepting it is how a
|
|
24
|
+
* contact form turns into an open relay: one `\r\nBcc:` and the message goes
|
|
25
|
+
* wherever the attacker asked.
|
|
11
26
|
*/
|
|
12
27
|
export function formatAddress(address: string, name?: string): string {
|
|
13
|
-
|
|
28
|
+
assertHeaderSafe(address, "address");
|
|
29
|
+
if (name === undefined || name === "") return address;
|
|
30
|
+
assertHeaderSafe(name, "name");
|
|
31
|
+
// Escape the escape character first, or a trailing backslash would swallow
|
|
32
|
+
// the closing quote.
|
|
33
|
+
const quoted = name.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
34
|
+
return `"${quoted}" <${address}>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertHeaderSafe(value: string, what: string): void {
|
|
38
|
+
if (!HEADER_BREAKERS.test(value)) return;
|
|
39
|
+
throw new RoverError(
|
|
40
|
+
"MAIL_HEADER_INJECTION",
|
|
41
|
+
`The ${what} contains a line break or NUL, which cannot appear in a mail header.`,
|
|
42
|
+
{
|
|
43
|
+
hint: "Strip CR/LF/NUL from user-supplied names and addresses before building the message.",
|
|
44
|
+
},
|
|
45
|
+
);
|
|
14
46
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every bundled transport is imported for its REGISTRATION side effect.
|
|
3
|
+
*
|
|
4
|
+
* Each module calls `registerTransport(name, factory)` when it loads, so a
|
|
5
|
+
* config naming `ses` or `mailgun` only worked if the app had imported that
|
|
6
|
+
* module itself — which nothing told it to do. `defineConfig` offers a helper
|
|
7
|
+
* for each of them, so each has to be there when the config is read.
|
|
8
|
+
*/
|
|
9
|
+
|
|
1
10
|
export type { MailAddress } from "./BaseMail.js";
|
|
2
11
|
export { BaseMail } from "./BaseMail.js";
|
|
3
|
-
export {
|
|
12
|
+
export type { TransportDescriptor } from "./config.js";
|
|
13
|
+
export { defineConfig, transports } from "./config.js";
|
|
4
14
|
export type {
|
|
5
15
|
EmitterLike,
|
|
6
16
|
MailAttachment,
|
|
7
17
|
MailConfig,
|
|
18
|
+
MailEventBase,
|
|
8
19
|
MailFailedEvent,
|
|
9
20
|
MailHooks,
|
|
10
21
|
MailMessage,
|
|
@@ -15,6 +26,7 @@ export type {
|
|
|
15
26
|
MailSentEvent,
|
|
16
27
|
MailTransport,
|
|
17
28
|
MailTransportFactory,
|
|
29
|
+
MessageBodyTemplates,
|
|
18
30
|
} from "./Mail.js";
|
|
19
31
|
export {
|
|
20
32
|
LogTransport,
|
|
@@ -24,7 +36,18 @@ export {
|
|
|
24
36
|
registerTransport,
|
|
25
37
|
SmtpTransport,
|
|
26
38
|
} from "./Mail.js";
|
|
27
|
-
export type {
|
|
39
|
+
export type {
|
|
40
|
+
AttachmentOptions,
|
|
41
|
+
CalendarEvent,
|
|
42
|
+
CalendarEventMethod,
|
|
43
|
+
CalendarEventOptions,
|
|
44
|
+
ListHeader,
|
|
45
|
+
MailEnvelope,
|
|
46
|
+
PreparedHeader,
|
|
47
|
+
Recipient,
|
|
48
|
+
RecipientObject,
|
|
49
|
+
} from "./MessageBuilder.js";
|
|
50
|
+
export { attachmentsFor, headerValue } from "./MessageBuilder.js";
|
|
28
51
|
export { RoverError } from "./RoverError.js";
|
|
29
52
|
export { default as RoverProvider } from "./RoverProvider.js";
|
|
30
53
|
export {
|
|
@@ -32,3 +55,9 @@ export {
|
|
|
32
55
|
isRetryableError,
|
|
33
56
|
type RetryConfig,
|
|
34
57
|
} from "./retry.js";
|
|
58
|
+
export { BrevoTransport } from "./transports/BrevoTransport.js";
|
|
59
|
+
export { MailgunTransport } from "./transports/MailgunTransport.js";
|
|
60
|
+
export { ResendTransport } from "./transports/ResendTransport.js";
|
|
61
|
+
export { SendGridTransport } from "./transports/SendGridTransport.js";
|
|
62
|
+
export { SesTransport } from "./transports/SesTransport.js";
|
|
63
|
+
export { SparkPostTransport } from "./transports/SparkPostTransport.js";
|
package/src/testing/FakeMail.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Plugin } from "@c9up/helix";
|
|
1
2
|
import { BaseMail } from "../BaseMail.js";
|
|
2
3
|
import type { MailMessage, MailSendOutcome, MailTransport } from "../Mail.js";
|
|
3
4
|
|
|
@@ -311,3 +312,48 @@ function describeCaptured(captured: Capture[]): string {
|
|
|
311
312
|
);
|
|
312
313
|
return `Captured (${captured.length}):\n${lines.join("\n")}`;
|
|
313
314
|
}
|
|
315
|
+
|
|
316
|
+
/** A mailer that can enter/exit fake mode — structurally the rover `Mail` manager. */
|
|
317
|
+
export interface FakeableMailer {
|
|
318
|
+
/** Activate fake mode and return the capturing {@link FakeMail}. */
|
|
319
|
+
fake(): FakeMail;
|
|
320
|
+
/** Exit fake mode. */
|
|
321
|
+
restore(): void;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* `mailFake()` — a helix plugin (AdonisJS `mail.fake()` parity) that injects a
|
|
326
|
+
* fresh {@link FakeMail} on the test context as `ctx.mail`, PER TEST, and
|
|
327
|
+
* auto-restores the mailer afterwards via `ctx.cleanup`:
|
|
328
|
+
*
|
|
329
|
+
* // tests/bootstrap.ts
|
|
330
|
+
* import { configure } from "@c9up/helix";
|
|
331
|
+
* import { mailFake } from "@c9up/rover/testing";
|
|
332
|
+
* await configure({ plugins: [mailFake(mailer)] });
|
|
333
|
+
*
|
|
334
|
+
* test("sends welcome", async ({ mail }) => {
|
|
335
|
+
* await sendWelcome();
|
|
336
|
+
* mail.assertSent((m) => m.hasTo("user@example.com"));
|
|
337
|
+
* });
|
|
338
|
+
*
|
|
339
|
+
* The fake is activated lazily on first `ctx.mail` access (inside the test
|
|
340
|
+
* frame) and torn down when that test ends — so capture never leaks between
|
|
341
|
+
* tests. Uses the manager's own `fake()`/`restore()` (the Adonis pattern).
|
|
342
|
+
*/
|
|
343
|
+
export function mailFake(mailer: FakeableMailer): Plugin {
|
|
344
|
+
return (api) => {
|
|
345
|
+
api.context.getter("mail", (ctx) => {
|
|
346
|
+
const fake = mailer.fake();
|
|
347
|
+
ctx.cleanup(() => mailer.restore());
|
|
348
|
+
return fake;
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Typing side of the plugin — importing `@c9up/rover/testing` augments the
|
|
354
|
+
// helix test context with `mail` (the Japa pattern).
|
|
355
|
+
declare module "@c9up/helix" {
|
|
356
|
+
interface TestContext {
|
|
357
|
+
mail: FakeMail;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -5,8 +5,9 @@ import {
|
|
|
5
5
|
type MailTransport,
|
|
6
6
|
registerTransport,
|
|
7
7
|
} from "../Mail.js";
|
|
8
|
+
import { attachmentsFor, headerValue } from "../MessageBuilder.js";
|
|
8
9
|
import { RoverError } from "../RoverError.js";
|
|
9
|
-
import { wrapFetchNetworkError } from "./fetchError.js";
|
|
10
|
+
import { fetchWithTimeout, wrapFetchNetworkError } from "./fetchError.js";
|
|
10
11
|
|
|
11
12
|
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
12
13
|
const normalizeConfig = (v: string): string => stripCrlf(v).trim();
|
|
@@ -98,14 +99,15 @@ export class BrevoTransport implements MailTransport {
|
|
|
98
99
|
const customHeaders = Object.entries(message.headers);
|
|
99
100
|
if (customHeaders.length > 0) {
|
|
100
101
|
body.headers = {};
|
|
101
|
-
for (const [k,
|
|
102
|
+
for (const [k, raw] of customHeaders) {
|
|
103
|
+
const v = headerValue(raw);
|
|
102
104
|
body.headers[stripCrlf(k)] = Array.isArray(v)
|
|
103
105
|
? v.map(stripCrlf).join(", ")
|
|
104
106
|
: stripCrlf(v);
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
|
-
if (message.
|
|
108
|
-
body.attachment = message.
|
|
109
|
+
if (attachmentsFor(message).length > 0) {
|
|
110
|
+
body.attachment = attachmentsFor(message).map((att) => ({
|
|
109
111
|
name: stripCrlf(att.filename),
|
|
110
112
|
content: Buffer.from(att.content as Buffer | string).toString("base64"),
|
|
111
113
|
}));
|
|
@@ -113,7 +115,7 @@ export class BrevoTransport implements MailTransport {
|
|
|
113
115
|
|
|
114
116
|
let res: Response;
|
|
115
117
|
try {
|
|
116
|
-
res = await
|
|
118
|
+
res = await fetchWithTimeout("Brevo", `${this.#baseUrl}/v3/smtp/email`, {
|
|
117
119
|
method: "POST",
|
|
118
120
|
headers: {
|
|
119
121
|
"api-key": this.#apiKey,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type MailTransport,
|
|
8
8
|
registerTransport,
|
|
9
9
|
} from "../Mail.js";
|
|
10
|
+
import { attachmentsFor, headerValue } from "../MessageBuilder.js";
|
|
10
11
|
import { RoverError } from "../RoverError.js";
|
|
11
12
|
|
|
12
13
|
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
@@ -127,13 +128,14 @@ export class MailgunTransport implements MailTransport {
|
|
|
127
128
|
if (message.text) data.text = message.text;
|
|
128
129
|
if (message.html) data.html = message.html;
|
|
129
130
|
if (message.replyTo) data["h:Reply-To"] = stripCrlf(message.replyTo);
|
|
130
|
-
for (const [k,
|
|
131
|
+
for (const [k, raw] of Object.entries(message.headers)) {
|
|
132
|
+
const v = headerValue(raw);
|
|
131
133
|
data[`h:${stripCrlf(k)}`] = Array.isArray(v)
|
|
132
134
|
? v.map(stripCrlf).join(", ")
|
|
133
135
|
: stripCrlf(v);
|
|
134
136
|
}
|
|
135
|
-
if (message.
|
|
136
|
-
data.attachment = message.
|
|
137
|
+
if (attachmentsFor(message).length > 0) {
|
|
138
|
+
data.attachment = attachmentsFor(message).map((att) => {
|
|
137
139
|
const entry: { filename: string; data: Buffer; contentType?: string } =
|
|
138
140
|
{
|
|
139
141
|
filename: stripCrlf(att.filename),
|
|
@@ -5,8 +5,9 @@ import {
|
|
|
5
5
|
type MailTransport,
|
|
6
6
|
registerTransport,
|
|
7
7
|
} from "../Mail.js";
|
|
8
|
+
import { attachmentsFor, headerValue } from "../MessageBuilder.js";
|
|
8
9
|
import { RoverError } from "../RoverError.js";
|
|
9
|
-
import { wrapFetchNetworkError } from "./fetchError.js";
|
|
10
|
+
import { fetchWithTimeout, wrapFetchNetworkError } from "./fetchError.js";
|
|
10
11
|
|
|
11
12
|
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
12
13
|
const normalizeConfig = (v: string): string => stripCrlf(v).trim();
|
|
@@ -76,8 +77,8 @@ export class ResendTransport implements MailTransport {
|
|
|
76
77
|
if (message.replyTo) body.reply_to = stripCrlf(message.replyTo);
|
|
77
78
|
if (message.html) body.html = message.html;
|
|
78
79
|
if (message.text) body.text = message.text;
|
|
79
|
-
if (message.
|
|
80
|
-
body.attachments = message.
|
|
80
|
+
if (attachmentsFor(message).length > 0) {
|
|
81
|
+
body.attachments = attachmentsFor(message).map((att) => {
|
|
81
82
|
const buf = Buffer.from(att.content as Buffer | string);
|
|
82
83
|
return {
|
|
83
84
|
filename: stripCrlf(att.filename),
|
|
@@ -91,7 +92,8 @@ export class ResendTransport implements MailTransport {
|
|
|
91
92
|
const customHeaders = Object.entries(message.headers);
|
|
92
93
|
if (customHeaders.length > 0) {
|
|
93
94
|
body.headers = {};
|
|
94
|
-
for (const [k,
|
|
95
|
+
for (const [k, raw] of customHeaders) {
|
|
96
|
+
const v = headerValue(raw);
|
|
95
97
|
body.headers[stripCrlf(k)] = Array.isArray(v)
|
|
96
98
|
? v.map(stripCrlf).join(", ")
|
|
97
99
|
: stripCrlf(v);
|
|
@@ -104,7 +106,7 @@ export class ResendTransport implements MailTransport {
|
|
|
104
106
|
// shims, `.cause.code` for Node's built-in undici).
|
|
105
107
|
let res: Response;
|
|
106
108
|
try {
|
|
107
|
-
res = await
|
|
109
|
+
res = await fetchWithTimeout("Resend", "https://api.resend.com/emails", {
|
|
108
110
|
method: "POST",
|
|
109
111
|
headers: {
|
|
110
112
|
Authorization: `Bearer ${this.#apiKey}`,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type MailTransport,
|
|
9
9
|
registerTransport,
|
|
10
10
|
} from "../Mail.js";
|
|
11
|
+
import { attachmentsFor, headerValue } from "../MessageBuilder.js";
|
|
11
12
|
import { RoverError } from "../RoverError.js";
|
|
12
13
|
|
|
13
14
|
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
@@ -90,16 +91,19 @@ export class SendGridTransport implements MailTransport {
|
|
|
90
91
|
...(Object.keys(message.headers).length
|
|
91
92
|
? {
|
|
92
93
|
headers: Object.fromEntries(
|
|
93
|
-
Object.entries(message.headers).map(([k,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
}),
|
|
97
101
|
),
|
|
98
102
|
}
|
|
99
103
|
: {}),
|
|
100
|
-
...(message.
|
|
104
|
+
...(attachmentsFor(message).length
|
|
101
105
|
? {
|
|
102
|
-
attachments: message.
|
|
106
|
+
attachments: attachmentsFor(message).map((att) => {
|
|
103
107
|
const entry: {
|
|
104
108
|
filename: string;
|
|
105
109
|
content: string;
|
|
@@ -7,8 +7,9 @@ import {
|
|
|
7
7
|
type MailTransport,
|
|
8
8
|
registerTransport,
|
|
9
9
|
} from "../Mail.js";
|
|
10
|
+
import { attachmentsFor } from "../MessageBuilder.js";
|
|
10
11
|
import { RoverError } from "../RoverError.js";
|
|
11
|
-
import { wrapFetchNetworkError } from "./fetchError.js";
|
|
12
|
+
import { fetchWithTimeout, wrapFetchNetworkError } from "./fetchError.js";
|
|
12
13
|
|
|
13
14
|
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
14
15
|
const normalizeConfig = (v: string): string => stripCrlf(v).trim();
|
|
@@ -101,7 +102,8 @@ export class SesTransport implements MailTransport {
|
|
|
101
102
|
// compose the MIME ourselves), so flip to raw whenever the message
|
|
102
103
|
// carries headers, even without attachments.
|
|
103
104
|
const useRaw =
|
|
104
|
-
message.
|
|
105
|
+
attachmentsFor(message).length > 0 ||
|
|
106
|
+
Object.keys(message.headers).length > 0;
|
|
105
107
|
const form = useRaw
|
|
106
108
|
? buildRawEmailForm(message)
|
|
107
109
|
: buildSendEmailForm(message);
|
|
@@ -112,7 +114,7 @@ export class SesTransport implements MailTransport {
|
|
|
112
114
|
// why undici's `cause.code` matters for retry classification.
|
|
113
115
|
let res: Response;
|
|
114
116
|
try {
|
|
115
|
-
res = await
|
|
117
|
+
res = await fetchWithTimeout("SES", url, {
|
|
116
118
|
method: "POST",
|
|
117
119
|
headers,
|
|
118
120
|
body: form,
|
|
@@ -279,14 +281,21 @@ function buildRawMime(message: MailMessage): string {
|
|
|
279
281
|
"content-transfer-encoding",
|
|
280
282
|
"content-disposition",
|
|
281
283
|
]);
|
|
282
|
-
for (const [k,
|
|
284
|
+
for (const [k, raw] of Object.entries(message.headers)) {
|
|
283
285
|
if (reserved.has(k.toLowerCase())) continue;
|
|
286
|
+
// A PREPARED header is the one case the flag actually decides: this
|
|
287
|
+
// builds raw MIME, and re-encoding a value that is already exactly what
|
|
288
|
+
// must go on the wire — a signature, a pre-encoded id — corrupts it.
|
|
289
|
+
if (!Array.isArray(raw) && typeof raw !== "string") {
|
|
290
|
+
parts.push(`${stripCrlf(k)}: ${stripCrlf(raw.value)}`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
284
293
|
parts.push(
|
|
285
|
-
`${stripCrlf(k)}: ${encodeHeaderWord(Array.isArray(
|
|
294
|
+
`${stripCrlf(k)}: ${encodeHeaderWord(Array.isArray(raw) ? raw.join(", ") : raw)}`,
|
|
286
295
|
);
|
|
287
296
|
}
|
|
288
297
|
|
|
289
|
-
const hasAttachments = message.
|
|
298
|
+
const hasAttachments = attachmentsFor(message).length > 0;
|
|
290
299
|
const mixedBoundary = freshBoundary();
|
|
291
300
|
|
|
292
301
|
if (hasAttachments) {
|
|
@@ -294,7 +303,7 @@ function buildRawMime(message: MailMessage): string {
|
|
|
294
303
|
parts.push("");
|
|
295
304
|
parts.push(`--${mixedBoundary}`);
|
|
296
305
|
appendBodyBlock(parts, message);
|
|
297
|
-
for (const att of message
|
|
306
|
+
for (const att of attachmentsFor(message)) {
|
|
298
307
|
appendAttachmentPart(parts, mixedBoundary, att);
|
|
299
308
|
}
|
|
300
309
|
parts.push(`--${mixedBoundary}--`);
|
|
@@ -5,8 +5,9 @@ import {
|
|
|
5
5
|
type MailTransport,
|
|
6
6
|
registerTransport,
|
|
7
7
|
} from "../Mail.js";
|
|
8
|
+
import { attachmentsFor, headerValue } from "../MessageBuilder.js";
|
|
8
9
|
import { RoverError } from "../RoverError.js";
|
|
9
|
-
import { wrapFetchNetworkError } from "./fetchError.js";
|
|
10
|
+
import { fetchWithTimeout, wrapFetchNetworkError } from "./fetchError.js";
|
|
10
11
|
|
|
11
12
|
const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
|
|
12
13
|
const normalizeConfig = (v: string): string => stripCrlf(v).trim();
|
|
@@ -102,15 +103,16 @@ export class SparkPostTransport implements MailTransport {
|
|
|
102
103
|
if (message.text) body.content.text = message.text;
|
|
103
104
|
if (message.replyTo) body.content.reply_to = stripCrlf(message.replyTo);
|
|
104
105
|
const headers: Record<string, string> = {};
|
|
105
|
-
for (const [k,
|
|
106
|
+
for (const [k, raw] of Object.entries(message.headers)) {
|
|
107
|
+
const v = headerValue(raw);
|
|
106
108
|
headers[stripCrlf(k)] = Array.isArray(v)
|
|
107
109
|
? v.map(stripCrlf).join(", ")
|
|
108
110
|
: stripCrlf(v);
|
|
109
111
|
}
|
|
110
112
|
if (message.cc.length) headers.CC = message.cc.map(stripCrlf).join(", ");
|
|
111
113
|
if (Object.keys(headers).length > 0) body.content.headers = headers;
|
|
112
|
-
if (message.
|
|
113
|
-
body.content.attachments = message.
|
|
114
|
+
if (attachmentsFor(message).length > 0) {
|
|
115
|
+
body.content.attachments = attachmentsFor(message).map((att) => ({
|
|
114
116
|
name: stripCrlf(att.filename),
|
|
115
117
|
type: att.contentType
|
|
116
118
|
? stripCrlf(att.contentType)
|
|
@@ -121,14 +123,18 @@ export class SparkPostTransport implements MailTransport {
|
|
|
121
123
|
|
|
122
124
|
let res: Response;
|
|
123
125
|
try {
|
|
124
|
-
res = await
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
res = await fetchWithTimeout(
|
|
127
|
+
"SparkPost",
|
|
128
|
+
`${this.#baseUrl}/api/v1/transmissions`,
|
|
129
|
+
{
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: {
|
|
132
|
+
Authorization: this.#apiKey,
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
},
|
|
135
|
+
body: JSON.stringify(body),
|
|
129
136
|
},
|
|
130
|
-
|
|
131
|
-
});
|
|
137
|
+
);
|
|
132
138
|
} catch (err) {
|
|
133
139
|
throw wrapFetchNetworkError("sparkpost", err);
|
|
134
140
|
}
|
|
@@ -40,3 +40,42 @@ export function wrapFetchNetworkError(
|
|
|
40
40
|
},
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/** Default ceiling on one provider request. */
|
|
45
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `fetch` with a deadline.
|
|
49
|
+
*
|
|
50
|
+
* Without one, a stalled provider connection never settles: the send hangs,
|
|
51
|
+
* the queue worker holding it hangs with it, and enough of them stop mail going
|
|
52
|
+
* out at all — with no error to explain the silence. A timeout surfaces as the
|
|
53
|
+
* same `MAIL_PROVIDER_ERROR` shape the retry classifier already understands, so
|
|
54
|
+
* it is retried like any other transient network failure.
|
|
55
|
+
*/
|
|
56
|
+
export async function fetchWithTimeout(
|
|
57
|
+
provider: string,
|
|
58
|
+
url: string,
|
|
59
|
+
init: RequestInit = {},
|
|
60
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
61
|
+
): Promise<Response> {
|
|
62
|
+
if (timeoutMs <= 0) return fetch(url, init);
|
|
63
|
+
try {
|
|
64
|
+
return await fetch(url, {
|
|
65
|
+
...init,
|
|
66
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
67
|
+
});
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
70
|
+
throw new RoverError(
|
|
71
|
+
"MAIL_PROVIDER_ERROR",
|
|
72
|
+
`${provider} did not answer within ${timeoutMs}ms.`,
|
|
73
|
+
{
|
|
74
|
+
context: { provider, upstreamStatus: "0", networkCode: "ETIMEDOUT" },
|
|
75
|
+
hint: "Raise `requestTimeoutMs` for large attachments, or check connectivity to the provider.",
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
throw wrapFetchNetworkError(provider, err);
|
|
80
|
+
}
|
|
81
|
+
}
|