Openstatus www.openstatus.dev

chore: parallelize follow-up email sending with Promise.all (#1239)

* chore: parallelize follow-up email sending with Promise.all

Previously, follow-up emails were sent sequentially inside a for...of loop, leading to slower execution. This change uses Promise.all to send emails in parallel, significantly improving the speed of the process.

* feat(emails): implement batch follow-up emails with rate limiting

Improve the follow-up email system by:
- Optimizing database queries to select only necessary email fields
- Implementing batched email sending (80 emails per batch)
- Adding proper rate limit detection and handling
- Improving error logging and recovery

* fix: improve follow-up email sending reliability and error handling

The changes improve email delivery reliability by:

- Enhancing email validation with stronger type guards
- Adding proper break statement for rate limit errors to prevent wasteful API calls
- Simplifying error flow control with early returns and consistent error handling
- Restructuring try/catch blocks for better exception management
- Improving TypeScript type safety with more specific error handling

These modifications ensure the system gracefully handles rate limiting while providing clearer logs for debugging. Email validation now properly checks for empty strings, preventing attempts to send to invalid addresses.

* minor change

* fix(followup-emails): added type checking for email as string

Initially, we were not checking type of email and calling trim() on it, this improvision check whether email is really a string and then calls trim() on it.

* minor change: removed un-necassary type checking for email

* ci: apply automated fixes

---------

Co-authored-by: Thibault Le Ouay <thibaultleouay@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Ajinkya Anil Kahane
Thibault Le Ouay
autofix-ci[bot]
and committed by
GitHub
0d3ba6c8 f5c36f9f

+61 -8
+2 -1
apps/workflows/package.json
··· 27 27 }, 28 28 "devDependencies": { 29 29 "@openstatus/tsconfig": "workspace:*", 30 - "@types/bun": "latest" 30 + "@types/bun": "latest", 31 + "typescript": "5.6.2" 31 32 } 32 33 }
+21 -4
apps/workflows/src/cron/emails.ts
··· 14 14 date2.setDate(date2.getDate() - 2); 15 15 16 16 const users = await db 17 - .select() 17 + .select({ 18 + email: user.email, 19 + }) 18 20 .from(user) 19 21 .where(and(gte(user.createdAt, date1), lte(user.createdAt, date2))) 20 22 .all(); 21 23 22 24 console.log(`Found ${users.length} users to send follow ups.`); 23 25 24 - for (const user of users) { 25 - if (user.email) { 26 - await email.sendFollowUp({ to: user.email }); 26 + // Filter valid emails 27 + const validEmails = users 28 + .map((u) => u.email) 29 + .filter((email) => email !== null) 30 + // I don't know why but I can't have both filter at the same time 31 + .filter((email) => email.trim() !== ""); 32 + 33 + // Chunk emails into batches of 80 34 + const batchSize = 80; 35 + for (let i = 0; i < validEmails.length; i += batchSize) { 36 + const batch = validEmails.slice(i, i + batchSize); 37 + console.log(`Sending batch with ${batch.length} emails...`); 38 + try { 39 + await email.sendFollowUpBatched({ to: batch }); 40 + } catch { 41 + //Stop email send when rate limit error is faced in order to avoid wasteful API calls 42 + console.error("Rate limit exceeded. Stopping further sends."); 43 + break; 27 44 } 28 45 } 29 46 }
+35 -3
packages/emails/src/client.tsx
··· 1 1 /** @jsxImportSource react */ 2 2 3 3 import { render } from "@react-email/render"; 4 - import { Resend } from "resend"; 4 + import { ErrorResponse, Resend } from "resend"; 5 5 import FollowUpEmail from "../emails/followup"; 6 6 import MonitorAlertEmail from "../emails/monitor-alert"; 7 7 import type { MonitorAlertProps } from "../emails/monitor-alert"; ··· 42 42 } 43 43 } 44 44 45 + public async sendFollowUpBatched(req: { to: string[] }) { 46 + if (process.env.NODE_ENV === "development") return; 47 + 48 + const html = await render(<FollowUpEmail />); 49 + const result = await this.client.batch.send( 50 + req.to.map((subscriber) => ({ 51 + from: "Thibault Le Ouay Ducasse <thibault@openstatus.dev>", 52 + subject: "How's it going with OpenStatus?", 53 + to: subscriber, 54 + html, 55 + })), 56 + ); 57 + 58 + if (result.error) { 59 + // We only throw the error if we are rate limited 60 + if (result.error?.name === "rate_limit_exceeded") { 61 + throw result.error; 62 + } 63 + // Otherwise let's log the error and continue 64 + console.error( 65 + `Error sending follow up email to ${req.to}: ${result.error}`, 66 + ); 67 + return; 68 + } 69 + 70 + console.log(`Sent follow up emails to ${req.to}`); 71 + } 72 + 45 73 public async sendStatusReportUpdate( 46 74 req: StatusReportProps & { to: string[] }, 47 75 ) { ··· 78 106 try { 79 107 const html = await render(<TeamInvitationEmail {...req} />); 80 108 const result = await this.client.emails.send({ 81 - from: `${req.workspaceName ?? "OpenStatus"} <notifications@notifications.openstatus.dev>`, 82 - subject: `You've been invited to join ${req.workspaceName ?? "OpenStatus"}`, 109 + from: `${ 110 + req.workspaceName ?? "OpenStatus" 111 + } <notifications@notifications.openstatus.dev>`, 112 + subject: `You've been invited to join ${ 113 + req.workspaceName ?? "OpenStatus" 114 + }`, 83 115 to: req.to, 84 116 html, 85 117 });
+3
pnpm-lock.yaml
··· 529 529 '@types/bun': 530 530 specifier: latest 531 531 version: 1.2.5 532 + typescript: 533 + specifier: 5.6.2 534 + version: 5.6.2 532 535 533 536 packages/analytics: 534 537 dependencies: