@hogsend/engine 0.0.1 → 0.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
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",
38
+ "@hogsend/core": "^0.1.0",
39
+ "@hogsend/db": "^0.1.0",
40
40
  "@hogsend/email": "^0.0.1",
41
- "@hogsend/plugin-resend": "^0.0.1",
42
- "@hogsend/plugin-posthog": "^0.0.1"
41
+ "@hogsend/plugin-posthog": "^0.0.1",
42
+ "@hogsend/plugin-resend": "^0.0.1"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/node": "^22.15.3",
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;
@@ -44,6 +59,12 @@ export interface HogsendClient {
44
59
  * track. The CLIENT track never gates boot (client-owned); engine does.
45
60
  */
46
61
  clientJournal: JournalShape;
62
+ /**
63
+ * Resolved scheduling + frequency-cap defaults. `timezone` always has a value
64
+ * ("UTC" when unset). Read by the journey context (tz/window) and the mailer
65
+ * (frequency cap).
66
+ */
67
+ defaults: HogsendDefaults;
47
68
  }
48
69
 
49
70
  export interface HogsendClientOptions {
@@ -89,6 +110,22 @@ export interface HogsendClientOptions {
89
110
  * Defaults to `{ entries: [] }` (empty client track ⇒ trivially in sync).
90
111
  */
91
112
  clientJournal?: JournalShape;
113
+ /**
114
+ * Declarative scheduling + delivery defaults.
115
+ *
116
+ * - `timezone` — global fallback IANA tz (e.g. "UTC"), the terminal step of
117
+ * the per-user tz resolution chain.
118
+ * - `sendWindow` — quiet-hours window ("HH:mm".."HH:mm") auto-applied by
119
+ * `ctx.when` so scheduled instants land inside the window. Enforced ONLY at
120
+ * the scheduling layer; immediate transactional sends bypass it.
121
+ * - `frequencyCap` — per-recipient send cap enforced in the mailer choke
122
+ * point. Opt-in; "transactional" is exempt by default.
123
+ */
124
+ defaults?: {
125
+ timezone?: TimeZone;
126
+ sendWindow?: SendWindow;
127
+ frequencyCap?: FrequencyCapConfig;
128
+ };
92
129
  /**
93
130
  * Genuinely advanced / test-only seams. You probably don't need these —
94
131
  * prefer the first-class `email` / `analytics` args above.
@@ -133,6 +170,19 @@ export function createHogsendClient(
133
170
  webhookSecret: env.RESEND_WEBHOOK_SECRET,
134
171
  });
135
172
 
173
+ const defaults: HogsendDefaults = {
174
+ timezone: opts.defaults?.timezone ?? "UTC",
175
+ sendWindow: opts.defaults?.sendWindow,
176
+ frequencyCap: opts.defaults?.frequencyCap,
177
+ };
178
+
179
+ // Expose the scheduling slice to the module-level journey task, which has no
180
+ // client reference of its own.
181
+ setClientScheduleDefaults({
182
+ timezone: defaults.timezone,
183
+ sendWindow: defaults.sendWindow,
184
+ });
185
+
136
186
  const emailService =
137
187
  opts.overrides?.mailer ??
138
188
  createTrackedMailer(
@@ -143,6 +193,8 @@ export function createHogsendClient(
143
193
  webhookSecret: env.RESEND_WEBHOOK_SECRET,
144
194
  bounceThreshold: 3,
145
195
  baseUrl: env.API_PUBLIC_URL,
196
+ frequencyCap: defaults.frequencyCap,
197
+ logger,
146
198
  },
147
199
  {
148
200
  provider,
@@ -168,5 +220,6 @@ export function createHogsendClient(
168
220
  registry,
169
221
  hatchet: opts.overrides?.hatchet ?? hatchet,
170
222
  clientJournal: opts.clientJournal ?? { entries: [] },
223
+ defaults,
171
224
  };
172
225
  }
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,14 @@ 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 {
90
+ type ResolveTimezoneInput,
91
+ type ResolveTimezoneResult,
92
+ resolveTimezone,
93
+ resolveTimezoneWithSource,
94
+ setContactTimezone,
95
+ type TimezoneSource,
96
+ } from "./lib/timezone.js";
85
97
  export {
86
98
  type PrepareTrackedHtmlFn,
87
99
  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) {
@@ -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,
@@ -44,7 +46,28 @@ export interface SendTrackedEmailOptions<
44
46
  export interface TrackedSendResult {
45
47
  emailSendId: string;
46
48
  resendId: string;
47
- status: "sent" | "suppressed" | "unsubscribed";
49
+ status: "sent" | "suppressed" | "unsubscribed" | "skipped";
50
+ /** Present only when `status === "skipped"` by the frequency cap. */
51
+ reason?: "frequency_capped";
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Frequency capping (client default config)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export interface FrequencyCapWindow {
59
+ count: number;
60
+ window: DurationObject;
61
+ }
62
+
63
+ export interface FrequencyCapConfig {
64
+ /** Global send count allowed within `window` per recipient. */
65
+ count: number;
66
+ window: DurationObject;
67
+ /** Per-category overrides (count + window, filtered by that category). */
68
+ byCategory?: Record<string, FrequencyCapWindow>;
69
+ /** Categories exempt from capping. Defaults to ["transactional"]. */
70
+ exemptCategories?: string[];
48
71
  }
49
72
 
50
73
  // ---------------------------------------------------------------------------
@@ -66,6 +89,14 @@ export interface EmailServiceConfig {
66
89
  retryOptions?: RetryOptions;
67
90
  bounceThreshold?: number;
68
91
  baseUrl?: string;
92
+ /**
93
+ * Optional per-client frequency cap. When set, sends are counted per
94
+ * recipient within the window and skipped (no provider call, no `sent` row)
95
+ * once the cap is reached. Opt-in: undefined ⇒ no capping.
96
+ */
97
+ frequencyCap?: FrequencyCapConfig;
98
+ /** Optional structured logger; used e.g. to record frequency-cap skips. */
99
+ logger?: Logger;
69
100
  }
70
101
 
71
102
  export interface EmailServiceSendOptions<
@@ -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,
@@ -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(
@@ -68,6 +83,30 @@ export async function sendTrackedEmail<K extends TemplateName>(
68
83
  : "suppressed",
69
84
  };
70
85
  }
86
+
87
+ // Frequency cap — consulted only for non-system sends (system mail sets
88
+ // skipPreferenceCheck and bypasses both suppression and the cap). On a cap
89
+ // hit: no provider call, no row inserted, no throw — the journey continues.
90
+ if (frequencyCap) {
91
+ const capped = await isFrequencyCapped({
92
+ db,
93
+ to: options.to,
94
+ category: options.category,
95
+ config: frequencyCap,
96
+ });
97
+ if (capped) {
98
+ logger?.info("send skipped: frequency_capped", {
99
+ to: options.to,
100
+ category: options.category,
101
+ });
102
+ return {
103
+ emailSendId: "",
104
+ resendId: "",
105
+ status: "skipped",
106
+ reason: "frequency_capped",
107
+ };
108
+ }
109
+ }
71
110
  }
72
111
 
73
112
  const {