SaaS Email Sequences: Transactional System, Template Engine, Queuing
Build a production SaaS email infrastructure: transactional email service with React Email templates, BullMQ queue for reliable delivery, drip sequence scheduling, open/click tracking, and unsubscribe handling.
Quick answer. Build SaaS email on React Email for type-safe templates and BullMQ on Redis for reliable queued delivery, separating immediate transactional mail (receipts, password resets) from scheduled drip sequences (onboarding days 1, 3, 7, 14). Track opens and enforce unsubscribe links to stay CAN-SPAM compliant. Email is the highest-ROI channel in SaaS. Onboarding sequences, trial conversion nudges, and renewal reminders drive activation and retention more reliably than any other touchpoint. But the implementation โ rendering templates, queuing delivery, tracking opens, respecting unsubscribes โ is more complex than it looks. A dropped email during onboarding loses activations. A missing unsubscribe link loses CAN-SPAM compliance.
This post covers the full email infrastructure stack: React Email for type-safe templates, BullMQ for reliable queued delivery, drip sequence scheduling, event tracking, and proper unsubscribe handling.
Architecture
Trigger (signup, trial start, payment)
โ
โผ
Email Queue (BullMQ/Redis)
โ
โโโ Immediate: transactional (password reset, receipt)
โโโ Scheduled: sequences (onboarding day 1, 3, 7, 14)
โ
โผ
Email Worker
โโโ Render template (React Email โ HTML)
โโโ Check suppression list (unsubscribed, bounced)
โโโ Send via provider (Resend / SES / SendGrid)
โโโ Record event (sent, delivered, opened, clicked)
1. Database Schema
-- Email contacts with suppression state
CREATE TABLE email_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
email TEXT NOT NULL,
name TEXT,
-- Suppression
status TEXT NOT NULL DEFAULT 'subscribed'
CHECK (status IN ('subscribed', 'unsubscribed', 'bounced', 'complained')),
unsubscribed_at TIMESTAMPTZ,
bounced_at TIMESTAMPTZ,
complained_at TIMESTAMPTZ,
-- Preferences
categories TEXT[] NOT NULL DEFAULT ARRAY['transactional', 'marketing'],
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, email)
);
-- Email send log (append-only)
CREATE TABLE email_sends (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
contact_id UUID NOT NULL REFERENCES email_contacts(id),
template_id TEXT NOT NULL, -- 'onboarding-day-1', 'trial-expiring', etc.
sequence_id TEXT, -- NULL for transactional
subject TEXT NOT NULL,
provider_id TEXT, -- Provider message ID (for tracking)
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'sent', 'delivered', 'bounced', 'failed')),
metadata JSONB NOT NULL DEFAULT '{}',
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
failure_reason TEXT,
INDEX idx_email_sends_contact (contact_id, queued_at DESC),
INDEX idx_email_sends_sequence (sequence_id, template_id, contact_id)
);
-- Email events (opens, clicks, unsubscribes)
CREATE TABLE email_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
send_id UUID NOT NULL REFERENCES email_sends(id),
event_type TEXT NOT NULL CHECK (event_type IN ('opened', 'clicked', 'unsubscribed', 'bounced', 'complained')),
url TEXT, -- For click events
user_agent TEXT,
ip INET,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
INDEX idx_email_events_send (send_id, occurred_at DESC),
INDEX idx_email_events_type (event_type, occurred_at DESC)
);
๐ SaaS MVP in 8 Weeks โ Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment โ all handled by one senior team.
- Week 1โ2: Architecture design + wireframes
- Week 3โ6: Core features built + tested
- Week 7โ8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
2. React Email Templates
npm install @react-email/components react-email
// src/emails/templates/OnboardingDay1.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Section,
Text,
Tailwind,
} from '@react-email/components';
interface OnboardingDay1Props {
userName: string;
accountName: string;
dashboardUrl: string;
unsubscribeUrl: string;
}
export function OnboardingDay1({
userName,
accountName,
dashboardUrl,
unsubscribeUrl,
}: OnboardingDay1Props) {
return (
<Html>
<Head />
<Preview>Welcome to Viprasol, {userName} โ here's how to get started</Preview>
<Tailwind>
<Body className="bg-gray-50 font-sans">
<Container className="mx-auto max-w-xl p-6">
<Img
src="https://viprasol.com/images/logo.png"
width={120}
height={40}
alt="Viprasol"
className="mb-8"
/>
<Heading className="text-2xl font-bold text-gray-900 mb-4">
Welcome to Viprasol, {userName}!
</Heading>
<Text className="text-gray-600 mb-4">
Your {accountName} workspace is ready. Here's what to do first:
</Text>
<Section className="bg-white rounded-lg p-6 mb-6 border border-gray-200">
<Text className="font-semibold text-gray-900 mb-2">โ
Step 1: Connect your data</Text>
<Text className="text-gray-600 text-sm mb-4">
Import your existing data or connect via our API to get started immediately.
</Text>
<Text className="font-semibold text-gray-900 mb-2">โ
Step 2: Invite your team</Text>
<Text className="text-gray-600 text-sm">
Collaboration is free โ add teammates from Settings โ Team.
</Text>
</Section>
<Button
href={dashboardUrl}
className="bg-blue-600 text-white rounded-lg px-6 py-3 font-semibold text-base no-underline block text-center"
>
Go to your dashboard โ
</Button>
<Text className="text-gray-400 text-xs mt-8 text-center">
You're receiving this because you created a Viprasol account.{' '}
<Link href={unsubscribeUrl} className="text-gray-400 underline">
Unsubscribe
</Link>
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
}
// Default export for React Email preview server
export default OnboardingDay1;
Template Registry
// src/emails/registry.ts
import { render } from '@react-email/components';
import { OnboardingDay1 } from './templates/OnboardingDay1';
import { TrialExpiring } from './templates/TrialExpiring';
import { PaymentFailed } from './templates/PaymentFailed';
import { PasswordReset } from './templates/PasswordReset';
export type TemplateId =
| 'onboarding-day-1'
| 'onboarding-day-3'
| 'onboarding-day-7'
| 'trial-expiring-7d'
| 'trial-expiring-1d'
| 'payment-failed-first'
| 'payment-failed-final'
| 'password-reset';
interface TemplateConfig<T extends object> {
subject: (props: T) => string;
component: (props: T) => JSX.Element;
category: 'transactional' | 'marketing';
}
// Templates with their subject line generators
const TEMPLATES: Record<TemplateId, TemplateConfig<any>> = {
'onboarding-day-1': {
subject: ({ userName }: { userName: string }) => `Welcome to Viprasol, ${userName}!`,
component: OnboardingDay1,
category: 'marketing',
},
'trial-expiring-7d': {
subject: () => 'Your trial ends in 7 days',
component: TrialExpiring,
category: 'transactional',
},
'payment-failed-first': {
subject: () => 'Action required: payment failed',
component: PaymentFailed,
category: 'transactional',
},
'password-reset': {
subject: () => 'Reset your Viprasol password',
component: PasswordReset,
category: 'transactional',
},
// ... more templates
} as any;
export async function renderTemplate(
templateId: TemplateId,
props: object
): Promise<{ html: string; text: string; subject: string }> {
const template = TEMPLATES[templateId];
if (!template) throw new Error(`Unknown template: ${templateId}`);
const element = template.component(props);
const html = await render(element);
const text = await render(element, { plainText: true });
const subject = template.subject(props);
return { html, text, subject };
}
export function getTemplateCategory(templateId: TemplateId): 'transactional' | 'marketing' {
return TEMPLATES[templateId]?.category ?? 'marketing';
}
3. Email Queue with BullMQ
// src/lib/email/queue.ts
import { Queue, Worker, Job } from 'bullmq';
import { redis } from '../redis';
import { renderTemplate, TemplateId, getTemplateCategory } from '../../emails/registry';
import { db } from '../db';
import { resend } from './providers/resend';
export interface EmailJobData {
sendId: string; // DB record ID
contactId: string;
tenantId: string;
templateId: TemplateId;
props: Record<string, unknown>;
scheduledFor?: string; // ISO string for delayed sends
}
export const emailQueue = new Queue<EmailJobData>('email', {
connection: redis,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 60_000 }, // 1min, 2min, 4min
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
});
// Add a single email to the queue
export async function queueEmail(
input: Omit<EmailJobData, 'sendId'> & { delay?: number }
): Promise<string> {
const send = await db.emailSend.create({
data: {
tenantId: input.tenantId,
contactId: input.contactId,
templateId: input.templateId,
sequenceId: (input.props.sequenceId as string) ?? null,
subject: '', // Will be populated by worker
status: 'queued',
},
});
await emailQueue.add(
`email:${send.id}`,
{ ...input, sendId: send.id },
{
delay: input.delay,
jobId: send.id, // Idempotency: same sendId = same job
}
);
return send.id;
}
Email Worker
// src/workers/email.worker.ts
import { Worker, Job } from 'bullmq';
import { redis } from '../lib/redis';
import { EmailJobData } from '../lib/email/queue';
import { renderTemplate, getTemplateCategory } from '../emails/registry';
import { db } from '../lib/db';
import { resend } from '../lib/email/providers/resend';
const worker = new Worker<EmailJobData>(
'email',
async (job: Job<EmailJobData>) => {
const { sendId, contactId, tenantId, templateId, props } = job.data;
// 1. Get contact and check suppression
const contact = await db.emailContact.findUnique({
where: { id: contactId },
});
if (!contact) {
throw new Error(`Contact ${contactId} not found`);
}
const category = getTemplateCategory(templateId);
if (contact.status !== 'subscribed') {
console.info(`Skipping ${templateId} for ${contact.email}: status=${contact.status}`);
await db.emailSend.update({
where: { id: sendId },
data: { status: 'failed', failureReason: `Suppressed: ${contact.status}` },
});
return; // Not an error โ don't retry
}
if (!contact.categories.includes(category)) {
console.info(`Skipping ${templateId} for ${contact.email}: category=${category} not in preferences`);
await db.emailSend.update({
where: { id: sendId },
data: { status: 'failed', failureReason: 'Category unsubscribed' },
});
return;
}
// 2. Render template with unsubscribe URL injected
const unsubscribeToken = generateUnsubscribeToken(contactId);
const { html, text, subject } = await renderTemplate(templateId, {
...props,
unsubscribeUrl: `${process.env.APP_URL}/unsubscribe?token=${unsubscribeToken}`,
});
// 3. Update subject in DB
await db.emailSend.update({
where: { id: sendId },
data: { subject },
});
// 4. Send via provider
const { id: providerId } = await resend.emails.send({
from: 'Viprasol <hello@viprasol.com>',
to: contact.email,
subject,
html,
text,
headers: {
'List-Unsubscribe': `<${process.env.APP_URL}/unsubscribe?token=${unsubscribeToken}>`,
'X-Send-Id': sendId,
},
});
// 5. Mark as sent
await db.emailSend.update({
where: { id: sendId },
data: { status: 'sent', sentAt: new Date(), providerId },
});
console.info(`Sent ${templateId} to ${contact.email} (${providerId})`);
},
{
connection: redis,
concurrency: 10, // 10 concurrent sends
}
);
worker.on('failed', async (job, err) => {
if (!job) return;
await db.emailSend.update({
where: { id: job.data.sendId },
data: {
status: 'failed',
failedAt: new Date(),
failureReason: err.message,
},
});
});

