@c9up/rover 0.1.5 → 0.1.7

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 (70) hide show
  1. package/README.md +3 -1
  2. package/dist/BaseMail.d.ts +2 -1
  3. package/dist/BaseMail.d.ts.map +1 -1
  4. package/dist/BaseMail.js +9 -4
  5. package/dist/BaseMail.js.map +1 -1
  6. package/dist/Mail.d.ts +65 -11
  7. package/dist/Mail.d.ts.map +1 -1
  8. package/dist/Mail.js +142 -49
  9. package/dist/Mail.js.map +1 -1
  10. package/dist/MessageBuilder.d.ts +44 -7
  11. package/dist/MessageBuilder.d.ts.map +1 -1
  12. package/dist/MessageBuilder.js +57 -10
  13. package/dist/MessageBuilder.js.map +1 -1
  14. package/dist/format.d.ts +12 -0
  15. package/dist/format.d.ts.map +1 -0
  16. package/dist/format.js +14 -0
  17. package/dist/format.js.map +1 -0
  18. package/dist/index.d.ts +3 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/queue/MailJob.d.ts.map +1 -1
  23. package/dist/queue/MailJob.js.map +1 -1
  24. package/dist/queue/MemoryMailMessenger.d.ts +15 -0
  25. package/dist/queue/MemoryMailMessenger.d.ts.map +1 -0
  26. package/dist/queue/MemoryMailMessenger.js +42 -0
  27. package/dist/queue/MemoryMailMessenger.js.map +1 -0
  28. package/dist/testing/FakeMail.d.ts +30 -3
  29. package/dist/testing/FakeMail.d.ts.map +1 -1
  30. package/dist/testing/FakeMail.js +106 -28
  31. package/dist/testing/FakeMail.js.map +1 -1
  32. package/dist/transports/BrevoTransport.d.ts +14 -0
  33. package/dist/transports/BrevoTransport.d.ts.map +1 -0
  34. package/dist/transports/BrevoTransport.js +134 -0
  35. package/dist/transports/BrevoTransport.js.map +1 -0
  36. package/dist/transports/MailgunTransport.d.ts.map +1 -1
  37. package/dist/transports/MailgunTransport.js +3 -1
  38. package/dist/transports/MailgunTransport.js.map +1 -1
  39. package/dist/transports/ResendTransport.d.ts.map +1 -1
  40. package/dist/transports/ResendTransport.js +4 -2
  41. package/dist/transports/ResendTransport.js.map +1 -1
  42. package/dist/transports/SendGridTransport.d.ts.map +1 -1
  43. package/dist/transports/SendGridTransport.js +2 -2
  44. package/dist/transports/SendGridTransport.js.map +1 -1
  45. package/dist/transports/SesTransport.js +1 -1
  46. package/dist/transports/SesTransport.js.map +1 -1
  47. package/dist/transports/SparkPostTransport.d.ts +14 -0
  48. package/dist/transports/SparkPostTransport.d.ts.map +1 -0
  49. package/dist/transports/SparkPostTransport.js +144 -0
  50. package/dist/transports/SparkPostTransport.js.map +1 -0
  51. package/index.darwin-arm64.node +0 -0
  52. package/index.darwin-x64.node +0 -0
  53. package/index.linux-arm64-gnu.node +0 -0
  54. package/index.linux-x64-gnu.node +0 -0
  55. package/index.win32-x64-msvc.node +0 -0
  56. package/package.json +10 -1
  57. package/src/BaseMail.ts +17 -5
  58. package/src/Mail.ts +205 -62
  59. package/src/MessageBuilder.ts +103 -12
  60. package/src/format.ts +14 -0
  61. package/src/index.ts +5 -1
  62. package/src/queue/MailJob.ts +1 -1
  63. package/src/queue/MemoryMailMessenger.ts +45 -0
  64. package/src/testing/FakeMail.ts +232 -31
  65. package/src/transports/BrevoTransport.ts +174 -0
  66. package/src/transports/MailgunTransport.ts +3 -1
  67. package/src/transports/ResendTransport.ts +4 -2
  68. package/src/transports/SendGridTransport.ts +2 -2
  69. package/src/transports/SesTransport.ts +3 -1
  70. package/src/transports/SparkPostTransport.ts +182 -0
