每个 SaaS 产品都离不开邮件——注册验证、密码重置、订单通知、营销触达。但根据 Mailgun 2025 年的行业报告,超过 40% 的开发者自建邮件系统在上线后 3 个月内遭遇投递率暴跌,其中 70% 的根因是 DNS 配置错误(SPF/DKIM/DMARC)。邮件发送看似只是调一个 SMTP API,实际上涉及 DNS 信誉管理、退信处理、速率限制、模板渲染、多租户隔离等多个工程环节。本文将从零搭建一套生产级邮件发送系统,覆盖从 DNS 配置到高可用投递的每一个实战细节。
📌 记住: 邮件投递率不是「发出去就行」,而是「收件箱到达率」。一封进入垃圾箱的邮件,在业务上等同于没发。
🔐 一、邮件认证三件套:SPF、DKIM 与 DMARC
1.1 为什么邮件认证是第一优先级
在写任何发送代码之前,你必须先配置好 DNS 认证记录。没有正确认证的邮件,Gmail 和 Outlook 会直接将其标记为垃圾邮件甚至拒绝接收。三大认证机制的作用:
- ✅ SPF(Sender Policy Framework):声明哪些 IP 地址有权代表你的域名发送邮件
- ✅ DKIM(DomainKeys Identified Mail):用私钥对邮件签名,收件方用公钥验证邮件未被篡改
- ✅ DMARC(Domain-based Message Authentication):告诉收件方如何处理 SPF/DKIM 验证失败的邮件
# SPF 记录示例 — 在 DNS 中添加 TXT 记录
# 允许 Resend 和 SendGrid 代发
yourdomain.com. IN TXT "v=spf1 include:amazonses.com include:sendgrid.net ~all"
⚠️ 警告: SPF 记录的 DNS 查询次数有 10 次上限(RFC 7208)。如果你同时使用多个邮件服务商,超过 10 次查询会导致 SPF 验证失败。解决方案是使用 SPF 扁平化(Flattening)服务。
# DKIM 记录示例 — 由邮件服务商生成,添加到 DNS
# selector 由服务商提供(如 resend、s1 等)
resend._domainkey.yourdomain.com. IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSq..."
# DMARC 记录示例 — 控制未通过验证邮件的处理策略
# 建议先用 p=none 收集报告,再逐步升级到 p=quarantine 或 p=reject
_dmarc.yourdomain.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com; pct=100"
1.2 DMARC 策略升级路线图
DMARC 策略分三个阶段,不要直接跳到 p=reject,否则合法邮件可能被误杀:
| 阶段 | 策略 | 行为 | 持续时间 | 推荐 |
|---|---|---|---|---|
| 观察期 | p=none |
仅收集报告,不拦截 | 2-4 周 | ✅ 必须 |
| 隔离期 | p=quarantine |
失败邮件进垃圾箱 | 2-4 周 | ✅ 推荐 |
| 拒绝期 | p=reject |
直接拒绝失败邮件 | 长期 | ⚠️ 确认无误后启用 |
💡 提示: 使用 DMARC 报告分析工具(如 dmarcian、Postmark DMARC Digest)来监控验证失败情况。
rua标签指定的邮箱会收到每日聚合报告,包含所有邮件的认证结果。
🚀 二、邮件服务商选型:Resend vs SendGrid vs AWS SES
2.1 核心对比
选择邮件服务商是关键的架构决策。以下是 2026 年三大主流方案的深度对比:
| 特性 | Resend | SendGrid | AWS SES |
|---|---|---|---|
| 开发体验 | ✅ 极佳(TypeScript SDK) | ⚠️ 一般(老旧 API) | ⚠️ 一般(需 AWS SDK) |
| 免费额度 | 3,000 封/月 | 100 封/天 | 62,000 封/月(EC2) |
| 付费起步价 | $20/月(50K 封) | $19.95/月(50K 封) | $0.10/千封 |
| Webhook 支持 | ✅ 完整 | ✅ 完整 | ✅ 通过 SNS |
| 模板系统 | React Email | 内置模板 | SES Templates |
| 投递率 | ✅ 优秀 | ✅ 优秀 | ✅ 优秀(需预热) |
| TypeScript 原生 | ✅ 一等公民 | ❌ 社区维护 | ❌ AWS SDK |
| 推荐场景 | ✅ 新项目首选 | ✅ 大规模发送 | ✅ 已用 AWS 的团队 |
⚡ 关键结论: 新项目优先选择 Resend——它的 TypeScript SDK 设计最优雅,React Email 模板引擎让前端开发者可以直接用 JSX 写邮件模板,学习成本最低。如果日发送量超过 100 万封,AWS SES 的成本优势会非常明显。
2.2 TypeScript SDK 集成
// mailer.ts — Resend SDK 封装(推荐方案)
import { Resend } from 'resend'
const resend = new Resend(process.env.RESEND_API_KEY)
interface SendEmailOptions {
from: string
to: string | string[]
subject: string
html?: string
react?: React.ReactElement // React Email 模板
replyTo?: string
tags?: { name: string; value: string }[]
}
// ✅ 推荐写法:封装统一的发送接口,便于切换服务商
export async function sendEmail(options: SendEmailOptions) {
try {
const { data, error } = await resend.emails.send({
from: options.from,
to: Array.isArray(options.to) ? options.to : [options.to],
subject: options.subject,
html: options.html,
react: options.react,
reply_to: options.replyTo,
tags: options.tags,
})
if (error) {
console.error('[邮件发送失败]', error)
throw new EmailSendError(error.message, options.to)
}
console.log('[邮件发送成功]', { id: data?.id, to: options.to })
return { id: data?.id, success: true }
} catch (err) {
if (err instanceof EmailSendError) throw err
throw new EmailSendError(
err instanceof Error ? err.message : '未知错误',
options.to
)
}
}
class EmailSendError extends Error {
constructor(
message: string,
public recipients: string | string[]
) {
super(`邮件发送失败: ${message}`)
this.name = 'EmailSendError'
}
}
2.3 多服务商故障切换
生产环境不应该依赖单一服务商。实现一个简单的故障切换机制:
// multi-provider-mailer.ts — 多服务商故障切换
import { Resend } from 'resend'
import sgMail from '@sendgrid/mail'
interface EmailProvider {
name: string
send(options: SendEmailOptions): Promise<{ id: string }>
}
class ResendProvider implements EmailProvider {
name = 'resend'
private client = new Resend(process.env.RESEND_API_KEY)
async send(options: SendEmailOptions) {
const { data, error } = await this.client.emails.send({
from: options.from,
to: Array.isArray(options.to) ? options.to : [options.to],
subject: options.subject,
html: options.html,
})
if (error) throw new Error(error.message)
return { id: data?.id ?? '' }
}
}
class SendGridProvider implements EmailProvider {
name = 'sendgrid'
constructor() {
sgMail.setApiKey(process.env.SENDGRID_API_KEY!)
}
async send(options: SendEmailOptions) {
const [result] = await sgMail.send({
from: options.from,
to: options.to,
subject: options.subject,
html: options.html,
})
return { id: result.headers['x-message-id'] ?? '' }
}
}
// ⚠️ 注意事项:主服务商失败后自动降级,但要记录降级事件用于告警
class MultiProviderMailer {
private providers: EmailProvider[]
private fallbackThreshold: number
constructor(providers: EmailProvider[], fallbackThreshold = 3) {
this.providers = providers
this.fallbackThreshold = fallbackThreshold
}
async send(options: SendEmailOptions) {
let lastError: Error | undefined
for (const provider of this.providers) {
try {
const result = await provider.send(options)
console.log(`[邮件] 通过 ${provider.name} 发送成功`, result)
return { ...result, provider: provider.name }
} catch (err) {
lastError = err as Error
console.warn(`[邮件] ${provider.name} 发送失败,尝试下一个`, err)
}
}
throw lastError ?? new Error('所有邮件服务商均不可用')
}
}
// 使用示例
const mailer = new MultiProviderMailer([
new ResendProvider(),
new SendGridProvider(),
])
// 发送邮件 — 自动故障切换
await mailer.send({
from: 'noreply@yourdomain.com',
to: 'user@example.com',
subject: '欢迎注册',
html: '<h1>欢迎!</h1><p>感谢您的注册。</p>',
})
📧 三、邮件模板引擎:MJML 与 React Email
3.1 为什么不能直接写 HTML 邮件
邮件 HTML 渲染是前端开发中最令人痛苦的兼容性问题。Gmail 移除 <style> 标签、Outlook 使用 Word 渲染引擎、各客户端对 Flexbox/Grid 支持不一致——你需要用 2005 年的 HTML 技术(表格布局、内联样式)来写邮件模板。
两大模板引擎的对比:
| 特性 | MJML | React Email |
|---|---|---|
| 语法 | 自定义标签 | JSX + Tailwind |
| 学习成本 | ✅ 低(标签语义化) | ⚠️ 需要 React 知识 |
| 响应式 | ✅ 自动处理 | ✅ 自动处理 |
| 组件复用 | ⚠️ 需要构建工具 | ✅ React 组件生态 |
| 实时预览 | ✅ MJML App | ✅ React Email Dev |
| 与 React 集成 | ⚠️ 需要编译 | ✅ 原生集成 |
| 推荐场景 | ✅ 独立邮件系统 | ✅ React 项目首选 |
3.2 React Email 模板实战
// emails/welcome.tsx — React Email 欢迎邮件模板
import {
Html, Head, Body, Container, Section,
Text, Button, Img, Hr, Link,
} from '@react-email/components'
interface WelcomeEmailProps {
username: string
verifyUrl: string
}
export function WelcomeEmail({ username, verifyUrl }: WelcomeEmailProps) {
return (
<Html lang="zh-CN">
<Head />
<Body style={body}>
<Container style={container}>
<Section style={header}>
<Img
src="https://yourdomain.com/logo.png"
width="120"
height="40"
alt="Logo"
/>
</Section>
<Section style={content}>
<Text style={heading}>欢迎加入,{username}!</Text>
<Text style={paragraph}>
感谢您注册我们的服务。请点击下方按钮验证您的邮箱地址,
验证后即可开始使用全部功能。
</Text>
<Button href={verifyUrl} style={button}>
验证邮箱
</Button>
<Text style={smallText}>
如果按钮无法点击,请复制以下链接到浏览器打开:
</Text>
<Link href={verifyUrl} style={link}>
{verifyUrl}
</Link>
</Section>
<Hr style={divider} />
<Section style={footer}>
<Text style={footerText}>
© 2026 YourCompany. 如有疑问,请回复此邮件联系我们。
</Text>
</Section>
</Container>
</Body>
</Html>
)
}
// 样式定义 — React Email 会自动内联到 HTML
const body = {
backgroundColor: '#f6f9fc',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
}
const container = {
margin: '0 auto',
padding: '20px 0 48px',
maxWidth: '580px',
}
const header = { padding: '24px', textAlign: 'center' as const }
const content = {
padding: '40px',
backgroundColor: '#ffffff',
borderRadius: '8px',
}
const heading = {
fontSize: '24px',
fontWeight: 'bold' as const,
color: '#1a1a1a',
margin: '0 0 16px',
}
const paragraph = {
fontSize: '16px',
lineHeight: '26px',
color: '#525f7f',
margin: '0 0 24px',
}
const button = {
backgroundColor: '#2563eb',
borderRadius: '6px',
color: '#ffffff',
fontSize: '16px',
fontWeight: 'bold' as const,
textDecoration: 'none',
textAlign: 'center' as const,
display: 'block',
padding: '12px 24px',
}
const smallText = {
fontSize: '14px',
color: '#8898aa',
margin: '24px 0 8px',
}
const link = { fontSize: '14px', color: '#2563eb', wordBreak: 'break-all' as const }
const divider = {
borderColor: '#e6ebf1',
margin: '20px 0',
}
const footer = { padding: '0 40px' }
const footerText = {
fontSize: '12px',
color: '#8898aa',
textAlign: 'center' as const,
}
💡 提示: 使用
@react-email/render的render()函数将 JSX 编译为 HTML 字符串:const html = await render(<WelcomeEmail username="张三" verifyUrl="..." />)。开发阶段用react-email dev实时预览。
3.3 邮件模板的常见陷阱
邮件模板开发中有几个必须注意的兼容性问题,这些问题会导致邮件在特定客户端显示异常:
- ❌ 不要使用
<div>做布局:Outlook 使用 Word 渲染引擎,对<div>的支持极差,必须用<table>布局 - ❌ 不要使用 CSS Flexbox/Grid:Gmail 会剥离
<style>标签,只保留内联样式 - ❌ 不要使用外部字体:大多数邮件客户端不支持
@font-face,使用系统字体栈 - ❌ 不要使用 CSS 变量:没有任何邮件客户端支持 CSS 自定义属性
- ✅ 所有样式必须内联:React Email 和 MJML 会自动处理,但手写 HTML 时必须手动内联
- ✅ 图片必须使用绝对路径:
https://开头的完整 URL,不能用相对路径
// ❌ 错误写法:在邮件中使用现代 CSS
const badTemplate = `
<div style="display: flex; gap: 16px;">
<div style="flex: 1;">内容</div>
</div>
`
// ✅ 正确写法:使用表格布局 + 内联样式
const goodTemplate = `
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="padding: 16px; font-family: Arial, sans-serif; color: #333;">
内容
</td>
</tr>
</table>
`
⚠️ 四、退信处理与投递率优化
4.1 退信类型与处理策略
退信(Bounce)分两种,处理策略完全不同:
| 退信类型 | 原因 | 处理策略 | 推荐 |
|---|---|---|---|
| 硬退信(Hard Bounce) | 邮箱不存在、域名无效 | ✅ 立即标记为无效,停止发送 | ❌ 不要重试 |
| 软退信(Soft Bounce) | 收件箱已满、临时拒收 | ⚠️ 指数退避重试 3 次 | ✅ 重试后标记 |
// bounce-handler.ts — 退信处理逻辑
import type { WebhookEvent } from 'resend'
interface BounceRecord {
email: string
type: 'hard' | 'soft'
reason: string
count: number
lastBounceAt: Date
suppressed: boolean
}
// 退信存储(生产环境用数据库)
const bounceMap = new Map<string, BounceRecord>()
export function handleBounceEvent(event: WebhookEvent) {
if (event.type !== 'email.bounced') return
const { to, bounce } = event.data
const email = Array.isArray(to) ? to[0] : to
const existing = bounceMap.get(email)
// ✅ 硬退信:立即抑制,永远不再发送
if (bounce?.type === 'hard') {
bounceMap.set(email, {
email,
type: 'hard',
reason: bounce.message ?? '邮箱不存在',
count: (existing?.count ?? 0) + 1,
lastBounceAt: new Date(),
suppressed: true,
})
console.warn(`[退信] 硬退信,已抑制: ${email}`)
return
}
// ⚠️ 软退信:累计 3 次后抑制
const newCount = (existing?.count ?? 0) + 1
bounceMap.set(email, {
email,
type: 'soft',
reason: bounce?.message ?? '临时拒收',
count: newCount,
lastBounceAt: new Date(),
suppressed: newCount >= 3,
})
if (newCount >= 3) {
console.warn(`[退信] 软退信累计 ${newCount} 次,已抑制: ${email}`)
}
}
// 发送前检查:跳过已抑制的邮箱
export function isSuppressed(email: string): boolean {
return bounceMap.get(email)?.suppressed ?? false
}
4.2 投递率优化清单
以下是一个经过生产验证的投递率优化清单:
- ✅ 配置 SPF + DKIM + DMARC:三大认证缺一不可
- ✅ 使用独立发送域名:不要用主域名发送营销邮件(如
mail.yourdomain.com) - ✅ IP 预热(Warm-up):新 IP 前两周逐步增加发送量,从 50 封/天开始
- ✅ 维护退信列表:硬退信立即停止,软退信累计后停止
- ✅ 设置 List-Unsubscribe 头:让邮件客户端显示退订按钮,减少用户标记垃圾邮件
- ✅ 控制发送频率:单个收件人每天不超过 2 封,每周不超过 5 封
- ❌ 不要购买邮件列表:这是最快毁掉域名信誉的方式
- ❌ 不要隐藏退订链接:会导致用户标记垃圾邮件,严重影响投递率
// 邮件头优化 — 提升投递率的关键配置
await sendEmail({
from: '通知 <notify@yourdomain.com>', // ✅ 使用友好名称
to: 'user@example.com',
subject: '您的订单已发货',
html: orderShippedHtml,
headers: {
// ✅ 设置退订头 — Gmail/Outlook 会显示退订按钮
'List-Unsubscribe': '<https://yourdomain.com/unsubscribe?token=xxx>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
// ✅ 自定义追踪头
'X-Entity-Ref-ID': orderId,
},
tags: [
// ✅ 标签用于 Webhook 分类和统计
{ name: 'category', value: 'order-notification' },
{ name: 'env', value: process.env.NODE_ENV! },
],
})
📌 记住: 邮件发送系统的核心指标不是「发了多少封」,而是「收件箱到达率(Inbox Rate)」。使用 GlockApps、Mail-Tester 等工具定期测试你的邮件到达率,目标是 95% 以上。
📊 五、Webhook 事件处理与发送统计
5.1 关键事件类型
邮件服务商通过 Webhook 通知你每封邮件的投递状态。以下是需要处理的关键事件:
| 事件 | 含义 | 处理动作 | 推荐 |
|---|---|---|---|
delivered |
成功投递到收件服务器 | ✅ 记录统计 | ✅ 必须 |
bounced |
退信 | ⚠️ 更新退信列表 | ✅ 必须 |
opened |
收件人打开了邮件 | 📊 更新打开率统计 | ✅ 推荐 |
clicked |
收件人点击了链接 | 📊 更新点击率统计 | ✅ 推荐 |
complained |
收件人标记为垃圾邮件 | ❌ 立即抑制该邮箱 | ✅ 必须 |
// webhook-handler.ts — Resend Webhook 事件处理
import { Hono } from 'hono'
import crypto from 'node:crypto'
const app = new Hono()
// ⚠️ 警告:必须验证 Webhook 签名,防止伪造请求
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)
}
app.post('/webhooks/resend', async (c) => {
const body = await c.req.text()
const signature = c.req.header('resend-signature') ?? ''
if (!verifyWebhookSignature(body, signature, process.env.RESEND_WEBHOOK_SECRET!)) {
return c.json({ error: '签名验证失败' }, 401)
}
const event = JSON.parse(body)
switch (event.type) {
case 'email.delivered':
await recordMetric('delivered', event.data)
break
case 'email.bounced':
await handleBounceEvent(event)
await recordMetric('bounced', event.data)
break
case 'email.complained':
await suppressEmail(event.data.to)
await recordMetric('complained', event.data)
break
case 'email.opened':
await recordMetric('opened', event.data)
break
case 'email.clicked':
await recordMetric('clicked', event.data)
break
}
return c.json({ received: true })
})
async function recordMetric(type: string, data: any) {
// 写入数据库或时序数据库(如 ClickHouse)
console.log(`[指标] ${type}`, { email: data.to, id: data.email_id })
}
async function suppressEmail(email: string) {
// 将投诉用户加入抑制列表
console.warn(`[抑制] 用户投诉,停止发送: ${email}`)
}
⚡ 关键结论: Webhook 处理必须幂等——同一个事件可能被多次推送。使用
email_id + event_type作为去重键,避免重复处理。
🏗️ 六、发送队列与速率限制
6.1 为什么需要发送队列
直接在 API 请求中同步发送邮件有三个问题:
- ❌ 超时风险:SMTP/API 调用可能需要数秒,阻塞用户请求
- ❌ 速率限制:服务商有发送速率上限(如 Resend 10 封/秒)
- ❌ 重试困难:同步失败后无法自动重试
使用消息队列解耦发送逻辑:
// email-queue.ts — 使用 BullMQ 的邮件发送队列
import { Queue, Worker, QueueEvents } from 'bullmq'
import IORedis from 'ioredis'
const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
})
// 创建邮件队列
const emailQueue = new Queue('email-sending', {
connection,
defaultJobOptions: {
attempts: 3, // 最多重试 3 次
backoff: {
type: 'exponential', // 指数退避
delay: 1000, // 初始延迟 1 秒
},
removeOnComplete: { age: 86400 }, // 完成后保留 24 小时
removeOnFail: { age: 604800 }, // 失败后保留 7 天
},
})
// 入队:添加邮件到发送队列
export async function enqueueEmail(options: SendEmailOptions) {
const job = await emailQueue.add('send', options, {
// ✅ 相同收件人 + 主题的消息在 5 分钟内去重
jobId: `${options.to}-${options.subject}-${Date.now()}`,
priority: options.priority ?? 5, // 1 最高,10 最低
})
console.log(`[队列] 邮件已入队: ${job.id}`)
return job.id
}
// Worker:消费队列并发送邮件
const worker = new Worker(
'email-sending',
async (job) => {
const { from, to, subject, html } = job.data
// ✅ 发送前检查:跳过已抑制的邮箱
const recipients = Array.isArray(to) ? to : [to]
const validRecipients = recipients.filter((r) => !isSuppressed(r))
if (validRecipients.length === 0) {
console.log(`[跳过] 所有收件人均已抑制`)
return { skipped: true }
}
// ✅ 速率限制:每秒最多 10 封(适配 Resend 限制)
await job.updateProgress(50)
const result = await sendEmail({ ...job.data, to: validRecipients })
await job.updateProgress(100)
return result
},
{
connection,
concurrency: 5, // 同时处理 5 个任务
limiter: {
max: 10, // 每个时间窗口最多 10 个任务
duration: 1000, // 时间窗口 1 秒
},
}
)
// 监听 Worker 事件
worker.on('completed', (job) => {
console.log(`[完成] 邮件发送成功: ${job.id}`)
})
worker.on('failed', (job, err) => {
console.error(`[失败] 邮件发送失败: ${job?.id}`, err.message)
})
📝 总结与最佳实践
构建生产级邮件发送系统的核心原则:
| 原则 | 说明 | 推荐 |
|---|---|---|
| 认证先行 | 先配好 SPF/DKIM/DMARC,再写发送代码 | ✅ 必须 |
| 多服务商 | 主备双服务商,自动故障切换 | ✅ 推荐 |
| 异步发送 | 用消息队列解耦,不阻塞业务请求 | ✅ 必须 |
| 退信管理 | 硬退信立即抑制,软退信累计后抑制 | ✅ 必须 |
| 模板引擎 | 用 React Email 或 MJML,不要裸写 HTML | ✅ 推荐 |
| 可观测性 | 处理 Webhook 事件,统计投递率/打开率 | ✅ 推荐 |
| 速率控制 | 遵守服务商限制,使用令牌桶算法 | ✅ 必须 |
相关工具推荐
- 🔧 Resend — TypeScript 优先的邮件发送服务,开发体验最佳
- 🔧 React Email — 用 JSX 编写响应式邮件模板
- 🔧 MJML — 邮件标记语言,自动处理兼容性
- 🔧 BullMQ — 基于 Redis 的可靠任务队列
- 🔧 Mail-Tester — 免费测试邮件投递率和垃圾邮件评分
- 🔧 dmarcian — DMARC 报告分析和合规管理