mcpField
E
email-template-builder

email-template-builder

github.com/borghei开发1.0k99 次安装1 周前更新
代码仓库

关于 email-template-builder

用于构建事务性邮件模板及发送基础设施的 Skill。工程团队可使用 React Email 或 MJML 创建可复用的本地化模板,并进行预览,检查送达能力和客户端兼容性。

主要功能

  1. 01构建可复用的 React Email 或 MJML 模板
  2. 02抽象多提供商发送逻辑
  3. 03通过热重载预览模板
  4. 04支持本地化与深色模式
  5. 05检查垃圾邮件风险和客户端兼容性

适用场景

  1. 01构建密码重置和发票邮件
  2. 02开发时预览模板改动
  3. 03本地化事务性邮件模板
  4. 04生产发送前检查渲染效果

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/borghei/claude-skills --skill email-template-builder

安装后在 Claude Code 中输入/email-template-builder确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

scripts/
render_size_analyzer.py
spam_score_checker.py
template_validator.py

详细介绍

邮件模板构建器

级别: 强大 类别: 工程 / 营销 标签: 邮件模板、React Email、MJML、响应式邮件、送达率、事务性邮件、深色模式

概述

构建完整的事务性邮件系统:使用 React Email 或 MJML 编写基于组件的模板,抽象多服务商发送,支持本地预览和热重载、i18n、深色模式、垃圾邮件优化及 UTM 跟踪。为任何主流邮件服务商生成可用于生产环境的代码。

此技能用于构建邮件渲染和发送基础设施。若要撰写邮件文案和设计邮件序列,请使用 email-sequence。


先确认

构建模板之前,请确认以下信息。如有任何内容未知或含糊不清,请询问,不要自行假设:

  • 框架 — React Email 或 MJML,以及团队是否使用 React/TypeScript(这会决定整个代码库;请参见下方的决策表)
  • 所需模板 — 欢迎邮件、密码重置、发票、试用期即将结束、摘要、团队邀请等(决定要搭建哪些模板文件)
  • 发送服务商 — Resend、SendGrid、Postmark 或 SES(决定要构建哪个服务商适配器)
  • 语言区域和深色模式 — 所需语言及深色模式支持(决定 i18n 系统和基础布局样式)

停止规则:只询问对输出影响最大的 2-3 项。如果用户说“直接起草”,则继续,并在交付内容顶部列出你的假设。

架构决策:React Email 与 MJML

因素React EmailMJML
组件复用完整的 React 组件模型部分支持(mj-attributes)
TypeScript原生支持需要构建步骤
预览服务内置(email dev)需要单独设置
邮件客户端兼容性良好(渲染为表格)出色(经过充分验证)
深色模式CSS 媒体查询CSS 媒体查询
学习曲线低(如果你熟悉 React)低(类似 HTML 的语法)
最适合已在使用 React 的团队追求邮件客户端最大兼容性的团队

建议: 对于使用 TypeScript 发布 SaaS 产品的团队,选择 React Email。对于需要在 Outlook、Gmail、Apple Mail 和旧版客户端中实现最大兼容性的营销团队,选择 MJML。


项目结构

emails/
├── components/
│   ├── layout/
│   │   ├── base-layout.tsx          # Shared wrapper: header, footer, styles
│   │   ├── button.tsx               # CTA button component
│   │   └── divider.tsx              # Styled horizontal rule
│   ├── blocks/
│   │   ├── hero.tsx                 # Hero section with heading + text
│   │   ├── feature-row.tsx          # Icon + text feature highlight
│   │   ├── testimonial.tsx          # Quote + attribution
│   │   └── pricing-table.tsx        # Plan comparison
├── templates/
│   ├── welcome.tsx                  # Welcome / confirm email
│   ├── password-reset.tsx           # Password reset link
│   ├── invoice.tsx                  # Payment receipt / invoice
│   ├── trial-expiring.tsx           # Trial expiration warning
│   ├── weekly-digest.tsx            # Activity summary
│   └── team-invite.tsx              # Team invitation
├── lib/
│   ├── send.ts                      # Unified send function
│   ├── providers/
│   │   ├── resend.ts                # Resend adapter
│   │   ├── sendgrid.ts              # SendGrid adapter
│   │   ├── postmark.ts              # Postmark adapter
│   │   └── ses.ts                   # AWS SES adapter
│   ├── tracking.ts                  # UTM parameter injection
│   └── render.ts                    # Template rendering
├── i18n/
│   ├── en.ts                        # English strings
│   ├── de.ts                        # German strings
│   └── types.ts                     # Typed translation keys
└── package.json