@@ -0,0 +1,45 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type { EmitterLike, MailMessage } from "../Mail.js";
3
+ import type { MailDispatcher } from "./MailJob.js";
4
+
5
+ /**
6
+ * Default in-memory messenger for `sendLater()` when no `@c9up/bay` queue is
7
+ * wired — mirrors `@adonisjs/mail`'s `MemoryQueueMessenger`. The send is
8
+ * scheduled on a microtask so `sendLater()` returns immediately (fire-and-
9
+ * forget). Background delivery failures surface on the `queued:mail:error`
10
+ * event rather than becoming unhandled rejections.
11
+ */
12
+ export class MemoryMailMessenger {
13
+ #dispatcher: MailDispatcher;
14
+ #emitter: EmitterLike | null;
15
+
16
+ constructor(dispatcher: MailDispatcher, emitter: EmitterLike | null) {
17
+ this.#dispatcher = dispatcher;
18
+ this.#emitter = emitter;
19
+ }
20
+
21
+ queue(message: MailMessage, transport?: string): string {
22
+ const jobId = `mem_${randomBytes(12).toString("hex")}`;
23
+ queueMicrotask(() => {
24
+ this.#dispatcher
25
+ .dispatchMessage(message, transport)
26
+ .catch((error: unknown) => {
27
+ if (!this.#emitter) return;
28
+ try {
29
+ this.#emitter.emit("queued:mail:error", {
30
+ jobId,
31
+ transportName: transport,
32
+ error:
33
+ error instanceof Error
34
+ ? { message: error.message }
35
+ : { message: String(error) },
36
+ });
37
+ } catch {
38
+ // Event bus failure ≠ mail failure — swallow so the microtask
39
+ // never rejects.
40
+ }
41
+ });
42
+ });
43
+ return jobId;
44
+ }
45
+ }
@@ -1,3 +1,4 @@
1
+ import { BaseMail } from "../BaseMail.js";
1
2
  import type { MailMessage, MailSendOutcome, MailTransport } from "../Mail.js";
2
3
 
3
4
  export interface FakeMailPredicate {
@@ -15,59 +16,259 @@ export type FakeMailPredicateArg =
15
16
  | ((m: MailMessage) => boolean);
16
17
 
17
18
  /**
18
- * In-memory transport for tests — captures every `send(message)` call and
19
- * exposes Adonis/Laravel-style `assertSent` / `assertNotSent` helpers.
19
+ * Constructor of a `BaseMail` subclass — the `new (...args: never[]) => T`
20
+ * form keeps `instanceof` sound without an `any` in the signature.
21
+ */
22
+ export type MailConstructor<T extends BaseMail = BaseMail> = new (
23
+ ...args: never[]
24
+ ) => T;
25
+
26
+ interface Capture {
27
+ message: MailMessage;
28
+ mail?: BaseMail;
29
+ }
30
+
31
+ /**
32
+ * In-memory capture for tests — records every `send` (`trackSent`) and
33
+ * `sendLater` (`trackQueued`) routed through a faked `Mail`, and exposes
34
+ * Adonis/Laravel-style assertions over both message content (predicate form)
35
+ * and the originating `BaseMail` class (constructor form).
20
36
  *
21
37
  * Not re-exported from the main barrel; reach via `@c9up/rover/testing`.
22
38
  */
23
39
  export class FakeMail implements MailTransport {
24
- #captured: MailMessage[] = [];
40
+ #sent: Capture[] = [];
41
+ #queued: Capture[] = [];
25
42
 
43
+ /** MailTransport contract — direct `send()` capture (backward compatible). */
26
44
  async send(message: MailMessage): Promise<MailSendOutcome> {
27
- this.#captured.push(message);
45
+ this.trackSent(message);
28
46
  return undefined;
29
47
  }
30
48
 
49
+ /** Record a synchronously-sent message (and its source `BaseMail`, if any). */
50
+ trackSent(message: MailMessage, mail?: BaseMail): void {
51
+ this.#sent.push({ message, mail });
52
+ }
53
+
54
+ /** Record a queued message (and its source `BaseMail`, if any). */
55
+ trackQueued(message: MailMessage, mail?: BaseMail): void {
56
+ this.#queued.push({ message, mail });
57
+ }
58
+
31
59
  /**
32
- * Return a defensive snapshot of captured messages. Each message is cloned
60
+ * Return a defensive snapshot of sent messages. Each message is cloned
33
61
  * (shallow-per-field with array copies) so test-side mutations can't bleed
34
62
  * back into the internal capture store — avoids cross-test contamination.
35
63
  */
36
64
  getSent(): MailMessage[] {
37
- return this.#captured.map((m) => ({
38
- from: m.from,
39
- to: m.to.slice(),
40
- cc: m.cc.slice(),
41
- bcc: m.bcc.slice(),
42
- replyTo: m.replyTo,
43
- subject: m.subject,
44
- html: m.html,
45
- text: m.text,
46
- attachments: m.attachments.slice(),
47
- headers: { ...m.headers },
48
- }));
65
+ return this.#sent.map((e) => cloneMessage(e.message));
66
+ }
67
+
68
+ /** Return a defensive snapshot of queued messages. */
69
+ getQueued(): MailMessage[] {
70
+ return this.#queued.map((e) => cloneMessage(e.message));
49
71
  }
