Files
waseel/lib/mailer.ts
T
KrikoriosandClaude Fable 5 eceb6b45d5 Fix SMTP delivery, add password reset and user deletion
SMTP:
- Add connection/greeting/socket timeouts so a stalled Gmail
  connection no longer hangs sign-up
- Wrap sendMail in try/catch and fall back to logging the code
- Derive secure from port (465 implicit TLS vs 587 STARTTLS)
- Strip whitespace from the Gmail app password
- Document SMTP_HOST/SMTP_PORT in .env.example and environment.d.ts

Password reset (new):
- POST /(api)/auth/forgot-password emails a 6-digit code and does
  not reveal whether the address is registered
- POST /(api)/auth/reset-password validates the code, sets the new
  password, verifies the email, and signs the user in
- password_reset_codes table added to seed-db.mjs
- "Forgot password?" flow on the mobile sign-in screen

User deletion (new):
- DELETE /(api)/admin/users/[id], owner-only, blocks self-deletion
- Delete button with confirmation on the dashboard Users page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 22:41:41 +03:00

67 lines
2.2 KiB
TypeScript

// 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());
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<boolean> => {
if (!isMailConfigured()) {
// Not configured: fall back to the server log so development still works.
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);
console.log(`[MAIL to=${to}] ${subject}\n${text}`);
return false;
}
};