---
title: "Email Templates and Delivery"
description: "The email templates and delivery system provides a robust, multi-transport dispatch infrastructure built around React Email component composition and automated background queues. It unifies outboun..."
last_updated: "2026-10-05T05:07:35.182658+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/automation-and-communications/email-templates-and-delivery"
---

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [apps/web/app/ee/api/cron/send-batch-email/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/send-batch-email/route.ts)
- [packages/email/src/send-via-resend.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts)
- [apps/web/app/api/resend/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/resend/webhook/route.ts)
- [packages/email/src/templates/webhook-failed.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-failed.tsx)
- [apps/web/app/ee/api/campaigns/campaignId/preview/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts)
- [apps/web/app/ee/api/email-domains/domain/forward-instructions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/email-domains/%5Bdomain%5D/forward-instructions/route.ts)
- [packages/email/src/templates/webhook-disabled.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-disabled.tsx)
- [apps/web/app/ee/api/embed/referrals/tremendous/send-otp/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/tremendous/send-otp/route.ts)
- [packages/email/src/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/index.ts)
- [apps/web/lib/partners/create-stripe-transfer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/create-stripe-transfer.ts)
- [packages/email/src/templates/webhook-added.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-added.tsx)
- [packages/email/src/send-via-nodemailer.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-nodemailer.ts)
- [apps/web/lib/email/queue-batch-email.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/queue-batch-email.ts)
- [packages/email/src/templates/lead-status-updated.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/lead-status-updated.tsx)
- [packages/email/src/templates/new-submitted-lead-comments-from-program.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-submitted-lead-comments-from-program.tsx)
- [apps/web/app/ee/api/cron/email-domains/verify/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/email-domains/verify/route.ts)
- [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts)
- [packages/email/src/resend/client.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/resend/client.ts)
- [packages/email/src/templates/partner-tremendous-verify-email.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/partner-tremendous-verify-email.tsx)
- [packages/email/src/templates/verify-email.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/verify-email.tsx)
- [apps/web/lib/email/render-trial-email.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/render-trial-email.tsx)
- [apps/web/lib/webhook/failure.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/failure.ts)
- [packages/email/src/templates/email-domain-status-changed.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/email-domain-status-changed.tsx)
- [apps/web/app/ee/api/cron/trial-emails/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/trial-emails/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/domains/email/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/domains/email/page-client.tsx)
- [apps/web/lib/email/email-templates-map.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/email-templates-map.ts)
- [packages/email/src/templates/new-submitted-lead-comments-from-partner.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-submitted-lead-comments-from-partner.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/invite-email-preview.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/invite-email-preview.tsx)
- [packages/email/src/templates/new-message-from-partner.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-message-from-partner.tsx)
- [packages/email/src/templates/feedback-email.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/feedback-email.tsx)
</details>

## Overview

The email templates and delivery system provides a robust, multi-transport dispatch infrastructure built around React Email component composition and automated background queues. It unifies outbound messaging across production and development environments by switching dynamically between Resend and Nodemailer transports, validating recipient restrictions, and handling asynchronous batch processing via QStash. The architecture also integrates custom email domain verification routines, interactive campaign preview flows, and webhook ingestion listeners to alert workspace owners of delivery failures.

