@c9up/rover 0.1.7 → 0.1.9

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 (76) hide show
  1. package/README.md +1 -1
  2. package/dist/BaseMail.d.ts +30 -1
  3. package/dist/BaseMail.d.ts.map +1 -1
  4. package/dist/BaseMail.js +30 -0
  5. package/dist/BaseMail.js.map +1 -1
  6. package/dist/Mail.d.ts +32 -1
  7. package/dist/Mail.d.ts.map +1 -1
  8. package/dist/Mail.js +35 -1
  9. package/dist/Mail.js.map +1 -1
  10. package/dist/MessageBuilder.d.ts +197 -7
  11. package/dist/MessageBuilder.d.ts.map +1 -1
  12. package/dist/MessageBuilder.js +402 -8
  13. package/dist/MessageBuilder.js.map +1 -1
  14. package/dist/RoverProvider.d.ts +1 -1
  15. package/dist/RoverProvider.d.ts.map +1 -1
  16. package/dist/RoverProvider.js +6 -6
  17. package/dist/RoverProvider.js.map +1 -1
  18. package/dist/config.d.ts +38 -0
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +34 -0
  21. package/dist/config.js.map +1 -1
  22. package/dist/format.d.ts +10 -0
  23. package/dist/format.d.ts.map +1 -1
  24. package/dist/format.js +28 -1
  25. package/dist/format.js.map +1 -1
  26. package/dist/index.d.ts +18 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +16 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/testing/FakeMail.d.ts +33 -0
  31. package/dist/testing/FakeMail.d.ts.map +1 -1
  32. package/dist/testing/FakeMail.js +28 -0
  33. package/dist/testing/FakeMail.js.map +1 -1
  34. package/dist/transports/BrevoTransport.d.ts.map +1 -1
  35. package/dist/transports/BrevoTransport.js +5 -4
  36. package/dist/transports/BrevoTransport.js.map +1 -1
  37. package/dist/transports/MailgunTransport.d.ts.map +1 -1
  38. package/dist/transports/MailgunTransport.js +3 -2
  39. package/dist/transports/MailgunTransport.js.map +1 -1
  40. package/dist/transports/ResendTransport.d.ts.map +1 -1
  41. package/dist/transports/ResendTransport.js +5 -4
  42. package/dist/transports/ResendTransport.js.map +1 -1
  43. package/dist/transports/SendGridTransport.d.ts.map +1 -1
  44. package/dist/transports/SendGridTransport.js +3 -2
  45. package/dist/transports/SendGridTransport.js.map +1 -1
  46. package/dist/transports/SesTransport.d.ts.map +1 -1
  47. package/dist/transports/SesTransport.js +7 -5
  48. package/dist/transports/SesTransport.js.map +1 -1
  49. package/dist/transports/SparkPostTransport.d.ts.map +1 -1
  50. package/dist/transports/SparkPostTransport.js +5 -4
  51. package/dist/transports/SparkPostTransport.js.map +1 -1
  52. package/dist/transports/fetchError.d.ts +10 -0
  53. package/dist/transports/fetchError.d.ts.map +1 -1
  54. package/dist/transports/fetchError.js +30 -0
  55. package/dist/transports/fetchError.js.map +1 -1
  56. package/index.darwin-arm64.node +0 -0
  57. package/index.darwin-x64.node +0 -0
  58. package/index.linux-arm64-gnu.node +0 -0
  59. package/index.linux-x64-gnu.node +0 -0
  60. package/index.win32-x64-msvc.node +0 -0
  61. package/package.json +6 -1
  62. package/src/BaseMail.ts +40 -1
  63. package/src/Mail.ts +55 -2
  64. package/src/MessageBuilder.ts +561 -11
  65. package/src/RoverProvider.ts +10 -7
  66. package/src/config.ts +46 -0
  67. package/src/format.ts +33 -1
  68. package/src/index.ts +28 -2
  69. package/src/testing/FakeMail.ts +46 -0
  70. package/src/transports/BrevoTransport.ts +5 -4
  71. package/src/transports/MailgunTransport.ts +3 -2
  72. package/src/transports/ResendTransport.ts +5 -4
  73. package/src/transports/SendGridTransport.ts +3 -2
  74. package/src/transports/SesTransport.ts +7 -5
  75. package/src/transports/SparkPostTransport.ts +15 -10
  76. 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,6 +1,16 @@
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,
@@ -24,7 +34,17 @@ export {
24
34
  registerTransport,
25
35
  SmtpTransport,
26
36
  } from "./Mail.js";
27
- export type { Recipient, RecipientObject } from "./MessageBuilder.js";
37
+ export type {
38
+ AttachmentOptions,
39
+ CalendarEvent,
40
+ CalendarEventMethod,
41
+ CalendarEventOptions,
42
+ ListHeader,
43
+ MailEnvelope,
44
+ Recipient,
45
+ RecipientObject,
46
+ } from "./MessageBuilder.js";
47
+ export { attachmentsFor } from "./MessageBuilder.js";
28
48
  export { RoverError } from "./RoverError.js";
29
49
  export { default as RoverProvider } from "./RoverProvider.js";
30
50
  export {
@@ -32,3 +52,9 @@ export {
32
52
  isRetryableError,
33
53
  type RetryConfig,
34
54
  } from "./retry.js";
55
+ export { BrevoTransport } from "./transports/BrevoTransport.js";
56
+ export { MailgunTransport } from "./transports/MailgunTransport.js";
57
+ export { ResendTransport } from "./transports/ResendTransport.js";
58
+ export { SendGridTransport } from "./transports/SendGridTransport.js";
59
+ export { SesTransport } from "./transports/SesTransport.js";
60
+ 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 } 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();
@@ -104,8 +105,8 @@ export class BrevoTransport implements MailTransport {
104
105
  : stripCrlf(v);
105
106
  }
