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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a0b297285a
commit
eceb6b45d5
+45
-75
@@ -1,96 +1,66 @@
|
||||
// Sends transactional email through the Gmail API using an OAuth2 refresh
|
||||
// token (no third-party email service needed on a self-hosted box).
|
||||
// Sends transactional email through Gmail SMTP using an App Password.
|
||||
//
|
||||
// Setup (one-time):
|
||||
// 1. Google Cloud console -> enable Gmail API, create an OAuth client.
|
||||
// 2. Generate a refresh token with scope
|
||||
// https://www.googleapis.com/auth/gmail.send
|
||||
// 3. Set GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN, GMAIL_FROM.
|
||||
// 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.
|
||||
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
const SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
let cachedAccessToken: { token: string; expiresAt: number } | null = null;
|
||||
// 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, "");
|
||||
|
||||
const getAccessToken = async (): Promise<string | null> => {
|
||||
const clientId = process.env.GMAIL_CLIENT_ID;
|
||||
const clientSecret = process.env.GMAIL_CLIENT_SECRET;
|
||||
const refreshToken = process.env.GMAIL_REFRESH_TOKEN;
|
||||
export const isMailConfigured = (): boolean =>
|
||||
Boolean(process.env.SMTP_USER && getPassword());
|
||||
|
||||
if (!clientId || !clientSecret || !refreshToken) return null;
|
||||
let transporter: nodemailer.Transporter | null = null;
|
||||
|
||||
if (cachedAccessToken && cachedAccessToken.expiresAt > Date.now() + 60_000) {
|
||||
return cachedAccessToken.token;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gmail token exchange failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
cachedAccessToken = {
|
||||
token: data.access_token,
|
||||
expiresAt: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return cachedAccessToken.token;
|
||||
return transporter;
|
||||
};
|
||||
|
||||
export const sendEmail = async (
|
||||
to: string,
|
||||
subject: string,
|
||||
text: string,
|
||||
): Promise<void> => {
|
||||
const accessToken = await getAccessToken();
|
||||
if (!accessToken) {
|
||||
): 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;
|
||||
return false;
|
||||
}
|
||||
|
||||
const from = process.env.GMAIL_FROM;
|
||||
if (!from) throw new Error("Missing GMAIL_FROM.");
|
||||
const from = process.env.SMTP_FROM ?? process.env.SMTP_USER!;
|
||||
|
||||
const mime = [
|
||||
`From: ${from}`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
text,
|
||||
].join("\r\n");
|
||||
|
||||
const response = await fetch(SEND_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
raw: Buffer.from(mime)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, ""),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gmail send failed: ${response.status}`);
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user