Openstatus
www.openstatus.dev
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}