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