email-template-builder について
トランザクションメールのテンプレートと送信基盤を構築するスキルです。エンジニアチームは React Email または MJML で再利用可能な多言語テンプレートを作成し、プレビューや配信性、クライアント互換性の確認を行えます。
主な機能
- 01再利用可能なReact EmailまたはMJMLテンプレートの作成
- 02複数プロバイダー向け送信処理の抽象化
- 03ホットリロードによるテンプレートのプレビュー
- 04多言語対応とダークモード
- 05スパムリスクとクライアント互換性の確認
活用シーン
- 01パスワードリセットや請求書メールの作成
- 02開発中のテンプレート変更のプレビュー
- 03トランザクションメールテンプレートの多言語化
- 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.pyspam_score_checker.pytemplate_validator.py詳細
メールテンプレートビルダー
ティア: パワフル カテゴリー: エンジニアリング/マーケティング タグ: メールテンプレート、React Email、MJML、レスポンシブメール、到達率、トランザクションメール、ダークモード
概要
トランザクションメールシステムを一式構築します。React Email または MJML を使ったコンポーネントベースのテンプレート、複数プロバイダーに対応した送信の抽象化、ホットリロード付きのローカルプレビュー、i18n 対応、ダークモード、迷惑メール対策、UTM トラッキングを実現します。主要なメールプロバイダーで使える、本番環境対応のコードを出力します。
このスキルでは、メールのレンダリングと送信のインフラを構築します。メール本文の作成や配信シーケンスの設計には、email-sequence を使用してください。
最初に確認すること
テンプレートを構築する前に、以下の情報を確認してください。不明または曖昧な項目がある場合は、推測せずに質問してください。
- フレームワーク — React Email と MJML のどちらを使うか、チームが React/TypeScript を使用しているか(コードベース全体に影響します。以下の比較表を参照してください)
- 必要なテンプレート — welcome、password reset、invoice、trial-expiring、digest、team-invite など(ひな形を作成するテンプレートファイルが決まります)
- 送信プロバイダー — Resend、SendGrid、Postmark、SES(構築するプロバイダーアダプターが決まります)
- ロケールとダークモード — 必要な言語とダークモードへの対応(i18n システムとベースレイアウトのスタイルが決まります)
停止ルール:出力内容に最も影響する2〜3項目だけを質問してください。ユーザーが「とりあえず下書きを」と言った場合は、そのまま進め、成果物の冒頭に前提条件を記載してください。
アーキテクチャの選択:React Email と MJML
| 要素 | React Email | MJML |
|---|---|---|
| コンポーネントの再利用 | React のコンポーネントモデルをフル活用 | 一部対応(mj-attributes) |
| TypeScript | ネイティブ対応 | ビルドステップが必要 |
| プレビューサーバー | 標準搭載(email dev) | 別途セットアップが必要 |
| メールクライアントとの互換性 | 良好(テーブルにレンダリング) | 優秀(実績のある方式) |
| ダークモード | CSS メディアクエリ | CSS メディアクエリ |
| 学習コスト | 低い(React の知識があれば) | 低い(HTML に似た構文) |
| 最適な用途 | すでに React を使用しているチーム | メールクライアントとの最大限の互換性 |
推奨: SaaS を提供する TypeScript チームには 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 短縮サービスを使わない(ブランド名入りの完全なリンクを使用する)
- 1通につき主要な CTA は1つにする
- すべての画像に alt テキストを設定する
- HTML が検証済みであること(タグの破損や閉じ忘れがないこと)
インフラ
- トランザクションメールとマーケティングメールで送信ドメインを分ける
- 新しい送信ドメインは徐々にウォームアップする(1日50通から始め、毎週2倍に増やす)
- バウンス率を監視する(ハードバウンスは2%未満)
- バウンスと苦情を自動的に処理する
- 本番送信前に Mail-Tester.com でテストする(目標:9/10以上)
メールクライアントとの互換性
既知の癖
| クライアント | 癖 | 回避策 |
|---|---|---|
| Outlook (Windows) | CSS grid/flexbox に対応しておらず、画像の margin を無視します | <table> レイアウトを使用します(React Email が処理します) |
| Gmail | <head> のスタイルを削除し、CSS を制限します | すべてのスタイルをインライン化します(React Email が処理します) |
| Apple Mail | 最も対応状況が良く、ダークモードも適切に表示します | 標準的な方法で問題ありません |
| Yahoo Mail | CSS の対応が限定的です | 高度なセレクターを避けます |
| Outlook.com | 背景画像を削除します | フォールバックとして background-color を使用します |
テストマトリックス
本番環境に移行する前に、すべてのテンプレートを以下のクライアントでテストしてください。
| 優先度 | クライアント | 方法 |
|---|---|---|
| Critical | Gmail (web) | テストメールを送信 |
| Critical | Apple Mail (iOS) | テストメールを送信 |
| Critical | Outlook (Windows, latest) | Litmus または Email on Acid |
| High | Outlook.com (web) | テストメールを送信 |
| High | Gmail (Android) | テストメールを送信 |
| Medium | Yahoo Mail | Litmus |
| Medium | 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 モバイルで崩れます | コンテナの max-width を 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 のワンクリック配信停止ヘッダーを追加します。 |
| 画像が読み込まれない | メールクライアントのデフォルト設定でブロックされている | 説明的な alt テキストを追加します。テキストと画像の比率を 60% 以上に保ちます。 |
| クライアントによってテンプレートの表示が異なる | 対応していない CSS プロパティが使われている | 本番環境で送信する前に、Gmail、Apple Mail、Outlook (Windows) でテストします。 |
成功基準
- 本番環境で送信する前に mail-tester.com でスパムスコア 9+/10 を達成している
- テンプレートが Gmail、Apple Mail、Outlook (Windows) で正しく表示される
- HTML が 80KB 未満(Gmail の 102KB の切り捨て基準を十分に下回る)
- テキストと画像の比率が 60% を超えている
- ダークモードをテスト済みで、30% 以上のユーザーが読める
- すべての画像に alt テキストと明示的な width/height の指定がある
- すべてのテンプレートでワンクリック配信停止(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