import "server-only";
import { createTransport } from "nodemailer";
import { BRAND } from "@/components/brand";
import { PUBLIC_ORIGIN } from "./request-origin";
/*
* Email goes out through the SMTP server in SMTP_HOST (with SMTP_PORT, SMTP_USER, SMTP_PASSWORD and
* SMTP_SECURE), from MAIL_FROM. Without SMTP_HOST nothing is sent, and what needs email (confirming
* addresses, resetting a forgotten password) isn't offered. The messages are in lib/emails.ts.
*/
export const MAIL_ENABLED = !!process.env.SMTP_HOST;
const PORT = Number(process.env.SMTP_PORT) || 587;
// Port 465 speaks TLS from the start; others upgrade with STARTTLS when the server offers it.
const SECURE = process.env.SMTP_SECURE ? ["1", "true"].includes(process.env.SMTP_SECURE.toLowerCase()) : PORT === 465;
const FROM = process.env.MAIL_FROM || process.env.SMTP_USER;
const g = globalThis as unknown as { __mailer?: ReturnType<typeof createTransport>; __mailOriginWarned?: boolean };
function mailer() {
if (!FROM) throw new Error("Set MAIL_FROM to the address emails are sent from.");
return (g.__mailer ??= createTransport({
host: process.env.SMTP_HOST,
port: PORT,
secure: SECURE,
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD ?? "" } : undefined,
}));
}
/** An absolute link to a page of this server. */
export function linkTo(path: string) {
if (!PUBLIC_ORIGIN && !g.__mailOriginWarned) {
g.__mailOriginWarned = true;
console.warn("[mail] PUBLIC_URL isn't set, so links in emails point at localhost.");
}
return `${PUBLIC_ORIGIN ?? `http://localhost:${process.env.PORT ?? 3000}`}${path}`;
}
export type Message = {
to: string;
subject: string;
/** Paragraphs of plain text. */
body: string[];
/** The one thing to do, as a button. */
action?: { label: string; url: string };
/** Smaller print below, e.g. why the email was sent. */
footer?: string;
};
const escape = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
function html({ body, action, footer }: Message) {
const p = (text: string) => `<p style="margin:0 0 16px">${escape(text)}</p>`;
const button = action
? `<p style="margin:24px 0"><a href="${escape(action.url)}" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#111827;color:#ffffff;text-decoration:none;font-weight:600">${escape(action.label)}</a></p>` +
`<p style="margin:0 0 16px;font-size:13px;color:#6b7280">Or open this link: <a href="${escape(action.url)}" style="color:#6b7280">${escape(action.url)}</a></p>`
: "";
const small = footer ? `<p style="margin:24px 0 0;font-size:13px;color:#6b7280">${escape(footer)}</p>` : "";
return `<!doctype html><html><body style="margin:0;padding:24px;background:#f9fafb"><div style="max-width:520px;margin:0 auto;padding:28px;background:#ffffff;border-radius:12px;font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#111827">${body.map(p).join("")}${button}${small}</div></body></html>`;
}
function text({ body, action, footer }: Message) {
return [...body, ...(action ? [`${action.label}: ${action.url}`] : []), ...(footer ? [`--\n${footer}`] : [])].join("\n\n");
}
/** Sends an email; throws when the SMTP server refuses it. */
export async function sendMail(message: Message) {
if (!MAIL_ENABLED) return;
await mailer().sendMail({ from: FROM, to: message.to, subject: `[${BRAND.name}] ${message.subject}`, text: text(message), html: html(message) });
}
/** Sends an email without waiting for it, for when the request shouldn't fail (or take longer) because of it. */
export function sendMailLater(message: Message) {
sendMail(message).catch((e) => console.error(`[mail] couldn't send "${message.subject}":`, e instanceof Error ? e.message : e));
}