@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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { renderFile as renderTemplateFile } from "./templating/SimpleTemplate.js";
|
|
2
|
+
|
|
3
|
+
export interface MailMessage {
|
|
4
|
+
from: string;
|
|
5
|
+
to: string[];
|
|
6
|
+
cc: string[];
|
|
7
|
+
bcc: string[];
|
|
8
|
+
replyTo?: string;
|
|
9
|
+
subject: string;
|
|
10
|
+
html?: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
attachments: MailAttachment[];
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MailAttachment {
|
|
17
|
+
filename: string;
|
|
18
|
+
content: Buffer | string;
|
|
19
|
+
contentType?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MessageBuilder {
|
|
23
|
+
#msg: MailMessage = {
|
|
24
|
+
from: "",
|
|
25
|
+
to: [],
|
|
26
|
+
cc: [],
|
|
27
|
+
bcc: [],
|
|
28
|
+
subject: "",
|
|
29
|
+
attachments: [],
|
|
30
|
+
headers: {},
|
|
31
|
+
};
|
|
32
|
+
#pendingView: { path: string; data: Record<string, unknown> } | null = null;
|
|
33
|
+
|
|
34
|
+
from(address: string): this {
|
|
35
|
+
this.#msg.from = address;
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
to(address: string): this {
|
|
39
|
+
this.#msg.to.push(address);
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
cc(address: string): this {
|
|
43
|
+
this.#msg.cc.push(address);
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
bcc(address: string): this {
|
|
47
|
+
this.#msg.bcc.push(address);
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
replyTo(address: string): this {
|
|
51
|
+
this.#msg.replyTo = address;
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
subject(text: string): this {
|
|
55
|
+
this.#msg.subject = text;
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
html(content: string): this {
|
|
59
|
+
this.#msg.html = content;
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
text(content: string): this {
|
|
63
|
+
this.#msg.text = content;
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
attach(
|
|
68
|
+
filename: string,
|
|
69
|
+
content: Buffer | string,
|
|
70
|
+
contentType?: string,
|
|
71
|
+
): this {
|
|
72
|
+
this.#msg.attachments.push({ filename, content, contentType });
|
|
73
|
+
return this;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
header(key: string, value: string): this {
|
|
77
|
+
this.#msg.headers[key] = value;
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Queue an HTML template render. The render happens lazily at `build()` time
|
|
83
|
+
* so the fluent chain stays synchronous; `build()` is async and awaits the
|
|
84
|
+
* render before returning the finalised `MailMessage`.
|
|
85
|
+
*/
|
|
86
|
+
htmlView(viewPath: string, data?: Record<string, unknown>): this {
|
|
87
|
+
this.#pendingView = { path: viewPath, data: data ?? {} };
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async build(): Promise<MailMessage> {
|
|
92
|
+
if (this.#pendingView !== null) {
|
|
93
|
+
this.#msg.html = await renderTemplateFile(
|
|
94
|
+
this.#pendingView.path,
|
|
95
|
+
this.#pendingView.data,
|
|
96
|
+
);
|
|
97
|
+
this.#pendingView = null;
|
|
98
|
+
}
|
|
99
|
+
return this.#msg;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rover's own structured error — keeps the package framework-agnostic (no
|
|
3
|
+
* dependency on `@c9up/ream`'s `ReamError`). Mirrors the error shape Rover
|
|
4
|
+
* carries through its transports and retry logic.
|
|
5
|
+
*/
|
|
6
|
+
export class RoverError extends Error {
|
|
7
|
+
/** Error code (e.g. "ROVER_SMTP_ERROR"). */
|
|
8
|
+
readonly code: string;
|
|
9
|
+
/** Additional context key-value pairs. */
|
|
10
|
+
readonly context: Record<string, string>;
|
|
11
|
+
/** Actionable hint for the developer. */
|
|
12
|
+
readonly hint?: string;
|
|
13
|
+
/** Source file where the error originated. */
|
|
14
|
+
readonly sourceFile?: string;
|
|
15
|
+
/** Line number in the source file. */
|
|
16
|
+
readonly sourceLine?: number;
|
|
17
|
+
/** URL to the error documentation page. */
|
|
18
|
+
readonly docsUrl?: string;
|
|
19
|
+
/** Pipeline stage where the error occurred (if applicable). */
|
|
20
|
+
readonly pipelineStage?: string;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
code: string,
|
|
24
|
+
message: string,
|
|
25
|
+
options?: {
|
|
26
|
+
context?: Record<string, string>;
|
|
27
|
+
hint?: string;
|
|
28
|
+
sourceFile?: string;
|
|
29
|
+
sourceLine?: number;
|
|
30
|
+
docsUrl?: string;
|
|
31
|
+
pipelineStage?: string;
|
|
32
|
+
},
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "RoverError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.context = options?.context ?? {};
|
|
38
|
+
this.hint = options?.hint;
|
|
39
|
+
this.sourceFile = options?.sourceFile;
|
|
40
|
+
this.sourceLine = options?.sourceLine;
|
|
41
|
+
this.docsUrl = options?.docsUrl;
|
|
42
|
+
this.pipelineStage = options?.pipelineStage;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { EmitterLike, MailConfig } from "./Mail.js";
|
|
2
|
+
import { Mail } from "./Mail.js";
|
|
3
|
+
import type { BayQueueLike } from "./queue/MailJob.js";
|
|
4
|
+
import { setMail } from "./services/main.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Duck-typed slice of the host's IoC container — rover does NOT import
|
|
8
|
+
* `@c9up/ream` so it stays publishable as a standalone package.
|
|
9
|
+
*/
|
|
10
|
+
interface RoverContainer {
|
|
11
|
+
singleton(token: unknown, factory: () => unknown): void;
|
|
12
|
+
resolve<T = unknown>(token: unknown): T;
|
|
13
|
+
}
|
|
14
|
+
interface RoverConfigStore {
|
|
15
|
+
get<T = unknown>(key: string): T | undefined;
|
|
16
|
+
}
|
|
17
|
+
export interface RoverAppContext {
|
|
18
|
+
container: RoverContainer;
|
|
19
|
+
config: RoverConfigStore;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default class RoverProvider {
|
|
23
|
+
constructor(protected app: RoverAppContext) {}
|
|
24
|
+
|
|
25
|
+
register() {
|
|
26
|
+
this.app.container.singleton(Mail, () => {
|
|
27
|
+
const config = this.app.config.get<MailConfig>("mail");
|
|
28
|
+
return new Mail(
|
|
29
|
+
config ?? {
|
|
30
|
+
default: "log",
|
|
31
|
+
from: "noreply@localhost",
|
|
32
|
+
transports: { log: { transport: "log" } },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
// Optional-peer wiring: if `@c9up/bay` is installed and its
|
|
36
|
+
// `QueueManager` is registered in the container, Mail gets
|
|
37
|
+
// queue support for `sendLater()`. If not, `sendLater()`
|
|
38
|
+
// throws `MAIL_QUEUE_REQUIRED` at call time (by design).
|
|
39
|
+
queue: tryResolve<BayQueueLike>(this.app, "QueueManager"),
|
|
40
|
+
// Same pattern for the event bus `Emitter` — enables `mail.sent`
|
|
41
|
+
// / `mail.failed` emission when available.
|
|
42
|
+
emitter: tryResolve<EmitterLike>(this.app, "Emitter"),
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
this.app.container.singleton("mail", () => {
|
|
47
|
+
return this.app.container.resolve<Mail>(Mail);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async boot() {
|
|
52
|
+
// Populate the `@c9up/rover/services/main` singleton with the
|
|
53
|
+
// container-resolved Mail instance so apps can
|
|
54
|
+
// `import mail from '@c9up/rover/services/main'` from anywhere.
|
|
55
|
+
setMail(this.app.container.resolve<Mail>(Mail));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async shutdown() {}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a token from the container without throwing when it's not
|
|
63
|
+
* registered. Rover never hard-depends on Bay or the event bus — both wire-points
|
|
64
|
+
* are purely opt-in.
|
|
65
|
+
*/
|
|
66
|
+
function tryResolve<T>(app: RoverAppContext, token: string): T | undefined {
|
|
67
|
+
try {
|
|
68
|
+
return app.container.resolve<T>(token);
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/config.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type { MailAddress } from "./BaseMail.js";
|
|
2
|
+
export { BaseMail } from "./BaseMail.js";
|
|
3
|
+
export { defineConfig } from "./config.js";
|
|
4
|
+
export { RoverError } from "./RoverError.js";
|
|
5
|
+
export type {
|
|
6
|
+
EmitterLike,
|
|
7
|
+
MailAttachment,
|
|
8
|
+
MailConfig,
|
|
9
|
+
MailFailedEvent,
|
|
10
|
+
MailHooks,
|
|
11
|
+
MailMessage,
|
|
12
|
+
MailSendOutcome,
|
|
13
|
+
MailSendResult,
|
|
14
|
+
MailSentEvent,
|
|
15
|
+
MailTransport,
|
|
16
|
+
MailTransportFactory,
|
|
17
|
+
} from "./Mail.js";
|
|
18
|
+
export {
|
|
19
|
+
LogTransport,
|
|
20
|
+
Mail,
|
|
21
|
+
MessageBuilder,
|
|
22
|
+
registerTransport,
|
|
23
|
+
SmtpTransport,
|
|
24
|
+
} from "./Mail.js";
|
|
25
|
+
export { default as RoverProvider } from "./RoverProvider.js";
|
|
26
|
+
export {
|
|
27
|
+
DEFAULT_RETRY_CONFIG,
|
|
28
|
+
isRetryableError,
|
|
29
|
+
type RetryConfig,
|
|
30
|
+
} from "./retry.js";
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { RoverError } from "../RoverError.js";
|
|
3
|
+
import type { MailAttachment, MailMessage } from "../Mail.js";
|
|
4
|
+
|
|
5
|
+
export const MAIL_JOB_NAME = "mail.send";
|
|
6
|
+
|
|
7
|
+
export interface MailJobPayload {
|
|
8
|
+
message: MailMessage;
|
|
9
|
+
transport?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Minimal structural interface for Bay's `QueueManager` — peer-dep friendly,
|
|
14
|
+
* matches `@c9up/bay`'s public API so `new QueueManager(driver)` is assignable.
|
|
15
|
+
*/
|
|
16
|
+
export interface BayQueueLike {
|
|
17
|
+
register(name: string, handler: BayJobHandlerLike): void;
|
|
18
|
+
dispatch(
|
|
19
|
+
name: string,
|
|
20
|
+
payload: unknown,
|
|
21
|
+
options?: { maxAttempts?: number },
|
|
22
|
+
): Promise<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BayJobHandlerLike {
|
|
26
|
+
handle(payload: unknown): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Pared-down RetryConfig shape — avoids importing from `../retry.js` into queue/. */
|
|
30
|
+
interface RetryOverride {
|
|
31
|
+
maxAttempts: number;
|
|
32
|
+
baseDelayMs: number;
|
|
33
|
+
factor: number;
|
|
34
|
+
maxDelayMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface MailDispatcher {
|
|
38
|
+
dispatchMessage(
|
|
39
|
+
message: MailMessage,
|
|
40
|
+
transport?: string,
|
|
41
|
+
overrideRetry?: RetryOverride,
|
|
42
|
+
): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Bay job handler for queued mail. Sets `maxAttempts: 1` so the in-process
|
|
47
|
+
* retry loop runs exactly once per job execution — Bay's own `maxAttempts`
|
|
48
|
+
* handles re-dispatch on throw. Prevents sync × queue retry compounding.
|
|
49
|
+
*/
|
|
50
|
+
export class MailJobHandler implements BayJobHandlerLike {
|
|
51
|
+
#mail: MailDispatcher;
|
|
52
|
+
|
|
53
|
+
constructor(mail: MailDispatcher) {
|
|
54
|
+
this.#mail = mail;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async handle(payload: unknown): Promise<void> {
|
|
58
|
+
const parsed = validatePayload(payload);
|
|
59
|
+
// `{ maxAttempts: 1 }` alone is sufficient — the loop runs exactly once
|
|
60
|
+
// and `computeBackoffMs` is never reached; the other RetryConfig fields
|
|
61
|
+
// resolve to defaults via `resolveRetryConfig` but don't affect control
|
|
62
|
+
// flow. Matches AC 8 wording exactly.
|
|
63
|
+
await this.#mail.dispatchMessage(revive(parsed.message), parsed.transport, {
|
|
64
|
+
maxAttempts: 1,
|
|
65
|
+
baseDelayMs: 0,
|
|
66
|
+
factor: 1,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Narrow an `unknown` payload from the queue driver into a `MailJobPayload`.
|
|
73
|
+
* Throws `MAIL_JOB_MALFORMED` when the shape is unrecognisable so Bay records
|
|
74
|
+
* a clean failure rather than crashing inside `dispatchMessage` with a raw
|
|
75
|
+
* `TypeError`.
|
|
76
|
+
*/
|
|
77
|
+
function validatePayload(payload: unknown): MailJobPayload {
|
|
78
|
+
if (!payload || typeof payload !== "object") {
|
|
79
|
+
throw new RoverError(
|
|
80
|
+
"MAIL_JOB_MALFORMED",
|
|
81
|
+
"Mail job payload is missing or not an object",
|
|
82
|
+
{ hint: "Queue driver returned a non-object — check serialisation." },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const asObj = payload as Record<string, unknown>;
|
|
86
|
+
const message = asObj.message;
|
|
87
|
+
if (!message || typeof message !== "object") {
|
|
88
|
+
throw new RoverError(
|
|
89
|
+
"MAIL_JOB_MALFORMED",
|
|
90
|
+
"Mail job payload.message is missing or not an object",
|
|
91
|
+
{
|
|
92
|
+
hint: "Payload shape must be { message: MailMessage, transport?: string }.",
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const m = message as Record<string, unknown>;
|
|
97
|
+
if (
|
|
98
|
+
typeof m.from !== "string" ||
|
|
99
|
+
!Array.isArray(m.to) ||
|
|
100
|
+
!Array.isArray(m.attachments)
|
|
101
|
+
) {
|
|
102
|
+
throw new RoverError(
|
|
103
|
+
"MAIL_JOB_MALFORMED",
|
|
104
|
+
"Mail job payload.message does not match MailMessage shape",
|
|
105
|
+
{
|
|
106
|
+
hint: "Expected `{ from: string, to: string[], ..., attachments: [] }`.",
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const transport =
|
|
111
|
+
typeof asObj.transport === "string" ? asObj.transport : undefined;
|
|
112
|
+
return { message: message as MailMessage, transport };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Revive Buffer attachments after JSON round-trip. Node serialises `Buffer` as
|
|
117
|
+
* `{ type: "Buffer", data: [...bytes] }`; we reconstruct to real `Buffer`.
|
|
118
|
+
*/
|
|
119
|
+
function revive(message: MailMessage): MailMessage {
|
|
120
|
+
if (!message.attachments || message.attachments.length === 0) return message;
|
|
121
|
+
const attachments: MailAttachment[] = message.attachments.map((att) => {
|
|
122
|
+
if (typeof att.content === "string") return att;
|
|
123
|
+
if (Buffer.isBuffer(att.content)) return att;
|
|
124
|
+
const serialised = att.content as { type?: string; data?: number[] };
|
|
125
|
+
if (serialised?.type === "Buffer" && Array.isArray(serialised.data)) {
|
|
126
|
+
return { ...att, content: Buffer.from(serialised.data) };
|
|
127
|
+
}
|
|
128
|
+
return att;
|
|
129
|
+
});
|
|
130
|
+
return { ...message, attachments };
|
|
131
|
+
}
|
package/src/retry.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { RoverError } from "./RoverError.js";
|
|
2
|
+
|
|
3
|
+
export interface RetryConfig {
|
|
4
|
+
maxAttempts: number;
|
|
5
|
+
baseDelayMs: number;
|
|
6
|
+
factor: number;
|
|
7
|
+
maxDelayMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {
|
|
11
|
+
maxAttempts: 3,
|
|
12
|
+
baseDelayMs: 500,
|
|
13
|
+
factor: 2,
|
|
14
|
+
maxDelayMs: 10_000,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const RETRYABLE_ERRNO = new Set([
|
|
18
|
+
"ETIMEDOUT",
|
|
19
|
+
"ECONNRESET",
|
|
20
|
+
"ECONNREFUSED",
|
|
21
|
+
"ENOTFOUND",
|
|
22
|
+
"EAI_AGAIN",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A failure is retryable when:
|
|
27
|
+
* - it's a `MAIL_PROVIDER_ERROR` with `upstreamStatus` 429 or ≥ 500
|
|
28
|
+
* - or it's a `MAIL_PROVIDER_ERROR` whose `context.networkCode` is a known
|
|
29
|
+
* retryable Node errno (SDK wrappers surface the original errno this way
|
|
30
|
+
* when the HTTP layer never produced a status)
|
|
31
|
+
* - or it's a raw Error with a matching Node networking errno on `.code`
|
|
32
|
+
* 4xx (non-429) is treated as permanent: retrying won't help.
|
|
33
|
+
*/
|
|
34
|
+
export function isRetryableError(err: unknown): boolean {
|
|
35
|
+
if (err instanceof RoverError && err.code === "MAIL_PROVIDER_ERROR") {
|
|
36
|
+
const statusStr = err.context.upstreamStatus;
|
|
37
|
+
const status = Number(statusStr);
|
|
38
|
+
if (Number.isFinite(status)) {
|
|
39
|
+
if (status === 429) return true;
|
|
40
|
+
if (status >= 500 && status < 600) return true;
|
|
41
|
+
}
|
|
42
|
+
const networkCode = err.context.networkCode;
|
|
43
|
+
if (networkCode && RETRYABLE_ERRNO.has(networkCode)) return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (err instanceof Error) {
|
|
47
|
+
const code = (err as { code?: unknown }).code;
|
|
48
|
+
if (typeof code === "string" && RETRYABLE_ERRNO.has(code)) return true;
|
|
49
|
+
// Node 18+ `fetch` (undici) wraps the underlying socket error in
|
|
50
|
+
// `cause` and surfaces `TypeError: fetch failed` at the top level —
|
|
51
|
+
// the original errno is on `err.cause.code`, not `err.code`. Without
|
|
52
|
+
// this branch transient ECONNRESET/ENOTFOUND/etc. from raw fetch
|
|
53
|
+
// look indistinguishable from a permanent failure and never retry.
|
|
54
|
+
const cause = (err as { cause?: unknown }).cause;
|
|
55
|
+
if (cause && typeof cause === "object") {
|
|
56
|
+
const causeCode = (cause as { code?: unknown }).code;
|
|
57
|
+
if (typeof causeCode === "string" && RETRYABLE_ERRNO.has(causeCode))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function computeBackoffMs(
|
|
65
|
+
attempt: number,
|
|
66
|
+
config: Required<RetryConfig>,
|
|
67
|
+
err?: unknown,
|
|
68
|
+
): number {
|
|
69
|
+
// Honour the provider's Retry-After hint when present (Mailgun/SendGrid/
|
|
70
|
+
// SES/Resend transports capture it into `context.retryAfter`). Value is
|
|
71
|
+
// seconds per HTTP RFC 7231 §7.1.3; we cap at `maxDelayMs` so a malicious
|
|
72
|
+
// or mistaken provider can't force a very-long wait.
|
|
73
|
+
if (err instanceof RoverError && err.code === "MAIL_PROVIDER_ERROR") {
|
|
74
|
+
const hint = err.context.retryAfter;
|
|
75
|
+
if (hint) {
|
|
76
|
+
const seconds = Number(hint);
|
|
77
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
78
|
+
return Math.min(config.maxDelayMs, Math.max(50, seconds * 1000));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const raw = config.baseDelayMs * config.factor ** (attempt - 1);
|
|
83
|
+
return Math.min(config.maxDelayMs, Math.max(50, raw));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveRetryConfig(
|
|
87
|
+
globalConfig: RetryConfig | undefined,
|
|
88
|
+
transportConfig: RetryConfig | undefined,
|
|
89
|
+
): Required<RetryConfig> {
|
|
90
|
+
// Skip entries whose value is `undefined` so a partial override doesn't
|
|
91
|
+
// clobber a default or global value. Pre-spread filter is required because
|
|
92
|
+
// `{ ...obj }` propagates explicit-undefined keys.
|
|
93
|
+
const strip = <T extends object>(x: T | undefined): Partial<T> =>
|
|
94
|
+
x
|
|
95
|
+
? (Object.fromEntries(
|
|
96
|
+
Object.entries(x).filter(([, v]) => v !== undefined),
|
|
97
|
+
) as Partial<T>)
|
|
98
|
+
: {};
|
|
99
|
+
|
|
100
|
+
const merged: Required<RetryConfig> = {
|
|
101
|
+
...DEFAULT_RETRY_CONFIG,
|
|
102
|
+
...strip(globalConfig),
|
|
103
|
+
...strip(transportConfig),
|
|
104
|
+
};
|
|
105
|
+
if (!Number.isInteger(merged.maxAttempts) || merged.maxAttempts < 1) {
|
|
106
|
+
throw new RoverError(
|
|
107
|
+
"MAIL_RETRY_CONFIG",
|
|
108
|
+
`RetryConfig.maxAttempts must be an integer >= 1 (got ${merged.maxAttempts})`,
|
|
109
|
+
{ hint: "Set retry.maxAttempts to a positive integer." },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (
|
|
113
|
+
typeof merged.baseDelayMs !== "number" ||
|
|
114
|
+
!Number.isFinite(merged.baseDelayMs) ||
|
|
115
|
+
merged.baseDelayMs < 0
|
|
116
|
+
) {
|
|
117
|
+
throw new RoverError(
|
|
118
|
+
"MAIL_RETRY_CONFIG",
|
|
119
|
+
`RetryConfig.baseDelayMs must be a non-negative finite number (got ${merged.baseDelayMs})`,
|
|
120
|
+
{ hint: "Use 0 or a positive number; the 50 ms floor kicks in anyway." },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
typeof merged.factor !== "number" ||
|
|
125
|
+
!Number.isFinite(merged.factor) ||
|
|
126
|
+
merged.factor <= 0
|
|
127
|
+
) {
|
|
128
|
+
throw new RoverError(
|
|
129
|
+
"MAIL_RETRY_CONFIG",
|
|
130
|
+
`RetryConfig.factor must be a positive finite number (got ${merged.factor})`,
|
|
131
|
+
{ hint: "Use >= 1 for exponential growth; 1 means constant delay." },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return merged;
|
|
135
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default `Mail` singleton — mirror of Adonis's
|
|
3
|
+
* `import mail from '@adonisjs/mail/services/main'` shape.
|
|
4
|
+
*
|
|
5
|
+
* The instance is populated by `RoverProvider.boot()` once the
|
|
6
|
+
* container has resolved the `Mail` binding. Callers can `import mail`
|
|
7
|
+
* before boot — the proxy delays method access until `setMail` runs.
|
|
8
|
+
*
|
|
9
|
+
* import mail from '@c9up/rover/services/main'
|
|
10
|
+
*
|
|
11
|
+
* await mail.send((m) => m.to(user.email).subject('Hi').text('Hello'))
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Mail } from "../Mail.js";
|
|
15
|
+
|
|
16
|
+
let instance: Mail | undefined;
|
|
17
|
+
|
|
18
|
+
/** @internal Set the resolved singleton (called by RoverProvider.boot). */
|
|
19
|
+
export function setMail(mail: Mail): void {
|
|
20
|
+
instance = mail;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @internal Get the resolved singleton (or `undefined` pre-boot). */
|
|
24
|
+
export function getMail(): Mail | undefined {
|
|
25
|
+
return instance;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const mail: Mail = new Proxy({} as Mail, {
|
|
29
|
+
get(_target, prop) {
|
|
30
|
+
if (!instance) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
"[rover] Mail singleton accessed before RoverProvider.boot() ran. " +
|
|
33
|
+
"Check that `@c9up/rover/provider` is listed in your reamrc.ts providers.",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const value = Reflect.get(instance, prop, instance);
|
|
37
|
+
return typeof value === "function" ? value.bind(instance) : value;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export default mail;
|