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; __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) => `

${escape(text)}

`; const button = action ? `

${escape(action.label)}

` + `

Or open this link: ${escape(action.url)}

` : ""; const small = footer ? `

${escape(footer)}

` : ""; return `
${body.map(p).join("")}${button}${small}
`; } 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)); }