diff --git a/apps/api/.env.example b/apps/api/.env.example index 772b05f..324b73c 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -54,6 +54,12 @@ AWS_SES_SECRET_ACCESS_KEY= SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI) +# Email sending rate limit (emails per second) +# If not set, automatically fetches from AWS SES account quota (recommended) +# Set this to override AWS quota (useful for setting lower limits or testing) +# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts) +# EMAIL_RATE_LIMIT_PER_SECOND=14 + # ============================================================================== # OAuth (Optional - for social login) # ============================================================================== diff --git a/apps/api/src/app/constants.ts b/apps/api/src/app/constants.ts index 85524ed..e93792e 100644 --- a/apps/api/src/app/constants.ts +++ b/apps/api/src/app/constants.ts @@ -45,6 +45,13 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION'); export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID'); export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY'); +// Email Processing Rate Limit (optional override) +// If not set, will automatically fetch from AWS SES account quota +// Set this to override AWS quota (useful for setting lower limits or testing) +export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECOND + ? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND) + : undefined; + // Storage export const REDIS_URL = validateEnv('REDIS_URL'); export const DATABASE_URL = validateEnv('DATABASE_URL'); diff --git a/apps/api/src/jobs/email-processor.ts b/apps/api/src/jobs/email-processor.ts index 96cae3e..104d839 100644 --- a/apps/api/src/jobs/email-processor.ts +++ b/apps/api/src/jobs/email-processor.ts @@ -11,10 +11,41 @@ import {EmailService} from '../services/EmailService.js'; import {EventService} from '../services/EventService.js'; import {MeterService} from '../services/MeterService.js'; import {emailQueue, type SendEmailJobData} from '../services/QueueService.js'; -import {sendRawEmail} from '../services/SESService.js'; -import {DASHBOARD_URI} from '../app/constants.js'; +import {getSendingQuota, sendRawEmail} from '../services/SESService.js'; +import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js'; -export function createEmailWorker() { +/** + * Determine the email sending rate limit (emails per second) + * Priority: ENV variable > AWS SES quota > Safe default (14) + */ +async function getEmailRateLimit(): Promise { + const DEFAULT_RATE_LIMIT = 14; // AWS SES sandbox limit - safe default + + // If env variable is set, use it (override) + if (EMAIL_RATE_LIMIT_PER_SECOND !== undefined) { + console.log(`[EMAIL-PROCESSOR] Using rate limit from environment: ${EMAIL_RATE_LIMIT_PER_SECOND} emails/second`); + return EMAIL_RATE_LIMIT_PER_SECOND; + } + + // Try to fetch from AWS SES + console.log('[EMAIL-PROCESSOR] Fetching rate limit from AWS SES...'); + const quota = await getSendingQuota(); + + if (quota) { + console.log( + `[EMAIL-PROCESSOR] AWS SES quota: ${quota.maxSendRate} emails/second (${quota.sentLast24Hours}/${quota.max24HourSend} emails sent today)`, + ); + return quota.maxSendRate; + } + + // Fallback to safe default + console.warn(`[EMAIL-PROCESSOR] Failed to fetch AWS quota, using safe default: ${DEFAULT_RATE_LIMIT} emails/second`); + return DEFAULT_RATE_LIMIT; +} + +export async function createEmailWorker() { + // Fetch the rate limit (from env, AWS, or default) + const rateLimit = await getEmailRateLimit(); const worker = new Worker( emailQueue.name, async (job: Job) => { @@ -157,7 +188,7 @@ export function createEmailWorker() { connection: emailQueue.opts.connection, concurrency: 10, // Process up to 10 emails concurrently limiter: { - max: 25, // Max 25 emails per second + max: rateLimit, // Max emails per second (from env, AWS SES quota, or default) duration: 1000, }, }, diff --git a/apps/api/src/jobs/worker.ts b/apps/api/src/jobs/worker.ts index 1cc488e..23bcd03 100644 --- a/apps/api/src/jobs/worker.ts +++ b/apps/api/src/jobs/worker.ts @@ -25,7 +25,7 @@ async function startWorkers() { try { // Start email worker - const emailWorker = createEmailWorker(); + const emailWorker = await createEmailWorker(); workers.push({name: 'email', worker: emailWorker}); signale.success('[WORKER] Email worker started'); diff --git a/apps/api/src/services/SESService.ts b/apps/api/src/services/SESService.ts index b33162f..39f2ccf 100644 --- a/apps/api/src/services/SESService.ts +++ b/apps/api/src/services/SESService.ts @@ -256,3 +256,26 @@ export const disableFeedbackForwarding = async (domain: string): Promise = ForwardingEnabled: false, }); }; + +/** + * Get AWS SES account sending quota and rate limit + * @returns MaxSendRate (emails per second) or null if the call fails + */ +export const getSendingQuota = async (): Promise<{ + maxSendRate: number; + max24HourSend: number; + sentLast24Hours: number; +} | null> => { + try { + const quota = await ses.getSendQuota({}); + + return { + maxSendRate: quota.MaxSendRate ?? 14, // Default to sandbox limit if not provided + max24HourSend: quota.Max24HourSend ?? 200, // Default sandbox daily limit + sentLast24Hours: quota.SentLast24Hours ?? 0, + }; + } catch (error) { + console.error('[SES] Failed to fetch sending quota:', error); + return null; + } +};