feat(scripts): expand send-email SMTP config and recipients
Expose broader nodemailer transport options, require a separate from address, and support optional cc/bcc plus reply-to and custom headers. Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
@@ -1,31 +1,143 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("nodemailer").TransportOptions} transportOptions
|
* @param {unknown} value
|
||||||
|
* @param {string} label
|
||||||
|
* @returns {string | undefined}
|
||||||
*/
|
*/
|
||||||
function createTransport(transportOptions) {
|
function normalizeRecipients(value, label) {
|
||||||
return nodemailer.createTransport(transportOptions);
|
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) {
|
async function sendEmail(ctx) {
|
||||||
const config = ctx.config ?? {};
|
const config = ctx.config ?? {};
|
||||||
const host = config.host;
|
assertTransportConfig(config);
|
||||||
if (typeof host !== "string" || host.length === 0) {
|
|
||||||
throw new Error("config.host is required");
|
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;
|
const to = normalizeRecipients(ctx.data?.to, "data.to");
|
||||||
if (typeof user !== "string" || user.length === 0) {
|
if (!to) {
|
||||||
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");
|
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)");
|
throw new Error("data.text is required (plain-text body)");
|
||||||
}
|
}
|
||||||
|
|
||||||
const password = $secrets.reveal(await $secrets.get(passwordSecret));
|
const cc = normalizeRecipients(ctx.data?.cc, "data.cc");
|
||||||
const from =
|
const bcc = normalizeRecipients(ctx.data?.bcc, "data.bcc");
|
||||||
typeof config.from === "string" && config.from.length > 0 ? config.from : user;
|
const replyTo =
|
||||||
const port = Number(config.port ?? 587);
|
normalizeRecipients(ctx.data?.replyTo ?? config.replyTo, "replyTo");
|
||||||
const secure = config.secure === true;
|
|
||||||
|
|
||||||
log.info(
|
let url = typeof config.url === "string" && config.url.length > 0 ? config.url : undefined;
|
||||||
{ host, port, secure, from, to, subjectLength: subject.length, textLength: text.length },
|
if (!url && typeof config.urlSecret === "string" && config.urlSecret.length > 0) {
|
||||||
"send-email: sending plain-text message",
|
url = $secrets.reveal(await $secrets.get(config.urlSecret));
|
||||||
);
|
}
|
||||||
|
|
||||||
const transporter = createTransport({
|
let password = null;
|
||||||
host,
|
if (typeof config.passwordSecret === "string" && config.passwordSecret.length > 0) {
|
||||||
port,
|
password = $secrets.reveal(await $secrets.get(config.passwordSecret));
|
||||||
secure,
|
}
|
||||||
auth: { user, pass: password },
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
from,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
text,
|
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");
|
log.info({ messageId: info.messageId }, "send-email: message sent");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sent: true,
|
sent: true,
|
||||||
messageId: info.messageId ?? null,
|
messageId: info.messageId ?? null,
|
||||||
|
from,
|
||||||
to,
|
to,
|
||||||
|
cc: cc ?? null,
|
||||||
|
bcc: bcc ?? null,
|
||||||
subject,
|
subject,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -77,9 +226,15 @@ async function sendEmail(ctx) {
|
|||||||
sendEmail.meta = {
|
sendEmail.meta = {
|
||||||
description: "Send a plain-text email via SMTP (nodemailer)",
|
description: "Send a plain-text email via SMTP (nodemailer)",
|
||||||
config: {
|
config: {
|
||||||
|
service: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
description:
|
||||||
|
"Well-known provider shortcut (e.g. gmail, outlook365, sendgrid). Alternative to host.",
|
||||||
|
},
|
||||||
host: {
|
host: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: false,
|
||||||
description: "SMTP server hostname (e.g. smtp.gmail.com)",
|
description: "SMTP server hostname (e.g. smtp.gmail.com)",
|
||||||
},
|
},
|
||||||
port: {
|
port: {
|
||||||
@@ -92,40 +247,175 @@ sendEmail.meta = {
|
|||||||
default: false,
|
default: false,
|
||||||
description: "Use TLS on connect (true for port 465)",
|
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: {
|
user: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: false,
|
||||||
description: "SMTP auth username (usually the sender email)",
|
description: "SMTP auth username (separate from the visible From address)",
|
||||||
},
|
},
|
||||||
from: {
|
from: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: false,
|
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: {
|
passwordSecret: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: false,
|
||||||
description: "Named secret holding the SMTP password or app password",
|
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: {
|
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" },
|
subject: { type: "string", required: true, description: "Email subject" },
|
||||||
text: {
|
text: {
|
||||||
type: "string",
|
type: "string",
|
||||||
required: true,
|
required: true,
|
||||||
description: "Plain-text body (aliases: body, message)",
|
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: {
|
output: {
|
||||||
sent: { type: "boolean", description: "Whether the message was sent" },
|
sent: { type: "boolean", description: "Whether the message was sent" },
|
||||||
messageId: { type: "string", description: "SMTP message id when available" },
|
messageId: { type: "string", description: "SMTP message id when available" },
|
||||||
|
from: { type: "string" },
|
||||||
to: { type: "string" },
|
to: { type: "string" },
|
||||||
|
cc: { type: "string" },
|
||||||
|
bcc: { type: "string" },
|
||||||
subject: { type: "string" },
|
subject: { type: "string" },
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
data: {
|
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",
|
subject: "Hello from scrunner",
|
||||||
text: "This is a plain-text test message.",
|
text: "This is a plain-text test message.",
|
||||||
},
|
},
|
||||||
@@ -133,7 +423,8 @@ sendEmail.meta = {
|
|||||||
host: "smtp.gmail.com",
|
host: "smtp.gmail.com",
|
||||||
port: 587,
|
port: 587,
|
||||||
secure: false,
|
secure: false,
|
||||||
user: "you@gmail.com",
|
user: "smtp-login@gmail.com",
|
||||||
|
from: "notifications@example.com",
|
||||||
passwordSecret: "gmail_app_password",
|
passwordSecret: "gmail_app_password",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user