@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 +7 -7
- package/src/container.ts +28 -0
- package/src/env.ts +28 -0
- package/src/index.ts +16 -0
- package/src/lib/boot.ts +22 -0
- package/src/lib/domain-status.ts +327 -0
- package/src/lib/email-providers-from-env.ts +5 -0
- package/src/lib/email-service-types.ts +8 -2
- package/src/lib/mailer.ts +131 -5
- package/src/lib/test-mode.ts +123 -0
- package/src/lib/tracked.ts +93 -6
- package/src/routes/admin/domain.ts +222 -0
- package/src/routes/admin/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "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.
|
|
44
|
-
"@hogsend/db": "^0.
|
|
45
|
-
"@hogsend/email": "^0.
|
|
46
|
-
"@hogsend/plugin-posthog": "^0.
|
|
47
|
-
"@hogsend/plugin-resend": "^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.
|
|
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
|
-
/**
|
|
77
|
-
|
|
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:
|
|
153
|
-
subject:
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/tracked.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
278
|
-
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);
|