Vectrum
Sign In
76 lines · 3.7 KB · TypeScript
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));
}