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:
@@ -16,6 +16,7 @@ RESEND_DAILY_QUOTA_LIMIT=100
|
|||||||
RESEND_MONTHLY_QUOTA_LIMIT=3000
|
RESEND_MONTHLY_QUOTA_LIMIT=3000
|
||||||
RESEND_QUOTA_RESERVE=5
|
RESEND_QUOTA_RESERVE=5
|
||||||
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
||||||
|
EMAIL_FALLBACK_LOG_TOKENS=false
|
||||||
AI_MODERATION_API_KEY=
|
AI_MODERATION_API_KEY=
|
||||||
|
|
||||||
# Local Docker debug defaults:
|
# Local Docker debug defaults:
|
||||||
|
|||||||
@@ -111,8 +111,10 @@
|
|||||||
- `EMAIL_FROM`
|
- `EMAIL_FROM`
|
||||||
- `APP_ORIGIN` 或 `FRONTEND_ORIGIN`
|
- `APP_ORIGIN` 或 `FRONTEND_ORIGIN`
|
||||||
- 认证邮件和密码重置邮件使用标准化 Pokopia Wiki 品牌 HTML 外壳;正文、按钮文案、兜底链接提示和纯文本版本仍通过 `surface=email` 的系统级文案维护。
|
- 认证邮件和密码重置邮件使用标准化 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` 调整。
|
- 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,并带过期时间和使用状态。
|
- 验证 token 只保存 hash,并带过期时间和使用状态。
|
||||||
- 只有邮箱已验证的用户可以登录。
|
- 只有邮箱已验证的用户可以登录。
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const resendDailyQuotaLimit = positiveIntegerEnv('RESEND_DAILY_QUOTA_LIMIT', 100
|
|||||||
const resendMonthlyQuotaLimit = positiveIntegerEnv('RESEND_MONTHLY_QUOTA_LIMIT', 3000);
|
const resendMonthlyQuotaLimit = positiveIntegerEnv('RESEND_MONTHLY_QUOTA_LIMIT', 3000);
|
||||||
const resendQuotaReserve = nonNegativeIntegerEnv('RESEND_QUOTA_RESERVE', 5);
|
const resendQuotaReserve = nonNegativeIntegerEnv('RESEND_QUOTA_RESERVE', 5);
|
||||||
const resendQuotaSnapshotTtlMs = positiveIntegerEnv('RESEND_QUOTA_SNAPSHOT_TTL_MINUTES', 10) * 60 * 1000;
|
const resendQuotaSnapshotTtlMs = positiveIntegerEnv('RESEND_QUOTA_SNAPSHOT_TTL_MINUTES', 10) * 60 * 1000;
|
||||||
|
const emailFallbackLogTokens = process.env.EMAIL_FALLBACK_LOG_TOKENS === 'true';
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
type DbClient = PoolClient;
|
||||||
|
|
||||||
@@ -782,6 +783,37 @@ function buildPasswordResetUrl(token: string): string {
|
|||||||
return buildTokenUrl('/reset-password', token);
|
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 {
|
function quotaThreshold(limit: number): number {
|
||||||
const reserve = Math.min(resendQuotaReserve, Math.max(0, limit - 1));
|
const reserve = Math.min(resendQuotaReserve, Math.max(0, limit - 1));
|
||||||
return limit - reserve;
|
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 displayName = await cleanDisplayName(payload.displayName, locale);
|
||||||
const password = await cleanPassword(payload.password, locale);
|
const password = await cleanPassword(payload.password, locale);
|
||||||
const referralCode = await cleanReferralCode(payload.referralCode, locale);
|
const referralCode = await cleanReferralCode(payload.referralCode, locale);
|
||||||
await assertResendEmailAvailable(locale);
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const verificationToken = createPlainToken();
|
const verificationToken = createPlainToken();
|
||||||
const verificationTokenHash = hashToken(verificationToken);
|
const verificationTokenHash = hashToken(verificationToken);
|
||||||
@@ -1152,7 +1183,22 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
await sendVerificationEmail(email, verificationToken, locale);
|
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') };
|
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) {
|
export async function requestPasswordReset(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const email = await cleanEmail(payload.email, locale);
|
const email = await cleanEmail(payload.email, locale);
|
||||||
await assertResendEmailAvailable(locale);
|
|
||||||
const user = await queryOne<UserRow>(
|
const user = await queryOne<UserRow>(
|
||||||
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
|
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
|
||||||
[email]
|
[email]
|
||||||
@@ -1230,7 +1275,14 @@ export async function requestPasswordReset(payload: Record<string, unknown>, loc
|
|||||||
try {
|
try {
|
||||||
await sendPasswordResetEmail(email, resetToken, locale);
|
await sendPasswordResetEmail(email, resetToken, locale);
|
||||||
} catch (error) {
|
} 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