From 9e927af813192fe7f39a2125dc48e424bba843f2 Mon Sep 17 00:00:00 2001 From: DexterStorey <36115192+DexterStorey@users.noreply.github.com> Date: Tue, 17 Oct 2023 14:26:49 -0400 Subject: [PATCH] feat: add rate limiting and more error handling to Cal.ai (#11898) Co-authored-by: tedspare --- apps/ai/package.json | 2 +- apps/ai/src/app/api/agent/route.ts | 7 ++++ apps/ai/src/app/api/receive/route.ts | 43 ++++++++++++++++----- packages/lib/checkRateLimitAndThrowError.ts | 2 +- packages/lib/rateLimit.ts | 8 +++- 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/apps/ai/package.json b/apps/ai/package.json index f006dac273..fcc9cea1c9 100644 --- a/apps/ai/package.json +++ b/apps/ai/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/ai", - "version": "1.2.0", + "version": "1.2.1", "private": true, "author": "Cal.com Inc.", "dependencies": { diff --git a/apps/ai/src/app/api/agent/route.ts b/apps/ai/src/app/api/agent/route.ts index e39f7329a5..0ffbfa36b0 100644 --- a/apps/ai/src/app/api/agent/route.ts +++ b/apps/ai/src/app/api/agent/route.ts @@ -40,6 +40,13 @@ export const POST = async (request: NextRequest) => { return new NextResponse("ok"); } catch (error) { + await sendEmail({ + subject: `Re: ${subject}`, + text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.", + to: user.email, + from: agentEmail, + }); + return new NextResponse( (error as Error).message || "Something went wrong. Please try again or reach out for help.", { status: 500 } diff --git a/apps/ai/src/app/api/receive/route.ts b/apps/ai/src/app/api/receive/route.ts index cf6ec9da71..63d6e5d0e4 100644 --- a/apps/ai/src/app/api/receive/route.ts +++ b/apps/ai/src/app/api/receive/route.ts @@ -3,6 +3,7 @@ import { simpleParser } from "mailparser"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import prisma from "@calcom/prisma"; import { env } from "../../../env.mjs"; @@ -31,18 +32,37 @@ export const POST = async (request: NextRequest) => { const formData = await request.formData(); const body = Object.fromEntries(formData); - - // body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass} - const signature = (body.dkim as string).includes(" : pass"); - const envelope = JSON.parse(body.envelope as string); const aiEmail = envelope.to[0]; + const subject = body.subject || ""; + + try { + await checkRateLimitAndThrowError({ + identifier: `ai:email:${envelope.from}`, + rateLimitingType: "ai", + }); + } catch (error) { + await sendEmail({ + subject: `Re: ${subject}`, + text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.", + to: envelope.from, + from: aiEmail, + }); + + return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid + } // Parse email from mixed MIME type const parsed: ParsedMail = await simpleParser(body.email as Source); if (!parsed.text && !parsed.subject) { + await sendEmail({ + subject: `Re: ${subject}`, + text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.", + to: envelope.from, + from: aiEmail, + }); return new NextResponse("Email missing text and subject", { status: 400 }); } @@ -62,11 +82,14 @@ export const POST = async (request: NextRequest) => { where: { email: envelope.from }, }); + // body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass} + const signature = (body.dkim as string).includes(" : pass"); + // User is not a cal.com user or is using an unverified email. if (!signature || !user) { await sendEmail({ html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address.`, - subject: `Re: ${body.subject}`, + subject: `Re: ${subject}`, text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`, to: envelope.from, from: aiEmail, @@ -83,7 +106,7 @@ export const POST = async (request: NextRequest) => { await sendEmail({ html: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install it.`, - subject: `Re: ${body.subject}`, + subject: `Re: ${subject}`, text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`, to: envelope.from, from: aiEmail, @@ -110,7 +133,7 @@ export const POST = async (request: NextRequest) => { if ("error" in availability) { await sendEmail({ - subject: `Re: ${body.subject}`, + subject: `Re: ${subject}`, text: "Sorry, there was an error fetching your availability. Please try again.", to: user.email, from: aiEmail, @@ -121,7 +144,7 @@ export const POST = async (request: NextRequest) => { if ("error" in eventTypes) { await sendEmail({ - subject: `Re: ${body.subject}`, + subject: `Re: ${subject}`, text: "Sorry, there was an error fetching your event types. Please try again.", to: user.email, from: aiEmail, @@ -139,8 +162,8 @@ export const POST = async (request: NextRequest) => { body: JSON.stringify({ apiKey, userId: user.id, - message: parsed.text, - subject: parsed.subject, + message: parsed.text || "", + subject: parsed.subject || "", replyTo: aiEmail, user: { email: user.email, diff --git a/packages/lib/checkRateLimitAndThrowError.ts b/packages/lib/checkRateLimitAndThrowError.ts index dd96494d37..88c43f0230 100644 --- a/packages/lib/checkRateLimitAndThrowError.ts +++ b/packages/lib/checkRateLimitAndThrowError.ts @@ -1,4 +1,4 @@ -import { TRPCError } from "@calcom/trpc"; +import { TRPCError } from "@calcom/trpc/server"; import type { RateLimitHelper } from "./rateLimit"; import { rateLimiter } from "./rateLimit"; diff --git a/packages/lib/rateLimit.ts b/packages/lib/rateLimit.ts index 0608d47eea..876efb79aa 100644 --- a/packages/lib/rateLimit.ts +++ b/packages/lib/rateLimit.ts @@ -7,7 +7,7 @@ import logger from "./logger"; const log = logger.getChildLogger({ prefix: ["RateLimit"] }); export type RateLimitHelper = { - rateLimitingType?: "core" | "forcedSlowMode" | "common" | "api"; + rateLimitingType?: "core" | "forcedSlowMode" | "common" | "api" | "ai"; identifier: string; }; @@ -75,6 +75,12 @@ export function rateLimiter() { prefix: "ratelimit:api", limiter: Ratelimit.fixedWindow(10, "60s"), }), + ai: new Ratelimit({ + redis, + analytics: true, + prefix: "ratelimit", + limiter: Ratelimit.fixedWindow(20, "1d"), + }), }; async function rateLimit({ rateLimitingType = "core", identifier }: RateLimitHelper) {