Getting Started
Core Architecture
Link Engine
Analytics & Attribution
Partners & Affiliates
Third-Party Integrations
Identity & Security
Automation & Messaging
Developer Tools
The following files were used as context for generating this wiki page:
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, packages/email/src/send-via-resend.ts:8-33, packages/email/src/index.ts:6-24, apps/web/lib/email/queue-batch-email.ts:14-16, apps/web/app/ee/api/cron/email-domains/verify/route.ts:21-36
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
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.
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
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.
Sources: packages/email/src/send-via-resend.ts:92-153, packages/email/src/send-via-nodemailer.ts:6-35, packages/email/src/resend/client.ts:3-5
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.
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.
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.
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, packages/email/src/send-via-resend.ts:127-153
Sources: packages/email/src/send-via-resend.ts:8-13, packages/email/src/send-via-resend.ts:25-30, packages/email/src/send-via-resend.ts:59, packages/email/src/send-via-resend.ts:65, packages/email/src/send-via-resend.ts:72-73
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:8-22, packages/email/src/send-via-resend.ts:24-33, packages/email/src/send-via-resend.ts:79-88
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.
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.
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.
The EMAIL_TEMPLATES_MAP object binds string identifiers to their respective React email components, supporting standard administrative notifications as well as broadcast announcements.
Sources: apps/web/app/ee/api/cron/send-batch-email/route.ts:54-87, apps/web/lib/email/queue-batch-email.ts:12-46
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, packages/email/src/templates/verify-email.tsx:24-32
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.
Sources: packages/email/src/templates/webhook-failed.tsx:17-73, packages/email/src/templates/webhook-disabled.tsx:17-71, packages/email/src/templates/webhook-added.tsx:17-72, packages/email/src/templates/verify-email.tsx:16-48, packages/email/src/templates/partner-tremendous-verify-email.tsx:16-56, packages/email/src/templates/feedback-email.tsx:15-41
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.
const MAX_DISPLAYED_MESSAGES = 3;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/lead-status-updated.tsx:16-44, packages/email/src/templates/new-submitted-lead-comments-from-program.tsx:22-61, packages/email/src/templates/new-submitted-lead-comments-from-partner.tsx:20-58, packages/email/src/templates/new-message-from-partner.tsx:26-69
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, apps/web/app/ee/api/email-domains/domain/forward-instructions/route.ts:1-98, apps/web/app/ee/api/cron/email-domains/verify/route.ts:1-134
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.
// 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(),
});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.
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.
Sources: apps/web/app/ee/api/cron/email-domains/verify/route.ts:12-36, packages/email/src/templates/email-domain-status-changed.tsx:17-24
Note
Only workspace owners with the domainConfigurationUpdates notification preference receive status change emails when verification transitions occur.
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.
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."),
});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).
Incoming requests to the Resend webhook endpoint execute a multi-step verification and dispatch procedure:
req.text() — Extracts the raw request body as a string.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.JSON.parse() — Parses the verified raw body to extract the type and data properties.switch (type) — Dispatches the event payload to its corresponding handler function.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;
}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.
When external webhook endpoints fail to receive dispatches, the failure management utility (handleWebhookFailure) increments the consecutiveFailures counter and updates lastFailedAt on the webhook record.
Tip
Webhook failure counters can be completely reset by invoking resetWebhookFailureCount(webhookId), which sets consecutiveFailures back to 0 and clears lastFailedAt.