@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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/dist/BaseMail.d.ts +28 -0
  4. package/dist/BaseMail.d.ts.map +1 -0
  5. package/dist/BaseMail.js +42 -0
  6. package/dist/BaseMail.js.map +1 -0
  7. package/dist/Mail.d.ts +166 -0
  8. package/dist/Mail.d.ts.map +1 -0
  9. package/dist/Mail.js +451 -0
  10. package/dist/Mail.js.map +1 -0
  11. package/dist/MessageBuilder.d.ts +38 -0
  12. package/dist/MessageBuilder.d.ts.map +1 -0
  13. package/dist/MessageBuilder.js +70 -0
  14. package/dist/MessageBuilder.js.map +1 -0
  15. package/dist/RoverError.d.ts +30 -0
  16. package/dist/RoverError.d.ts.map +1 -0
  17. package/dist/RoverError.js +33 -0
  18. package/dist/RoverError.js.map +1 -0
  19. package/dist/RoverProvider.d.ts +24 -0
  20. package/dist/RoverProvider.d.ts.map +1 -0
  21. package/dist/RoverProvider.js +51 -0
  22. package/dist/RoverProvider.js.map +1 -0
  23. package/dist/config.d.ts +4 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +4 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/index.d.ts +9 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +7 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/queue/MailJob.d.ts +41 -0
  32. package/dist/queue/MailJob.d.ts.map +1 -0
  33. package/dist/queue/MailJob.js +75 -0
  34. package/dist/queue/MailJob.js.map +1 -0
  35. package/dist/retry.d.ts +20 -0
  36. package/dist/retry.d.ts.map +1 -0
  37. package/dist/retry.js +101 -0
  38. package/dist/retry.js.map +1 -0
  39. package/dist/services/main.d.ts +20 -0
  40. package/dist/services/main.d.ts.map +1 -0
  41. package/dist/services/main.js +33 -0
  42. package/dist/services/main.js.map +1 -0
  43. package/dist/templating/SimpleTemplate.d.ts +7 -0
  44. package/dist/templating/SimpleTemplate.d.ts.map +1 -0
  45. package/dist/templating/SimpleTemplate.js +192 -0
  46. package/dist/templating/SimpleTemplate.js.map +1 -0
  47. package/dist/templating/loadNapi.d.ts +21 -0
  48. package/dist/templating/loadNapi.d.ts.map +1 -0
  49. package/dist/templating/loadNapi.js +115 -0
  50. package/dist/templating/loadNapi.js.map +1 -0
  51. package/dist/testing/FakeMail.d.ts +31 -0
  52. package/dist/testing/FakeMail.d.ts.map +1 -0
  53. package/dist/testing/FakeMail.js +90 -0
  54. package/dist/testing/FakeMail.js.map +1 -0
  55. package/dist/transports/MailgunTransport.d.ts +7 -0
  56. package/dist/transports/MailgunTransport.d.ts.map +1 -0
  57. package/dist/transports/MailgunTransport.js +149 -0
  58. package/dist/transports/MailgunTransport.js.map +1 -0
  59. package/dist/transports/ResendTransport.d.ts +7 -0
  60. package/dist/transports/ResendTransport.d.ts.map +1 -0
  61. package/dist/transports/ResendTransport.js +111 -0
  62. package/dist/transports/ResendTransport.js.map +1 -0
  63. package/dist/transports/SendGridTransport.d.ts +7 -0
  64. package/dist/transports/SendGridTransport.d.ts.map +1 -0
  65. package/dist/transports/SendGridTransport.js +165 -0
  66. package/dist/transports/SendGridTransport.js.map +1 -0
  67. package/dist/transports/SesTransport.d.ts +7 -0
  68. package/dist/transports/SesTransport.d.ts.map +1 -0
  69. package/dist/transports/SesTransport.js +334 -0
  70. package/dist/transports/SesTransport.js.map +1 -0
  71. package/dist/transports/fetchError.d.ts +13 -0
  72. package/dist/transports/fetchError.d.ts.map +1 -0
  73. package/dist/transports/fetchError.js +35 -0
  74. package/dist/transports/fetchError.js.map +1 -0
  75. package/dist/webhooks/context.d.ts +35 -0
  76. package/dist/webhooks/context.d.ts.map +1 -0
  77. package/dist/webhooks/context.js +7 -0
  78. package/dist/webhooks/context.js.map +1 -0
  79. package/dist/webhooks/mailgun.d.ts +13 -0
  80. package/dist/webhooks/mailgun.d.ts.map +1 -0
  81. package/dist/webhooks/mailgun.js +102 -0
  82. package/dist/webhooks/mailgun.js.map +1 -0
  83. package/dist/webhooks/resend.d.ts +16 -0
  84. package/dist/webhooks/resend.d.ts.map +1 -0
  85. package/dist/webhooks/resend.js +121 -0
  86. package/dist/webhooks/resend.js.map +1 -0
  87. package/dist/webhooks/sendgrid.d.ts +13 -0
  88. package/dist/webhooks/sendgrid.d.ts.map +1 -0
  89. package/dist/webhooks/sendgrid.js +104 -0
  90. package/dist/webhooks/sendgrid.js.map +1 -0
  91. package/index.darwin-arm64.node +0 -0
  92. package/index.darwin-x64.node +0 -0
  93. package/index.linux-arm64-gnu.node +0 -0
  94. package/index.linux-x64-gnu.node +0 -0
  95. package/index.win32-x64-msvc.node +0 -0
  96. package/package.json +97 -0
  97. package/scripts/copy-napi.mjs +62 -0
  98. package/src/BaseMail.ts +52 -0
  99. package/src/Mail.ts +663 -0
  100. package/src/MessageBuilder.ts +101 -0
  101. package/src/RoverError.ts +44 -0
  102. package/src/RoverProvider.ts +72 -0
  103. package/src/config.ts +7 -0
  104. package/src/index.ts +30 -0
  105. package/src/queue/MailJob.ts +131 -0
  106. package/src/retry.ts +135 -0
  107. package/src/services/main.ts +41 -0
  108. package/src/templating/SimpleTemplate.ts +240 -0
  109. package/src/templating/loadNapi.ts +169 -0
  110. package/src/testing/FakeMail.ts +112 -0
  111. package/src/transports/MailgunTransport.ts +204 -0
  112. package/src/transports/ResendTransport.ts +147 -0
  113. package/src/transports/SendGridTransport.ts +246 -0
  114. package/src/transports/SesTransport.ts +394 -0
  115. package/src/transports/fetchError.ts +42 -0
  116. package/src/webhooks/context.ts +41 -0
  117. package/src/webhooks/mailgun.ts +134 -0
  118. package/src/webhooks/resend.ts +164 -0
  119. package/src/webhooks/sendgrid.ts +133 -0
