@hogsend/engine 0.1.0 → 0.2.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 +6 -6
- package/src/app.ts +37 -0
- package/src/buckets/check-membership.ts +499 -0
- package/src/buckets/define-bucket.ts +29 -0
- package/src/buckets/registry-singleton.ts +21 -0
- package/src/buckets/registry.ts +62 -0
- package/src/container.ts +50 -2
- package/src/env.ts +10 -0
- package/src/index.ts +40 -1
- package/src/lib/auth.ts +8 -1
- package/src/lib/bucket-emit.ts +107 -0
- package/src/lib/bucket-posthog-sync.ts +63 -0
- package/src/lib/email-service-types.ts +6 -0
- package/src/lib/email.ts +2 -0
- package/src/lib/ingestion.ts +25 -0
- package/src/lib/mailer.ts +9 -1
- package/src/lib/metrics-sql.ts +17 -0
- package/src/lib/studio.ts +105 -0
- package/src/lib/tracked.ts +4 -0
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/require-admin.ts +26 -0
- package/src/routes/admin/buckets.ts +464 -0
- package/src/routes/admin/emails.ts +155 -9
- package/src/routes/admin/index.ts +10 -2
- package/src/routes/admin/metrics.ts +286 -23
- package/src/routes/admin/reporting.ts +369 -0
- package/src/routes/admin/suppressions.ts +99 -0
- package/src/routes/admin/templates.ts +298 -0
- package/src/worker.ts +35 -0
- package/src/workflows/bucket-backfill.ts +556 -0
- package/src/workflows/bucket-reconcile.ts +721 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getTemplate,
|
|
3
|
+
getTemplateDefinition,
|
|
4
|
+
getTemplateNames,
|
|
5
|
+
renderToHtml,
|
|
6
|
+
renderToPlainText,
|
|
7
|
+
type TemplateName,
|
|
8
|
+
type TemplateRegistry,
|
|
9
|
+
} from "@hogsend/email";
|
|
10
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
11
|
+
import type { AppEnv } from "../../app.js";
|
|
12
|
+
import { errorSchema } from "../../lib/schemas.js";
|
|
13
|
+
import { requireScope } from "../../middleware/api-key.js";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Engine-injected preview defaults
|
|
17
|
+
//
|
|
18
|
+
// At real send time `sendEmail()` (lib/email.ts) always injects `name`,
|
|
19
|
+
// `unsubscribeUrl`, `journeyName`, `eventName`, and `body`. We mirror that here
|
|
20
|
+
// so previews render the same shape a recipient would actually receive, then
|
|
21
|
+
// layer the template's `examples` and any caller-supplied props on top.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function engineInjectedDefaults(key: string): Record<string, unknown> {
|
|
25
|
+
return {
|
|
26
|
+
name: "there",
|
|
27
|
+
journeyName: key,
|
|
28
|
+
eventName: key,
|
|
29
|
+
body: "This is a preview of the email body.",
|
|
30
|
+
// A no-op preview URL: never a real tracking domain, so previewing never
|
|
31
|
+
// writes tracked_links or otherwise touches the send pipeline.
|
|
32
|
+
unsubscribeUrl: "https://example.com/unsubscribe?preview=1",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function decodeProps(raw?: string): Record<string, unknown> {
|
|
37
|
+
if (!raw) return {};
|
|
38
|
+
const json = Buffer.from(raw, "base64").toString("utf8");
|
|
39
|
+
const parsed = JSON.parse(json);
|
|
40
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
41
|
+
throw new Error("props must decode to a JSON object");
|
|
42
|
+
}
|
|
43
|
+
return parsed as Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// GET /templates — catalog of all registered templates
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const catalogRoute = createRoute({
|
|
51
|
+
method: "get",
|
|
52
|
+
path: "/",
|
|
53
|
+
tags: ["Admin — Templates"],
|
|
54
|
+
summary: "List all registered email templates",
|
|
55
|
+
responses: {
|
|
56
|
+
200: {
|
|
57
|
+
content: {
|
|
58
|
+
"application/json": {
|
|
59
|
+
schema: z.object({
|
|
60
|
+
templates: z.array(
|
|
61
|
+
z.object({
|
|
62
|
+
key: z.string(),
|
|
63
|
+
defaultSubject: z.string(),
|
|
64
|
+
category: z.string().nullable(),
|
|
65
|
+
hasPreview: z.boolean(),
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
description: "Template catalog",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// GET /templates/{key}/preview — render one template
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const previewRoute = createRoute({
|
|
81
|
+
method: "get",
|
|
82
|
+
path: "/{key}/preview",
|
|
83
|
+
tags: ["Admin — Templates"],
|
|
84
|
+
summary: "Render a template preview (HTML + plain text)",
|
|
85
|
+
request: {
|
|
86
|
+
params: z.object({ key: z.string() }),
|
|
87
|
+
query: z.object({
|
|
88
|
+
props: z.string().optional(),
|
|
89
|
+
format: z.enum(["html", "text"]).optional(),
|
|
90
|
+
}),
|
|
91
|
+
},
|
|
92
|
+
responses: {
|
|
93
|
+
200: {
|
|
94
|
+
content: {
|
|
95
|
+
"application/json": {
|
|
96
|
+
schema: z.object({
|
|
97
|
+
key: z.string(),
|
|
98
|
+
subject: z.string(),
|
|
99
|
+
category: z.string().nullable(),
|
|
100
|
+
preview: z.string().nullable(),
|
|
101
|
+
html: z.string(),
|
|
102
|
+
text: z.string(),
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
"text/html": { schema: z.string() },
|
|
106
|
+
"text/plain": { schema: z.string() },
|
|
107
|
+
},
|
|
108
|
+
description: "Rendered preview",
|
|
109
|
+
},
|
|
110
|
+
400: {
|
|
111
|
+
content: { "application/json": { schema: errorSchema } },
|
|
112
|
+
description: "Invalid props payload",
|
|
113
|
+
},
|
|
114
|
+
404: {
|
|
115
|
+
content: { "application/json": { schema: errorSchema } },
|
|
116
|
+
description: "Template not found",
|
|
117
|
+
},
|
|
118
|
+
500: {
|
|
119
|
+
content: { "application/json": { schema: errorSchema } },
|
|
120
|
+
description: "Template failed to render",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// POST /templates/{key}/send-test — send one real email
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
const sendTestRoute = createRoute({
|
|
130
|
+
method: "post",
|
|
131
|
+
path: "/{key}/send-test",
|
|
132
|
+
tags: ["Admin — Templates"],
|
|
133
|
+
summary: "Send a single test email of a template",
|
|
134
|
+
middleware: [requireScope("full-admin")] as const,
|
|
135
|
+
request: {
|
|
136
|
+
params: z.object({ key: z.string() }),
|
|
137
|
+
body: {
|
|
138
|
+
content: {
|
|
139
|
+
"application/json": {
|
|
140
|
+
schema: z.object({
|
|
141
|
+
to: z.string().email(),
|
|
142
|
+
props: z.record(z.string(), z.unknown()).optional(),
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
responses: {
|
|
149
|
+
200: {
|
|
150
|
+
content: {
|
|
151
|
+
"application/json": {
|
|
152
|
+
schema: z.object({
|
|
153
|
+
status: z.string(),
|
|
154
|
+
emailSendId: z.string().optional(),
|
|
155
|
+
}),
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
description: "Test email dispatched",
|
|
159
|
+
},
|
|
160
|
+
404: {
|
|
161
|
+
content: { "application/json": { schema: errorSchema } },
|
|
162
|
+
description: "Template not found",
|
|
163
|
+
},
|
|
164
|
+
500: {
|
|
165
|
+
content: { "application/json": { schema: errorSchema } },
|
|
166
|
+
description: "Test send failed",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
function templateExists(registry: TemplateRegistry, key: string): boolean {
|
|
172
|
+
return getTemplateNames(registry).includes(key as TemplateName);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const templatesRouter = new OpenAPIHono<AppEnv>()
|
|
176
|
+
.openapi(catalogRoute, (c) => {
|
|
177
|
+
const { templates } = c.get("container");
|
|
178
|
+
|
|
179
|
+
const catalog = getTemplateNames(templates).map((key) => {
|
|
180
|
+
const def = getTemplateDefinition({
|
|
181
|
+
key: key as TemplateName,
|
|
182
|
+
registry: templates,
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
key: key as string,
|
|
186
|
+
defaultSubject: def.defaultSubject,
|
|
187
|
+
category: def.category ?? null,
|
|
188
|
+
hasPreview: typeof def.preview === "function",
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return c.json({ templates: catalog }, 200);
|
|
193
|
+
})
|
|
194
|
+
.openapi(previewRoute, async (c) => {
|
|
195
|
+
const { templates } = c.get("container");
|
|
196
|
+
const { key } = c.req.valid("param");
|
|
197
|
+
const { props: encodedProps, format } = c.req.valid("query");
|
|
198
|
+
|
|
199
|
+
if (!templateExists(templates, key)) {
|
|
200
|
+
return c.json({ error: "Template not found" }, 404);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let decoded: Record<string, unknown>;
|
|
204
|
+
try {
|
|
205
|
+
decoded = decodeProps(encodedProps);
|
|
206
|
+
} catch {
|
|
207
|
+
return c.json(
|
|
208
|
+
{ error: "Invalid props: expected base64 JSON object" },
|
|
209
|
+
400,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const definition = getTemplateDefinition({
|
|
214
|
+
key: key as TemplateName,
|
|
215
|
+
registry: templates,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Engine defaults < template examples < caller-supplied props.
|
|
219
|
+
const props = {
|
|
220
|
+
...engineInjectedDefaults(key),
|
|
221
|
+
...(definition.examples ?? {}),
|
|
222
|
+
...decoded,
|
|
223
|
+
} as never;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const { element, subject, category } = getTemplate({
|
|
227
|
+
key: key as TemplateName,
|
|
228
|
+
props,
|
|
229
|
+
registry: templates,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (format === "html") {
|
|
233
|
+
const html = await renderToHtml(element);
|
|
234
|
+
return c.body(html, 200, {
|
|
235
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
if (format === "text") {
|
|
239
|
+
const text = await renderToPlainText(element);
|
|
240
|
+
return c.body(text, 200, {
|
|
241
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const [html, text] = await Promise.all([
|
|
246
|
+
renderToHtml(element),
|
|
247
|
+
renderToPlainText(element),
|
|
248
|
+
]);
|
|
249
|
+
const preview = definition.preview?.(props) ?? null;
|
|
250
|
+
|
|
251
|
+
return c.json(
|
|
252
|
+
{ key, subject, category: category ?? null, preview, html, text },
|
|
253
|
+
200,
|
|
254
|
+
);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const message = err instanceof Error ? err.message : "Render failed";
|
|
257
|
+
return c.json({ error: `Failed to render template: ${message}` }, 500);
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
.openapi(sendTestRoute, async (c) => {
|
|
261
|
+
const { emailService, templates } = c.get("container");
|
|
262
|
+
const { key } = c.req.valid("param");
|
|
263
|
+
const { to, props: bodyProps } = c.req.valid("json");
|
|
264
|
+
|
|
265
|
+
if (!templateExists(templates, key)) {
|
|
266
|
+
return c.json({ error: "Template not found" }, 404);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const definition = getTemplateDefinition({
|
|
270
|
+
key: key as TemplateName,
|
|
271
|
+
registry: templates,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const props = {
|
|
275
|
+
...engineInjectedDefaults(key),
|
|
276
|
+
...(definition.examples ?? {}),
|
|
277
|
+
...(bodyProps ?? {}),
|
|
278
|
+
} as never;
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const result = await emailService.send({
|
|
282
|
+
template: key as TemplateName,
|
|
283
|
+
props,
|
|
284
|
+
to,
|
|
285
|
+
subject: definition.defaultSubject,
|
|
286
|
+
category: definition.category ?? "transactional",
|
|
287
|
+
skipPreferenceCheck: true,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return c.json(
|
|
291
|
+
{ status: result.status, emailSendId: result.emailSendId },
|
|
292
|
+
200,
|
|
293
|
+
);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
const message = err instanceof Error ? err.message : "Send failed";
|
|
296
|
+
return c.json({ error: `Test send failed: ${message}` }, 500);
|
|
297
|
+
}
|
|
298
|
+
});
|
package/src/worker.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
+
import type { DefinedBucket } from "./buckets/define-bucket.js";
|
|
2
|
+
import { selectBucketTasks } from "./buckets/registry.js";
|
|
1
3
|
import type { HogsendClient } from "./container.js";
|
|
2
4
|
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
3
5
|
import { selectJourneyTasks } from "./journeys/registry.js";
|
|
4
6
|
import { hatchet } from "./lib/hatchet.js";
|
|
5
7
|
import { getPostHog } from "./lib/posthog.js";
|
|
6
8
|
import { getRedisIfConnected } from "./lib/redis.js";
|
|
9
|
+
import {
|
|
10
|
+
bucketBackfillTask,
|
|
11
|
+
enqueueBucketBackfills,
|
|
12
|
+
} from "./workflows/bucket-backfill.js";
|
|
13
|
+
import { bucketReconcileTask } from "./workflows/bucket-reconcile.js";
|
|
7
14
|
import { checkAlertsTask } from "./workflows/check-alerts.js";
|
|
8
15
|
import { importContactsTask } from "./workflows/import-contacts.js";
|
|
9
16
|
import { sendEmailTask } from "./workflows/send-email.js";
|
|
@@ -11,8 +18,12 @@ import { sendEmailTask } from "./workflows/send-email.js";
|
|
|
11
18
|
export interface CreateWorkerOptions {
|
|
12
19
|
container: HogsendClient;
|
|
13
20
|
journeys: DefinedJourney[];
|
|
21
|
+
/** Buckets whose fast-expiry timer tasks are registered. Defaults to none. */
|
|
22
|
+
buckets?: DefinedBucket[];
|
|
14
23
|
/** Defaults to `container.env.ENABLED_JOURNEYS`. */
|
|
15
24
|
enabledJourneys?: string;
|
|
25
|
+
/** Defaults to `container.env.ENABLED_BUCKETS`. */
|
|
26
|
+
enabledBuckets?: string;
|
|
16
27
|
/** Extra client tasks registered alongside the built-in workflows. */
|
|
17
28
|
extraWorkflows?: unknown[];
|
|
18
29
|
}
|
|
@@ -27,11 +38,22 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
|
27
38
|
const enabled = opts.enabledJourneys ?? container.env.ENABLED_JOURNEYS;
|
|
28
39
|
const journeyTasks = selectJourneyTasks(journeys, enabled);
|
|
29
40
|
|
|
41
|
+
const enabledBuckets = opts.enabledBuckets ?? container.env.ENABLED_BUCKETS;
|
|
42
|
+
// The single place a bucket's per-user fast-expiry timer task is constructed
|
|
43
|
+
// (Section 9.4): the shared `bucket:arm-expiry` durableTask, registered once iff
|
|
44
|
+
// any enabled bucket opts into fastExpiry. The engine-wide time-based-leave
|
|
45
|
+
// reconcile cron (bucketReconcileTask) is ALWAYS registered in baseWorkflows
|
|
46
|
+
// below (Section 10), regardless of fastExpiry.
|
|
47
|
+
const bucketTasks = selectBucketTasks(opts.buckets ?? [], enabledBuckets);
|
|
48
|
+
|
|
30
49
|
const baseWorkflows = [
|
|
31
50
|
sendEmailTask,
|
|
32
51
|
importContactsTask,
|
|
33
52
|
checkAlertsTask,
|
|
53
|
+
bucketReconcileTask,
|
|
54
|
+
bucketBackfillTask,
|
|
34
55
|
...journeyTasks,
|
|
56
|
+
...bucketTasks,
|
|
35
57
|
];
|
|
36
58
|
const workflows = [
|
|
37
59
|
...baseWorkflows,
|
|
@@ -58,6 +80,19 @@ export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
|
58
80
|
);
|
|
59
81
|
|
|
60
82
|
await _worker.start();
|
|
83
|
+
|
|
84
|
+
// Boot-time backfill / criteria-change re-eval (Section 6.6 B): diff each
|
|
85
|
+
// enabled bucket's criteriaHash against bucket_configs and enqueue a
|
|
86
|
+
// backfill/re-eval job where it differs. Best-effort — never block worker
|
|
87
|
+
// start; the cron is the backstop for time-based leaves regardless.
|
|
88
|
+
enqueueBucketBackfills({
|
|
89
|
+
db: container.db,
|
|
90
|
+
logger: container.logger,
|
|
91
|
+
}).catch((err) => {
|
|
92
|
+
container.logger.warn("Bucket backfill enqueue (boot) failed", {
|
|
93
|
+
error: err instanceof Error ? err.message : String(err),
|
|
94
|
+
});
|
|
95
|
+
});
|
|
61
96
|
}
|
|
62
97
|
|
|
63
98
|
return { start, stop };
|