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を入力して読み込まれたか確認します。
詳細
メールシステム
概要
このスキルでは、トランザクションメールの送信、テンプレートの設計、到達率の最適化、受信トレイへの配信など、ウェブアプリケーション向けの信頼性の高いメールシステムの構築を扱います。メールサービスプロバイダー(Resend、AWS SES、SendGrid、Postmark)との統合、React Email と MJML を使ったレスポンシブな HTML メールテンプレートの構築、到達率向上のための DNS レコード(SPF、DKIM、DMARC)の設定、メールの種類(トランザクション、通知、マーケティング)の管理、キュー管理、バウンスや苦情への対応について説明します。
ウェルカムメール、パスワードリセット、注文確認、通知ダイジェスト、マーケティングキャンペーン、またはシステムが生成するメールを送信する際に、このスキルを使用してください。また、メールの到達率に関する問題のデバッグや、メールプロバイダー間の移行にも使用してください。
基本原則
- トランザクションメールとマーケティングメールは別物です - トランザクションメール(パスワードリセット、領収書)は、即座に確実に届く必要があります。マーケティングメール(ニュースレター、プロモーション)はまとめて送信でき、配信停止リンクを設ける必要があります。同じ送信ドメインで両者を混在させないでください。
- HTML メールはウェブ開発とは異なります - メールクライアントは 2004 年当時の HTML をレンダリングします。フレックスボックス、グリッド、最新の 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 | 開発者に使いやすいトランザクションメール | 1日100通まで無料、50,000通で月額$20 | ネイティブ対応 | 優れた開発者体験、React Email チーム |
| AWS SES | 大量配信、コスト最適化 | 1,000通あたり$0.10 | レンダリング経由 | 大規模利用時に最安 |
| SendGrid | マーケティングとトランザクションメール | 1日100通まで無料 | レンダリング経由 | マーケティング自動化 |
| Postmark | 信頼性の高いトランザクションメール | 10,000通で月額$15 | レンダリング経由 | トランザクションメールの到達率が最良 |
アンチパターン
| アンチパターン | 問題点 | より良い方法 |
|---|---|---|
| リクエストハンドラー内でメールを送信する | ユーザーへの応答が遅れ、再試行もできません | バックグラウンドワーカーを使ったキュー |
| トランザクションメールとマーケティングメールで同じドメインを使う | マーケティングメールの評価がパスワードリセットメールに影響します | サブドメインを分けます(mail.app.com と news.app.com) |
| 抑制リストがない | バウンスが繰り返されると送信者の評価が損なわれます | 送信前に必ず抑制リストを確認し、維持します |
メール HTML で <div> レイアウトを使う | Outlook や Gmail で表示が崩れます | <table> レイアウト、または React Email/MJML を使います |
| 通知メールに配信停止リンクがない | CAN-SPAM 違反や迷惑メールの苦情につながります | ワンクリック配信停止ヘッダーと、見える場所にリンクを設置します |
| 実在するメールアドレスでテストする | 実在する人への迷惑メール送信や苦情につながります | Mailtrap、Ethereal、または Resend のテストモードを使います |
| no-reply アドレスから送信する | ユーザーが返信できず、よそよそしい印象になります | 監視されている返信先アドレスを使います |
チェックリスト
- 送信ドメインに 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 のコンポーネントパターン)