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 → BrevoSwitch 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
| Provider | SDK | Env Value | Batch Limit | Key Feature |
|---|---|---|---|---|
| SendGrid | @sendgrid/mail | sendgrid | 1000/request | Default. High deliverability, native parallel send |
| Resend | resend | resend | 100/request | Developer-friendly, auto-chunked batches |
| Postmark | postmark | postmark | 500/request | Deliverability-focused, auto-chunking |
| Brevo | @getbrevo/brevo | brevo | Sequential | Promise.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
| Template | File | Trigger | Content |
|---|---|---|---|
welcome | welcome.tsx | Post-signup | Welcome + getting started CTA |
passwordReset | password-reset.tsx | Forgot password | Reset link |
subscriptionConfirmed | subscription-confirmed.tsx | Checkout complete | Confirmation + plan details |
paymentReceipt | payment-receipt.tsx | Payment processed | Receipt + invoice link |
trialExpiring | trial-expiring.tsx | Trial nearing end | Upgrade CTA |
contactNotification | contact-notification.tsx | Contact form submitted | Super-Admin notification |
creditsLow | credits-low.tsx | Credits below threshold | Top-up CTA |
onboardingDay3 | onboarding-day3.tsx | Day 3 post-signup | Engagement nudge |
orgMemberJoined | org-member-joined.tsx | Team member joins | Join notification |
paymentFailed | payment-failed.tsx | Payment fails | Update payment method CTA |
purchaseConfirmation | purchase-confirmation.tsx | One-time purchase | Purchase confirmation |
waitlistApproved | waitlist-approved.tsx | Waitlist approval | Access granted CTA |
weeklyDigest | weekly-digest.tsx | Weekly summary | Activity digest |
i18n Content
locales/emails/
├── en.ts # English (source of truth)
├── es.ts # Spanish
└── fr.ts # Frenchimport { 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.
- Persistence: The locale is stored in the
waitlist_subscribers.localecolumn. - Trigger: When an administrator approves a subscriber, the system retrieves this locale and passes it to the
sendEmailfunction. - Result: The
waitlistApprovedtemplate 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:
| Setting | Column | Default |
|---|---|---|
| Marketing emails | marketing_emails | true |
| Security alerts | security_emails | true |
Stored in notification_preferences. All send functions respect these.
Background Safeguards
| Safeguard | Implementation |
|---|---|
| XSS prevention | escapeHtml() sanitizes user.fullName before HTML |
| Validated delivery | result.value.success checked before stats |
| Dynamic pricing | Plan names from getPricingTier(), never hardcoded |
Adding a Template
Follow .agents/workflows/add-email-template.md:
- Define content in
locales/emails/{en,es,fr}.ts(all 3 languages) - Add to
EmailTemplateTypeunion - Create rendering function in
lib/email/templates/ - Trigger from Server Action or background job
Copyright © 2026 Vibe44. Built with ❤️ by Scale with Alap.