From cccff2bcc017d2fddd4eed7ce2ae03d490b818ef Mon Sep 17 00:00:00 2001 From: Mohammed Rayan A Date: Fri, 7 Aug 2026 21:48:00 +0530 Subject: [PATCH] fix(email): close cost/abuse gap in inbound email-to-save webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems: (1) the route fetched the full email body via Resend's API before checking whether the sender matched a registered user, so random spam still cost a Resend API call every time; (2) checkResourceLimit only caps free-tier accounts at 50 resources — Pro/unlimited plans had no cap at all, so a spoofed From header impersonating a real paying user could trigger unbounded Gemini embedding calls with no limit. Reorder to match the sender against payload.data.from (already present in the webhook metadata) before ever calling resend.emails.receiving.get(), and add checkEmailSaveRateLimit — a new 20/day-per-user Upstash limit that applies regardless of plan tier, closing the Pro-account gap. --- app/api/_utils/rateLimit.ts | 29 +++++++++++ app/api/webhooks/resend-inbound/route.ts | 63 ++++++++++++++---------- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/app/api/_utils/rateLimit.ts b/app/api/_utils/rateLimit.ts index d5d1819..e365f3c 100644 --- a/app/api/_utils/rateLimit.ts +++ b/app/api/_utils/rateLimit.ts @@ -19,6 +19,7 @@ function getRedis(): Redis | null { let _authenticatedLimiter: Ratelimit | null = null; let _publicLimiter: Ratelimit | null = null; let _aiLimiter: Ratelimit | null = null; +let _emailSaveLimiter: Ratelimit | null = null; function getAuthenticatedLimiter(): Ratelimit | null { if (_authenticatedLimiter) return _authenticatedLimiter; @@ -59,6 +60,22 @@ function getAiLimiter(): Ratelimit | null { return _aiLimiter; } +function getEmailSaveLimiter(): Ratelimit | null { + if (_emailSaveLimiter) return _emailSaveLimiter; + const redis = getRedis(); + if (!redis) return null; + _emailSaveLimiter = new Ratelimit({ + redis, + // 20 inbound-email saves/day per user, regardless of plan — bounds Gemini + // embedding cost against a spoofed From header, since checkResourceLimit + // alone doesn't (Pro/unlimited plans have no resource cap). + limiter: Ratelimit.slidingWindow(20, '1 d'), + prefix: 'rl:email-save', + analytics: false, + }); + return _emailSaveLimiter; +} + // --------------------------------------------------------------------------- // Identifier helpers // --------------------------------------------------------------------------- @@ -124,6 +141,18 @@ export async function checkPublicRateLimit( ); } +// Unlike the other checkers, this returns a plain boolean rather than a +// NextResponse — the webhook caller (Resend) isn't the account owner, so a +// 429 would just look like a delivery failure. The route logs and silently +// drops the email instead, same as its other soft-fail paths. +export async function checkEmailSaveRateLimit(userId: string): Promise<{ isLimited: boolean }> { + const limiter = getEmailSaveLimiter(); + if (!limiter) return { isLimited: false }; // Redis not configured – skip + + const { success } = await limiter.limit(userId); + return { isLimited: !success }; +} + export async function checkAiRateLimit( _request: NextRequest, userId: string, diff --git a/app/api/webhooks/resend-inbound/route.ts b/app/api/webhooks/resend-inbound/route.ts index 4c5ec02..6f32a71 100644 --- a/app/api/webhooks/resend-inbound/route.ts +++ b/app/api/webhooks/resend-inbound/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import crypto from 'crypto'; import { Resend } from 'resend'; import { getServerFirestore } from '../../_utils/firebaseAdmin'; +import { checkEmailSaveRateLimit } from '../../_utils/rateLimit'; import { indexResource } from '../../_utils/resourceIndexer'; import { checkResourceLimit } from '../../_utils/subscription'; @@ -76,35 +77,22 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing email_id' }, { status: 400 }); } - const apiKey = process.env.RESEND_API_KEY; - if (!apiKey) { - console.error('RESEND_API_KEY not configured — cannot fetch inbound email content'); - return NextResponse.json({ success: true, message: 'Resend not configured' }); - } - - // The webhook payload only carries metadata — fetch the full body separately. - const resend = new Resend(apiKey); - const { data: email, error: fetchError } = await resend.emails.receiving.get(emailId); - - if (fetchError || !email) { - console.error('Failed to fetch inbound email content:', fetchError); - return NextResponse.json({ success: true, message: 'Could not fetch email content' }); - } - - const fromAddress = email.from; + // The webhook payload's metadata already includes the sender — match the + // user BEFORE fetching the full body, so spam from unregistered senders + // costs nothing beyond this one Firestore query (no Resend fetch, no + // Gemini call). + const fromAddress: string | undefined = payload.data?.from; const db = getServerFirestore(); // Match by the sender's registered account email — no per-user token needed, // matches the "just forward it" promise on the landing page. Trade-off: a - // spoofed From header could inject a junk note into someone's private vault - // (no read/exfiltration risk); revisit with rate-limiting if it's abused. - const userSnapshot = await db - .collection('users') - .where('email', '==', fromAddress) - .limit(1) - .get(); - - if (userSnapshot.empty) { + // spoofed From header could still create resources for a real user (see + // rate limit below, which bounds the cost of that regardless of plan). + const userSnapshot = fromAddress + ? await db.collection('users').where('email', '==', fromAddress).limit(1).get() + : null; + + if (!userSnapshot || userSnapshot.empty) { console.warn('Inbound email received but sender did not match any registered account:', fromAddress); return NextResponse.json({ success: true, message: 'Sender not matched to a user' }); } @@ -117,6 +105,31 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, message: 'Resource limit reached, save skipped' }); } + // Free-tier's 50-resource cap doesn't help Pro/unlimited accounts — this + // caps inbound-email-triggered saves (and their Gemini cost) regardless + // of plan, so a spoofed From header can't be used to run up costs. + const emailRateLimitCheck = await checkEmailSaveRateLimit(uid); + if (emailRateLimitCheck.isLimited) { + console.warn(`Inbound email save skipped for ${uid} — daily email-save limit reached`); + return NextResponse.json({ success: true, message: 'Daily email-save limit reached, save skipped' }); + } + + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + console.error('RESEND_API_KEY not configured — cannot fetch inbound email content'); + return NextResponse.json({ success: true, message: 'Resend not configured' }); + } + + // Only now — after confirming a real, under-limit user — fetch the full + // body (the webhook payload alone only carries metadata). + const resend = new Resend(apiKey); + const { data: email, error: fetchError } = await resend.emails.receiving.get(emailId); + + if (fetchError || !email) { + console.error('Failed to fetch inbound email content:', fetchError); + return NextResponse.json({ success: true, message: 'Could not fetch email content' }); + } + const bodyText = email.text || (email.html ? email.html.replace(/<[^>]+>/g, ' ').trim() : '') || ''; const now = new Date(); const resourceRef = db.collection('resources').doc();