@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,11 +35,11 @@
35
35
  "resend": "^6.12.3",
36
36
  "winston": "^3.19.0",
37
37
  "zod": "^4.4.3",
38
- "@hogsend/core": "^0.0.1",
39
- "@hogsend/db": "^0.0.1",
40
- "@hogsend/email": "^0.0.1",
41
- "@hogsend/plugin-resend": "^0.0.1",
42
- "@hogsend/plugin-posthog": "^0.0.1"
38
+ "@hogsend/core": "^0.1.0",
39
+ "@hogsend/db": "^0.1.0",
40
+ "@hogsend/email": "^0.1.0",
41
+ "@hogsend/plugin-posthog": "^0.1.0",
42
+ "@hogsend/plugin-resend": "^0.1.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^22.15.3",
package/src/app.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { user } from "@hogsend/db";
1
2
  import { OpenAPIHono } from "@hono/zod-openapi";
2
3
  import { apiReference } from "@scalar/hono-api-reference";
3
4
  import { compress } from "hono/compress";
@@ -8,6 +9,7 @@ import type { ErrorHandler, MiddlewareHandler } from "hono/types";
8
9
  import type { HogsendClient } from "./container.js";
9
10
  import { API_VERSION } from "./env.js";
10
11
  import type { Auth } from "./lib/auth.js";
12
+ import { mountStudio } from "./lib/studio.js";
11
13
  import type { ApiKeyContext } from "./middleware/api-key.js";
12
14
  import { errorHandler } from "./middleware/error-handler.js";
13
15
  import { requestLogger } from "./middleware/request-logger.js";
@@ -64,13 +66,48 @@ export function createApp(
64
66
  return c.json({ error: "Not Found" }, 404);
65
67
  });
66
68
 
69
+ // Closed signup: the first user may register (first-load "create admin");
70
+ // once any user exists, sign-up is blocked. This is the security control that
71
+ // lets `requireAdmin` trust any authenticated session in a single-tenant app.
72
+ app.use("/api/auth/sign-up/*", async (c, next) => {
73
+ if (c.req.method === "POST") {
74
+ const { db } = c.get("container");
75
+ const existing = await db.select({ id: user.id }).from(user).limit(1);
76
+ if (existing.length > 0) {
77
+ return c.json(
78
+ { error: "Sign-ups are closed. An admin already exists." },
79
+ 403,
80
+ );
81
+ }
82
+ }
83
+ return next();
84
+ });
85
+
67
86
  app.on(["POST", "GET"], "/api/auth/*", (c) => {
68
87
  const { auth } = c.get("container");
69
88
  return auth.handler(c.req.raw);
70
89
  });
71
90
 
91
+ // Public bootstrap probe: tells the Studio whether to show the first-run
92
+ // "create admin" screen (no users yet) instead of the login screen.
93
+ app.get("/v1/auth/status", async (c) => {
94
+ const { db } = c.get("container");
95
+ const existing = await db.select({ id: user.id }).from(user).limit(1);
96
+ return c.json({ needsSetup: existing.length === 0 });
97
+ });
98
+
72
99
  registerRoutes(app, { webhookSources: opts.webhookSources ?? [] });
73
100
 
101
+ // Serve the Studio SPA at /studio/* (static layer, no auth — the SPA gates
102
+ // itself via /v1/auth/status + login; data endpoints stay behind requireAdmin).
103
+ // No-op when no built dist is present, so an unbuilt studio never crashes boot.
104
+ const studio = mountStudio(app);
105
+ if (studio.mounted) {
106
+ container.logger.info(
107
+ `Studio mounted at /studio (dist: ${studio.distPath})`,
108
+ );
109
+ }
110
+
74
111
  opts.routes?.(app);
75
112
 
