@c9up/rover 0.1.6 → 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.
- package/README.md +3 -1
- package/dist/BaseMail.d.ts +2 -1
- package/dist/BaseMail.d.ts.map +1 -1
- package/dist/BaseMail.js +9 -4
- package/dist/BaseMail.js.map +1 -1
- package/dist/Mail.d.ts +65 -11
- package/dist/Mail.d.ts.map +1 -1
- package/dist/Mail.js +142 -49
- package/dist/Mail.js.map +1 -1
- package/dist/MessageBuilder.d.ts +44 -7
- package/dist/MessageBuilder.d.ts.map +1 -1
- package/dist/MessageBuilder.js +57 -10
- package/dist/MessageBuilder.js.map +1 -1
- package/dist/format.d.ts +12 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +14 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/queue/MailJob.d.ts.map +1 -1
- package/dist/queue/MailJob.js.map +1 -1
- package/dist/queue/MemoryMailMessenger.d.ts +15 -0
- package/dist/queue/MemoryMailMessenger.d.ts.map +1 -0
- package/dist/queue/MemoryMailMessenger.js +42 -0
- package/dist/queue/MemoryMailMessenger.js.map +1 -0
- package/dist/testing/FakeMail.d.ts +30 -3
- package/dist/testing/FakeMail.d.ts.map +1 -1
- package/dist/testing/FakeMail.js +106 -28
- package/dist/testing/FakeMail.js.map +1 -1
- package/dist/transports/BrevoTransport.d.ts +14 -0
- package/dist/transports/BrevoTransport.d.ts.map +1 -0
- package/dist/transports/BrevoTransport.js +134 -0
- package/dist/transports/BrevoTransport.js.map +1 -0
- package/dist/transports/MailgunTransport.d.ts.map +1 -1
- package/dist/transports/MailgunTransport.js +3 -1
- package/dist/transports/MailgunTransport.js.map +1 -1
- package/dist/transports/ResendTransport.d.ts.map +1 -1
- package/dist/transports/ResendTransport.js +4 -2
- package/dist/transports/ResendTransport.js.map +1 -1
- package/dist/transports/SendGridTransport.d.ts.map +1 -1
- package/dist/transports/SendGridTransport.js +2 -2
- package/dist/transports/SendGridTransport.js.map +1 -1
- package/dist/transports/SesTransport.js +1 -1
- package/dist/transports/SesTransport.js.map +1 -1
- package/dist/transports/SparkPostTransport.d.ts +14 -0
- package/dist/transports/SparkPostTransport.d.ts.map +1 -0
- package/dist/transports/SparkPostTransport.js +144 -0
- package/dist/transports/SparkPostTransport.js.map +1 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +10 -1
- package/src/BaseMail.ts +17 -5
- package/src/Mail.ts +205 -62
- package/src/MessageBuilder.ts +103 -12
- package/src/format.ts +14 -0
- package/src/index.ts +5 -1
- package/src/queue/MailJob.ts +1 -1
- package/src/queue/MemoryMailMessenger.ts +45 -0
- package/src/testing/FakeMail.ts +232 -31
- package/src/transports/BrevoTransport.ts +174 -0
- package/src/transports/MailgunTransport.ts +3 -1
- package/src/transports/ResendTransport.ts +4 -2
- package/src/transports/SendGridTransport.ts +2 -2
- package/src/transports/SesTransport.ts +3 -1
- 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
|
+
}
|
package/src/testing/FakeMail.ts
CHANGED
|
@@ -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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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.#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.#
|
|
74
|
+
this.#sent = [];
|
|
75
|
+
this.#queued = [];
|
|
53
76
|
}
|
|
54
77
|
|
|
55
|
-
assertSent(predicate: FakeMailPredicateArg): void
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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:
|
|
306
|
+
function describeCaptured(captured: Capture[]): string {
|
|
106
307
|
if (captured.length === 0) return "Captured: (none)";
|
|
107
308
|
const lines = captured.map(
|
|
108
|
-
(
|
|
109
|
-
` [${i}] to=[${
|
|
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)}`] =
|
|
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)] =
|
|
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(
|
|
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;
|