SaaS 邮件营销自动化实战:45% 打开率背后的 7 个 Drip 工作流与数据库 Schema

生产级邮件自动化配置:我们用多触点序列在 12 个 SaaS 应用中做出 45% 打开率。覆盖自动化新手引导、互动唤回与流失挽回流程,附每个工作流背后的数据库 Schema。

Maya Patel
Maya Patel
June 11, 202615 min read
其他语言:Deutsch · English

本文由 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

sql
-- 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)做一致、响应式的邮件模板:

typescript
// 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>
  )
}

服务端邮件渲染

typescript
// 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)

typescript
// 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") }
}

自动化活动引擎

处理触发器并发送邮件的核心自动化引擎:

typescript
// 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 天)

typescript
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. 扣款失败挽回

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

邮件分析仪表盘

typescript
// 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 }
  }
)

送达率优化

再好的邮件内容,进了垃圾箱也白搭。送达率检查清单:

技术配置

toml
# 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 最强大的自有营销渠道,因为它同时具备三项关键能力:

  1. 个性化的时机——每封邮件都可以由特定的用户行为(或不作为)触发,在用户正需要的时刻触达
  2. 可衡量的归因——每次打开、点击和转化都可追踪,给你清晰的 ROI 数据
  3. 复利回报——欢迎序列建一次,就为每一个新用户永久运行,边际成本为零

本文概述的架构——数据库驱动的活动、React Email 模板、Cloudflare Workers 上的服务端渲染、行为触发器,以及送达率优先的基础设施——给你一套完整的邮件自动化系统,能从最初 100 个用户扩展到前 10 万个。

构建用户需要的序列,而不是最容易发的序列。每封邮件要么帮用户成功,要么帮你的业务更好地理解他们。两者都不占的,别发。

邮件自动化是SaaS 增长框架:前 100 个付费客户的关键组成部分。要在更广的营销归因之外追踪邮件活动表现,看UTM 归因完整指南。要设计利用现有用户的推荐型邮件活动,看如何设计有效的推荐奖励系统。