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 <nasyarobby@gmail.com>
This commit is contained in:
@@ -4,6 +4,9 @@ import { db } from "./db.js";
|
|||||||
const MAX_NAME_LENGTH = 128;
|
const MAX_NAME_LENGTH = 128;
|
||||||
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||||
const ALLOWED_MIME = new Set(["html", "json"]);
|
const ALLOWED_MIME = new Set(["html", "json"]);
|
||||||
|
const ALLOWED_KIND = new Set(["response", "template"]);
|
||||||
|
|
||||||
|
export const DEFAULT_EMAIL_TEMPLATE_NAME = "email-default";
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -41,6 +44,20 @@ export function assertMime(mime) {
|
|||||||
return /** @type {"html" | "json"} */ (m);
|
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
|
* @param {unknown} status
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
@@ -63,6 +80,8 @@ function publicPage(row) {
|
|||||||
content: row.content,
|
content: row.content,
|
||||||
mime: row.mime,
|
mime: row.mime,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
kind: row.kind ?? "response",
|
||||||
|
system: Boolean(row.system),
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_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 pageName = assertPageName(name);
|
||||||
const pageMime = assertMime(mime);
|
const pageMime = assertMime(mime);
|
||||||
|
const pageKind = assertPageKind(kind, "response");
|
||||||
const pageStatus = assertHttpStatus(status, 200);
|
const pageStatus = assertHttpStatus(status, 200);
|
||||||
if (typeof content !== "string") {
|
if (typeof content !== "string") {
|
||||||
const err = new Error("content must be a string");
|
const err = new Error("content must be a string");
|
||||||
err.statusCode = 400;
|
err.statusCode = 400;
|
||||||
throw err;
|
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 now = nowIso();
|
||||||
const existing = await db("http_pages").where({ name: pageName }).first();
|
const existing = await db("http_pages").where({ name: pageName }).first();
|
||||||
|
|
||||||
if (existing) {
|
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")
|
await db("http_pages")
|
||||||
.where({ id: existing.id })
|
.where({ id: existing.id })
|
||||||
.update({
|
.update({
|
||||||
content,
|
content,
|
||||||
mime: pageMime,
|
mime: pageMime,
|
||||||
status: pageStatus,
|
status: pageStatus,
|
||||||
|
kind: Boolean(existing.system) ? existing.kind : pageKind,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
return getHttpPageById(existing.id);
|
return getHttpPageById(existing.id);
|
||||||
@@ -125,6 +173,8 @@ export async function upsertHttpPage({ name, content, mime, status }) {
|
|||||||
content,
|
content,
|
||||||
mime: pageMime,
|
mime: pageMime,
|
||||||
status: pageStatus,
|
status: pageStatus,
|
||||||
|
kind: pageKind,
|
||||||
|
system: 0,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -136,6 +186,13 @@ export async function upsertHttpPage({ name, content, mime, status }) {
|
|||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
export async function deleteHttpPage(id) {
|
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();
|
const n = await db("http_pages").where({ id }).del();
|
||||||
return n > 0;
|
return n > 0;
|
||||||
}
|
}
|
||||||
@@ -148,3 +205,24 @@ export function contentTypeForMime(mime) {
|
|||||||
if (mime === "html") return "text/html; charset=utf-8";
|
if (mime === "html") return "text/html; charset=utf-8";
|
||||||
return "application/json; 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { timingSafeEqual } from "node:crypto";
|
|||||||
import { kvGet } from "./kv-store.js";
|
import { kvGet } from "./kv-store.js";
|
||||||
import { getSecretPlaintext } from "./secrets-store.js";
|
import { getSecretPlaintext } from "./secrets-store.js";
|
||||||
import { getHttpAuthInternal, assertAuthType } from "./http-auths-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";
|
import { log } from "./logger.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -246,15 +246,19 @@ export function resolveUnauthorizedSpec(trigger, mechanism) {
|
|||||||
export async function sendHttpPageOrJson(reply, status, pageName, fallbackBody) {
|
export async function sendHttpPageOrJson(reply, status, pageName, fallbackBody) {
|
||||||
if (pageName) {
|
if (pageName) {
|
||||||
const page = await getHttpPageByName(pageName);
|
const page = await getHttpPageByName(pageName);
|
||||||
if (page) {
|
if (page && page.kind !== "template") {
|
||||||
const code = status ?? page.status;
|
const code = status ?? page.status;
|
||||||
return reply
|
return reply
|
||||||
.code(code)
|
.code(code)
|
||||||
.type(contentTypeForMime(page.mime))
|
.type(contentTypeForMime(page.mime))
|
||||||
.send(page.content);
|
.send(page.content);
|
||||||
}
|
}
|
||||||
|
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");
|
log.warn({ pageName }, "http page not found; using fallback");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return reply.code(status).send(fallbackBody ?? { error: "unauthorized" });
|
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) {
|
export async function sendSuccessPage(reply, pageName, fallbackBody) {
|
||||||
const page = await getHttpPageByName(pageName);
|
const page = await getHttpPageByName(pageName);
|
||||||
if (page) {
|
if (page && page.kind !== "template") {
|
||||||
return reply
|
return reply
|
||||||
.code(page.status)
|
.code(page.status)
|
||||||
.type(contentTypeForMime(page.mime))
|
.type(contentTypeForMime(page.mime))
|
||||||
.send(page.content);
|
.send(page.content);
|
||||||
}
|
}
|
||||||
|
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");
|
log.warn({ pageName }, "success page not found; using default JSON");
|
||||||
|
}
|
||||||
return reply.send(fallbackBody);
|
return reply.send(fallbackBody);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
const DEFAULT_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{title}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>{{title}}</h1>
|
||||||
|
{{#message}}
|
||||||
|
<p>{{message}}</p>
|
||||||
|
{{/message}}
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{{#items}}
|
||||||
|
<li>
|
||||||
|
<a href="{{link}}">{{title}}</a>
|
||||||
|
{{#summary}}<p>{{summary}}</p>{{/summary}}
|
||||||
|
</li>
|
||||||
|
{{/items}}
|
||||||
|
</ul>
|
||||||
|
{{^items}}
|
||||||
|
<p>No items.</p>
|
||||||
|
{{/items}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
"fastify": "^5.12.0",
|
"fastify": "^5.12.0",
|
||||||
"jsonata": "^2.2.2",
|
"jsonata": "^2.2.2",
|
||||||
"knex": "^3.3.0",
|
"knex": "^3.3.0",
|
||||||
|
"mustache": "^4.2.0",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
"node-html-parser": "^9.0.1",
|
"node-html-parser": "^9.0.1",
|
||||||
"nodemailer": "^9.0.5",
|
"nodemailer": "^9.0.5",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createKvApi } from "./kv-store.js";
|
|||||||
import { createFingerprintApi } from "./script-fingerprint.js";
|
import { createFingerprintApi } from "./script-fingerprint.js";
|
||||||
import { SCRIPTS_DIR } from "./paths.js";
|
import { SCRIPTS_DIR } from "./paths.js";
|
||||||
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
|
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
|
||||||
|
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
|
||||||
import { getSecretPlaintext } from "./secrets-store.js";
|
import { getSecretPlaintext } from "./secrets-store.js";
|
||||||
|
|
||||||
const hostRequire = createRequire(import.meta.url);
|
const hostRequire = createRequire(import.meta.url);
|
||||||
@@ -15,6 +16,7 @@ const hostRequire = createRequire(import.meta.url);
|
|||||||
const ALLOWED_MODULES = new Set([
|
const ALLOWED_MODULES = new Set([
|
||||||
"axios",
|
"axios",
|
||||||
"jsonata",
|
"jsonata",
|
||||||
|
"mustache",
|
||||||
"node-html-parser",
|
"node-html-parser",
|
||||||
"nodemailer",
|
"nodemailer",
|
||||||
"rss-parser",
|
"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 = {
|
const $workflowsStub = {
|
||||||
async trigger() {
|
async trigger() {
|
||||||
throw new Error("workflow runner is not available");
|
throw new Error("workflow runner is not available");
|
||||||
@@ -351,6 +380,7 @@ function createScriptSandbox({
|
|||||||
const $kv = createKvApi(workflowName);
|
const $kv = createKvApi(workflowName);
|
||||||
const $fingerprint = createFingerprintApi($kv);
|
const $fingerprint = createFingerprintApi($kv);
|
||||||
const $secrets = createSecretsApi(owner);
|
const $secrets = createSecretsApi(owner);
|
||||||
|
const $responses = createResponsesApi();
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
...pickBuiltins(),
|
...pickBuiltins(),
|
||||||
log: scriptLog,
|
log: scriptLog,
|
||||||
@@ -359,6 +389,7 @@ function createScriptSandbox({
|
|||||||
$kv,
|
$kv,
|
||||||
$fingerprint,
|
$fingerprint,
|
||||||
$secrets,
|
$secrets,
|
||||||
|
$responses,
|
||||||
$workflows,
|
$workflows,
|
||||||
require: createRestrictedRequire($axios),
|
require: createRestrictedRequire($axios),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import Mustache from "mustache";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} html
|
||||||
|
*/
|
||||||
|
function htmlToText(html) {
|
||||||
|
return html
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||||
|
.replace(/<br\s*\/?>/gi, "\n")
|
||||||
|
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, "\n")
|
||||||
|
.replace(/<li[^>]*>/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<string, string>} */
|
||||||
|
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<string, unknown>} */ (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;
|
||||||
@@ -147,8 +147,11 @@ async function sendEmail(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const text = ctx.data?.text ?? ctx.data?.body ?? ctx.data?.message;
|
const text = ctx.data?.text ?? ctx.data?.body ?? ctx.data?.message;
|
||||||
if (typeof text !== "string" || text.length === 0) {
|
const html = ctx.data?.html;
|
||||||
throw new Error("data.text is required (plain-text body)");
|
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");
|
const cc = normalizeRecipients(ctx.data?.cc, "data.cc");
|
||||||
@@ -174,8 +177,9 @@ async function sendEmail(ctx) {
|
|||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
text,
|
|
||||||
};
|
};
|
||||||
|
if (hasText) mail.text = text;
|
||||||
|
if (hasHtml) mail.html = html;
|
||||||
if (cc) mail.cc = cc;
|
if (cc) mail.cc = cc;
|
||||||
if (bcc) mail.bcc = bcc;
|
if (bcc) mail.bcc = bcc;
|
||||||
if (replyTo) mail.replyTo = replyTo;
|
if (replyTo) mail.replyTo = replyTo;
|
||||||
@@ -203,9 +207,10 @@ async function sendEmail(ctx) {
|
|||||||
cc: cc ?? null,
|
cc: cc ?? null,
|
||||||
bcc: bcc ? "[redacted]" : null,
|
bcc: bcc ? "[redacted]" : null,
|
||||||
subjectLength: subject.length,
|
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);
|
const info = await transporter.sendMail(mail);
|
||||||
@@ -224,7 +229,7 @@ async function sendEmail(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendEmail.meta = {
|
sendEmail.meta = {
|
||||||
description: "Send a plain-text email via SMTP (nodemailer)",
|
description: "Send an email via SMTP (nodemailer); plain text, HTML, or both",
|
||||||
config: {
|
config: {
|
||||||
service: {
|
service: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -387,9 +392,14 @@ sendEmail.meta = {
|
|||||||
subject: { type: "string", required: true, description: "Email subject" },
|
subject: { type: "string", required: true, description: "Email subject" },
|
||||||
text: {
|
text: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: false,
|
||||||
description: "Plain-text body (aliases: body, message)",
|
description: "Plain-text body (aliases: body, message)",
|
||||||
},
|
},
|
||||||
|
html: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description: "HTML body (e.g. from render-template.js)",
|
||||||
|
},
|
||||||
priority: {
|
priority: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
deleteHttpAuth,
|
deleteHttpAuth,
|
||||||
revealHttpAuthLiterals,
|
revealHttpAuthLiterals,
|
||||||
} from "../../http-auths-store.js";
|
} 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
|
* @param {import("fastify").FastifyInstance} fastify
|
||||||
@@ -62,11 +62,11 @@ export default async function httpAuthsPlugin(fastify) {
|
|||||||
String(body.unauthorized_response).length > 0
|
String(body.unauthorized_response).length > 0
|
||||||
) {
|
) {
|
||||||
const page = await getHttpPageByName(String(body.unauthorized_response));
|
const page = await getHttpPageByName(String(body.unauthorized_response));
|
||||||
if (!page) {
|
assertHttpResponsePage(
|
||||||
return reply
|
page,
|
||||||
.code(400)
|
String(body.unauthorized_response),
|
||||||
.send({ error: `unknown response page "${body.unauthorized_response}"` });
|
"unauthorized_response",
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
const auth = await upsertHttpAuth({
|
const auth = await upsertHttpAuth({
|
||||||
name: String(body.name),
|
name: String(body.name),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
assertPageName,
|
assertPageName,
|
||||||
assertMime,
|
assertMime,
|
||||||
assertHttpStatus,
|
assertHttpStatus,
|
||||||
|
assertPageKind,
|
||||||
listHttpPages,
|
listHttpPages,
|
||||||
getHttpPageById,
|
getHttpPageById,
|
||||||
getHttpPageByName,
|
getHttpPageByName,
|
||||||
@@ -37,11 +38,13 @@ export default async function httpPagesPlugin(fastify) {
|
|||||||
content?: string,
|
content?: string,
|
||||||
mime?: string,
|
mime?: string,
|
||||||
status?: number,
|
status?: number,
|
||||||
|
kind?: string,
|
||||||
}} */ (req.body ?? {});
|
}} */ (req.body ?? {});
|
||||||
try {
|
try {
|
||||||
assertPageName(String(body.name ?? ""));
|
assertPageName(String(body.name ?? ""));
|
||||||
assertMime(body.mime);
|
assertMime(body.mime);
|
||||||
assertHttpStatus(body.status, 200);
|
assertHttpStatus(body.status, 200);
|
||||||
|
const kind = assertPageKind(body.kind, "response");
|
||||||
if (typeof body.content !== "string") {
|
if (typeof body.content !== "string") {
|
||||||
return reply.code(400).send({ error: "content must be a 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,
|
content: body.content,
|
||||||
mime: String(body.mime),
|
mime: String(body.mime),
|
||||||
status: body.status,
|
status: body.status,
|
||||||
|
kind,
|
||||||
});
|
});
|
||||||
return reply.send({ page });
|
return reply.send({ page });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -63,7 +67,11 @@ export default async function httpPagesPlugin(fastify) {
|
|||||||
if (!existing) {
|
if (!existing) {
|
||||||
return reply.code(404).send({ error: "page not found" });
|
return reply.code(404).send({ error: "page not found" });
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await deleteHttpPage(id);
|
await deleteHttpPage(id);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(err.statusCode ?? 500).send({ error: err.message });
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Validate HTTP trigger auth / response fields on workflow save.
|
* Validate HTTP trigger auth / response fields on workflow save.
|
||||||
*/
|
*/
|
||||||
import { assertAuthType, getHttpAuthByName } from "./http-auths-store.js";
|
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";
|
import { authLabel } from "./http-trigger-auth.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,11 +81,7 @@ async function validatePageRef(pageName, label) {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const page = await getHttpPageByName(pageName);
|
const page = await getHttpPageByName(pageName);
|
||||||
if (!page) {
|
assertHttpResponsePage(page, pageName, label);
|
||||||
const err = new Error(`unknown response page "${pageName}"`);
|
|
||||||
err.statusCode = 400;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ const emptyForm = {
|
|||||||
content: "",
|
content: "",
|
||||||
mime: "html",
|
mime: "html",
|
||||||
status: 200,
|
status: 200,
|
||||||
|
kind: "response",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function kindLabel(kind) {
|
||||||
|
return kind === "template" ? "HTML template" : "HTTP response";
|
||||||
|
}
|
||||||
|
|
||||||
export function ResponsesPage() {
|
export function ResponsesPage() {
|
||||||
const { data: pages = [], isLoading } = useHttpPages();
|
const { data: pages = [], isLoading } = useHttpPages();
|
||||||
const upsert = useUpsertHttpPage();
|
const upsert = useUpsertHttpPage();
|
||||||
@@ -35,6 +40,8 @@ export function ResponsesPage() {
|
|||||||
content: p.content,
|
content: p.content,
|
||||||
mime: p.mime,
|
mime: p.mime,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
|
kind: p.kind ?? "response",
|
||||||
|
system: Boolean(p.system),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +56,9 @@ export function ResponsesPage() {
|
|||||||
{
|
{
|
||||||
name: form.name,
|
name: form.name,
|
||||||
content: form.content,
|
content: form.content,
|
||||||
mime: form.mime,
|
mime: form.kind === "template" ? "html" : form.mime,
|
||||||
status: Number(form.status) || 200,
|
status: Number(form.status) || 200,
|
||||||
|
kind: form.kind,
|
||||||
},
|
},
|
||||||
{ onSuccess: closeForm },
|
{ onSuccess: closeForm },
|
||||||
);
|
);
|
||||||
@@ -66,8 +74,11 @@ export function ResponsesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm opacity-70">
|
<p className="text-sm opacity-70">
|
||||||
Named HTML/JSON pages for HTTP trigger success or unauthorized responses. Reference them in
|
Named HTML/JSON pages for HTTP trigger responses, or HTML templates for Mustache
|
||||||
YAML as <code className="font-mono text-xs">response: name</code>.
|
rendering in workflows. HTTP responses use{" "}
|
||||||
|
<code className="font-mono text-xs">response: name</code>; templates use{" "}
|
||||||
|
<code className="font-mono text-xs">render-template.js</code> with{" "}
|
||||||
|
<code className="font-mono text-xs">config.template: name</code>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -80,6 +91,7 @@ export function ResponsesPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
|
<th>Kind</th>
|
||||||
<th>Mime</th>
|
<th>Mime</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Updated</th>
|
<th>Updated</th>
|
||||||
@@ -89,9 +101,15 @@ export function ResponsesPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{pages.map((p) => (
|
{pages.map((p) => (
|
||||||
<tr key={p.id} className="hover">
|
<tr key={p.id} className="hover">
|
||||||
<td className="font-mono">{p.name}</td>
|
<td className="font-mono">
|
||||||
|
{p.name}
|
||||||
|
{p.system ? (
|
||||||
|
<span className="badge badge-ghost badge-xs ml-2">system</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>{kindLabel(p.kind ?? "response")}</td>
|
||||||
<td>{p.mime}</td>
|
<td>{p.mime}</td>
|
||||||
<td>{p.status}</td>
|
<td>{p.kind === "template" ? "—" : p.status}</td>
|
||||||
<td className="whitespace-nowrap">{formatTime(p.updated_at)}</td>
|
<td className="whitespace-nowrap">{formatTime(p.updated_at)}</td>
|
||||||
<td className="text-right whitespace-nowrap">
|
<td className="text-right whitespace-nowrap">
|
||||||
<button
|
<button
|
||||||
@@ -105,7 +123,8 @@ export function ResponsesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-xs text-error"
|
className="btn btn-ghost btn-xs text-error"
|
||||||
title="Delete"
|
title={p.system ? "System pages cannot be deleted" : "Delete"}
|
||||||
|
disabled={p.system}
|
||||||
onClick={() => setConfirmDelete(p)}
|
onClick={() => setConfirmDelete(p)}
|
||||||
>
|
>
|
||||||
<LuTrash2 className="size-4" />
|
<LuTrash2 className="size-4" />
|
||||||
@@ -125,7 +144,7 @@ export function ResponsesPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<legend className="fieldset-legend">
|
<legend className="fieldset-legend">
|
||||||
{mode === "add" ? "New response page" : `Edit ${form.name}`}
|
{mode === "add" ? "New page" : `Edit ${form.name}`}
|
||||||
</legend>
|
</legend>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -146,6 +165,31 @@ export function ResponsesPage() {
|
|||||||
title="Letters, numbers, dots, underscores, hyphens"
|
title="Letters, numbers, dots, underscores, hyphens"
|
||||||
disabled={mode === "edit"}
|
disabled={mode === "edit"}
|
||||||
/>
|
/>
|
||||||
|
<label className="label">Kind</label>
|
||||||
|
<select
|
||||||
|
className="select w-full"
|
||||||
|
value={form.kind}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
kind: e.target.value,
|
||||||
|
mime: e.target.value === "template" ? "html" : form.mime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={mode === "edit" && form.system}
|
||||||
|
>
|
||||||
|
<option value="response">HTTP response</option>
|
||||||
|
<option value="template">HTML template</option>
|
||||||
|
</select>
|
||||||
|
{form.kind === "template" ? (
|
||||||
|
<p className="text-xs opacity-70">
|
||||||
|
Templates use Mustache syntax (<code>{"{{title}}"}</code>,{" "}
|
||||||
|
<code>{"{{#items}}"}</code>) and are rendered by{" "}
|
||||||
|
<code className="font-mono">render-template.js</code>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{form.kind === "response" ? (
|
||||||
|
<>
|
||||||
<label className="label">Mime</label>
|
<label className="label">Mime</label>
|
||||||
<select
|
<select
|
||||||
className="select w-full"
|
className="select w-full"
|
||||||
@@ -165,6 +209,8 @@ export function ResponsesPage() {
|
|||||||
max={599}
|
max={599}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<label className="label">Content</label>
|
<label className="label">Content</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="textarea w-full font-mono text-sm min-h-40"
|
className="textarea w-full font-mono text-sm min-h-40"
|
||||||
@@ -198,7 +244,7 @@ export function ResponsesPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-error"
|
className="btn btn-error"
|
||||||
disabled={del.isPending}
|
disabled={del.isPending || confirmDelete.system}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
del.mutate(confirmDelete.id, { onSuccess: () => setConfirmDelete(null) })
|
del.mutate(confirmDelete.id, { onSuccess: () => setConfirmDelete(null) })
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+9
@@ -44,6 +44,9 @@ importers:
|
|||||||
knex:
|
knex:
|
||||||
specifier: ^3.3.0
|
specifier: ^3.3.0
|
||||||
version: 3.3.0(better-sqlite3@13.0.3)
|
version: 3.3.0(better-sqlite3@13.0.3)
|
||||||
|
mustache:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^4.6.0
|
specifier: ^4.6.0
|
||||||
version: 4.6.0
|
version: 4.6.0
|
||||||
@@ -1590,6 +1593,10 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
mustache@4.2.0:
|
||||||
|
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
nanoid@3.3.18:
|
nanoid@3.3.18:
|
||||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -3394,6 +3401,8 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
mustache@4.2.0: {}
|
||||||
|
|
||||||
nanoid@3.3.18: {}
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
node-addon-api@8.9.2: {}
|
node-addon-api@8.9.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user