TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import * as React from "react";2import { render } from "@react-email/render";3import { Resend } from "resend";4import {5 AccountDeletionEmail,6 ApiKeyCreatedEmail,7 ApiKeyRevokedEmail,8 BillingEmail,9 EmailChangeApprovalEmail,10 EmailChangedEmail,11 InviteEmail,12 NewLoginEmail,13 PasswordResetEmail,14 UsageAlertEmail,15 VerificationEmail,16 WelcomeEmail,17} from "./templates";1819export interface EmailServiceOptions {20 apiKey?: string;21 from?: string;22 fromTransactional?: string;23 siteUrl?: string;24 /** When true, emails are logged instead of sent. */25 dryRun?: boolean;26 logger?: { info: (msg: string, meta?: unknown) => void; error: (msg: string, meta?: unknown) => void };27}2829export interface SendResult {30 id: string | null;31 skipped?: boolean;32}3334/**35 * Email service abstraction. The rest of the platform never touches Resend directly.36 */37export class EmailService {38 private readonly resend: Resend | null;39 private readonly from: string;40 private readonly fromTx: string;41 private readonly siteUrl: string;42 private readonly dryRun: boolean;43 private readonly log: NonNullable<EmailServiceOptions["logger"]>;4445 constructor(opts: EmailServiceOptions = {}) {46 const apiKey = opts.apiKey ?? process.env.RESEND_API_KEY;47 this.from = opts.from ?? process.env.EMAIL_FROM ?? "Fetcha <hello@fetcha.co>";48 this.fromTx = opts.fromTransactional ?? process.env.EMAIL_FROM_TRANSACTIONAL ?? "Fetcha <notifications@fetcha.co>";49 this.siteUrl = (opts.siteUrl ?? process.env.WEB_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.fetcha.co").replace(/\/$/, "");50 this.dryRun = opts.dryRun ?? (!apiKey || process.env.EMAIL_DRY_RUN === "1");51 this.resend = apiKey ? new Resend(apiKey) : null;52 this.log = opts.logger ?? { info: (m, meta) => console.log("[email]", m, meta ?? ""), error: (m, meta) => console.error("[email]", m, meta ?? "") };53 }5455 private async send(to: string, subject: string, element: React.ReactElement, opts: { transactional?: boolean; replyTo?: string } = {}): Promise<SendResult> {56 const html = await render(element);57 const text = await render(element, { plainText: true });58 const from = opts.transactional ? this.fromTx : this.from;59 if (this.dryRun || !this.resend) {60 this.log.info(`dry-run → ${to} | ${subject}`);61 if (process.env.EMAIL_DRY_RUN_PRINT === "1") this.log.info(text);62 return { id: null, skipped: true };63 }64 const { data, error } = await this.resend.emails.send({ from, to: [to], subject, html, text, replyTo: opts.replyTo ?? "support@fetcha.co" });65 if (error) {66 this.log.error(`send failed → ${to} | ${subject}`, error);67 throw new Error(`Email delivery failed: ${error.message}`);68 }69 this.log.info(`sent → ${to} | ${subject} | ${data?.id}`);70 return { id: data?.id ?? null };71 }7273 sendVerification(to: string, url: string) {74 return this.send(to, "Confirm your email — Fetcha", React.createElement(VerificationEmail, { url, siteUrl: this.siteUrl }));75 }7677 sendWelcome(to: string, name: string) {78 return this.send(79 to,80 "Welcome to Fetcha",81 React.createElement(WelcomeEmail, { name, siteUrl: this.siteUrl, dashboardUrl: `${this.siteUrl}/dashboard`, docsUrl: `${this.siteUrl}/docs` }),82 );83 }8485 /** Invitation to join the private platform (marketing sender). `signupUrl` should pre-fill the email. */86 sendInvite(to: string, p: { inviterName: string; signupUrl: string }) {87 return this.send(to, "You're invited to Fetcha", React.createElement(InviteEmail, { ...p, siteUrl: this.siteUrl }), { replyTo: "hello@fetcha.co" });88 }8990 sendPasswordReset(to: string, url: string) {91 return this.send(to, "Reset your Fetcha password", React.createElement(PasswordResetEmail, { url, siteUrl: this.siteUrl }), { transactional: true });92 }9394 sendEmailChangeApproval(to: string, url: string, newEmail: string) {95 return this.send(to, "Approve your email change — Fetcha", React.createElement(EmailChangeApprovalEmail, { url, newEmail, siteUrl: this.siteUrl }), { transactional: true });96 }9798 sendEmailChanged(to: string, oldEmail: string, newEmail: string) {99 return this.send(to, "Your Fetcha email address was changed", React.createElement(EmailChangedEmail, { oldEmail, newEmail, siteUrl: this.siteUrl }), { transactional: true });100 }101102 sendApiKeyCreated(to: string, p: { keyName: string; prefix: string; projectName: string }) {103 return this.send(104 to,105 `New API key "${p.keyName}" created — Fetcha`,106 React.createElement(ApiKeyCreatedEmail, { ...p, when: new Date().toUTCString(), siteUrl: this.siteUrl, dashboardUrl: `${this.siteUrl}/dashboard/api-keys` }),107 { transactional: true },108 );109 }110111 sendApiKeyRevoked(to: string, p: { keyName: string; prefix: string }) {112 return this.send(to, `API key "${p.keyName}" revoked — Fetcha`, React.createElement(ApiKeyRevokedEmail, { ...p, siteUrl: this.siteUrl }), { transactional: true });113 }114115 sendNewLogin(to: string, p: { ip: string; userAgent: string }) {116 return this.send(117 to,118 "New sign-in to your Fetcha account",119 React.createElement(NewLoginEmail, { ...p, when: new Date().toUTCString(), siteUrl: this.siteUrl, securityUrl: `${this.siteUrl}/dashboard/settings/security` }),120 { transactional: true },121 );122 }123124 sendUsageAlert(to: string, p: { projectName: string; spent: string; limit: string; kind: "soft" | "hard" }) {125 return this.send(126 to,127 p.kind === "hard" ? `Spending limit reached on ${p.projectName} — Fetcha` : `Usage alert on ${p.projectName} — Fetcha`,128 React.createElement(UsageAlertEmail, { ...p, siteUrl: this.siteUrl, usageUrl: `${this.siteUrl}/dashboard/usage` }),129 { transactional: true },130 );131 }132133 sendAccountDeletion(to: string, url: string) {134 return this.send(to, "Confirm account deletion — Fetcha", React.createElement(AccountDeletionEmail, { url, siteUrl: this.siteUrl }), { transactional: true });135 }136137 sendBilling(to: string, title: string, body: string) {138 return this.send(to, `${title} — Fetcha`, React.createElement(BillingEmail, { title, body, siteUrl: this.siteUrl, billingUrl: `${this.siteUrl}/dashboard/billing` }), { transactional: true });139 }140}141142let _svc: EmailService | null = null;143export function getEmailService(): EmailService {144 if (!_svc) _svc = new EmailService();145 return _svc;146}147