From b0e2464c24bcacde35db1ce29113ccdbcb11218a Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 3 May 2026 19:42:41 +0800 Subject: [PATCH] feat(auth): implement Resend email quota and rate limit protection Track Resend API usage via response headers to prevent quota exhaustion Block auth requests with 503 when email delivery limits are reached --- .env.example | 4 + DESIGN.md | 2 + backend/src/auth.ts | 224 +++++++++++++++++++++++++++++++++++------- backend/src/server.ts | 4 + system-wordings.ts | 6 +- 5 files changed, 200 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 7237db3..1e0d6ae 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,10 @@ VITE_API_BASE_URL=http://localhost:20016 VITE_SITE_URL=https://pokopiawiki.tootaio.com RESEND_API_KEY= EMAIL_FROM="Pokopia Wiki " +RESEND_DAILY_QUOTA_LIMIT=100 +RESEND_MONTHLY_QUOTA_LIMIT=3000 +RESEND_QUOTA_RESERVE=5 +RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10 AI_MODERATION_API_KEY= # Cloudflared tunnel deployment example: diff --git a/DESIGN.md b/DESIGN.md index b1de51d..e318ff5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -102,6 +102,8 @@ - `EMAIL_FROM` - `APP_ORIGIN` 或 `FRONTEND_ORIGIN` - 认证邮件和密码重置邮件使用标准化 Pokopia Wiki 品牌 HTML 外壳;正文、按钮文案、兜底链接提示和纯文本版本仍通过 `surface=email` 的系统级文案维护。 +- 后端从 Resend 邮件发送响应 headers 读取日/月发送额度和 rate limit 状态,并维护短期内存 snapshot;当 Resend 已报告额度接近用尽、额度耗尽或 API 限流时,认证邮件发送会暂时停止并返回本地化用户提示。 +- Resend 额度保护不使用本项目自增发送计数;默认按 Free 计划 `100/day`、`3000/month` 和 5 封保留量判断,可通过 `RESEND_DAILY_QUOTA_LIMIT`、`RESEND_MONTHLY_QUOTA_LIMIT`、`RESEND_QUOTA_RESERVE`、`RESEND_QUOTA_SNAPSHOT_TTL_MINUTES` 调整。 - 验证邮件包含一次性验证链接。 - 验证 token 只保存 hash,并带过期时间和使用状态。 - 只有邮箱已验证的用户可以登录。 diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 9e625e0..2805aed 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -14,6 +14,10 @@ const defaultLocale = 'en'; const referralCodeLength = 8; const referralAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const referralCodePattern = /^[A-Z0-9]{8,16}$/; +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; type DbClient = PoolClient; @@ -65,6 +69,7 @@ type AuthMessageKey = | 'emailKicker' | 'emailLinkFallback' | 'emailFooter' + | 'emailDeliveryUnavailable' | 'verificationActionLabel' | 'passwordResetSubject' | 'passwordResetHtml' @@ -162,6 +167,30 @@ const criticalPermissionKeys = [ 'admin.permissions.delete' ]; +type ResendBlockReason = 'quota' | 'rateLimit'; + +type ResendQuotaSnapshot = { + dailyUsed?: number; + monthlyUsed?: number; + rateLimitRemaining?: number; + rateLimitResetAt?: number; + blockedUntil?: number; + blockedReason?: ResendBlockReason; + updatedAt?: number; +}; + +const resendQuotaSnapshot: ResendQuotaSnapshot = {}; + +function positiveIntegerEnv(key: string, fallback: number): number { + const value = Number(process.env[key]); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function nonNegativeIntegerEnv(key: string, fallback: number): number { + const value = Number(process.env[key]); + return Number.isInteger(value) && value >= 0 ? value : fallback; +} + function statusError(message: string, statusCode: number): StatusError { const error = new Error(message) as StatusError; error.statusCode = statusCode; @@ -193,6 +222,7 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record= 0 ? parsedValue : undefined; +} + +function parseRetryAfterMs(headers: Headers, now = Date.now()): number | undefined { + const value = headers.get('retry-after'); + if (!value) { + return undefined; + } + + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds > 0) { + return seconds * 1000; + } + + const retryAt = Date.parse(value); + return Number.isFinite(retryAt) && retryAt > now ? retryAt - now : undefined; +} + +function updateResendQuotaSnapshot(headers: Headers): void { + const now = Date.now(); + const dailyUsed = parseHeaderInteger(headers, 'x-resend-daily-quota'); + const monthlyUsed = parseHeaderInteger(headers, 'x-resend-monthly-quota'); + const rateLimitRemaining = parseHeaderInteger(headers, 'ratelimit-remaining'); + const rateLimitReset = parseHeaderInteger(headers, 'ratelimit-reset'); + + if (dailyUsed !== undefined) { + resendQuotaSnapshot.dailyUsed = dailyUsed; + } else { + delete resendQuotaSnapshot.dailyUsed; + } + if (monthlyUsed !== undefined) { + resendQuotaSnapshot.monthlyUsed = monthlyUsed; + } else { + delete resendQuotaSnapshot.monthlyUsed; + } + if (rateLimitRemaining !== undefined) { + resendQuotaSnapshot.rateLimitRemaining = rateLimitRemaining; + } else { + delete resendQuotaSnapshot.rateLimitRemaining; + } + if (rateLimitReset !== undefined) { + resendQuotaSnapshot.rateLimitResetAt = now + rateLimitReset * 1000; + } else { + delete resendQuotaSnapshot.rateLimitResetAt; + } + + resendQuotaSnapshot.updatedAt = now; +} + +function blockResendEmail(reason: ResendBlockReason, headers: Headers): void { + const now = Date.now(); + const retryAfterMs = parseRetryAfterMs(headers, now); + resendQuotaSnapshot.blockedReason = reason; + resendQuotaSnapshot.blockedUntil = now + (retryAfterMs ?? resendQuotaSnapshotTtlMs); + resendQuotaSnapshot.updatedAt = now; +} + +function currentResendBlockReason(now = Date.now()): ResendBlockReason | null { + if (resendQuotaSnapshot.blockedUntil && resendQuotaSnapshot.blockedUntil > now) { + return resendQuotaSnapshot.blockedReason ?? 'quota'; + } + + if (!resendQuotaSnapshot.updatedAt || now - resendQuotaSnapshot.updatedAt > resendQuotaSnapshotTtlMs) { + return null; + } + + if ( + resendQuotaSnapshot.dailyUsed !== undefined && + resendQuotaSnapshot.dailyUsed >= quotaThreshold(resendDailyQuotaLimit) + ) { + return 'quota'; + } + + if ( + resendQuotaSnapshot.monthlyUsed !== undefined && + resendQuotaSnapshot.monthlyUsed >= quotaThreshold(resendMonthlyQuotaLimit) + ) { + return 'quota'; + } + + if ( + resendQuotaSnapshot.rateLimitRemaining !== undefined && + resendQuotaSnapshot.rateLimitRemaining <= 0 && + resendQuotaSnapshot.rateLimitResetAt !== undefined && + resendQuotaSnapshot.rateLimitResetAt > now + ) { + return 'rateLimit'; + } + + return null; +} + +async function assertResendEmailAvailable(locale: string): Promise { + if (currentResendBlockReason()) { + throw statusError(await authMessage(locale, 'emailDeliveryUnavailable'), 503); + } +} + +function resendFailureReason(status: number, responseText: string): ResendBlockReason | null { + if (status !== 429) { + return null; + } + + return /daily_quota_exceeded|monthly_quota_exceeded/i.test(responseText) ? 'quota' : 'rateLimit'; +} + +async function sendResendEmail( + locale: string, + payload: { apiKey: string; from: string; to: string; subject: string; html: string; text: string } +): Promise { + await assertResendEmailAvailable(locale); + + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${payload.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from: payload.from, + to: [payload.to], + subject: payload.subject, + html: payload.html, + text: payload.text + }) + }); + updateResendQuotaSnapshot(response.headers); + + if (!response.ok) { + const responseText = await response.text(); + const blockReason = resendFailureReason(response.status, responseText); + if (blockReason) { + blockResendEmail(blockReason, response.headers); + throw statusError(await authMessage(locale, 'emailDeliveryUnavailable'), 503); + } + throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`); + } +} + function escapeHtml(value: string): string { return value .replaceAll('&', '&') @@ -834,25 +1016,7 @@ async function sendVerificationEmail(email: string, token: string, locale: strin linkFallback, footer }); - const response = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - from, - to: [email], - subject, - html, - text - }) - }); - - if (!response.ok) { - const responseText = await response.text(); - throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`); - } + await sendResendEmail(locale, { apiKey, from, to: email, subject, html, text }); } async function sendPasswordResetEmail(email: string, token: string, locale: string): Promise { @@ -876,25 +1040,7 @@ async function sendPasswordResetEmail(email: string, token: string, locale: stri linkFallback, footer }); - const response = await fetch('https://api.resend.com/emails', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - from, - to: [email], - subject, - html, - text - }) - }); - - if (!response.ok) { - const responseText = await response.text(); - throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`); - } + await sendResendEmail(locale, { apiKey, from, to: email, subject, html, text }); } export async function registerUser(payload: Record, locale = defaultLocale) { @@ -902,6 +1048,7 @@ 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); @@ -1015,6 +1162,7 @@ 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] diff --git a/backend/src/server.ts b/backend/src/server.ts index e9700a3..1c5ee96 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -186,6 +186,10 @@ app.setErrorHandler(async (error, _request, reply) => { return reply.code(429).send({ message: await serverMessage(locale, 'rateLimited') }); } + if (pgError.statusCode === 503) { + return reply.code(503).send({ message: await localizedStatusMessage(locale, pgError.message) }); + } + if (pgError.statusCode && pgError.statusCode < 500) { return reply.code(pgError.statusCode).send({ message: await localizedStatusMessage(locale, pgError.message) }); } diff --git a/system-wordings.ts b/system-wordings.ts index b9a309c..c3f0627 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -998,7 +998,8 @@ export const systemWordingMessages = { verifyEmailFirst: 'Please complete email verification first', invalidResetToken: 'The password reset link is invalid or expired', currentPasswordInvalid: 'Current password is incorrect', - invalidReferralCode: 'Referral code is invalid' + invalidReferralCode: 'Referral code is invalid', + emailDeliveryUnavailable: 'Email delivery is temporarily unavailable. Please try again later.' }, validation: { nameRequired: 'Name is required', @@ -2072,7 +2073,8 @@ export const systemWordingMessages = { verifyEmailFirst: '请先完成邮箱验证', invalidResetToken: '密码重置链接无效或已过期', currentPasswordInvalid: '当前密码不正确', - invalidReferralCode: '邀请码无效' + invalidReferralCode: '邀请码无效', + emailDeliveryUnavailable: '邮件发送暂时不可用,请稍后再试。' }, validation: { nameRequired: '请输入名称',