50
72
 
51
73
  reset(): void {
52
- this.#captured = [];
74
+ this.#sent = [];
75
+ this.#queued = [];
53
76
  }
54
77
 
55
- assertSent(predicate: FakeMailPredicateArg): void {
56
- const match = makeMatcher(predicate);
57
- if (this.#captured.some(match)) return;
58
- throw new Error(
59
- `mail.assertSent() failed — no captured message matches ${describePredicate(predicate)}.\n${describeCaptured(this.#captured)}`,
78
+ assertSent(predicate: FakeMailPredicateArg): void;
79
+ assertSent<T extends BaseMail>(
80
+ mailConstructor: MailConstructor<T>,
81
+ findFn?: (mail: T) => boolean,
82
+ ): void;
83
+ assertSent(
84
+ arg: FakeMailPredicateArg | MailConstructor<BaseMail>,
85
+ findFn?: (mail: BaseMail) => boolean,
86
+ ): void {
87
+ this.#assertPresence("assertSent", "sent", this.#sent, arg, findFn, true);
88
+ }
89
+
90
+ assertNotSent(predicate: FakeMailPredicateArg): void;
91
+ assertNotSent<T extends BaseMail>(
92
+ mailConstructor: MailConstructor<T>,
93
+ findFn?: (mail: T) => boolean,
94
+ ): void;
95
+ assertNotSent(
96
+ arg: FakeMailPredicateArg | MailConstructor<BaseMail>,
97
+ findFn?: (mail: BaseMail) => boolean,
98
+ ): void {
99
+ this.#assertPresence(
100
+ "assertNotSent",
101
+ "sent",
102
+ this.#sent,
103
+ arg,
104
+ findFn,
105
+ false,
106
+ );
107
+ }
108
+
109
+ assertQueued(predicate: FakeMailPredicateArg): void;
110
+ assertQueued<T extends BaseMail>(
111
+ mailConstructor: MailConstructor<T>,
112
+ findFn?: (mail: T) => boolean,
113
+ ): void;
114
+ assertQueued(
115
+ arg: FakeMailPredicateArg | MailConstructor<BaseMail>,
116
+ findFn?: (mail: BaseMail) => boolean,
117
+ ): void {
118
+ this.#assertPresence(
119
+ "assertQueued",
120
+ "queued",
121
+ this.#queued,
122
+ arg,
123
+ findFn,
124
+ true,
60
125
  );
61
126
  }
62
127
 
63
- assertNotSent(predicate: FakeMailPredicateArg): void {
64
- const match = makeMatcher(predicate);
65
- const found = this.#captured.find(match);
66
- if (!found) return;
128
+ assertNotQueued(predicate: FakeMailPredicateArg): void;
129
+ assertNotQueued<T extends BaseMail>(
130
+ mailConstructor: MailConstructor<T>,
131
+ findFn?: (mail: T) => boolean,
132
+ ): void;
133
+ assertNotQueued(
134
+ arg: FakeMailPredicateArg | MailConstructor<BaseMail>,
135
+ findFn?: (mail: BaseMail) => boolean,
136
+ ): void {
137
+ this.#assertPresence(
138
+ "assertNotQueued",
139
+ "queued",
140
+ this.#queued,
141
+ arg,
142
+ findFn,
143
+ false,
144
+ );
145
+ }
146
+
147
+ assertSentCount(count: number): void;
148
+ assertSentCount(
149
+ mailConstructor: MailConstructor<BaseMail>,
150
+ count: number,
151
+ ): void;
152
+ assertSentCount(
153
+ arg: number | MailConstructor<BaseMail>,
154
+ count?: number,
155
+ ): void {
156
+ this.#assertCount("assertSentCount", this.#sent, arg, count);
157
+ }
158
+
159
+ assertQueuedCount(count: number): void;
160
+ assertQueuedCount(
161
+ mailConstructor: MailConstructor<BaseMail>,
162
+ count: number,
163
+ ): void;
164
+ assertQueuedCount(
165
+ arg: number | MailConstructor<BaseMail>,
166
+ count?: number,
167
+ ): void {
168
+ this.#assertCount("assertQueuedCount", this.#queued, arg, count);
169
+ }
170
+
171
+ assertNoneSent(): void {
172
+ if (this.#sent.length !== 0) {
173
+ throw new Error(
174
+ `mail.assertNoneSent() failed — expected zero sent messages, found ${this.#sent.length}.\n${describeCaptured(this.#sent)}`,
175
+ );
176
+ }
177
+ }
178
+
179
+ assertNoneQueued(): void {
180
+ if (this.#queued.length !== 0) {
181
+ throw new Error(
182
+ `mail.assertNoneQueued() failed — expected zero queued messages, found ${this.#queued.length}.\n${describeCaptured(this.#queued)}`,
183
+ );
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Shared body for the present/absent assertions. `expectPresent` flips the
189
+ * pass/fail sense so `assertSent` and `assertNotSent` share one code path.
190
+ */
191
+ #assertPresence(
192
+ method: string,
193
+ bucket: string,
194
+ entries: Capture[],
195
+ arg: FakeMailPredicateArg | MailConstructor<BaseMail>,
196
+ findFn: ((mail: BaseMail) => boolean) | undefined,
197
+ expectPresent: boolean,
198
+ ): void {
199
+ const found = isMailConstructor(arg)
200
+ ? entries.some(
201
+ (e) =>
202
+ e.mail !== undefined &&
203
+ e.mail instanceof arg &&
204
+ (findFn ? findFn(e.mail) : true),
205
+ )
206
+ : entries.some((e) => makeMatcher(arg)(e.message));
207
+ if (found === expectPresent) return;
208
+ const target = isMailConstructor(arg)
209
+ ? `an instance of ${arg.name}`
210
+ : describePredicate(arg);
211
+ const reason = expectPresent
212
+ ? `no ${bucket} message matches ${target}`
213
+ : `at least one ${bucket} message matches ${target}`;
67
214
  throw new Error(
68
- `mail.assertNotSent() failed — at least one captured message matches ${describePredicate(predicate)}.\n${describeCaptured(this.#captured)}`,
215
+ `mail.${method}() failed — ${reason}.\n${describeCaptured(entries)}`,
69
216
  );
70
217
  }
218
+
219
+ #assertCount(
220
+ method: string,
221
+ entries: Capture[],
222
+ arg: number | MailConstructor<BaseMail>,
223
+ count: number | undefined,
224
+ ): void {
225
+ if (typeof arg === "number") {
226
+ if (entries.length !== arg) {
227
+ throw new Error(
228
+ `mail.${method}() failed — expected ${arg}, found ${entries.length}.\n${describeCaptured(entries)}`,
229
+ );
230
+ }
231
+ return;
232
+ }
233
+ const expected = count ?? 0;
234
+ const actual = entries.filter(
235
+ (e) => e.mail !== undefined && e.mail instanceof arg,
236
+ ).length;
237
+ if (actual !== expected) {
238
+ throw new Error(
239
+ `mail.${method}() failed — expected ${expected} of ${arg.name}, found ${actual}.\n${describeCaptured(entries)}`,
240
+ );
241
+ }
242
+ }
243
+ }
244
+
245
+ function isMailConstructor(
246
+ arg: FakeMailPredicateArg | MailConstructor<BaseMail>,
247
+ ): arg is MailConstructor<BaseMail> {
248
+ return (
249
+ typeof arg === "function" &&
250
+ typeof arg.prototype === "object" &&
251
+ arg.prototype instanceof BaseMail
252
+ );
253
+ }
254
+
255
+ function cloneMessage(m: MailMessage): MailMessage {
256
+ return {
257
+ from: m.from,
258
+ to: m.to.slice(),
259
+ cc: m.cc.slice(),
260
+ bcc: m.bcc.slice(),
261
+ replyTo: m.replyTo,
262
+ subject: m.subject,
263
+ html: m.html,
264
+ text: m.text,
265
+ attachments: m.attachments.slice(),
266
+ headers: { ...m.headers },
267
+ priority: m.priority,
268
+ messageId: m.messageId,
269
+ inReplyTo: m.inReplyTo,
270
+ references: m.references ? m.references.slice() : undefined,
271
+ };
71
272
  }
