import * as React from "react"; import { render } from "@react-email/render"; import { Resend } from "resend"; import { AccountDeletionEmail, ApiKeyCreatedEmail, ApiKeyRevokedEmail, BillingEmail, EmailChangeApprovalEmail, EmailChangedEmail, InviteEmail, NewLoginEmail, PasswordResetEmail, UsageAlertEmail, VerificationEmail, WelcomeEmail, } from "./templates"; export interface EmailServiceOptions { apiKey?: string; from?: string; fromTransactional?: string; siteUrl?: string; /** When true, emails are logged instead of sent. */ dryRun?: boolean; logger?: { info: (msg: string, meta?: unknown) => void; error: (msg: string, meta?: unknown) => void }; } export interface SendResult { id: string | null; skipped?: boolean; } /** * Email service abstraction. The rest of the platform never touches Resend directly. */ export class EmailService { private readonly resend: Resend | null; private readonly from: string; private readonly fromTx: string; private readonly siteUrl: string; private readonly dryRun: boolean; private readonly log: NonNullable; constructor(opts: EmailServiceOptions = {}) { const apiKey = opts.apiKey ?? process.env.RESEND_API_KEY; this.from = opts.from ?? process.env.EMAIL_FROM ?? "Fetcha "; this.fromTx = opts.fromTransactional ?? process.env.EMAIL_FROM_TRANSACTIONAL ?? "Fetcha "; this.siteUrl = (opts.siteUrl ?? process.env.WEB_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.fetcha.co").replace(/\/$/, ""); this.dryRun = opts.dryRun ?? (!apiKey || process.env.EMAIL_DRY_RUN === "1"); this.resend = apiKey ? new Resend(apiKey) : null; this.log = opts.logger ?? { info: (m, meta) => console.log("[email]", m, meta ?? ""), error: (m, meta) => console.error("[email]", m, meta ?? "") }; } private async send(to: string, subject: string, element: React.ReactElement, opts: { transactional?: boolean; replyTo?: string } = {}): Promise { const html = await render(element); const text = await render(element, { plainText: true }); const from = opts.transactional ? this.fromTx : this.from; if (this.dryRun || !this.resend) { this.log.info(`dry-run → ${to} | ${subject}`); if (process.env.EMAIL_DRY_RUN_PRINT === "1") this.log.info(text); return { id: null, skipped: true }; } const { data, error } = await this.resend.emails.send({ from, to: [to], subject, html, text, replyTo: opts.replyTo ?? "support@fetcha.co" }); if (error) { this.log.error(`send failed → ${to} | ${subject}`, error); throw new Error(`Email delivery failed: ${error.message}`); } this.log.info(`sent → ${to} | ${subject} | ${data?.id}`); return { id: data?.id ?? null }; } sendVerification(to: string, url: string) { return this.send(to, "Confirm your email — Fetcha", React.createElement(VerificationEmail, { url, siteUrl: this.siteUrl })); } sendWelcome(to: string, name: string) { return this.send( to, "Welcome to Fetcha", React.createElement(WelcomeEmail, { name, siteUrl: this.siteUrl, dashboardUrl: `${this.siteUrl}/dashboard`, docsUrl: `${this.siteUrl}/docs` }), ); } /** Invitation to join the private platform (marketing sender). `signupUrl` should pre-fill the email. */ sendInvite(to: string, p: { inviterName: string; signupUrl: string }) { return this.send(to, "You're invited to Fetcha", React.createElement(InviteEmail, { ...p, siteUrl: this.siteUrl }), { replyTo: "hello@fetcha.co" }); } sendPasswordReset(to: string, url: string) { return this.send(to, "Reset your Fetcha password", React.createElement(PasswordResetEmail, { url, siteUrl: this.siteUrl }), { transactional: true }); } sendEmailChangeApproval(to: string, url: string, newEmail: string) { return this.send(to, "Approve your email change — Fetcha", React.createElement(EmailChangeApprovalEmail, { url, newEmail, siteUrl: this.siteUrl }), { transactional: true }); } sendEmailChanged(to: string, oldEmail: string, newEmail: string) { return this.send(to, "Your Fetcha email address was changed", React.createElement(EmailChangedEmail, { oldEmail, newEmail, siteUrl: this.siteUrl }), { transactional: true }); } sendApiKeyCreated(to: string, p: { keyName: string; prefix: string; projectName: string }) { return this.send( to, `New API key "${p.keyName}" created — Fetcha`, React.createElement(ApiKeyCreatedEmail, { ...p, when: new Date().toUTCString(), siteUrl: this.siteUrl, dashboardUrl: `${this.siteUrl}/dashboard/api-keys` }), { transactional: true }, ); } sendApiKeyRevoked(to: string, p: { keyName: string; prefix: string }) { return this.send(to, `API key "${p.keyName}" revoked — Fetcha`, React.createElement(ApiKeyRevokedEmail, { ...p, siteUrl: this.siteUrl }), { transactional: true }); } sendNewLogin(to: string, p: { ip: string; userAgent: string }) { return this.send( to, "New sign-in to your Fetcha account", React.createElement(NewLoginEmail, { ...p, when: new Date().toUTCString(), siteUrl: this.siteUrl, securityUrl: `${this.siteUrl}/dashboard/settings/security` }), { transactional: true }, ); } sendUsageAlert(to: string, p: { projectName: string; spent: string; limit: string; kind: "soft" | "hard" }) { return this.send( to, p.kind === "hard" ? `Spending limit reached on ${p.projectName} — Fetcha` : `Usage alert on ${p.projectName} — Fetcha`, React.createElement(UsageAlertEmail, { ...p, siteUrl: this.siteUrl, usageUrl: `${this.siteUrl}/dashboard/usage` }), { transactional: true }, ); } sendAccountDeletion(to: string, url: string) { return this.send(to, "Confirm account deletion — Fetcha", React.createElement(AccountDeletionEmail, { url, siteUrl: this.siteUrl }), { transactional: true }); } sendBilling(to: string, title: string, body: string) { return this.send(to, `${title} — Fetcha`, React.createElement(BillingEmail, { title, body, siteUrl: this.siteUrl, billingUrl: `${this.siteUrl}/dashboard/billing` }), { transactional: true }); } } let _svc: EmailService | null = null; export function getEmailService(): EmailService { if (!_svc) _svc = new EmailService(); return _svc; }