@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.
@@ -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 };