Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/api/_utils/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand Down
63 changes: 38 additions & 25 deletions app/api/webhooks/resend-inbound/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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' });
}
Expand All @@ -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();
Expand Down
Loading