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.
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
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Single announcement email (template + send) | 1 dev | 1โ2 days | $400โ800 |
| Full campaign system (A/B, batch, tracking) | 1โ2 devs | 1โ2 weeks | $3,000โ6,000 |
| + Digest scheduling + preference center | 1โ2 devs | 2โ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
- SaaS Email Sequences and Drip Campaigns
- SaaS Notification Preferences Center
- SaaS In-App Notifications System
- SaaS Changelog System
- Stripe Webhook Handling with Idempotency
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.
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 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.
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.