基础布局组件

// emails/components/layout/base-layout.tsx
import {
  Body, Container, Head, Html, Img, Preview,
  Section, Text, Hr, Font
} from "@react-email/components";

interface BaseLayoutProps {
  preview: string;
  locale?: string;
  children: React.ReactNode;
}

export function BaseLayout({ preview, locale = "en", children }: BaseLayoutProps) {
  return (
    <Html lang={locale}>
      <Head>
        <Font
          fontFamily="Inter"
          fallbackFontFamily="Arial"
          webFont={{
            url: "https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hiJ-Ek-_EeA.woff2",
            format: "woff2",
          }}
          fontWeight={400}
          fontStyle="normal"
        />
        <style>{`
          @media (prefers-color-scheme: dark) {
            .email-body { background-color: #111827 !important; }
            .email-container { background-color: #1f2937 !important; }
            .email-text { color: #e5e7eb !important; }
            .email-heading { color: #f9fafb !important; }
            .email-muted { color: #9ca3af !important; }
          }
          @media only screen and (max-width: 600px) {
            .email-container { width: 100% !important; padding: 16px !important; }
          }
        `}</style>
      </Head>
      <Preview>{preview}</Preview>
      <Body className="email-body" style={body}>
        <Container className="email-container" style={container}>
          <Section style={header}>
            <Img
              src={`${process.env.ASSET_URL}/logo.png`}
              width={120} height={36} alt="[Product]"
            />
          </Section>
          <Section style={content}>{children}</Section>
          <Hr className="email-muted" style={divider} />
          <Section style={footer}>
            <Text className="email-muted" style={footerText}>
              [Company] Inc. - [Address]
            </Text>
            <Text className="email-muted" style={footerText}>
              <a href="{{unsubscribe_url}}" style={link}>Unsubscribe</a>
              {" | "}
              <a href="{{preferences_url}}" style={link}>Email Preferences</a>
              {" | "}
              <a href="{{privacy_url}}" style={link}>Privacy Policy</a>
            </Text>
          </Section>
        </Container>
      </Body>
    </Html>
  );
}

// Styles (inline for email client compatibility)
const body = { backgroundColor: "#f3f4f6", fontFamily: "Inter, Arial, sans-serif", margin: 0, padding: "40px 0" };
const container = { maxWidth: "600px", margin: "0 auto", backgroundColor: "#ffffff", borderRadius: "8px", overflow: "hidden" };
const header = { padding: "24px 32px", borderBottom: "1px solid #e5e7eb" };
const content = { padding: "32px" };
const divider = { borderColor: "#e5e7eb", margin: "0 32px" };
const footer = { padding: "24px 32px" };
const footerText = { fontSize: "12px", color: "#6b7280", textAlign: "center" as const, margin: "4px 0", lineHeight: "1.6" };
const link = { color: "#6b7280", textDecoration: "underline" };

模板示例

欢迎邮件
// emails/templates/welcome.tsx
import { Button, Heading, Text } from "@react-email/components";
import { BaseLayout } from "../components/layout/base-layout";

interface WelcomeProps {
  name: string;
  confirmUrl: string;
  trialDays?: number;
}

export default function Welcome({ name, confirmUrl, trialDays = 14 }: WelcomeProps) {
  return (
    <BaseLayout preview={`Welcome, ${name}! Confirm your email to get started.`}>
      <Heading className="email-heading" style={h1}>
        Welcome to [Product], {name}
      </Heading>
      <Text className="email-text" style={text}>
        You have {trialDays} days to explore everything -- no credit card required.
        Confirm your email to activate your account:
      </Text>
      <Button href={confirmUrl} style={button}>
        Confirm Email Address
      </Button>
      <Text className="email-muted" style={muted}>
        Button not working? Paste this link in your browser:{" "}
        <a href={confirmUrl} style={linkStyle}>{confirmUrl}</a>
      </Text>
    </BaseLayout>
  );
}

const h1 = { fontSize: "24px", fontWeight: "700", color: "#111827", margin: "0 0 16px", lineHeight: "1.3" };
const text = { fontSize: "16px", lineHeight: "1.6", color: "#374151", margin: "0 0 24px" };
const button = { backgroundColor: "#4f46e5", color: "#ffffff", borderRadius: "6px", fontSize: "16px", fontWeight: "600", padding: "12px 24px", textDecoration: "none", display: "inline-block" };
const muted = { fontSize: "13px", color: "#6b7280", marginTop: "24px", lineHeight: "1.5" };
const linkStyle = { color: "#4f46e5", wordBreak: "break-all" as const };
发票邮件
// emails/templates/invoice.tsx
import { Row, Column, Section, Heading, Text, Hr, Button } from "@react-email/components";
import { BaseLayout } from "../components/layout/base-layout";

