Merge pull request #5 from nasyarobby/cursor/send-email-script-1315

feat(scripts): add send-email script using nodemailer
This commit is contained in:
2026-08-16 22:27:40 +07:00
committed by GitHub
12 changed files with 869 additions and 44 deletions
+80 -2
View File
@@ -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<boolean>}
*/
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;
}
}
+11 -3
View File
@@ -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,15 +246,19 @@ 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);
}
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);
}
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);
}
@@ -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");
});
}
+2
View File
@@ -20,8 +20,10 @@
"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",
"pino": "^10.3.1",
"pino-roll": "^4.0.0",
"yaml": "^2.9.0"
+38 -1
View File
@@ -8,11 +8,19 @@ 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);
const ALLOWED_MODULES = new Set(["axios", "jsonata", "node-html-parser", "rss-parser"]);
const ALLOWED_MODULES = new Set([
"axios",
"jsonata",
"mustache",
"node-html-parser",
"nodemailer",
"rss-parser",
]);
const BUILTIN_NAMES = [
"Infinity",
@@ -318,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");
@@ -345,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,
@@ -353,6 +389,7 @@ function createScriptSandbox({
$kv,
$fingerprint,
$secrets,
$responses,
$workflows,
require: createRestrictedRequire($axios),
};
+126
View File
@@ -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(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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;
+443
View File
@@ -0,0 +1,443 @@
import nodemailer from "nodemailer";
/**
* @param {unknown} value
* @param {string} label
* @returns {string | undefined}
*/
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<string, unknown>} 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<string, unknown>} */
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<string, unknown>} 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 ?? {};
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 to = normalizeRecipients(ctx.data?.to, "data.to");
if (!to) {
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;
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");
const bcc = normalizeRecipients(ctx.data?.bcc, "data.bcc");
const replyTo =
normalizeRecipients(ctx.data?.replyTo ?? config.replyTo, "replyTo");
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));
}
let password = null;
if (typeof config.passwordSecret === "string" && config.passwordSecret.length > 0) {
password = $secrets.reveal(await $secrets.get(config.passwordSecret));
}
const transportConfig = url ? { ...config, url } : config;
const transporter = nodemailer.createTransport(buildTransportOptions(transportConfig, password));
/** @type {import("nodemailer").SendMailOptions} */
const mail = {
from,
to,
subject,
};
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;
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: hasText ? text.length : 0,
htmlLength: hasHtml ? html.length : 0,
},
"send-email: sending 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,
};
}
sendEmail.meta = {
description: "Send an email via SMTP (nodemailer); plain text, HTML, or both",
config: {
service: {
type: "string",
required: false,
description:
"Well-known provider shortcut (e.g. gmail, outlook365, sendgrid). Alternative to host.",
},
host: {
type: "string",
required: false,
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)",
},
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: false,
description: "SMTP auth username (separate from the visible From address)",
},
from: {
type: "string",
required: false,
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: 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: {
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: 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,
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: {
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.",
},
config: {
host: "smtp.gmail.com",
port: 587,
secure: false,
user: "smtp-login@gmail.com",
from: "notifications@example.com",
passwordSecret: "gmail_app_password",
},
},
};
export default sendEmail;
+6 -6
View File
@@ -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),
+8
View File
@@ -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" });
}
try {
await deleteHttpPage(id);
} catch (err) {
return reply.code(err.statusCode ?? 500).send({ error: err.message });
}
return { ok: true };
});
}
+2 -6
View File
@@ -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);
}
/**
+54 -8
View File
@@ -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() {
</button>
</div>
<p className="text-sm opacity-70">
Named HTML/JSON pages for HTTP trigger success or unauthorized responses. Reference them in
YAML as <code className="font-mono text-xs">response: name</code>.
Named HTML/JSON pages for HTTP trigger responses, or HTML templates for Mustache
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>
{isLoading ? (
@@ -80,6 +91,7 @@ export function ResponsesPage() {
<thead>
<tr>
<th>Name</th>
<th>Kind</th>
<th>Mime</th>
<th>Status</th>
<th>Updated</th>
@@ -89,9 +101,15 @@ export function ResponsesPage() {
<tbody>
{pages.map((p) => (
<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.status}</td>
<td>{p.kind === "template" ? "—" : p.status}</td>
<td className="whitespace-nowrap">{formatTime(p.updated_at)}</td>
<td className="text-right whitespace-nowrap">
<button
@@ -105,7 +123,8 @@ export function ResponsesPage() {
<button
type="button"
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)}
>
<LuTrash2 className="size-4" />
@@ -125,7 +144,7 @@ export function ResponsesPage() {
>
<div className="flex items-center justify-between">
<legend className="fieldset-legend">
{mode === "add" ? "New response page" : `Edit ${form.name}`}
{mode === "add" ? "New page" : `Edit ${form.name}`}
</legend>
<button
type="button"
@@ -146,6 +165,31 @@ export function ResponsesPage() {
title="Letters, numbers, dots, underscores, hyphens"
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>
<select
className="select w-full"
@@ -165,6 +209,8 @@ export function ResponsesPage() {
max={599}
required
/>
</>
) : null}
<label className="label">Content</label>
<textarea
className="textarea w-full font-mono text-sm min-h-40"
@@ -198,7 +244,7 @@ export function ResponsesPage() {
<button
type="button"
className="btn btn-error"
disabled={del.isPending}
disabled={del.isPending || confirmDelete.system}
onClick={() =>
del.mutate(confirmDelete.id, { onSuccess: () => setConfirmDelete(null) })
}
+18
View File
@@ -44,12 +44,18 @@ importers:
knex:
specifier: ^3.3.0
version: 3.3.0(better-sqlite3@13.0.3)
mustache:
specifier: ^4.2.0
version: 4.2.0
node-cron:
specifier: ^4.6.0
version: 4.6.0
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
@@ -1587,6 +1593,10 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mustache@4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1607,6 +1617,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==}
@@ -3387,6 +3401,8 @@ snapshots:
ms@2.1.3: {}
mustache@4.2.0: {}
nanoid@3.3.18: {}
node-addon-api@8.9.2: {}
@@ -3400,6 +3416,8 @@ snapshots:
node-releases@2.0.53: {}
nodemailer@9.0.5: {}
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0