Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 265 lines 8.1 kB view raw
1/** @jsxImportSource react */ 2 3import { render } from "@react-email/render"; 4import { Effect, Schedule } from "effect"; 5import { Resend } from "resend"; 6import FollowUpEmail from "../emails/followup"; 7import type { MonitorAlertProps } from "../emails/monitor-alert"; 8import PageSubscriptionEmail from "../emails/page-subscription"; 9import type { PageSubscriptionProps } from "../emails/page-subscription"; 10import StatusPageMagicLinkEmail from "../emails/status-page-magic-link"; 11import type { StatusPageMagicLinkProps } from "../emails/status-page-magic-link"; 12import StatusReportEmail from "../emails/status-report"; 13import type { StatusReportProps } from "../emails/status-report"; 14import TeamInvitationEmail from "../emails/team-invitation"; 15import type { TeamInvitationProps } from "../emails/team-invitation"; 16import { monitorAlertEmail } from "../hotfix/monitor-alert"; 17 18// split an array into chunks of a given size. 19function chunk<T>(array: T[], size: number): T[][] { 20 const result: T[][] = []; 21 for (let i = 0; i < array.length; i += size) { 22 result.push(array.slice(i, i + size)); 23 } 24 return result; 25} 26 27export class EmailClient { 28 public readonly client: Resend; 29 30 constructor(opts: { apiKey: string }) { 31 this.client = new Resend(opts.apiKey); 32 } 33 34 public async sendFollowUp(req: { to: string }) { 35 if (process.env.NODE_ENV === "development") { 36 console.log(`Sending follow up email to ${req.to}`); 37 return; 38 } 39 40 try { 41 const html = await render(<FollowUpEmail />); 42 const result = await this.client.emails.send({ 43 from: "Thibault Le Ouay Ducasse <welcome@openstatus.dev>", 44 replyTo: "Thibault Le Ouay Ducasse <thibault@openstatus.dev>", 45 subject: "How's it going with OpenStatus?", 46 to: req.to, 47 html, 48 }); 49 50 if (!result.error) { 51 console.log(`Sent follow up email to ${req.to}`); 52 return; 53 } 54 55 throw result.error; 56 } catch (err) { 57 console.error(`Error sending follow up email to ${req.to}: ${err}`); 58 } 59 } 60 61 public async sendFollowUpBatched(req: { to: string[] }) { 62 if (process.env.NODE_ENV === "development") { 63 console.log(`Sending follow up emails to ${req.to.join(", ")}`); 64 return; 65 } 66 67 const html = await render(<FollowUpEmail />); 68 const result = await this.client.batch.send( 69 req.to.map((subscriber) => ({ 70 from: "Thibault Le Ouay Ducasse <thibault@openstatus.dev>", 71 subject: "How's it going with OpenStatus?", 72 to: subscriber, 73 html, 74 })), 75 ); 76 77 if (result.error) { 78 // We only throw the error if we are rate limited 79 if (result.error?.name === "rate_limit_exceeded") { 80 throw result.error; 81 } 82 // Otherwise let's log the error and continue 83 console.error( 84 `Error sending follow up email to ${req.to}: ${result.error}`, 85 ); 86 return; 87 } 88 89 console.log(`Sent follow up emails to ${req.to}`); 90 } 91 92 public async sendStatusReportUpdate( 93 req: StatusReportProps & { 94 subscribers: Array<{ email: string; token: string }>; 95 pageSlug: string; 96 customDomain?: string | null; 97 }, 98 ) { 99 const statusPageBaseUrl = req.customDomain 100 ? `https://${req.customDomain}` 101 : `https://${req.pageSlug}.openstatus.dev`; 102 103 if (process.env.NODE_ENV === "development") { 104 console.log( 105 `Sending status report update emails to ${req.subscribers 106 .map((s) => s.email) 107 .join(", ")}`, 108 ); 109 return; 110 } 111 112 for (const recipients of chunk(req.subscribers, 100)) { 113 const sendEmail = Effect.tryPromise({ 114 try: () => 115 this.client.batch.send( 116 recipients.map((subscriber) => { 117 const unsubscribeUrl = `${statusPageBaseUrl}/unsubscribe/${subscriber.token}`; 118 return { 119 from: `${req.pageTitle} <notifications@notifications.openstatus.dev>`, 120 subject: req.reportTitle, 121 to: subscriber.email, 122 react: ( 123 <StatusReportEmail {...req} unsubscribeUrl={unsubscribeUrl} /> 124 ), 125 }; 126 }), 127 ), 128 catch: (_unknown) => 129 new Error( 130 `Error sending status report update batch to ${recipients.map( 131 (r) => r.email, 132 )}`, 133 ), 134 }).pipe( 135 Effect.andThen((result) => 136 result.error ? Effect.fail(result.error) : Effect.succeed(result), 137 ), 138 Effect.retry({ 139 times: 3, 140 schedule: Schedule.exponential("1000 millis"), 141 }), 142 ); 143 await Effect.runPromise(sendEmail).catch(console.error); 144 } 145 146 console.log( 147 `Sent status report update email to ${req.subscribers.length} subscribers`, 148 ); 149 } 150 151 public async sendTeamInvitation(req: TeamInvitationProps & { to: string }) { 152 if (process.env.NODE_ENV === "development") { 153 console.log(`Sending team invitation email to ${req.to}`); 154 return; 155 } 156 157 try { 158 const html = await render(<TeamInvitationEmail {...req} />); 159 const result = await this.client.emails.send({ 160 from: `${ 161 req.workspaceName ?? "OpenStatus" 162 } <notifications@notifications.openstatus.dev>`, 163 subject: `You've been invited to join ${ 164 req.workspaceName ?? "OpenStatus" 165 }`, 166 to: req.to, 167 html, 168 }); 169 170 if (!result.error) { 171 console.log(`Sent team invitation email to ${req.to}`); 172 return; 173 } 174 175 throw result.error; 176 } catch (err) { 177 console.error(`Error sending team invitation email to ${req.to}`, err); 178 } 179 } 180 181 public async sendMonitorAlert(req: MonitorAlertProps & { to: string }) { 182 if (process.env.NODE_ENV === "development") { 183 console.log(`Sending monitor alert email to ${req.to}`); 184 return; 185 } 186 187 try { 188 // const html = await render(<MonitorAlertEmail {...req} />); 189 const html = monitorAlertEmail(req); 190 const result = await this.client.emails.send({ 191 from: "OpenStatus <notifications@notifications.openstatus.dev>", 192 subject: `${req.name}: ${req.type.toUpperCase()}`, 193 to: req.to, 194 html, 195 }); 196 197 if (!result.error) { 198 console.log(`Sent monitor alert email to ${req.to}`); 199 return; 200 } 201 202 throw result.error; 203 } catch (err) { 204 console.error(`Error sending monitor alert to ${req.to}`, err); 205 throw err; 206 } 207 } 208 209 public async sendPageSubscription( 210 req: PageSubscriptionProps & { to: string }, 211 ) { 212 if (process.env.NODE_ENV === "development") { 213 console.log(`Sending page subscription email to ${req.to}`); 214 return; 215 } 216 217 try { 218 const html = await render(<PageSubscriptionEmail {...req} />); 219 const result = await this.client.emails.send({ 220 from: "Status Page <notifications@notifications.openstatus.dev>", 221 subject: `Confirm your subscription to ${req.page}`, 222 to: req.to, 223 html, 224 }); 225 226 if (!result.error) { 227 console.log(`Sent page subscription email to ${req.to}`); 228 return; 229 } 230 231 throw result.error; 232 } catch (err) { 233 console.error(`Error sending page subscription to ${req.to}`, err); 234 } 235 } 236 237 public async sendStatusPageMagicLink( 238 req: StatusPageMagicLinkProps & { to: string }, 239 ) { 240 if (process.env.NODE_ENV === "development") { 241 console.log(`Sending status page magic link email to ${req.to}`); 242 console.log(`>>> Magic Link: ${req.link}`); 243 return; 244 } 245 246 try { 247 const html = await render(<StatusPageMagicLinkEmail {...req} />); 248 const result = await this.client.emails.send({ 249 from: "Status Page <notifications@notifications.openstatus.dev>", 250 subject: `Authenticate to ${req.page}`, 251 to: req.to, 252 html, 253 }); 254 255 if (!result.error) { 256 console.log(`Sent status page magic link email to ${req.to}`); 257 return; 258 } 259 260 throw result.error; 261 } catch (err) { 262 console.error(`Error sending status page magic link to ${req.to}`, err); 263 } 264 } 265}