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", }, },