@c9up/rover 0.1.3
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/LICENSE +21 -0
- package/README.md +91 -0
- package/dist/BaseMail.d.ts +28 -0
- package/dist/BaseMail.d.ts.map +1 -0
- package/dist/BaseMail.js +42 -0
- package/dist/BaseMail.js.map +1 -0
- package/dist/Mail.d.ts +166 -0
- package/dist/Mail.d.ts.map +1 -0
- package/dist/Mail.js +451 -0
- package/dist/Mail.js.map +1 -0
- package/dist/MessageBuilder.d.ts +38 -0
- package/dist/MessageBuilder.d.ts.map +1 -0
- package/dist/MessageBuilder.js +70 -0
- package/dist/MessageBuilder.js.map +1 -0
- package/dist/RoverError.d.ts +30 -0
- package/dist/RoverError.d.ts.map +1 -0
- package/dist/RoverError.js +33 -0
- package/dist/RoverError.js.map +1 -0
- package/dist/RoverProvider.d.ts +24 -0
- package/dist/RoverProvider.d.ts.map +1 -0
- package/dist/RoverProvider.js +51 -0
- package/dist/RoverProvider.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/queue/MailJob.d.ts +41 -0
- package/dist/queue/MailJob.d.ts.map +1 -0
- package/dist/queue/MailJob.js +75 -0
- package/dist/queue/MailJob.js.map +1 -0
- package/dist/retry.d.ts +20 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +101 -0
- package/dist/retry.js.map +1 -0
- package/dist/services/main.d.ts +20 -0
- package/dist/services/main.d.ts.map +1 -0
- package/dist/services/main.js +33 -0
- package/dist/services/main.js.map +1 -0
- package/dist/templating/SimpleTemplate.d.ts +7 -0
- package/dist/templating/SimpleTemplate.d.ts.map +1 -0
- package/dist/templating/SimpleTemplate.js +192 -0
- package/dist/templating/SimpleTemplate.js.map +1 -0
- package/dist/templating/loadNapi.d.ts +21 -0
- package/dist/templating/loadNapi.d.ts.map +1 -0
- package/dist/templating/loadNapi.js +115 -0
- package/dist/templating/loadNapi.js.map +1 -0
- package/dist/testing/FakeMail.d.ts +31 -0
- package/dist/testing/FakeMail.d.ts.map +1 -0
- package/dist/testing/FakeMail.js +90 -0
- package/dist/testing/FakeMail.js.map +1 -0
- package/dist/transports/MailgunTransport.d.ts +7 -0
- package/dist/transports/MailgunTransport.d.ts.map +1 -0
- package/dist/transports/MailgunTransport.js +149 -0
- package/dist/transports/MailgunTransport.js.map +1 -0
- package/dist/transports/ResendTransport.d.ts +7 -0
- package/dist/transports/ResendTransport.d.ts.map +1 -0
- package/dist/transports/ResendTransport.js +111 -0
- package/dist/transports/ResendTransport.js.map +1 -0
- package/dist/transports/SendGridTransport.d.ts +7 -0
- package/dist/transports/SendGridTransport.d.ts.map +1 -0
- package/dist/transports/SendGridTransport.js +165 -0
- package/dist/transports/SendGridTransport.js.map +1 -0
- package/dist/transports/SesTransport.d.ts +7 -0
- package/dist/transports/SesTransport.d.ts.map +1 -0
- package/dist/transports/SesTransport.js +334 -0
- package/dist/transports/SesTransport.js.map +1 -0
- package/dist/transports/fetchError.d.ts +13 -0
- package/dist/transports/fetchError.d.ts.map +1 -0
- package/dist/transports/fetchError.js +35 -0
- package/dist/transports/fetchError.js.map +1 -0
- package/dist/webhooks/context.d.ts +35 -0
- package/dist/webhooks/context.d.ts.map +1 -0
- package/dist/webhooks/context.js +7 -0
- package/dist/webhooks/context.js.map +1 -0
- package/dist/webhooks/mailgun.d.ts +13 -0
- package/dist/webhooks/mailgun.d.ts.map +1 -0
- package/dist/webhooks/mailgun.js +102 -0
- package/dist/webhooks/mailgun.js.map +1 -0
- package/dist/webhooks/resend.d.ts +16 -0
- package/dist/webhooks/resend.d.ts.map +1 -0
- package/dist/webhooks/resend.js +121 -0
- package/dist/webhooks/resend.js.map +1 -0
- package/dist/webhooks/sendgrid.d.ts +13 -0
- package/dist/webhooks/sendgrid.d.ts.map +1 -0
- package/dist/webhooks/sendgrid.js +104 -0
- package/dist/webhooks/sendgrid.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 +97 -0
- package/scripts/copy-napi.mjs +62 -0
- package/src/BaseMail.ts +52 -0
- package/src/Mail.ts +663 -0
- package/src/MessageBuilder.ts +101 -0
- package/src/RoverError.ts +44 -0
- package/src/RoverProvider.ts +72 -0
- package/src/config.ts +7 -0
- package/src/index.ts +30 -0
- package/src/queue/MailJob.ts +131 -0
- package/src/retry.ts +135 -0
- package/src/services/main.ts +41 -0
- package/src/templating/SimpleTemplate.ts +240 -0
- package/src/templating/loadNapi.ts +169 -0
- package/src/testing/FakeMail.ts +112 -0
- package/src/transports/MailgunTransport.ts +204 -0
- package/src/transports/ResendTransport.ts +147 -0
- package/src/transports/SendGridTransport.ts +246 -0
- package/src/transports/SesTransport.ts +394 -0
- package/src/transports/fetchError.ts +42 -0
- package/src/webhooks/context.ts +41 -0
- package/src/webhooks/mailgun.ts +134 -0
- package/src/webhooks/resend.ts +164 -0
- package/src/webhooks/sendgrid.ts +133 -0
package/src/Mail.ts
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { RoverError } from "./RoverError.js";
|
|
3
|
+
import nodemailer, { type Transporter } from "nodemailer";
|
|
4
|
+
import { BaseMail } from "./BaseMail.js";
|
|
5
|
+
import {
|
|
6
|
+
type MailAttachment,
|
|
7
|
+
type MailMessage,
|
|
8
|
+
MessageBuilder,
|
|
9
|
+
} from "./MessageBuilder.js";
|
|
10
|
+
import {
|
|
11
|
+
type BayQueueLike,
|
|
12
|
+
MAIL_JOB_NAME,
|
|
13
|
+
MailJobHandler,
|
|
14
|
+
} from "./queue/MailJob.js";
|
|
15
|
+
import {
|
|
16
|
+
computeBackoffMs,
|
|
17
|
+
isRetryableError,
|
|
18
|
+
type RetryConfig,
|
|
19
|
+
resolveRetryConfig,
|
|
20
|
+
} from "./retry.js";
|
|
21
|
+
import { setViewsRoot } from "./templating/SimpleTemplate.js";
|
|
22
|
+
import { FakeMail } from "./testing/FakeMail.js";
|
|
23
|
+
|
|
24
|
+
// `MailMessage` / `MailAttachment` / `MessageBuilder` live in MessageBuilder.ts
|
|
25
|
+
// so `BaseMail` can import the builder without importing this module — breaking
|
|
26
|
+
// the BaseMail ↔ Mail value cycle. Re-exported here to keep the public surface
|
|
27
|
+
// (`@c9up/rover` → these symbols come from `./Mail.js`) unchanged.
|
|
28
|
+
export type { MailAttachment, MailMessage };
|
|
29
|
+
export { MessageBuilder };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mail — send emails via pluggable transports.
|
|
33
|
+
*
|
|
34
|
+
* Like AdonisJS Mail:
|
|
35
|
+
* await mail.send((message) => {
|
|
36
|
+
* message.to('user@example.com')
|
|
37
|
+
* message.subject('Welcome')
|
|
38
|
+
* message.html('<h1>Hello</h1>')
|
|
39
|
+
* })
|
|
40
|
+
*
|
|
41
|
+
* Transports: SMTP, log (dev), custom.
|
|
42
|
+
* Configured via config/mail.ts.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export interface MailSendResult {
|
|
46
|
+
/** Provider-returned message id (Mailgun `mg-abc`, SendGrid `X-Message-Id`, etc.). */
|
|
47
|
+
providerId?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Return shape of `MailTransport.send`. Transports that don't surface a
|
|
52
|
+
* provider id may `return` nothing (implicit `undefined`); transports that
|
|
53
|
+
* do return `{ providerId }`. `undefined` rather than `void` in the union
|
|
54
|
+
* keeps the linter happy (`noConfusingVoidType`) while preserving the
|
|
55
|
+
* "no result" semantics.
|
|
56
|
+
*/
|
|
57
|
+
export type MailSendOutcome = MailSendResult | undefined;
|
|
58
|
+
|
|
59
|
+
export interface MailTransport {
|
|
60
|
+
send(message: MailMessage): Promise<MailSendOutcome>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Structural interface for the event bus `Emitter` — peer-dep friendly.
|
|
65
|
+
* Rover never hard-imports the event bus.
|
|
66
|
+
*/
|
|
67
|
+
export interface EmitterLike {
|
|
68
|
+
emit(event: string, data: unknown): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MailSentEvent {
|
|
72
|
+
messageId: string;
|
|
73
|
+
to: string[];
|
|
74
|
+
cc: string[];
|
|
75
|
+
bcc: string[];
|
|
76
|
+
transportName: string;
|
|
77
|
+
timestamp: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MailFailedEvent {
|
|
81
|
+
messageId: string;
|
|
82
|
+
to: string[];
|
|
83
|
+
cc: string[];
|
|
84
|
+
bcc: string[];
|
|
85
|
+
transportName: string;
|
|
86
|
+
error: {
|
|
87
|
+
code: string;
|
|
88
|
+
message: string;
|
|
89
|
+
upstreamStatus?: number;
|
|
90
|
+
upstreamStatusRaw?: string;
|
|
91
|
+
attempts: number;
|
|
92
|
+
};
|
|
93
|
+
timestamp: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface MailConfig {
|
|
97
|
+
default: string;
|
|
98
|
+
from: string;
|
|
99
|
+
transports: Record<
|
|
100
|
+
string,
|
|
101
|
+
{ transport: string; retry?: RetryConfig; [key: string]: unknown }
|
|
102
|
+
>;
|
|
103
|
+
/** Root directory for `htmlView(path, data)` template lookups. Default: `"resources/views/emails"`. */
|
|
104
|
+
viewsRoot?: string;
|
|
105
|
+
/** Optional Bay queue tuning for `sendLater()`. */
|
|
106
|
+
queue?: { name?: string; maxAttempts?: number };
|
|
107
|
+
/** Process-wide retry defaults. Overridden per-transport via `transports[name].retry`. */
|
|
108
|
+
retry?: RetryConfig;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Event hooks invoked by the internal dispatch loop. Default implementations
|
|
113
|
+
* are no-ops; when an event-bus `EmitterLike` is wired, the hooks emit
|
|
114
|
+
* `mail.sent` / `mail.failed`. Tests inject spies.
|
|
115
|
+
*/
|
|
116
|
+
export interface MailHooks {
|
|
117
|
+
onSent?(event: MailSentEvent): void;
|
|
118
|
+
onFailed?(event: MailFailedEvent): void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Message builder — fluent API for composing an email.
|
|
123
|
+
*/
|
|
124
|
+
/**
|
|
125
|
+
* SMTP transport — thin wrapper around `nodemailer`. Nodemailer handles
|
|
126
|
+
* the SMTP state machine, TLS / STARTTLS negotiation, AUTH LOGIN /
|
|
127
|
+
* PLAIN / XOAUTH2, dot-stuffing, MIME assembly, attachments, and address
|
|
128
|
+
* encoding for 10+ years. We forward the `MailMessage` shape and surface
|
|
129
|
+
* the `messageId` returned by the server.
|
|
130
|
+
*/
|
|
131
|
+
export class SmtpTransport implements MailTransport {
|
|
132
|
+
#transporter: Transporter;
|
|
133
|
+
|
|
134
|
+
constructor(config: Record<string, unknown>) {
|
|
135
|
+
// Host is required — defaulting to "localhost" silently hid env-var
|
|
136
|
+
// misconfigurations (SMTP_HOST unset) until the SMTP connection
|
|
137
|
+
// timed out against a nonexistent local server. Fail at
|
|
138
|
+
// construction with an actionable message instead.
|
|
139
|
+
if (config.host !== undefined && typeof config.host !== "string") {
|
|
140
|
+
throw new RoverError(
|
|
141
|
+
"MAIL_PROVIDER_CONFIG",
|
|
142
|
+
`SMTP host must be a string, got ${typeof config.host}`,
|
|
143
|
+
{ hint: "Set config.host to your SMTP server hostname." },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (typeof config.host !== "string" || config.host.length === 0) {
|
|
147
|
+
throw new RoverError("MAIL_PROVIDER_CONFIG", "SMTP host is required", {
|
|
148
|
+
hint: "Set config.host (e.g. process.env.SMTP_HOST). Use the fake / log transports for local development.",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const host = config.host;
|
|
152
|
+
const port = typeof config.port === "number" ? config.port : 587;
|
|
153
|
+
const secure = typeof config.secure === "boolean" ? config.secure : false;
|
|
154
|
+
const user = typeof config.user === "string" ? config.user : undefined;
|
|
155
|
+
const pass = typeof config.pass === "string" ? config.pass : undefined;
|
|
156
|
+
const requireTLS =
|
|
157
|
+
typeof config.requireTLS === "boolean" ? config.requireTLS : undefined;
|
|
158
|
+
// Partial auth config (only user OR only pass) is almost always a typo —
|
|
159
|
+
// fail fast rather than connect anonymously and let the server reject.
|
|
160
|
+
if ((user && !pass) || (!user && pass)) {
|
|
161
|
+
throw new RoverError(
|
|
162
|
+
"MAIL_PROVIDER_CONFIG",
|
|
163
|
+
"SMTP auth requires both `user` and `pass` or neither",
|
|
164
|
+
{
|
|
165
|
+
hint: "Check your env vars — one half of the credential pair is missing.",
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
// Allow DI for tests: if the config carries a pre-built transporter,
|
|
170
|
+
// use it instead of nodemailer.createTransport.
|
|
171
|
+
const injected = config._transporter;
|
|
172
|
+
if (injected && typeof injected === "object" && "sendMail" in injected) {
|
|
173
|
+
this.#transporter = injected as Transporter;
|
|
174
|
+
} else {
|
|
175
|
+
this.#transporter = nodemailer.createTransport({
|
|
176
|
+
host,
|
|
177
|
+
port,
|
|
178
|
+
secure,
|
|
179
|
+
requireTLS,
|
|
180
|
+
auth: user && pass ? { user, pass } : undefined,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async send(message: MailMessage): Promise<MailSendOutcome> {
|
|
186
|
+
if (
|
|
187
|
+
message.to.length === 0 &&
|
|
188
|
+
message.cc.length === 0 &&
|
|
189
|
+
message.bcc.length === 0
|
|
190
|
+
) {
|
|
191
|
+
throw new RoverError(
|
|
192
|
+
"MAIL_PROVIDER_CONFIG",
|
|
193
|
+
"Mail message has no recipients",
|
|
194
|
+
{ hint: "Set at least one `to`, `cc`, or `bcc` before sending." },
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const info = await this.#transporter.sendMail({
|
|
199
|
+
from: message.from,
|
|
200
|
+
to: message.to,
|
|
201
|
+
cc: message.cc.length ? message.cc : undefined,
|
|
202
|
+
bcc: message.bcc.length ? message.bcc : undefined,
|
|
203
|
+
replyTo: message.replyTo,
|
|
204
|
+
subject: message.subject,
|
|
205
|
+
html: message.html,
|
|
206
|
+
text: message.text,
|
|
207
|
+
headers: Object.keys(message.headers).length
|
|
208
|
+
? message.headers
|
|
209
|
+
: undefined,
|
|
210
|
+
attachments: message.attachments.length
|
|
211
|
+
? message.attachments.map((att) => ({
|
|
212
|
+
filename: att.filename,
|
|
213
|
+
content: att.content,
|
|
214
|
+
contentType: att.contentType,
|
|
215
|
+
}))
|
|
216
|
+
: undefined,
|
|
217
|
+
});
|
|
218
|
+
if (info.messageId) return { providerId: info.messageId };
|
|
219
|
+
return undefined;
|
|
220
|
+
} catch (err) {
|
|
221
|
+
throw wrapSmtpError(err);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Normalise nodemailer / socket errors into the uniform `MAIL_PROVIDER_ERROR`
|
|
228
|
+
* shape used by the rest of the library. Preserves the `code` field (errno)
|
|
229
|
+
* in `context.networkCode` so `isRetryableError` can classify transient
|
|
230
|
+
* network failures without losing the root cause.
|
|
231
|
+
*/
|
|
232
|
+
function wrapSmtpError(err: unknown): RoverError {
|
|
233
|
+
if (err instanceof RoverError) return err;
|
|
234
|
+
const anyErr = err as {
|
|
235
|
+
code?: string | number;
|
|
236
|
+
responseCode?: number;
|
|
237
|
+
message?: string;
|
|
238
|
+
};
|
|
239
|
+
const status =
|
|
240
|
+
typeof anyErr.responseCode === "number" ? anyErr.responseCode : 0;
|
|
241
|
+
const ctx: Record<string, string> = {
|
|
242
|
+
provider: "smtp",
|
|
243
|
+
upstreamStatus: String(status),
|
|
244
|
+
providerMessage: anyErr.message ?? "unknown",
|
|
245
|
+
};
|
|
246
|
+
if (typeof anyErr.code === "string") {
|
|
247
|
+
ctx.networkCode = anyErr.code;
|
|
248
|
+
}
|
|
249
|
+
return new RoverError(
|
|
250
|
+
"MAIL_PROVIDER_ERROR",
|
|
251
|
+
`SMTP failed: ${anyErr.message ?? "unknown"}`,
|
|
252
|
+
{
|
|
253
|
+
hint: "Inspect `context.networkCode` (ECONNRESET/ETIMEDOUT/...) or `context.upstreamStatus` (SMTP response code) to decide retry eligibility.",
|
|
254
|
+
context: ctx,
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Log transport — logs emails to console (development).
|
|
261
|
+
*/
|
|
262
|
+
export class LogTransport implements MailTransport {
|
|
263
|
+
async send(message: MailMessage): Promise<MailSendOutcome> {
|
|
264
|
+
console.log(
|
|
265
|
+
`[MAIL] To: ${message.to.join(", ")} | Subject: ${message.subject}`,
|
|
266
|
+
);
|
|
267
|
+
if (message.text) console.log(` Body: ${message.text.slice(0, 200)}`);
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type MailTransportFactory = (
|
|
273
|
+
config: Record<string, unknown>,
|
|
274
|
+
) => MailTransport;
|
|
275
|
+
|
|
276
|
+
const transportFactories: Record<string, MailTransportFactory> = {
|
|
277
|
+
smtp: (config) => new SmtpTransport(config),
|
|
278
|
+
log: () => new LogTransport(),
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Register a custom transport factory globally.
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* registerTransport('mailgun', (config) => new MailgunTransport(config))
|
|
286
|
+
* // Now config/mail.ts can reference `transport: 'mailgun'`
|
|
287
|
+
*/
|
|
288
|
+
export function registerTransport(
|
|
289
|
+
name: string,
|
|
290
|
+
factory: MailTransportFactory,
|
|
291
|
+
): void {
|
|
292
|
+
transportFactories[name] = factory;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Mail manager — send emails via configured transport.
|
|
297
|
+
*/
|
|
298
|
+
export class Mail {
|
|
299
|
+
#transports: Map<string, MailTransport> = new Map();
|
|
300
|
+
#defaultTransport: string;
|
|
301
|
+
#defaultFrom: string;
|
|
302
|
+
#fakeSnapshot: { transportName: string; original: MailTransport } | null =
|
|
303
|
+
null;
|
|
304
|
+
#queue: BayQueueLike | null = null;
|
|
305
|
+
#queueName: string;
|
|
306
|
+
#queueMaxAttempts: number;
|
|
307
|
+
#globalRetry: RetryConfig | undefined;
|
|
308
|
+
#transportRetry: Map<string, RetryConfig> = new Map();
|
|
309
|
+
#hooks: MailHooks;
|
|
310
|
+
#emitter: EmitterLike | null;
|
|
311
|
+
|
|
312
|
+
constructor(
|
|
313
|
+
config: MailConfig,
|
|
314
|
+
options?: {
|
|
315
|
+
queue?: BayQueueLike;
|
|
316
|
+
hooks?: MailHooks;
|
|
317
|
+
emitter?: EmitterLike;
|
|
318
|
+
},
|
|
319
|
+
) {
|
|
320
|
+
this.#defaultTransport = config.default;
|
|
321
|
+
this.#defaultFrom = config.from;
|
|
322
|
+
// Bay uses a single `name` for both `register(name, handler)` and
|
|
323
|
+
// `dispatch(name, payload)`. Default to the canonical mail job name.
|
|
324
|
+
this.#queueName = config.queue?.name ?? MAIL_JOB_NAME;
|
|
325
|
+
this.#queueMaxAttempts = config.queue?.maxAttempts ?? 3;
|
|
326
|
+
this.#globalRetry = config.retry;
|
|
327
|
+
this.#hooks = options?.hooks ?? {};
|
|
328
|
+
this.#emitter = options?.emitter ?? null;
|
|
329
|
+
if (config.viewsRoot !== undefined) {
|
|
330
|
+
setViewsRoot(config.viewsRoot);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const [name, transportConfig] of Object.entries(config.transports)) {
|
|
334
|
+
const factory = transportFactories[transportConfig.transport];
|
|
335
|
+
if (!factory) {
|
|
336
|
+
throw new RoverError(
|
|
337
|
+
"MAIL_UNKNOWN_TRANSPORT",
|
|
338
|
+
`Unknown mail transport type '${transportConfig.transport}' (configured under name '${name}')`,
|
|
339
|
+
{
|
|
340
|
+
hint: "Register the transport with registerTransport() before constructing Mail, or fix the typo in config.mail.transports[*].transport.",
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
this.#transports.set(name, factory(transportConfig));
|
|
345
|
+
if (transportConfig.retry) {
|
|
346
|
+
this.#transportRetry.set(name, transportConfig.retry);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (options?.queue) {
|
|
351
|
+
this.#queue = options.queue;
|
|
352
|
+
this.#queue.register(this.#queueName, new MailJobHandler(this));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Send an email using the fluent message builder. */
|
|
357
|
+
async send(
|
|
358
|
+
callback: (message: MessageBuilder) => void,
|
|
359
|
+
transport?: string,
|
|
360
|
+
): Promise<void>;
|
|
361
|
+
/** Send an email using a class-based `BaseMail` instance. */
|
|
362
|
+
async send(instance: BaseMail, transport?: string): Promise<void>;
|
|
363
|
+
async send(
|
|
364
|
+
arg: ((message: MessageBuilder) => void) | BaseMail,
|
|
365
|
+
transport?: string,
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
const transportName = transport ?? this.#defaultTransport;
|
|
368
|
+
// Validate transport up-front before running any callback / prepare() side effects.
|
|
369
|
+
if (!this.#transports.has(transportName)) {
|
|
370
|
+
throw new Error(`Mail transport '${transportName}' not configured`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const message = await this.#buildMessage(arg);
|
|
374
|
+
await this.dispatchMessage(message, transportName);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Enqueue a send onto the Bay queue. Returns the job id. Throws
|
|
379
|
+
* `MAIL_QUEUE_REQUIRED` if no `QueueManager` was wired through the
|
|
380
|
+
* constructor options.
|
|
381
|
+
*/
|
|
382
|
+
async sendLater(
|
|
383
|
+
arg: ((message: MessageBuilder) => void) | BaseMail,
|
|
384
|
+
options?: { transport?: string; queue?: string },
|
|
385
|
+
): Promise<string> {
|
|
386
|
+
if (this.#queue === null) {
|
|
387
|
+
throw new RoverError(
|
|
388
|
+
"MAIL_QUEUE_REQUIRED",
|
|
389
|
+
"mail.sendLater() requires @c9up/bay QueueManager",
|
|
390
|
+
{
|
|
391
|
+
hint: "Register @c9up/bay and pass the QueueManager to Mail via RoverProvider, or use mail.send() for synchronous delivery.",
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
const message = await this.#buildMessage(arg);
|
|
396
|
+
const queueName = options?.queue ?? this.#queueName;
|
|
397
|
+
return this.#queue.dispatch(
|
|
398
|
+
queueName,
|
|
399
|
+
{ message, transport: options?.transport },
|
|
400
|
+
{ maxAttempts: this.#queueMaxAttempts },
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Shared transport-resolve-then-send helper used by `send()` and `MailJobHandler`.
|
|
406
|
+
* Applies in-process retry with exponential backoff per `RetryConfig` resolution
|
|
407
|
+
* (per-transport > global > library default). Fires `onSent` / `onFailed` hooks.
|
|
408
|
+
*
|
|
409
|
+
* `overrideRetry` (optional) wins over config-level retry; `MailJobHandler` passes
|
|
410
|
+
* `{ maxAttempts: 1 }` so queue-level and sync-level retries don't compound.
|
|
411
|
+
*/
|
|
412
|
+
async dispatchMessage(
|
|
413
|
+
message: MailMessage,
|
|
414
|
+
transportName?: string,
|
|
415
|
+
overrideRetry?: RetryConfig,
|
|
416
|
+
): Promise<void> {
|
|
417
|
+
// Defense-in-depth: queue payloads bypass `#buildMessage`, so a
|
|
418
|
+
// malformed message deserialised from storage would otherwise reach
|
|
419
|
+
// the transport with the cryptic provider error A2 was meant to
|
|
420
|
+
// eliminate. Cheap re-validation on every dispatch keeps the
|
|
421
|
+
// failure mode uniform across send() and queue dequeue.
|
|
422
|
+
validateMailMessage(message);
|
|
423
|
+
const name = transportName ?? this.#defaultTransport;
|
|
424
|
+
const t = this.#transports.get(name);
|
|
425
|
+
if (!t) throw new Error(`Mail transport '${name}' not configured`);
|
|
426
|
+
|
|
427
|
+
const retry = overrideRetry
|
|
428
|
+
? resolveRetryConfig(undefined, overrideRetry)
|
|
429
|
+
: resolveRetryConfig(this.#globalRetry, this.#transportRetry.get(name));
|
|
430
|
+
|
|
431
|
+
const generatedId = randomBytes(16).toString("hex");
|
|
432
|
+
let lastError: unknown;
|
|
433
|
+
|
|
434
|
+
for (let attempt = 1; attempt <= retry.maxAttempts; attempt += 1) {
|
|
435
|
+
let sendResult: MailSendOutcome;
|
|
436
|
+
try {
|
|
437
|
+
sendResult = await t.send(message);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
lastError = err;
|
|
440
|
+
const retryable = isRetryableError(err);
|
|
441
|
+
if (!retryable || attempt === retry.maxAttempts) {
|
|
442
|
+
const annotated = this.#withAttempts(err, attempt);
|
|
443
|
+
this.#fireFailed({
|
|
444
|
+
messageId: generatedId,
|
|
445
|
+
to: message.to.slice(),
|
|
446
|
+
cc: message.cc.slice(),
|
|
447
|
+
bcc: message.bcc.slice(),
|
|
448
|
+
transportName: name,
|
|
449
|
+
error: errorDescriptor(annotated, attempt),
|
|
450
|
+
timestamp: Date.now(),
|
|
451
|
+
});
|
|
452
|
+
throw annotated;
|
|
453
|
+
}
|
|
454
|
+
const delay = computeBackoffMs(attempt, retry, err);
|
|
455
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
// Success path lives outside the try/catch so a throw inside the
|
|
459
|
+
// success hook cannot be mistaken for a transport failure.
|
|
460
|
+
const providerIdRaw =
|
|
461
|
+
sendResult &&
|
|
462
|
+
typeof sendResult === "object" &&
|
|
463
|
+
"providerId" in sendResult
|
|
464
|
+
? (sendResult.providerId as string | undefined)
|
|
465
|
+
: undefined;
|
|
466
|
+
// `??` would accept empty-string providerId; explicit truthy check
|
|
467
|
+
// so a provider returning `{ providerId: "" }` falls back to the
|
|
468
|
+
// internally generated correlation id.
|
|
469
|
+
const providerId =
|
|
470
|
+
providerIdRaw && providerIdRaw.length > 0 ? providerIdRaw : undefined;
|
|
471
|
+
this.#fireSent({
|
|
472
|
+
messageId: providerId ?? generatedId,
|
|
473
|
+
to: message.to.slice(),
|
|
474
|
+
cc: message.cc.slice(),
|
|
475
|
+
bcc: message.bcc.slice(),
|
|
476
|
+
transportName: name,
|
|
477
|
+
timestamp: Date.now(),
|
|
478
|
+
});
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// Unreachable — the loop always returns or throws — but TS wants a throw.
|
|
482
|
+
throw lastError;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Return a shallow clone of `err` with `context.attempts` annotated. We
|
|
487
|
+
* avoid mutating the original error so outer retry layers / shared error
|
|
488
|
+
* references don't see their attempt counter overwritten.
|
|
489
|
+
*/
|
|
490
|
+
#withAttempts(err: unknown, attempts: number): unknown {
|
|
491
|
+
if (!(err instanceof RoverError)) return err;
|
|
492
|
+
const clone = new RoverError(err.code, err.message, {
|
|
493
|
+
hint: err.hint,
|
|
494
|
+
sourceFile: err.sourceFile,
|
|
495
|
+
sourceLine: err.sourceLine,
|
|
496
|
+
docsUrl: err.docsUrl,
|
|
497
|
+
pipelineStage: err.pipelineStage,
|
|
498
|
+
context: { ...err.context, attempts: String(attempts) },
|
|
499
|
+
});
|
|
500
|
+
if (err.stack) clone.stack = err.stack;
|
|
501
|
+
return clone;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#fireSent(event: MailSentEvent): void {
|
|
505
|
+
// Hooks are in user-land and may throw; their failure must not poison
|
|
506
|
+
// delivery outcome. Emitter errors are already defensively swallowed.
|
|
507
|
+
try {
|
|
508
|
+
this.#hooks.onSent?.(event);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
process.stderr.write(
|
|
511
|
+
`[rover] onSent hook threw: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
if (this.#emitter) {
|
|
515
|
+
try {
|
|
516
|
+
this.#emitter.emit("mail.sent", event);
|
|
517
|
+
} catch {
|
|
518
|
+
// Event bus failure ≠ mail delivery failure — swallow.
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
#fireFailed(event: MailFailedEvent): void {
|
|
524
|
+
try {
|
|
525
|
+
this.#hooks.onFailed?.(event);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
process.stderr.write(
|
|
528
|
+
`[rover] onFailed hook threw: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
if (this.#emitter) {
|
|
532
|
+
try {
|
|
533
|
+
this.#emitter.emit("mail.failed", event);
|
|
534
|
+
} catch {
|
|
535
|
+
// Event bus failure ≠ mail delivery failure — swallow.
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async #buildMessage(
|
|
541
|
+
arg: ((message: MessageBuilder) => void) | BaseMail,
|
|
542
|
+
): Promise<MailMessage> {
|
|
543
|
+
let result: MailMessage;
|
|
544
|
+
if (arg instanceof BaseMail) {
|
|
545
|
+
const built = await arg.build();
|
|
546
|
+
result = built.from ? built : { ...built, from: this.#defaultFrom };
|
|
547
|
+
} else {
|
|
548
|
+
const builder = new MessageBuilder();
|
|
549
|
+
builder.from(this.#defaultFrom);
|
|
550
|
+
arg(builder);
|
|
551
|
+
result = await builder.build();
|
|
552
|
+
}
|
|
553
|
+
validateMailMessage(result);
|
|
554
|
+
return result;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Get a specific transport. */
|
|
558
|
+
use(name: string): MailTransport {
|
|
559
|
+
const t = this.#transports.get(name);
|
|
560
|
+
if (!t) throw new Error(`Mail transport '${name}' not configured`);
|
|
561
|
+
return t;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Swap the default transport with a `FakeMail` that captures every send.
|
|
566
|
+
* Call `restore()` to re-install the original. Throws if a fake is already
|
|
567
|
+
* active — nested fakes always indicate a forgotten `restore()`.
|
|
568
|
+
*/
|
|
569
|
+
fake(): FakeMail {
|
|
570
|
+
if (this.#fakeSnapshot !== null) {
|
|
571
|
+
throw new Error("Mail.fake() already active — call restore() first");
|
|
572
|
+
}
|
|
573
|
+
const transportName = this.#defaultTransport;
|
|
574
|
+
const original = this.#transports.get(transportName);
|
|
575
|
+
if (!original) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`Cannot fake default transport '${transportName}' — not configured`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
const fake = new FakeMail();
|
|
581
|
+
this.#fakeSnapshot = { transportName, original };
|
|
582
|
+
this.#transports.set(transportName, fake);
|
|
583
|
+
return fake;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Undo the swap installed by `fake()`. No-op if no fake is active. */
|
|
587
|
+
restore(): void {
|
|
588
|
+
if (this.#fakeSnapshot === null) return;
|
|
589
|
+
const { transportName, original } = this.#fakeSnapshot;
|
|
590
|
+
this.#transports.set(transportName, original);
|
|
591
|
+
this.#fakeSnapshot = null;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Reject empty `from` and empty recipients before any transport is reached —
|
|
597
|
+
* keeps the failure mode uniform across SMTP / Mailgun / SendGrid / SES /
|
|
598
|
+
* Resend / log instead of relying on each transport to surface a (possibly
|
|
599
|
+
* cryptic) "no recipients" provider error.
|
|
600
|
+
*/
|
|
601
|
+
function validateMailMessage(message: MailMessage): void {
|
|
602
|
+
// `MailMessage.from` is typed `string` but config-loader paths
|
|
603
|
+
// (`app.config.get<MailConfig>(...)`) cast at runtime — a missing key
|
|
604
|
+
// can yield `undefined` despite the type. Whitespace-only is also
|
|
605
|
+
// invalid per RFC 5321 reverse-path semantics.
|
|
606
|
+
if (typeof message.from !== "string" || message.from.trim() === "") {
|
|
607
|
+
throw new RoverError(
|
|
608
|
+
"MAIL_INVALID_MESSAGE",
|
|
609
|
+
"Mail message has no `from` address",
|
|
610
|
+
{
|
|
611
|
+
hint: "Set `config.from`, an instance `from`, or call `message.from(...)` in the builder.",
|
|
612
|
+
},
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
// Defense-in-depth on each array: `MailJob.validatePayload` only requires
|
|
616
|
+
// `to` to be an array (cc/bcc are optional at the queue boundary), so a
|
|
617
|
+
// deserialised job payload may reach here with `cc`/`bcc` as `undefined`.
|
|
618
|
+
// `.some(...)` on `undefined` throws `TypeError` instead of the
|
|
619
|
+
// structured `MAIL_INVALID_MESSAGE` this validator is supposed to surface.
|
|
620
|
+
const hasRecipient =
|
|
621
|
+
(Array.isArray(message.to) && message.to.some(isNonEmptyAddress)) ||
|
|
622
|
+
(Array.isArray(message.cc) && message.cc.some(isNonEmptyAddress)) ||
|
|
623
|
+
(Array.isArray(message.bcc) && message.bcc.some(isNonEmptyAddress));
|
|
624
|
+
if (!hasRecipient) {
|
|
625
|
+
throw new RoverError(
|
|
626
|
+
"MAIL_INVALID_MESSAGE",
|
|
627
|
+
"Mail message has no recipients",
|
|
628
|
+
{
|
|
629
|
+
hint: "Call `message.to(...)`, `cc(...)`, or `bcc(...)` with a non-empty address before sending.",
|
|
630
|
+
},
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function isNonEmptyAddress(addr: unknown): boolean {
|
|
636
|
+
return typeof addr === "string" && addr.trim() !== "";
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function errorDescriptor(
|
|
640
|
+
err: unknown,
|
|
641
|
+
attempts: number,
|
|
642
|
+
): MailFailedEvent["error"] {
|
|
643
|
+
if (err instanceof RoverError) {
|
|
644
|
+
const statusStr = err.context.upstreamStatus;
|
|
645
|
+
const upstream = Number(statusStr);
|
|
646
|
+
return {
|
|
647
|
+
code: err.code,
|
|
648
|
+
message: err.message,
|
|
649
|
+
upstreamStatus: Number.isFinite(upstream) ? upstream : undefined,
|
|
650
|
+
upstreamStatusRaw: statusStr,
|
|
651
|
+
attempts,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
if (err instanceof Error) {
|
|
655
|
+
const errnoCode = (err as { code?: unknown }).code;
|
|
656
|
+
return {
|
|
657
|
+
code: typeof errnoCode === "string" ? errnoCode : "UNKNOWN",
|
|
658
|
+
message: err.message,
|
|
659
|
+
attempts,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
return { code: "UNKNOWN", message: String(err), attempts };
|
|
663
|
+
}
|