@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 +5 -5
- package/src/container.ts +54 -1
- package/src/index.ts +12 -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/email-service-types.ts +32 -1
- package/src/lib/frequency-cap.ts +54 -0
- package/src/lib/mailer.ts +2 -0
- package/src/lib/timezone.ts +126 -0
- package/src/lib/tracked.ts +40 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.0
|
|
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
|
|
39
|
-
"@hogsend/db": "^0.0
|
|
38
|
+
"@hogsend/core": "^0.1.0",
|
|
39
|
+
"@hogsend/db": "^0.1.0",
|
|
40
40
|
"@hogsend/email": "^0.0.1",
|
|
41
|
-
"@hogsend/plugin-
|
|
42
|
-
"@hogsend/plugin-
|
|
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 {
|
|
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
|
|
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
|
|
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: {
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
131
|
+
await hatchetCtx.sleepFor(durationOrMs);
|
|
56
132
|
|
|
57
|
-
|
|
133
|
+
const resumedAt = new Date().toISOString();
|
|
58
134
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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(
|
|
@@ -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 {
|