@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,240 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { RoverError } from "../RoverError.js";
4
+ import { getNative, type NativeRoverIr, toReamError } from "./loadNapi.js";
5
+
6
+ // The compile + interpret hot path now runs in Rust via napi-rs
7
+ // (`rover-template-engine`). This TS facade keeps the byte-identical public
8
+ // surface — `render` / `renderFile` / `setViewsRoot` / `getViewsRoot` /
9
+ // `resetCache` — and owns everything that touches the filesystem: path
10
+ // resolution (incl. the `viewsRoot` traversal guard), the FS read, the
11
+ // compiled-IR cache, and the transitive partial pre-resolution (D55.2.1). The
12
+ // engine never reads disk (ADR-007). Render-time `{{> partial}}` recursion is
13
+ // detected Rust-side (D55.2.2) and surfaces here as `MAIL_TEMPLATE_RECURSION`.
14
+
15
+ let viewsRoot = "resources/views/emails";
16
+ // Resolved-absolute-path -> compiled Rust IR handle. Content-blind: an entry is
17
+ // reused verbatim until `resetCache()` (D55.2.3 — NO mtime/content invalidation;
18
+ // locked by `simple-template.test.ts:109-122`).
19
+ const cache = new Map<string, NativeRoverIr>();
20
+
21
+ export function setViewsRoot(rootDir: string): void {
22
+ viewsRoot = rootDir;
23
+ }
24
+
25
+ export function getViewsRoot(): string {
26
+ return viewsRoot;
27
+ }
28
+
29
+ /** Reset cached compiled templates (test helper). */
30
+ export function resetCache(): void {
31
+ cache.clear();
32
+ }
33
+
34
+ export async function render(
35
+ source: string,
36
+ data: Record<string, unknown>,
37
+ ): Promise<string> {
38
+ const ir = compileSource(source);
39
+ const partials = await buildPartialMap(ir);
40
+ return renderNative(ir, data, partials);
41
+ }
42
+
43
+ export async function renderFile(
44
+ viewPath: string,
45
+ data: Record<string, unknown>,
46
+ _visited: Set<string> = new Set(),
47
+ ): Promise<string> {
48
+ const ir = await loadIr(viewPath);
49
+ const partials = await buildPartialMap(ir);
50
+ return renderNative(ir, data, partials);
51
+ }
52
+
53
+ /** Compile inline source to an IR handle, mapping native errors to `RoverError`. */
54
+ function compileSource(source: string): NativeRoverIr {
55
+ try {
56
+ return getNative().compile(source);
57
+ } catch (err) {
58
+ throw toReamError(err);
59
+ }
60
+ }
61
+
62
+ const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
63
+ const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
64
+
65
+ /**
66
+ * JSON replacer reconciling JS values the pre-migration engine rendered via
67
+ * `String(raw)` but that `JSON.stringify` drops or rejects at the NAPI boundary:
68
+ * - bigint: widen to Number when it round-trips exactly, else refuse (a raw
69
+ * `JSON.stringify` would throw a bare TypeError → opaque error).
70
+ * - NaN / ±Infinity: JSON encodes both as null, so they would render empty and
71
+ * Infinity would silently flip from truthy to falsy. Refuse loudly instead.
72
+ * Note: a `Date` is serialised by its `toJSON` (ISO-8601), a deliberate change
73
+ * from the old engine's locale `String(date)` — ISO is the stable mail format.
74
+ */
75
+ function dataReplacer(_key: string, value: unknown): unknown {
76
+ if (typeof value === "bigint") {
77
+ if (value >= MIN_SAFE_BIGINT && value <= MAX_SAFE_BIGINT) {
78
+ return Number(value);
79
+ }
80
+ throw new RoverError(
81
+ "MAIL_TEMPLATE_SYNTAX",
82
+ `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`,
83
+ );
84
+ }
85
+ if (typeof value === "number" && !Number.isFinite(value)) {
86
+ throw new RoverError(
87
+ "MAIL_TEMPLATE_SYNTAX",
88
+ `Cannot render non-finite number ${value} — NaN and Infinity have no representation across the template engine boundary; format it to a string before rendering`,
89
+ );
90
+ }
91
+ return value;
92
+ }
93
+
94
+ /** Render a compiled IR, mapping native errors (syntax/recursion) to `RoverError`. */
95
+ function renderNative(
96
+ ir: NativeRoverIr,
97
+ data: Record<string, unknown>,
98
+ partials: Record<string, NativeRoverIr>,
99
+ ): string {
100
+ try {
101
+ // `data` crosses as a JSON string: `JSON.stringify` is own-enumerable-only,
102
+ // preserving the engine's `Object.hasOwn` dot-path contract (inherited
103
+ // prototype-chain props stay invisible — the own-property-only parity test).
104
+ // `dataReplacer` reconciles bigint / NaN / Infinity (see above).
105
+ return getNative().renderIr(
106
+ ir,
107
+ JSON.stringify(data, dataReplacer),
108
+ partials,
109
+ );
110
+ } catch (err) {
111
+ throw toReamError(err);
112
+ }
113
+ }
114
+
115
+ /** Extract a Node `fs` errno string (e.g. `"ENOENT"`) without an `as` cast. */
116
+ function errnoCode(err: unknown): string {
117
+ if (err !== null && typeof err === "object") {
118
+ const code = Reflect.get(err, "code");
119
+ if (typeof code === "string") return code;
120
+ }
121
+ return "";
122
+ }
123
+
124
+ /**
125
+ * Resolve + read + compile one template path, caching the compiled IR by its
126
+ * resolved absolute path. Owns the `MAIL_TEMPLATE_NOT_FOUND` / `_READ_ERROR`
127
+ * raising (unchanged from the pre-migration engine).
128
+ */
129
+ async function loadIr(viewPath: string): Promise<NativeRoverIr> {
130
+ const { resolved, tried } = resolveTemplatePath(viewPath);
131
+ const cached = cache.get(resolved);
132
+ if (cached !== undefined) return cached;
133
+
134
+ let source: string;
135
+ try {
136
+ source = await readFile(resolved, "utf8");
137
+ } catch (err) {
138
+ const code = errnoCode(err);
139
+ // Only ENOENT maps to NOT_FOUND; permission/io errors surface distinctly.
140
+ if (code !== "ENOENT") {
141
+ throw new RoverError(
142
+ "MAIL_TEMPLATE_READ_ERROR",
143
+ `Template read failed at ${resolved} (${code || "unknown"})`,
144
+ {
145
+ hint: "Check filesystem permissions and file descriptor limits.",
146
+ context: { path: resolved, code, viewsRoot },
147
+ },
148
+ );
149
+ }
150
+ throw new RoverError(
151
+ "MAIL_TEMPLATE_NOT_FOUND",
152
+ `Template not found at ${resolved}`,
153
+ {
154
+ hint: "Create the file or update config.mail.viewsRoot.",
155
+ context: {
156
+ path: resolved,
157
+ paths: tried.join(", "),
158
+ viewsRoot,
159
+ },
160
+ },
161
+ );
162
+ }
163
+
164
+ const ir = compileSource(source);
165
+ cache.set(resolved, ir);
166
+ return ir;
167
+ }
168
+
169
+ /**
170
+ * Transitively pre-resolve every `{{> name}}` partial reachable from `rootIr`
171
+ * into a `name -> IR handle` map (D55.2.1). Stops re-descending once a name is
172
+ * mapped, so a cyclic partial graph terminates here WITHOUT erroring — the
173
+ * actual `MAIL_TEMPLATE_RECURSION` is raised Rust-side only when the cycle is
174
+ * really rendered (D55.2.2). Partials read here are cached by resolved path
175
+ * (content-blind), exactly as the pre-migration recursive `renderFile` did.
176
+ */
177
+ async function buildPartialMap(
178
+ rootIr: NativeRoverIr,
179
+ ): Promise<Record<string, NativeRoverIr>> {
180
+ const map: Record<string, NativeRoverIr> = {};
181
+ const stack: NativeRoverIr[] = [rootIr];
182
+ while (stack.length > 0) {
183
+ const ir = stack.pop();
184
+ if (ir === undefined) break;
185
+ for (const name of ir.partialNames) {
186
+ if (Object.hasOwn(map, name)) continue;
187
+ let partialIr: NativeRoverIr;
188
+ try {
189
+ partialIr = await loadIr(`partials/${name}`);
190
+ } catch (err) {
191
+ // A partial referenced only inside a falsy `{{#if}}` is never
192
+ // rendered. The pre-migration engine loaded partials lazily, so a
193
+ // missing such partial did not error. Skip it here; if it IS reached
194
+ // at render time the Rust renderer raises MAIL_TEMPLATE_NOT_FOUND.
195
+ if (
196
+ err instanceof RoverError &&
197
+ err.code === "MAIL_TEMPLATE_NOT_FOUND"
198
+ ) {
199
+ continue;
200
+ }
201
+ throw err;
202
+ }
203
+ map[name] = partialIr;
204
+ stack.push(partialIr);
205
+ }
206
+ }
207
+ return map;
208
+ }
209
+
210
+ /**
211
+ * Resolve a template path under `viewsRoot`, rejecting any attempt to escape
212
+ * the root (absolute path outside, or `../` traversal after normalisation).
213
+ * Returns the resolved absolute path plus the set of candidates tried.
214
+ */
215
+ function resolveTemplatePath(viewPath: string): {
216
+ resolved: string;
217
+ tried: string[];
218
+ } {
219
+ const rootAbs = path.resolve(viewsRoot);
220
+ const startingPoint = path.isAbsolute(viewPath)
221
+ ? viewPath
222
+ : path.resolve(rootAbs, viewPath);
223
+ const withoutExt = startingPoint;
224
+ const withExt = startingPoint.endsWith(".html")
225
+ ? startingPoint
226
+ : `${startingPoint}.html`;
227
+ const candidate = path.resolve(withExt);
228
+ const rel = path.relative(rootAbs, candidate);
229
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
230
+ throw new RoverError(
231
+ "MAIL_TEMPLATE_NOT_FOUND",
232
+ `Template path "${viewPath}" resolves outside of viewsRoot`,
233
+ {
234
+ hint: "Template names must stay under viewsRoot. Absolute paths and `..` traversals are rejected.",
235
+ context: { path: candidate, viewsRoot: rootAbs },
236
+ },
237
+ );
238
+ }
239
+ return { resolved: candidate, tried: [withExt, withoutExt] };
240
+ }
@@ -0,0 +1,169 @@
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
+
10
+ import { createRequire } from "node:module";
11
+ import { arch, platform } from "node:process";
12
+ import { fileURLToPath } from "node:url";
13
+ import { RoverError } from "../RoverError.js";
14
+
15
+ const SUFFIX_MAP: Readonly<Record<string, string>> = {
16
+ "linux-x64": "linux-x64-gnu",
17
+ "linux-arm64": "linux-arm64-gnu",
18
+ "darwin-x64": "darwin-x64",
19
+ "darwin-arm64": "darwin-arm64",
20
+ "win32-x64": "win32-x64-msvc",
21
+ };
22
+
23
+ function platformSuffix(): string {
24
+ const key = `${platform}-${arch}`;
25
+ const suffix = SUFFIX_MAP[key];
26
+ if (typeof suffix !== "string") {
27
+ throw new RoverError(
28
+ "MAIL_TEMPLATE_NAPI_REQUIRED",
29
+ `Unsupported platform/arch '${key}' for @c9up/rover native binary. Supported: ${Object.keys(SUFFIX_MAP).join(", ")}.`,
30
+ {
31
+ hint: "Build the native binary on a supported platform with 'pnpm --filter @c9up/rover build:napi'.",
32
+ },
33
+ );
34
+ }
35
+ return suffix;
36
+ }
37
+
38
+ /** Opaque handle to a compiled template IR (Rust `RoverIr`). */
39
+ export interface NativeRoverIr {
40
+ /** Referenced partial names, in document order (descends into if-bodies). */
41
+ readonly partialNames: readonly string[];
42
+ }
43
+
44
+ interface NativeExports {
45
+ readonly engineVersion: () => string;
46
+ readonly compile: (source: string) => NativeRoverIr;
47
+ readonly renderIr: (
48
+ ir: NativeRoverIr,
49
+ dataJson: string,
50
+ partials: Record<string, NativeRoverIr>,
51
+ ) => string;
52
+ }
53
+
54
+ function isNativeExports(value: unknown): value is NativeExports {
55
+ if (value === null || typeof value !== "object") return false;
56
+ return (
57
+ typeof Reflect.get(value, "engineVersion") === "function" &&
58
+ typeof Reflect.get(value, "compile") === "function" &&
59
+ typeof Reflect.get(value, "renderIr") === "function"
60
+ );
61
+ }
62
+
63
+ let cachedNative: NativeExports | undefined;
64
+
65
+ export function getNative(): NativeExports {
66
+ if (cachedNative !== undefined) return cachedNative;
67
+
68
+ const require = createRequire(import.meta.url);
69
+ const here = fileURLToPath(import.meta.url);
70
+ // `here` is `…/packages/rover/src/templating/loadNapi.ts` (or `dist/…`). The
71
+ // `.node` lives at the package root `…/packages/rover/index.<suffix>.node`,
72
+ // two levels up from `src/templating` / `dist/templating`.
73
+ const suffix = platformSuffix();
74
+ const candidate = `../../index.${suffix}.node`;
75
+ let loaded: unknown;
76
+ let lastErr: unknown;
77
+ try {
78
+ loaded = require(candidate);
79
+ } catch (err) {
80
+ lastErr = err;
81
+ }
82
+ if (loaded === undefined) {
83
+ const causeMessage =
84
+ lastErr instanceof Error ? lastErr.message : String(lastErr);
85
+ // The prebuilt linux binaries target glibc (`-gnu`). On musl hosts (Alpine
86
+ // containers) the `-gnu` binary fails to dlopen with a libc symbol error —
87
+ // surface that explicitly rather than only pointing at the build step.
88
+ const muslHint = suffix.endsWith("-gnu")
89
+ ? " If you are on Alpine/musl, note the prebuilt binaries target glibc (musl is not a supported target)."
90
+ : "";
91
+ throw new RoverError(
92
+ "MAIL_TEMPLATE_NAPI_REQUIRED",
93
+ `@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}`,
94
+ {
95
+ hint: "Run 'pnpm --filter @c9up/rover build:napi' to compile the native template engine.",
96
+ },
97
+ );
98
+ }
99
+ if (!isNativeExports(loaded)) {
100
+ throw new RoverError(
101
+ "MAIL_TEMPLATE_NAPI_REQUIRED",
102
+ "@c9up/rover native binary loaded but missing expected exports (engineVersion / compile / renderIr). Rebuild with 'pnpm --filter @c9up/rover build:napi'.",
103
+ {
104
+ hint: "The native binary is stale — rebuild with 'pnpm --filter @c9up/rover build:napi'.",
105
+ },
106
+ );
107
+ }
108
+ cachedNative = loaded;
109
+ return cachedNative;
110
+ }
111
+
112
+ /**
113
+ * Shape of the JSON payload Rust packs into `napi::Error::from_reason`. Rust
114
+ * guarantees `code` + `message` are present.
115
+ */
116
+ interface NapiErrorPayload {
117
+ readonly code: string;
118
+ readonly message: string;
119
+ }
120
+
121
+ function isNapiErrorPayload(value: unknown): value is NapiErrorPayload {
122
+ if (value === null || typeof value !== "object") return false;
123
+ return (
124
+ typeof Reflect.get(value, "code") === "string" &&
125
+ typeof Reflect.get(value, "message") === "string"
126
+ );
127
+ }
128
+
129
+ /** Codes the Rust engine legitimately emits, with their actionable hints. */
130
+ const CODE_HINTS: Readonly<Record<string, string>> = {
131
+ MAIL_TEMPLATE_SYNTAX:
132
+ "Check the template grammar: {{ var }}, {{{ raw }}}, {{#if x}}...{{/if}}, {{> partial}}.",
133
+ MAIL_TEMPLATE_RECURSION:
134
+ "Break the cycle — a template cannot include itself (directly or via partials).",
135
+ MAIL_TEMPLATE_NOT_FOUND:
136
+ "Create the referenced partial file or remove the {{> name}} reference.",
137
+ };
138
+
139
+ /**
140
+ * Translate a thrown value from a native call into a `RoverError`.
141
+ * Already a `RoverError` (e.g. the loader threw) → pass through. A `napi::Error`
142
+ * carrying our `{code,message}` JSON envelope → reconstruct typed (line numbers
143
+ * preserved in `message`). Anything else → wrap as a syntax error.
144
+ */
145
+ export function toReamError(err: unknown): RoverError {
146
+ if (err instanceof RoverError) return err;
147
+ if (err instanceof Error) {
148
+ let parsed: unknown;
149
+ try {
150
+ parsed = JSON.parse(err.message);
151
+ } catch {
152
+ parsed = undefined;
153
+ }
154
+ if (isNapiErrorPayload(parsed)) {
155
+ const hint = CODE_HINTS[parsed.code];
156
+ if (hint !== undefined) {
157
+ return new RoverError(parsed.code, parsed.message, { hint });
158
+ }
159
+ }
160
+ return new RoverError(
161
+ "MAIL_TEMPLATE_SYNTAX",
162
+ `Native template call failed: ${err.message}`,
163
+ );
164
+ }
165
+ return new RoverError(
166
+ "MAIL_TEMPLATE_SYNTAX",
167
+ `Native template call failed with non-Error: ${String(err)}`,
168
+ );
169
+ }
@@ -0,0 +1,112 @@
1
+ import type { MailMessage, MailSendOutcome, MailTransport } from "../Mail.js";
2
+
3
+ export interface FakeMailPredicate {
4
+ from?: string;
5
+ to?: string;
6
+ cc?: string;
7
+ bcc?: string;
8
+ replyTo?: string;
9
+ subject?: string;
10
+ containing?: string;
11
+ }
12
+
13
+ export type FakeMailPredicateArg =
14
+ | FakeMailPredicate
15
+ | ((m: MailMessage) => boolean);
16
+
17
+ /**
18
+ * In-memory transport for tests — captures every `send(message)` call and
19
+ * exposes Adonis/Laravel-style `assertSent` / `assertNotSent` helpers.
20
+ *
21
+ * Not re-exported from the main barrel; reach via `@c9up/rover/testing`.
22
+ */
23
+ export class FakeMail implements MailTransport {
24
+ #captured: MailMessage[] = [];
25
+
26
+ async send(message: MailMessage): Promise<MailSendOutcome> {
27
+ this.#captured.push(message);
28
+ return undefined;
29
+ }
30
+
31
+ /**
32
+ * Return a defensive snapshot of captured messages. Each message is cloned
33
+ * (shallow-per-field with array copies) so test-side mutations can't bleed
34
+ * back into the internal capture store — avoids cross-test contamination.
35
+ */
36
+ getSent(): MailMessage[] {
37
+ return this.#captured.map((m) => ({
38
+ from: m.from,
39
+ to: m.to.slice(),
40
+ cc: m.cc.slice(),
41
+ bcc: m.bcc.slice(),
42
+ replyTo: m.replyTo,
43
+ subject: m.subject,
44
+ html: m.html,
45
+ text: m.text,
46
+ attachments: m.attachments.slice(),
47
+ headers: { ...m.headers },
48
+ }));
49
+ }
50
+
51
+ reset(): void {
52
+ this.#captured = [];
53
+ }
54
+
55
+ assertSent(predicate: FakeMailPredicateArg): void {
56
+ const match = makeMatcher(predicate);
57
+ if (this.#captured.some(match)) return;
58
+ throw new Error(
59
+ `mail.assertSent() failed — no captured message matches ${describePredicate(predicate)}.\n${describeCaptured(this.#captured)}`,
60
+ );
61
+ }
62
+
63
+ assertNotSent(predicate: FakeMailPredicateArg): void {
64
+ const match = makeMatcher(predicate);
65
+ const found = this.#captured.find(match);
66
+ if (!found) return;
67
+ throw new Error(
68
+ `mail.assertNotSent() failed — at least one captured message matches ${describePredicate(predicate)}.\n${describeCaptured(this.#captured)}`,
69
+ );
70
+ }
71
+ }
72
+
73
+ function makeMatcher(
74
+ predicate: FakeMailPredicateArg,
75
+ ): (m: MailMessage) => boolean {
76
+ if (typeof predicate === "function") return predicate;
77
+ const p = predicate;
78
+ if (p.containing !== undefined && p.containing === "") {
79
+ throw new Error(
80
+ "FakeMail: `containing` predicate cannot be an empty string — it would match every captured message. Pass a non-empty needle.",
81
+ );
82
+ }
83
+ return (m) => {
84
+ if (p.from !== undefined && m.from !== p.from) return false;
85
+ if (p.replyTo !== undefined && m.replyTo !== p.replyTo) return false;
86
+ if (p.subject !== undefined && m.subject !== p.subject) return false;
87
+ if (p.to !== undefined && !m.to.includes(p.to)) return false;
88
+ if (p.cc !== undefined && !m.cc.includes(p.cc)) return false;
89
+ if (p.bcc !== undefined && !m.bcc.includes(p.bcc)) return false;
90
+ if (p.containing !== undefined) {
91
+ const needle = p.containing;
92
+ const inHtml = m.html?.includes(needle) ?? false;
93
+ const inText = m.text?.includes(needle) ?? false;
94
+ if (!inHtml && !inText) return false;
95
+ }
96
+ return true;
97
+ };
98
+ }
99
+
100
+ function describePredicate(predicate: FakeMailPredicateArg): string {
101
+ if (typeof predicate === "function") return "<function predicate>";
102
+ return JSON.stringify(predicate);
103
+ }
104
+
105
+ function describeCaptured(captured: MailMessage[]): string {
106
+ if (captured.length === 0) return "Captured: (none)";
107
+ const lines = captured.map(
108
+ (m, i) =>
109
+ ` [${i}] to=[${m.to.join(", ")}] subject="${m.subject}" from="${m.from}"`,
110
+ );
111
+ return `Captured (${captured.length}):\n${lines.join("\n")}`;
112
+ }