72
273
 
73
274
  function makeMatcher(
@@ -102,11 +303,11 @@ function describePredicate(predicate: FakeMailPredicateArg): string {
102
303
  return JSON.stringify(predicate);
103
304
  }
104
305
 
105
- function describeCaptured(captured: MailMessage[]): string {
306
+ function describeCaptured(captured: Capture[]): string {
106
307
  if (captured.length === 0) return "Captured: (none)";
107
308
  const lines = captured.map(
108
- (m, i) =>
109
- ` [${i}] to=[${m.to.join(", ")}] subject="${m.subject}" from="${m.from}"`,
309
+ (e, i) =>
310
+ ` [${i}] to=[${e.message.to.join(", ")}] subject="${e.message.subject}" from="${e.message.from}"`,
110
311
  );
111
312
  return `Captured (${captured.length}):\n${lines.join("\n")}`;
112
313
  }
@@ -0,0 +1,174 @@
1
+ import { Buffer } from "node:buffer";
2
+ import {
3
+ type MailMessage,
4
+ type MailSendOutcome,
5
+ type MailTransport,
6
+ registerTransport,
7
+ } from "../Mail.js";
8
+ import { RoverError } from "../RoverError.js";
9
+ import { wrapFetchNetworkError } from "./fetchError.js";
10
+
11
+ const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
12
+ const normalizeConfig = (v: string): string => stripCrlf(v).trim();
13
+ const MAX_PROVIDER_MESSAGE = 16 * 1024;
14
+ const capMessage = (s: string): string =>
15
+ s.length <= MAX_PROVIDER_MESSAGE
16
+ ? s
17
+ : `${s.slice(0, MAX_PROVIDER_MESSAGE)}...[truncated]`;
18
+ /** Redact Basic/Bearer tokens if the upstream echoes our own request headers. */
19
+ const redactSecrets = (s: string): string =>
20
+ s
21
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
22
+ .replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
23
+
24
+ interface BrevoContact {
25
+ email: string;
26
+ name?: string;
27
+ }
28
+
29
+ interface BrevoBody {
30
+ sender: BrevoContact;
31
+ to: BrevoContact[];
32
+ cc?: BrevoContact[];
33
+ bcc?: BrevoContact[];
34
+ replyTo?: BrevoContact;
35
+ subject: string;
36
+ htmlContent?: string;
37
+ textContent?: string;
38
+ headers?: Record<string, string>;
39
+ attachment?: Array<{ name: string; content: string }>;
40
+ }
41
+
42
+ /**
43
+ * Brevo (formerly Sendinblue) transactional email transport. Talks to the
44
+ * `v3/smtp/email` REST API over `fetch`, mirroring the `@adonisjs/mail` Brevo
45
+ * transport. Same conventions as the other fetch-based transports (Resend /
46
+ * SES): CRLF stripping at the wire boundary, secret redaction, `retry-after`
47
+ * surfacing.
48
+ */
49
+ export class BrevoTransport implements MailTransport {
50
+ #apiKey: string;
51
+ #baseUrl: string;
52
+
53
+ constructor(config: Record<string, unknown>) {
54
+ const apiKey =
55
+ typeof config.apiKey === "string"
56
+ ? normalizeConfig(config.apiKey)
57
+ : typeof config.key === "string"
58
+ ? normalizeConfig(config.key)
59
+ : "";
60
+ if (!apiKey) {
61
+ throw new RoverError(
62
+ "MAIL_PROVIDER_CONFIG",
63
+ "Brevo transport requires apiKey",
64
+ { hint: "Set { apiKey } in your mail config." },
65
+ );
66
+ }
67
+ this.#apiKey = apiKey;
68
+ this.#baseUrl =
69
+ typeof config.baseUrl === "string"
70
+ ? normalizeConfig(config.baseUrl).replace(/\/+$/, "")
71
+ : "https://api.brevo.com";
72
+ }
73
+
74
+ async send(message: MailMessage): Promise<MailSendOutcome> {
75
+ if (
76
+ message.to.length === 0 &&
77
+ message.cc.length === 0 &&
78
+ message.bcc.length === 0
79
+ ) {
80
+ throw new RoverError(
81
+ "MAIL_PROVIDER_CONFIG",
82
+ "Mail message has no recipients",
83
+ { hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
84
+ );
85
+ }
86
+ const body: BrevoBody = {
87
+ sender: parseContact(message.from),
88
+ to: (message.to.length > 0 ? message.to : [message.from]).map(
89
+ parseContact,
90
+ ),
91
+ subject: stripCrlf(message.subject),
92
+ };
93
+ if (message.cc.length) body.cc = message.cc.map(parseContact);
94
+ if (message.bcc.length) body.bcc = message.bcc.map(parseContact);
95
+ if (message.replyTo) body.replyTo = parseContact(message.replyTo);
96
+ if (message.html) body.htmlContent = message.html;
97
+ if (message.text) body.textContent = message.text;
98
+ const customHeaders = Object.entries(message.headers);
99
+ if (customHeaders.length > 0) {
100
+ body.headers = {};
101
+ for (const [k, v] of customHeaders) {
102
+ body.headers[stripCrlf(k)] = Array.isArray(v)
103
+ ? v.map(stripCrlf).join(", ")
104
+ : stripCrlf(v);
105
+ }
106
+ }
107
+ if (message.attachments.length > 0) {
108
+ body.attachment = message.attachments.map((att) => ({
109
+ name: stripCrlf(att.filename),
110
+ content: Buffer.from(att.content as Buffer | string).toString("base64"),
111
+ }));
112
+ }
113
+
114
+ let res: Response;
115
+ try {
116
+ res = await fetch(`${this.#baseUrl}/v3/smtp/email`, {
117
+ method: "POST",
118
+ headers: {
119
+ "api-key": this.#apiKey,
120
+ "Content-Type": "application/json",
121
+ Accept: "application/json",
122
+ },
123
+ body: JSON.stringify(body),
124
+ });
125
+ } catch (err) {
126
+ throw wrapFetchNetworkError("brevo", err);
127
+ }
128
+ if (!res.ok) {
129
+ const providerMessage = redactSecrets(capMessage(await res.text()));
130
+ const retryAfter = res.headers.get("retry-after") ?? undefined;
131
+ const ctx: Record<string, string> = {
132
+ provider: "brevo",
133
+ upstreamStatus: String(res.status),
134
+ providerMessage,
135
+ };
136
+ if (retryAfter) ctx.retryAfter = retryAfter;
137
+ throw new RoverError(
138
+ "MAIL_PROVIDER_ERROR",
139
+ `Brevo returned ${res.status}`,
140
+ {
141
+ hint: "Inspect `context.upstreamStatus` to decide retry eligibility. `context.retryAfter` (when set) carries the provider's backoff hint in seconds.",
142
+ context: ctx,
143
+ },
144
+ );
145
+ }
146
+ // Success: Brevo returns `{ messageId: "<id>" }`.
147
+ try {
148
+ const parsed = (await res.json()) as { messageId?: string };
149
+ if (typeof parsed.messageId === "string" && parsed.messageId.length > 0) {
150
+ return { providerId: parsed.messageId };
151
+ }
152
+ } catch {
153
+ // Empty / non-JSON body — fall back to generated id.
154
+ }
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Split a possibly-formatted address (`"Name" <addr>` / `Name <addr>` / bare
161
+ * `addr`) into Brevo's `{ email, name? }` contact shape.
162
+ */
163
+ function parseContact(input: string): BrevoContact {
164
+ const s = stripCrlf(input).trim();
165
+ const match = s.match(/^(.*)<([^>]+)>\s*$/);
166
+ if (match) {
167
+ const email = match[2].trim();
168
+ const name = match[1].trim().replace(/^"|"$/g, "").trim();
169
+ return name ? { email, name } : { email };
170
+ }
171
+ return { email: s };
172
+ }
173
+
174
+ registerTransport("brevo", (config) => new BrevoTransport(config));
@@ -128,7 +128,9 @@ export class MailgunTransport implements MailTransport {
128
128
  if (message.html) data.html = message.html;
129
129
  if (message.replyTo) data["h:Reply-To"] = stripCrlf(message.replyTo);
130
130
  for (const [k, v] of Object.entries(message.headers)) {
131
- data[`h:${stripCrlf(k)}`] = stripCrlf(v);
131
+ data[`h:${stripCrlf(k)}`] = Array.isArray(v)
132
+ ? v.map(stripCrlf).join(", ")
133
+ : stripCrlf(v);
132
134
  }
133
135
  if (message.attachments.length > 0) {
134
136
  data.attachment = message.attachments.map((att) => {
@@ -1,11 +1,11 @@
1
1
  import { Buffer } from "node:buffer";
2
- import { RoverError } from "../RoverError.js";
3
2
  import {
4
3
  type MailMessage,
5
4
  type MailSendOutcome,
6
5
  type MailTransport,
7
6
  registerTransport,
8
7
  } from "../Mail.js";
8
+ import { RoverError } from "../RoverError.js";
9
9
  import { wrapFetchNetworkError } from "./fetchError.js";
10
10
 
11
11
  const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
@@ -92,7 +92,9 @@ export class ResendTransport implements MailTransport {
92
92
  if (customHeaders.length > 0) {
93
93
  body.headers = {};
94
94
  for (const [k, v] of customHeaders) {
95
- body.headers[stripCrlf(k)] = stripCrlf(v);
95
+ body.headers[stripCrlf(k)] = Array.isArray(v)
96
+ ? v.map(stripCrlf).join(", ")
97
+ : stripCrlf(v);
96
98
  }
97
99
  }
98
100
 
@@ -1,4 +1,3 @@
1
- import { RoverError } from "../RoverError.js";
2
1
  import sgMail, {
3
2
  type MailDataRequired,
4
3
  type MailService,
@@ -9,6 +8,7 @@ import {
9
8
  type MailTransport,
10
9
  registerTransport,
11
10
  } from "../Mail.js";
11
+ import { RoverError } from "../RoverError.js";
12
12
 
13
13
  const stripCrlf = (v: string): string => v.replace(/[\r\n]/g, "");
14
14
  const normalizeConfig = (v: string): string => stripCrlf(v).trim();
@@ -92,7 +92,7 @@ export class SendGridTransport implements MailTransport {
92
92
  headers: Object.fromEntries(
93
93
  Object.entries(message.headers).map(([k, v]) => [
94
94
  stripCrlf(k),
95
- stripCrlf(v),
95
+ Array.isArray(v) ? v.map(stripCrlf).join(", ") : stripCrlf(v),
96
96
  ]),
97
97
  ),
98
98
  }
@@ -281,7 +281,9 @@ function buildRawMime(message: MailMessage): string {
281
281
  ]);
282
282
  for (const [k, v] of Object.entries(message.headers)) {
283
283
  if (reserved.has(k.toLowerCase())) continue;
284
- parts.push(`${stripCrlf(k)}: ${encodeHeaderWord(v)}`);
284
+ parts.push(
285
+ `${stripCrlf(k)}: ${encodeHeaderWord(Array.isArray(v) ? v.join(", ") : v)}`,
286
+ );
285
287
  }
286
288
 
287
289
  const hasAttachments = message.attachments.length > 0;