@hogsend/engine 0.0.1 → 0.1.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 +6 -6
- package/src/app.ts +37 -0
- package/src/container.ts +77 -2
- package/src/env.ts +4 -0
- package/src/index.ts +13 -0
- package/src/journeys/client-defaults-singleton.ts +29 -0
- package/src/journeys/define-journey.ts +45 -2
- package/src/journeys/journey-context.ts +125 -20
- package/src/lib/auth.ts +8 -1
- package/src/lib/email-service-types.ts +38 -1
- package/src/lib/email.ts +2 -0
- package/src/lib/frequency-cap.ts +54 -0
- package/src/lib/mailer.ts +11 -1
- package/src/lib/metrics-sql.ts +17 -0
- package/src/lib/studio.ts +105 -0
- package/src/lib/timezone.ts +126 -0
- package/src/lib/tracked.ts +44 -1
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/require-admin.ts +26 -0
- package/src/routes/admin/emails.ts +155 -9
- package/src/routes/admin/index.ts +8 -2
- package/src/routes/admin/metrics.ts +31 -23
- package/src/routes/admin/reporting.ts +369 -0
- package/src/routes/admin/suppressions.ts +99 -0
- package/src/routes/admin/templates.ts +298 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { durationToMs } from "@hogsend/core";
|
|
2
|
+
import type { Database } from "@hogsend/db";
|
|
3
|
+
import { emailSends } from "@hogsend/db";
|
|
4
|
+
import { and, count, eq, gte, ne } from "drizzle-orm";
|
|
5
|
+
import type { FrequencyCapConfig } from "./email-service-types.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_EXEMPT = ["transactional"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* True if this recipient has hit the configured send cap within the window.
|
|
11
|
+
*
|
|
12
|
+
* - `config` undefined → false (feature is opt-in; safe default = no cap).
|
|
13
|
+
* - An exempt `category` (default "transactional") → false.
|
|
14
|
+
* - A `byCategory[category]` override uses its own count/window AND filters the
|
|
15
|
+
* COUNT by that category; otherwise the global rule counts ALL of the
|
|
16
|
+
* recipient's non-failed sends in the window (NULL-category rows included).
|
|
17
|
+
*
|
|
18
|
+
* The COUNT is served by `email_sends_freq_cap_idx (to_email, created_at,
|
|
19
|
+
* category)`. Never-dispatched / failed rows (`status = 'failed'`) are excluded.
|
|
20
|
+
*/
|
|
21
|
+
export async function isFrequencyCapped(opts: {
|
|
22
|
+
db: Database;
|
|
23
|
+
to: string;
|
|
24
|
+
category?: string;
|
|
25
|
+
config?: FrequencyCapConfig;
|
|
26
|
+
}): Promise<boolean> {
|
|
27
|
+
const { db, to, category, config } = opts;
|
|
28
|
+
if (!config) return false;
|
|
29
|
+
|
|
30
|
+
const exempt = config.exemptCategories ?? DEFAULT_EXEMPT;
|
|
31
|
+
if (category && exempt.includes(category)) return false;
|
|
32
|
+
|
|
33
|
+
const override = category ? config.byCategory?.[category] : undefined;
|
|
34
|
+
const rule = override ?? { count: config.count, window: config.window };
|
|
35
|
+
|
|
36
|
+
const since = new Date(Date.now() - durationToMs(rule.window));
|
|
37
|
+
|
|
38
|
+
const conditions = [
|
|
39
|
+
eq(emailSends.toEmail, to),
|
|
40
|
+
gte(emailSends.createdAt, since),
|
|
41
|
+
ne(emailSends.status, "failed"),
|
|
42
|
+
];
|
|
43
|
+
// The byCategory branch counts only sends in that category.
|
|
44
|
+
if (override && category) {
|
|
45
|
+
conditions.push(eq(emailSends.category, category));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [row] = await db
|
|
49
|
+
.select({ n: count() })
|
|
50
|
+
.from(emailSends)
|
|
51
|
+
.where(and(...conditions));
|
|
52
|
+
|
|
53
|
+
return (row?.n ?? 0) >= rule.count;
|
|
54
|
+
}
|
package/src/lib/mailer.ts
CHANGED
|
@@ -82,6 +82,8 @@ export function createTrackedMailer(
|
|
|
82
82
|
registry,
|
|
83
83
|
retryOptions: retryDefaults,
|
|
84
84
|
prepareTrackedHtml: deps.prepareTrackedHtml,
|
|
85
|
+
frequencyCap: config.frequencyCap,
|
|
86
|
+
logger: config.logger,
|
|
85
87
|
options: {
|
|
86
88
|
templateKey: options.template,
|
|
87
89
|
props: options.props,
|
|
@@ -89,6 +91,8 @@ export function createTrackedMailer(
|
|
|
89
91
|
to: options.to,
|
|
90
92
|
subject: options.subject,
|
|
91
93
|
journeyStateId: options.journeyStateId,
|
|
94
|
+
userId: options.userId,
|
|
95
|
+
userEmail: options.userEmail,
|
|
92
96
|
category: options.category,
|
|
93
97
|
tags: options.tags,
|
|
94
98
|
headers: options.headers,
|
|
@@ -188,7 +192,10 @@ export function createTrackedMailer(
|
|
|
188
192
|
await updateEmailStatus(event.type, event.data.email_id);
|
|
189
193
|
break;
|
|
190
194
|
case "email.bounced":
|
|
191
|
-
await updateEmailStatus(event.type, event.data.email_id
|
|
195
|
+
await updateEmailStatus(event.type, event.data.email_id, {
|
|
196
|
+
bounceType: event.data.bounce?.type,
|
|
197
|
+
bounceReason: event.data.bounce?.message,
|
|
198
|
+
});
|
|
192
199
|
await handleBounce(event.data.to);
|
|
193
200
|
break;
|
|
194
201
|
case "email.complained":
|
|
@@ -245,6 +252,7 @@ export function createTrackedMailer(
|
|
|
245
252
|
async function updateEmailStatus(
|
|
246
253
|
eventType: WebhookEventType,
|
|
247
254
|
resendId: string,
|
|
255
|
+
extra?: { bounceType?: string; bounceReason?: string },
|
|
248
256
|
): Promise<void> {
|
|
249
257
|
if (!db) return;
|
|
250
258
|
|
|
@@ -257,6 +265,8 @@ export function createTrackedMailer(
|
|
|
257
265
|
.set({
|
|
258
266
|
status: status as typeof emailSends.$inferSelect.status,
|
|
259
267
|
[timestampField]: new Date(),
|
|
268
|
+
...(extra?.bounceType ? { bounceType: extra.bounceType } : {}),
|
|
269
|
+
...(extra?.bounceReason ? { bounceReason: extra.bounceReason } : {}),
|
|
260
270
|
updatedAt: new Date(),
|
|
261
271
|
})
|
|
262
272
|
.where(eq(emailSends.resendId, resendId));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
// Shared SQL building blocks for the admin metrics + reporting routers.
|
|
4
|
+
|
|
5
|
+
/** Guarded divide, rounded to 4 decimal places. Returns 0 when denom <= 0. */
|
|
6
|
+
export const rate = (num: number, denom: number) =>
|
|
7
|
+
denom > 0 ? Math.round((num / denom) * 10000) / 10000 : 0;
|
|
8
|
+
|
|
9
|
+
/** `date_trunc` granularity literals, keyed by the API's period/granularity enum. */
|
|
10
|
+
export const TRUNC_SQL = {
|
|
11
|
+
hour: sql`'hour'`,
|
|
12
|
+
day: sql`'day'`,
|
|
13
|
+
week: sql`'week'`,
|
|
14
|
+
month: sql`'month'`,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type TruncPeriod = keyof typeof TRUNC_SQL;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
6
|
+
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
7
|
+
import type { AppEnv } from "../app.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where the built Studio SPA lives. The Studio (`@hogsend/studio`) is a separate
|
|
11
|
+
* Vite package that builds to a static `dist/` under base `/studio/`. The engine
|
|
12
|
+
* serves that `dist/` as static files at `/studio/*` with an SPA fallback.
|
|
13
|
+
*
|
|
14
|
+
* The Studio is NOT a runtime dependency of the engine — it ships as a built
|
|
15
|
+
* artifact and is optional. Mounting is best-effort: if no `dist/` is found, the
|
|
16
|
+
* mount is silently skipped so an unbuilt / studio-less deploy never crashes.
|
|
17
|
+
*
|
|
18
|
+
* Resolution order:
|
|
19
|
+
* 1. `STUDIO_DIST_PATH` env var (explicit override; absolute or cwd-relative).
|
|
20
|
+
* 2. `require.resolve("@hogsend/studio/package.json")` → sibling `dist/`
|
|
21
|
+
* (works when the studio package is installed/linked alongside the engine).
|
|
22
|
+
* 3. Monorepo source layout: walk up from this file to `packages/studio/dist`.
|
|
23
|
+
* 4. cwd-relative `packages/studio/dist` (dogfood app run from repo root).
|
|
24
|
+
*/
|
|
25
|
+
function resolveStudioDist(): string | null {
|
|
26
|
+
const candidates: string[] = [];
|
|
27
|
+
|
|
28
|
+
const envPath = process.env.STUDIO_DIST_PATH;
|
|
29
|
+
if (envPath && envPath.length > 0) {
|
|
30
|
+
candidates.push(resolve(process.cwd(), envPath));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
try {
|
|
35
|
+
const pkgJson = require.resolve("@hogsend/studio/package.json");
|
|
36
|
+
candidates.push(join(dirname(pkgJson), "dist"));
|
|
37
|
+
} catch {
|
|
38
|
+
// Not resolvable as a module — fall through to layout-based guesses.
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Monorepo source layout: this file is packages/engine/src/lib/studio.ts, so
|
|
42
|
+
// the studio dist is ../../../studio/dist relative to here.
|
|
43
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
candidates.push(resolve(here, "../../../studio/dist"));
|
|
45
|
+
|
|
46
|
+
// cwd fallbacks for a repo-root process (apps/api dogfood, tests).
|
|
47
|
+
candidates.push(resolve(process.cwd(), "packages/studio/dist"));
|
|
48
|
+
candidates.push(resolve(process.cwd(), "../../packages/studio/dist"));
|
|
49
|
+
|
|
50
|
+
for (const dir of candidates) {
|
|
51
|
+
if (existsSync(join(dir, "index.html"))) {
|
|
52
|
+
return dir;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MountStudioResult {
|
|
59
|
+
/** True when the SPA was mounted, false when no built dist was found. */
|
|
60
|
+
mounted: boolean;
|
|
61
|
+
/** Absolute path to the served dist directory, when mounted. */
|
|
62
|
+
distPath?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mount the Studio SPA at `/studio/*` as static files, with an SPA fallback to
|
|
67
|
+
* `index.html` for client-side routes.
|
|
68
|
+
*
|
|
69
|
+
* IMPORTANT: this is intentionally OUTSIDE the `/v1/admin` auth guard at the
|
|
70
|
+
* static layer. The SPA itself gates access via `/v1/auth/status` + login; the
|
|
71
|
+
* actual data endpoints under `/v1/admin/*` stay protected by `requireAdmin`.
|
|
72
|
+
*
|
|
73
|
+
* No-op (returns `{ mounted: false }`) when no built `dist/` is found, so an
|
|
74
|
+
* unbuilt studio never crashes the server.
|
|
75
|
+
*/
|
|
76
|
+
export function mountStudio(app: OpenAPIHono<AppEnv>): MountStudioResult {
|
|
77
|
+
const distPath = resolveStudioDist();
|
|
78
|
+
if (!distPath) {
|
|
79
|
+
return { mounted: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// serveStatic resolves `path` relative to `root` (which is relative to cwd by
|
|
83
|
+
// default). We pass an absolute `root` and strip the `/studio` URL prefix so a
|
|
84
|
+
// request for `/studio/assets/x.js` maps to `<dist>/assets/x.js`.
|
|
85
|
+
const staticHandler = serveStatic({
|
|
86
|
+
root: distPath,
|
|
87
|
+
rewriteRequestPath: (path) => path.replace(/^\/studio/, "") || "/",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Redirect the bare `/studio` to `/studio/` so relative/base assets resolve.
|
|
91
|
+
app.get("/studio", (c) => c.redirect("/studio/"));
|
|
92
|
+
|
|
93
|
+
// Static assets (js/css/images) under /studio/*.
|
|
94
|
+
app.use("/studio/*", staticHandler);
|
|
95
|
+
|
|
96
|
+
// SPA fallback: any /studio/* path that didn't resolve to a file serves
|
|
97
|
+
// index.html so client-side (TanStack Router) routes work on hard refresh.
|
|
98
|
+
const indexHandler = serveStatic({
|
|
99
|
+
root: distPath,
|
|
100
|
+
rewriteRequestPath: () => "/index.html",
|
|
101
|
+
});
|
|
102
|
+
app.get("/studio/*", indexHandler);
|
|
103
|
+
|
|
104
|
+
return { mounted: true, distPath };
|
|
105
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { isValidTimeZone, type TimeZone } from "@hogsend/core/schedule";
|
|
2
|
+
import { contacts, type Database } from "@hogsend/db";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
|
|
5
|
+
export interface ResolveTimezoneInput {
|
|
6
|
+
/** Explicit per-call override, e.g. from `ctx.when.tz("Area/City")`. */
|
|
7
|
+
explicit?: string;
|
|
8
|
+
/** PostHog person properties ($timezone, $geoip_time_zone). */
|
|
9
|
+
posthogProperties?: Record<string, unknown>;
|
|
10
|
+
/** The `contacts.timezone` cache column. */
|
|
11
|
+
contactTimezone?: string | null;
|
|
12
|
+
/** The `contacts.properties` jsonb. */
|
|
13
|
+
contactProperties?: Record<string, unknown> | null;
|
|
14
|
+
/** The client `defaults.timezone`. */
|
|
15
|
+
defaultTimezone?: string;
|
|
16
|
+
logger?: { warn(msg: string): void };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Source of a resolved timezone, surfaced so callers (e.g. `define-journey`)
|
|
21
|
+
* can decide whether to opportunistically cache it back to `contacts.timezone`.
|
|
22
|
+
*/
|
|
23
|
+
export type TimezoneSource =
|
|
24
|
+
| "explicit"
|
|
25
|
+
| "posthog_timezone"
|
|
26
|
+
| "posthog_geoip"
|
|
27
|
+
| "contact_column"
|
|
28
|
+
| "contact_properties"
|
|
29
|
+
| "default"
|
|
30
|
+
| "fallback";
|
|
31
|
+
|
|
32
|
+
export interface ResolveTimezoneResult {
|
|
33
|
+
timezone: string;
|
|
34
|
+
source: TimezoneSource;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function candidate(value: unknown): string | undefined {
|
|
38
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a user's IANA timezone via the precedence chain. The first *valid*
|
|
43
|
+
* candidate wins; invalid candidates are skipped and warned. Never throws —
|
|
44
|
+
* the terminal fallback is `"UTC"`.
|
|
45
|
+
*
|
|
46
|
+
* Precedence: explicit → PostHog `$timezone` → PostHog `$geoip_time_zone` →
|
|
47
|
+
* `contacts.timezone` → `contacts.properties.timezone` → client default → UTC.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveTimezoneWithSource(
|
|
50
|
+
input: ResolveTimezoneInput,
|
|
51
|
+
): ResolveTimezoneResult {
|
|
52
|
+
const { logger } = input;
|
|
53
|
+
|
|
54
|
+
// An explicit (author-supplied) timezone is a hard contract: if it is present
|
|
55
|
+
// but invalid, throw rather than silently falling through to UTC. Data-sourced
|
|
56
|
+
// candidates below stay lenient (warn + skip) — they are not author input.
|
|
57
|
+
const explicit = candidate(input.explicit);
|
|
58
|
+
if (explicit !== undefined && !isValidTimeZone(explicit)) {
|
|
59
|
+
throw new TypeError(
|
|
60
|
+
`resolveTimezone: invalid explicit timezone "${explicit}"`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const chain: Array<{ value: string | undefined; source: TimezoneSource }> = [
|
|
65
|
+
{ value: candidate(input.explicit), source: "explicit" },
|
|
66
|
+
{
|
|
67
|
+
value: candidate(input.posthogProperties?.$timezone),
|
|
68
|
+
source: "posthog_timezone",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
value: candidate(input.posthogProperties?.$geoip_time_zone),
|
|
72
|
+
source: "posthog_geoip",
|
|
73
|
+
},
|
|
74
|
+
{ value: candidate(input.contactTimezone), source: "contact_column" },
|
|
75
|
+
{
|
|
76
|
+
value: candidate(input.contactProperties?.timezone),
|
|
77
|
+
source: "contact_properties",
|
|
78
|
+
},
|
|
79
|
+
{ value: candidate(input.defaultTimezone), source: "default" },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
for (const { value, source } of chain) {
|
|
83
|
+
if (value === undefined) continue;
|
|
84
|
+
if (isValidTimeZone(value)) {
|
|
85
|
+
return { timezone: value, source };
|
|
86
|
+
}
|
|
87
|
+
logger?.warn(`resolveTimezone: ignoring invalid tz '${value}'`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { timezone: "UTC", source: "fallback" };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Convenience wrapper returning just the resolved IANA timezone string. */
|
|
94
|
+
export function resolveTimezone(input: ResolveTimezoneInput): string {
|
|
95
|
+
return resolveTimezoneWithSource(input).timezone;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Persist a known timezone for a contact (e.g. one the user picked in your
|
|
100
|
+
* app's settings) into the canonical `contacts.timezone` column, so the
|
|
101
|
+
* resolution chain prefers it over PostHog/geoip on the next journey run.
|
|
102
|
+
*
|
|
103
|
+
* Validates the zone and throws `TypeError` on an invalid one — this is an
|
|
104
|
+
* explicit, author-driven write, not best-effort data ingestion. Returns
|
|
105
|
+
* `{ updated: false }` if no contact exists yet for `userId` (it is created on
|
|
106
|
+
* first event ingestion); call again once the contact exists.
|
|
107
|
+
*/
|
|
108
|
+
export async function setContactTimezone(opts: {
|
|
109
|
+
db: Database;
|
|
110
|
+
userId: string;
|
|
111
|
+
timezone: TimeZone;
|
|
112
|
+
}): Promise<{ updated: boolean }> {
|
|
113
|
+
const { db, userId, timezone } = opts;
|
|
114
|
+
|
|
115
|
+
if (!isValidTimeZone(timezone)) {
|
|
116
|
+
throw new TypeError(`setContactTimezone: invalid timezone "${timezone}"`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rows = await db
|
|
120
|
+
.update(contacts)
|
|
121
|
+
.set({ timezone, updatedAt: new Date() })
|
|
122
|
+
.where(eq(contacts.externalId, userId))
|
|
123
|
+
.returning({ id: contacts.id });
|
|
124
|
+
|
|
125
|
+
return { updated: rows.length > 0 };
|
|
126
|
+
}
|
package/src/lib/tracked.ts
CHANGED
|
@@ -10,9 +10,12 @@ import { getTemplate, renderToHtml } from "@hogsend/email";
|
|
|
10
10
|
import type { EmailProvider } from "@hogsend/plugin-resend";
|
|
11
11
|
import { eq } from "drizzle-orm";
|
|
12
12
|
import type {
|
|
13
|
+
FrequencyCapConfig,
|
|
13
14
|
SendTrackedEmailOptions,
|
|
14
15
|
TrackedSendResult,
|
|
15
16
|
} from "./email-service-types.js";
|
|
17
|
+
import { isFrequencyCapped } from "./frequency-cap.js";
|
|
18
|
+
import type { Logger } from "./logger.js";
|
|
16
19
|
|
|
17
20
|
export type PrepareTrackedHtmlFn = (opts: {
|
|
18
21
|
html: string;
|
|
@@ -28,12 +31,24 @@ interface TrackedEmailDeps {
|
|
|
28
31
|
registry: TemplateRegistry;
|
|
29
32
|
retryOptions?: RetryOptions;
|
|
30
33
|
prepareTrackedHtml?: PrepareTrackedHtmlFn;
|
|
34
|
+
/** Optional per-client frequency cap; undefined disables capping. */
|
|
35
|
+
frequencyCap?: FrequencyCapConfig;
|
|
36
|
+
/** Optional structured logger for operational events (e.g. cap skips). */
|
|
37
|
+
logger?: Logger;
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export async function sendTrackedEmail<K extends TemplateName>(
|
|
34
41
|
opts: TrackedEmailDeps & { options: SendTrackedEmailOptions<K> },
|
|
35
42
|
): Promise<TrackedSendResult> {
|
|
36
|
-
const {
|
|
43
|
+
const {
|
|
44
|
+
db,
|
|
45
|
+
provider,
|
|
46
|
+
registry,
|
|
47
|
+
prepareTrackedHtml,
|
|
48
|
+
frequencyCap,
|
|
49
|
+
logger,
|
|
50
|
+
options,
|
|
51
|
+
} = opts;
|
|
37
52
|
|
|
38
53
|
if (!options.skipPreferenceCheck) {
|
|
39
54
|
const suppression = await checkSuppression(
|
|
@@ -51,6 +66,8 @@ export async function sendTrackedEmail<K extends TemplateName>(
|
|
|
51
66
|
subject: options.subject ?? "",
|
|
52
67
|
category: options.category,
|
|
53
68
|
journeyStateId: options.journeyStateId,
|
|
69
|
+
userId: options.userId,
|
|
70
|
+
userEmail: options.userEmail ?? options.to,
|
|
54
71
|
status: "failed",
|
|
55
72
|
})
|
|
56
73
|
.returning({ id: emailSends.id });
|
|
@@ -68,6 +85,30 @@ export async function sendTrackedEmail<K extends TemplateName>(
|
|
|
68
85
|
: "suppressed",
|
|
69
86
|
};
|
|
70
87
|
}
|
|
88
|
+
|
|
89
|
+
// Frequency cap — consulted only for non-system sends (system mail sets
|
|
90
|
+
// skipPreferenceCheck and bypasses both suppression and the cap). On a cap
|
|
91
|
+
// hit: no provider call, no row inserted, no throw — the journey continues.
|
|
92
|
+
if (frequencyCap) {
|
|
93
|
+
const capped = await isFrequencyCapped({
|
|
94
|
+
db,
|
|
95
|
+
to: options.to,
|
|
96
|
+
category: options.category,
|
|
97
|
+
config: frequencyCap,
|
|
98
|
+
});
|
|
99
|
+
if (capped) {
|
|
100
|
+
logger?.info("send skipped: frequency_capped", {
|
|
101
|
+
to: options.to,
|
|
102
|
+
category: options.category,
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
emailSendId: "",
|
|
106
|
+
resendId: "",
|
|
107
|
+
status: "skipped",
|
|
108
|
+
reason: "frequency_capped",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
71
112
|
}
|
|
72
113
|
|
|
73
114
|
const {
|
|
@@ -87,6 +128,8 @@ export async function sendTrackedEmail<K extends TemplateName>(
|
|
|
87
128
|
subject,
|
|
88
129
|
category: options.category ?? category,
|
|
89
130
|
journeyStateId: options.journeyStateId,
|
|
131
|
+
userId: options.userId,
|
|
132
|
+
userEmail: options.userEmail ?? options.to,
|
|
90
133
|
status: "queued",
|
|
91
134
|
})
|
|
92
135
|
.returning({ id: emailSends.id });
|
|
@@ -13,7 +13,7 @@ export const rateLimit = createMiddleware<AppEnv>(async (c, next) => {
|
|
|
13
13
|
if (process.env.NODE_ENV === "test") return next();
|
|
14
14
|
|
|
15
15
|
const apiKey = c.get("apiKey");
|
|
16
|
-
const keyId = apiKey?.id ?? "anonymous";
|
|
16
|
+
const keyId = apiKey?.id ?? c.get("user")?.id ?? "anonymous";
|
|
17
17
|
const now = Date.now();
|
|
18
18
|
|
|
19
19
|
let count: number;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import type { AppEnv } from "../app.js";
|
|
3
|
+
import { requireApiKey } from "./api-key.js";
|
|
4
|
+
|
|
5
|
+
// Authorizes admin routes via EITHER an API key (Bearer header, the
|
|
6
|
+
// programmatic/CLI path) OR a Better-Auth session (cookie, the Studio path).
|
|
7
|
+
// A Bearer header is always treated as an API key; otherwise we resolve the
|
|
8
|
+
// session. Role-based gating is deferred — the security control for the session
|
|
9
|
+
// path is closed signup (only the first user can register; see app.ts), so any
|
|
10
|
+
// authenticated session is an intended admin in this single-tenant model.
|
|
11
|
+
export const requireAdmin = createMiddleware<AppEnv>(async (c, next) => {
|
|
12
|
+
const header = c.req.header("authorization");
|
|
13
|
+
if (header?.startsWith("Bearer ")) {
|
|
14
|
+
return requireApiKey(c, next);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { auth } = c.get("container");
|
|
18
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
19
|
+
if (!session?.user) {
|
|
20
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
c.set("user", session.user);
|
|
24
|
+
c.set("session", session.session);
|
|
25
|
+
return next();
|
|
26
|
+
});
|