@hogsend/engine 0.1.0 → 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.
@@ -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
+ });