关于 email-systems
email-systems 是面向 Web 应用开发者的邮件系统构建指南,涵盖事务性与营销邮件的发送、模板、投递和处理流程。它提供队列重试、DNS 身份验证及退信与投诉管理等实现模式。
主要功能
- 01事务性与营销邮件区分
- 02React Email 和 MJML 模板实践
- 03队列发送与失败重试
- 04SPF、DKIM 和 DMARC 配置
- 05退信、投诉与抑制列表管理
- 06邮件投递测试与排查
适用场景
- 01发送注册欢迎和密码重置邮件
- 02构建订单确认或通知摘要
- 03诊断邮件进入垃圾箱的问题
- 04迁移邮件服务提供商
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ travisjneuman/ .claude --skill email-systems安装后在 Claude Code 中输入/email-systems确认已加载。
详细介绍
电子邮件系统
概述
此技能涵盖为 Web 应用构建可靠的电子邮件系统,包括事务性邮件发送、模板设计、送达率优化和收件箱投递。内容涉及与电子邮件服务提供商(Resend、AWS SES、SendGrid、Postmark)集成,使用 React Email 和 MJML 构建响应式 HTML 邮件模板,配置用于提升送达率的 DNS 记录(SPF、DKIM、DMARC),管理邮件类型(事务性、通知、营销)、队列管理,以及退信和投诉处理。
在发送欢迎邮件、密码重置邮件、订单确认邮件、通知摘要、营销活动邮件或任何系统生成的邮件时,请使用此技能。排查送达问题或迁移电子邮件服务提供商时也请使用此技能。
核心原则
- 事务性邮件和营销邮件有所不同——事务性邮件(密码重置、收据)必须即时且可靠地送达。营销邮件(新闻简报、促销)可以批量发送,并且应包含取消订阅链接。切勿在同一发送域名上混发这两类邮件。
- HTML 邮件并非 Web 开发——电子邮件客户端渲染的是 2004 年的 HTML。不支持 flexbox、grid 等现代 CSS。使用表格布局、内联样式,并在不同客户端中进行测试。React Email 和 MJML 可以简化这些繁琐工作。
- 送达率需要长期维护——SPF、DKIM 和 DMARC 是基本要求,并非送达保证。保持较低的退信率(< 2%)和投诉率(< 0.1%),并逐步预热新的发送域名。
- 所有邮件都应加入队列——切勿在请求处理程序中同步发送邮件。将邮件发送加入队列,避免阻塞用户请求,并支持失败重试。
- 发送前先测试——在开发环境中使用 Mailtrap 或 Ethereal,在多个客户端中预览(Litmus、Email on Acid),并确认每个链接都能正常使用。
关键模式
模式 1:使用 Resend 的 React Email 模板
**适用场景:**使用现代开发体验构建类型安全、基于组件的邮件模板,并确保可靠送达。
实现:
// emails/welcome.tsx - React Email template
import {
Html,
Head,
Body,
Container,
Section,
Text,
Button,
Img,
Hr,
Link,
Preview,
} from "@react-email/components";
interface WelcomeEmailProps {
userName: string;
loginUrl: string;
guideUrl: string;
}
export function WelcomeEmail({ userName, loginUrl, guideUrl }: WelcomeEmailProps) {
return (
<Html lang="en">
<Head />
<Preview>Welcome to MyApp, {userName}! Here's how to get started.</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Img
src="https://myapp.com/logo.png"
width={120}
height={36}
alt="MyApp"
/>
<Section style={styles.section}>
<Text style={styles.heading}>Welcome, {userName}!</Text>
<Text style={styles.text}>
Thanks for signing up. Your account is ready to go.
Here are three things to get you started:
</Text>
<Text style={styles.listItem}>
<strong>1. Create your first project</strong> - Click "New Project" on your dashboard.
</Text>
<Text style={styles.listItem}>
<strong>2. Invite your team</strong> - Share your workspace with colleagues.
</Text>
<Text style={styles.listItem}>
<strong>3. Explore the guide</strong> - Learn tips and best practices.
</Text>
<Section style={styles.buttonContainer}>
<Button style={styles.button} href={loginUrl}>
Go to Dashboard
</Button>
</Section>
</Section>
<Hr style={styles.hr} />
<Section style={styles.footer}>
<Text style={styles.footerText}>
Need help? Reply to this email or visit our{" "}
<Link href={guideUrl} style={styles.link}>help center</Link>.
</Text>
<Text style={styles.footerText}>
MyApp, Inc. | 123 Street, City, ST 12345
</Text>
</Section>
</Container>
</Body>
</Html>
);
}
const styles = {
body: {
backgroundColor: "#f6f9fc",
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
},
container: {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "20px 0 48px",
maxWidth: "600px",
borderRadius: "8px",
},
section: { padding: "0 48px" },
heading: { fontSize: "24px", fontWeight: "bold" as const, marginBottom: "16px" },
text: { fontSize: "16px", lineHeight: "26px", color: "#404040" },
listItem: { fontSize: "15px", lineHeight: "24px", color: "#404040", marginBottom: "8px" },
buttonContainer: { textAlign: "center" as const, marginTop: "24px", marginBottom: "24px" },
button: {
backgroundColor: "#0066ff",
borderRadius: "6px",
color: "#ffffff",
fontSize: "16px",
fontWeight: "bold" as const,
textDecoration: "none",
textAlign: "center" as const,
display: "inline-block",
padding: "12px 24px",
},
hr: { borderColor: "#e6ebf1", margin: "32px 0" },
footer: { padding: "0 48px" },
footerText: { fontSize: "12px", color: "#8898aa", lineHeight: "20px" },
link: { color: "#0066ff" },
};
// Default props for preview
WelcomeEmail.PreviewProps = {
userName: "Jane",
loginUrl: "https://myapp.com/dashboard",
guideUrl: "https://myapp.com/guide",
} satisfies WelcomeEmailProps;
export default WelcomeEmail;
// lib/email.ts - Email sending service
import { Resend } from "resend";
import { WelcomeEmail } from "@/emails/welcome";
import { PasswordResetEmail } from "@/emails/password-reset";
const resend = new Resend(process.env.RESEND_API_KEY);
// Type-safe email sending
type EmailTemplate =
| { template: "welcome"; props: { userName: string; loginUrl: string; guideUrl: string } }
| { template: "password-reset"; props: { resetUrl: string; expiresIn: string } }
| { template: "invoice"; props: { invoiceUrl: string; amount: string; dueDate: string } };
const templates: Record<string, React.FC<Record<string, unknown>>> = {
welcome: WelcomeEmail,
"password-reset": PasswordResetEmail,
};
const subjects: Record<string, (props: Record<string, unknown>) => string> = {
welcome: () => "Welcome to MyApp!",
"password-reset": () => "Reset your password",
invoice: (p) => `Invoice for $${p.amount}`,
};
async function sendEmail(
to: string,
email: EmailTemplate
): Promise<{ id: string }> {
const Template = templates[email.template];
const subject = subjects[email.template](email.props);
const { data, error } = await resend.emails.send({
from: "MyApp <[email protected]>",
to,
subject,
react: Template(email.props),
});
if (error) {
throw new EmailSendError(error.message, { to, template: email.template });
}
return { id: data!.id };
}
export { sendEmail };
**原因:**React Email 提供基于组件的邮件模板、TypeScript 类型安全、开发期间的热重载,以及自动转换为适用于电子邮件的 HTML。Resend 具有较高的送达率、简单的 API,并开箱即用地集成 React Email。类型安全的模板定义可避免发送缺少属性的邮件。
模式 2:带重试逻辑的邮件队列
**适用场景:**任何生产环境中的邮件发送。切勿在请求处理程序中同步发送邮件。
实现:
// jobs/email.ts - Background email processing
import { Queue, Worker } from "bullmq";
import { Redis } from "ioredis";
import { sendEmail } from "@/lib/email";
const connection = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });
// Define the email queue
const emailQueue = new Queue("email", {
connection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: "exponential",
delay: 60_000, // 1 min, then 2 min, then 4 min
},
removeOnComplete: { age: 7 * 24 * 60 * 60 }, // Keep completed jobs for 7 days
removeOnFail: { age: 30 * 24 * 60 * 60 }, // Keep failed jobs for 30 days
},
});
// Queue an email (call this from your application code)
async function queueEmail(
to: string,
template: EmailTemplate,
options?: { delay?: number; priority?: number }
): Promise<string> {
const job = await emailQueue.add(
template.template,
{ to, template },
{
delay: options?.delay,
priority: options?.priority ?? 0,
}
);
return job.id!;
}
// Process emails in the background
const emailWorker = new Worker(
"email",
async (job) => {
const { to, template } = job.data;
try {
const result = await sendEmail(to, template);
return result;
} catch (error) {
// Log for monitoring
console.error(`Email send failed (attempt ${job.attemptsMade + 1}/${job.opts.attempts}):`, {
to,
template: template.template,
error: (error as Error).message,
});
throw error; // BullMQ will retry based on backoff config
}
},
{
connection,
concurrency: 10, // Process 10 emails concurrently
limiter: {
max: 100, // Max 100 emails per minute (respect ESP rate limits)
duration: 60_000,
},
}
);
// Listen for permanent failures
emailWorker.on("failed", (job, error) => {
if (job && job.attemptsMade >= (job.opts.attempts ?? 3)) {
// All retries exhausted - alert and log
alertOps(`Email permanently failed: ${job.data.to} - ${error.message}`);
}
});
export { queueEmail };
// Usage in application code
async function handleUserSignup(user: User) {
// Create user in database (synchronous, in request)
await db.user.create({ data: user });
// Queue welcome email (async, background)
await queueEmail(user.email, {
template: "welcome",
props: {
userName: user.name,
loginUrl: `${process.env.APP_URL}/dashboard`,
guideUrl: `${process.env.APP_URL}/guide`,
},
});
}
**原因:**将邮件加入队列可使邮件送达与用户请求解耦。如果邮件服务提供商响应缓慢或服务中断,用户注册仍能成功。指数退避可处理暂时性故障。速率限制可避免超出 ESP 配额。保留作业有助于排查邮件送达问题。
模式 3:提升送达率的 DNS 配置
**适用场景:**设置新的发送域名或诊断送达问题。
实现:
# SPF Record - Authorize sending servers
# Add to DNS as TXT record on your domain
myapp.com. TXT "v=spf1 include:_spf.resend.com include:amazonses.com ~all"
# DKIM Record - Cryptographic email signing
# Provider-specific, usually a CNAME record
resend._domainkey.myapp.com. CNAME resend._domainkey.example.com.
# DMARC Record - Alignment policy
# Start with p=none (monitor), move to p=quarantine, then p=reject
_dmarc.myapp.com. TXT "v=DMARC1; p=quarantine; rua=mailto:[email protected]; pct=100; adkim=s; aspf=s"
# Return-Path / Bounce domain (custom envelope sender)
bounce.myapp.com. MX 10 feedback-smtp.us-east-1.amazonses.com.
bounce.myapp.com. TXT "v=spf1 include:amazonses.com ~all"
// Verify DNS configuration programmatically
import { resolveTxt, resolveCname, resolveMx } from "dns/promises";
async function verifyEmailDNS(domain: string): Promise<Record<string, boolean>> {
const results: Record<string, boolean> = {};
// Check SPF
try {
const txt = await resolveTxt(domain);
const spf = txt.flat().find((r) => r.startsWith("v=spf1"));
results.spf = !!spf;
} catch {
results.spf = false;
}
// Check DMARC
try {
const txt = await resolveTxt(`_dmarc.${domain}`);
const dmarc = txt.flat().find((r) => r.startsWith("v=DMARC1"));
results.dmarc = !!dmarc;
} catch {
results.dmarc = false;
}
// Check DKIM (provider-specific selector)
try {
const cname = await resolveCname(`resend._domainkey.${domain}`);
results.dkim = cname.length > 0;
} catch {
results.dkim = false;
}
return results;
}
**原因:**电子邮件身份验证(SPF、DKIM、DMARC)可向接收邮件服务器证明邮件的合法性。没有这些配置,邮件可能会进入垃圾邮件文件夹。SPF 指定哪些服务器可以代表你的域名发送邮件。DKIM 使用加密方式为邮件签名。DMARC 告知接收方如何处理未经身份验证的邮件。
模式 4:退信和投诉处理
**适用场景:**长期维护发件人信誉和送达率。
实现:
// Webhook handler for bounces and complaints (SES/Resend/SendGrid)
async function handleEmailEvent(event: EmailEvent): Promise<void> {
switch (event.type) {
case "bounce": {
const { email, bounceType } = event;
if (bounceType === "permanent") {
// Hard bounce - never send to this address again
await db.emailSuppression.upsert({
where: { email },
create: { email, reason: "hard_bounce", suppressedAt: new Date() },
update: { reason: "hard_bounce", suppressedAt: new Date() },
});
// Update user record
await db.user.updateMany({
where: { email },
data: { emailVerified: false, emailBounced: true },
});
} else {
// Soft bounce - retry later, suppress after 3 soft bounces
const count = await db.emailEvent.count({
where: { email, type: "soft_bounce", createdAt: { gte: thirtyDaysAgo() } },
});
if (count >= 3) {
await db.emailSuppression.create({
data: { email, reason: "repeated_soft_bounce", suppressedAt: new Date() },
});
}
}
break;
}
case "complaint": {
// Spam complaint - suppress immediately
await db.emailSuppression.upsert({
where: { email: event.email },
create: { email: event.email, reason: "complaint", suppressedAt: new Date() },
update: { reason: "complaint", suppressedAt: new Date() },
});
// Remove from all marketing lists
await db.marketingSubscription.updateMany({
where: { email: event.email },
data: { unsubscribedAt: new Date(), reason: "spam_complaint" },
});
break;
}
}
}
// Check suppression list before sending
async function canSendTo(email: string): Promise<boolean> {
const suppressed = await db.emailSuppression.findUnique({
where: { email },
});
return !suppressed;
}
**原因:**ESP 会监控退信率和投诉率。超过阈值(退信率 > 5%、投诉率 > 0.1%)会触发发送暂停。抑制列表可防止向已知无效地址发送邮件,从而保护发件人信誉,并确保合法邮件送达收件箱。
邮件服务商对比
| 服务商 | 最适合 | 定价 | React Email | 主要特点 |
|---|---|---|---|---|
| Resend | 面向开发者的事务性邮件 | 每天免费 100 封,50k 封每月 20 美元 | 原生支持 | 开发者体验最佳,React Email 团队打造 |
| AWS SES | 大批量发送、成本优化 | 每 1,000 封 0.10 美元 | 通过 render | 规模化后成本最低 |
| SendGrid | 营销邮件 + 事务性邮件 | 每天免费 100 封 | 通过 render | 营销自动化 |
| Postmark | 可靠的事务性邮件 | 10k 封每月 15 美元 | 通过 render | 事务性邮件送达率最佳 |
反模式
| 反模式 | 不妥之处 | 更好的做法 |
|---|---|---|
| 在请求处理程序中发送邮件 | 阻塞用户响应,且无法重试 | 使用后台工作器处理队列 |
| 事务性邮件和营销邮件使用同一域名 | 营销邮件的声誉会影响密码重置邮件 | 使用不同的子域名(mail.app.com 与 news.app.com) |
| 没有抑制名单 | 反复退信会损害发件人声誉 | 维护抑制名单,并在每次发送前检查 |
在邮件 HTML 中使用 <div> 布局 | 在 Outlook、Gmail 中渲染异常 | 使用 <table> 布局或 React Email/MJML |
| 通知邮件中没有退订链接 | 违反 CAN-SPAM 法案,并引发垃圾邮件投诉 | 添加一键退订标头和醒目可见的链接 |
| 使用真实邮箱地址测试 | 给真实用户发送垃圾邮件,招致投诉 | 使用 Mailtrap、Ethereal 或 Resend 测试模式 |
| 使用 no-reply 地址发送邮件 | 用户无法回复,显得缺乏亲和力 | 使用有人监控的 reply-to 地址 |
检查清单
- 为发件域名配置 SPF 记录
- 启用并验证 DKIM 签名
- 设置 DMARC 策略(从
p=none开始,逐步升级到p=quarantine) - 在 Gmail、Outlook、Apple Mail、Yahoo 中测试邮件模板
- 通过带重试逻辑的队列发送邮件
- 配置退信和投诉 Webhook
- 在每次发送前检查抑制名单
- 所有通知/营销邮件中均包含退订链接
- 为事务性邮件和营销邮件使用不同的发件域名
- 开发环境使用测试模式(Mailtrap/Ethereal)
- 配置速率限制,确保不超出 ESP 配额
- 配置 Return-Path / 退信域名
相关资源
- 技能:
authentication-patterns(密码重置邮件)、payment-integration(发票/收据邮件) - 技能:
event-driven-architecture(将邮件作为异步事件处理) - 规则:
docs/reference/stacks/react-typescript.md(React Email 组件模式)