mcpField
E
email-systems

email-systems

github.com/travisjneuman开发106128 次安装1 天前更新
代码仓库

关于 email-systems

email-systems 是面向 Web 应用开发者的邮件系统构建指南,涵盖事务性与营销邮件的发送、模板、投递和处理流程。它提供队列重试、DNS 身份验证及退信与投诉管理等实现模式。

主要功能

  1. 01事务性与营销邮件区分
  2. 02React Email 和 MJML 模板实践
  3. 03队列发送与失败重试
  4. 04SPF、DKIM 和 DMARC 配置
  5. 05退信、投诉与抑制列表管理
  6. 06邮件投递测试与排查

适用场景

  1. 01发送注册欢迎和密码重置邮件
  2. 02构建订单确认或通知摘要
  3. 03诊断邮件进入垃圾箱的问题
  4. 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),管理邮件类型(事务性、通知、营销)、队列管理,以及退信和投诉处理。

在发送欢迎邮件、密码重置邮件、订单确认邮件、通知摘要、营销活动邮件或任何系统生成的邮件时,请使用此技能。排查送达问题或迁移电子邮件服务提供商时也请使用此技能。


核心原则

  1. 事务性邮件和营销邮件有所不同——事务性邮件(密码重置、收据)必须即时且可靠地送达。营销邮件(新闻简报、促销)可以批量发送,并且应包含取消订阅链接。切勿在同一发送域名上混发这两类邮件。
  2. HTML 邮件并非 Web 开发——电子邮件客户端渲染的是 2004 年的 HTML。不支持 flexbox、grid 等现代 CSS。使用表格布局、内联样式,并在不同客户端中进行测试。React Email 和 MJML 可以简化这些繁琐工作。
  3. 送达率需要长期维护——SPF、DKIM 和 DMARC 是基本要求,并非送达保证。保持较低的退信率(< 2%)和投诉率(< 0.1%),并逐步预热新的发送域名。
  4. 所有邮件都应加入队列——切勿在请求处理程序中同步发送邮件。将邮件发送加入队列,避免阻塞用户请求,并支持失败重试。
  5. 发送前先测试——在开发环境中使用 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&apos;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 组件模式)

评论

支持 Markdown