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 <nasyarobby@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-15 01:04:34 +00:00
co-authored by nsrb
parent 5bfbafeb91
commit 13a46188ba
4 changed files with 159 additions and 1 deletions
+1
View File
@@ -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"
+7 -1
View File
@@ -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",
+142
View File
@@ -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;
+9
View File
@@ -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