๐ก The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments โ with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity โ you own everything
4. Drip Sequence Scheduling
// src/lib/email/sequences.ts
import { queueEmail } from './queue';
interface SequenceStep {
templateId: string;
delayDays: number;
condition?: (contact: EmailContact) => boolean;
}
const SEQUENCES: Record<string, SequenceStep[]> = {
'onboarding': [
{ templateId: 'onboarding-day-1', delayDays: 0 },
{ templateId: 'onboarding-day-3', delayDays: 3 },
{ templateId: 'onboarding-day-7', delayDays: 7,
condition: (c) => !c.hasActivated }, // Skip if user already activated
{ templateId: 'onboarding-day-14', delayDays: 14,
condition: (c) => !c.hasActivated },
],
'trial': [
{ templateId: 'trial-expiring-7d', delayDays: -7 }, // 7 days before trial ends
{ templateId: 'trial-expiring-1d', delayDays: -1 },
],
};
export async function enrollInSequence(
contactId: string,
tenantId: string,
sequenceId: keyof typeof SEQUENCES,
baseDate: Date = new Date(),
props: Record<string, unknown> = {}
): Promise<void> {
const steps = SEQUENCES[sequenceId];
if (!steps) throw new Error(`Unknown sequence: ${sequenceId}`);
for (const step of steps) {
const scheduledFor = new Date(baseDate);
scheduledFor.setDate(scheduledFor.getDate() + step.delayDays);
const delayMs = scheduledFor.getTime() - Date.now();
// Skip if scheduled in the past
if (delayMs < -60_000) continue;
await queueEmail({
contactId,
tenantId,
templateId: step.templateId as any,
props: { ...props, sequenceId },
delay: Math.max(0, delayMs),
});
}
console.info(
`Enrolled contact ${contactId} in sequence ${sequenceId} (${steps.length} emails)`
);
}
// Cancel remaining sequence emails
export async function cancelSequence(
contactId: string,
sequenceId: string
): Promise<void> {
const pendingSends = await db.emailSend.findMany({
where: {
contactId,
sequenceId,
status: 'queued',
},
});
for (const send of pendingSends) {
await emailQueue.remove(send.id);
await db.emailSend.update({
where: { id: send.id },
data: { status: 'failed', failureReason: 'Sequence cancelled' },
});
}
}
// Usage: enroll after signup
// await enrollInSequence(contactId, tenantId, 'onboarding', new Date(), {
// userName: user.name,
// accountName: account.name,
// dashboardUrl: `${APP_URL}/dashboard`,
// });
5. Unsubscribe Handling
// src/app/api/unsubscribe/route.ts
import crypto from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '../../../lib/db';
const UNSUBSCRIBE_SECRET = process.env.UNSUBSCRIBE_HMAC_SECRET!;
export function generateUnsubscribeToken(contactId: string): string {
return crypto
.createHmac('sha256', UNSUBSCRIBE_SECRET)
.update(contactId)
.digest('hex');
}
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.json({ error: 'Missing token' }, { status: 400 });
}
// Find contact by verifying token against all contacts (or store token in DB)
// Efficient: store token hash in DB at enrollment time
const contact = await db.emailContact.findFirst({
where: {
unsubscribeToken: token,
status: 'subscribed',
},
});
if (!contact) {
return NextResponse.redirect(`${process.env.APP_URL}/unsubscribe/invalid`);
}
await db.emailContact.update({
where: { id: contact.id },
data: {
status: 'unsubscribed',
unsubscribedAt: new Date(),
},
});
// Cancel any pending sequence emails
await db.emailSend.updateMany({
where: { contactId: contact.id, status: 'queued' },
data: { status: 'failed', failureReason: 'Unsubscribed' },
});
return NextResponse.redirect(`${process.env.APP_URL}/unsubscribe/confirmed`);
}
Cost Reference
| Volume | Provider | Monthly Cost | Notes |
|---|---|---|---|
| < 3,000/mo | Resend free tier | $0 | Dev/small SaaS |
| 50K/mo | Resend Pro | $20/mo | Good deliverability |
| 100K/mo | Resend or SES | $10โ20/mo | SES is cheapest at scale |
| 500K/mo | SendGrid Essentials | $90/mo | Analytics included |
| 1M+/mo | SendGrid Pro or SES | $200โ800/mo | Dedicated IPs |
| + Queue infra (Redis) | Upstash or ElastiCache | $10โ50/mo | For BullMQ |
Continue Learning
- SaaS Customer Success Engineering: Health Scores and Churn Signals
- SaaS Trial Conversion: Onboarding Sequences and Feature Gates
- SaaS Dunning Management: Recovering Failed Payments
- Background Jobs Architecture with BullMQ
- SaaS Audit Logging: Immutable Trails and Compliance
Why Clients Trust Viprasol
Building a SaaS product that needs onboarding sequences, trial conversion emails, and dunning flows that actually work โ with proper unsubscribe handling and CAN-SPAM compliance? We design and implement the full email infrastructure stack: React Email templates, BullMQ queues, drip sequence scheduling, and delivery analytics.
Talk to our team โ | Explore our SaaS engineering services โ
External Resources
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours โ fast.
Free consultation โข No commitment โข Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow โ automating repetitive tasks, qualifying leads, and responding across every channel your customers use.