@hogsend/engine 0.11.0 → 0.12.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,14 +40,14 @@
40
40
  "svix": "^1.95.1",
41
41
  "winston": "^3.19.0",
42
42
  "zod": "^4.4.3",
43
- "@hogsend/core": "^0.11.0",
44
- "@hogsend/db": "^0.11.0",
45
- "@hogsend/email": "^0.11.0",
46
- "@hogsend/plugin-posthog": "^0.11.0",
47
- "@hogsend/plugin-resend": "^0.11.0"
43
+ "@hogsend/core": "^0.12.1",
44
+ "@hogsend/db": "^0.12.1",
45
+ "@hogsend/email": "^0.12.1",
46
+ "@hogsend/plugin-posthog": "^0.12.1",
47
+ "@hogsend/plugin-resend": "^0.12.1"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.11.0"
50
+ "@hogsend/plugin-postmark": "^0.12.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.3",
package/src/container.ts CHANGED
@@ -27,6 +27,10 @@ import type { DefinedJourney } from "./journeys/define-journey.js";
27
27
  import { buildJourneyRegistry } from "./journeys/registry.js";
28
28
  import { setAnalytics } from "./lib/analytics-singleton.js";
29
29
  import { type Auth, createAuth } from "./lib/auth.js";
30
+ import {
31
+ createDomainStatusService,
32
+ type DomainStatusService,
33
+ } from "./lib/domain-status.js";
30
34
  import { setEmailService } from "./lib/email.js";
31
35
  import { EmailProviderRegistry } from "./lib/email-provider-registry.js";
32
36
  import { emailProvidersFromEnv } from "./lib/email-providers-from-env.js";
