Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
6.9 kB ยท 228 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229/** @jsxRuntime automatic @jsxImportSource react */
import { render } from "react-email";
import { plural } from "../emails/_components/format";import type { PlanLoss } from "../emails/_components/plan-loss";import CancellationScheduledEmail, { CANCELLATION_SCHEDULED_SUBJECT,} from "../emails/cancellation-scheduled";import MemberRemovedEmail, { type MemberRemovedProps, memberRemovedSubject,} from "../emails/member-removed";import PlanDowngradedEmail, { type PlanDowngradedProps, planDowngradedSubject,} from "../emails/plan-downgraded";import PlanEndingSoonEmail, { type PlanEndingSoonProps, planEndingSoonSubject,} from "../emails/plan-ending-soon";import TrialEndingEmail, { type TrialEndingProps, trialEndingSubject,} from "../emails/trial-ending";import { sendBatchEmailHtml, sendEmail } from "./send";
const RAW_FROM = "Thibault from openstatus <thibault@openstatus.dev>";// Signed hybrid: personal name on the system subdomain, replies to a real inbox.const SIGNED_FROM = "Thibault from openstatus <thibault@notifications.openstatus.dev>";const SIGNED_REPLY_TO = "thibault@openstatus.dev";const SYSTEM_FROM = "openstatus <notifications@notifications.openstatus.dev>";const SUPPORT_EMAIL = "ping@openstatus.dev";
const DAY_MS = 86_400_000;export const REMINDER_LEAD_DAYS = 3;const RESEND_MAX_SCHEDULE_DAYS = 30;const RESEND_BATCH_SIZE = 100;
/** `stripe:<event.id>:<template>` โ a Stripe redelivery reuses the event id. */export function stripeIdempotencyKey(eventId: string, template: string) { return `stripe:${eventId}:${template}`;}
/** Workspace owners + Stripe customer email, deduped case-insensitively. */export function billingRecipients( owners: Array<string | null | undefined>, customerEmail?: string | null,): string[] { const seen = new Set<string>(); const result: string[] = []; for (const raw of [...owners, customerEmail]) { const email = raw?.trim(); if (!email) continue; const key = email.toLowerCase(); if (seen.has(key)) continue; seen.add(key); result.push(email); } return result;}
/** Plain-text loss lines for the raw cancellation email. */export function planLossLines(loss: PlanLoss): string[] { const lines: string[] = []; if (loss.pagesDeleted.length > 0) { const n = loss.pagesDeleted.length; lines.push( `${plural(n, "status page")} ${n === 1 ? "gets" : "get"} deleted (${loss.pagesDeleted.join(", ")})`, ); } if (loss.monitorsDeactivated > 0) { const n = loss.monitorsDeactivated; lines.push(`${plural(n, "monitor")} ${n === 1 ? "gets" : "get"} paused`); } if (loss.membersRemoved > 0) { const n = loss.membersRemoved; lines.push(`${plural(n, "member")} ${n === 1 ? "loses" : "lose"} access`); } if (loss.invitationsDeleted > 0) { const n = loss.invitationsDeleted; lines.push( `${plural(n, "pending invitation")} ${n === 1 ? "gets" : "get"} deleted`, ); } if (loss.notificationsDeleted > 0) { const n = loss.notificationsDeleted; lines.push( `${plural(n, "notification channel")} ${n === 1 ? "gets" : "get"} removed`, ); } if (loss.customDomains.length > 0) { const many = loss.customDomains.length > 1; lines.push( `the custom domain${many ? "s" : ""} ${loss.customDomains.join(", ")} ${ many ? "are" : "is" } released`, ); } if (loss.sso) lines.push("SAML SSO turns off"); return lines;}
/** * When to send the "plan ends in 3 days" reminder, or null when it cannot be * scheduled: already inside the lead window, or beyond Resend's 30-day limit. */export function reminderScheduledAt(endsAt: Date, now = new Date()) { const at = new Date(endsAt.getTime() - REMINDER_LEAD_DAYS * DAY_MS); if (at.getTime() <= now.getTime()) return null; if (at.getTime() - now.getTime() > RESEND_MAX_SCHEDULE_DAYS * DAY_MS) { return null; } return at;}
export async function sendPlanDowngraded( req: PlanDowngradedProps & { to: string[]; eventId: string },) { if (req.to.length === 0) return; const { to, eventId, ...props } = req; return sendEmail( { from: SIGNED_FROM, reply_to: SIGNED_REPLY_TO, to, subject: planDowngradedSubject(props), react: <PlanDowngradedEmail {...props} />, }, { idempotencyKey: stripeIdempotencyKey(eventId, "plan-downgraded") }, );}
export async function sendMemberRemoved( req: MemberRemovedProps & { to: string[]; idempotencyKey?: string },) { if (req.to.length === 0) return; const { to, idempotencyKey, ...props } = req; const html = await render(<MemberRemovedEmail {...props} />); // Resend's batch API takes at most 100 emails. for (let i = 0; i < to.length; i += RESEND_BATCH_SIZE) { await sendBatchEmailHtml( to.slice(i, i + RESEND_BATCH_SIZE).map((email) => ({ from: SYSTEM_FROM, reply_to: SUPPORT_EMAIL, to: email, subject: memberRemovedSubject(props), html, })), { idempotencyKey: idempotencyKey ? `${idempotencyKey}:${i / RESEND_BATCH_SIZE}` : undefined, }, ); }}
export async function sendCancellationScheduled(req: { to: string[]; eventId: string; plan: string; endsAt: Date; loss: PlanLoss;}) { if (req.to.length === 0) return; return sendEmail( { from: RAW_FROM, to: req.to, subject: CANCELLATION_SCHEDULED_SUBJECT, react: ( <CancellationScheduledEmail plan={req.plan} endsAt={req.endsAt} losses={planLossLines(req.loss)} /> ), }, { idempotencyKey: stripeIdempotencyKey( req.eventId, "cancellation-scheduled", ), }, );}
/** Returns the Resend id to cancel on resume, or undefined if not scheduled. */export async function schedulePlanEndingSoon( req: PlanEndingSoonProps & { to: string[]; eventId: string; now?: Date },) { if (req.to.length === 0) return; const { to, eventId, now, ...props } = req; const scheduledAt = reminderScheduledAt(props.endsAt, now); if (!scheduledAt) return; return sendEmail( { from: SIGNED_FROM, reply_to: SIGNED_REPLY_TO, to, subject: planEndingSoonSubject(props), react: <PlanEndingSoonEmail {...props} />, }, { idempotencyKey: stripeIdempotencyKey(eventId, "plan-ending-soon"), scheduledAt: scheduledAt.toISOString(), }, );}
export async function sendTrialEnding( req: TrialEndingProps & { to: string[]; eventId: string },) { if (req.to.length === 0) return; const { to, eventId, ...props } = req; return sendEmail( { from: SIGNED_FROM, reply_to: SIGNED_REPLY_TO, to, subject: trialEndingSubject(props), react: <TrialEndingEmail {...props} />, }, { idempotencyKey: stripeIdempotencyKey(eventId, "trial-ending") }, );}