From 46e2cbb83be4a7515c54176731a1540528eec2c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 01:44:51 +0000 Subject: [PATCH] feat(scripts): add mustache render-template via Responses pages Add HTML template kind to Responses with a seeded system email-default template, render-template.js using Mustache, and html body support in send-email. Templates are editable but system pages cannot be deleted. Co-authored-by: Nasyarobby Putra --- packages/server/http-pages-store.js | 82 +++++++++++- packages/server/http-trigger-auth.js | 18 ++- .../20260815070000_http_page_templates.js | 63 +++++++++ packages/server/package.json | 1 + packages/server/script-sandbox.js | 31 +++++ packages/server/scripts/render-template.js | 126 ++++++++++++++++++ packages/server/scripts/send-email.js | 24 +++- packages/server/src/api/http-auths.js | 12 +- packages/server/src/api/http-pages.js | 10 +- packages/server/workflow-http-validate.js | 8 +- packages/web/src/pages/ResponsesPage.jsx | 92 +++++++++---- pnpm-lock.yaml | 9 ++ 12 files changed, 426 insertions(+), 50 deletions(-) create mode 100644 packages/server/migrations/20260815070000_http_page_templates.js create mode 100644 packages/server/scripts/render-template.js diff --git a/packages/server/http-pages-store.js b/packages/server/http-pages-store.js index 006137a..271c841 100644 --- a/packages/server/http-pages-store.js +++ b/packages/server/http-pages-store.js @@ -4,6 +4,9 @@ import { db } from "./db.js"; const MAX_NAME_LENGTH = 128; const NAME_RE = /^[A-Za-z0-9._-]+$/; const ALLOWED_MIME = new Set(["html", "json"]); +const ALLOWED_KIND = new Set(["response", "template"]); + +export const DEFAULT_EMAIL_TEMPLATE_NAME = "email-default"; function nowIso() { return new Date().toISOString(); @@ -41,6 +44,20 @@ export function assertMime(mime) { return /** @type {"html" | "json"} */ (m); } +/** + * @param {unknown} kind + * @returns {"response" | "template"} + */ +export function assertPageKind(kind, fallback = "response") { + const k = String(kind ?? fallback); + if (!ALLOWED_KIND.has(k)) { + const err = new Error('kind must be "response" or "template"'); + err.statusCode = 400; + throw err; + } + return /** @type {"response" | "template"} */ (k); +} + /** * @param {unknown} status * @returns {number} @@ -63,6 +80,8 @@ function publicPage(row) { content: row.content, mime: row.mime, status: row.status, + kind: row.kind ?? "response", + system: Boolean(row.system), created_at: row.created_at, updated_at: row.updated_at, }; @@ -92,27 +111,56 @@ export async function getHttpPageById(id) { } /** - * @param {{ name: string, content: string, mime: string, status?: number }} opts + * @param {string} name */ -export async function upsertHttpPage({ name, content, mime, status }) { +export async function getHttpTemplateByName(name) { + const page = await getHttpPageByName(name); + if (!page) return null; + if (page.kind !== "template") return null; + return page; +} + +/** + * @param {{ + * name: string, + * content: string, + * mime: string, + * status?: number, + * kind?: string, + * }} opts + */ +export async function upsertHttpPage({ name, content, mime, status, kind }) { const pageName = assertPageName(name); const pageMime = assertMime(mime); + const pageKind = assertPageKind(kind, "response"); const pageStatus = assertHttpStatus(status, 200); if (typeof content !== "string") { const err = new Error("content must be a string"); err.statusCode = 400; throw err; } + if (pageKind === "template" && pageMime !== "html") { + const err = new Error('template pages must use mime "html"'); + err.statusCode = 400; + throw err; + } + const now = nowIso(); const existing = await db("http_pages").where({ name: pageName }).first(); if (existing) { + if (Boolean(existing.system) && pageKind !== "template" && existing.kind === "template") { + const err = new Error("system template pages cannot be changed to HTTP responses"); + err.statusCode = 400; + throw err; + } await db("http_pages") .where({ id: existing.id }) .update({ content, mime: pageMime, status: pageStatus, + kind: Boolean(existing.system) ? existing.kind : pageKind, updated_at: now, }); return getHttpPageById(existing.id); @@ -125,6 +173,8 @@ export async function upsertHttpPage({ name, content, mime, status }) { content, mime: pageMime, status: pageStatus, + kind: pageKind, + system: 0, created_at: now, updated_at: now, }); @@ -136,6 +186,13 @@ export async function upsertHttpPage({ name, content, mime, status }) { * @returns {Promise} */ export async function deleteHttpPage(id) { + const existing = await db("http_pages").where({ id }).first(); + if (!existing) return false; + if (Boolean(existing.system)) { + const err = new Error("system pages cannot be deleted"); + err.statusCode = 409; + throw err; + } const n = await db("http_pages").where({ id }).del(); return n > 0; } @@ -148,3 +205,24 @@ export function contentTypeForMime(mime) { if (mime === "html") return "text/html; charset=utf-8"; return "application/json; charset=utf-8"; } + +/** + * Ensure a page referenced by HTTP triggers is a response page, not a template. + * @param {{ kind?: string } | null} page + * @param {string} pageName + * @param {string} label + */ +export function assertHttpResponsePage(page, pageName, label) { + if (!page) { + const err = new Error(`unknown response page "${pageName}"`); + err.statusCode = 400; + throw err; + } + if (page.kind === "template") { + const err = new Error( + `"${pageName}" is an HTML template; ${label} must reference a response page`, + ); + err.statusCode = 400; + throw err; + } +} diff --git a/packages/server/http-trigger-auth.js b/packages/server/http-trigger-auth.js index 7a7e7e1..c5d1e86 100644 --- a/packages/server/http-trigger-auth.js +++ b/packages/server/http-trigger-auth.js @@ -2,7 +2,7 @@ import { timingSafeEqual } from "node:crypto"; import { kvGet } from "./kv-store.js"; import { getSecretPlaintext } from "./secrets-store.js"; import { getHttpAuthInternal, assertAuthType } from "./http-auths-store.js"; -import { getHttpPageByName, contentTypeForMime } from "./http-pages-store.js"; +import { getHttpPageByName, contentTypeForMime, assertHttpResponsePage } from "./http-pages-store.js"; import { log } from "./logger.js"; /** @@ -246,14 +246,18 @@ export function resolveUnauthorizedSpec(trigger, mechanism) { export async function sendHttpPageOrJson(reply, status, pageName, fallbackBody) { if (pageName) { const page = await getHttpPageByName(pageName); - if (page) { + if (page && page.kind !== "template") { const code = status ?? page.status; return reply .code(code) .type(contentTypeForMime(page.mime)) .send(page.content); } - log.warn({ pageName }, "http page not found; using fallback"); + if (page?.kind === "template") { + log.warn({ pageName }, "http template page cannot be used as HTTP response"); + } else { + log.warn({ pageName }, "http page not found; using fallback"); + } } return reply.code(status).send(fallbackBody ?? { error: "unauthorized" }); } @@ -266,12 +270,16 @@ export async function sendHttpPageOrJson(reply, status, pageName, fallbackBody) */ export async function sendSuccessPage(reply, pageName, fallbackBody) { const page = await getHttpPageByName(pageName); - if (page) { + if (page && page.kind !== "template") { return reply .code(page.status) .type(contentTypeForMime(page.mime)) .send(page.content); } - log.warn({ pageName }, "success page not found; using default JSON"); + if (page?.kind === "template") { + log.warn({ pageName }, "html template page cannot be used as HTTP success response"); + } else { + log.warn({ pageName }, "success page not found; using default JSON"); + } return reply.send(fallbackBody); } diff --git a/packages/server/migrations/20260815070000_http_page_templates.js b/packages/server/migrations/20260815070000_http_page_templates.js new file mode 100644 index 0000000..e1c7157 --- /dev/null +++ b/packages/server/migrations/20260815070000_http_page_templates.js @@ -0,0 +1,63 @@ +const DEFAULT_EMAIL_TEMPLATE = ` + + + + {{title}} + + +

{{title}}

+ {{#message}} +

{{message}}

+ {{/message}} + +
    + {{#items}} +
  • + {{title}} + {{#summary}}

    {{summary}}

    {{/summary}} +
  • + {{/items}} +
+ {{^items}} +

No items.

+ {{/items}} + + +`; + +/** + * @param {import("knex").Knex} knex + */ +export async function up(knex) { + await knex.schema.alterTable("http_pages", (t) => { + t.text("kind").notNullable().defaultTo("response"); + t.integer("system").notNullable().defaultTo(0); + }); + + const now = new Date().toISOString(); + const existing = await knex("http_pages").where({ name: "email-default" }).first(); + if (!existing) { + await knex("http_pages").insert({ + id: "00000000-0000-4000-8000-000000000001", + name: "email-default", + content: DEFAULT_EMAIL_TEMPLATE, + mime: "html", + status: 200, + kind: "template", + system: 1, + created_at: now, + updated_at: now, + }); + } +} + +/** + * @param {import("knex").Knex} knex + */ +export async function down(knex) { + await knex("http_pages").where({ name: "email-default", system: 1 }).del(); + await knex.schema.alterTable("http_pages", (t) => { + t.dropColumn("kind"); + t.dropColumn("system"); + }); +} diff --git a/packages/server/package.json b/packages/server/package.json index 910514b..e5bc384 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -20,6 +20,7 @@ "fastify": "^5.12.0", "jsonata": "^2.2.2", "knex": "^3.3.0", + "mustache": "^4.2.0", "node-cron": "^4.6.0", "node-html-parser": "^9.0.1", "nodemailer": "^9.0.5", diff --git a/packages/server/script-sandbox.js b/packages/server/script-sandbox.js index 6699cae..aa22fc6 100644 --- a/packages/server/script-sandbox.js +++ b/packages/server/script-sandbox.js @@ -8,6 +8,7 @@ import { createKvApi } from "./kv-store.js"; import { createFingerprintApi } from "./script-fingerprint.js"; import { SCRIPTS_DIR } from "./paths.js"; import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js"; +import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js"; import { getSecretPlaintext } from "./secrets-store.js"; const hostRequire = createRequire(import.meta.url); @@ -15,6 +16,7 @@ const hostRequire = createRequire(import.meta.url); const ALLOWED_MODULES = new Set([ "axios", "jsonata", + "mustache", "node-html-parser", "nodemailer", "rss-parser", @@ -324,6 +326,33 @@ function createSecretsApi(owner) { }; } +/** + * Load HTML template pages from the Responses store. + */ +function createResponsesApi() { + return { + /** + * @param {string} name + */ + async getTemplate(name) { + if (typeof name !== "string" || name.length === 0) { + throw new Error("template name is required"); + } + const page = await getHttpTemplateByName(name); + if (!page) { + const any = await getHttpPageByName(name); + if (any && any.kind !== "template") { + throw new Error( + `"${name}" is an HTTP response page, not an HTML template`, + ); + } + throw new Error(`html template "${name}" not found`); + } + return page.content; + }, + }; +} + const $workflowsStub = { async trigger() { throw new Error("workflow runner is not available"); @@ -351,6 +380,7 @@ function createScriptSandbox({ const $kv = createKvApi(workflowName); const $fingerprint = createFingerprintApi($kv); const $secrets = createSecretsApi(owner); + const $responses = createResponsesApi(); const sandbox = { ...pickBuiltins(), log: scriptLog, @@ -359,6 +389,7 @@ function createScriptSandbox({ $kv, $fingerprint, $secrets, + $responses, $workflows, require: createRestrictedRequire($axios), }; diff --git a/packages/server/scripts/render-template.js b/packages/server/scripts/render-template.js new file mode 100644 index 0000000..7870055 --- /dev/null +++ b/packages/server/scripts/render-template.js @@ -0,0 +1,126 @@ +import Mustache from "mustache"; + +/** + * @param {string} html + */ +function htmlToText(html) { + return html + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "\n") + .replace(/<\/(p|div|h[1-6]|li|tr)>/gi, "\n") + .replace(/]*>/gi, "- ") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +/** + * @param {unknown} partialsConfig + */ +async function loadPartials(partialsConfig) { + /** @type {Record} */ + const partials = {}; + if (partialsConfig == null) return partials; + if (typeof partialsConfig !== "object" || Array.isArray(partialsConfig)) { + throw new Error("config.partials must be an object"); + } + + for (const [partialName, pageName] of Object.entries( + /** @type {Record} */ (partialsConfig), + )) { + if (typeof pageName !== "string" || pageName.length === 0) { + throw new Error(`config.partials.${partialName} must be a template page name`); + } + partials[partialName] = await $responses.getTemplate(pageName); + } + return partials; +} + +async function renderTemplate(ctx) { + const templateName = ctx.config?.template; + if (typeof templateName !== "string" || templateName.length === 0) { + throw new Error("config.template is required"); + } + + const vars = ctx.data?.vars ?? ctx.data; + if (vars == null || typeof vars !== "object" || Array.isArray(vars)) { + throw new Error("data.vars must be an object"); + } + + const template = await $responses.getTemplate(templateName); + const partials = await loadPartials(ctx.config?.partials); + + log.info( + { + template: templateName, + partials: Object.keys(partials), + varKeys: Object.keys(vars), + }, + "render-template: rendering mustache template", + ); + + const html = Mustache.render(template, vars, partials); + const text = htmlToText(html); + + return { + html, + text, + template: templateName, + }; +} + +renderTemplate.meta = { + description: + "Render an HTML template from Responses (kind: template) with Mustache and return html + plain-text fallback", + config: { + template: { + type: "string", + required: true, + description: "Responses page name with kind=template", + }, + partials: { + type: "object", + required: false, + description: "Map of partial name to template page name (e.g. { item: email-item })", + }, + }, + input: { + vars: { + type: "object", + required: true, + description: "Mustache view data (title, items, etc.)", + }, + }, + output: { + html: { type: "string", description: "Rendered HTML" }, + text: { type: "string", description: "Plain-text fallback stripped from HTML" }, + template: { type: "string", description: "Template page name used" }, + }, + example: { + data: { + vars: { + title: "Daily digest", + message: "Latest items from your feed.", + items: [ + { + title: "Example post", + link: "https://example.com/post", + summary: "A short summary.", + }, + ], + }, + }, + config: { + template: "email-default", + }, + }, +}; + +export default renderTemplate; diff --git a/packages/server/scripts/send-email.js b/packages/server/scripts/send-email.js index a0c4312..cf88b25 100644 --- a/packages/server/scripts/send-email.js +++ b/packages/server/scripts/send-email.js @@ -147,8 +147,11 @@ async function sendEmail(ctx) { } const text = ctx.data?.text ?? ctx.data?.body ?? ctx.data?.message; - if (typeof text !== "string" || text.length === 0) { - throw new Error("data.text is required (plain-text body)"); + const html = ctx.data?.html; + const hasText = typeof text === "string" && text.length > 0; + const hasHtml = typeof html === "string" && html.length > 0; + if (!hasText && !hasHtml) { + throw new Error("data.text or data.html is required"); } const cc = normalizeRecipients(ctx.data?.cc, "data.cc"); @@ -174,8 +177,9 @@ async function sendEmail(ctx) { from, to, subject, - text, }; + if (hasText) mail.text = text; + if (hasHtml) mail.html = html; if (cc) mail.cc = cc; if (bcc) mail.bcc = bcc; if (replyTo) mail.replyTo = replyTo; @@ -203,9 +207,10 @@ async function sendEmail(ctx) { cc: cc ?? null, bcc: bcc ? "[redacted]" : null, subjectLength: subject.length, - textLength: text.length, + textLength: hasText ? text.length : 0, + htmlLength: hasHtml ? html.length : 0, }, - "send-email: sending plain-text message", + "send-email: sending message", ); const info = await transporter.sendMail(mail); @@ -224,7 +229,7 @@ async function sendEmail(ctx) { } sendEmail.meta = { - description: "Send a plain-text email via SMTP (nodemailer)", + description: "Send an email via SMTP (nodemailer); plain text, HTML, or both", config: { service: { type: "string", @@ -387,9 +392,14 @@ sendEmail.meta = { subject: { type: "string", required: true, description: "Email subject" }, text: { type: "string", - required: true, + required: false, description: "Plain-text body (aliases: body, message)", }, + html: { + type: "string", + required: false, + description: "HTML body (e.g. from render-template.js)", + }, priority: { type: "string", required: false, diff --git a/packages/server/src/api/http-auths.js b/packages/server/src/api/http-auths.js index d579710..c7d85f0 100644 --- a/packages/server/src/api/http-auths.js +++ b/packages/server/src/api/http-auths.js @@ -8,7 +8,7 @@ import { deleteHttpAuth, revealHttpAuthLiterals, } from "../../http-auths-store.js"; -import { getHttpPageByName } from "../../http-pages-store.js"; +import { getHttpPageByName, assertHttpResponsePage } from "../../http-pages-store.js"; /** * @param {import("fastify").FastifyInstance} fastify @@ -62,11 +62,11 @@ export default async function httpAuthsPlugin(fastify) { String(body.unauthorized_response).length > 0 ) { const page = await getHttpPageByName(String(body.unauthorized_response)); - if (!page) { - return reply - .code(400) - .send({ error: `unknown response page "${body.unauthorized_response}"` }); - } + assertHttpResponsePage( + page, + String(body.unauthorized_response), + "unauthorized_response", + ); } const auth = await upsertHttpAuth({ name: String(body.name), diff --git a/packages/server/src/api/http-pages.js b/packages/server/src/api/http-pages.js index 8c7785c..0dd1116 100644 --- a/packages/server/src/api/http-pages.js +++ b/packages/server/src/api/http-pages.js @@ -2,6 +2,7 @@ import { assertPageName, assertMime, assertHttpStatus, + assertPageKind, listHttpPages, getHttpPageById, getHttpPageByName, @@ -37,11 +38,13 @@ export default async function httpPagesPlugin(fastify) { content?: string, mime?: string, status?: number, + kind?: string, }} */ (req.body ?? {}); try { assertPageName(String(body.name ?? "")); assertMime(body.mime); assertHttpStatus(body.status, 200); + const kind = assertPageKind(body.kind, "response"); if (typeof body.content !== "string") { return reply.code(400).send({ error: "content must be a string" }); } @@ -50,6 +53,7 @@ export default async function httpPagesPlugin(fastify) { content: body.content, mime: String(body.mime), status: body.status, + kind, }); return reply.send({ page }); } catch (err) { @@ -63,7 +67,11 @@ export default async function httpPagesPlugin(fastify) { if (!existing) { return reply.code(404).send({ error: "page not found" }); } - await deleteHttpPage(id); + try { + await deleteHttpPage(id); + } catch (err) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } return { ok: true }; }); } diff --git a/packages/server/workflow-http-validate.js b/packages/server/workflow-http-validate.js index 7e5c93e..58a0b06 100644 --- a/packages/server/workflow-http-validate.js +++ b/packages/server/workflow-http-validate.js @@ -2,7 +2,7 @@ * Validate HTTP trigger auth / response fields on workflow save. */ import { assertAuthType, getHttpAuthByName } from "./http-auths-store.js"; -import { getHttpPageByName } from "./http-pages-store.js"; +import { getHttpPageByName, assertHttpResponsePage } from "./http-pages-store.js"; import { authLabel } from "./http-trigger-auth.js"; /** @@ -81,11 +81,7 @@ async function validatePageRef(pageName, label) { throw err; } const page = await getHttpPageByName(pageName); - if (!page) { - const err = new Error(`unknown response page "${pageName}"`); - err.statusCode = 400; - throw err; - } + assertHttpResponsePage(page, pageName, label); } /** diff --git a/packages/web/src/pages/ResponsesPage.jsx b/packages/web/src/pages/ResponsesPage.jsx index 96600af..3515098 100644 --- a/packages/web/src/pages/ResponsesPage.jsx +++ b/packages/web/src/pages/ResponsesPage.jsx @@ -13,8 +13,13 @@ const emptyForm = { content: "", mime: "html", status: 200, + kind: "response", }; +function kindLabel(kind) { + return kind === "template" ? "HTML template" : "HTTP response"; +} + export function ResponsesPage() { const { data: pages = [], isLoading } = useHttpPages(); const upsert = useUpsertHttpPage(); @@ -35,6 +40,8 @@ export function ResponsesPage() { content: p.content, mime: p.mime, status: p.status, + kind: p.kind ?? "response", + system: Boolean(p.system), }); } @@ -49,8 +56,9 @@ export function ResponsesPage() { { name: form.name, content: form.content, - mime: form.mime, + mime: form.kind === "template" ? "html" : form.mime, status: Number(form.status) || 200, + kind: form.kind, }, { onSuccess: closeForm }, ); @@ -66,8 +74,11 @@ export function ResponsesPage() {

- Named HTML/JSON pages for HTTP trigger success or unauthorized responses. Reference them in - YAML as response: name. + Named HTML/JSON pages for HTTP trigger responses, or HTML templates for Mustache + rendering in workflows. HTTP responses use{" "} + response: name; templates use{" "} + render-template.js with{" "} + config.template: name.

{isLoading ? ( @@ -80,6 +91,7 @@ export function ResponsesPage() { Name + Kind Mime Status Updated @@ -89,9 +101,15 @@ export function ResponsesPage() { {pages.map((p) => ( - {p.name} + + {p.name} + {p.system ? ( + system + ) : null} + + {kindLabel(p.kind ?? "response")} {p.mime} - {p.status} + {p.kind === "template" ? "—" : p.status} {formatTime(p.updated_at)}