@@ -0,0 +1,192 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { RoverError } from "../RoverError.js";
4
+ import { getNative, toReamError } from "./loadNapi.js";
5
+ // The compile + interpret hot path now runs in Rust via napi-rs
6
+ // (`rover-template-engine`). This TS facade keeps the byte-identical public
7
+ // surface — `render` / `renderFile` / `setViewsRoot` / `getViewsRoot` /
8
+ // `resetCache` — and owns everything that touches the filesystem: path
9
+ // resolution (incl. the `viewsRoot` traversal guard), the FS read, the
10
+ // compiled-IR cache, and the transitive partial pre-resolution (D55.2.1). The
11
+ // engine never reads disk (ADR-007). Render-time `{{> partial}}` recursion is
12
+ // detected Rust-side (D55.2.2) and surfaces here as `MAIL_TEMPLATE_RECURSION`.
13
+ let viewsRoot = "resources/views/emails";
14
+ // Resolved-absolute-path -> compiled Rust IR handle. Content-blind: an entry is
15
+ // reused verbatim until `resetCache()` (D55.2.3 — NO mtime/content invalidation;
16
+ // locked by `simple-template.test.ts:109-122`).
17
+ const cache = new Map();
18
+ export function setViewsRoot(rootDir) {
19
+ viewsRoot = rootDir;
20
+ }
21
+ export function getViewsRoot() {
22
+ return viewsRoot;
23
+ }
24
+ /** Reset cached compiled templates (test helper). */
25
+ export function resetCache() {
26
+ cache.clear();
27
+ }
28
+ export async function render(source, data) {
29
+ const ir = compileSource(source);
30
+ const partials = await buildPartialMap(ir);
31
+ return renderNative(ir, data, partials);
32
+ }
33
+ export async function renderFile(viewPath, data, _visited = new Set()) {
34
+ const ir = await loadIr(viewPath);
35
+ const partials = await buildPartialMap(ir);
36
+ return renderNative(ir, data, partials);
37
+ }
38
+ /** Compile inline source to an IR handle, mapping native errors to `RoverError`. */
39
+ function compileSource(source) {
40
+ try {
41
+ return getNative().compile(source);
42
+ }
43
+ catch (err) {
44
+ throw toReamError(err);
45
+ }
46
+ }
47
+ const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
48
+ const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
49
+ /**
50
+ * JSON replacer reconciling JS values the pre-migration engine rendered via
51
+ * `String(raw)` but that `JSON.stringify` drops or rejects at the NAPI boundary:
52
+ * - bigint: widen to Number when it round-trips exactly, else refuse (a raw
53
+ * `JSON.stringify` would throw a bare TypeError → opaque error).
54
+ * - NaN / ±Infinity: JSON encodes both as null, so they would render empty and
55
+ * Infinity would silently flip from truthy to falsy. Refuse loudly instead.
56
+ * Note: a `Date` is serialised by its `toJSON` (ISO-8601), a deliberate change
57
+ * from the old engine's locale `String(date)` — ISO is the stable mail format.
58
+ */
59
+ function dataReplacer(_key, value) {
60
+ if (typeof value === "bigint") {
61
+ if (value >= MIN_SAFE_BIGINT && value <= MAX_SAFE_BIGINT) {
62
+ return Number(value);
63
+ }
64
+ throw new RoverError("MAIL_TEMPLATE_SYNTAX", `Cannot render bigint ${value} — it exceeds Number.MAX_SAFE_INTEGER and cannot cross the template engine boundary without precision loss; format it to a string before rendering`);
65
+ }
66
+ if (typeof value === "number" && !Number.isFinite(value)) {
67
+ throw new RoverError("MAIL_TEMPLATE_SYNTAX", `Cannot render non-finite number ${value} — NaN and Infinity have no representation across the template engine boundary; format it to a string before rendering`);
68
+ }
69
+ return value;
70
+ }
71
+ /** Render a compiled IR, mapping native errors (syntax/recursion) to `RoverError`. */
72
+ function renderNative(ir, data, partials) {
73
+ try {
74
+ // `data` crosses as a JSON string: `JSON.stringify` is own-enumerable-only,
75
+ // preserving the engine's `Object.hasOwn` dot-path contract (inherited
76
+ // prototype-chain props stay invisible — the own-property-only parity test).
77
+ // `dataReplacer` reconciles bigint / NaN / Infinity (see above).
78
+ return getNative().renderIr(ir, JSON.stringify(data, dataReplacer), partials);
79
+ }
80
+ catch (err) {
81
+ throw toReamError(err);
82
+ }
83
+ }
84
+ /** Extract a Node `fs` errno string (e.g. `"ENOENT"`) without an `as` cast. */
85
+ function errnoCode(err) {
86
+ if (err !== null && typeof err === "object") {
87
+ const code = Reflect.get(err, "code");
88
+ if (typeof code === "string")
89
+ return code;
90
+ }
91
+ return "";
92
+ }
93
+ /**
94
+ * Resolve + read + compile one template path, caching the compiled IR by its
95
+ * resolved absolute path. Owns the `MAIL_TEMPLATE_NOT_FOUND` / `_READ_ERROR`
96
+ * raising (unchanged from the pre-migration engine).
97
+ */
98
+ async function loadIr(viewPath) {
99
+ const { resolved, tried } = resolveTemplatePath(viewPath);
100
+ const cached = cache.get(resolved);
101
+ if (cached !== undefined)
102
+ return cached;
103
+ let source;
104
+ try {
105
+ source = await readFile(resolved, "utf8");
106
+ }
107
+ catch (err) {
108
+ const code = errnoCode(err);
109
+ // Only ENOENT maps to NOT_FOUND; permission/io errors surface distinctly.
110
+ if (code !== "ENOENT") {
111
+ throw new RoverError("MAIL_TEMPLATE_READ_ERROR", `Template read failed at ${resolved} (${code || "unknown"})`, {
112
+ hint: "Check filesystem permissions and file descriptor limits.",
113
+ context: { path: resolved, code, viewsRoot },
114
+ });
115
+ }
116
+ throw new RoverError("MAIL_TEMPLATE_NOT_FOUND", `Template not found at ${resolved}`, {
117
+ hint: "Create the file or update config.mail.viewsRoot.",
118
+ context: {
119
+ path: resolved,
120
+ paths: tried.join(", "),
121
+ viewsRoot,
122
+ },
123
+ });
124
+ }
125
+ const ir = compileSource(source);
126
+ cache.set(resolved, ir);
127
+ return ir;
128
+ }
129
+ /**
130
+ * Transitively pre-resolve every `{{> name}}` partial reachable from `rootIr`
131
+ * into a `name -> IR handle` map (D55.2.1). Stops re-descending once a name is
132
+ * mapped, so a cyclic partial graph terminates here WITHOUT erroring — the
133
+ * actual `MAIL_TEMPLATE_RECURSION` is raised Rust-side only when the cycle is
134
+ * really rendered (D55.2.2). Partials read here are cached by resolved path
135
+ * (content-blind), exactly as the pre-migration recursive `renderFile` did.
136
+ */
137
+ async function buildPartialMap(rootIr) {
138
+ const map = {};
139
+ const stack = [rootIr];
140
+ while (stack.length > 0) {
141
+ const ir = stack.pop();
142
+ if (ir === undefined)
143
+ break;
144
+ for (const name of ir.partialNames) {
145
+ if (Object.hasOwn(map, name))
146
+ continue;
147
+ let partialIr;
148
+ try {
149
+ partialIr = await loadIr(`partials/${name}`);
150
+ }
151
+ catch (err) {
152
+ // A partial referenced only inside a falsy `{{#if}}` is never
153
+ // rendered. The pre-migration engine loaded partials lazily, so a
154
+ // missing such partial did not error. Skip it here; if it IS reached
155
+ // at render time the Rust renderer raises MAIL_TEMPLATE_NOT_FOUND.
156
+ if (err instanceof RoverError &&
157
+ err.code === "MAIL_TEMPLATE_NOT_FOUND") {
158
+ continue;
159
+ }
160
+ throw err;
161
+ }
162
+ map[name] = partialIr;
163
+ stack.push(partialIr);
164
+ }
165
+ }
166
+ return map;
167
+ }
168
+ /**
169
+ * Resolve a template path under `viewsRoot`, rejecting any attempt to escape
170
+ * the root (absolute path outside, or `../` traversal after normalisation).
171
+ * Returns the resolved absolute path plus the set of candidates tried.
172
+ */
173
+ function resolveTemplatePath(viewPath) {
174
+ const rootAbs = path.resolve(viewsRoot);
175
+ const startingPoint = path.isAbsolute(viewPath)
176
+ ? viewPath
177
+ : path.resolve(rootAbs, viewPath);
178
+ const withoutExt = startingPoint;
179
+ const withExt = startingPoint.endsWith(".html")
180
+ ? startingPoint
181
+ : `${startingPoint}.html`;
182
+ const candidate = path.resolve(withExt);
183
+ const rel = path.relative(rootAbs, candidate);
184
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
185
+ throw new RoverError("MAIL_TEMPLATE_NOT_FOUND", `Template path "${viewPath}" resolves outside of viewsRoot`, {
186
+ hint: "Template names must stay under viewsRoot. Absolute paths and `..` traversals are rejected.",
187
+ context: { path: candidate, viewsRoot: rootAbs },
188
+ });
189
+ }
190
+ return { resolved: candidate, tried: [withExt, withoutExt] };
191
+ }
192
+ //# sourceMappingURL=SimpleTemplate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SimpleTemplate.js","sourceRoot":"","sources":["../../src/templating/SimpleTemplate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ import { RoverError } from "../RoverError.js";
2
+ /** Opaque handle to a compiled template IR (Rust `RoverIr`). */
3
+ export interface NativeRoverIr {
4
+ /** Referenced partial names, in document order (descends into if-bodies). */
5
+ readonly partialNames: readonly string[];
6
+ }
7
+ interface NativeExports {
8
+ readonly engineVersion: () => string;
9
+ readonly compile: (source: string) => NativeRoverIr;
10
+ readonly renderIr: (ir: NativeRoverIr, dataJson: string, partials: Record<string, NativeRoverIr>) => string;
11
+ }
12
+ export declare function getNative(): NativeExports;
13
+ /**
14
+ * Translate a thrown value from a native call into a `RoverError`.
15
+ * Already a `RoverError` (e.g. the loader threw) → pass through. A `napi::Error`
16
+ * carrying our `{code,message}` JSON envelope → reconstruct typed (line numbers
17
+ * preserved in `message`). Anything else → wrap as a syntax error.
18
+ */
19
+ export declare function toReamError(err: unknown): RoverError;
20
+ export {};
21
+ //# sourceMappingURL=loadNapi.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loadNapi.d.ts","sourceRoot":"","sources":["../../src/templating/loadNapi.ts"],"names":[],"mappings":"AAYA,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAyB9C,gEAAgE;AAChE,MAAM,WAAW,aAAa;IAC7B,6EAA6E;IAC7E,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CACzC;AAED,UAAU,aAAa;IACtB,QAAQ,CAAC,aAAa,EAAE,MAAM,MAAM,CAAC;IACrC,QAAQ,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,aAAa,CAAC;IACpD,QAAQ,CAAC,QAAQ,EAAE,CAClB,EAAE,EAAE,aAAa,EACjB,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,KACnC,MAAM,CAAC;CACZ;AAaD,wBAAgB,SAAS,IAAI,aAAa,CA6CzC;AA6BD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,CAwBpD"}
@@ -0,0 +1,115 @@
1
+ // Loads the native `rover-template-engine-napi` binary built by
2
+ // `scripts/copy-napi.mjs` and re-throws load failures as
3
+ // `MAIL_TEMPLATE_NAPI_REQUIRED` (D55.2.4) — actionable hint points at
4
+ // `pnpm --filter @c9up/rover build:napi`.
5
+ //
6
+ // Per cerebrum 2026-04-15 there is NO JS fallback. If the binary fails to load,
7
+ // consumers get a typed `RoverError`. Zero `as` / `any` per `feedback_no_any_types`
8
+ // — every boundary is narrowed with a `Reflect.get` type guard.
9
+ import { createRequire } from "node:module";
10
+ import { arch, platform } from "node:process";
11
+ import { fileURLToPath } from "node:url";
12
+ import { RoverError } from "../RoverError.js";
13
+ const SUFFIX_MAP = {
14
+ "linux-x64": "linux-x64-gnu",
15
+ "linux-arm64": "linux-arm64-gnu",
16
+ "darwin-x64": "darwin-x64",
17
+ "darwin-arm64": "darwin-arm64",
18
+ "win32-x64": "win32-x64-msvc",
19
+ };
20
+ function platformSuffix() {
21
+ const key = `${platform}-${arch}`;
22
+ const suffix = SUFFIX_MAP[key];
23
+ if (typeof suffix !== "string") {
24
+ throw new RoverError("MAIL_TEMPLATE_NAPI_REQUIRED", `Unsupported platform/arch '${key}' for @c9up/rover native binary. Supported: ${Object.keys(SUFFIX_MAP).join(", ")}.`, {
25
+ hint: "Build the native binary on a supported platform with 'pnpm --filter @c9up/rover build:napi'.",
26
+ });
27
+ }
28
+ return suffix;
29
+ }
30
+ function isNativeExports(value) {
31
+ if (value === null || typeof value !== "object")
32
+ return false;
33
+ return (typeof Reflect.get(value, "engineVersion") === "function" &&
34
+ typeof Reflect.get(value, "compile") === "function" &&
35
+ typeof Reflect.get(value, "renderIr") === "function");
36
+ }
37
+ let cachedNative;
38
+ export function getNative() {
39
+ if (cachedNative !== undefined)
40
+ return cachedNative;
41
+ const require = createRequire(import.meta.url);
42
+ const here = fileURLToPath(import.meta.url);
43
+ // `here` is `…/packages/rover/src/templating/loadNapi.ts` (or `dist/…`). The
44
+ // `.node` lives at the package root `…/packages/rover/index.<suffix>.node`,
45
+ // two levels up from `src/templating` / `dist/templating`.
46
+ const suffix = platformSuffix();
47
+ const candidate = `../../index.${suffix}.node`;
48
+ let loaded;
49
+ let lastErr;
50
+ try {
51
+ loaded = require(candidate);
52
+ }
53
+ catch (err) {
54
+ lastErr = err;
55
+ }
56
+ if (loaded === undefined) {
57
+ const causeMessage = lastErr instanceof Error ? lastErr.message : String(lastErr);
58
+ // The prebuilt linux binaries target glibc (`-gnu`). On musl hosts (Alpine
59
+ // containers) the `-gnu` binary fails to dlopen with a libc symbol error —
60
+ // surface that explicitly rather than only pointing at the build step.
61
+ const muslHint = suffix.endsWith("-gnu")
62
+ ? " If you are on Alpine/musl, note the prebuilt binaries target glibc (musl is not a supported target)."
63
+ : "";
64
+ throw new RoverError("MAIL_TEMPLATE_NAPI_REQUIRED", `@c9up/rover native binary 'index.${suffix}.node' not found or failed to load near ${here} — run 'pnpm --filter @c9up/rover build:napi' to build it.${muslHint} Cause: ${causeMessage}`, {
65
+ hint: "Run 'pnpm --filter @c9up/rover build:napi' to compile the native template engine.",
66
+ });
67
+ }
68
+ if (!isNativeExports(loaded)) {
69
+ throw new RoverError("MAIL_TEMPLATE_NAPI_REQUIRED", "@c9up/rover native binary loaded but missing expected exports (engineVersion / compile / renderIr). Rebuild with 'pnpm --filter @c9up/rover build:napi'.", {
70
+ hint: "The native binary is stale — rebuild with 'pnpm --filter @c9up/rover build:napi'.",
71
+ });
72
+ }
73
+ cachedNative = loaded;
74
+ return cachedNative;
75
+ }
76
+ function isNapiErrorPayload(value) {
77
+ if (value === null || typeof value !== "object")
78
+ return false;
79
+ return (typeof Reflect.get(value, "code") === "string" &&
80
+ typeof Reflect.get(value, "message") === "string");
81
+ }
82
+ /** Codes the Rust engine legitimately emits, with their actionable hints. */
83
+ const CODE_HINTS = {
84
+ MAIL_TEMPLATE_SYNTAX: "Check the template grammar: {{ var }}, {{{ raw }}}, {{#if x}}...{{/if}}, {{> partial}}.",
85
+ MAIL_TEMPLATE_RECURSION: "Break the cycle — a template cannot include itself (directly or via partials).",
86
+ MAIL_TEMPLATE_NOT_FOUND: "Create the referenced partial file or remove the {{> name}} reference.",
87
+ };
88
+ /**
89
+ * Translate a thrown value from a native call into a `RoverError`.
90
+ * Already a `RoverError` (e.g. the loader threw) → pass through. A `napi::Error`
91
+ * carrying our `{code,message}` JSON envelope → reconstruct typed (line numbers
92
+ * preserved in `message`). Anything else → wrap as a syntax error.
93
+ */
94
+ export function toReamError(err) {
95
+ if (err instanceof RoverError)
96
+ return err;
97
+ if (err instanceof Error) {
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(err.message);
101
+ }
102
+ catch {
103
+ parsed = undefined;
104
+ }
105
+ if (isNapiErrorPayload(parsed)) {
106
+ const hint = CODE_HINTS[parsed.code];
107
+ if (hint !== undefined) {
108
+ return new RoverError(parsed.code, parsed.message, { hint });
109
+ }
110
+ }
111
+ return new RoverError("MAIL_TEMPLATE_SYNTAX", `Native template call failed: ${err.message}`);
112
+ }
113
+ return new RoverError("MAIL_TEMPLATE_SYNTAX", `Native template call failed with non-Error: ${String(err)}`);
114
+ }
115
+ //# sourceMappingURL=loadNapi.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loadNapi.js","sourceRoot":"","sources":["../../src/templating/loadNapi.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ import type { MailMessage, MailSendOutcome, MailTransport } from "../Mail.js";
2
+ export interface FakeMailPredicate {
3
+ from?: string;
4
+ to?: string;
5
+ cc?: string;
6
+ bcc?: string;
7
+ replyTo?: string;
8
+ subject?: string;
9
+ containing?: string;
10
+ }
11
+ export type FakeMailPredicateArg = FakeMailPredicate | ((m: MailMessage) => boolean);
12
+ /**
13
+ * In-memory transport for tests — captures every `send(message)` call and
14
+ * exposes Adonis/Laravel-style `assertSent` / `assertNotSent` helpers.
15
+ *
16
+ * Not re-exported from the main barrel; reach via `@c9up/rover/testing`.
17
+ */
18
+ export declare class FakeMail implements MailTransport {
19
+ #private;
20
+ send(message: MailMessage): Promise<MailSendOutcome>;
21
+ /**
22
+ * Return a defensive snapshot of captured messages. Each message is cloned
23
+ * (shallow-per-field with array copies) so test-side mutations can't bleed
24
+ * back into the internal capture store — avoids cross-test contamination.
25
+ */
26
+ getSent(): MailMessage[];
27
+ reset(): void;
28
+ assertSent(predicate: FakeMailPredicateArg): void;
29
+ assertNotSent(predicate: FakeMailPredicateArg): void;
30
+ }
31
+ //# sourceMappingURL=FakeMail.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FakeMail.d.ts","sourceRoot":"","sources":["../../src/testing/FakeMail.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE9E,MAAM,WAAW,iBAAiB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,oBAAoB,GAC7B,iBAAiB,GACjB,CAAC,CAAC,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC;AAEjC;;;;;GAKG;AACH,qBAAa,QAAS,YAAW,aAAa;;IAGvC,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,CAAC;IAK1D;;;;OAIG;IACH,OAAO,IAAI,WAAW,EAAE;IAexB,KAAK,IAAI,IAAI;IAIb,UAAU,CAAC,SAAS,EAAE,oBAAoB,GAAG,IAAI;IAQjD,aAAa,CAAC,SAAS,EAAE,oBAAoB,GAAG,IAAI;CAQpD"}
@@ -0,0 +1,90 @@
1
+ /**
2
+ * In-memory transport for tests — captures every `send(message)` call and
3
+ * exposes Adonis/Laravel-style `assertSent` / `assertNotSent` helpers.
4
+ *
5
+ * Not re-exported from the main barrel; reach via `@c9up/rover/testing`.
6
+ */
7
+ export class FakeMail {
8
+ #captured = [];
9
+ async send(message) {
10
+ this.#captured.push(message);
11
+ return undefined;
12
+ }
13
+ /**
14
+ * Return a defensive snapshot of captured messages. Each message is cloned
15
+ * (shallow-per-field with array copies) so test-side mutations can't bleed
16
+ * back into the internal capture store — avoids cross-test contamination.
17
+ */
18
+ getSent() {
19
+ return this.#captured.map((m) => ({
20
+ from: m.from,
21
+ to: m.to.slice(),
22
+ cc: m.cc.slice(),
23
+ bcc: m.bcc.slice(),
24
+ replyTo: m.replyTo,
25
+ subject: m.subject,
26
+ html: m.html,
27
+ text: m.text,
28
+ attachments: m.attachments.slice(),
29
+ headers: { ...m.headers },
30
+ }));
31
+ }
32
+ reset() {
33
+ this.#captured = [];
34
+ }
35
+ assertSent(predicate) {
36
+ const match = makeMatcher(predicate);
37
+ if (this.#captured.some(match))
38
+ return;
39
+ throw new Error(`mail.assertSent() failed — no captured message matches ${describePredicate(predicate)}.\n${describeCaptured(this.#captured)}`);
40
+ }
41
+ assertNotSent(predicate) {
42
+ const match = makeMatcher(predicate);
43
+ const found = this.#captured.find(match);
44
+ if (!found)
45
+ return;
46
+ throw new Error(`mail.assertNotSent() failed — at least one captured message matches ${describePredicate(predicate)}.\n${describeCaptured(this.#captured)}`);
47
+ }
48
+ }
49
+ function makeMatcher(predicate) {
50
+ if (typeof predicate === "function")
51
+ return predicate;
52
+ const p = predicate;
53
+ if (p.containing !== undefined && p.containing === "") {
54
+ throw new Error("FakeMail: `containing` predicate cannot be an empty string — it would match every captured message. Pass a non-empty needle.");
55
+ }
56
+ return (m) => {
57
+ if (p.from !== undefined && m.from !== p.from)
58
+ return false;
59
+ if (p.replyTo !== undefined && m.replyTo !== p.replyTo)
60
+ return false;
61
+ if (p.subject !== undefined && m.subject !== p.subject)
62
+ return false;
63
+ if (p.to !== undefined && !m.to.includes(p.to))
64
+ return false;
65
+ if (p.cc !== undefined && !m.cc.includes(p.cc))
66
+ return false;
67
+ if (p.bcc !== undefined && !m.bcc.includes(p.bcc))
68
+ return false;
69
+ if (p.containing !== undefined) {
70
+ const needle = p.containing;
71
+ const inHtml = m.html?.includes(needle) ?? false;
72
+ const inText = m.text?.includes(needle) ?? false;
73
+ if (!inHtml && !inText)
74
+ return false;
75
+ }
76
+ return true;
77
+ };
78
+ }
79
+ function describePredicate(predicate) {
80
+ if (typeof predicate === "function")
81
+ return "<function predicate>";
82
+ return JSON.stringify(predicate);
83
+ }
84
+ function describeCaptured(captured) {
85
+ if (captured.length === 0)
86
+ return "Captured: (none)";
87
+ const lines = captured.map((m, i) => ` [${i}] to=[${m.to.join(", ")}] subject="${m.subject}" from="${m.from}"`);
88
+ return `Captured (${captured.length}):\n${lines.join("\n")}`;
89
+ }
90
+ //# sourceMappingURL=FakeMail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FakeMail.js","sourceRoot":"","sources":["../../src/testing/FakeMail.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ import { type MailMessage, type MailSendOutcome, type MailTransport } from "../Mail.js";
2
+ export declare class MailgunTransport implements MailTransport {
3
+ #private;
4
+ constructor(config: Record<string, unknown>);
5
+ send(message: MailMessage): Promise<MailSendOutcome>;
6
+ }
7
+ //# sourceMappingURL=MailgunTransport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MailgunTransport.d.ts","sourceRoot":"","sources":["../../src/transports/MailgunTransport.ts"],"names":[],"mappings":"AAIA,OAAO,EACN,KAAK,WAAW,EAChB,KAAK,eAAe,EACpB,KAAK,aAAa,EAElB,MAAM,YAAY,CAAC;AAyBpB,qBAAa,gBAAiB,YAAW,aAAa;;gBAIzC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IA6DrC,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,eAAe,CAAC;CAmD1D"}
@@ -0,0 +1,149 @@
1
+ import { RoverError } from "../RoverError.js";
2
+ import formData from "form-data";
3
+ // mailgun.js is UMD-bundled; the class lives on `.default` under NodeNext.
4
+ import MailgunModule from "mailgun.js";
5
+ import { registerTransport, } from "../Mail.js";
6
+ const stripCrlf = (v) => v.replace(/[\r\n]/g, "");
7
+ const normalizeConfig = (v) => stripCrlf(v).trim();
8
+ const MAX_PROVIDER_MESSAGE = 16 * 1024;
9
+ const capMessage = (s) => s.length <= MAX_PROVIDER_MESSAGE
10
+ ? s
11
+ : `${s.slice(0, MAX_PROVIDER_MESSAGE)}...[truncated]`;
12
+ const redactSecrets = (s) => s
13
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]")
14
+ .replace(/Basic\s+[A-Za-z0-9+/=]+/g, "Basic [REDACTED]");
15
+ export class MailgunTransport {
16
+ #client;
17
+ #domain;
18
+ constructor(config) {
19
+ const apiKey = typeof config.apiKey === "string" ? normalizeConfig(config.apiKey) : "";
20
+ const domain = typeof config.domain === "string" ? normalizeConfig(config.domain) : "";
21
+ if (!apiKey || !domain) {
22
+ throw new RoverError("MAIL_PROVIDER_CONFIG", "Mailgun transport requires apiKey and domain", { hint: "Set { apiKey, domain } in your mail config." });
23
+ }
24
+ this.#domain = domain;
25
+ // Region: reject non-string when *defined* (wrong type signals a config
26
+ // bug — defaulting to "us" under those conditions is a compliance risk).
27
+ if (config.region !== undefined && typeof config.region !== "string") {
28
+ throw new RoverError("MAIL_PROVIDER_CONFIG", `Mailgun region must be a string ("us" or "eu"), got ${typeof config.region}`, { hint: "Set config.region explicitly as 'us' or 'eu'." });
29
+ }
30
+ const rawRegion = typeof config.region === "string"
31
+ ? normalizeConfig(config.region).toLowerCase()
32
+ : "us";
33
+ if (rawRegion !== "us" && rawRegion !== "eu") {
34
+ throw new RoverError("MAIL_PROVIDER_CONFIG", `Mailgun region must be "us" or "eu", got "${rawRegion}"`, {
35
+ hint: "Use { region: 'us' } or { region: 'eu' }. A typo silently routing EU traffic to US infrastructure is a compliance risk.",
36
+ });
37
+ }
38
+ const url = rawRegion === "eu"
39
+ ? "https://api.eu.mailgun.net"
40
+ : "https://api.mailgun.net";
41
+ // Dependency injection for tests: `_client` wins over real SDK. Guarded
42
+ // against non-object and non-shape inputs so a typo'd config can't
43
+ // silently bypass the real client.
44
+ const injected = config._client;
45
+ if (injected &&
46
+ typeof injected === "object" &&
47
+ "messages" in injected &&
48
+ typeof injected.messages?.create === "function") {
49
+ this.#client = injected;
50
+ }
51
+ else {
52
+ // mailgun.js ships a UMD-style default export; the class constructor
53
+ // lives on `.default` in the typings (`static get default`).
54
+ const MailgunCtor = MailgunModule.default;
55
+ const mailgun = new MailgunCtor(formData);
56
+ this.#client = mailgun.client({ username: "api", key: apiKey, url });
57
+ }
58
+ }
59
+ async send(message) {
60
+ if (message.to.length === 0 &&
61
+ message.cc.length === 0 &&
62
+ message.bcc.length === 0) {
63
+ throw new RoverError("MAIL_PROVIDER_CONFIG", "Mail message has no recipients", { hint: "Set at least one `to`, `cc`, or `bcc` before sending." });
64
+ }
65
+ // CRLF stripping is applied at the wire boundary regardless of SDK
66
+ // promises — defence-in-depth. Anti-pattern from spec Dev Notes:
67
+ // "never skip CRLF sanitisation because the provider will handle it".
68
+ const data = {
69
+ from: stripCrlf(message.from),
70
+ to: message.to.map(stripCrlf),
71
+ subject: stripCrlf(message.subject),
72
+ };
73
+ if (message.cc.length)
74
+ data.cc = message.cc.map(stripCrlf);
75
+ if (message.bcc.length)
76
+ data.bcc = message.bcc.map(stripCrlf);
77
+ if (message.text)
78
+ data.text = message.text;
79
+ if (message.html)
80
+ data.html = message.html;
81
+ if (message.replyTo)
82
+ data["h:Reply-To"] = stripCrlf(message.replyTo);
83
+ for (const [k, v] of Object.entries(message.headers)) {
84
+ data[`h:${stripCrlf(k)}`] = stripCrlf(v);
85
+ }
86
+ if (message.attachments.length > 0) {
87
+ data.attachment = message.attachments.map((att) => {
88
+ const entry = {
89
+ filename: stripCrlf(att.filename),
90
+ data: Buffer.from(att.content),
91
+ };
92
+ if (att.contentType) {
93
+ entry.contentType = stripCrlf(att.contentType);
94
+ }
95
+ return entry;
96
+ });
97
+ }
98
+ try {
99
+ const res = await this.#client.messages.create(this.#domain, data);
100
+ if (res.id)
101
+ return { providerId: res.id };
102
+ return undefined;
103
+ }
104
+ catch (err) {
105
+ throw wrapMailgunError(err);
106
+ }
107
+ }
108
+ }
109
+ /**
110
+ * mailgun.js's own error shape is `{ status, details | message, ... }`.
111
+ * Map it to our uniform `MAIL_PROVIDER_ERROR` so retry + observability
112
+ * consumers don't need to branch on provider.
113
+ *
114
+ * Bare `Error` (no `status`) — typical for network/ECONNRESET failures from
115
+ * mailgun.js — surface the original errno (`code`) in context so the retry
116
+ * predicate can still classify it as transient.
117
+ */
118
+ function wrapMailgunError(err) {
119
+ if (err instanceof RoverError)
120
+ return err;
121
+ const anyErr = err;
122
+ // Coerce string-typed status ("401") into number — mailgun.js is
123
+ // inconsistent across versions.
124
+ const statusNum = Number(anyErr.status);
125
+ const status = Number.isFinite(statusNum) ? statusNum : 0;
126
+ const providerMessage = redactSecrets(capMessage(anyErr.details ?? anyErr.message ?? "unknown"));
127
+ const ctx = {
128
+ provider: "mailgun",
129
+ upstreamStatus: String(status),
130
+ providerMessage,
131
+ };
132
+ if (typeof anyErr.code === "string") {
133
+ ctx.networkCode = anyErr.code;
134
+ }
135
+ // Retry-After may arrive either as a header (`retry-after`) or as a JSON
136
+ // body field — pass both upstream so `computeBackoffMs` can honour it.
137
+ const retryAfterHeader = anyErr.headers?.["retry-after"];
138
+ if (retryAfterHeader) {
139
+ ctx.retryAfter = Array.isArray(retryAfterHeader)
140
+ ? (retryAfterHeader[0] ?? "")
141
+ : retryAfterHeader;
142
+ }
143
+ return new RoverError("MAIL_PROVIDER_ERROR", `Mailgun returned ${status || "unknown"}`, {
144
+ hint: "Inspect `context.upstreamStatus` (HTTP) or `context.networkCode` (ECONNRESET/etc.) to decide retry eligibility.",
145
+ context: ctx,
146
+ });
147
+ }
148
+ registerTransport("mailgun", (config) => new MailgunTransport(config));
149
+ //# sourceMappingURL=MailgunTransport.js.map