Email Campaigns
Setting up and running email campaigns with templates, lead targeting, and delivery tracking
Email Campaigns
Jose Madrid Salsa includes a full email campaign system built on Resend for transactional email delivery. This guide covers setting up campaigns, creating templates, and monitoring delivery.
Architecture Overview
The email system is organized into several modules:
Prerequisites
Required Environment Variables
RESEND_API_KEY="re_..." # Resend API key
FROM_EMAIL="hello@josemadrid.net" # Sender email addressIf RESEND_API_KEY is not set, all email operations are silently skipped with a console warning.
Creating a Campaign
Create an Email Template
Navigate to /admin/email-templates to create a new template. Templates support variable interpolation using double-brace syntax:
<h1>Hello {{contact_name}},</h1>
<p>We'd love to partner with {{school_name}} for their {{sport}} program.</p>
<p>{{sport_pitch}}</p>Available template variables:
{{school_name}}- Organization name{{contact_name}}- Contact person name{{title}}- Contact's title (e.g., "Athletic Director"){{sport}}- Sport name{{sport_pitch}}- Auto-generated pitch based on sport type{{city}}- City{{state}}- State
Create a Campaign
Navigate to /admin/email-campaigns/new to create a new campaign. Link it to an email template and configure the target audience.
Campaigns are stored in the LeadCampaign table and linked to Lead records that contain contact information.
Send the Campaign
When you trigger the campaign, the runCampaignSender function processes each lead sequentially:
export async function runCampaignSender(campaignId: string) {
const campaign = await prisma.leadCampaign.findUnique({
where: { id: campaignId },
include: { template: true },
})
const leads = await prisma.lead.findMany({
where: {
campaignId,
status: 'CONTACT_FOUND',
email: { not: null },
},
})
for (const lead of leads) {
// Process template variables
// Send via Resend
// Update lead status
await new Promise(resolve => setTimeout(resolve, 500)) // Rate limit delay
}
}Email Sending Mechanism
The core sendEmail function in lib/email.ts uses the Resend SDK:
import { Resend } from 'resend'
export async function sendEmail(options: {
to: string
subject: string
html?: string
text?: string
}) {
const resend = new Resend(process.env.RESEND_API_KEY)
return resend.emails.send({
from: process.env.FROM_EMAIL || 'no-reply@example.com',
to: options.to,
subject: options.subject,
...(options.html ? { html: options.html } : { text: options.text || '' }),
})
}Campaign Status Flow
CREATED -> SENDING_EMAILS -> COMPLETEDEach lead within a campaign tracks its own status:
| Lead Status | Description |
|---|---|
CONTACT_FOUND | Ready to send |
EMAIL_SENT | Successfully delivered |
EMAIL_FAILED | Delivery failed |
Rate Limiting
The campaign sender includes a 500ms delay between emails to respect Resend API rate limits. The lib/email/rate-limit.ts module provides additional rate limiting controls.
Built-in Email Templates
The platform includes several built-in email types:
await sendPasswordResetEmail(email, token)
// Sends branded HTML email with reset link
// Link expires in 1 hourawait sendAdminReplyEmail(to, subject, message)
// Loads template from DB (key: 'admin_reply')
// Falls back to plain text if no template existsawait runCampaignSender(campaignId)
// Processes all leads with CONTACT_FOUND status
// Replaces template variables per-lead
// Tracks sent/failed countsAnnouncement and order-confirmation layouts
Four standalone layouts ship as selectable templates. Their source HTML lives in
public/templates/ and is registered in lib/email/templates/:
| Key | Category | Layout |
|---|---|---|
announcement_newsletter | MARKETING | Multi-story newsletter with stacked sections |
announcement_single | MARKETING | One headline, one call to action |
order_confirmation_light | TRANSACTIONAL | Itemized receipt, light background |
order_confirmation_dark | TRANSACTIONAL | Itemized receipt, dark background |
The two order-confirmation layouts iterate line_items with {{#each}} and
expect subtotal, shipping_cost, tax, and total. They are additional
options — the transactional send path still uses the order_confirmation
template.
The two announcement layouts are also previewable and exportable from
/admin/email-templates.
Monitoring Campaign Progress
Campaign progress is tracked via real-time events:
emitScraperEvent(campaignId, 'info', 'email',
`[${idx + 1}/${leads.length}] Sending to ${lead.email}`)The admin panel at /admin/email-campaigns/[id] shows:
- Total leads in campaign
- Emails sent vs failed
- Per-lead delivery status and error messages
- Campaign completion status
Suppression and Unsubscribe
The platform supports unsubscribe preferences via the UnsubscribePreference model. The suppression module in lib/email/suppression.ts checks these preferences before sending.
Unsubscribe links are processed at /api/unsubscribe.
Email Compliance
Always include an unsubscribe link in marketing emails. The platform tracks unsubscribe preferences per user to comply with CAN-SPAM requirements.
Template Rendering
Template bodies are rendered with Handlebars by
substituteVariables() in lib/email/render.ts, shared by the send path and
the admin preview so the two cannot drift.
Alongside flat {{variable}} substitution, templates may use block helpers:
{{#each line_items}}
<tr><td>{{item_name}}</td><td>{{item_qty}}</td><td>{{item_line_total}}</td></tr>
{{/each}}
{{#if tracking_url}}<a href="{{tracking_url}}">Track your order</a>{{/if}}Values are inserted unescaped (noEscape), because templates pass
pre-rendered HTML through variables such as {{orderItems}}. A template that
fails to compile falls back to flat substitution rather than failing the send.
Seeding Email Templates
Use the seed script to populate default templates:
npm run db:seed:email-templatesSeeding overwrites stored template bodies
The seed upserts, so a blanket run replaces the subject and HTML of every template it defines — including any edited in the admin panel. Pass template keys to limit the run:
npm run db:seed:email-templates -- announcement_single order_confirmation_lightHow is this guide?
Last updated on