134 lines
3.5 KiB
TypeScript
134 lines
3.5 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Checks the connection to the SMTP server, or resolves false when email is
|
|
* disabled. Nothing calls it yet.
|
|
*
|
|
* @throws {Error} When the server can't be reached or refuses the login.
|
|
*/
|
|
export async function verifyEmailTransport(): Promise<boolean> {
|
|
if (!isEmailEnabled()) return false;
|
|
await getTransporter().verify();
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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<EmailSendResult> {
|
|
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),
|
|
};
|
|
}
|