// Sends transactional email through Gmail SMTP using an App Password. // // Setup (one-time): // 1. Google account -> Security -> 2-Step Verification -> enable. // 2. Create an App Password (myaccount.google.com/apppasswords). // 3. Set SMTP_USER, SMTP_PASS and optionally SMTP_FROM in .env. import nodemailer from "nodemailer"; // Google shows the App Password in "abcd efgh ijkl mnop" form; the spaces are // presentation only and must not reach the AUTH exchange. const getPassword = (): string | undefined => process.env.SMTP_PASS?.replace(/\s+/g, ""); export const isMailConfigured = (): boolean => Boolean(process.env.SMTP_USER && getPassword()); // Whether the OTP code may be surfaced outside email (response body or server // stdout) for self-hosted development. Never in production. export const isDevOtpExposed = (): boolean => process.env.NODE_ENV !== "production"; let transporter: nodemailer.Transporter | null = null; const getTransporter = (): nodemailer.Transporter => { if (!transporter) { const port = Number(process.env.SMTP_PORT) || 465; transporter = nodemailer.createTransport({ host: process.env.SMTP_HOST ?? "smtp.gmail.com", port, // 465 is implicit TLS; 587 starts plaintext and upgrades via STARTTLS. secure: port === 465, auth: { user: process.env.SMTP_USER, pass: getPassword(), }, // Without these a stalled connection blocks the request forever, which // hangs sign-up rather than falling back to the logged code below. connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 20_000, }); } return transporter; }; export const sendEmail = async ( to: string, subject: string, text: string, ): Promise => { if (!isMailConfigured()) { // Not configured: fall back to the server log so development still works. // In production never log the code to stdout; just report not sent. if (isDevOtpExposed()) { console.log(`[MAIL to=${to}] ${subject}\n${text}`); } return false; } const from = process.env.SMTP_FROM ?? process.env.SMTP_USER!; try { await getTransporter().sendMail({ from, to, subject, text }); return true; } catch (error) { // Delivery is best-effort: report the failure and let the caller surface // the code another way instead of failing the whole request. console.error(`[MAIL to=${to}] send failed:`, error); if (isDevOtpExposed()) { console.log(`[MAIL to=${to}] ${subject}\n${text}`); } return false; } };