interface LineItem { description: string; amount: number; }

interface InvoiceProps {
  name: string;
  invoiceNumber: string;
  date: string;
  dueDate: string;
  items: LineItem[];
  total: number;
  currency?: string;
  downloadUrl: string;
}

export default function Invoice({
  name, invoiceNumber, date, dueDate, items,
  total, currency = "USD", downloadUrl,
}: InvoiceProps) {
  const fmt = new Intl.NumberFormat("en-US", { style: "currency", currency });

  return (
    <BaseLayout preview={`Invoice ${invoiceNumber} -- ${fmt.format(total / 100)}`}>
      <Heading className="email-heading" style={h1}>
        Invoice #{invoiceNumber}
      </Heading>
      <Text className="email-text" style={text}>Hi {name},</Text>
      <Text className="email-text" style={text}>
        Here is your invoice. Thank you for your business.
      </Text>

      {/* Meta row */}
      <Section style={metaBox}>
        <Row>
          <Column>
            <Text style={metaLabel}>Invoice Date</Text>
            <Text style={metaValue}>{date}</Text>
          </Column>
          <Column>
            <Text style={metaLabel}>Due Date</Text>
            <Text style={metaValue}>{dueDate}</Text>
          </Column>
          <Column>
            <Text style={metaLabel}>Amount Due</Text>
            <Text style={metaValueBold}>{fmt.format(total / 100)}</Text>
          </Column>
        </Row>
      </Section>

      {/* Line items */}
      {items.map((item, i) => (
        <Row key={i} style={i % 2 === 0 ? rowEven : rowOdd}>
          <Column><Text style={cell}>{item.description}</Text></Column>
          <Column><Text style={cellRight}>{fmt.format(item.amount / 100)}</Text></Column>
        </Row>
      ))}
      <Hr style={divider} />
      <Row>
        <Column><Text style={totalLabel}>Total</Text></Column>
        <Column><Text style={totalValue}>{fmt.format(total / 100)}</Text></Column>
      </Row>

      <Button href={downloadUrl} style={button}>
        Download PDF
      </Button>
    </BaseLayout>
  );
}

const h1 = { fontSize: "24px", fontWeight: "700", color: "#111827", margin: "0 0 16px" };
const text = { fontSize: "15px", lineHeight: "1.6", color: "#374151", margin: "0 0 12px" };
const metaBox = { backgroundColor: "#f9fafb", borderRadius: "8px", padding: "16px", margin: "16px 0" };
const metaLabel = { fontSize: "11px", color: "#6b7280", fontWeight: "600", textTransform: "uppercase" as const, margin: "0 0 4px", letterSpacing: "0.05em" };
const metaValue = { fontSize: "14px", color: "#111827", margin: "0" };
const metaValueBold = { fontSize: "18px", fontWeight: "700", color: "#4f46e5", margin: "0" };
const rowEven = { backgroundColor: "#ffffff" };
const rowOdd = { backgroundColor: "#f9fafb" };
const cell = { fontSize: "14px", color: "#374151", padding: "10px 12px" };
const cellRight = { ...cell, textAlign: "right" as const };
const divider = { borderColor: "#e5e7eb", margin: "8px 0" };
const totalLabel = { fontSize: "16px", fontWeight: "700", color: "#111827", padding: "8px 12px" };
const totalValue = { ...totalLabel, textAlign: "right" as const };
const button = { backgroundColor: "#4f46e5", color: "#ffffff", borderRadius: "6px", padding: "12px 24px", fontSize: "15px", fontWeight: "600", textDecoration: "none", display: "inline-block", marginTop: "16px" };

多服务商发送抽象

// emails/lib/send.ts
import { render } from "@react-email/render";

interface EmailPayload {
  to: string;
  subject: string;
  template: React.ReactElement;
  tags?: Record<string, string>;
}

interface EmailProvider {
  send(payload: { to: string; subject: string; html: string; text: string; tags?: Record<string, string> }): Promise<{ id: string }>;
}

// Provider factory
function getProvider(): EmailProvider {
  const provider = process.env.EMAIL_PROVIDER || "resend";
  switch (provider) {
    case "resend": return require("./providers/resend").default;
    case "sendgrid": return require("./providers/sendgrid").default;
    case "postmark": return require("./providers/postmark").default;
    case "ses": return require("./providers/ses").default;
    default: throw new Error(`Unknown email provider: ${provider}`);
  }
}

