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