Sources: [apps/web/app/ee/api/cron/send-batch-email/route.ts:4-5](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/send-batch-email/route.ts#L4-L5), [packages/email/src/send-via-resend.ts:8-33](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L8-L33), [packages/email/src/index.ts:6-24](https://github.com/blade47/dub/blob/HEAD/packages/email/src/index.ts#L6-L24), [apps/web/lib/email/queue-batch-email.ts:14-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/queue-batch-email.ts#L14-L16), [apps/web/app/ee/api/cron/email-domains/verify/route.ts:21-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/email-domains/verify/route.ts#L21-L36)

## Core Email Dispatch Architecture

### Core Email Dispatch Architecture

The public export surface of the email package handles outbound communication through two primary entry points: `sendEmail` and `sendBatchEmail`. Both functions implement a fallback dispatch pattern that checks for an initialized Resend client before falling back to an SMTP configuration via Nodemailer. If neither transport is configured, the dispatch functions log an informational message and return safely without throwing unhandled exceptions.

Sources: [packages/email/src/index.ts:1-72](https://github.com/blade47/dub/blob/HEAD/packages/email/src/index.ts#L1-L72)

### Dispatch Switching Logic

When `sendEmail` is invoked with `ResendEmailOptions`, it first evaluates whether the `resend` client instance is present. If active, it delegates immediately to `sendEmailViaResend`. When Resend is absent, it inspects `process.env.SMTP_HOST` and `process.env.SMTP_PORT` to determine if SMTP is configured. If `smtpConfigured` evaluates to true, it extracts `to`, `subject`, `text`, and `react` properties and dispatches via `sendViaNodeMailer`. 

```mermaid
graph TD
    A[sendEmail / sendBatchEmail] --> B{resend client initialized?}
    B -- Yes --> C[Dispatch via Resend API]
    B -- No --> D{SMTP_HOST & SMTP_PORT set?}
    D -- Yes --> E[Dispatch via Nodemailer SMTP]
    D -- No --> F[Log configuration warning & exit]
```

Sources: [packages/email/src/index.ts:6-29](https://github.com/blade47/dub/blob/HEAD/packages/email/src/index.ts#L6-L29), [packages/email/src/resend/client.ts:3-5](https://github.com/blade47/dub/blob/HEAD/packages/email/src/resend/client.ts#L3-L5)

> [!NOTE]
> The batch dispatch wrapper `sendBatchEmail` accepts an array of bulk email options alongside an optional `idempotencyKey`. Under the SMTP fallback path, it executes individual mail transmissions concurrently using `Promise.all` and returns a generated structure containing random UUID identifiers mapped to each recipient.

Sources: [packages/email/src/index.ts:31-72](https://github.com/blade47/dub/blob/HEAD/packages/email/src/index.ts#L31-L72)

### Transport Mechanisms and Configuration

The underlying transport adapters initialize differently based on their target service. The Resend client instantiates using `process.env.RESEND_API_KEY`, while Nodemailer constructs an SMTP transporter with insecure TLS rejection overrides (`tls: { rejectUnauthorized: false }`) and renders React Email components into HTML strings using `@react-email/render` and `pretty`.

| Transport Module | Primary File | Core Function | Configuration Dependency |
| :--- | :--- | :--- | :--- |
| Resend Single | [packages/email/src/send-via-resend.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts) | `sendEmailViaResend` | `RESEND_API_KEY` |
| Resend Batch | [packages/email/src/send-via-resend.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts) | `sendBatchEmailViaResend` | `RESEND_API_KEY` |
| Nodemailer | [packages/email/src/send-via-nodemailer.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-nodemailer.ts) | `sendViaNodeMailer` | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD` |
| Client Init | [packages/email/src/resend/client.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/resend/client.ts) | `resend` export | `RESEND_API_KEY` |

Sources: [packages/email/src/send-via-resend.ts:92-153](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L92-L153), [packages/email/src/send-via-nodemailer.ts:6-35](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-nodemailer.ts#L6-L35), [packages/email/src/resend/client.ts:3-5](https://github.com/blade47/dub/blob/HEAD/packages/email/src/resend/client.ts#L3-L5)

## Recipient Filtering and Address Rewriting

### Recipient Filtering and Address Rewriting

### Blocked Recipient Detection and Sandbox Rewriting

Resend rejects emails sent to reserved test domains with a 422 status code. To prevent delivery failures during testing or previews, the email delivery pipeline intercepts outgoing addresses and evaluates them against a static list of reserved domains (`example.com`, `example.net`, `example.org`, and `test.com`). The helper function `isResendBlockedRecipient` normalizes the target email address by converting it to lowercase, trimming whitespace, extracting the domain portion after the `@` symbol, and testing whether it matches or subdomains any entry in `RESEND_BLOCKED_DOMAINS`.

Sources: [packages/email/src/send-via-resend.ts:8-22](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L8-L22)

> [!WARNING]
> When `VERCEL_ENV` is set to `"preview"`, recipient addresses are unconditionally overridden with `"delivered@resend.dev"` regardless of the original target. In non-preview environments, addresses matching `RESEND_BLOCKED_DOMAINS` are rewritten by `rewriteBlockedRecipient` to `delivered+username@resend.dev` (where `username` is extracted from the local part of the original address) to preserve batch cardinality for zipping Resend IDs by index while routing through Resend's test sink.

Sources: [packages/email/src/send-via-resend.ts:24-33](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L24-L33), [packages/email/src/send-via-resend.ts:53-59](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L53-L59)

### Resend Delivery Options Compilation

The `resendEmailForOptions` function transforms incoming `ResendEmailOptions` into the standard `CreateEmailOptions` structure required by the Resend SDK. It orchestrates recipient rewriting, sender resolution via `VARIANT_TO_FROM_MAP`, conditional branch evaluation for reply-to fallbacks (`support@dub.co` or omission when set to `"noreply"`), and marketing list-unsubscribe header injections.

Sources: [packages/email/src/send-via-resend.ts:35-77](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L35-L77)

The compilation and dispatch call chain proceeds through specific internal layers before hitting the external SDK client:

`sendEmailViaResend()` or `sendBatchEmailViaResend()` → checks `resend` client initialization → `resendEmailForOptions()` → `isResendBlockedRecipient()` → `rewriteBlockedRecipient()` → `resend.emails.send()` or `resend.batch.send()`.

Sources: [packages/email/src/send-via-resend.ts:35-101](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L35-L101), [packages/email/src/send-via-resend.ts:127-153](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L127-L153)

| Constant / Helper | Value / Target | Purpose |
| :--- | :--- | :--- |
| `RESEND_BLOCKED_DOMAINS` | `example.com`, `example.net`, `example.org`, `test.com` | Identifies reserved domains that trigger Resend 422 errors |
| Preview Recipient | `delivered@resend.dev` | Fallback recipient used across all preview deployment environments |
| Blocked Rewrite Pattern | `delivered+{username}@resend.dev` | Preserves batch index cardinality for blocked test addresses |
| Default Reply-To | `support@dub.co` | Fallback address when `replyTo` is omitted (`noreply` omits the field entirely) |
| Marketing Unsubscribe | `https://app.dub.co/account/settings` | Default `List-Unsubscribe` header URL for marketing variants |

Sources: [packages/email/src/send-via-resend.ts:8-13](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L8-L13), [packages/email/src/send-via-resend.ts:25-30](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L25-L30), [packages/email/src/send-via-resend.ts:59](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L59), [packages/email/src/send-via-resend.ts:65](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L65), [packages/email/src/send-via-resend.ts:72-73](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L72-L73)

### Batch Processing and Filtering

When `sendBatchEmailViaResend` executes, it verifies that the input array is non-empty and filters out any item lacking a `to` address via `Array.reduce`. Each valid email option object is mapped through `resendEmailForOptions` to produce a `filteredBatch`. If an `idempotencyKey` option is provided, it is forwarded directly to `resend.batch.send`.

Sources: [packages/email/src/send-via-resend.ts:103-153](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L103-L153)

| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Static blocklist matching (`RESEND_BLOCKED_DOMAINS`) | Prevents upstream 422 API rejections with zero external roundtrips | Requires manual maintenance if Resend adds reserved domains |
| Cardinality-preserving address rewriting (`delivered+user@resend.dev`) | Maintains exact batch array lengths so callers can zip returned Resend IDs | Rewrites destination mailboxes to a test sink, preventing real receipt |
| Conditional body rendering (`react` vs `text` check) | Ensures `CreateEmailOptions` always receives at least one valid render payload | Forces fallback to an empty string `text: ""` if both are absent |

Sources: [packages/email/src/send-via-resend.ts:8-22](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L8-L22), [packages/email/src/send-via-resend.ts:24-33](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L24-L33), [packages/email/src/send-via-resend.ts:79-88](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L79-L88)

## Asynchronous Batch Dispatch and QStash

### Overview

Bulk email delivery in Dub is handled asynchronously by chunking recipient lists and queueing them through QStash. The `queueBatchEmail()` function splits large arrays into deterministic chunks of 100 recipients (`BATCH_SIZE`) and pushes each chunk to the QStash `send-batch-email` queue. When idempotency keys are supplied, they are automatically suffixed per batch index (e.g., `${options.idempotencyKey}-batch-${i}`) to provide both QStash deduplication and Resend upstream deduplication.

Sources: [apps/web/lib/email/queue-batch-email.ts:12-60](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/queue-batch-email.ts#L12-L60)

### Template Mapping and Execution Walkthrough

When QStash delivers a batch payload to the cron endpoint (`POST /api/cron/send-batch-email`), the request flows through a rigid verification and execution sequence:

`POST` handler → `verifyQstashSignature()` → `batchEmailPayloadSchema.parse()` → `Promise.allSettled()` iteration → `EMAIL_TEMPLATES_MAP[emailItem.templateName]` lookup → `React.createElement()` rendering → `sendBatchEmail()` → `NextResponse.json()` confirmation.

Sources: [apps/web/app/ee/api/cron/send-batch-email/route.ts:38-147](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/send-batch-email/route.ts#L38-L147)

> [!WARNING]
> If any individual email item in a batch references an unknown template name that is absent from `EMAIL_TEMPLATES_MAP`, that specific item fails its promise settlement, logs a database error with `log()`, and appends an error object to the response while allowing valid items in the batch to proceed.

Sources: [apps/web/app/ee/api/cron/send-batch-email/route.ts:56-121](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/send-batch-email/route.ts#L56-L121)

### Registered Email Templates

The `EMAIL_TEMPLATES_MAP` object binds string identifiers to their respective React email components, supporting standard administrative notifications as well as broadcast announcements.

| Template Key | Source Component Path | Primary Purpose |
| :--- | :--- | :--- |
| `BountyApproved` | `@dub/email/templates/bounty-approved` | Partner bounty reward approval confirmation |
| `ConnectPayoutReminder` | `@dub/email/templates/connect-payout-reminder` | Stripe Connect payout setup reminder |
| `ConnectPlatformsReminder` | `@dub/email/templates/connect-platforms-reminder` | Platform integration onboarding reminder |
| `PartnerPayoutConfirmed` | `@dub/email/templates/partner-payout-confirmed` | Confirmation of confirmed partner payout |
| `PartnerPayoutProcessed` | `@dub/email/templates/partner-payout-processed` | Notification that partner payout has been sent |
| `PartnerDeactivated` | `@dub/email/templates/partner-deactivated` | Partner program account deactivation notice |
| `PartnerBanned` | `@dub/email/templates/partner-banned` | Notice of partner program ban |
| `ProgramPayoutThankYou` | `@dub/email/templates/program-payout-thank-you` | Gratitude notice following program payouts |
| `UnresolvedRiskEventsSummary` | `@dub/email/templates/unresolved-risk-events-summary` | Security and risk events digest summary |
| `PartnerGroupChanged` | `@dub/email/templates/partner-group-changed` | Partner commission tier or group change notice |
| `PartnerRewardUpdated` | `@dub/email/templates/partner-reward-updated` | Partner reward structure update alert |
| `WorkspaceDisabled` | `@dub/email/templates/workspace-disabled` | Workspace suspension notice |
| `DubStartupProgramAnnouncement` | `@dub/email/templates/broadcasts/dub-startup-program-announcement` | Special broadcast for startup program applicants |
| `DubProductUpdateSummer26` | `@dub/email/templates/broadcasts/dub-product-update-summer26` | Seasonal product update broadcast |

Sources: [apps/web/lib/email/email-templates-map.ts:1-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/email-templates-map.ts#L1-L32)

### Queue Architecture Design Trade-offs

| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Fixed chunk size (`BATCH_SIZE = 100`) | Prevents HTTP payload size violations and keeps memory bounded | Multi-batch jobs require sequential queue entries per chunk |
| Indexed idempotency key suffixes (`-batch-{i}`) | Enables precise retry deduplication across split chunks | Requires callers to pass unique root idempotency keys |
| Parallel template rendering (`Promise.allSettled`) | Speeds up batch compilation without failing the entire batch on one bad template | Consumes memory rendering all React components simultaneously per batch |

Sources: [apps/web/app/ee/api/cron/send-batch-email/route.ts:54-87](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/send-batch-email/route.ts#L54-L87), [apps/web/lib/email/queue-batch-email.ts:12-46](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/queue-batch-email.ts#L12-L46)

## React Email Template Design System

### Overview

The email package defines template layouts using React Email components combined with Tailwind CSS styling. Each template establishes an HTML structure wrapped in standard metadata containers, wordmark branding elements, and responsive layout wrappers.

Sources: [packages/email/src/templates/webhook-failed.tsx:43-48](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-failed.tsx#L43-L48), [packages/email/src/templates/verify-email.tsx:24-32](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/verify-email.tsx#L24-L32)

### Shared Footers and Layout Elements

Templates accept structured configuration props and render consistent branding components. The `DUB_WORDMARK` asset is consistently loaded across templates via `Img`, while the reusable `Footer` component handles recipient email display and profile notification links.

| Template Component | Primary Action / Link | Footer Configuration |
| :--- | :--- | :--- |
| `WebhookFailed` | Edit Webhook ([/settings/webhooks/[id]/edit](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-failed.tsx#L67)) | `email={email}` |
| `WebhookDisabled` | Edit Webhook ([/settings/webhooks/[id]/edit](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-disabled.tsx#L65)) | `email={email}` |
| `WebhookAdded` | View Webhook ([/settings/webhooks](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-added.tsx#L56)) | `email={email}` |
| `VerifyEmail` | Verification Code (`10 min` expiry) | `email={email}` |
| `PartnerTremendousVerifyEmail` | Gift Card Payout Verification (`expiryMinutes` prop) | `email={email}` |
| `FeedbackEmail` | Direct feedback text display | No footer component |

Sources: [packages/email/src/templates/webhook-failed.tsx:17-73](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-failed.tsx#L17-L73), [packages/email/src/templates/webhook-disabled.tsx:17-71](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-disabled.tsx#L17-L71), [packages/email/src/templates/webhook-added.tsx:17-72](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-added.tsx#L17-L72), [packages/email/src/templates/verify-email.tsx:16-48](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/verify-email.tsx#L16-L48), [packages/email/src/templates/partner-tremendous-verify-email.tsx:16-56](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/partner-tremendous-verify-email.tsx#L16-L56), [packages/email/src/templates/feedback-email.tsx:15-41](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/feedback-email.tsx#L15-L41)

### Lead and Notification Layouts

Complex notification templates render participant profiles, lead cards, and threaded messages. `NewMessageFromPartner` enforces a display cap using `MAX_DISPLAYED_MESSAGES = 3` and appends an overflow text indicator when message counts exceed this threshold.

```typescript
const MAX_DISPLAYED_MESSAGES = 3;
```

Sources: [packages/email/src/templates/new-message-from-partner.tsx:24-24](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-message-from-partner.tsx#L24-L24)

> [!NOTE]
> `NewMessageFromPartner` truncates rendered message rows at `MAX_DISPLAYED_MESSAGES` but calculates overflow totals using `messages.length - MAX_DISPLAYED_MESSAGES` to inform recipients of additional unrendered messages in the thread.

Sources: [packages/email/src/templates/new-message-from-partner.tsx:99-138](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-message-from-partner.tsx#L99-L138)

| Lead / Notification Template | Key Props Rendered | Interactive Links / Destinations |
| :--- | :--- | :--- |
| `LeadStatusUpdated` | `partner`, `program`, `lead`, `notes` | Notification settings profile URL |
| `NewSubmittedLeadCommentsFromProgram` | `program`, `lead`, `comments`, `email` | `partners.dub.co/programs/{slug}/leads?leadId={id}` |
| `NewSubmittedLeadCommentsFromPartner` | `workspace`, `partner`, `lead`, `comments`, `email` | `app.dub.co/{slug}/program/leads?leadId={id}` |
| `NewMessageFromPartner` | `workspaceSlug`, `partner`, `messages`, `email` | `app.dub.co/{slug}/program/messages/{partnerId}` |

Sources: [packages/email/src/templates/lead-status-updated.tsx:16-44](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/lead-status-updated.tsx#L16-L44), [packages/email/src/templates/new-submitted-lead-comments-from-program.tsx:22-61](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-submitted-lead-comments-from-program.tsx#L22-L61), [packages/email/src/templates/new-submitted-lead-comments-from-partner.tsx:20-58](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-submitted-lead-comments-from-partner.tsx#L20-L58), [packages/email/src/templates/new-message-from-partner.tsx:26-69](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-message-from-partner.tsx#L26-L69)

## Custom Domains and Campaign Previews

### Overview

Custom email domains undergo periodic verification checks and permit campaign preview transmissions. When custom domains require configuration, instructions can be emailed directly to target recipients or managed via domain status change handlers. Campaign previews render test communications using default template variables and enforce domain ownership rules.

Sources: [apps/web/app/ee/api/campaigns/campaignId/preview/route.ts:1-134](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L1-L134), [apps/web/app/ee/api/email-domains/domain/forward-instructions/route.ts:1-98](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/email-domains/%5Bdomain%5D/forward-instructions/route.ts#L1-L98), [apps/web/app/ee/api/cron/email-domains/verify/route.ts:1-134](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/email-domains/verify/route.ts#L1-L134)

### DNS Instruction Forwarding and Rate Limiting

The DNS forwarding endpoint (`POST /api/email-domains/[domain]/forward-instructions`) retrieves Resend domain records, maps them to forward rows, and dispatches them via email. The route applies strict rate limiting policies before interacting with Resend or sending messages.

```typescript
// Rate limit policies applied in forward-instructions
await assertRateLimit({
  policy: RATELIMIT_POLICIES.forwardDnsInstructions,
  identifier: [workspace.id, session.user.id],
});

await assertRateLimit({
  policy: RATELIMIT_POLICIES.forwardDnsInstructionsTarget,
  identifier: email.toLowerCase(),
});
```

Sources: [apps/web/app/ee/api/email-domains/domain/forward-instructions/route.ts:31-39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/email-domains/%5Bdomain%5D/forward-instructions/route.ts#L31-L39)

> [!WARNING]
> If `resendDomainId` is missing from the email domain or Resend returns zero records, the forward-instructions route throws a `bad_request` or `internal_server_error` DubApiError, halting email delivery.

Sources: [apps/web/app/ee/api/email-domains/domain/forward-instructions/route.ts:48-71](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/email-domains/%5Bdomain%5D/forward-instructions/route.ts#L48-L71)

### Domain Verification Cron and Status Mapping

The email domain verification cron (`GET /api/cron/email-domains/verify`) executes hourly (`0 * * * *`), querying up to 10 email domains ordered by `lastChecked: asc` that possess a `resendDomainId`.

| Email Domain Status | Notification Subject Line |
| :--- | :--- |
| `verified` | `Your email domain has been verified` |
| `failed` | `Your email domain verification has failed` |
| `partially_failed` | `Your email domain verification has failed` |

Sources: [apps/web/app/ee/api/cron/email-domains/verify/route.ts:12-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/email-domains/verify/route.ts#L12-L36), [packages/email/src/templates/email-domain-status-changed.tsx:17-24](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/email-domain-status-changed.tsx#L17-L24)

> [!NOTE]
> Only workspace owners with the `domainConfigurationUpdates` notification preference receive status change emails when verification transitions occur.

Sources: [apps/web/app/ee/api/cron/email-domains/verify/route.ts:92-96](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/email-domains/verify/route.ts#L92-L96)

### Campaign Preview Rendering and Validation

The campaign preview endpoint (`POST /api/campaigns/[campaignId]/preview`) validates that test recipient counts remain between 1 and 10 addresses. If a custom `from` address is specified, it parses the address and verifies that the domain matches a verified email domain linked to the workspace program.

```typescript
const sendPreviewEmailSchema = CampaignSchema.pick({
  subject: true,
  preview: true,
  bodyJson: true,
}).extend({
  from: campaignFromSchema.optional(),
  emailAddresses: z
    .array(z.email())
    .min(1)
    .max(10, "Maximum 10 email addresses allowed."),
});
```

Sources: [apps/web/app/ee/api/campaigns/campaignId/preview/route.ts:24-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L24-L34)

## Resend Webhook Ingestion and Failures

### Overview

The Resend Webhook ingestion pipeline processes external delivery events originating from Resend, while companion workers handle consecutive delivery failures and workspace owner notifications. Incoming webhooks are received at `POST /api/resend/webhook`, where payload integrity is verified using Svix headers before routing events to specific handlers based on the event type (`email.opened`, `email.delivered`, or `email.bounced`).

Sources: [apps/web/app/api/resend/webhook/route.ts:10-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/resend/webhook/route.ts#L10-L34)

### Webhook Verification and Event Routing Walkthrough

Incoming requests to the Resend webhook endpoint execute a multi-step verification and dispatch procedure:

1. `req.text()` — Extracts the raw request body as a string.
2. `Webhook.verify()` — Validates the Svix signature using `svix-id`, `svix-timestamp`, and `svix-signature` request headers against `process.env.RESEND_WEBHOOK_SECRET`, throwing an error on failure.
3. `JSON.parse()` — Parses the verified raw body to extract the `type` and `data` properties.
4. `switch (type)` — Dispatches the event payload to its corresponding handler function.

```typescript
const rawBody = await req.text();
const webhook = new Webhook(webhookSecret);

webhook.verify(rawBody, {
  "svix-id": req.headers.get("svix-id")!,
  "svix-timestamp": req.headers.get("svix-timestamp")!,
  "svix-signature": req.headers.get("svix-signature")!,
});

const { type, data } = JSON.parse(rawBody) || {};

switch (type) {
  case "email.opened":
    await emailOpened(data);
    break;
  case "email.delivered":
    await emailDelivered(data);
    break;
  case "email.bounced":
    await emailBounced(data);
    break;
}
```

Sources: [apps/web/app/api/resend/webhook/route.ts:12-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/resend/webhook/route.ts#L12-L34)

> [!CAUTION]
> Omitting any of the three Svix validation headers (`svix-id`, `svix-timestamp`, or `svix-signature`) causes `webhook.verify()` to throw an immediate validation error, aborting webhook processing and returning a 500-level response.

Sources: [apps/web/app/api/resend/webhook/route.ts:16-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/resend/webhook/route.ts#L16-L20)

### Webhook Failure Handling and Notification Thresholds

When external webhook endpoints fail to receive dispatches, the failure management utility (`handleWebhookFailure`) increments the `consecutiveFailures` counter and updates `lastFailedAt` on the webhook record.

Sources: [apps/web/lib/webhook/failure.ts:12-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/failure.ts#L12-L31)

| Condition / Threshold | Trigger Action | Executed Functions |
| :--- | :--- | :--- |
| `webhook.disabledAt` present | None (skips execution) | Early return |
| `WEBHOOK_FAILURE_NOTIFY_THRESHOLDS` matched | Sends failure notification email to workspace owners | `notifyWebhookFailure()` |
| `consecutiveFailures >= WEBHOOK_FAILURE_DISABLE_THRESHOLD` | Disables webhook, alerts owners, and syncs status | `notifyWebhookDisabled()`, `syncWorkspaceWebhookStatus()` |

Sources: [apps/web/lib/webhook/failure.ts:33-62](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/failure.ts#L33-L62)

> [!TIP]
> Webhook failure counters can be completely reset by invoking `resetWebhookFailureCount(webhookId)`, which sets `consecutiveFailures` back to `0` and clears `lastFailedAt`.

Sources: [apps/web/lib/webhook/failure.ts:65-73](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/failure.ts#L65-L73)

## Related

- [Campaign Broadcaster](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/automation-and-communications/campaign-broadcaster)
- [Partner Portal and Onboarding](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/partner-portal-and-onboarding)


## Sitemap

See the full [sitemap](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/llms.txt) for all pages in this wiki.
