/** * Sends mail through the SMTP server of {@link SMTPS_URL}, which may be unset. * Its two callers, the welcome email at sign-up and the password reset link, * check {@link isEmailEnabled} first and don't wait for delivery. * * @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, as nodemailer * takes them. */ export type EmailRecipient = string | string[]; /** * Passed to {@link sendEmail}. It needs a recipient, a subject, and a text or * HTML body; the callers send the same Markdown as both, rendered for `html`. */ export interface EmailMessage { from: string; to: EmailRecipient; subject: string; text?: string; html?: string; cc?: EmailRecipient; bcc?: EmailRecipient; replyTo?: string; } /** * Returned by {@link sendEmail} once the SMTP server has taken the message. * No caller reads it yet. */ export interface EmailSendResult { messageId: string; accepted: string[]; rejected: string[]; } let transporter: Transporter | null = null; /** * Checks whether an SMTP server is configured. Without one, sign-up sends no * welcome email and a password reset request does nothing. */ 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; } /** * Sends `message` and resolves once the SMTP server has accepted it. The * transport is created on the first send. * * @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), }; }