Back to Blog

SaaS Feature Announcement Emails: Changelog Digests, A/B Subject Lines, and Resend Batch API

Build a feature announcement email system for your SaaS. Covers changelog digest emails, A/B subject line testing, Resend batch API for bulk sends, user preference filtering, open/click tracking, and React Email templates.

Viprasol Tech Team
April 16, 2027
12 min read

Feature announcement emails are one of the highest-ROI touchpoints in SaaS โ€” they re-engage dormant users, reduce churn by surfacing value users didn't know existed, and drive adoption of new features. The implementation challenge is doing them right: respecting user preferences, not hammering active users with notifications they don't need, and measuring what actually drives logins.

This guide covers the full announcement email stack with React Email templates, Resend batch sending, A/B subject testing, and preference-aware targeting.

Database Schema

-- Feature announcements / changelog
CREATE TABLE changelog_entries (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title        TEXT NOT NULL,
  slug         TEXT NOT NULL UNIQUE,
  summary      TEXT NOT NULL,            -- 1โ€“2 sentence preview for email
  body_html    TEXT NOT NULL,            -- Full HTML content for landing page
  category     TEXT NOT NULL,           -- 'feature' | 'improvement' | 'fix' | 'deprecation'
  is_major     BOOLEAN NOT NULL DEFAULT FALSE,  -- Major = always email; minor = digest only
  published_at TIMESTAMPTZ,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Email campaigns tied to changelog entries
CREATE TABLE email_campaigns (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  changelog_entry_id UUID REFERENCES changelog_entries(id),
  campaign_type    TEXT NOT NULL,       -- 'announcement' | 'digest' | 'winback'
  subject_a        TEXT NOT NULL,
  subject_b        TEXT,               -- NULL = no A/B test
  status           TEXT NOT NULL DEFAULT 'draft',  -- 'draft' | 'sending' | 'sent'
  sent_at          TIMESTAMPTZ,
  total_recipients INTEGER DEFAULT 0,
  opens_a          INTEGER DEFAULT 0,
  opens_b          INTEGER DEFAULT 0,
  clicks_a         INTEGER DEFAULT 0,
  clicks_b         INTEGER DEFAULT 0,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Per-user send records (dedup + tracking)
CREATE TABLE email_sends (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  campaign_id  UUID NOT NULL REFERENCES email_campaigns(id),
  user_id      UUID NOT NULL REFERENCES users(id),
  variant      TEXT NOT NULL DEFAULT 'a',  -- 'a' | 'b'
  resend_id    TEXT,                       -- Resend message ID
  sent_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  opened_at    TIMESTAMPTZ,
  clicked_at   TIMESTAMPTZ,
  UNIQUE (campaign_id, user_id)           -- Prevent duplicates
);

CREATE INDEX idx_email_sends_campaign ON email_sends(campaign_id, variant);
CREATE INDEX idx_email_sends_user ON email_sends(user_id);

-- User email preferences
ALTER TABLE user_preferences ADD COLUMN email_product_updates BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE user_preferences ADD COLUMN email_digest BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE user_preferences ADD COLUMN email_unsubscribed_at TIMESTAMPTZ;

React Email: Announcement Template

// emails/feature-announcement.tsx
import {
  Body, Container, Head, Heading, Hr, Html, Img,
  Link, Preview, Section, Text, Button, Row, Column
} from "@react-email/components";
import * as React from "react";

interface FeatureAnnouncementEmailProps {
  userName: string;
  featureTitle: string;
  featureSummary: string;
  featureCategory: "feature" | "improvement" | "fix";
  changelogUrl: string;
  ctaLabel: string;
  ctaUrl: string;
  unsubscribeUrl: string;
}

const CATEGORY_BADGE: Record<string, { label: string; color: string }> = {
  feature:     { label: "New Feature",   color: "#2563eb" },
  improvement: { label: "Improvement",   color: "#059669" },
  fix:         { label: "Bug Fix",       color: "#d97706" },
};

export function FeatureAnnouncementEmail({
  userName,
  featureTitle,
  featureSummary,
  featureCategory,
  changelogUrl,
  ctaLabel,
  ctaUrl,
  unsubscribeUrl,
}: FeatureAnnouncementEmailProps) {
  const badge = CATEGORY_BADGE[featureCategory] ?? CATEGORY_BADGE.feature;

  return (
    <Html>
      <Head />
      <Preview>{featureTitle} โ€” now available in your account</Preview>
      <Body style={{ backgroundColor: "#f9fafb", fontFamily: "system-ui, sans-serif" }}>
        <Container style={{ maxWidth: "600px", margin: "40px auto", backgroundColor: "#ffffff", borderRadius: "12px", overflow: "hidden", border: "1px solid #e5e7eb" }}>

          {/* Header */}
          <Section style={{ backgroundColor: "#1e40af", padding: "32px 40px" }}>
            <Text style={{ color: "#ffffff", fontSize: "20px", fontWeight: "700", margin: 0 }}>
              Viprasol
            </Text>
          </Section>

          {/* Body */}
          <Section style={{ padding: "40px" }}>
            {/* Category badge */}
            <Text style={{
              display: "inline-block",
              backgroundColor: badge.color + "15",
              color: badge.color,
              fontSize: "12px",
              fontWeight: "600",
              padding: "4px 12px",
              borderRadius: "100px",
              textTransform: "uppercase",
              letterSpacing: "0.05em",
              marginBottom: "16px",
            }}>
              {badge.label}
            </Text>

            <Heading style={{ fontSize: "24px", color: "#111827", fontWeight: "700", marginBottom: "12px" }}>
              {featureTitle}
            </Heading>

            <Text style={{ fontSize: "16px", color: "#4b5563", lineHeight: "1.6", marginBottom: "24px" }}>
              Hi {userName}, {featureSummary}
            </Text>

            {/* CTA button */}
            <Button
              href={ctaUrl}
              style={{
                backgroundColor: "#2563eb",
                color: "#ffffff",
                padding: "12px 24px",
                borderRadius: "8px",
                fontSize: "15px",
                fontWeight: "600",
                textDecoration: "none",
                display: "inline-block",
              }}
            >
              {ctaLabel}
            </Button>

            <Hr style={{ borderColor: "#e5e7eb", margin: "32px 0" }} />

            <Text style={{ fontSize: "14px", color: "#6b7280" }}>
              See the full changelog at{" "}
              <Link href={changelogUrl} style={{ color: "#2563eb" }}>
                viprasol.com/changelog
              </Link>
            </Text>
          </Section>

          {/* Footer */}
          <Section style={{ backgroundColor: "#f9fafb", padding: "24px 40px", borderTop: "1px solid #e5e7eb" }}>
            <Text style={{ fontSize: "12px", color: "#9ca3af", margin: 0 }}>
              You're receiving this because you opted into product updates.{" "}
              <Link href={unsubscribeUrl} style={{ color: "#6b7280" }}>
                Unsubscribe
              </Link>
            </Text>
          </Section>
        </Container>
      </Body>
    </Html>
  );
}

๐Ÿš€ 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

Changelog Digest Template

// emails/changelog-digest.tsx โ€” weekly digest of multiple entries
interface DigestEntry {
  title: string;
  summary: string;
  category: string;
  url: string;
}

interface ChangelogDigestEmailProps {
  userName: string;
  weekOf: string;
  entries: DigestEntry[];
  unsubscribeUrl: string;
}

export function ChangelogDigestEmail({
  userName,
  weekOf,
  entries,
  unsubscribeUrl,
}: ChangelogDigestEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>What's new in Viprasol โ€” {weekOf}</Preview>
      <Body style={{ backgroundColor: "#f9fafb", fontFamily: "system-ui, sans-serif" }}>
        <Container style={{ maxWidth: "600px", margin: "40px auto", backgroundColor: "#ffffff", borderRadius: "12px", border: "1px solid #e5e7eb" }}>
          <Section style={{ padding: "40px 40px 24px" }}>
            <Heading style={{ fontSize: "22px", color: "#111827", marginBottom: "4px" }}>
              What's new this week
            </Heading>
            <Text style={{ color: "#6b7280", fontSize: "14px", marginTop: 0 }}>
              Hi {userName} โ€” here's what shipped the week of {weekOf}.
            </Text>
          </Section>

          {entries.map((entry, i) => (
            <Section key={i} style={{ padding: "0 40px 24px" }}>
              <Hr style={{ borderColor: "#f3f4f6", marginBottom: "24px" }} />
              <Row>
                <Column>
                  <Text style={{ fontSize: "13px", color: CATEGORY_BADGE[entry.category]?.color ?? "#6b7280", fontWeight: "600", margin: "0 0 6px", textTransform: "uppercase", letterSpacing: "0.05em" }}>
                    {CATEGORY_BADGE[entry.category]?.label ?? entry.category}
                  </Text>
                  <Link href={entry.url} style={{ fontSize: "16px", fontWeight: "600", color: "#111827", textDecoration: "none" }}>
                    {entry.title}
                  </Link>
                  <Text style={{ fontSize: "14px", color: "#6b7280", lineHeight: "1.5", margin: "6px 0 0" }}>
                    {entry.summary}
                  </Text>
                </Column>
              </Row>
            </Section>
          ))}

          <Section style={{ backgroundColor: "#f9fafb", padding: "20px 40px", borderTop: "1px solid #e5e7eb" }}>
            <Text style={{ fontSize: "12px", color: "#9ca3af", margin: 0 }}>
              <Link href={unsubscribeUrl} style={{ color: "#6b7280" }}>Unsubscribe from weekly digest</Link>
            </Text>
          </Section>
        </Container>
      </Body>
    </Html>
  );
}

A/B Subject Line Assignment

// lib/email/ab-test.ts

// Deterministic variant assignment: SHA-256 of userId+campaignId
// Same user always gets same variant โ€” no database lookup needed
import { createHash } from "crypto";

export function assignVariant(userId: string, campaignId: string): "a" | "b" {
  const hash = createHash("sha256")
    .update(`${userId}:${campaignId}`)
    .digest("hex");

  // First byte: 0โ€“127 = variant A, 128โ€“255 = variant B
  return parseInt(hash.slice(0, 2), 16) < 128 ? "a" : "b";
}

export function selectSubject(
  campaign: { subjectA: string; subjectB: string | null },
  variant: "a" | "b"
): string {
  if (!campaign.subjectB || variant === "a") return campaign.subjectA;
  return campaign.subjectB;
}

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

Audience Filtering

// lib/email/audience.ts
import { prisma } from "@/lib/prisma";

interface AudienceFilter {
  emailType: "product_updates" | "digest";
  activeWithinDays?: number;   // Only users active in last N days
  planFilter?: string[];       // Only users on specific plans
  excludeUserIds?: string[];
}

export async function buildAudience(filter: AudienceFilter): Promise<
  Array<{ id: string; email: string; name: string }>
> {
  const cutoffDate = filter.activeWithinDays
    ? new Date(Date.now() - filter.activeWithinDays * 24 * 60 * 60 * 1000)
    : undefined;

  const prefColumn =
    filter.emailType === "product_updates"
      ? "emailProductUpdates"
      : "emailDigest";

  return prisma.user.findMany({
    where: {
      AND: [
        { emailVerified: true },
        { preferences: { [prefColumn]: true, emailUnsubscribedAt: null } },
        cutoffDate ? { lastActiveAt: { gte: cutoffDate } } : {},
        filter.planFilter
          ? { subscription: { plan: { in: filter.planFilter } } }
          : {},
        filter.excludeUserIds
          ? { id: { notIn: filter.excludeUserIds } }
          : {},
      ],
    },
    select: { id: true, email: true, name: true },
  });
}

Resend Batch Send

// lib/email/send-campaign.ts
import { Resend } from "resend";
import { render } from "@react-email/render";
import { prisma } from "@/lib/prisma";
import { buildAudience } from "./audience";
import { assignVariant, selectSubject } from "./ab-test";
import { FeatureAnnouncementEmail } from "@/emails/feature-announcement";

const resend = new Resend(process.env.RESEND_API_KEY!);

const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;
const BATCH_SIZE = 100; // Resend batch max

export async function sendAnnouncementCampaign(campaignId: string): Promise<void> {
  const campaign = await prisma.emailCampaign.findUniqueOrThrow({
    where: { id: campaignId },
    include: { changelogEntry: true },
  });

  if (campaign.status !== "draft") {
    throw new Error(`Campaign ${campaignId} is already ${campaign.status}`);
  }

  // Mark as sending to prevent duplicate sends
  await prisma.emailCampaign.update({
    where: { id: campaignId },
    data: { status: "sending" },
  });

  try {
    const audience = await buildAudience({ emailType: "product_updates" });

    // Chunk into batches of 100
    for (let i = 0; i < audience.length; i += BATCH_SIZE) {
      const batch = audience.slice(i, i + BATCH_SIZE);

      const messages = batch.map((user) => {
        const variant = assignVariant(user.id, campaignId);
        const subject = selectSubject(campaign, variant);

        const html = render(
          FeatureAnnouncementEmail({
            userName: user.name.split(" ")[0],
            featureTitle: campaign.changelogEntry!.title,
            featureSummary: campaign.changelogEntry!.summary,
            featureCategory: campaign.changelogEntry!.category as any,
            changelogUrl: `${APP_URL}/changelog`,
            ctaLabel: "See what's new",
            ctaUrl: `${APP_URL}/dashboard?ref=announcement&campaign=${campaignId}`,
            unsubscribeUrl: `${APP_URL}/settings/notifications?unsubscribe=product_updates&uid=${user.id}`,
          })
        );

        return {
          from: "Viprasol <updates@mail.viprasol.com>",
          to: user.email,
          subject,
          html,
          tags: [
            { name: "campaign_id", value: campaignId },
            { name: "variant",     value: variant },
            { name: "user_id",     value: user.id },
          ],
        };
      });

      // Send batch
      const { data, error } = await resend.batch.send(messages);

      if (error || !data) {
        console.error(`Batch ${i / BATCH_SIZE + 1} failed:`, error);
        continue;
      }

      // Record sends
      await prisma.emailSend.createMany({
        data: batch.map((user, j) => ({
          campaignId,
          userId: user.id,
          variant: assignVariant(user.id, campaignId),
          resendId: data[j]?.id ?? null,
        })),
        skipDuplicates: true,
      });
    }

    await prisma.emailCampaign.update({
      where: { id: campaignId },
      data: {
        status: "sent",
        sentAt: new Date(),
        totalRecipients: audience.length,
      },
    });
  } catch (err) {
    await prisma.emailCampaign.update({
      where: { id: campaignId },
      data: { status: "draft" }, // Reset so it can be retried
    });
    throw err;
  }
}

Webhook: Track Opens and Clicks

// app/api/webhooks/resend/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function POST(req: NextRequest) {
  // Verify Resend webhook signature
  const svix_id        = req.headers.get("svix-id");
  const svix_timestamp = req.headers.get("svix-timestamp");
  const svix_signature = req.headers.get("svix-signature");

  if (!svix_id || !svix_timestamp || !svix_signature) {
    return NextResponse.json({ error: "Missing signature" }, { status: 400 });
  }

  const body = await req.text();

  // Verify with Resend webhook secret (using Svix)
  const { Webhook } = await import("svix");
  const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET!);
  let event: any;
  try {
    event = wh.verify(body, { "svix-id": svix_id, "svix-timestamp": svix_timestamp, "svix-signature": svix_signature });
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  const { type, data } = event;
  const resendId = data.email_id;

  switch (type) {
    case "email.opened":
      await prisma.emailSend.updateMany({
        where: { resendId, openedAt: null },
        data: { openedAt: new Date() },
      });
      break;

    case "email.clicked":
      await prisma.emailSend.updateMany({
        where: { resendId, clickedAt: null },
        data: { clickedAt: new Date() },
      });
      break;

    case "email.bounced":
    case "email.complained":
      // Suppress the user's email
      const send = await prisma.emailSend.findFirst({ where: { resendId } });
      if (send) {
        await prisma.userPreferences.updateMany({
          where: { userId: send.userId },
          data: { emailUnsubscribedAt: new Date() },
        });
      }
      break;
  }

  return NextResponse.json({ ok: true });
}