106
107
  }
107
- if (message.attachments.length > 0) {
108
- body.attachment = message.attachments.map((att) => ({
108
+ if (attachmentsFor(message).length > 0) {
109
+ body.attachment = attachmentsFor(message).map((att) => ({
109
110
  name: stripCrlf(att.filename),
110
111
  content: Buffer.from(att.content as Buffer | string).toString("base64"),
111
112
  }));
@@ -113,7 +114,7 @@ export class BrevoTransport implements MailTransport {
113
114
 
114
115
  let res: Response;
115
116
  try {
116
- res = await fetch(`${this.#baseUrl}/v3/smtp/email`, {
117
+ res = await fetchWithTimeout("Brevo", `${this.#baseUrl}/v3/smtp/email`, {
117
118
  method: "POST",
118
119
  headers: {
119
120
  "api-key": this.#apiKey,
@@ -7,6 +7,7 @@ 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
12
 
12
13
  const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
@@ -132,8 +133,8 @@ export class MailgunTransport implements MailTransport {
132
133
  ? v.map(stripCrlf).join(", ")
133
134
  : stripCrlf(v);
134
135
  }
135
- if (message.attachments.length > 0) {
136
- data.attachment = message.attachments.map((att) => {
136
+ if (attachmentsFor(message).length > 0) {
137
+ data.attachment = attachmentsFor(message).map((att) => {
137
138
  const entry: { filename: string; data: Buffer; contentType?: string } =
138
139
  {
139
140
  filename: stripCrlf(att.filename),
@@ -5,8 +5,9 @@ import {
5
5
  type MailTransport,
6
6
  registerTransport,
7
7
  } from "../Mail.js";
8
+ import { attachmentsFor } 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),
@@ -104,7 +105,7 @@ export class ResendTransport implements MailTransport {
104
105
  // shims, `.cause.code` for Node's built-in undici).
105
106
  let res: Response;
106
107
  try {
107
- res = await fetch("https://api.resend.com/emails", {
108
+ res = await fetchWithTimeout("Resend", "https://api.resend.com/emails", {
108
109
  method: "POST",
109
110
  headers: {
110
111
  Authorization: `Bearer ${this.#apiKey}`,
@@ -8,6 +8,7 @@ import {
8
8
  type MailTransport,
9
9
  registerTransport,
10
10
  } from "../Mail.js";
11
+ import { attachmentsFor } from "../MessageBuilder.js";
11
12
  import { RoverError } from "../RoverError.js";
12
13
 
13
14
  const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
@@ -97,9 +98,9 @@ export class SendGridTransport implements MailTransport {
97
98
  ),
98
99
  }
99
100
  : {}),
100
- ...(message.attachments.length
101
+ ...(attachmentsFor(message).length
101
102
  ? {
102
- attachments: message.attachments.map((att) => {
103
+ attachments: attachmentsFor(message).map((att) => {
103
104
  const entry: {
104
105
  filename: string;
105
106
  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,
@@ -286,7 +288,7 @@ function buildRawMime(message: MailMessage): string {
286
288
  );
287
289
  }
288
290
 
289
- const hasAttachments = message.attachments.length > 0;
291
+ const hasAttachments = attachmentsFor(message).length > 0;
290
292
  const mixedBoundary = freshBoundary();
291
293
 
292
294
  if (hasAttachments) {
@@ -294,7 +296,7 @@ function buildRawMime(message: MailMessage): string {
294
296
  parts.push("");
295
297
  parts.push(`--${mixedBoundary}`);
296
298
  appendBodyBlock(parts, message);
297
- for (const att of message.attachments) {
299
+ for (const att of attachmentsFor(message)) {
298
300
  appendAttachmentPart(parts, mixedBoundary, att);
299
301
  }
300
302
  parts.push(`--${mixedBoundary}--`);
@@ -5,8 +5,9 @@ import {
5
5
  type MailTransport,
6
6
  registerTransport,
7
7
  } from "../Mail.js";
8
+ import { attachmentsFor } 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();
@@ -109,8 +110,8 @@ export class SparkPostTransport implements MailTransport {
109
110
  }
110
111
  if (message.cc.length) headers.CC = message.cc.map(stripCrlf).join(", ");
111
112
  if (Object.keys(headers).length > 0) body.content.headers = headers;
112
- if (message.attachments.length > 0) {
113
- body.content.attachments = message.attachments.map((att) => ({
113
+ if (attachmentsFor(message).length > 0) {
114
+ body.content.attachments = attachmentsFor(message).map((att) => ({
114
115
  name: stripCrlf(att.filename),
115
116
  type: att.contentType
116
117
  ? stripCrlf(att.contentType)
@@ -121,14 +122,18 @@ export class SparkPostTransport implements MailTransport {
121
122
 
122
123
  let res: Response;
123
124
  try {
124
- res = await fetch(`${this.#baseUrl}/api/v1/transmissions`, {
125
- method: "POST",
126
- headers: {
127
- Authorization: this.#apiKey,
128
- "Content-Type": "application/json",
125
+ res = await fetchWithTimeout(
126
+ "SparkPost",
127
+ `${this.#baseUrl}/api/v1/transmissions`,
128
+ {
129
+ method: "POST",
130
+ headers: {
131
+ Authorization: this.#apiKey,
132
+ "Content-Type": "application/json",
133
+ },
134
+ body: JSON.stringify(body),
129
135
  },
130
- body: JSON.stringify(body),
131
- });
136
+ );
132
137
  } catch (err) {
133
138
  throw wrapFetchNetworkError("sparkpost", err);
134
139
  }
@@ -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
+ }