@@ -73,6 +77,14 @@ export interface HogsendClient {
73
77
  * defaulting to the env-built Resend provider for byte-for-byte parity.
74
78
  */
75
79
  emailProvider: EmailProvider;
80
+ /**
81
+ * Cached sending-domain status for the ACTIVE provider. Consumed by the
82
+ * mailer's test-mode check (F3 — sync `testModeCached()` per send), the
83
+ * `GET/POST /v1/admin/domain` routes, and (via HTTP) the CLI
84
+ * (`hogsend domain`) + Studio Setup view. In-memory cache only; the per-send
85
+ * path never awaits a provider call.
86
+ */
87
+ domainStatus: DomainStatusService;
76
88
  /**
77
89
  * The app's template registry (key → component + subject + category +
78
90
  * optional preview/examples). Same object threaded into the engine mailer;
@@ -345,6 +357,16 @@ export function createHogsendClient(
345
357
  );
346
358
  }
347
359
 
360
+ // Cached sending-domain status for the active provider. Constructed right
361
+ // after provider resolution so it binds the SAME provider the mailer sends
362
+ // through. One non-blocking warm-up refresh primes the cache at boot —
363
+ // fire-and-forget, swallowed errors, must never block or fail boot. Skipped
364
+ // under NODE_ENV=test so test runs stay hermetic (no real provider HTTP).
365
+ const domainStatus = createDomainStatusService({ provider, env, logger });
366
+ if (env.NODE_ENV !== "test") {
367
+ domainStatus.refreshIfStale();
368
+ }
369
+
348
370
  const defaults: HogsendDefaults = {
349
371
  timezone: opts.defaults?.timezone ?? "UTC",
350
372
  sendWindow: opts.defaults?.sendWindow,
@@ -375,6 +397,11 @@ export function createHogsendClient(
375
397
  {
376
398
  provider,
377
399
  prepareTrackedHtml,
400
+ // Test-mode redirect: the mailer reads `domainStatus.testModeCached()`
401
+ // (sync, cache-only) per send to decide whether to redirect to the safe
402
+ // inbox. Constructed above (right after provider resolution) so it binds
403
+ // the SAME active provider the mailer sends through.
404
+ domainStatus,
378
405
  },
379
406
  );
380
407
 
@@ -498,6 +525,7 @@ export function createHogsendClient(
498
525
  emailService,
499
526
  emailProviders,
500
527
  emailProvider: provider,
528
+ domainStatus,
501
529
  templates,
502
530
  analytics,
503
531
  registry,
package/src/env.ts CHANGED
@@ -53,6 +53,29 @@ export const env = createEnv({
53
53
  // `EMAIL_FROM ?? RESEND_FROM_EMAIL`, so an unset EMAIL_FROM keeps today's
54
54
  // Resend-named default.
55
55
  EMAIL_FROM: z.string().email().optional(),
56
+ // The sending domain the domain-status service reports on. OVERRIDES the
57
+ // default derivation (host part of EMAIL_FROM, falling back to the host of
58
+ // RESEND_FROM_EMAIL) — set it when you send from a subaddress domain that
59
+ // differs from the one registered at the provider.
60
+ EMAIL_DOMAIN: z.string().optional(),
61
+ // --- Test mode (provider-neutral send redirect) ---
62
+ // Controls whether the engine redirects every send to a safe inbox while the
63
+ // sending domain isn't verified yet:
64
+ // auto (default) — test mode iff the active provider supports domains AND
65
+ // an EMAIL_DOMAIN is configured AND it is UNVERIFIED per
66
+ // the cached DomainStatusService. Fail-OPEN: a cache miss
67
+ // or provider outage resolves to LIVE (never silently
68
+ // redirects prod mail). With no domains capability or no
69
+ // EMAIL_DOMAIN, `auto` stays LIVE — existing deploys are
70
+ // unaffected.
71
+ // true — always redirect (reason: "env_flag").
72
+ // false — never redirect, even with an unverified domain.
73
+ HOGSEND_TEST_MODE: z.enum(["auto", "true", "false"]).default("auto"),
74
+ // The safe inbox every redirected send is delivered to in test mode. Falls
75
+ // back to STUDIO_ADMIN_EMAIL when unset; when NEITHER resolves while test
76
+ // mode is active, the send is BLOCKED (recorded, never delivered to the real
77
+ // recipient) with a loud, actionable log.
78
+ HOGSEND_TEST_EMAIL: z.string().email().optional(),
56
79
  // --- Postmark (opt-in BYO provider) ---
57
80
  // Postmark stays OPT-IN: a preset is built only when POSTMARK_SERVER_TOKEN
58
81
  // is present, and it NEVER changes the default active provider — set
@@ -60,6 +83,11 @@ export const env = createEnv({
60
83
  // authenticity is HTTP Basic creds in the webhook URL — fail-closed when
61
84
  // unset (status updates rejected).
62
85
  POSTMARK_SERVER_TOKEN: z.string().min(1).optional(),
86
+ // Postmark ACCOUNT token (X-Postmark-Account-Token) — unlocks the Domains
87
+ // API capability on the Postmark provider. Optional: without it the
88
+ // provider still sends, it just can't manage sending domains
89
+ // (`supported: false` on /v1/admin/domain).
90
+ POSTMARK_ACCOUNT_TOKEN: z.string().min(1).optional(),
63
91
  POSTMARK_MESSAGE_STREAM: z.string().min(1).optional(),
64
92
  POSTMARK_WEBHOOK_USER: z.string().min(1).optional(),
65
93
  POSTMARK_WEBHOOK_PASS: z.string().min(1).optional(),
package/src/index.ts CHANGED
@@ -3,9 +3,18 @@
3
3
  // Content (journeys, webhook sources, workflows) is injected into these
4
4
  // factories by client app code; the engine never imports content.
5
5
 
6
+ // Sending-domain capability contract (presence of `EmailProvider.domains` is
7
+ // the gate). Already covered by the `export * from "@hogsend/core"` above —
8
+ // re-named here for discoverability.
6
9
  export type {
7
10
  BatchEmailItem,
8
11
  CaptureOptions,
12
+ DnsRecord,
13
+ DnsRecordPurpose,
14
+ DnsRecordStatus,
15
+ DomainStatus,
16
+ DomainsCapability,
17
+ DomainVerificationState,
9
18
  EmailEvent,
10
19
  EmailEventType,
11
20
  EmailProvider,
@@ -161,6 +170,13 @@ export {
161
170
  } from "./lib/create-admin.js";
162
171
  // --- Infrastructure singletons ---
163
172
  export { getDb } from "./lib/db.js";
173
+ // --- Sending-domain status service (cached; container-held) ---
174
+ export {
175
+ createDomainStatusService,
176
+ type DomainStatusService,
177
+ type EngineDomainStatus,
178
+ type TestModeState,
179
+ } from "./lib/domain-status.js";
164
180
  // --- Email ---
165
181
  export {
166
182
  type SendEmailOptions,
package/src/lib/boot.ts CHANGED
@@ -81,6 +81,12 @@ export function reportApiReady(info: ApiReadyInfo): void {
81
81
  const templates = Object.keys(client.templates).length;
82
82
  const localUrl = `http://localhost:${port}`;
83
83
 
84
+ // Cache-only, sync, never throws — so reading it here adds no boot latency.
85
+ // Loud when env-flag-forced (resolves even with a cold cache); the
86
+ // domain-unverified auto banner is additionally fired as a transition WARN by
87
+ // the warm-up refresh in the container.
88
+ const testMode = client.domainStatus.testModeCached();
89
+
84
90
  if (!bannerMode(client)) {
85
91
  client.logger.info("Hogsend API ready", {
86
92
  engineVersion,
@@ -91,6 +97,14 @@ export function reportApiReady(info: ApiReadyInfo): void {
91
97
  buckets,
92
98
  templates,
93
99
  schema: info.schemaVersion ?? undefined,
100
+ ...(testMode.active
101
+ ? {
102
+ testMode: {
103
+ redirectTo: testMode.redirectTo,
104
+ reason: testMode.reason,
105
+ },
106
+ }
107
+ : {}),
94
108
  });
95
109
  return;
96
110
  }
@@ -104,6 +118,13 @@ export function reportApiReady(info: ApiReadyInfo): void {
104
118
  ].join(dim(" · "));
105
119
  const label = (text: string) => dim(text.padEnd(7));
106
120
 
121
+ const testModeLine = testMode.active
122
+ ? ` ${color.bgYellow(color.black(" TEST MODE "))} ${color.yellow(
123
+ `all sends → ${testMode.redirectTo ?? "(no redirect address — sends will fail!)"} ` +
124
+ dim(`(${testMode.reason ?? "unknown"})`),
125
+ )}`
126
+ : null;
127
+
107
128
  writeBanner([
108
129
  `${BADGE} ${dim(`engine ${engineVersion} · api ${API_VERSION}`)}`,
109
130
  "",
@@ -111,6 +132,7 @@ export function reportApiReady(info: ApiReadyInfo): void {
111
132
  info.schemaVersion
112
133
  ? ` ${ok} schema in sync ${dim(`(${info.schemaVersion})`)}`
113
134
  : null,
135
+ testModeLine,
114
136
  "",
115
137
  ` ${label("API")}${color.cyan(localUrl)}`,
116
138
  ` ${label("Docs")}${color.cyan(`${localUrl}/docs`)}`,
@@ -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),
@@ -0,0 +1,123 @@
1
+ import { normalizeRecipients } from "@hogsend/core";
2
+ import type { TestModeState } from "./domain-status.js";
3
+ import type { Logger } from "./logger.js";
4
+
5
+ /**
6
+ * Test-mode redirect helpers — the provider-neutral safety net that protects an
7
+ * operator from sending real mail before their DNS verifies. The active
8
+ * {@link TestModeState} is resolved ONCE per send by the mailer (cache-only,
9
+ * zero provider latency) and threaded here; these helpers are pure transforms.
10
+ *
11
+ * Why the redirect lives in the engine (not the provider): any `EmailProvider`
12
+ * is a dumb HTML wire; test mode must protect Postmark/SES users identically.
13
+ * Only `fromOverride` is provider-aware (Resend's `onboarding@resend.dev`), and
14
+ * that knowledge sits in the domain-status resolver, keyed on `providerId`.
15
+ */
16
+
17
+ /** The structured WARN event name fired per redirected send. */
18
+ export const TEST_MODE_REDIRECT_EVENT = "email.test_mode_redirect";
19
+
20
+ /** The actionable error message when test mode is active but unaddressable. */
21
+ export const NO_REDIRECT_MESSAGE =
22
+ "test mode active but no redirect address — set HOGSEND_TEST_EMAIL " +
23
+ "(or STUDIO_ADMIN_EMAIL); the send was BLOCKED, not delivered to the real recipient";
24
+
25
+ /**
26
+ * Thrown by the no-db raw send paths (`sendRaw`/`sendBatch`) when test mode is
27
+ * active but no redirect address resolves. The tracked (DB) path does NOT throw
28
+ * — it records a `failed` row and returns a skipped result, mirroring the
29
+ * suppression branch — but the raw paths have no row to write, so they fail
30
+ * loudly rather than silently delivering to the real recipient.
31
+ */
32
+ export class TestModeNoRedirectError extends Error {
33
+ constructor() {
34
+ super(NO_REDIRECT_MESSAGE);
35
+ this.name = "TestModeNoRedirectError";
36
+ }
37
+ }
38
+
39
+ /** The redirected wire fields for a single message. */
40
+ export interface RedirectedFields {
41
+ /** Always the single redirect inbox (cc/bcc are dropped). */
42
+ to: string[];
43
+ /** The single redirect address (`to[0]`), for the `email_sends.toEmail` column. */
44
+ redirectTo: string;
45
+ /** Prefixed `[TEST → <originalRecipients>] <subject>`. */
46
+ subject: string;
47
+ /** `fromOverride ?? originalFrom` (Resend ⇒ onboarding@resend.dev). */
48
+ from: string;
49
+ /** Comma-joined ORIGINAL recipients, for the WARN log + email_sends marker. */
50
+ originalTo: string;
51
+ /** The flattened original recipient list (to + cc + bcc). */
52
+ originalRecipients: string[];
53
+ }
54
+
55
+ /**
56
+ * Resolve the active {@link TestModeState} for a send. Returns the cached state
57
+ * when `active`, else `null` (live send). ALWAYS fires the fire-and-forget
58
+ * `refreshIfStale()` first — the ONLY cache-refresh trigger on the send path,
59
+ * never awaited. `domainStatus` is optional so direct mailer construction
60
+ * (tests) without it keeps today's behavior.
61
+ */
62
+ export function resolveTestMode(domainStatus?: {
63
+ testModeCached(): TestModeState;
64
+ refreshIfStale(): void;
65
+ }): TestModeState | null {
66
+ if (!domainStatus) return null;
67
+ domainStatus.refreshIfStale();
68
+ const state = domainStatus.testModeCached();
69
+ return state.active ? state : null;
70
+ }
71
+
72
+ /**
73
+ * Build the redirected wire fields for one message under an active test mode.
74
+ * `to`/`cc`/`bcc` flatten into the subject prefix; the wire `to` becomes the
75
+ * single redirect inbox and cc/bcc are dropped entirely (never leak the test
76
+ * mail to an original cc/bcc recipient). Caller MUST have checked
77
+ * `state.redirectTo !== null` first (use {@link isUnaddressable}).
78
+ */
79
+ export function buildRedirect(opts: {
80
+ from: string;
81
+ to: string | string[];
82
+ cc?: string | string[];
83
+ bcc?: string | string[];
84
+ subject: string;
85
+ state: TestModeState;
86
+ }): RedirectedFields {
87
+ const originalRecipients = [
88
+ ...normalizeRecipients(opts.to),
89
+ ...normalizeRecipients(opts.cc),
90
+ ...normalizeRecipients(opts.bcc),
91
+ ];
92
+ const originalTo = originalRecipients.join(",");
93
+ // redirectTo is non-null here — the caller gates on isUnaddressable first.
94
+ const redirectTo = opts.state.redirectTo as string;
95
+ return {
96
+ to: [redirectTo],
97
+ redirectTo,
98
+ subject: `[TEST → ${originalTo}] ${opts.subject}`,
99
+ from: opts.state.fromOverride ?? opts.from,
100
+ originalTo,
101
+ originalRecipients,
102
+ };
103
+ }
104
+
105
+ /** True when test mode is active but no redirect address resolves. */
106
+ export function isUnaddressable(state: TestModeState): boolean {
107
+ return state.active && state.redirectTo === null;
108
+ }
109
+
110
+ /** Fire the per-send structured WARN for a redirected send. */
111
+ export function logRedirect(
112
+ logger: Logger | undefined,
113
+ meta: {
114
+ originalTo: string;
115
+ redirectTo: string | null;
116
+ reason: string | null;
117
+ },
118
+ ): void {
119
+ logger?.warn(TEST_MODE_REDIRECT_EVENT, {
120
+ event: TEST_MODE_REDIRECT_EVENT,
121
+ ...meta,
122
+ });
123
+ }
@@ -14,6 +14,7 @@ import {
14
14
  } from "@hogsend/email";
15
15
  import { eq } from "drizzle-orm";
16
16
  import { getListRegistry } from "../lists/registry-singleton.js";
17
+ import type { TestModeState } from "./domain-status.js";
17
18
  import {
18
19
  type FrequencyCapConfig,
19
20
  type SendTrackedEmailOptions,
@@ -24,6 +25,12 @@ import { isFrequencyCapped } from "./frequency-cap.js";
24
25
  import { hatchet } from "./hatchet.js";
25
26
  import { createLogger, type Logger } from "./logger.js";
26
27
  import { emitOutbound } from "./outbound.js";
28
+ import {
29
+ buildRedirect,
30
+ isUnaddressable,
31
+ logRedirect,
32
+ NO_REDIRECT_MESSAGE,
33
+ } from "./test-mode.js";
27
34
 
28
35
  // Module-level fallback logger for the outbound emit — the tracked-mailer's
29
36
  // `logger` dep is optional, but `emitOutbound` requires one. Mirrors the
@@ -49,6 +56,13 @@ interface TrackedEmailDeps {
49
56
  frequencyCap?: FrequencyCapConfig;
50
57
  /** Optional structured logger for operational events (e.g. cap skips). */
51
58
  logger?: Logger;
59
+ /**
60
+ * The active test-mode state, resolved ONCE by the mailer (cache-only) and
61
+ * threaded in so this module stays domainStatus-unaware. `null` ⇒ live send.
62
+ * When active, suppression/frequency/unsubscribe still key to the ORIGINAL
63
+ * recipient (`options.to`); only the wire + `email_sends` row are redirected.
64
+ */
65
+ testMode?: TestModeState | null;
52
66
  }
53
67
 
54
68
  export async function sendTrackedEmail<K extends TemplateName>(
@@ -61,9 +75,19 @@ export async function sendTrackedEmail<K extends TemplateName>(
61
75
  prepareTrackedHtml,
62
76
  frequencyCap,
63
77
  logger,
78
+ testMode,
64
79
  options,
65
80
  } = opts;
66
81
 
82
+ // Test-mode redirect (resolved by the mailer; null ⇒ live). When active, the
83
+ // wire `to`/`from`/`subject` + the email_sends row are redirected, but EVERY
84
+ // preference statement below (suppression, frequency cap, unsubscribe token)
85
+ // still keys to the ORIGINAL recipient `options.to` — preferences belong to
86
+ // the real user, never the shared test inbox. Resolved lazily after the
87
+ // suppression/frequency-cap branch so the original recipient's preferences are
88
+ // honored first.
89
+ const redirectActive = Boolean(testMode?.active);
90
+
67
91
  // The idempotency-collision result, built identically whether the prior row is
68
92
  // found by the up-front short-circuit select OR the concurrent-insert loser
69
93
  // path below: surface the winner's send id, mapping "sent" → sent and anything
@@ -220,17 +244,80 @@ export async function sendTrackedEmail<K extends TemplateName>(
220
244
  }).element
221
245
  : element;
222
246
 
247
+ // Test-mode redirect — resolved AFTER the original-recipient preference checks
248
+ // above (suppression/frequency/unsubscribe), so preferences belong to the real
249
+ // user. Hard-fail branch: active but unaddressable ⇒ write a `failed` row with
250
+ // the metadata marker (so Studio surfaces the blocked send) and return a
251
+ // skipped result. The provider is NEVER reached — the real recipient must not
252
+ // receive mail from an unverified domain just because no test inbox is set.
253
+ if (redirectActive && testMode && isUnaddressable(testMode)) {
254
+ (logger ?? emitLogger).error(NO_REDIRECT_MESSAGE, {
255
+ originalTo: options.to,
256
+ templateKey: options.templateKey,
257
+ });
258
+ const rows = await db
259
+ .insert(emailSends)
260
+ .values({
261
+ templateKey: options.templateKey,
262
+ fromEmail: options.from,
263
+ toEmail: options.to,
264
+ subject: options.subject ?? "",
265
+ category: effectiveCategory,
266
+ journeyStateId: options.journeyStateId,
267
+ userId: options.userId,
268
+ userEmail: options.userEmail ?? options.to,
269
+ status: "failed",
270
+ metadata: { testMode: true, originalTo: options.to },
271
+ })
272
+ .returning({ id: emailSends.id });
273
+ const blockedRow = rows[0];
274
+ if (!blockedRow) throw new Error("Failed to insert email_sends row");
275
+ return trackedSendResult({
276
+ emailSendId: blockedRow.id,
277
+ messageId: "",
278
+ status: "skipped",
279
+ reason: "test_mode_blocked",
280
+ });
281
+ }
282
+
283
+ // Active + addressable: compute the redirected wire fields ONCE. `email_sends`
284
+ // records what ACTUALLY went out the wire (redirect inbox, prefixed subject,
285
+ // effective from) plus the `metadata.originalTo` marker, while preferences
286
+ // above stayed keyed to `options.to`.
287
+ const redirect =
288
+ redirectActive && testMode
289
+ ? buildRedirect({
290
+ from: options.from,
291
+ to: options.to,
292
+ subject,
293
+ state: testMode,
294
+ })
295
+ : null;
296
+ if (redirect && testMode) {
297
+ logRedirect(logger ?? emitLogger, {
298
+ originalTo: redirect.originalTo,
299
+ redirectTo: testMode.redirectTo,
300
+ reason: testMode.reason,
301
+ });
302
+ }
303
+ const wireTo = redirect ? redirect.to : options.to;
304
+ const wireSubject = redirect ? redirect.subject : subject;
305
+ const wireFrom = redirect ? redirect.from : options.from;
306
+
223
307
  const baseInsert = db.insert(emailSends).values({
224
308
  templateKey: options.templateKey,
225
- fromEmail: options.from,
226
- toEmail: options.to,
227
- subject,
309
+ fromEmail: wireFrom,
310
+ toEmail: redirect ? redirect.redirectTo : options.to,
311
+ subject: wireSubject,
228
312
  category: effectiveCategory,
229
313
  journeyStateId: options.journeyStateId,
230
314
  userId: options.userId,
231
315
  userEmail: options.userEmail ?? options.to,
232
316
  status: "queued",
233
317
  idempotencyKey: options.idempotencyKey,
318
+ ...(redirect
319
+ ? { metadata: { testMode: true, originalTo: options.to } }
320
+ : {}),
234
321
  });
235
322
 
236
323
  // With an idempotency key, swallow a concurrent-insert collision on the unique
@@ -274,9 +361,9 @@ export async function sendTrackedEmail<K extends TemplateName>(
274
361
  : rawHtml;
275
362
 
276
363
  const result = await provider.send({
277
- from: options.from,
278
- to: options.to,
279
- subject,
364
+ from: wireFrom,
365
+ to: wireTo,
366
+ subject: wireSubject,
280
367
  html,
281
368
  tags: options.tags,
282
369
  headers: sendHeaders,
@@ -0,0 +1,222 @@
1
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
+ import type { AppEnv } from "../../app.js";
3
+ import { errorSchema } from "../../lib/schemas.js";
4
+
5
+ /**
6
+ * Admin sending-domain routes. The provider's optional `domains` capability is
7
+ * the gate: when the active provider has none, the POSTs return 501
8
+ * `{ error: "provider_unsupported" }` and the GET reports `supported: false`.
9
+ * Provider API keys never leave the server — the CLI (`hogsend domain`) and
10
+ * Studio's Setup view only ever talk to these routes.
11
+ */
12
+
13
+ // Mirrors the pinned `DnsRecord` shape (@hogsend/core providers/domains.ts).
14
+ const DnsRecordSchema = z.object({
15
+ type: z.enum(["TXT", "CNAME", "MX"]),
16
+ name: z.string(),
17
+ value: z.string(),
18
+ ttl: z.number().optional(),
19
+ priority: z.number().optional(),
20
+ purpose: z.enum([
21
+ "verification",
22
+ "spf",
23
+ "dkim",
24
+ "return_path",
25
+ "tracking",
26
+ "mx",
27
+ "other",
28
+ ]),
29
+ status: z.enum(["pending", "verified", "failed", "unknown"]),
30
+ });
31
+
32
+ // Mirrors the pinned `DomainStatus` shape.
33
+ const DomainStatusSchema = z.object({
34
+ domain: z.string(),
35
+ state: z.enum(["not_found", "pending", "verified", "failed"]),
36
+ records: z.array(DnsRecordSchema),
37
+ providerId: z.string(),
38
+ checkedAt: z.string(),
39
+ raw: z.unknown().optional(),
40
+ });
41
+
42
+ // Mirrors the pinned `TestModeState` shape (stubbed inactive until F3).
43
+ const TestModeStateSchema = z.object({
44
+ active: z.boolean(),
45
+ reason: z.enum(["env_flag", "domain_unverified"]).nullable(),
46
+ redirectTo: z.string().nullable(),
47
+ fromOverride: z.string().nullable(),
48
+ });
49
+
50
+ // Mirrors the pinned `EngineDomainStatus` shape.
51
+ const EngineDomainStatusSchema = z.object({
52
+ domain: z.string().nullable(),
53
+ providerId: z.string(),
54
+ supported: z.boolean(),
55
+ status: DomainStatusSchema.nullable(),
56
+ testMode: TestModeStateSchema,
57
+ });
58
+
59
+ /** Pinned domain validation regex (PROJECT_SPEC §e). */
60
+ const DOMAIN_RE = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
61
+
62
+ /**
63
+ * Provider domains calls can fail for configuration reasons the operator must
64
+ * act on (e.g. a send-only restricted Resend key cannot read the domains API).
65
+ * Surface those as 502 with the provider's message — `hogsend domain` and
66
+ * Studio render this string directly — instead of an opaque 500.
67
+ */
68
+ const providerErrorBody = (providerId: string, err: unknown) => ({
69
+ error: `domains request to provider "${providerId}" failed: ${
70
+ err instanceof Error ? err.message : String(err)
71
+ }`,
72
+ });
73
+
74
+ const providerErrorResponse = {
75
+ content: { "application/json": { schema: errorSchema } },
76
+ description: "The provider rejected or failed the domains request",
77
+ } as const;
78
+
79
+ const getDomainRoute = createRoute({
80
+ method: "get",
81
+ path: "/",
82
+ tags: ["Admin — Domain"],
83
+ summary: "Sending-domain status (records, verification state, test mode)",
84
+ request: {
85
+ query: z.object({
86
+ refresh: z.coerce.boolean().optional(),
87
+ }),
88
+ },
89
+ responses: {
90
+ 200: {
91
+ content: {
92
+ "application/json": { schema: EngineDomainStatusSchema },
93
+ },
94
+ description:
95
+ "Cached domain status for the active email provider; ?refresh=true forces a provider round-trip",
96
+ },
97
+ 502: providerErrorResponse,
98
+ },
99
+ });
100
+
101
+ const addDomainRoute = createRoute({
102
+ method: "post",
103
+ path: "/",
104
+ tags: ["Admin — Domain"],
105
+ summary: "Register the sending domain with the active email provider",
106
+ request: {
107
+ body: {
108
+ content: {
109
+ "application/json": {
110
+ schema: z.object({
111
+ domain: z.string().regex(DOMAIN_RE, "invalid domain"),
112
+ }),
113
+ },
114
+ },
115
+ },
116
+ },
117
+ responses: {
118
+ 200: {
119
+ content: {
120
+ "application/json": { schema: EngineDomainStatusSchema },
121
+ },
122
+ description: "Domain registered (idempotent) — fresh status",
123
+ },
124
+ 501: {
125
+ content: { "application/json": { schema: errorSchema } },
126
+ description: "The active provider has no domains capability",
127
+ },
128
+ 502: providerErrorResponse,
129
+ },
130
+ });
131
+
132
+ const verifyDomainRoute = createRoute({
133
+ method: "post",
134
+ path: "/verify",
135
+ tags: ["Admin — Domain"],
136
+ summary: "Trigger a provider-side verification pass for the sending domain",
137
+ responses: {
138
+ 200: {
139
+ content: {
140
+ "application/json": { schema: EngineDomainStatusSchema },
141
+ },
142
+ description: "Verification pass triggered — fresh status",
143
+ },
144
+ 400: {
145
+ content: { "application/json": { schema: errorSchema } },
146
+ description: "No sending domain configured (EMAIL_DOMAIN / EMAIL_FROM)",
147
+ },
148
+ 501: {
149
+ content: { "application/json": { schema: errorSchema } },
150
+ description: "The active provider has no domains capability",
151
+ },
152
+ 502: providerErrorResponse,
153
+ },
154
+ });
155
+
156
+ export const domainRouter = new OpenAPIHono<AppEnv>()
157
+ .openapi(getDomainRoute, async (c) => {
158
+ const { domainStatus, emailProvider } = c.get("container");
159
+ const { refresh } = c.req.valid("query");
160
+ try {
161
+ const status = await domainStatus.getStatus({ refresh });
162
+ return c.json(status, 200);
163
+ } catch (err) {
164
+ return c.json(
165
+ providerErrorBody(emailProvider.meta?.id ?? "email", err),
166
+ 502,
167
+ );
168
+ }
169
+ })
170
+ .openapi(addDomainRoute, async (c) => {
171
+ const { domainStatus, emailProvider } = c.get("container");
172
+ const { domain } = c.req.valid("json");
173
+
174
+ if (!emailProvider.domains) {
175
+ return c.json({ error: "provider_unsupported" }, 501);
176
+ }
177
+
178
+ try {
179
+ // Idempotent at the provider (an existing domain falls through to lookup).
180
+ await emailProvider.domains.create(domain);
181
+
182
+ // Bust + refresh the cached snapshot so the response reflects the create.
183
+ const status = await domainStatus.getStatus({ refresh: true });
184
+ return c.json(status, 200);
185
+ } catch (err) {
186
+ return c.json(
187
+ providerErrorBody(emailProvider.meta?.id ?? "email", err),
188
+ 502,
189
+ );
190
+ }
191
+ })
192
+ .openapi(verifyDomainRoute, async (c) => {
193
+ const { domainStatus, emailProvider } = c.get("container");
194
+
195
+ if (!emailProvider.domains) {
196
+ return c.json({ error: "provider_unsupported" }, 501);
197
+ }
198
+
199
+ try {
200
+ const current = await domainStatus.getStatus();
201
+ if (!current.domain) {
202
+ return c.json({ error: "no_domain_configured" }, 400);
203
+ }
204
+
205
+ // Prefer the provider's explicit verification pass; fall back to a plain
206
+ // status fetch for providers without one.
207
+ const capability = emailProvider.domains;
208
+ if (capability.verify) {
209
+ await capability.verify(current.domain);
210
+ } else {
211
+ await capability.get(current.domain);
212
+ }
213
+
214
+ const status = await domainStatus.getStatus({ refresh: true });
215
+ return c.json(status, 200);
216
+ } catch (err) {
217
+ return c.json(
218
+ providerErrorBody(emailProvider.meta?.id ?? "email", err),
219
+ 502,
220
+ );
221
+ }
222
+ });
@@ -10,6 +10,7 @@ import { bucketsRouter } from "./buckets.js";
10
10
  import { bulkRouter } from "./bulk.js";
11
11
  import { contactsRouter } from "./contacts.js";
12
12
  import { dlqRouter } from "./dlq.js";
13
+ import { domainRouter } from "./domain.js";
13
14
  import { emailsRouter } from "./emails.js";
14
15
  import { eventsRouter } from "./events.js";
15
16
  import { journeyLogsRouter } from "./journey-logs.js";
@@ -44,3 +45,4 @@ adminRouter.route("/webhooks", webhooksRouter);
44
45
  adminRouter.route("/audit-logs", auditLogsRouter);
45
46
  adminRouter.route("/alerts", alertsRouter);
46
47
  adminRouter.route("/dlq", dlqRouter);
48
+ adminRouter.route("/domain", domainRouter);