@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 +6 -6
- package/src/app.ts +37 -0
- package/src/container.ts +77 -2
- package/src/env.ts +4 -0
- package/src/index.ts +13 -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/auth.ts +8 -1
- package/src/lib/email-service-types.ts +38 -1
- package/src/lib/email.ts +2 -0
- package/src/lib/frequency-cap.ts +54 -0
- package/src/lib/mailer.ts +11 -1
- package/src/lib/metrics-sql.ts +17 -0
- package/src/lib/studio.ts +105 -0
- package/src/lib/timezone.ts +126 -0
- package/src/lib/tracked.ts +44 -1
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/require-admin.ts +26 -0
- package/src/routes/admin/emails.ts +155 -9
- package/src/routes/admin/index.ts +8 -2
- package/src/routes/admin/metrics.ts +31 -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
|
@@ -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
|
+
});
|