本文由 Huifer 撰写,他是 TanStack Ship 的独立开发者与维护者。 自 2022 年起,我为 12 个 SaaS 应用交付过邮件自动化。持续拉动激活的流程是:欢迎序列(试用激活 +23%)、试用到期提醒(转化 +31%),以及挽回了 18% 失败扣款的催缴序列。这些流程运行在 TanStack Start + Cloudflare Workers + D1 上,模板用 React Email。这是我每次发布前都会过一遍的检查清单。
信息来源: React Email · Cloudflare Workers 邮件路由 · Stripe webhook 事件 · SendGrid 邮件送达率 · 最后更新:2026-09-10 · Changelog
TL;DR: 邮件自动化是 SaaS 回报率最高的营销渠道,平均每投入 1 美元回报 42 美元。本指南覆盖完整的邮件营销自动化系统——从数据库 schema 和模板渲染,到行为触发、A/B 测试与送达率优化。所有代码示例都为 TanStack Start / Cloudflare Workers 技术栈设计,使用 D1 存储和 React Email 模板,可直接投入生产。
邮件自动化飞轮
邮件没有死——它是 SaaS 最有效的自有渠道。不同于社交媒体或搜索引擎,分发、时机和消息都由你掌控。一套搭好的邮件自动化系统 7×24 小时运转,把注册变成客户,把客户变成推广者。
| 邮件类型 | 目的 | 自动化触发 | 平均打开率 | 平均点击率 |
|---|---|---|---|---|
| 欢迎序列 | 新手引导与激活 | 用户注册 | 50-70% | 15-30% |
| Drip 序列 | 教育与价值 | 基于时间 | 35-55% | 8-20% |
| 行为触发 | 唤回 | 用户行为(或不作为) | 40-60% | 10-25% |
| 事务邮件 | 回执与通知 | 系统事件 | 80-90% | 5-15% |
| 促销邮件 | 优惠与公告 | 手动 / 定时 | 20-35% | 3-8% |
邮件活动的数据库 Schema
-- Table 1: Email campaigns
CREATE TABLE email_campaigns (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
campaign_type TEXT NOT NULL CHECK (
campaign_type IN (
'welcome', 'drip', 'behavioural', 'transactional', 'promotional', 'newsletter'
)
),
status TEXT NOT NULL DEFAULT 'draft' CHECK (
status IN ('draft', 'scheduled', 'active', 'paused', 'completed', 'archived')
),
trigger_event TEXT, -- For behavioral: 'user_signup', 'trial_expiring', 'payment_failed', etc.
trigger_delay_hours INTEGER DEFAULT 0,
segment_filter JSON, -- SQL-like conditions stored as JSON
from_name TEXT NOT NULL DEFAULT 'TanStack Ship Team',
from_email TEXT NOT NULL DEFAULT 'hello@tanstack-ship.com',
reply_to TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Table 2: Campaign emails (individual emails within a campaign)
CREATE TABLE campaign_emails (
id TEXT PRIMARY KEY,
campaign_id TEXT NOT NULL,
subject TEXT NOT NULL,
preheader TEXT, -- The preview text shown after subject in email clients
template_name TEXT NOT NULL, -- References a template key
template_data JSON DEFAULT '{}', -- Dynamic data for template rendering
send_order INTEGER NOT NULL DEFAULT 0, -- Order within the campaign
delay_hours INTEGER DEFAULT 0, -- Delay from campaign trigger or previous email
is_ab_test INTEGER DEFAULT 0,
ab_test_winner_id TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (campaign_id) REFERENCES email_campaigns(id)
);
-- Table 3: Email sending log (for tracking and analytics)
CREATE TABLE email_log (
id TEXT PRIMARY KEY,
campaign_email_id TEXT NOT NULL,
user_id TEXT NOT NULL,
recipient_email TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL CHECK (
status IN ('queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'failed')
),
opens_count INTEGER NOT NULL DEFAULT 0,
clicks_count INTEGER NOT NULL DEFAULT 0,
last_opened_at INTEGER,
last_clicked_at INTEGER,
error_message TEXT,
sent_at INTEGER,
delivered_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (campaign_email_id) REFERENCES campaign_emails(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_email_log_user ON email_log(user_id);
CREATE INDEX idx_email_log_status ON email_log(status);
CREATE INDEX idx_email_log_campaign ON email_log(campaign_email_id);
-- Table 4: Email templates (stored in database with MJML or HTML)
CREATE TABLE email_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
subject_template TEXT NOT NULL, -- With variables like {{product_name}}
html_body TEXT NOT NULL, -- MJML or HTML
plain_text_body TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
邮件模板渲染
用 React Email(或 MJML)做一致、响应式的邮件模板:
// src/lib/email/templates/WelcomeEmail.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Preview,
Section,
Text,
} from "react-email"
interface WelcomeEmailProps {
username: string
productName: string
dashboardUrl: string
tutorialUrl: string
supportEmail: string
}
export function WelcomeEmail({
username,
productName,
dashboardUrl,
tutorialUrl,
supportEmail,
}: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to {productName} — Your First Project Awaits</Preview>
<Body style={{ fontFamily: "sans-serif", padding: "20px" }}>
<Container>
<Img
src="https://tanstackship.com/logo.png"
alt={productName}
width="48"
height="48"
/>
<Heading>Welcome, {username}! 👋</Heading>
<Text>
We are excited to have you onboard. Here is your personalized
getting-started checklist:
</Text>
<Section style={{ padding: "20px", background: "#f9f9f9", borderRadius: "8px" }}>
<Text>✅ Set up your first project</Text>
<Text>✅ Configure your domain</Text>
<Text>✅ Connect your payment provider</Text>
<Text>✅ Invite your team members</Text>
</Section>
<Section style={{ textAlign: "center", margin: "20px 0" }}>
<Button
href={dashboardUrl}
style={{
background: "#2563eb",
color: "white",
padding: "12px 24px",
borderRadius: "8px",
textDecoration: "none",
}}
>
Go to Dashboard
</Button>
</Section>
<Text>
New to {productName}?{" "}
<a href={tutorialUrl}>Watch the 5-minute getting started tutorial →</a>
</Text>
<Hr />
<Text style={{ color: "#666", fontSize: "12px" }}>
Need help? Reply to this email or contact {supportEmail}
</Text>
</Container>
</Body>
</Html>
)
}
服务端邮件渲染
// src/lib/email/render.ts
import { render } from "react-email/components"
import { WelcomeEmail } from "./templates/WelcomeEmail"
interface RenderEmailOptions {
templateName: string
data: Record<string, unknown>
}
export async function renderEmail({
templateName,
data,
}: RenderEmailOptions): Promise<{ html: string; subject: string }> {
switch (templateName) {
case "welcome": {
const props = data as WelcomeEmailProps
const html = await render(<WelcomeEmail {...props} />)
const subject = `Welcome to ${props.productName}!`
return { html, subject }
}
// 按需增加更多模板分支
default:
throw new Error(`Unknown template: ${templateName}`)
}
}
邮件发送服务(Cloudflare Workers)
// src/lib/email/send.ts
import { createServerFn } from "@tanstack/react-start"
interface SendEmailParams {
to: string
subject: string
html: string
text?: string
headers?: Record<string, string>
}
// 使用 Cloudflare Workers 内置的邮件发送(需要 SendGrid 或类似服务)
export async function sendEmail(params: SendEmailParams) {
const { to, subject, html, text, headers } = params
// 方式 1:SendGrid API
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
personalizations: [{ to: [{ email: to }] }],
from: { email: "hello@tanstack-ship.com", name: "TanStack Ship Team" },
subject,
content: [
{ type: "text/html", value: html },
...(text ? [{ type: "text/plain", value: text }] : []),
],
headers: {
"X-Campaign-Id": headers?.["X-Campaign-Id"] ?? "",
...headers,
},
tracking_settings: {
open_tracking: { enable: true },
click_tracking: { enable: true, enable_text: true },
},
}),
})
if (!response.ok) {
const error = await response.text()
console.error("Email send failed:", error)
return { success: false, error }
}
return { success: true, messageId: response.headers.get("X-Message-Id") }
}
自动化活动引擎
处理触发器并发送邮件的核心自动化引擎:
// src/lib/email/engine.ts
import { createServerFn } from "@tanstack/react-start"
// 由 Cron 触发器驱动(Cloudflare Workers Cron)
export const processEmailQueue = createServerFn({ method: "POST" }).handler(
async () => {
// 第 1 步:根据触发器找出需要收邮件的用户
const pendingUsers = await findPendingUsers()
const results = []
for (const user of pendingUsers) {
try {
const result = await processUserCampaign(user)
results.push(result)
} catch (error) {
console.error(`Failed to process campaign for user ${user.id}:`, error)
}
}
return { processed: results.length }
}
)
async function findPendingUsers(): Promise<UserRecord[]> {
// 示例:找 24 小时前注册但尚未完成新手引导的用户
return env.DB.prepare(`
SELECT u.* FROM users u
WHERE u.created_at <= unixepoch() - 86400
AND u.id NOT IN (
SELECT user_id FROM activation_events
WHERE event_type = 'completed_onboarding'
)
AND u.id NOT IN (
SELECT user_id FROM email_log el
JOIN campaign_emails ce ON ce.id = el.campaign_email_id
JOIN email_campaigns ec ON ec.id = ce.campaign_id
WHERE ec.trigger_event = 'onboarding_incomplete'
AND el.created_at > unixepoch() - 604800 -- 最近 7 天
)
LIMIT 100
`).all() as Promise<unknown> as Promise<UserRecord[]>
}
async function processUserCampaign(user: UserRecord) {
// 获取活动及其邮件
const campaign = await env.DB.prepare(`
SELECT * FROM email_campaigns
WHERE trigger_event = 'onboarding_incomplete'
AND status = 'active'
LIMIT 1
`).first() as EmailCampaign | null
if (!campaign) return null
const emails = await env.DB.prepare(`
SELECT * FROM campaign_emails
WHERE campaign_id = ?
ORDER BY send_order ASC
`).bind(campaign.id).all() as { results: CampaignEmail[] }
// 找出用户进行到哪一封
const lastSent = await env.DB.prepare(`
SELECT ce.send_order
FROM email_log el
JOIN campaign_emails ce ON ce.id = el.campaign_email_id
WHERE el.user_id = ? AND el.status IN ('sent', 'delivered', 'opened', 'clicked')
ORDER BY el.created_at DESC
LIMIT 1
`).bind(user.id).first() as { send_order: number } | null
const nextOrder = (lastSent?.send_order ?? -1) + 1
const nextEmail = emails.results.find((e) => e.send_order === nextOrder)
if (!nextEmail) return null
// 渲染并发送
const { html, subject } = await renderEmail({
templateName: nextEmail.template_name,
data: {
username: user.name ?? user.email,
productName: "TanStack Ship",
dashboardUrl: `https://tanstackship.com/dashboard?utm_source=email&utm_medium=email&utm_campaign=${campaign.name}`,
tutorialUrl: "https://tanstackship.com/blog/tanstack-start-end-to-end-tutorial",
supportEmail: "support@tanstack-ship.com",
},
})
const sendResult = await sendEmail({
to: user.email,
subject,
html,
headers: {
"X-Campaign-Id": campaign.id,
"X-Campaign-Email-Id": nextEmail.id,
},
})
// 记录发送日志
await env.DB.prepare(
`INSERT INTO email_log (id, campaign_email_id, user_id, recipient_email, subject, status, sent_at)
VALUES (?, ?, ?, ?, ?, 'sent', unixepoch())`
).bind(
crypto.randomUUID(),
nextEmail.id,
user.id,
user.email,
subject
).run()
return { userId: user.id, emailId: nextEmail.id, sent: sendResult.success }
}
行为触发场景
每个 SaaS 都应该实现的关键行为邮件触发器:
1. 欢迎序列(注册后第 0-14 天)
| 天数 | 邮件 | 目标 |
|---|---|---|
| 第 0 天 | 欢迎 + 仪表盘链接 | 激活 |
| 第 1 天 | 「5 分钟内能做的 5 件事」 | 快速见效 |
| 第 3 天 | 教程:第一个项目 | 产品采纳 |
| 第 7 天 | 「35% 的用户错过了这个功能 →」 | 功能发现 |
| 第 14 天 | 案例 / 成功故事 | 社会证明 |
2. 试用到期(第 10-16 天)
export const checkTrialExpiring = createServerFn({ method: "POST" }).handler(
async () => {
// 找出 3 天后试用到期的用户
const expiringUsers = await env.DB.prepare(`
SELECT u.* FROM users u
WHERE u.created_at >= unixepoch() - (14 * 86400) - (3 * 86400)
AND u.created_at <= unixepoch() - (14 * 86400) - (2 * 86400)
AND u.id NOT IN (SELECT user_id FROM subscriptions WHERE status = 'active')
AND u.id NOT IN (
SELECT user_id FROM email_log
WHERE subject LIKE '%trial ending%'
AND created_at > unixepoch() - 604800
)
`).all()
for (const user of expiringUsers) {
await sendEmail({
to: user.email,
subject: "Your trial ends in 3 days — here is a special offer",
html: await renderTrialExpiryEmail(user),
})
}
return { notified: expiringUsers.length }
}
)
3. 扣款失败挽回
export const handleFailedPayment = createServerFn({ method: "POST" }).handler(
async ({ subscriptionId }: { subscriptionId: string }) => {
const subscription = await env.DB.prepare(`
SELECT s.*, u.email, u.name FROM subscriptions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ?
`).bind(subscriptionId).first() as Subscription & { email: string; name: string }
// 统计扣款失败次数
const failureCount = await env.DB.prepare(`
SELECT COUNT(*) as count FROM email_log
WHERE user_id = ? AND subject LIKE '%payment%failed%'
`).bind(subscription.user_id).first() as { count: number }
const templates = [
{ delay: 0, subject: "Your payment failed — update your billing info" },
{ delay: 48, subject: "Second attempt failed — your access will be paused" },
{ delay: 96, subject: "Last chance: your account will be downgraded" },
]
const template = templates[failureCount.count] ?? templates[templates.length - 1]
await sendEmail({
to: subscription.email,
subject: template.subject,
html: await renderPaymentFailedEmail(subscription, failureCount.count),
})
}
)
邮件分析仪表盘
// src/server/email-stats.ts
export const getEmailAnalytics = createServerFn({ method: "GET" }).handler(
async () => {
// 整体活动表现
const campaignStats = await env.DB.prepare(`
SELECT
ec.name,
ec.campaign_type,
COUNT(DISTINCT el.user_id) as recipients,
COUNT(DISTINCT CASE WHEN el.status = 'opened' THEN el.user_id END) as opens,
COUNT(DISTINCT CASE WHEN el.status = 'clicked' THEN el.user_id END) as clicks,
COUNT(DISTINCT CASE WHEN el.status = 'bounced' THEN el.user_id END) as bounces,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN el.status = 'opened' THEN el.user_id END) /
NULLIF(COUNT(DISTINCT el.user_id), 0), 1) as open_rate,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN el.status = 'clicked' THEN el.user_id END) /
NULLIF(COUNT(DISTINCT CASE WHEN el.status IN ('opened', 'clicked', 'delivered') THEN el.user_id END), 0), 1) as click_rate
FROM email_campaigns ec
JOIN campaign_emails ce ON ce.campaign_id = ec.id
JOIN email_log el ON el.campaign_email_id = ce.id
GROUP BY ec.id
ORDER BY ec.created_at DESC
`).all()
// 转化追踪:邮件 → 付费
const emailToRevenue = await env.DB.prepare(`
SELECT
ec.name as campaign_name,
COUNT(DISTINCT el.user_id) as emailed_users,
COUNT(DISTINCT CASE WHEN s.status = 'active' THEN el.user_id END) as converted_to_paid,
ROUND(100.0 * COUNT(DISTINCT CASE WHEN s.status = 'active' THEN el.user_id END) /
NULLIF(COUNT(DISTINCT el.user_id), 0), 2) as conversion_rate
FROM email_campaigns ec
JOIN campaign_emails ce ON ce.campaign_id = ec.id
JOIN email_log el ON el.campaign_email_id = ce.id
LEFT JOIN subscriptions s ON s.user_id = el.user_id AND s.status = 'active'
GROUP BY ec.id
`).all()
return { campaignStats, emailToRevenue }
}
)
送达率优化
再好的邮件内容,进了垃圾箱也白搭。送达率检查清单:
技术配置
# wrangler.jsonc — 邮件送达率所需的 DNS 记录
{
"dns": {
"records": [
{ "type": "TXT", "name": "@", "value": "v=spf1 include:sendgrid.net ~all" },
{ "type": "TXT", "name": "s1._domainkey", "value": "DKIM_KEY_HERE" },
{ "type": "TXT", "name": "_dmarc", "value": "v=DMARC1; p=quarantine; rua=mailto:dmarc@tanstack-ship.com" },
]
}
}
最佳实践
- 预热发送域名——从每天 50 封起步,两周内每天增加 50 封
- 监控退信率——保持在 2% 以下,硬退信立即移除
- 监控投诉率——保持在 0.1% 以下,移除反复投诉者
- 使用双重确认订阅——确认过的订阅者互动率高 5 倍
- 发送量保持稳定——突增会触发垃圾邮件过滤器
- 认证配置——SPF、DKIM、DMARC 不可妥协(按上文配置)
- 列表卫生——移除不活跃订阅者(90 天无打开)
邮件自动化检查清单
- 欢迎序列已配置(第 0、1、3、7、14 天)
- 试用到期序列(第 -3、-1、0 天)在收回访问权限之前发出
- 扣款失败序列(5 天内 3 封)带清晰的 CTA
- 非活跃用户的唤回序列(7、30、60 天)
- 事务邮件(回执、发票、账户变更)已自动化
- UTM 参数自动注入所有活动邮件链接
- 邮件模板在主流客户端(Gmail、Outlook、Apple Mail)渲染正常
- SPF、DKIM、DMARC 已配置并验证
- 退信处理自动移除无效地址
- 每封邮件都有退订链接(CAN-SPAM 合规)
- 邮件分析仪表盘追踪打开、点击与转化
- A/B 测试框架可测试主题行与内容变体
- 所有邮件在 HTML 之外附带纯文本版本
- 发送域名已预热(逐步加量)
结论
邮件自动化是 SaaS 最强大的自有营销渠道,因为它同时具备三项关键能力:
- 个性化的时机——每封邮件都可以由特定的用户行为(或不作为)触发,在用户正需要的时刻触达
- 可衡量的归因——每次打开、点击和转化都可追踪,给你清晰的 ROI 数据
- 复利回报——欢迎序列建一次,就为每一个新用户永久运行,边际成本为零
本文概述的架构——数据库驱动的活动、React Email 模板、Cloudflare Workers 上的服务端渲染、行为触发器,以及送达率优先的基础设施——给你一套完整的邮件自动化系统,能从最初 100 个用户扩展到前 10 万个。
构建用户需要的序列,而不是最容易发的序列。每封邮件要么帮用户成功,要么帮你的业务更好地理解他们。两者都不占的,别发。
邮件自动化是SaaS 增长框架:前 100 个付费客户的关键组成部分。要在更广的营销归因之外追踪邮件活动表现,看UTM 归因完整指南。要设计利用现有用户的推荐型邮件活动,看如何设计有效的推荐奖励系统。