export async function sendEmail(payload: EmailPayload) {
  const html = addTracking(render(payload.template), { campaign: payload.tags?.type || "transactional" });
  const text = render(payload.template, { plainText: true });

  return getProvider().send({
    to: payload.to,
    subject: payload.subject,
    html,
    text,
    tags: payload.tags,
  });
}

UTM 跟踪注入

// emails/lib/tracking.ts
interface TrackingConfig {
  campaign: string;
  source?: string;
  medium?: string;
}

export function addTracking(html: string, config: TrackingConfig): string {
  const params = new URLSearchParams({
    utm_source: config.source || "email",
    utm_medium: config.medium || "transactional",
    utm_campaign: config.campaign,
  }).toString();

  // Add UTM to all internal links (skip unsubscribe and external)
  return html.replace(
    /href="(https?:\/\/(?:www\.)?yourdomain\.com[^"]*?)"/g,
    (match, url) => {
      const sep = url.includes("?") ? "&" : "?";
      return `href="${url}${sep}${params}"`;
    }
  );
}

i18n 系统

// emails/i18n/types.ts
export interface EmailStrings {
  welcome: {
    preview: (name: string) => string;
    heading: (name: string) => string;
    body: (days: number) => string;
    cta: string;
    fallbackLink: string;
  };
  invoice: {
    preview: (number: string, amount: string) => string;
    heading: (number: string) => string;
    greeting: (name: string) => string;
    downloadCta: string;
  };
  common: {
    unsubscribe: string;
    preferences: string;
    privacy: string;
  };
}

// emails/i18n/en.ts
import type { EmailStrings } from "./types";
export const en: EmailStrings = {
  welcome: {
    preview: (name) => `Welcome, ${name}! Confirm your email to get started.`,
    heading: (name) => `Welcome to [Product], ${name}`,
    body: (days) => `You have ${days} days to explore everything -- no credit card required.`,
    cta: "Confirm Email Address",
    fallbackLink: "Button not working? Paste this link in your browser:",
  },
  // ... other templates
};

// emails/i18n/de.ts
import type { EmailStrings } from "./types";
export const de: EmailStrings = {
  welcome: {
    preview: (name) => `Willkommen, ${name}! Bestaetigen Sie Ihre E-Mail.`,
    heading: (name) => `Willkommen bei [Product], ${name}`,
    body: (days) => `Sie haben ${days} Tage Zeit, alles zu erkunden -- keine Kreditkarte noetig.`,
    cta: "E-Mail-Adresse bestaetigen",
    fallbackLink: "Button funktioniert nicht? Fuegen Sie diesen Link in Ihren Browser ein:",
  },
  // ... other templates
};

送达率检查清单

DNS 记录(必需)
  • SPF:发送域名上的 v=spf1 include:_spf.provider.com ~all
  • DKIM:已配置服务商专属的 CNAME 记录
  • DMARC:v=DMARC1; p=quarantine; rua=mailto:[email protected]
  • Return-Path:与发送域名匹配(不是服务商默认值)
内容规则
  • 发件人使用自己的域名(而非 @gmail.com)
  • 主题少于 50 个字符,不使用全大写,也不包含垃圾邮件触发词
  • 文本与图片比例:文本至少占 60%
  • 除 HTML 版本外,还包含纯文本版本
  • 每封邮件都包含退订链接(CAN-SPAM、GDPR、一键退订)
  • 页脚包含实体邮寄地址(CAN-SPAM 要求)
  • 不使用 URL 缩短服务(使用完整的品牌链接)
  • 每封邮件只有一个主要 CTA
  • 所有图片均包含替代文本
  • HTML 验证通过(无损坏或未闭合的标签)
基础设施
  • 为事务性邮件和营销邮件使用不同的发送域名
  • 逐步预热新的发送域名(从每天 50 封开始,每周翻倍)
  • 监控退信率(硬退信低于 2%)
  • 自动处理退信和投诉
  • 生产环境发送前使用 Mail-Tester.com 测试(目标:9+/10)

邮件客户端兼容性

已知问题
客户端问题解决方法
Outlook(Windows)不支持 CSS grid/flexbox,忽略图片的 margin使用 <table> 布局(React Email 会处理)
Gmail会移除 <head> 样式,限制 CSS将所有样式内联(React Email 会处理)
Apple Mail支持最佳,深色模式渲染良好使用标准方法即可
Yahoo MailCSS 支持有限避免使用高级选择器
Outlook.com会移除背景图片使用 background-color 作为备用方案
测试矩阵

正式发布前,请在以下客户端上测试每个模板:

