@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.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/dist/BaseMail.d.ts +41 -1
  3. package/dist/BaseMail.d.ts.map +1 -1
  4. package/dist/BaseMail.js +48 -1
  5. package/dist/BaseMail.js.map +1 -1
  6. package/dist/Mail.d.ts +90 -24
  7. package/dist/Mail.d.ts.map +1 -1
  8. package/dist/Mail.js +123 -40
  9. package/dist/Mail.js.map +1 -1
  10. package/dist/MessageBuilder.d.ts +296 -8
  11. package/dist/MessageBuilder.d.ts.map +1 -1
  12. package/dist/MessageBuilder.js +537 -8
  13. package/dist/MessageBuilder.js.map +1 -1
  14. package/dist/config.d.ts +38 -0
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +34 -0
  17. package/dist/config.js.map +1 -1
  18. package/dist/format.d.ts +10 -0
  19. package/dist/format.d.ts.map +1 -1
  20. package/dist/format.js +28 -1
  21. package/dist/format.js.map +1 -1
  22. package/dist/index.d.ts +19 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +16 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/testing/FakeMail.d.ts +33 -0
  27. package/dist/testing/FakeMail.d.ts.map +1 -1
  28. package/dist/testing/FakeMail.js +28 -0
  29. package/dist/testing/FakeMail.js.map +1 -1
  30. package/dist/transports/BrevoTransport.d.ts.map +1 -1
  31. package/dist/transports/BrevoTransport.js +7 -5
  32. package/dist/transports/BrevoTransport.js.map +1 -1
  33. package/dist/transports/MailgunTransport.d.ts.map +1 -1
  34. package/dist/transports/MailgunTransport.js +5 -3
  35. package/dist/transports/MailgunTransport.js.map +1 -1
  36. package/dist/transports/ResendTransport.d.ts.map +1 -1
  37. package/dist/transports/ResendTransport.js +7 -5
  38. package/dist/transports/ResendTransport.js.map +1 -1
  39. package/dist/transports/SendGridTransport.d.ts.map +1 -1
  40. package/dist/transports/SendGridTransport.js +10 -6
  41. package/dist/transports/SendGridTransport.js.map +1 -1
  42. package/dist/transports/SesTransport.d.ts.map +1 -1
  43. package/dist/transports/SesTransport.js +16 -7
  44. package/dist/transports/SesTransport.js.map +1 -1
  45. package/dist/transports/SparkPostTransport.d.ts.map +1 -1
  46. package/dist/transports/SparkPostTransport.js +7 -5
  47. package/dist/transports/SparkPostTransport.js.map +1 -1
  48. package/dist/transports/fetchError.d.ts +10 -0
  49. package/dist/transports/fetchError.d.ts.map +1 -1
  50. package/dist/transports/fetchError.js +30 -0
  51. package/dist/transports/fetchError.js.map +1 -1
  52. package/index.darwin-arm64.node +0 -0
  53. package/index.darwin-x64.node +0 -0
  54. package/index.linux-arm64-gnu.node +0 -0
  55. package/index.linux-x64-gnu.node +0 -0
  56. package/index.win32-x64-msvc.node +0 -0
  57. package/package.json +6 -1
  58. package/src/BaseMail.ts +59 -2
  59. package/src/Mail.ts +194 -65
  60. package/src/MessageBuilder.ts +756 -12
  61. package/src/config.ts +46 -0
  62. package/src/format.ts +33 -1
  63. package/src/index.ts +31 -2
  64. package/src/testing/FakeMail.ts +46 -0
  65. package/src/transports/BrevoTransport.ts +7 -5
  66. package/src/transports/MailgunTransport.ts +5 -3
  67. package/src/transports/ResendTransport.ts +7 -5
  68. package/src/transports/SendGridTransport.ts +10 -6
  69. package/src/transports/SesTransport.ts +16 -7
  70. package/src/transports/SparkPostTransport.ts +17 -11
  71. 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
- return name !== undefined && name !== "" ? `"${name}" <${address}>` : address;
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 { defineConfig } from "./config.js";
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 { Recipient, RecipientObject } from "./MessageBuilder.js";
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";
@@ -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, v] of customHeaders) {
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.attachments.length > 0) {
108
- body.attachment = message.attachments.map((att) => ({
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 fetch(`${this.#baseUrl}/v3/smtp/email`, {
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, v] of Object.entries(message.headers)) {
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.attachments.length > 0) {
136
- data.attachment = message.attachments.map((att) => {
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.attachments.length > 0) {
80
- body.attachments = message.attachments.map((att) => {
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, v] of customHeaders) {
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 fetch("https://api.resend.com/emails", {
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, v]) => [
94
- stripCrlf(k),
95
- Array.isArray(v) ? v.map(stripCrlf).join(", ") : stripCrlf(v),
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.attachments.length
104
+ ...(attachmentsFor(message).length
101
105
  ? {
102
- attachments: message.attachments.map((att) => {
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.attachments.length > 0 || Object.keys(message.headers).length > 0;
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 fetch(url, {
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, v] of Object.entries(message.headers)) {
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(v) ? v.join(", ") : v)}`,
294
+ `${stripCrlf(k)}: ${encodeHeaderWord(Array.isArray(raw) ? raw.join(", ") : raw)}`,
286
295
  );
287
296
  }
288
297
 
289
- const hasAttachments = message.attachments.length > 0;
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.attachments) {
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, v] of Object.entries(message.headers)) {
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.attachments.length > 0) {
113
- body.content.attachments = message.attachments.map((att) => ({
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 fetch(`${this.#baseUrl}/api/v1/transmissions`, {
125
- method: "POST",
126
- headers: {
127
- Authorization: this.#apiKey,
128
- "Content-Type": "application/json",
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
- body: JSON.stringify(body),
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
+ }