A/B Test Results Query

-- Compare open rates between variants
SELECT
  variant,
  COUNT(*)                                      AS sent,
  COUNT(opened_at)                              AS opens,
  ROUND(COUNT(opened_at)::numeric / COUNT(*) * 100, 1) AS open_rate_pct,
  COUNT(clicked_at)                             AS clicks,
  ROUND(COUNT(clicked_at)::numeric / COUNT(*) * 100, 1) AS click_rate_pct
FROM email_sends
WHERE campaign_id = $1
GROUP BY variant;

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Single announcement email (template + send)1 dev1โ€“2 days$400โ€“800
Full campaign system (A/B, batch, tracking)1โ€“2 devs1โ€“2 weeks$3,000โ€“6,000
+ Digest scheduling + preference center1โ€“2 devs2โ€“3 weeks$6,000โ€“12,000

Resend pricing (2026): Free tier: 3,000 emails/month; Pro: $20/month for 50K emails; $0.35/1K above that.

See Also


Working With Viprasol

Feature announcement emails are the most direct line between shipping and adoption. Our team builds the full stack: React Email templates matched to your brand, Resend batch sending with A/B subject line testing, open/click tracking via webhooks, and user preference management so you never email someone who opted out.

What we deliver:

  • React Email templates: announcement + weekly digest
  • Resend batch API with 100-message chunks
  • Deterministic A/B variant assignment (SHA-256 of userId+campaignId)
  • Audience builder: preference filter + last-active filter + plan filter
  • Svix-verified webhook handler for open/click/bounce/complaint events

Talk to our team about your product email strategy โ†’

Or explore our SaaS development services.

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 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

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.