@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
package/src/lib/redis.ts
CHANGED
|
@@ -28,3 +28,71 @@ export function getRedis(): Redis {
|
|
|
28
28
|
export function getRedisIfConnected(): Redis | undefined {
|
|
29
29
|
return _redis;
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Namespace for better-auth's secondary-storage keys so they never collide with
|
|
34
|
+
* the PostHog person-property cache or the worker heartbeat sharing this Redis.
|
|
35
|
+
*/
|
|
36
|
+
const AUTH_STORAGE_PREFIX = "hogsend:auth:";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The minimal shape better-auth's `secondaryStorage` option expects. Mirrors
|
|
40
|
+
* `@better-auth/core`'s `SecondaryStorage` so we don't pull a type out of a deep
|
|
41
|
+
* subpath: `get` returns the raw stored string (better-auth `JSON.parse`s it),
|
|
42
|
+
* `set` takes an optional TTL in SECONDS, `delete` removes the key.
|
|
43
|
+
*/
|
|
44
|
+
export interface AuthSecondaryStorage {
|
|
45
|
+
get: (key: string) => Promise<string | null>;
|
|
46
|
+
set: (key: string, value: string, ttl?: number) => Promise<void>;
|
|
47
|
+
delete: (key: string) => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Adapt an ioredis client to better-auth's `secondaryStorage` contract so all
|
|
52
|
+
* better-auth session AND rate-limit counters live in Redis — shared across
|
|
53
|
+
* Railway replicas and surviving restarts. Without this, better-auth defaults
|
|
54
|
+
* `rateLimit.storage` to in-memory (per-instance, reset on redeploy), so the
|
|
55
|
+
* sign-in / request-password-reset limits are materially weaker than they look
|
|
56
|
+
* on a multi-replica deploy (security finding #2).
|
|
57
|
+
*
|
|
58
|
+
* Reuses the SHARED engine Redis singleton ({@link getRedis}) — it never opens a
|
|
59
|
+
* second pool. better-auth gives `set` a TTL in SECONDS, which we honour with
|
|
60
|
+
* `EX`; entries with no TTL persist (matching better-auth's own behaviour).
|
|
61
|
+
*
|
|
62
|
+
* Every operation is wrapped so a Redis blip degrades gracefully instead of
|
|
63
|
+
* crashing the auth flow: `get` returns `null` (better-auth treats it as a
|
|
64
|
+
* miss), `set`/`delete` no-op. We never want a transient cache fault to take
|
|
65
|
+
* down sign-in.
|
|
66
|
+
*/
|
|
67
|
+
export function createRedisSecondaryStorage(
|
|
68
|
+
redis: Redis,
|
|
69
|
+
): AuthSecondaryStorage {
|
|
70
|
+
const k = (key: string) => `${AUTH_STORAGE_PREFIX}${key}`;
|
|
71
|
+
return {
|
|
72
|
+
async get(key) {
|
|
73
|
+
try {
|
|
74
|
+
return await redis.get(k(key));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async set(key, value, ttl) {
|
|
80
|
+
try {
|
|
81
|
+
if (typeof ttl === "number" && ttl > 0) {
|
|
82
|
+
await redis.set(k(key), value, "EX", ttl);
|
|
83
|
+
} else {
|
|
84
|
+
await redis.set(k(key), value);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// Degrade to no-op — never fail the auth flow on a cache write.
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
async delete(key) {
|
|
91
|
+
try {
|
|
92
|
+
await redis.del(k(key));
|
|
93
|
+
} catch {
|
|
94
|
+
// Degrade to no-op.
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { getEmailService } from "./email.js";
|
|
2
|
+
import type { EmailService } from "./email-service-types.js";
|
|
3
|
+
import { createLogger, type Logger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
// Fallback logger for the no-provider warning — callers may not pass one. Mirrors
|
|
6
|
+
// the engine-lib singleton pattern (mailer, define-journey, tracked).
|
|
7
|
+
const fallbackLogger = createLogger(process.env.LOG_LEVEL);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The TTL the reset link advertises in its copy. Mirrors the
|
|
11
|
+
* `resetPasswordTokenExpiresIn` we configure in `createAuth` (15 minutes) so the
|
|
12
|
+
* email never claims a window that doesn't match the server's enforcement.
|
|
13
|
+
*/
|
|
14
|
+
const RESET_TTL_MINUTES = 15;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The engine-owned, self-contained password-reset email. The engine ships NO
|
|
18
|
+
* business templates (those live in the consumer's `src/emails/`), so a reset
|
|
19
|
+
* email that required a consumer template would break the "works out of the box"
|
|
20
|
+
* guarantee. This builds a tiny inline HTML + plaintext body — no React Email
|
|
21
|
+
* dependency, no template-registry lookup — and sends it through the resolved
|
|
22
|
+
* provider via the mailer's RAW path.
|
|
23
|
+
*
|
|
24
|
+
* `sendRaw` is correct here (not `send`): a password reset is strictly
|
|
25
|
+
* transactional and must bypass template resolution AND the
|
|
26
|
+
* preference/suppression check — a recovering operator must always receive it,
|
|
27
|
+
* marketing opt-out or not. There is no tracking pixel and no unsubscribe footer
|
|
28
|
+
* for the same reason.
|
|
29
|
+
*
|
|
30
|
+
* Security:
|
|
31
|
+
* - NEVER logs the `url` or the token. On a delivery failure we log a generic
|
|
32
|
+
* warning (pointing the operator at the CLI) and RESOLVE without throwing, so
|
|
33
|
+
* better-auth's neutral "if this email exists…" response is preserved (no user
|
|
34
|
+
* enumeration, no leak of whether the address was real).
|
|
35
|
+
* - The `from` resolves from `EMAIL_FROM ?? RESEND_FROM_EMAIL` — but we don't
|
|
36
|
+
* pass it explicitly: the mailer's `sendRaw` defaults `from` to its configured
|
|
37
|
+
* `defaultFrom`, which is exactly that pair.
|
|
38
|
+
*/
|
|
39
|
+
export async function sendResetPasswordEmail(opts: {
|
|
40
|
+
to: string;
|
|
41
|
+
url: string;
|
|
42
|
+
/**
|
|
43
|
+
* The mailer to send through. Optional: defaults to the container-installed
|
|
44
|
+
* singleton (`getEmailService()`). Injectable so tests can pass a spy and
|
|
45
|
+
* assert the send fires without touching a real provider.
|
|
46
|
+
*/
|
|
47
|
+
emailService?: EmailService;
|
|
48
|
+
/** Optional structured logger; defaults to a stdout logger. */
|
|
49
|
+
logger?: Logger;
|
|
50
|
+
}): Promise<void> {
|
|
51
|
+
const { to, url } = opts;
|
|
52
|
+
const log = opts.logger ?? fallbackLogger;
|
|
53
|
+
|
|
54
|
+
let service: EmailService;
|
|
55
|
+
try {
|
|
56
|
+
service = opts.emailService ?? getEmailService();
|
|
57
|
+
} catch {
|
|
58
|
+
// The mailer singleton hasn't been installed (container never booted). Steer
|
|
59
|
+
// the operator to the guaranteed recovery path; never throw (preserves the
|
|
60
|
+
// neutral response). Do NOT log the url/token.
|
|
61
|
+
log.warn(
|
|
62
|
+
"password reset requested but no email service is configured — use `hogsend studio admin reset`",
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const subject = "Reset your Hogsend Studio password";
|
|
68
|
+
const html = buildResetHtml(url);
|
|
69
|
+
const text = buildResetText(url);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await service.sendRaw({ to, subject, html, text });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// A provider error (missing/invalid key, network) must not surface to the
|
|
75
|
+
// caller — better-auth's neutral response stays intact. Log a generic
|
|
76
|
+
// warning that points at the CLI fallback. NEVER include the url/token.
|
|
77
|
+
log.warn(
|
|
78
|
+
"password reset email failed to send (no usable email provider?) — use `hogsend studio admin reset`",
|
|
79
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Minimal, dependency-free HTML body. The URL appears as a button and a raw
|
|
85
|
+
* link (so it works even when buttons are stripped). No tracking, no footer. */
|
|
86
|
+
function buildResetHtml(url: string): string {
|
|
87
|
+
const safeUrl = escapeHtmlAttr(url);
|
|
88
|
+
const safeText = escapeHtml(url);
|
|
89
|
+
return `<!doctype html>
|
|
90
|
+
<html lang="en">
|
|
91
|
+
<body style="margin:0;padding:24px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#18181b;">
|
|
92
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;padding:32px;">
|
|
93
|
+
<tr><td>
|
|
94
|
+
<h1 style="margin:0 0 16px;font-size:20px;font-weight:600;">Reset your password</h1>
|
|
95
|
+
<p style="margin:0 0 24px;font-size:14px;line-height:1.6;color:#3f3f46;">
|
|
96
|
+
We received a request to reset your Hogsend Studio password. Click the
|
|
97
|
+
button below to choose a new one.
|
|
98
|
+
</p>
|
|
99
|
+
<p style="margin:0 0 24px;">
|
|
100
|
+
<a href="${safeUrl}" style="display:inline-block;background:#18181b;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;padding:12px 20px;border-radius:6px;">Reset password</a>
|
|
101
|
+
</p>
|
|
102
|
+
<p style="margin:0 0 16px;font-size:13px;line-height:1.6;color:#71717a;">
|
|
103
|
+
Or paste this link into your browser:<br />
|
|
104
|
+
<a href="${safeUrl}" style="color:#2563eb;word-break:break-all;">${safeText}</a>
|
|
105
|
+
</p>
|
|
106
|
+
<p style="margin:0;font-size:12px;line-height:1.6;color:#a1a1aa;">
|
|
107
|
+
This link expires in ${RESET_TTL_MINUTES} minutes and can be used once.
|
|
108
|
+
If you didn't request a password reset, you can safely ignore this email.
|
|
109
|
+
</p>
|
|
110
|
+
</td></tr>
|
|
111
|
+
</table>
|
|
112
|
+
</body>
|
|
113
|
+
</html>`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Plain-text alternative (same content, no markup). */
|
|
117
|
+
function buildResetText(url: string): string {
|
|
118
|
+
return [
|
|
119
|
+
"Reset your password",
|
|
120
|
+
"",
|
|
121
|
+
"We received a request to reset your Hogsend Studio password.",
|
|
122
|
+
"Open this link to choose a new one:",
|
|
123
|
+
"",
|
|
124
|
+
url,
|
|
125
|
+
"",
|
|
126
|
+
`This link expires in ${RESET_TTL_MINUTES} minutes and can be used once.`,
|
|
127
|
+
"If you didn't request a password reset, you can safely ignore this email.",
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Escape for an HTML text node. */
|
|
132
|
+
function escapeHtml(s: string): string {
|
|
133
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Escape for a double-quoted HTML attribute (e.g. an `href`). */
|
|
137
|
+
function escapeHtmlAttr(s: string): string {
|
|
138
|
+
return escapeHtml(s).replace(/"/g, """);
|
|
139
|
+
}
|
|
@@ -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,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
1
2
|
import { createMiddleware } from "hono/factory";
|
|
2
3
|
import type { AppEnv } from "../app.js";
|
|
3
4
|
import { getRedis } from "../lib/redis.js";
|
|
@@ -11,6 +12,38 @@ export interface RateLimitOptions {
|
|
|
11
12
|
windowMs?: number;
|
|
12
13
|
max?: number;
|
|
13
14
|
prefix?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Derive the per-request bucket key. Default keys on the resolved api-key /
|
|
17
|
+
* user id (falling back to "anonymous") — correct for the authenticated data
|
|
18
|
+
* plane. Pass `clientIpKey` for UNAUTHENTICATED surfaces (e.g. the first-admin
|
|
19
|
+
* sign-up gate) where every request would otherwise collapse onto the single
|
|
20
|
+
* "anonymous" bucket and let one attacker exhaust the budget for everyone.
|
|
21
|
+
*/
|
|
22
|
+
keyFn?: (c: Context<AppEnv>) => string;
|
|
23
|
+
/**
|
|
24
|
+
* The middleware no-ops under `NODE_ENV=test` by default so the suite isn't
|
|
25
|
+
* throttled. Set `false` to keep it ACTIVE in tests — required to assert the
|
|
26
|
+
* unauthenticated sign-up limiter actually returns 429 past the threshold.
|
|
27
|
+
*/
|
|
28
|
+
disableInTest?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Best-effort client IP from the proxy headers Railway/Cloudflare set (same
|
|
33
|
+
* source as `audit.ts` / tracking). Falls back to "unknown" so a request with
|
|
34
|
+
* no forwarded IP still shares a single bounded bucket rather than bypassing.
|
|
35
|
+
*/
|
|
36
|
+
function clientIp(c: Context<AppEnv>): string {
|
|
37
|
+
return (
|
|
38
|
+
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
39
|
+
c.req.header("x-real-ip") ||
|
|
40
|
+
"unknown"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Bucket key for unauthenticated, IP-scoped surfaces (e.g. sign-up). */
|
|
45
|
+
export function clientIpKey(c: Context<AppEnv>): string {
|
|
46
|
+
return `ip:${clientIp(c)}`;
|
|
14
47
|
}
|
|
15
48
|
|
|
16
49
|
/**
|
|
@@ -25,6 +58,7 @@ export function createRateLimit(opts: RateLimitOptions = {}) {
|
|
|
25
58
|
const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
|
|
26
59
|
const max = opts.max ?? DEFAULT_MAX_REQUESTS;
|
|
27
60
|
const prefix = opts.prefix ?? DEFAULT_PREFIX;
|
|
61
|
+
const disableInTest = opts.disableInTest ?? true;
|
|
28
62
|
|
|
29
63
|
// Per-instance memory store so prefixes stay budget-isolated in the
|
|
30
64
|
// Redis-less fallback path too.
|
|
@@ -32,10 +66,11 @@ export function createRateLimit(opts: RateLimitOptions = {}) {
|
|
|
32
66
|
let cleanupCounter = 0;
|
|
33
67
|
|
|
34
68
|
return createMiddleware<AppEnv>(async (c, next) => {
|
|
35
|
-
if (process.env.NODE_ENV === "test") return next();
|
|
69
|
+
if (disableInTest && process.env.NODE_ENV === "test") return next();
|
|
36
70
|
|
|
37
|
-
const
|
|
38
|
-
|
|
71
|
+
const keyId = opts.keyFn
|
|
72
|
+
? opts.keyFn(c)
|
|
73
|
+
: (c.get("apiKey")?.id ?? c.get("user")?.id ?? "anonymous");
|
|
39
74
|
const now = Date.now();
|
|
40
75
|
|
|
41
76
|
let count: number;
|