优先级客户端方法
严重Gmail(网页)发送测试邮件
严重Apple Mail(iOS)发送测试邮件
严重Outlook(Windows,最新版本)使用 Litmus 或 Email on Acid
高Outlook.com(网页)发送测试邮件
高Gmail(Android)发送测试邮件
中Yahoo Mail使用 Litmus
中Outlook(Mac)发送测试邮件

开发工作流

# Start preview server with hot reload
npx email dev --dir emails/templates --port 3001

# Export to static HTML (for testing with Litmus/Email on Acid)
npx email export --dir emails/templates --outDir emails/dist

# Send test email
npx tsx emails/lib/send-test.ts --template welcome --to [email protected]

# Validate HTML
npx email lint --dir emails/templates

常见问题

问题后果预防方法
使用 CSS grid/flexbox布局在 Outlook 中错乱使用 React Email 的 Row/Column(会渲染为表格)
容器宽度超过 600px在 Gmail 移动版中显示异常将容器的最大宽度设为 600px
缺少纯文本版本投递能力评分较低始终使用 render(template, { plainText: true }) 生成纯文本
事务性邮件和营销邮件使用同一域名营销邮件投诉会损害事务性邮件的投递效果使用不同的发送域名/子域名
跳过邮件预热邮件会进入垃圾邮件箱从低量开始,在 2-4 周内逐步增加
忽略深色模式30% 以上的用户会收到难以阅读的邮件添加带有 !important 的 prefers-color-scheme: dark 媒体查询

相关技能

技能适用场景
email-sequence撰写邮件文案和设计自动化流程
analytics-tracking设置邮件互动跟踪和归因
launch-strategy协调产品发布所需的邮件模板

故障排除

症状可能原因解决方法
邮件在 Gmail 中被截断HTML 超过 102KB运行 render_size_analyzer.py。删除注释、压缩代码、替换 base64 图片。
布局在 Outlook 中错乱使用了 CSS flexbox/grid使用基于表格的布局。运行 template_validator.py 检查兼容性。
Gmail 移除了样式样式仅位于 <head> 中将所有 CSS 内联。React Email 会自动处理。
深色模式下难以阅读缺少深色模式 CSS添加带有 !important 的 prefers-color-scheme: dark 媒体查询。
投递能力评分较低缺少退订方式、图片过多运行 spam_score_checker.py。添加符合 RFC 8058 的一键退订标头。
图片无法加载被邮件客户端的默认设置拦截添加描述性替代文本。确保文本与图片比例达到 60% 以上。
模板在不同客户端中的渲染效果不一致使用了不受支持的 CSS 属性正式发送前,在 Gmail、Apple Mail 和 Outlook(Windows)上测试。

成功标准

  • 正式发送前,在 mail-tester.com 上的垃圾邮件评分达到 9+/10
  • 模板在 Gmail、Apple Mail 和 Outlook(Windows)上均能正确渲染
  • HTML 小于 80KB(远低于 Gmail 的 102KB 截断阈值)
  • 文本与图片比例超过 60%
  • 已测试深色模式,且对 30% 以上的用户而言清晰易读
  • 所有图片均有替代文本以及明确的宽度/高度尺寸
  • 所有模板均已实现一键退订(RFC 8058)
  • 事务性邮件与营销邮件使用不同的发送域名

范围与限制

范围内: 邮件 HTML/CSS 模板工程、React Email 和 MJML 组件、多提供商发送抽象、i18n、深色模式、投递基础设施、垃圾邮件评分优化。

范围外: 邮件文案/序列撰写(使用 email-sequence)、营销自动化工作流、邮件列表管理、A/B 测试统计分析。


Python 自动化工具

1. 垃圾邮件评分检查器 (scripts/spam_score_checker.py)

分析电子邮件 HTML 的垃圾邮件风险:文字与图片比例、链接密度、垃圾邮件词汇、退订信息是否存在,以及 HTML 结构。

python scripts/spam_score_checker.py template.html
python scripts/spam_score_checker.py template.html --json
2. 模板验证器 (scripts/template_validator.py)

验证电子邮件模板的客户端兼容性(Outlook、Gmail)、无障碍性、响应式设计和内联样式。

python scripts/template_validator.py template.html
python scripts/template_validator.py template.html --json
3. 渲染大小分析器 (scripts/render_size_analyzer.py)

分析模板文件大小、估算渲染体积,并检查是否超过 Gmail 的 102KB 剪裁阈值,同时提供详细的细分数据。

python scripts/render_size_analyzer.py template.html
python scripts/render_size_analyzer.py --dir templates/ --json

评论

支持 Markdown