feat(auth): log fallback tokens when email delivery fails

Introduce EMAIL_FALLBACK_LOG_TOKENS to output verification and reset links to backend logs.
Remove strict email quota assertions to allow auth flows to proceed in fallback mode.
This commit is contained in:
2026-05-11 14:14:36 +08:00
parent 42319695e9
commit b99ea1fad9
3 changed files with 60 additions and 5 deletions

View File

@@ -18,6 +18,7 @@ const resendDailyQuotaLimit = positiveIntegerEnv('RESEND_DAILY_QUOTA_LIMIT', 100
const resendMonthlyQuotaLimit = positiveIntegerEnv('RESEND_MONTHLY_QUOTA_LIMIT', 3000);
const resendQuotaReserve = nonNegativeIntegerEnv('RESEND_QUOTA_RESERVE', 5);
const resendQuotaSnapshotTtlMs = positiveIntegerEnv('RESEND_QUOTA_SNAPSHOT_TTL_MINUTES', 10) * 60 * 1000;
const emailFallbackLogTokens = process.env.EMAIL_FALLBACK_LOG_TOKENS === 'true';
type DbClient = PoolClient;
@@ -782,6 +783,37 @@ function buildPasswordResetUrl(token: string): string {
return buildTokenUrl('/reset-password', token);
}
function shouldLogEmailFallbackTokens(): boolean {
return emailFallbackLogTokens || process.env.NODE_ENV !== 'production';
}
function emailFallbackFailureMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function logEmailFallback(options: {
purpose: 'email-verification' | 'password-reset';
email: string;
token: string;
url: string;
expiresInHours: number;
error: unknown;
}): boolean {
if (!shouldLogEmailFallbackTokens()) {
console.error(`${options.purpose} email failed`, options.error);
return false;
}
console.warn(`${options.purpose} email failed; using backend fallback`, {
email: options.email,
expiresInHours: options.expiresInHours,
token: options.token,
url: options.url,
error: emailFallbackFailureMessage(options.error)
});
return true;
}
function quotaThreshold(limit: number): number {
const reserve = Math.min(resendQuotaReserve, Math.max(0, limit - 1));
return limit - reserve;
@@ -1092,7 +1124,6 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
const displayName = await cleanDisplayName(payload.displayName, locale);
const password = await cleanPassword(payload.password, locale);
const referralCode = await cleanReferralCode(payload.referralCode, locale);
await assertResendEmailAvailable(locale);
const passwordHash = await hashPassword(password);
const verificationToken = createPlainToken();
const verificationTokenHash = hashToken(verificationToken);
@@ -1152,7 +1183,22 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
);
});
await sendVerificationEmail(email, verificationToken, locale);
try {
await sendVerificationEmail(email, verificationToken, locale);
} catch (error) {
const fallbackLogged = logEmailFallback({
purpose: 'email-verification',
email,
token: verificationToken,
url: buildVerificationUrl(verificationToken),
expiresInHours: verificationTokenHours,
error
});
if (!fallbackLogged) {
throw error;
}
}
return { message: await authMessage(locale, 'checkVerificationEmail') };
}
@@ -1206,7 +1252,6 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
export async function requestPasswordReset(payload: Record<string, unknown>, locale = defaultLocale) {
const email = await cleanEmail(payload.email, locale);
await assertResendEmailAvailable(locale);
const user = await queryOne<UserRow>(
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
[email]
@@ -1230,7 +1275,14 @@ export async function requestPasswordReset(payload: Record<string, unknown>, loc
try {
await sendPasswordResetEmail(email, resetToken, locale);
} catch (error) {
console.error('Password reset email failed', error);
logEmailFallback({
purpose: 'password-reset',
email,
token: resetToken,
url: buildPasswordResetUrl(resetToken),
expiresInHours: passwordResetTokenHours,
error
});
}
}