@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.
@@ -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
+ }
@@ -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 { db, provider, registry, prepareTrackedHtml, options } = opts;
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
+ });