0%
Vibe44 Logo

Email

Universal Email Adapter — 4 providers with automatic fallback, 13 React Email templates, i18n in 3 languages, background job safeguards.

How It Works

Application Code
    ↓
sendEmail({ to, template, data })     ← reads EMAIL_PROVIDER env var
    ↓
Returns SendGrid | Resend | Postmark | Brevo implementation
    ↓
All 4 implement the same EmailProvider interface
    ↓
Automatic fallback: SendGrid → Resend → Postmark → Brevo

Switch providers with one env var. Zero code changes. EMAIL_PROVIDER is validated before factory resolution. If the selected provider is not configured, the adapter falls back through the configured-key chain in this order: SendGrid, Resend, Postmark, Brevo. Provider send failures are returned to the caller instead of silently retrying through another service.


Providers

ProviderSDKEnv ValueBatch LimitKey Feature
SendGrid@sendgrid/mailsendgrid1000/requestDefault. High deliverability, native parallel send
Resendresendresend100/requestDeveloper-friendly, auto-chunked batches
Postmarkpostmarkpostmark500/requestDeliverability-focused, auto-chunking
Brevo@getbrevo/brevobrevoSequentialPromise.allSettled metrics

Accepted values for EMAIL_PROVIDER: "sendgrid" | "resend" | "postmark" | "brevo"

Postmark auto-chunks batches exceeding 500. Resend auto-chunks at 100. SendGrid supports up to 1000 natively. Brevo sends sequentially via Promise.allSettled.


Provider Factory Contract

lib/email/index.ts is the only application entrypoint for outbound email. Application code calls sendEmail() / sendBatchEmails(); it does not import provider SDKs or provider instances directly.

getEmailProvider() lazy-loads provider adapters with canonical alias imports:

await import("@/lib/email/providers/postmark");
await import("@/lib/email/providers/resend");
await import("@/lib/email/providers/sendgrid");
await import("@/lib/email/providers/brevo");

Do not switch these dynamic imports back to relative paths. The alias import identity keeps runtime code, Vitest mocks, and provider fallback tests resolving the same modules and prevents unit tests from falling through to live network providers.


️ Configuration

# .env.local
EMAIL_PROVIDER=sendgrid  # "sendgrid" | "resend" | "postmark" | "brevo"

# SendGrid (default)
SENDGRID_API_KEY=SG...

# Resend
RESEND_API_KEY=re_...

# Postmark
POSTMARK_API_KEY=...

# Brevo
BREVO_API_KEY=xkeysib-...

# Shared
EMAIL_FROM=[email protected]
EMAIL_FROM_NAME=Your App
EMAIL_REPLY_TO=[email protected]

Type Safety

EmailTemplateType — strict union, not open string:

type EmailTemplateType =
  | "welcome"
  | "passwordReset"
  | "subscriptionConfirmed"
  | "paymentReceipt"
  | "trialExpiring"
  | "contactNotification"
  | "purchaseConfirmation"
  | "waitlistApproved"
  | "onboardingDay3"
  | "orgMemberJoined"
  | "creditsLow"
  | "paymentFailed"
  | "weeklyDigest";

Typos caught at compile time.


Templates

TemplateFileTriggerContent
welcomewelcome.tsxPost-signupWelcome + getting started CTA
passwordResetpassword-reset.tsxForgot passwordReset link
subscriptionConfirmedsubscription-confirmed.tsxCheckout completeConfirmation + plan details
paymentReceiptpayment-receipt.tsxPayment processedReceipt + invoice link
trialExpiringtrial-expiring.tsxTrial nearing endUpgrade CTA
contactNotificationcontact-notification.tsxContact form submittedSuper-Admin notification
creditsLowcredits-low.tsxCredits below thresholdTop-up CTA
onboardingDay3onboarding-day3.tsxDay 3 post-signupEngagement nudge
orgMemberJoinedorg-member-joined.tsxTeam member joinsJoin notification
paymentFailedpayment-failed.tsxPayment failsUpdate payment method CTA
purchaseConfirmationpurchase-confirmation.tsxOne-time purchasePurchase confirmation
waitlistApprovedwaitlist-approved.tsxWaitlist approvalAccess granted CTA
weeklyDigestweekly-digest.tsxWeekly summaryActivity digest

i18n Content

locales/emails/
├── en.ts   # English (source of truth)
├── es.ts   # Spanish
└── fr.ts   # French
import { getEmailContent } from "@/locales/emails";
const content = getEmailContent("welcome", "es");

Waitlist Localization

The waitlist system captures the user's active locale (from the NEXT_LOCALE cookie) during the joinWaitlist submission.

  1. Persistence: The locale is stored in the waitlist_subscribers.locale column.
  2. Trigger: When an administrator approves a subscriber, the system retrieves this locale and passes it to the sendEmail function.
  3. Result: The waitlistApproved template is automatically rendered in the user's preferred language (English, Spanish, or French).

Usage

import { sendEmail } from "@/lib/email";

await sendEmail({
  to: "[email protected]",
  template: "welcome",
  data: { name: "John", locale: "en" },
});

Notification Preferences

Settings → Notifications:

SettingColumnDefault
Marketing emailsmarketing_emailstrue
Security alertssecurity_emailstrue

Stored in notification_preferences. All send functions respect these.


Background Safeguards

SafeguardImplementation
XSS preventionescapeHtml() sanitizes user.fullName before HTML
Validated deliveryresult.value.success checked before stats
Dynamic pricingPlan names from getPricingTier(), never hardcoded

Adding a Template

Follow .agents/workflows/add-email-template.md:

  1. Define content in locales/emails/{en,es,fr}.ts (all 3 languages)
  2. Add to EmailTemplateType union
  3. Create rendering function in lib/email/templates/
  4. Trigger from Server Action or background job

Copyright © 2026 Vibe44. Built with ❤️ by Scale with Alap.