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

@@ -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:

View File

@@ -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并带过期时间和使用状态。
- 只有邮箱已验证的用户可以登录。

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
});
}
}