/** * Sending mail through the SMTP server in {@link SMTPS_URL}. * * @module */ import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer"; import { SMTPS_URL } from "../config.ts"; /** One address, a comma-separated list of them, or an array. */ export type EmailRecipient = string | string[]; /** A message to send. It needs a recipient, a subject, and a text or HTML body. */ export interface EmailMessage { /** The sender's address. */ from: string; /** The recipients. */ to: EmailRecipient; /** The subject line. */ subject: string; /** The plain-text body. */ text?: string; /** The HTML body. */ html?: string; /** The carbon-copy recipients. */ cc?: EmailRecipient; /** The blind-carbon-copy recipients. */ bcc?: EmailRecipient; /** The address replies go to. */ replyTo?: string; } /** What the SMTP server said about a sent message. */ export interface EmailSendResult { /** The message's `Message-ID`. */ messageId: string; /** The recipients the server accepted. */ accepted: string[]; /** The recipients the server rejected. */ rejected: string[]; } let transporter: Transporter | null = null; /** Checks whether an SMTP server is configured. */ export function isEmailEnabled(): boolean { return SMTPS_URL.length > 0; } /** Flattens the recipient forms nodemailer takes and returns into addresses. */ function normalizeRecipients( recipient: string | readonly (string | { address?: string })[] | undefined, ): string[] { if (!recipient) return []; if (typeof recipient === "string") { return recipient.split(",").map((s) => s.trim()).filter(Boolean); } return recipient.flatMap((entry) => typeof entry === "string" ? entry.trim() : (entry.address?.trim() ?? "") ).filter(Boolean); } function getTransporter(): Transporter { if (!isEmailEnabled()) { throw new Error( "GERBEUR_SMTPS_URL environment variable is required to send emails.", ); } if (!transporter) { transporter = nodemailer.createTransport(SMTPS_URL); } return transporter; } /** * Checks the connection to the SMTP server. Resolves false when email is * disabled. * * @throws {Error} When the server can't be reached or refuses the login. */ export async function verifyEmailTransport(): Promise { if (!isEmailEnabled()) return false; await getTransporter().verify(); return true; } /** * Sends `message`. * * @throws {Error} When email is disabled, the message lacks a recipient, * subject or body, or the server refuses it. */ export async function sendEmail( message: EmailMessage, ): Promise { if (normalizeRecipients(message.to).length === 0) { throw new Error("Email recipient is required."); } if (!message.subject.trim()) { throw new Error("Email subject is required."); } if (!message.text?.trim() && !message.html?.trim()) { throw new Error("Email body is required (text and/or html)."); } const options: SendMailOptions = { from: message.from, to: message.to, cc: message.cc, bcc: message.bcc, subject: message.subject, text: message.text, html: message.html, replyTo: message.replyTo, }; const info = await getTransporter().sendMail(options); return { messageId: info.messageId, accepted: normalizeRecipients(info.accepted), rejected: normalizeRecipients(info.rejected), }; }