@hogsend/engine 0.10.0 → 0.12.0

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.
@@ -0,0 +1,104 @@
1
+ import type { Auth } from "./auth.js";
2
+
3
+ /**
4
+ * Shared admin-minting primitive used by BOTH the CLI's `admin create` and the
5
+ * engine's env bootstrap. Mints a credential admin via better-auth's
6
+ * INTERNAL ADAPTER (scrypt-identical to the running app) rather than the public
7
+ * sign-up endpoint — which is now blocked by `disableSignUp` (see lib/auth.ts).
8
+ *
9
+ * Why the internal adapter (not `auth.api.signUpEmail`): in better-auth 1.6.11
10
+ * the `disableSignUp` check lives INSIDE the sign-up endpoint handler, and
11
+ * `auth.api.signUpEmail` dispatches through that SAME handler — so with sign-up
12
+ * disabled it throws `EMAIL_PASSWORD_SIGN_UP_DISABLED` for the in-process API
13
+ * too. The internal adapter is NOT subject to that guard. This mirrors exactly
14
+ * what admin-recovery's `reset()` already does for its no-credential branch
15
+ * (`ctx.password.hash` + `ctx.internalAdapter.createAccount({ providerId:
16
+ * "credential" })`).
17
+ *
18
+ * Security invariants (acceptance gates, not preferences):
19
+ * - The password is hashed via `ctx.password.hash` (scrypt, identical to the
20
+ * app). There is NO raw SQL password write.
21
+ * - The password is never logged and never returned in any result object.
22
+ * - `emailVerified: true` because this is an operator-minted admin (CLI or
23
+ * boot env), not a self-service signup.
24
+ *
25
+ * Lives in `lib/` (reachable via the `@hogsend/engine/create-admin` subpath)
26
+ * with a module graph that touches ONLY better-auth — it never pulls `env.ts`,
27
+ * Hatchet, or Resend — so the CLI can import it the same way it imports
28
+ * `createAuth` from `@hogsend/engine/auth`.
29
+ */
30
+
31
+ /** A single admin row, no secrets. Shared with the CLI's `AdminSummary`. */
32
+ export interface CreatedAdmin {
33
+ id: string;
34
+ email: string;
35
+ name: string;
36
+ createdAt: string;
37
+ }
38
+
39
+ /** Thrown when an admin with the given email already exists. */
40
+ export class AdminAlreadyExistsError extends Error {
41
+ constructor(public readonly email: string) {
42
+ super(
43
+ `An admin with email "${email}" already exists. ` +
44
+ "Use `hogsend studio admin reset` to set a new password.",
45
+ );
46
+ this.name = "AdminAlreadyExistsError";
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Create a credential admin user against a built better-auth instance. Throws
52
+ * {@link AdminAlreadyExistsError} if the email already exists (so callers can
53
+ * point the operator at `reset`). The unique constraint on `user.email` is the
54
+ * backstop: a concurrent racer that slips past the pre-check throws on
55
+ * `createUser` — callers that need idempotency should catch that.
56
+ */
57
+ export async function createAdminUser(opts: {
58
+ auth: Auth;
59
+ email: string;
60
+ name?: string;
61
+ password: string;
62
+ }): Promise<CreatedAdmin> {
63
+ const { auth, email, password } = opts;
64
+ const displayName = opts.name ?? email.split("@")[0] ?? email;
65
+
66
+ const ctx = await auth.$context;
67
+
68
+ // Pre-check for a clear error (better-auth lowercases on lookup + create).
69
+ const existing = await ctx.internalAdapter.findUserByEmail(email);
70
+ if (existing) {
71
+ throw new AdminAlreadyExistsError(email);
72
+ }
73
+
74
+ // scrypt hash — identical to the running app (admin-recovery.reset uses the
75
+ // same call). NO raw SQL password write.
76
+ const hashed = await ctx.password.hash(password);
77
+
78
+ // `createUser` returns the bare user row (createWithHooks → the created user),
79
+ // NOT `{ user }` — verified against better-auth@1.6.11 internal-adapter.mjs:75.
80
+ const created = await ctx.internalAdapter.createUser({
81
+ email,
82
+ name: displayName,
83
+ emailVerified: true,
84
+ });
85
+
86
+ await ctx.internalAdapter.createAccount({
87
+ userId: created.id,
88
+ providerId: "credential",
89
+ accountId: created.id,
90
+ password: hashed,
91
+ });
92
+
93
+ const createdAt =
94
+ created.createdAt instanceof Date
95
+ ? created.createdAt.toISOString()
96
+ : String(created.createdAt ?? new Date().toISOString());
97
+
98
+ return {
99
+ id: created.id,
100
+ email: created.email,
101
+ name: created.name,
102
+ createdAt,
103
+ };
104
+ }
@@ -0,0 +1,327 @@
1
+ import type { DomainStatus, EmailProvider } from "@hogsend/core";
2
+ import type { env as envSchema } from "../env.js";
3
+ import type { Logger } from "./logger.js";
4
+
5
+ /**
6
+ * Per-send test-mode snapshot (PROJECT_SPEC pinned shape).
7
+ *
8
+ * F1 ships the FULL shape STUBBED INACTIVE — `testModeCached()` and the
9
+ * `testMode` block of `getStatus()` always return
10
+ * `{ active: false, reason: null, redirectTo: null, fromOverride: null }`.
11
+ * F3 test-mode-sends replaces this stub with the real env-flag +
12
+ * domain-unverified logic; every surface that renders the block (admin route,
13
+ * CLI, Studio) lights up with zero further changes.
14
+ */
15
+ export interface TestModeState {
16
+ active: boolean;
17
+ reason: "env_flag" | "domain_unverified" | null;
18
+ /** HOGSEND_TEST_EMAIL ?? STUDIO_ADMIN_EMAIL ?? null (F3). */
19
+ redirectTo: string | null;
20
+ /** "onboarding@resend.dev" when providerId==="resend" && active (F3); else null. */
21
+ fromOverride: string | null;
22
+ }
23
+
24
+ /**
25
+ * The engine-level domain snapshot every surface consumes: the admin route
26
+ * (`GET /v1/admin/domain`), the CLI (`hogsend domain status`), Studio's Setup
27
+ * view, and (cached, F3) the mailer's test-mode check.
28
+ */
29
+ export interface EngineDomainStatus {
30
+ /**
31
+ * EMAIL_DOMAIN ?? host part of EMAIL_FROM (?? RESEND_FROM_EMAIL); `null`
32
+ * when underivable.
33
+ */
34
+ domain: string | null;
35
+ providerId: string;
36
+ /** `!!provider.domains` — presence of the capability is the gate. */
37
+ supported: boolean;
38
+ /** `null` when `!supported || !domain` (the provider is never called then). */
39
+ status: DomainStatus | null;
40
+ testMode: TestModeState;
41
+ }
42
+
43
+ /**
44
+ * Cached domain-status service. The per-send safety contract:
45
+ * `isVerifiedCached()`/`testModeCached()` are SYNC and cache-only (never await
46
+ * a provider call, never throw), and `refreshIfStale()` is fire-and-forget —
47
+ * so the mailer's hot path adds zero provider latency.
48
+ */
49
+ export interface DomainStatusService {
50
+ /** `refresh: true` bypasses + busts the cache (admin `?refresh=true`, CLI `domain check`). */
51
+ getStatus(opts?: { refresh?: boolean }): Promise<EngineDomainStatus>;
52
+ /**
53
+ * Cache-only, NEVER awaits a provider call, never throws. FAIL-OPEN: no
54
+ * cache entry / unknown ⇒ `true`, so a provider outage can never silently
55
+ * redirect production mail.
56
+ */
57
+ isVerifiedCached(): boolean;
58
+ /** Sync snapshot for the per-send path (mailer). Cache-only. */
59
+ testModeCached(): TestModeState;
60
+ /**
61
+ * Fire-and-forget refresh when the cache is stale; called by the mailer per
62
+ * send and once at boot. Cheap no-op when fresh; concurrent refreshes are
63
+ * deduped; errors are swallowed + `logger.warn`ed.
64
+ */
65
+ refreshIfStale(): void;
66
+ }
67
+
68
+ /** TTL once the domain is verified — re-checks are cheap insurance only. */
69
+ const VERIFIED_TTL_MS = 10 * 60 * 1000;
70
+ /** TTL while unverified/failed/unknown — keeps test-mode auto-exit ≤60 s. */
71
+ const UNVERIFIED_TTL_MS = 60 * 1000;
72
+
73
+ /** Extract the host part of an email address ("hello@x.com" → "x.com"). */
74
+ function hostPartOf(email: string | undefined): string | null {
75
+ if (!email) return null;
76
+ const at = email.lastIndexOf("@");
77
+ if (at === -1 || at === email.length - 1) return null;
78
+ return email.slice(at + 1).toLowerCase();
79
+ }
80
+
81
+ /** The Resend unverified-domain from-address fallback (so a redirected mail
82
+ * still delivers while the real sending domain isn't verified yet). */
83
+ const RESEND_UNVERIFIED_FROM = "onboarding@resend.dev";
84
+
85
+ /** Inputs to the pure test-mode resolver — single-object-in/result-object-out. */
86
+ interface ResolveTestModeDeps {
87
+ /** env.HOGSEND_TEST_MODE. */
88
+ mode: "auto" | "true" | "false";
89
+ /**
90
+ * Whether the sending domain is verified per the CACHE. FAIL-OPEN: a cache
91
+ * miss / provider outage / `!supported` / no domain resolves to `true`
92
+ * (verified assumed), so a provider outage can never silently redirect prod
93
+ * mail (inherits {@link DomainStatusService.isVerifiedCached}).
94
+ */
95
+ verifiedCached: boolean;
96
+ /**
97
+ * Whether `auto` is allowed to ARM at all. `auto` only redirects when an
98
+ * EMAIL_DOMAIN is explicitly configured AND the provider supports domains —
99
+ * a bare deploy (no domain / no capability) keeps today's LIVE behavior, so
100
+ * existing users' sends are never silently redirected.
101
+ */
102
+ autoArmable: boolean;
103
+ providerId: string;
104
+ /** env.HOGSEND_TEST_EMAIL. */
105
+ testEmail?: string;
106
+ /** env.STUDIO_ADMIN_EMAIL (the fallback redirect target). */
107
+ adminEmail?: string;
108
+ }
109
+
110
+ /**
111
+ * Pure resolver for the {@link TestModeState} (PROJECT_SPEC §b, frozen rules):
112
+ * - `active` = `mode === "true"` OR (`mode === "auto"` AND `autoArmable` AND
113
+ * `!verifiedCached`). `mode === "false"` ⇒ never active.
114
+ * - `reason` = `"env_flag"` when forced by `mode === "true"`, else
115
+ * `"domain_unverified"` when auto-activated, else `null`.
116
+ * - `redirectTo` = `testEmail ?? adminEmail ?? null`.
117
+ * - `fromOverride` = `onboarding@resend.dev` iff `active && providerId === "resend"`,
118
+ * else `null` (Postmark et al. get a provider-neutral redirect, no from-override).
119
+ */
120
+ function resolveTestMode(deps: ResolveTestModeDeps): TestModeState {
121
+ const {
122
+ mode,
123
+ verifiedCached,
124
+ autoArmable,
125
+ providerId,
126
+ testEmail,
127
+ adminEmail,
128
+ } = deps;
129
+
130
+ const forced = mode === "true";
131
+ const autoActive = mode === "auto" && autoArmable && !verifiedCached;
132
+ const active = forced || autoActive;
133
+
134
+ const reason: TestModeState["reason"] = forced
135
+ ? "env_flag"
136
+ : autoActive
137
+ ? "domain_unverified"
138
+ : null;
139
+
140
+ return {
141
+ active,
142
+ reason,
143
+ redirectTo: active ? (testEmail ?? adminEmail ?? null) : null,
144
+ fromOverride:
145
+ active && providerId === "resend" ? RESEND_UNVERIFIED_FROM : null,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Build the cached {@link DomainStatusService} for the active email provider.
151
+ * In-memory cache ONLY (one process = one cache; the API and worker each keep
152
+ * their own) — no Redis dependency.
153
+ */
154
+ export function createDomainStatusService(deps: {
155
+ provider: EmailProvider;
156
+ env: typeof envSchema;
157
+ logger: Logger;
158
+ }): DomainStatusService {
159
+ const { provider, env, logger } = deps;
160
+
161
+ const providerId = provider.meta?.id ?? "resend";
162
+ const supported = Boolean(provider.domains);
163
+ const domain =
164
+ env.EMAIL_DOMAIN ??
165
+ hostPartOf(env.EMAIL_FROM) ??
166
+ hostPartOf(env.RESEND_FROM_EMAIL);
167
+
168
+ // `auto` only ARMS when an EMAIL_DOMAIN is explicitly configured AND the
169
+ // provider has the domains capability. A deploy with no EMAIL_DOMAIN or no
170
+ // capability keeps today's LIVE behavior under `auto` — this is the critical
171
+ // back-compat guard, NOT to be broadened.
172
+ const autoArmable = supported && Boolean(env.EMAIL_DOMAIN);
173
+ const mode = env.HOGSEND_TEST_MODE;
174
+
175
+ let cache: { snapshot: EngineDomainStatus; fetchedAt: number } | null = null;
176
+ let inflight: Promise<EngineDomainStatus> | null = null;
177
+
178
+ const isFresh = (): boolean => {
179
+ if (!cache) return false;
180
+ const ttl =
181
+ cache.snapshot.status?.state === "verified"
182
+ ? VERIFIED_TTL_MS
183
+ : UNVERIFIED_TTL_MS;
184
+ return Date.now() - cache.fetchedAt < ttl;
185
+ };
186
+
187
+ // FAIL-OPEN verified check against the live cache (shared by the resolver and
188
+ // the public `isVerifiedCached`): no cache entry, or nothing to verify
189
+ // (unsupported provider / underivable domain), ⇒ verified-assumed.
190
+ const verifiedCachedNow = (): boolean => {
191
+ if (!cache || cache.snapshot.status === null) return true;
192
+ return cache.snapshot.status.state === "verified";
193
+ };
194
+
195
+ /** Compute the CURRENT live test-mode snapshot off the cache + env. Pure +
196
+ * synchronous; never throws (fail-open inherits from `verifiedCachedNow`). */
197
+ const computeTestMode = (): TestModeState =>
198
+ resolveTestMode({
199
+ mode,
200
+ verifiedCached: verifiedCachedNow(),
201
+ autoArmable,
202
+ providerId,
203
+ testEmail: env.HOGSEND_TEST_EMAIL,
204
+ adminEmail: env.STUDIO_ADMIN_EMAIL,
205
+ });
206
+
207
+ // Previous-active flag drives one transition log per flip. Seeded `false` so
208
+ // the FIRST resolution that activates test mode logs the entering banner once
209
+ // (the boot warm-up refresh IS the banner — no separate boot code path).
210
+ let previousActive = false;
211
+
212
+ /** Log the entering/exiting transition exactly once per flip of `active`. */
213
+ const logTransition = (testMode: TestModeState): void => {
214
+ if (testMode.active === previousActive) return;
215
+ if (testMode.active) {
216
+ logger.warn(
217
+ "test mode ACTIVE — domain unverified, redirecting all sends",
218
+ { redirectTo: testMode.redirectTo, reason: testMode.reason },
219
+ );
220
+ } else {
221
+ logger.info("test mode exited — domain verified, sends are LIVE", {
222
+ domain,
223
+ });
224
+ }
225
+ previousActive = testMode.active;
226
+ };
227
+
228
+ /**
229
+ * Refill the cache snapshot with the resolved `status`, then compute the live
230
+ * `testMode` off the JUST-written cache and fire the transition log on a flip.
231
+ * Test mode is computed last so it reads the fresh verification state.
232
+ */
233
+ const commitSnapshot = (status: DomainStatus | null): EngineDomainStatus => {
234
+ // Seed the cache with a placeholder testMode so `computeTestMode` reads the
235
+ // fresh `status`, then overwrite the block with the resolved state.
236
+ const snapshot: EngineDomainStatus = {
237
+ domain,
238
+ providerId,
239
+ supported,
240
+ status,
241
+ testMode: {
242
+ active: false,
243
+ reason: null,
244
+ redirectTo: null,
245
+ fromOverride: null,
246
+ },
247
+ };
248
+ cache = { snapshot, fetchedAt: Date.now() };
249
+ const testMode = computeTestMode();
250
+ snapshot.testMode = testMode;
251
+ logTransition(testMode);
252
+ return snapshot;
253
+ };
254
+
255
+ /** Always queries the provider (when supported) and refills the cache. */
256
+ const fetchSnapshot = async (): Promise<EngineDomainStatus> => {
257
+ // No capability / no derivable domain: resolve instantly, NEVER call the
258
+ // provider. status stays null per the pinned EngineDomainStatus contract.
259
+ if (!supported || !domain) {
260
+ return commitSnapshot(null);
261
+ }
262
+
263
+ // biome-ignore lint/style/noNonNullAssertion: `supported` guarantees it.
264
+ const capability = provider.domains!;
265
+ const providerStatus = await capability.get(domain);
266
+ return commitSnapshot(
267
+ // Provider doesn't know the domain yet → an explicit not_found status
268
+ // (the Studio Setup view keys its add-domain form off this).
269
+ providerStatus ?? {
270
+ domain,
271
+ state: "not_found",
272
+ records: [],
273
+ providerId,
274
+ checkedAt: new Date().toISOString(),
275
+ },
276
+ );
277
+ };
278
+
279
+ /** Deduped fetch: concurrent callers share one in-flight provider call. */
280
+ const fetchDeduped = (): Promise<EngineDomainStatus> => {
281
+ if (!inflight) {
282
+ inflight = fetchSnapshot().finally(() => {
283
+ inflight = null;
284
+ });
285
+ }
286
+ return inflight;
287
+ };
288
+
289
+ return {
290
+ async getStatus(opts?: { refresh?: boolean }): Promise<EngineDomainStatus> {
291
+ if (opts?.refresh) {
292
+ // Bypass + bust: drop the cache so a failed refresh can't leave a
293
+ // stale "fresh" entry, then force a provider round-trip.
294
+ cache = null;
295
+ return fetchDeduped();
296
+ }
297
+ if (isFresh() && cache) return cache.snapshot;
298
+ return fetchDeduped();
299
+ },
300
+
301
+ isVerifiedCached(): boolean {
302
+ // FAIL-OPEN: no cache entry, or nothing to verify (unsupported provider /
303
+ // underivable domain) ⇒ treat as verified so a provider outage or a bare
304
+ // deploy can never silently redirect production mail.
305
+ return verifiedCachedNow();
306
+ },
307
+
308
+ testModeCached(): TestModeState {
309
+ // Sync, cache-only, never throws. Recomputed off the CURRENT cache so the
310
+ // per-send path always sees the freshest verification state without
311
+ // awaiting (env-flag mode resolves even with a cold cache; auto fails open
312
+ // to LIVE while the cache is empty/unknown).
313
+ return computeTestMode();
314
+ },
315
+
316
+ refreshIfStale(): void {
317
+ if (isFresh()) return;
318
+ void fetchDeduped().catch((error: unknown) => {
319
+ logger.warn("domain-status refresh failed", {
320
+ domain,
321
+ providerId,
322
+ error: error instanceof Error ? error.message : String(error),
323
+ });
324
+ });
325
+ },
326
+ };
327
+ }
@@ -27,6 +27,7 @@ type CreatePostmarkProvider = (cfg: {
27
27
  serverToken: string;
28
28
  messageStream?: string;
29
29
  webhookBasicAuth?: { user: string; pass: string };
30
+ accountToken?: string;
30
31
  }) => EmailProvider;
31
32
 
32
33
  const POSTMARK_PACKAGE = ["@hogsend", "plugin-postmark"].join("/");
@@ -78,6 +79,10 @@ export function emailProvidersFromEnv(env: typeof envSchema): EmailProvider[] {
78
79
  ...(env.POSTMARK_MESSAGE_STREAM
79
80
  ? { messageStream: env.POSTMARK_MESSAGE_STREAM }
80
81
  : {}),
82
+ // Account token unlocks the Domains API capability (optional).
83
+ ...(env.POSTMARK_ACCOUNT_TOKEN
84
+ ? { accountToken: env.POSTMARK_ACCOUNT_TOKEN }
85
+ : {}),
81
86
  ...(env.POSTMARK_WEBHOOK_USER && env.POSTMARK_WEBHOOK_PASS
82
87
  ? {
83
88
  webhookBasicAuth: {
@@ -73,8 +73,14 @@ export interface TrackedSendResult {
73
73
  */
74
74
  resendId: string;
75
75
  status: "sent" | "suppressed" | "unsubscribed" | "skipped";
76
- /** Present only when `status === "skipped"` by the frequency cap. */
77
- reason?: "frequency_capped";
76
+ /**
77
+ * Present only when `status === "skipped"`:
78
+ * - `"frequency_capped"` — the per-recipient frequency cap was hit.
79
+ * - `"test_mode_blocked"` — test mode was active but no redirect address
80
+ * resolved (no `HOGSEND_TEST_EMAIL` / `STUDIO_ADMIN_EMAIL`), so the send was
81
+ * blocked rather than delivered to the real recipient.
82
+ */
83
+ reason?: "frequency_capped" | "test_mode_blocked";
78
84
  }
79
85
 
80
86
  /**
package/src/lib/mailer.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  } from "@hogsend/email";
15
15
  import { getTemplate, renderToHtml, renderToPlainText } from "@hogsend/email";
16
16
  import { eq, inArray, sql } from "drizzle-orm";
17
+ import type { DomainStatusService } from "./domain-status.js";
17
18
  import {
18
19
  type EmailService,
19
20
  type EmailServiceConfig,
@@ -27,6 +28,14 @@ import {
27
28
  import { hatchet } from "./hatchet.js";
28
29
  import { createLogger } from "./logger.js";
29
30
  import { emitOutbound } from "./outbound.js";
31
+ import {
32
+ buildRedirect,
33
+ isUnaddressable,
34
+ logRedirect,
35
+ NO_REDIRECT_MESSAGE,
36
+ resolveTestMode,
37
+ TestModeNoRedirectError,
38
+ } from "./test-mode.js";
30
39
  import type { PrepareTrackedHtmlFn } from "./tracked.js";
31
40
  import { sendTrackedEmail } from "./tracked.js";
32
41
  import { resolveEmailSendContextByMessageId } from "./tracking-events.js";
@@ -71,12 +80,20 @@ export function createTrackedMailer(
71
80
  deps: {
72
81
  provider: EmailProvider;
73
82
  prepareTrackedHtml?: PrepareTrackedHtmlFn;
83
+ /**
84
+ * Cached sending-domain status, injected by the container. Drives test-mode
85
+ * redirect: when `testModeCached().active`, every send is redirected to the
86
+ * safe inbox before reaching the provider. OPTIONAL — direct construction
87
+ * without it (tests) keeps today's behavior; the container always passes it.
88
+ */
89
+ domainStatus?: DomainStatusService;
74
90
  },
75
91
  ): EmailService {
76
- const { provider } = deps;
92
+ const { provider, domainStatus } = deps;
77
93
  const db = config.db as Database | undefined;
78
94
  const retryDefaults = config.retryOptions;
79
95
  const registry = config.templates;
96
+ const logger = config.logger ?? emitLogger;
80
97
 
81
98
  function resolveFrom(overrideFrom?: string): string {
82
99
  return overrideFrom ?? config.defaultFrom;
@@ -109,6 +126,11 @@ export function createTrackedMailer(
109
126
  ): Promise<TrackedSendResult> {
110
127
  const from = resolveFrom(options.from);
111
128
 
129
+ // Resolve test mode ONCE (cache-only, fires the fire-and-forget refresh).
130
+ // The DB path threads the resolved state into sendTrackedEmail so
131
+ // tracked.ts stays domainStatus-unaware; the no-db path applies it inline.
132
+ const testMode = resolveTestMode(domainStatus);
133
+
112
134
  if (db) {
113
135
  return sendTrackedEmail({
114
136
  db,
@@ -118,6 +140,7 @@ export function createTrackedMailer(
118
140
  prepareTrackedHtml: deps.prepareTrackedHtml,
119
141
  frequencyCap: config.frequencyCap,
120
142
  logger: config.logger,
143
+ testMode,
121
144
  options: {
122
145
  templateKey: options.template,
123
146
  props: options.props,
@@ -143,14 +166,47 @@ export function createTrackedMailer(
143
166
  props: options.props,
144
167
  registry,
145
168
  });
169
+ const subject = options.subject ?? defaultSubject;
146
170
  // HTML-ONLY wire — the engine ALWAYS renders React → HTML itself before
147
171
  // the provider. React Email stays first-class for authoring/Studio; it
148
172
  // never crosses the provider boundary.
149
173
  const html = await renderToHtml(element);
174
+
175
+ // Test-mode redirect on the no-db path. Hard-fail (no row to write here)
176
+ // by returning a skipped result rather than reaching the real recipient.
177
+ let wireTo: string | string[] = options.to;
178
+ let wireSubject = subject;
179
+ let wireFrom = from;
180
+ if (testMode) {
181
+ if (isUnaddressable(testMode)) {
182
+ logger.error(NO_REDIRECT_MESSAGE, { originalTo: options.to });
183
+ return trackedSendResult({
184
+ emailSendId: "",
185
+ messageId: "",
186
+ status: "skipped",
187
+ reason: "test_mode_blocked",
188
+ });
189
+ }
190
+ const r = buildRedirect({
191
+ from,
192
+ to: options.to,
193
+ subject,
194
+ state: testMode,
195
+ });
196
+ wireTo = r.to;
197
+ wireSubject = r.subject;
198
+ wireFrom = r.from;
199
+ logRedirect(logger, {
200
+ originalTo: r.originalTo,
201
+ redirectTo: testMode.redirectTo,
202
+ reason: testMode.reason,
203
+ });
204
+ }
205
+
150
206
  const result = await provider.send({
151
- from,
152
- to: options.to,
153
- subject: options.subject ?? defaultSubject,
207
+ from: wireFrom,
208
+ to: wireTo,
209
+ subject: wireSubject,
154
210
  html,
155
211
  tags: options.tags,
156
212
  headers: options.headers,
@@ -166,12 +222,82 @@ export function createTrackedMailer(
166
222
 
167
223
  async sendRaw(options: SendRawOptions): Promise<SendResult> {
168
224
  const gated = applyScheduledAtGate(options);
169
- return provider.send({ ...gated, from: resolveFrom(options.from) });
225
+ const from = resolveFrom(options.from);
226
+
227
+ const testMode = resolveTestMode(domainStatus);
228
+ if (testMode) {
229
+ // Raw sends have no email_sends row to record a skip against, so an
230
+ // unaddressable test mode THROWS loudly rather than silently delivering.
231
+ if (isUnaddressable(testMode)) {
232
+ logger.error(NO_REDIRECT_MESSAGE, { originalTo: gated.to });
233
+ throw new TestModeNoRedirectError();
234
+ }
235
+ const r = buildRedirect({
236
+ from,
237
+ to: gated.to,
238
+ cc: gated.cc,
239
+ bcc: gated.bcc,
240
+ subject: gated.subject,
241
+ state: testMode,
242
+ });
243
+ logRedirect(logger, {
244
+ originalTo: r.originalTo,
245
+ redirectTo: testMode.redirectTo,
246
+ reason: testMode.reason,
247
+ });
248
+ // Drop cc/bcc entirely — never leak the test mail to an original recipient.
249
+ const { cc: _cc, bcc: _bcc, ...rest } = gated;
250
+ return provider.send({
251
+ ...rest,
252
+ from: r.from,
253
+ to: r.to,
254
+ subject: r.subject,
255
+ });
256
+ }
257
+
258
+ return provider.send({ ...gated, from });
170
259
  },
171
260
 
172
261
  async sendBatch(options: {
173
262
  emails: BatchEmailItem[];
174
263
  }): Promise<{ results: SendResult[] }> {
264
+ const testMode = resolveTestMode(domainStatus);
265
+
266
+ if (testMode) {
267
+ // Unaddressable ⇒ throw before any item reaches the provider.
268
+ if (isUnaddressable(testMode)) {
269
+ logger.error(NO_REDIRECT_MESSAGE, {
270
+ count: options.emails.length,
271
+ });
272
+ throw new TestModeNoRedirectError();
273
+ }
274
+ // Each item gets its OWN [TEST → …] prefix; ONE structured WARN for the
275
+ // whole batch (never N log lines for a 1000-item batch).
276
+ const originalTos: string[] = [];
277
+ const emails = options.emails.map((e) => {
278
+ const from = resolveFrom(e.from);
279
+ const r = buildRedirect({
280
+ from,
281
+ to: e.to,
282
+ cc: e.cc,
283
+ bcc: e.bcc,
284
+ subject: e.subject,
285
+ state: testMode,
286
+ });
287
+ originalTos.push(r.originalTo);
288
+ const { cc: _cc, bcc: _bcc, ...rest } = e;
289
+ return { ...rest, from: r.from, to: r.to, subject: r.subject };
290
+ });
291
+ logger.warn("email.test_mode_redirect", {
292
+ event: "email.test_mode_redirect",
293
+ count: emails.length,
294
+ redirectTo: testMode.redirectTo,
295
+ reason: testMode.reason,
296
+ originalTo: originalTos,
297
+ });
298
+ return provider.sendBatch(emails);
299
+ }
300
+
175
301
  const emails = options.emails.map((e) => ({
176
302
  ...e,
177
303
  from: resolveFrom(e.from),