SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
6.2 KB · 147 lines typescript
Raw Blame History
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