88 type ActionCtx ,
99} from "./_generated/server.js" ;
1010import { Workpool } from "@convex-dev/workpool" ;
11- import { RateLimiter } from "@convex-dev/rate-limiter" ;
11+ import { RateLimiter , SECOND } from "@convex-dev/rate-limiter" ;
12+ import type { RateLimitConfig } from "@convex-dev/rate-limiter" ;
1213import { api , components , internal } from "./_generated/api.js" ;
1314import { internalMutation } from "./_generated/server.js" ;
1415import { type Id , type Doc } from "./_generated/dataModel.js" ;
@@ -33,6 +34,8 @@ const BATCH_SIZE = 100;
3334const EMAIL_POOL_SIZE = 4 ;
3435const CALLBACK_POOL_SIZE = 4 ;
3536const RESEND_ONE_CALL_EVERY_MS = 600 ; // Half the stated limit, but it keeps us sane.
37+ const DEFAULT_EMAILS_PER_SECOND = SECOND / RESEND_ONE_CALL_EVERY_MS ;
38+ const MIN_EMAILS_PER_SECOND = 0.1 ;
3639const FINALIZED_EMAIL_RETENTION_MS = 1000 * 60 * 60 * 24 * 7 ; // 7 days
3740const FINALIZED_EPOCH = Number . MAX_SAFE_INTEGER ;
3841const ABANDONED_EMAIL_RETENTION_MS = 1000 * 60 * 60 * 24 * 30 ; // 30 days
@@ -79,14 +82,7 @@ const callbackPool = new Workpool(components.callbackWorkpool, {
7982} ) ;
8083
8184// We rate limit our calls to the Resend API.
82- // FUTURE -- make this rate configurable if an account ups its sending rate with Resend.
83- const resendApiRateLimiter = new RateLimiter ( components . rateLimiter , {
84- resendApi : {
85- kind : "fixed window" ,
86- period : RESEND_ONE_CALL_EVERY_MS ,
87- rate : 1 ,
88- } ,
89- } ) ;
85+ const resendApiRateLimiter = new RateLimiter ( components . rateLimiter ) ;
9086
9187// Enqueue an email to be send. A background job will grab batches
9288// of emails and enqueue them to be sent by the workpool.
@@ -190,6 +186,23 @@ export const sendEmail = mutation({
190186 } ,
191187} ) ;
192188
189+ async function getResendRateLimitConfig (
190+ ctx : MutationCtx ,
191+ ) : Promise < RateLimitConfig > {
192+ const record = await ctx . db . query ( "resendOptions" ) . unique ( ) ;
193+ const raw = record ?. options . rateLimitEmailsPerSecond ;
194+ const emailsPerSecond =
195+ typeof raw === "number" && Number . isFinite ( raw ) && raw > 0
196+ ? Math . max ( raw , MIN_EMAILS_PER_SECOND )
197+ : Math . max ( DEFAULT_EMAILS_PER_SECOND , MIN_EMAILS_PER_SECOND ) ;
198+ return {
199+ kind : "token bucket" ,
200+ rate : emailsPerSecond ,
201+ period : SECOND ,
202+ capacity : Math . max ( emailsPerSecond , 1 ) ,
203+ } ;
204+ }
205+
193206export const createManualEmail = mutation ( {
194207 args : {
195208 from : v . string ( ) ,
@@ -271,6 +284,26 @@ export const cancelEmail = mutation({
271284 } ,
272285} ) ;
273286
287+ export const setResendRateLimit = mutation ( {
288+ args : { rateLimitEmailsPerSecond : v . number ( ) } ,
289+ returns : v . null ( ) ,
290+ handler : async ( ctx , { rateLimitEmailsPerSecond } ) => {
291+ if (
292+ ! Number . isFinite ( rateLimitEmailsPerSecond ) ||
293+ rateLimitEmailsPerSecond <= 0
294+ ) {
295+ throw new Error ( "rateLimitEmailsPerSecond must be a positive number" ) ;
296+ }
297+ const existing = await ctx . db . query ( "resendOptions" ) . unique ( ) ;
298+ const options = { rateLimitEmailsPerSecond } ;
299+ if ( existing ) {
300+ await ctx . db . patch ( existing . _id , { options } ) ;
301+ } else {
302+ await ctx . db . insert ( "resendOptions" , { options } ) ;
303+ }
304+ } ,
305+ } ) ;
306+
274307// Get the status of an email.
275308export const getStatus = query ( {
276309 args : {
@@ -684,9 +717,11 @@ async function createResendBatchPayload(
684717}
685718
686719const FIXED_WINDOW_DELAY = 100 ;
687- async function getDelay ( ctx : RunMutationCtx & RunQueryCtx ) : Promise < number > {
720+ async function getDelay ( ctx : MutationCtx ) : Promise < number > {
721+ const config = await getResendRateLimitConfig ( ctx ) ;
688722 const limit = await resendApiRateLimiter . limit ( ctx , "resendApi" , {
689723 reserve : true ,
724+ config,
690725 } ) ;
691726 //console.log(`RL: ${limit.ok} ${limit.retryAfter}`);
692727 const jitter = Math . random ( ) * FIXED_WINDOW_DELAY ;
0 commit comments