Back to Blog

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.

Viprasol Tech Team
14 min read
Updated 2026

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,
    },
  });
});

saas - SaaS Email Sequences: Transactional System, Template Engine, Queuing

๐Ÿ’ก 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

VolumeProviderMonthly CostNotes
< 3,000/moResend free tier$0Dev/small SaaS
50K/moResend Pro$20/moGood deliverability
100K/moResend or SES$10โ€“20/moSES is cheapest at scale
500K/moSendGrid Essentials$90/moAnalytics included
1M+/moSendGrid Pro or SES$200โ€“800/moDedicated IPs
+ Queue infra (Redis)Upstash or ElastiCache$10โ€“50/moFor BullMQ

Continue Learning


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 โ†’

saasemailtypescriptreact-emailbullmqredisbackend
Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours โ€” fast.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท AI Agent Systems

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.