76
113
  if (container.env.NODE_ENV !== "production") {
package/src/container.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
+ import type { TimeZone } from "@hogsend/core";
2
3
  import type { JourneyRegistry } from "@hogsend/core/registry";
4
+ import type { SendWindow } from "@hogsend/core/schedule";
3
5
  import {
4
6
  createDatabase,
5
7
  type Database,
@@ -15,17 +17,30 @@ import {
15
17
  } from "@hogsend/plugin-resend";
16
18
  import type { Resend } from "resend";
17
19
  import { env } from "./env.js";
20
+ import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
18
21
  import type { DefinedJourney } from "./journeys/define-journey.js";
19
22
  import { buildJourneyRegistry } from "./journeys/registry.js";
20
23
  import { type Auth, createAuth } from "./lib/auth.js";
21
24
  import { setEmailService } from "./lib/email.js";
22
- import type { EmailService } from "./lib/email-service-types.js";
25
+ import type {
26
+ EmailService,
27
+ FrequencyCapConfig,
28
+ } from "./lib/email-service-types.js";
23
29
  import { hatchet } from "./lib/hatchet.js";
24
30
  import { createLogger, type Logger } from "./lib/logger.js";
25
31
  import { createTrackedMailer } from "./lib/mailer.js";
26
32
  import { getPostHog } from "./lib/posthog.js";
27
33
  import { prepareTrackedHtml } from "./lib/tracking.js";
28
34
 
35
+ export interface HogsendDefaults {
36
+ /** Global fallback IANA timezone for scheduling. Defaults to "UTC". */
37
+ timezone: TimeZone;
38
+ /** Default quiet-hours / send window auto-applied by `ctx.when`. */
39
+ sendWindow?: SendWindow;
40
+ /** Optional per-recipient frequency cap enforced in the mailer. */
41
+ frequencyCap?: FrequencyCapConfig;
42
+ }
43
+
29
44
  export interface HogsendClient {
30
45
  env: typeof env;
31
46
  logger: Logger;
@@ -34,6 +49,13 @@ export interface HogsendClient {
34
49
  auth: Auth;
35
50
  email: Resend;
36
51
  emailService: EmailService;
52
+ /**
53
+ * The app's template registry (key → component + subject + category +
54
+ * optional preview/examples). Same object threaded into the engine mailer;
55
+ * exposed here so admin preview/catalog routes can enumerate keys and render
56
+ * templates without going through a send. Empty when no templates are wired.
57
+ */
58
+ templates: TemplateRegistry;
37
59
  analytics?: PostHogService;
38
60
  registry: JourneyRegistry;
39
61
  hatchet: HatchetClient;
@@ -44,6 +66,12 @@ export interface HogsendClient {
44
66
  * track. The CLIENT track never gates boot (client-owned); engine does.
45
67
  */
46
68
  clientJournal: JournalShape;
69
+ /**
70
+ * Resolved scheduling + frequency-cap defaults. `timezone` always has a value
71
+ * ("UTC" when unset). Read by the journey context (tz/window) and the mailer
72
+ * (frequency cap).
73
+ */
74
+ defaults: HogsendDefaults;
47
75
  }
48
76
 
49
77
  export interface HogsendClientOptions {
@@ -89,6 +117,22 @@ export interface HogsendClientOptions {
89
117
  * Defaults to `{ entries: [] }` (empty client track ⇒ trivially in sync).
90
118
  */
91
119
  clientJournal?: JournalShape;
120
+ /**
121
+ * Declarative scheduling + delivery defaults.
122
+ *
123
+ * - `timezone` — global fallback IANA tz (e.g. "UTC"), the terminal step of
124
+ * the per-user tz resolution chain.
125
+ * - `sendWindow` — quiet-hours window ("HH:mm".."HH:mm") auto-applied by
126
+ * `ctx.when` so scheduled instants land inside the window. Enforced ONLY at
127
+ * the scheduling layer; immediate transactional sends bypass it.
128
+ * - `frequencyCap` — per-recipient send cap enforced in the mailer choke
129
+ * point. Opt-in; "transactional" is exempt by default.
130
+ */
131
+ defaults?: {
132
+ timezone?: TimeZone;
133
+ sendWindow?: SendWindow;
134
+ frequencyCap?: FrequencyCapConfig;
135
+ };
92
136
  /**
93
137
  * Genuinely advanced / test-only seams. You probably don't need these —
94
138
  * prefer the first-class `email` / `analytics` args above.
@@ -117,6 +161,18 @@ export function createHogsendClient(
117
161
  db,
118
162
  secret: env.BETTER_AUTH_SECRET,
119
163
  baseURL: env.BETTER_AUTH_URL,
164
+ // Always trust the public API origin; add any explicitly configured ones
165
+ // (e.g. a remote Studio origin) on top. baseURL is trusted automatically.
166
+ trustedOrigins: Array.from(
167
+ new Set(
168
+ [
169
+ env.API_PUBLIC_URL,
170
+ ...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []),
171
+ ]
172
+ .map((o) => o.trim())
173
+ .filter(Boolean),
174
+ ),
175
+ ),
120
176
  });
121
177
 
122
178
  const email = createResendClient({ apiKey: env.RESEND_API_KEY });
@@ -133,16 +189,33 @@ export function createHogsendClient(
133
189
  webhookSecret: env.RESEND_WEBHOOK_SECRET,
134
190
  });
135
191
 
192
+ const defaults: HogsendDefaults = {
193
+ timezone: opts.defaults?.timezone ?? "UTC",
194
+ sendWindow: opts.defaults?.sendWindow,
195
+ frequencyCap: opts.defaults?.frequencyCap,
196
+ };
197
+
198
+ // Expose the scheduling slice to the module-level journey task, which has no
199
+ // client reference of its own.
200
+ setClientScheduleDefaults({
201
+ timezone: defaults.timezone,
202
+ sendWindow: defaults.sendWindow,
203
+ });
204
+
205
+ const templates = opts.email?.templates ?? ({} as TemplateRegistry);
206
+
136
207
  const emailService =
137
208
  opts.overrides?.mailer ??
138
209
  createTrackedMailer(
139
210
  {
140
211
  defaultFrom: env.RESEND_FROM_EMAIL,
141
- templates: opts.email?.templates ?? ({} as TemplateRegistry),
212
+ templates,
142
213
  db,
143
214
  webhookSecret: env.RESEND_WEBHOOK_SECRET,
144
215
  bounceThreshold: 3,
145
216
  baseUrl: env.API_PUBLIC_URL,
217
+ frequencyCap: defaults.frequencyCap,
218
+ logger,
146
219
  },
147
220
  {
148
221
  provider,
@@ -164,9 +237,11 @@ export function createHogsendClient(
164
237
  auth,
165
238
  email,
166
239
  emailService,
240
+ templates,
167
241
  analytics,
168
242
  registry,
169
243
  hatchet: opts.overrides?.hatchet ?? hatchet,
170
244
  clientJournal: opts.clientJournal ?? { entries: [] },
245
+ defaults,
171
246
  };
172
247
  }
package/src/env.ts CHANGED
@@ -16,6 +16,10 @@ export const env = createEnv({
16
16
  REDIS_URL: z.string().min(1).default("redis://localhost:6379"),
17
17
  BETTER_AUTH_SECRET: z.string().min(1),
18
18
  BETTER_AUTH_URL: z.string().url().default("http://localhost:3002"),
19
+ // Extra origins allowed to call the auth endpoints (beyond BETTER_AUTH_URL),
20
+ // comma-separated. Needed when the Studio is served from a different origin
21
+ // than the API — e.g. the `hogsend studio` CLI pointing at a remote instance.
22
+ BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
19
23
  RESEND_API_KEY: z.string().min(1),
20
24
  RESEND_FROM_EMAIL: z.string().email().default("noreply@hogsend.com"),
21
25
  // Hatchet connection contract. The @hatchet-dev SDK also reads these straight
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  createHogsendClient,
24
24
  type HogsendClient,
25
25
  type HogsendClientOptions,
26
+ type HogsendDefaults,
26
27
  } from "./container.js";
27
28
  // --- Env ---
28
29
  export { API_VERSION, env } from "./env.js";
@@ -65,11 +66,14 @@ export type {
65
66
  EmailServiceSendOptions,
66
67
  EmailServiceWebhookOptions,
67
68
  EmailServiceWebhookResult,
69
+ FrequencyCapConfig,
70
+ FrequencyCapWindow,
68
71
  SendTrackedEmailOptions,
69
72
  TrackedSendResult,
70
73
  } from "./lib/email-service-types.js";
71
74
  // --- Enrollment guards ---
72
75
  export { checkEmailPreferences } from "./lib/enrollment-guards.js";
76
+ export { isFrequencyCapped } from "./lib/frequency-cap.js";
73
77
  export { hatchet } from "./lib/hatchet.js";
74
78
  // --- Ingestion pipeline ---
75
79
  export {
@@ -82,6 +86,15 @@ export { createLogger, type Logger } from "./lib/logger.js";
82
86
  export { createTrackedMailer } from "./lib/mailer.js";
83
87
  export { getPostHog } from "./lib/posthog.js";
84
88
  export { getRedisIfConnected } from "./lib/redis.js";
89
+ export { type MountStudioResult, mountStudio } from "./lib/studio.js";
90
+ export {
91
+ type ResolveTimezoneInput,
92
+ type ResolveTimezoneResult,
93
+ resolveTimezone,
94
+ resolveTimezoneWithSource,
95
+ setContactTimezone,
96
+ type TimezoneSource,
97
+ } from "./lib/timezone.js";
85
98
  export {
86
99
  type PrepareTrackedHtmlFn,
87
100
  sendTrackedEmail,
@@ -0,0 +1,29 @@
1
+ import type { SendWindow } from "@hogsend/core/schedule";
2
+
3
+ /**
4
+ * The scheduling-relevant slice of the client `defaults`, set once by
5
+ * `createHogsendClient` and read by the module-level journey task in
6
+ * `define-journey` (which has no client reference of its own). Frequency-cap
7
+ * config is threaded through the mailer, not here.
8
+ */
9
+ export interface ClientScheduleDefaults {
10
+ timezone: string;
11
+ sendWindow?: SendWindow;
12
+ }
13
+
14
+ let _defaults: ClientScheduleDefaults = { timezone: "UTC" };
15
+
16
+ export function setClientScheduleDefaults(
17
+ defaults: ClientScheduleDefaults,
18
+ ): void {
19
+ _defaults = defaults;
20
+ }
21
+
22
+ export function getClientScheduleDefaults(): ClientScheduleDefaults {
23
+ return _defaults;
24
+ }
25
+
26
+ /** Reset to the UTC default — only for test cleanup. */
27
+ export function resetClientScheduleDefaults(): void {
28
+ _defaults = { timezone: "UTC" };
29
+ }
@@ -5,7 +5,7 @@ import type {
5
5
  JourneyRunFn,
6
6
  JourneyUser,
7
7
  } from "@hogsend/core/types";
8
- import { journeyConfigs, journeyStates } from "@hogsend/db";
8
+ import { contacts, journeyConfigs, journeyStates } from "@hogsend/db";
9
9
  import { and, eq, inArray } from "drizzle-orm";
10
10
  import { getDb } from "../lib/db.js";
11
11
  import {
@@ -15,6 +15,8 @@ import {
15
15
  import { hatchet } from "../lib/hatchet.js";
16
16
  import { createLogger } from "../lib/logger.js";
17
17
  import { getPostHog } from "../lib/posthog.js";
18
+ import { resolveTimezoneWithSource } from "../lib/timezone.js";
19
+ import { getClientScheduleDefaults } from "./client-defaults-singleton.js";
18
20
  import { createJourneyContext } from "./journey-context.js";
19
21
  import { getJourneyRegistrySingleton } from "./registry-singleton.js";
20
22
 
@@ -127,17 +129,58 @@ export function defineJourney(options: {
127
129
  journeyName: meta.name,
128
130
  };
129
131
 
132
+ const posthog = getPostHog();
133
+ const scheduleDefaults = getClientScheduleDefaults();
134
+
135
+ // Resolve the user's timezone via the precedence chain (explicit is N/A
136
+ // at enrollment; PostHog person props → contacts row → client default →
137
+ // UTC). Best-effort: failures fall through to the client default tz.
138
+ // Independent I/O — fetch the contact row and PostHog person props
139
+ // concurrently. PostHog failures fall through to undefined.
140
+ const [contact, posthogProperties] = await Promise.all([
141
+ db.query.contacts.findFirst({
142
+ where: eq(contacts.externalId, userId),
143
+ }),
144
+ posthog?.getPersonProperties(userId).catch(() => undefined),
145
+ ]);
146
+
147
+ const tz = resolveTimezoneWithSource({
148
+ posthogProperties,
149
+ contactTimezone: contact?.timezone ?? null,
150
+ contactProperties: contact?.properties ?? null,
151
+ defaultTimezone: scheduleDefaults.timezone,
152
+ logger,
153
+ });
154
+
155
+ // Opportunistic cache write: when the tz came from a PostHog source and
156
+ // the contacts.timezone column is empty, persist it (fire-and-forget;
157
+ // PostHog/JSONB remain authoritative so nothing blocks on the column).
158
+ if (
159
+ (tz.source === "posthog_timezone" || tz.source === "posthog_geoip") &&
160
+ contact &&
161
+ !contact.timezone
162
+ ) {
163
+ db.update(contacts)
164
+ .set({ timezone: tz.timezone, updatedAt: new Date() })
165
+ .where(eq(contacts.id, contact.id))
166
+ .catch(() => {
167
+ // best-effort cache write; never block the journey
168
+ });
169
+ }
170
+
130
171
  const ctx = createJourneyContext({
131
172
  db,
132
173
  hatchet,
133
174
  hatchetCtx,
134
175
  registry: getJourneyRegistrySingleton(),
135
176
  logger,
136
- posthog: getPostHog(),
177
+ posthog,
137
178
  stateId,
138
179
  userId,
139
180
  userEmail,
140
181
  journeyContext: { ...properties },
182
+ resolvedTimezone: tz.timezone,
183
+ defaultSendWindow: scheduleDefaults.sendWindow,
141
184
  });
142
185
 
143
186
  try {
@@ -2,7 +2,21 @@ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
2
  import type { DurationObject } from "@hogsend/core";
3
3
  import { evaluateEventCondition } from "@hogsend/core";
4
4
  import type { JourneyRegistry } from "@hogsend/core/registry";
5
- import type { JourneyContext } from "@hogsend/core/types";
5
+ import {
6
+ isValidTimeZone,
7
+ resolveAfter,
8
+ resolveNextLocalTime,
9
+ resolveNextWeekday,
10
+ resolveTomorrow,
11
+ type SendWindow,
12
+ } from "@hogsend/core/schedule";
13
+ import type {
14
+ IfPast,
15
+ JourneyContext,
16
+ TimeOfDayBuilder,
17
+ Weekday,
18
+ WhenBuilder,
19
+ } from "@hogsend/core/types";
6
20
  import { type Database, emailSends, journeyStates } from "@hogsend/db";
7
21
  import type { PostHogService } from "@hogsend/plugin-posthog";
8
22
  import { and, count, eq, max } from "drizzle-orm";
@@ -13,7 +27,11 @@ import type { Logger } from "../lib/logger.js";
13
27
  interface JourneyContextConfig {
14
28
  db: Database;
15
29
  hatchet: HatchetClient;
16
- hatchetCtx: { sleepFor: (duration: DurationObject) => Promise<unknown> };
30
+ hatchetCtx: {
31
+ // Hatchet's real `sleepFor` accepts a number (milliseconds) in addition to
32
+ // duration strings/objects; we use the number-ms form for `sleepUntil`.
33
+ sleepFor: (duration: DurationObject | number) => Promise<unknown>;
34
+ };
17
35
  registry: JourneyRegistry;
18
36
  logger: Logger;
19
37
  posthog?: PostHogService;
@@ -21,6 +39,61 @@ interface JourneyContextConfig {
21
39
  userId: string;
22
40
  userEmail: string;
23
41
  journeyContext: Record<string, unknown>;
42
+ /** The user's resolved IANA timezone, bound into `ctx.when`. */
43
+ resolvedTimezone: string;
44
+ /** The client default send window, auto-applied by `ctx.when`. */
45
+ defaultSendWindow?: SendWindow;
46
+ }
47
+
48
+ /**
49
+ * Build the timezone-bound fluent scheduler. A thin wrapper over the pure core
50
+ * resolvers: it injects the user's resolved tz, the real current instant, and
51
+ * the (optionally overridden) send window, returning absolute `Date`s.
52
+ */
53
+ function createWhenBuilder(opts: {
54
+ timezone: string;
55
+ window?: SendWindow;
56
+ ifPast: IfPast;
57
+ }): WhenBuilder {
58
+ const baseOpts = () => ({
59
+ timezone: opts.timezone,
60
+ now: new Date(),
61
+ window: opts.window,
62
+ ifPast: opts.ifPast,
63
+ });
64
+
65
+ const timeBuilder = (resolve: (time: string) => Date): TimeOfDayBuilder => ({
66
+ at: (time) => resolve(time),
67
+ });
68
+
69
+ return {
70
+ next(weekday: Weekday) {
71
+ return timeBuilder((time) =>
72
+ resolveNextWeekday(weekday, time, baseOpts()),
73
+ );
74
+ },
75
+ nextLocal(time: string) {
76
+ return resolveNextLocalTime(time, baseOpts());
77
+ },
78
+ tomorrow() {
79
+ return timeBuilder((time) => resolveTomorrow(time, baseOpts()));
80
+ },
81
+ in(duration: DurationObject) {
82
+ return timeBuilder((time) => resolveAfter(duration, time, baseOpts()));
83
+ },
84
+ tz(timezone: string) {
85
+ if (!isValidTimeZone(timezone)) {
86
+ throw new TypeError(`ctx.when.tz: invalid timezone "${timezone}"`);
87
+ }
88
+ return createWhenBuilder({ ...opts, timezone });
89
+ },
90
+ window(start: string, end: string) {
91
+ return createWhenBuilder({ ...opts, window: { start, end } });
92
+ },
93
+ ifPast(strategy: IfPast) {
94
+ return createWhenBuilder({ ...opts, ifPast: strategy });
95
+ },
96
+ };
24
97
  }
25
98
 
26
99
  export function createJourneyContext(
@@ -37,31 +110,63 @@ export function createJourneyContext(
37
110
  userId,
38
111
  userEmail,
39
112
  journeyContext,
113
+ resolvedTimezone,
114
+ defaultSendWindow,
40
115
  } = config;
41
116
 
42
- return {
43
- async sleep({ duration, label }) {
44
- const sleptAt = new Date().toISOString();
117
+ // Shared wait lifecycle: mark the state "waiting", durably sleep, mark it
118
+ // "active" again. `sleep` passes a DurationObject; `sleepUntil` passes a
119
+ // precomputed ms delay — Hatchet's `sleepFor` accepts both.
120
+ const performSleep = async (
121
+ durationOrMs: DurationObject | number,
122
+ nodeId: string,
123
+ ): Promise<{ sleptAt: string; resumedAt: string }> => {
124
+ const sleptAt = new Date().toISOString();
45
125
 
46
- await db
47
- .update(journeyStates)
48
- .set({
49
- status: "waiting",
50
- currentNodeId: label ?? `wait:${JSON.stringify(duration)}`,
51
- updatedAt: new Date(),
52
- })
53
- .where(eq(journeyStates.id, stateId));
126
+ await db
127
+ .update(journeyStates)
128
+ .set({ status: "waiting", currentNodeId: nodeId, updatedAt: new Date() })
129
+ .where(eq(journeyStates.id, stateId));
54
130
 
55
- await hatchetCtx.sleepFor(duration);
131
+ await hatchetCtx.sleepFor(durationOrMs);
56
132
 
57
- const resumedAt = new Date().toISOString();
133
+ const resumedAt = new Date().toISOString();
58
134
 
59
- await db
60
- .update(journeyStates)
61
- .set({ status: "active", updatedAt: new Date() })
62
- .where(eq(journeyStates.id, stateId));
135
+ await db
136
+ .update(journeyStates)
137
+ .set({ status: "active", updatedAt: new Date() })
138
+ .where(eq(journeyStates.id, stateId));
139
+
140
+ return { sleptAt, resumedAt };
141
+ };
142
+
143
+ return {
144
+ when: createWhenBuilder({
145
+ timezone: resolvedTimezone,
146
+ window: defaultSendWindow,
147
+ ifPast: "next",
148
+ }),
149
+
150
+ async sleep({ duration, label }) {
151
+ return performSleep(
152
+ duration,
153
+ label ?? `wait:${JSON.stringify(duration)}`,
154
+ );
155
+ },
156
+
157
+ async sleepUntil(at, opts) {
158
+ const target = at instanceof Date ? at.getTime() : new Date(at).getTime();
159
+ if (Number.isNaN(target)) {
160
+ throw new TypeError("sleepUntil: invalid date");
161
+ }
63
162
 
64
- return { sleptAt, resumedAt };
163
+ // Compute the wake delay ONCE. Durability comes from Hatchet preserving
164
+ // the deadline across replays/restarts; a past instant gives ms = 0.
165
+ const ms = Math.max(0, target - Date.now());
166
+ return performSleep(
167
+ ms,
168
+ opts?.label ?? `wait-until:${new Date(target).toISOString()}`,
169
+ );
65
170
  },
66
171
 
67
172
  async checkpoint(label) {
package/src/lib/auth.ts CHANGED
@@ -8,12 +8,19 @@ export function createAuth(opts: {
8
8
  db: Database;
9
9
  secret: string;
10
10
  baseURL: string;
11
+ /**
12
+ * Extra origins allowed to call auth endpoints, beyond `baseURL` (which is
13
+ * always trusted). Needed when the Studio is served from a different origin
14
+ * than the API (e.g. the `hogsend studio` CLI against a remote instance).
15
+ */
16
+ trustedOrigins?: string[];
11
17
  }) {
12
- const { db, secret, baseURL } = opts;
18
+ const { db, secret, baseURL, trustedOrigins } = opts;
13
19
  return betterAuth({
14
20
  basePath: "/api/auth",
15
21
  secret,
16
22
  baseURL,
23
+ ...(trustedOrigins && trustedOrigins.length > 0 ? { trustedOrigins } : {}),
17
24
  database: drizzleAdapter(db, {
18
25
  provider: "pg",
19
26
  schema,
@@ -1,3 +1,4 @@
1
+ import type { DurationObject } from "@hogsend/core";
1
2
  import type {
2
3
  EmailServiceRenderOptions,
3
4
  EmailServiceRenderResult,
@@ -13,6 +14,7 @@ import type {
13
14
  WebhookEventType,
14
15
  WebhookHandlerMap,
15
16
  } from "@hogsend/plugin-resend";
17
+ import type { Logger } from "./logger.js";
16
18
 
17
19
  export type {
18
20
  BatchEmailItem,
@@ -33,6 +35,9 @@ export interface SendTrackedEmailOptions<
33
35
  to: string;
34
36
  subject?: string;
35
37
  journeyStateId?: string;
38
+ /** Denormalized recipient identity, persisted on the email_sends row for reporting. */
39
+ userId?: string;
40
+ userEmail?: string;
36
41
  category?: string;
37
42
  tags?: Array<{ name: string; value: string }>;
38
43
  headers?: Record<string, string>;
@@ -44,7 +49,28 @@ export interface SendTrackedEmailOptions<
44
49
  export interface TrackedSendResult {
45
50
  emailSendId: string;
46
51
  resendId: string;
47
- status: "sent" | "suppressed" | "unsubscribed";
52
+ status: "sent" | "suppressed" | "unsubscribed" | "skipped";
53
+ /** Present only when `status === "skipped"` by the frequency cap. */
54
+ reason?: "frequency_capped";
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Frequency capping (client default config)
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export interface FrequencyCapWindow {
62
+ count: number;
63
+ window: DurationObject;
64
+ }
65
+
66
+ export interface FrequencyCapConfig {
67
+ /** Global send count allowed within `window` per recipient. */
68
+ count: number;
69
+ window: DurationObject;
70
+ /** Per-category overrides (count + window, filtered by that category). */
71
+ byCategory?: Record<string, FrequencyCapWindow>;
72
+ /** Categories exempt from capping. Defaults to ["transactional"]. */
73
+ exemptCategories?: string[];
48
74
  }
49
75
 
50
76
  // ---------------------------------------------------------------------------
@@ -66,6 +92,14 @@ export interface EmailServiceConfig {
66
92
  retryOptions?: RetryOptions;
67
93
  bounceThreshold?: number;
68
94
  baseUrl?: string;
95
+ /**
96
+ * Optional per-client frequency cap. When set, sends are counted per
97
+ * recipient within the window and skipped (no provider call, no `sent` row)
98
+ * once the cap is reached. Opt-in: undefined ⇒ no capping.
99
+ */
100
+ frequencyCap?: FrequencyCapConfig;
101
+ /** Optional structured logger; used e.g. to record frequency-cap skips. */
102
+ logger?: Logger;
69
103
  }
70
104
 
71
105
  export interface EmailServiceSendOptions<
@@ -77,6 +111,9 @@ export interface EmailServiceSendOptions<
77
111
  from?: string;
78
112
  subject?: string;
79
113
  journeyStateId?: string;
114
+ /** Denormalized recipient identity, persisted on the email_sends row for reporting. */
115
+ userId?: string;
116
+ userEmail?: string;
80
117
  category?: string;
81
118
  tags?: Array<{ name: string; value: string }>;
82
119
  headers?: Record<string, string>;
package/src/lib/email.ts CHANGED
@@ -76,6 +76,8 @@ export async function sendEmail(
76
76
  to: opts.to,
77
77
  subject: opts.subject,
78
78
  journeyStateId: opts.journeyStateId,
79
+ userId: opts.userId,
80
+ userEmail: opts.to,
79
81
  category: "journey",
80
82
  tags: [
81
83
  { name: "journeyId", value: opts.journeyName ?? opts.template },