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:
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user