From 13a46188ba21842300a24893e03e6b3c48a684f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 01:04:34 +0000 Subject: [PATCH 1/3] feat(scripts): add send-email script using nodemailer Add a sandboxed send-email.js workflow script with SMTP settings exposed via script config and password loaded from named secrets. Allow nodemailer in the script sandbox require whitelist. Co-authored-by: Nasyarobby Putra --- packages/server/package.json | 1 + packages/server/script-sandbox.js | 8 +- packages/server/scripts/send-email.js | 142 ++++++++++++++++++++++++++ pnpm-lock.yaml | 9 ++ 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 packages/server/scripts/send-email.js diff --git a/packages/server/package.json b/packages/server/package.json index a6a2657..910514b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -22,6 +22,7 @@ "knex": "^3.3.0", "node-cron": "^4.6.0", "node-html-parser": "^9.0.1", + "nodemailer": "^9.0.5", "pino": "^10.3.1", "pino-roll": "^4.0.0", "yaml": "^2.9.0" diff --git a/packages/server/script-sandbox.js b/packages/server/script-sandbox.js index ee5f662..6699cae 100644 --- a/packages/server/script-sandbox.js +++ b/packages/server/script-sandbox.js @@ -12,7 +12,13 @@ import { getSecretPlaintext } from "./secrets-store.js"; const hostRequire = createRequire(import.meta.url); -const ALLOWED_MODULES = new Set(["axios", "jsonata", "node-html-parser", "rss-parser"]); +const ALLOWED_MODULES = new Set([ + "axios", + "jsonata", + "node-html-parser", + "nodemailer", + "rss-parser", +]); const BUILTIN_NAMES = [ "Infinity", diff --git a/packages/server/scripts/send-email.js b/packages/server/scripts/send-email.js new file mode 100644 index 0000000..e20a4d7 --- /dev/null +++ b/packages/server/scripts/send-email.js @@ -0,0 +1,142 @@ +import nodemailer from "nodemailer"; + +/** + * @param {import("nodemailer").TransportOptions} transportOptions + */ +function createTransport(transportOptions) { + return nodemailer.createTransport(transportOptions); +} + +async function sendEmail(ctx) { + const config = ctx.config ?? {}; + const host = config.host; + if (typeof host !== "string" || host.length === 0) { + throw new Error("config.host is required"); + } + + const user = config.user; + if (typeof user !== "string" || user.length === 0) { + throw new Error("config.user is required"); + } + + const passwordSecret = config.passwordSecret; + if (typeof passwordSecret !== "string" || passwordSecret.length === 0) { + throw new Error("config.passwordSecret is required"); + } + + const to = ctx.data?.to; + if (typeof to !== "string" || to.length === 0) { + throw new Error("data.to is required"); + } + + const subject = ctx.data?.subject; + if (typeof subject !== "string" || subject.length === 0) { + throw new Error("data.subject is required"); + } + + 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 password = $secrets.reveal(await $secrets.get(passwordSecret)); + const from = + typeof config.from === "string" && config.from.length > 0 ? config.from : user; + const port = Number(config.port ?? 587); + const secure = config.secure === true; + + log.info( + { host, port, secure, from, to, subjectLength: subject.length, textLength: text.length }, + "send-email: sending plain-text message", + ); + + const transporter = createTransport({ + host, + port, + secure, + auth: { user, pass: password }, + }); + + const info = await transporter.sendMail({ + from, + to, + subject, + text, + }); + + log.info({ messageId: info.messageId }, "send-email: message sent"); + + return { + sent: true, + messageId: info.messageId ?? null, + to, + subject, + }; +} + +sendEmail.meta = { + description: "Send a plain-text email via SMTP (nodemailer)", + config: { + host: { + type: "string", + required: true, + description: "SMTP server hostname (e.g. smtp.gmail.com)", + }, + port: { + type: "number", + default: 587, + description: "SMTP port (587 for STARTTLS, 465 for SSL)", + }, + secure: { + type: "boolean", + default: false, + description: "Use TLS on connect (true for port 465)", + }, + user: { + type: "string", + required: true, + description: "SMTP auth username (usually the sender email)", + }, + from: { + type: "string", + required: false, + description: "From address (defaults to user)", + }, + passwordSecret: { + type: "string", + required: true, + description: "Named secret holding the SMTP password or app password", + }, + }, + input: { + to: { type: "string", required: true, description: "Recipient email address" }, + subject: { type: "string", required: true, description: "Email subject" }, + text: { + type: "string", + required: true, + description: "Plain-text body (aliases: body, message)", + }, + }, + output: { + sent: { type: "boolean", description: "Whether the message was sent" }, + messageId: { type: "string", description: "SMTP message id when available" }, + to: { type: "string" }, + subject: { type: "string" }, + }, + example: { + data: { + to: "recipient@example.com", + subject: "Hello from scrunner", + text: "This is a plain-text test message.", + }, + config: { + host: "smtp.gmail.com", + port: 587, + secure: false, + user: "you@gmail.com", + passwordSecret: "gmail_app_password", + }, + }, +}; + +export default sendEmail; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18680c5..3fa30d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: node-html-parser: specifier: ^9.0.1 version: 9.0.1 + nodemailer: + specifier: ^9.0.5 + version: 9.0.5 pino: specifier: ^10.3.1 version: 10.3.1 @@ -1607,6 +1610,10 @@ packages: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} + nodemailer@9.0.5: + resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} + engines: {node: '>=6.0.0'} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -3400,6 +3407,8 @@ snapshots: node-releases@2.0.53: {} + nodemailer@9.0.5: {} + nth-check@2.1.1: dependencies: boolbase: 1.0.0 From 23ce72fec2056d85588d28cc47fc1149566850f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 01:10:37 +0000 Subject: [PATCH 2/3] feat(scripts): expand send-email SMTP config and recipients Expose broader nodemailer transport options, require a separate from address, and support optional cc/bcc plus reply-to and custom headers. Co-authored-by: Nasyarobby Putra --- packages/server/scripts/send-email.js | 377 +++++++++++++++++++++++--- 1 file changed, 334 insertions(+), 43 deletions(-) diff --git a/packages/server/scripts/send-email.js b/packages/server/scripts/send-email.js index e20a4d7..a0c4312 100644 --- a/packages/server/scripts/send-email.js +++ b/packages/server/scripts/send-email.js @@ -1,31 +1,143 @@ import nodemailer from "nodemailer"; /** - * @param {import("nodemailer").TransportOptions} transportOptions + * @param {unknown} value + * @param {string} label + * @returns {string | undefined} */ -function createTransport(transportOptions) { - return nodemailer.createTransport(transportOptions); +function normalizeRecipients(value, label) { + if (value == null || value === "") return undefined; + if (typeof value === "string") return value; + if (Array.isArray(value)) { + const items = value.filter((item) => typeof item === "string" && item.length > 0); + if (items.length === 0) return undefined; + if (items.length !== value.length) { + throw new Error(`${label} must be a string or array of non-empty strings`); + } + return items.join(", "); + } + throw new Error(`${label} must be a string or array of strings`); +} + +/** + * @param {Record} config + * @param {string | null} password + */ +function buildTransportOptions(config, password) { + /** @type {import("nodemailer").TransportOptions} */ + const transport = {}; + + if (typeof config.service === "string" && config.service.length > 0) { + transport.service = config.service; + } + if (typeof config.url === "string" && config.url.length > 0) { + transport.url = config.url; + } + if (typeof config.host === "string" && config.host.length > 0) { + transport.host = config.host; + } + if (config.port != null && config.port !== "") { + transport.port = Number(config.port); + } + if (config.secure === true) transport.secure = true; + if (config.requireTLS === true) transport.requireTLS = true; + if (config.ignoreTLS === true) transport.ignoreTLS = true; + if (typeof config.name === "string" && config.name.length > 0) { + transport.name = config.name; + } + if (config.connectionTimeout != null && config.connectionTimeout !== "") { + transport.connectionTimeout = Number(config.connectionTimeout); + } + if (config.greetingTimeout != null && config.greetingTimeout !== "") { + transport.greetingTimeout = Number(config.greetingTimeout); + } + if (config.socketTimeout != null && config.socketTimeout !== "") { + transport.socketTimeout = Number(config.socketTimeout); + } + if (typeof config.authMethod === "string" && config.authMethod.length > 0) { + transport.authMethod = config.authMethod; + } + if (config.tls != null && typeof config.tls === "object" && !Array.isArray(config.tls)) { + transport.tls = config.tls; + } + if (config.pool === true) { + transport.pool = true; + if (config.maxConnections != null && config.maxConnections !== "") { + transport.maxConnections = Number(config.maxConnections); + } + if (config.maxMessages != null && config.maxMessages !== "") { + transport.maxMessages = Number(config.maxMessages); + } + if (config.rateDelta != null && config.rateDelta !== "") { + transport.rateDelta = Number(config.rateDelta); + } + if (config.rateLimit != null && config.rateLimit !== "") { + transport.rateLimit = Number(config.rateLimit); + } + } + + const user = config.user; + if (typeof user === "string" && user.length > 0) { + /** @type {Record} */ + const auth = { user }; + if (typeof password === "string" && password.length > 0) { + auth.pass = password; + } + if (typeof config.authType === "string" && config.authType.length > 0) { + auth.type = config.authType; + } + if (typeof config.authMethod === "string" && config.authMethod.length > 0) { + auth.method = config.authMethod; + } + transport.auth = auth; + } + + return transport; +} + +/** + * @param {Record} config + */ +function assertTransportConfig(config) { + const hasEndpoint = + (typeof config.service === "string" && config.service.length > 0) || + (typeof config.host === "string" && config.host.length > 0) || + (typeof config.url === "string" && config.url.length > 0) || + (typeof config.urlSecret === "string" && config.urlSecret.length > 0); + + if (!hasEndpoint) { + throw new Error("config.service, config.host, config.url, or config.urlSecret is required"); + } + + const hasUrl = typeof config.url === "string" && config.url.length > 0; + const hasUrlSecret = typeof config.urlSecret === "string" && config.urlSecret.length > 0; + const hasUser = typeof config.user === "string" && config.user.length > 0; + const hasPasswordSecret = + typeof config.passwordSecret === "string" && config.passwordSecret.length > 0; + + if (!hasUrl && !hasUrlSecret && !hasUser) { + throw new Error("config.user is required unless config.url or config.urlSecret is set"); + } + if (!hasUrl && !hasUrlSecret && !hasPasswordSecret) { + throw new Error("config.passwordSecret is required unless config.url or config.urlSecret is set"); + } } async function sendEmail(ctx) { const config = ctx.config ?? {}; - const host = config.host; - if (typeof host !== "string" || host.length === 0) { - throw new Error("config.host is required"); + assertTransportConfig(config); + + const fromConfig = + typeof config.from === "string" && config.from.length > 0 ? config.from : undefined; + const fromData = + typeof ctx.data?.from === "string" && ctx.data.from.length > 0 ? ctx.data.from : undefined; + const from = fromData ?? fromConfig; + if (!from) { + throw new Error("data.from or config.from is required (sender email address)"); } - const user = config.user; - if (typeof user !== "string" || user.length === 0) { - throw new Error("config.user is required"); - } - - const passwordSecret = config.passwordSecret; - if (typeof passwordSecret !== "string" || passwordSecret.length === 0) { - throw new Error("config.passwordSecret is required"); - } - - const to = ctx.data?.to; - if (typeof to !== "string" || to.length === 0) { + const to = normalizeRecipients(ctx.data?.to, "data.to"); + if (!to) { throw new Error("data.to is required"); } @@ -39,37 +151,74 @@ async function sendEmail(ctx) { throw new Error("data.text is required (plain-text body)"); } - const password = $secrets.reveal(await $secrets.get(passwordSecret)); - const from = - typeof config.from === "string" && config.from.length > 0 ? config.from : user; - const port = Number(config.port ?? 587); - const secure = config.secure === true; + const cc = normalizeRecipients(ctx.data?.cc, "data.cc"); + const bcc = normalizeRecipients(ctx.data?.bcc, "data.bcc"); + const replyTo = + normalizeRecipients(ctx.data?.replyTo ?? config.replyTo, "replyTo"); - log.info( - { host, port, secure, from, to, subjectLength: subject.length, textLength: text.length }, - "send-email: sending plain-text message", - ); + let url = typeof config.url === "string" && config.url.length > 0 ? config.url : undefined; + if (!url && typeof config.urlSecret === "string" && config.urlSecret.length > 0) { + url = $secrets.reveal(await $secrets.get(config.urlSecret)); + } - const transporter = createTransport({ - host, - port, - secure, - auth: { user, pass: password }, - }); + let password = null; + if (typeof config.passwordSecret === "string" && config.passwordSecret.length > 0) { + password = $secrets.reveal(await $secrets.get(config.passwordSecret)); + } - const info = await transporter.sendMail({ + const transportConfig = url ? { ...config, url } : config; + const transporter = nodemailer.createTransport(buildTransportOptions(transportConfig, password)); + + /** @type {import("nodemailer").SendMailOptions} */ + const mail = { from, to, subject, text, - }); + }; + if (cc) mail.cc = cc; + if (bcc) mail.bcc = bcc; + if (replyTo) mail.replyTo = replyTo; + + if (typeof config.priority === "string" && config.priority.length > 0) { + mail.priority = config.priority; + } else if (typeof ctx.data?.priority === "string" && ctx.data.priority.length > 0) { + mail.priority = ctx.data.priority; + } + + if (ctx.data?.headers != null && typeof ctx.data.headers === "object" && !Array.isArray(ctx.data.headers)) { + mail.headers = ctx.data.headers; + } else if (config.headers != null && typeof config.headers === "object" && !Array.isArray(config.headers)) { + mail.headers = config.headers; + } + + log.info( + { + service: config.service, + host: config.host, + port: config.port, + secure: config.secure === true, + from, + to, + cc: cc ?? null, + bcc: bcc ? "[redacted]" : null, + subjectLength: subject.length, + textLength: text.length, + }, + "send-email: sending plain-text message", + ); + + const info = await transporter.sendMail(mail); log.info({ messageId: info.messageId }, "send-email: message sent"); return { sent: true, messageId: info.messageId ?? null, + from, to, + cc: cc ?? null, + bcc: bcc ?? null, subject, }; } @@ -77,9 +226,15 @@ async function sendEmail(ctx) { sendEmail.meta = { description: "Send a plain-text email via SMTP (nodemailer)", config: { + service: { + type: "string", + required: false, + description: + "Well-known provider shortcut (e.g. gmail, outlook365, sendgrid). Alternative to host.", + }, host: { type: "string", - required: true, + required: false, description: "SMTP server hostname (e.g. smtp.gmail.com)", }, port: { @@ -92,40 +247,175 @@ sendEmail.meta = { default: false, description: "Use TLS on connect (true for port 465)", }, + requireTLS: { + type: "boolean", + required: false, + description: "Require STARTTLS upgrade", + }, + ignoreTLS: { + type: "boolean", + required: false, + description: "Disable STARTTLS even if the server supports it", + }, + name: { + type: "string", + required: false, + description: "Client EHLO hostname", + }, user: { type: "string", - required: true, - description: "SMTP auth username (usually the sender email)", + required: false, + description: "SMTP auth username (separate from the visible From address)", }, from: { type: "string", required: false, - description: "From address (defaults to user)", + description: "Default sender email address (overridden by data.from)", + }, + replyTo: { + type: "string", + required: false, + description: "Default Reply-To address (overridden by data.replyTo)", }, passwordSecret: { type: "string", - required: true, + required: false, description: "Named secret holding the SMTP password or app password", }, + url: { + type: "string", + required: false, + description: "Full SMTP connection URL (smtp:// or smtps://); prefer urlSecret in production", + }, + urlSecret: { + type: "string", + required: false, + description: "Named secret holding a full SMTP connection URL", + }, + authType: { + type: "string", + required: false, + description: "SMTP auth type (e.g. LOGIN, OAUTH2)", + }, + authMethod: { + type: "string", + required: false, + description: "SMTP auth method override (e.g. LOGIN, PLAIN, CRAM-MD5)", + }, + tls: { + type: "object", + required: false, + description: "TLS options (e.g. rejectUnauthorized, minVersion, ciphers)", + }, + connectionTimeout: { + type: "number", + required: false, + description: "Socket connection timeout in milliseconds", + }, + greetingTimeout: { + type: "number", + required: false, + description: "SMTP greeting timeout in milliseconds", + }, + socketTimeout: { + type: "number", + required: false, + description: "Socket inactivity timeout in milliseconds", + }, + pool: { + type: "boolean", + required: false, + description: "Reuse SMTP connections", + }, + maxConnections: { + type: "number", + required: false, + description: "Max pooled connections when pool is enabled", + }, + maxMessages: { + type: "number", + required: false, + description: "Max messages per pooled connection", + }, + rateDelta: { + type: "number", + required: false, + description: "Rate limit window in milliseconds when pool is enabled", + }, + rateLimit: { + type: "number", + required: false, + description: "Max messages per rateDelta when pool is enabled", + }, + priority: { + type: "string", + required: false, + description: "Default message priority: high, normal, or low", + }, + headers: { + type: "object", + required: false, + description: "Default custom headers object", + }, }, input: { - to: { type: "string", required: true, description: "Recipient email address" }, + from: { + type: "string", + required: false, + description: "Sender email address (overrides config.from)", + }, + to: { + type: "string", + required: true, + description: "Recipient(s); string or array of strings", + }, + cc: { + type: "string", + required: false, + description: "CC recipient(s); string or array of strings", + }, + bcc: { + type: "string", + required: false, + description: "BCC recipient(s); string or array of strings", + }, + replyTo: { + type: "string", + required: false, + description: "Reply-To address (overrides config.replyTo)", + }, subject: { type: "string", required: true, description: "Email subject" }, text: { type: "string", required: true, description: "Plain-text body (aliases: body, message)", }, + priority: { + type: "string", + required: false, + description: "Message priority: high, normal, or low", + }, + headers: { + type: "object", + required: false, + description: "Per-message custom headers", + }, }, output: { sent: { type: "boolean", description: "Whether the message was sent" }, messageId: { type: "string", description: "SMTP message id when available" }, + from: { type: "string" }, to: { type: "string" }, + cc: { type: "string" }, + bcc: { type: "string" }, subject: { type: "string" }, }, example: { data: { - to: "recipient@example.com", + from: "notifications@example.com", + to: ["recipient@example.com", "other@example.com"], + cc: "manager@example.com", + bcc: "audit@example.com", subject: "Hello from scrunner", text: "This is a plain-text test message.", }, @@ -133,7 +423,8 @@ sendEmail.meta = { host: "smtp.gmail.com", port: 587, secure: false, - user: "you@gmail.com", + user: "smtp-login@gmail.com", + from: "notifications@example.com", passwordSecret: "gmail_app_password", }, }, From 46e2cbb83be4a7515c54176731a1540528eec2c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 01:44:51 +0000 Subject: [PATCH 3/3] 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)}