From b99ea1fad97f63299e69f31afd4570eef1e15f1f Mon Sep 17 00:00:00 2001 From: Kingsmai Date: Mon, 11 May 2026 14:14:36 +0800 Subject: [PATCH] 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. --- .env.example | 1 + DESIGN.md | 4 ++- backend/src/auth.ts | 60 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index da7308f..7095084 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,7 @@ RESEND_DAILY_QUOTA_LIMIT=100 RESEND_MONTHLY_QUOTA_LIMIT=3000 RESEND_QUOTA_RESERVE=5 RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10 +EMAIL_FALLBACK_LOG_TOKENS=false AI_MODERATION_API_KEY= # Local Docker debug defaults: diff --git a/DESIGN.md b/DESIGN.md index eb5cfb0..33de24f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -111,8 +111,10 @@ - `EMAIL_FROM` - `APP_ORIGIN` 或 `FRONTEND_ORIGIN` - 认证邮件和密码重置邮件使用标准化 Pokopia Wiki 品牌 HTML 外壳;正文、按钮文案、兜底链接提示和纯文本版本仍通过 `surface=email` 的系统级文案维护。 -- 后端从 Resend 邮件发送响应 headers 读取日/月发送额度和 rate limit 状态,并维护短期内存 snapshot;当 Resend 已报告额度接近用尽、额度耗尽或 API 限流时,认证邮件发送会暂时停止并返回本地化用户提示。 +- 后端从 Resend 邮件发送响应 headers 读取日/月发送额度和 rate limit 状态,并维护短期内存 snapshot;当 Resend 已报告额度接近用尽、额度耗尽或 API 限流时,认证邮件发送会暂时停止;若没有启用邮件 fallback,注册等需要即时邮件送达的流程会返回本地化用户提示。 - Resend 额度保护不使用本项目自增发送计数;默认按 Free 计划 `100/day`、`3000/month` 和 5 封保留量判断,可通过 `RESEND_DAILY_QUOTA_LIMIT`、`RESEND_MONTHLY_QUOTA_LIMIT`、`RESEND_QUOTA_RESERVE`、`RESEND_QUOTA_SNAPSHOT_TTL_MINUTES` 调整。 +- 邮件发送失败时,后端可通过受保护的 fallback 日志显示一次性验证 / 重置 token 和完整链接,便于开发或应急验证;非生产环境默认启用,生产环境必须显式设置 `EMAIL_FALLBACK_LOG_TOKENS=true` 才会输出 token 或链接。 +- 邮件 fallback token / 链接只允许出现在后端日志,不得进入 API 响应、前端 UI 或普通管理界面。 - 验证邮件包含一次性验证链接。 - 验证 token 只保存 hash,并带过期时间和使用状态。 - 只有邮箱已验证的用户可以登录。 diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 0fa1777..0f2155d 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -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, 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, 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, locale = def export async function requestPasswordReset(payload: Record, locale = defaultLocale) { const email = await cleanEmail(payload.email, locale); - await assertResendEmailAvailable(locale); const user = await queryOne( 'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1', [email] @@ -1230,7 +1275,14 @@ export async function requestPasswordReset(payload: Record, 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 + }); } }