Claude Codeでメール自動化: リード獲得から収益化までの実装ガイド
Claude Codeでリード獲得、配信、解除、分析まで安全に作るメール自動化の実装ガイド。
メール自動化は、単に「フォーム送信後に1通送る」機能ではありません。無料PDFを届ける、初回オンボーディングを送る、相談予約の前後でフォローする、購読解除を確実に処理する、バウンスを止める、配信結果を分析する。ここまでつながって初めて、記事の読者を無理なく商品、講座、相談につなげる収益導線になります。
Claude Codeを使う価値は、コード生成そのものよりも「配信ルール、テンプレート、キュー、テスト、運用チェック」を同じ文脈で作れることです。Masaがこのサイトの無料PDF導線を見直したとき、最初の失敗はResendの送信コードだけを先に作ったことでした。フォーム、同意ログ、解除URL、配信失敗時の再送、CTAクリック分析が後付けになり、結局テンプレートを何度も直しました。先に設計表を作ってからClaude Codeに実装させる方が、手戻りは小さくなります。
この記事では、リードマグネット配信、オンボーディング、相談フォローに使えるNode.js/TypeScript実装を、プロバイダー非依存のアダプターで作ります。Resend風APIとSendGrid風APIを切り替えられる形にし、レート制限、再試行、バウンス、購読解除、分析イベントまで含めます。関連する収益導線はコンテンツファネル監査、計測はアナリティクス実装、Cookieと同意はCookie管理も合わせて確認してください。
最初に決める設計
初心者がつまずきやすいのは、メールの種類を分けないことです。リードマグネットとは、メールアドレスと引き換えに渡す無料PDF、チェックリスト、テンプレートのことです。オンボーディングは、登録直後に使い方を案内する流れです。相談フォローは、問い合わせや面談後に次の資料、日程、見積もりを送る流れです。これらを同じ「newsletter」扱いにすると、解除、分析、件名、配信頻度が崩れます。
まず、Claude Codeに次の表を作らせます。
| 目的 | 送る相手 | メール例 | 収益導線 | 注意点 |
|---|---|---|---|---|
| リード獲得 | 無料PDFを請求した読者 | ダウンロードURL、関連ガイド | 無料PDFから商品へ | 明示的な同意と解除URLを保存 |
| オンボーディング | 商品購入者、講座参加者 | 始め方、チェックリスト、質問先 | テンプレート、講座、追加サポート | 購入メールに販促を混ぜすぎない |
| 相談フォロー | 問い合わせ済みの見込み客 | 議事メモ、提案、次回予約 | 研修・相談 | 相手の文脈を無視した一斉送信にしない |
| 休眠リード再接点 | 同意済みだが未購入の読者 | 実例、失敗談、更新通知 | 関連商品または相談 | 送信頻度と解除率を監視 |
専門用語も平易にしておきます。SPFは「このサーバーは自社ドメインから送ってよい」とDNSで示す仕組みです。DKIMは「本文が改ざんされていない」と署名で示す仕組みです。DMARCは「SPFやDKIMに失敗したメールをどう扱うか」を受信側に伝える方針です。バウンスはメールが届かず戻ってくること、レート制限は短時間に送りすぎたときにプロバイダーが一時停止することです。
送信ドメインの認証は、必ず公式ドキュメントを基準にします。Gmail宛ての要件はGoogleのメール送信者ガイドラインを確認してください。Resendを使うならResendのドメイン管理、SendGridを使うならTwilio SendGridのドメイン認証が出発点です。DMARCは2026年5月にRFC 9989が公開され、旧RFC 7489を置き換えています。マーケティングメールの解除については、米国向けならFTCのCAN-SPAMガイドも確認します。法律判断は国や事業形態で変わるので、この記事のコードだけで法務確認を省略しないでください。
flowchart LR
Visitor["記事読者"]
Form["リード獲得フォーム"]
Consent["同意ログ"]
Queue["配信キュー"]
Provider["Resend / SendGrid"]
Inbox["受信箱"]
Webhook["配信イベント"]
Analytics["分析"]
Offer["商品 / 研修 / 相談"]
Visitor --> Form --> Consent --> Queue --> Provider --> Inbox
Provider --> Webhook --> Analytics --> Offer
Inbox --> Offer
Claude Codeに渡すプロンプト
いきなり「メール自動化を作って」と頼むと、送信処理だけの薄い実装になりがちです。次のように境界を固定します。
このリポジトリにメール自動化を実装してください。
目的は、無料PDFのリードマグネット配信、3通のオンボーディング、相談フォローです。
制約:
- Node.js 20以上、TypeScriptで書く
- 送信プロバイダーはResend風APIとSendGrid風APIを切り替えられるadapterにする
- APIキーはサーバー側envだけで扱い、ブラウザに出さない
- lead, email job, unsubscribe, provider eventのschemaを作る
- 429と5xxは指数バックオフで再試行する
- unsubscribeまたはcomplaint済みのメールには送らない
- bounceが複数回続いた宛先はsuppression listに入れる
- 本文にはテキスト版、HTML版、解除URL、送信元情報を含める
- 公式ドキュメントへのリンクをREADMEに残す
- 実行できるscriptと最小テストを追加する
まず設計表とファイル一覧を出し、承認後に実装してください。
ポイントは「安全な送信の境界」を先に言うことです。Claude Codeは便利ですが、許可していないファイルまで触らせると別のエージェントの作業と衝突します。この記事の実装を自分のリポジトリで試す場合も、対象ファイルを限定してから依頼してください。
コピーして動かせる最小構成
以下はデモとして動かせる最小構成です。ローカルではJSONファイルを簡易キューにします。本番ではPostgreSQL、Redis、SQS、Cloud Tasksなど、ロックと監査ログを扱える仕組みに置き換えてください。
{
"type": "module",
"scripts": {
"lead:send": "tsx scripts/send-lead-magnet.ts",
"email:worker": "tsx scripts/email-worker.ts"
},
"dependencies": {
"zod": "latest"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}
// src/email/schema.ts
import { z } from "zod";
export const leadSchema = z.object({
email: z.string().email(),
name: z.string().trim().min(1).max(80),
locale: z.enum(["ja", "en", "zh", "ko", "es", "fr", "de", "pt", "hi", "id"]).default("ja"),
source: z.enum(["article", "product", "workshop", "consultation"]),
consentAt: z.string().datetime(),
tags: z.array(z.string()).default([]),
});
export const sendMessageSchema = z.object({
to: z.string().email(),
from: z.string().email(),
fromName: z.string().min(1),
replyTo: z.string().email().optional(),
subject: z.string().min(1).max(120),
text: z.string().min(1),
html: z.string().min(1),
unsubscribeUrl: z.string().url(),
category: z.enum(["lead_magnet", "onboarding", "consultation_followup"]),
metadata: z.record(z.string()).default({}),
});
export const emailJobSchema = z.object({
message: sendMessageSchema,
maxAttempts: z.number().int().min(1).max(8).default(4),
});
export const providerEventSchema = z.object({
provider: z.enum(["resend", "sendgrid", "unknown"]),
type: z.enum(["delivered", "bounce", "complaint", "unsubscribe", "open", "click", "deferred"]),
email: z.string().email().optional(),
providerMessageId: z.string().optional(),
reason: z.string().optional(),
occurredAt: z.string().datetime(),
});
export type Lead = z.infer<typeof leadSchema>;
export type SendMessage = z.infer<typeof sendMessageSchema>;
export type EmailJobInput = z.infer<typeof emailJobSchema>;
export type ProviderEvent = z.infer<typeof providerEventSchema>;
// src/email/provider.ts
import { randomUUID } from "node:crypto";
import type { SendMessage } from "./schema";
type SendResult = {
providerMessageId: string;
acceptedAt: string;
};
export interface EmailProvider {
send(message: SendMessage): Promise<SendResult>;
}
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing env: ${name}`);
return value;
}
async function parseProviderError(response: Response): Promise<Error> {
const body = await response.text().catch(() => "");
const retryable = response.status === 429 || response.status >= 500;
const error = new Error(`Email provider error ${response.status}: ${body || response.statusText}`);
(error as Error & { retryable?: boolean }).retryable = retryable;
return error;
}
export class ResendProvider implements EmailProvider {
async send(message: SendMessage): Promise<SendResult> {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${requiredEnv("RESEND_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: `${message.fromName} <${message.from}>`,
to: [message.to],
reply_to: message.replyTo,
subject: message.subject,
text: message.text,
html: message.html,
headers: {
"List-Unsubscribe": `<${message.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
}),
});
if (!response.ok) throw await parseProviderError(response);
const data = (await response.json().catch(() => ({}))) as { id?: string };
return { providerMessageId: data.id ?? randomUUID(), acceptedAt: new Date().toISOString() };
}
}
export class SendGridProvider implements EmailProvider {
async send(message: SendMessage): Promise<SendResult> {
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: {
Authorization: `Bearer ${requiredEnv("SENDGRID_API_KEY")}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
personalizations: [{ to: [{ email: message.to }], custom_args: message.metadata }],
from: { email: message.from, name: message.fromName },
reply_to: message.replyTo ? { email: message.replyTo } : undefined,
subject: message.subject,
content: [
{ type: "text/plain", value: message.text },
{ type: "text/html", value: message.html },
],
headers: {
"List-Unsubscribe": `<${message.unsubscribeUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
}),
});
if (!response.ok) throw await parseProviderError(response);
return {
providerMessageId: response.headers.get("x-message-id") ?? randomUUID(),
acceptedAt: new Date().toISOString(),
};
}
}
export function createEmailProvider(): EmailProvider {
return process.env.EMAIL_PROVIDER === "sendgrid" ? new SendGridProvider() : new ResendProvider();
}
export function isRetryable(error: unknown): boolean {
return Boolean((error as { retryable?: boolean })?.retryable);
}
// src/email/templates.ts
import type { Lead, SendMessage } from "./schema";
const escapeHtml = (value: string) =>
value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]!));
export function renderLeadMagnetEmail(input: {
lead: Lead;
downloadUrl: string;
unsubscribeUrl: string;
}): SendMessage {
const name = escapeHtml(input.lead.name);
const downloadUrl = input.downloadUrl;
const unsubscribeUrl = input.unsubscribeUrl;
return {
to: input.lead.email,
from: "hello@example.com",
fromName: "ClaudeCodeLab",
replyTo: "masa@example.com",
subject: "Claude Codeメール自動化チェックリストをお届けします",
category: "lead_magnet",
unsubscribeUrl,
metadata: { source: input.lead.source, locale: input.lead.locale },
text: [
`${input.lead.name}さん`,
"",
"Claude Codeメール自動化チェックリストの請求ありがとうございます。",
`ダウンロード: ${downloadUrl}`,
"",
"次に読むなら、コンテンツファネル監査とアナリティクス実装の記事がおすすめです。",
`配信停止: ${unsubscribeUrl}`,
].join("\n"),
html: `<!doctype html>
<html lang="ja">
<body style="font-family: sans-serif; line-height: 1.7; color: #1f2937;">
<p>${name}さん</p>
<p>Claude Codeメール自動化チェックリストの請求ありがとうございます。</p>
<p><a href="${downloadUrl}">チェックリストをダウンロードする</a></p>
<p>次のステップでは、無料PDF、商品、研修、相談のどこへつなげるかをメールごとに分けて設計してください。</p>
<p style="font-size: 12px; color: #6b7280;"><a href="${unsubscribeUrl}">配信停止</a></p>
</body>
</html>`,
};
}
// src/email/queue.ts
import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { emailJobSchema, type EmailJobInput } from "./schema";
type StoredJob = EmailJobInput & {
id: string;
status: "scheduled" | "processing" | "sent" | "failed";
attempts: number;
nextAttemptAt: string;
lastError?: string;
};
const queueFile = process.env.EMAIL_QUEUE_FILE ?? ".email-queue.json";
async function loadQueue(): Promise<StoredJob[]> {
if (!existsSync(queueFile)) return [];
return JSON.parse(await readFile(queueFile, "utf8")) as StoredJob[];
}
async function saveQueue(jobs: StoredJob[]) {
await writeFile(queueFile, JSON.stringify(jobs, null, 2) + "\n");
}
export async function enqueueEmail(input: EmailJobInput) {
const parsed = emailJobSchema.parse(input);
const jobs = await loadQueue();
const job: StoredJob = {
...parsed,
id: randomUUID(),
status: "scheduled",
attempts: 0,
nextAttemptAt: new Date().toISOString(),
};
jobs.push(job);
await saveQueue(jobs);
return job.id;
}
export async function claimDueJobs(limit = 5): Promise<StoredJob[]> {
const now = Date.now();
const jobs = await loadQueue();
const due = jobs
.filter((job) => job.status === "scheduled" && Date.parse(job.nextAttemptAt) <= now)
.slice(0, limit);
for (const job of due) job.status = "processing";
await saveQueue(jobs);
return due;
}
export async function completeJob(id: string) {
const jobs = await loadQueue();
const job = jobs.find((item) => item.id === id);
if (job) job.status = "sent";
await saveQueue(jobs);
}
export async function failJob(id: string, error: unknown) {
const jobs = await loadQueue();
const job = jobs.find((item) => item.id === id);
if (!job) return;
job.attempts += 1;
job.lastError = error instanceof Error ? error.message : String(error);
if (job.attempts >= job.maxAttempts) {
job.status = "failed";
} else {
const delayMs = Math.min(15 * 60_000, 2 ** job.attempts * 1000);
job.status = "scheduled";
job.nextAttemptAt = new Date(Date.now() + delayMs).toISOString();
}
await saveQueue(jobs);
}
// scripts/send-lead-magnet.ts
import { enqueueEmail } from "../src/email/queue";
import { leadSchema } from "../src/email/schema";
import { renderLeadMagnetEmail } from "../src/email/templates";
const email = process.env.EMAIL_TO;
if (!email) throw new Error("Set EMAIL_TO before running this script.");
const appUrl = process.env.APP_URL ?? "https://example.com";
const lead = leadSchema.parse({
email,
name: process.env.LEAD_NAME ?? "Reader",
locale: "ja",
source: "article",
consentAt: new Date().toISOString(),
tags: ["claude-code", "email-automation"],
});
const message = renderLeadMagnetEmail({
lead,
downloadUrl: `${appUrl}/downloads/claude-code-email-checklist.pdf`,
unsubscribeUrl: `${appUrl}/unsubscribe?email=${encodeURIComponent(lead.email)}`,
});
const id = await enqueueEmail({ message, maxAttempts: 4 });
console.log(`Queued lead magnet email: ${id}`);
// scripts/email-worker.ts
import { claimDueJobs, completeJob, failJob } from "../src/email/queue";
import { createEmailProvider, isRetryable } from "../src/email/provider";
const provider = createEmailProvider();
const jobs = await claimDueJobs(Number(process.env.EMAIL_WORKER_BATCH ?? 3));
for (const job of jobs) {
try {
const result = await provider.send(job.message);
await completeJob(job.id);
console.log(`sent ${job.id} as ${result.providerMessageId}`);
} catch (error) {
await failJob(job.id, error);
const retryable = isRetryable(error) ? "retryable" : "non-retryable";
console.error(`failed ${job.id}: ${retryable}`, error);
}
}
実行例です。最初は自分のテスト用メールだけに送り、送信元ドメインの認証が終わるまで読者に送らないでください。
npm install
EMAIL_TO=you@example.com APP_URL=https://example.com npm run lead:send
EMAIL_PROVIDER=resend RESEND_API_KEY=re_xxx npm run email:worker
バウンス、解除、分析を同じ設計に入れる
送れたかどうかだけを見ると、メール自動化は壊れます。実際には「届いた」「一時拒否された」「恒久的に戻った」「迷惑メール報告された」「解除された」「CTAが押された」を分けます。バウンスが続く宛先に送り続けると、ドメイン評価が落ちます。解除済みの人に再送すると、信頼と法令面の両方で危険です。
Webhookの形式はプロバイダーごとに違うため、最初に自社の共通イベントへ正規化します。次のように、イベント名、メールアドレス、プロバイダー側ID、発生時刻だけをまず保存します。
// src/email/events.ts
import { providerEventSchema, type ProviderEvent } from "./schema";
export function normalizeProviderEvent(payload: unknown): ProviderEvent {
const raw = payload as Record<string, unknown>;
const type = String(raw.type ?? raw.event ?? "delivered");
const email = String(raw.email ?? raw.recipient ?? "");
const mappedType =
type.includes("bounce") ? "bounce" :
type.includes("complaint") || type.includes("spam") ? "complaint" :
type.includes("unsubscribe") ? "unsubscribe" :
type.includes("click") ? "click" :
type.includes("open") ? "open" :
type.includes("defer") ? "deferred" :
"delivered";
return providerEventSchema.parse({
provider: raw.sg_event_id ? "sendgrid" : raw.created_at ? "resend" : "unknown",
type: mappedType,
email: email || undefined,
providerMessageId: String(raw.email_id ?? raw.sg_message_id ?? ""),
reason: typeof raw.reason === "string" ? raw.reason : undefined,
occurredAt: new Date(String(raw.created_at ?? Date.now())).toISOString(),
});
}
export function shouldSuppress(event: ProviderEvent): boolean {
return event.type === "bounce" || event.type === "complaint" || event.type === "unsubscribe";
}
分析では、開封率だけに寄せない方が実用的です。画像ブロックやプライバシー保護の影響で開封はぶれます。無料PDFのダウンロード、商品ページクリック、相談フォーム到達、返信、解除率、バウンス率を並べて見ます。イベント名はアナリティクス実装と同じく、lead_magnet_requested、email_cta_click、consultation_request_startedのように意味が分かるsnake_caseに統一します。
3つ以上の実用ケース
1つ目は、記事下の無料PDFです。読者が「Claude Codeのメール自動化チェックリスト」を請求したら、即時にPDFを送り、翌日に「失敗しやすい送信設計」、3日後に「商品テンプレート」、7日後に「研修・相談」を案内します。ただし、各メールの目的を1つに絞り、購読解除リンクを必ず入れます。
2つ目は、商品購入後のオンボーディングです。Gumroadや自社決済で購入した人には、領収や購入情報と販促を混ぜすぎないことが大切です。最初のメールは利用開始、2通目はよくある詰まり、3通目は高度な使い方にします。購入者はすでに高い関心を持っているため、強い売り込みよりも実装成功を助ける方が次の購入につながります。
3つ目は、相談フォローです。問い合わせ後に一斉テンプレートだけを送ると、相手が話した課題とずれて見えます。面談メモ、決定事項、次の準備、参考記事、見積もり期限を差し込めるテンプレートにして、最後に研修・相談ページへのリンクを置きます。営業メールというより、相手の意思決定を助ける業務連絡に近づけます。
4つ目は、休眠リードの再接点です。半年以上反応がない読者には、毎週送るのではなく、実装失敗談や大きなアップデートだけを低頻度で送ります。クリックや返信が戻らないセグメントは配信頻度を下げ、解除しやすくします。短期売上より、ドメイン評価と信頼を守る方が長く効きます。
具体的な落とし穴
最初の落とし穴は、APIキーをブラウザに出すことです。メール送信キーは必ずサーバー側で扱います。フロントエンドから直接ResendやSendGridに投げる構成は、キー流出と悪用の入口になります。
2つ目は、送信元ドメインを認証しないことです。from: hello@gmail.comのような別ドメインなりすましは避け、独自ドメインでSPF、DKIM、DMARCを整えます。Googleのガイドラインは大量送信者だけでなく、小規模送信者にも参考になります。
3つ目は、解除済みやバウンス済みの宛先に送り続けることです。unsubscribe、complaint、hard bounceはsuppression listに入り、通常配信から除外します。解除URLは「メール末尾に小さく置くだけ」ではなく、処理が本当に動くかテストします。
4つ目は、レート制限を「再実行すればよい」と考えることです。429や一時的な5xxは指数バックオフで待ち、バースト送信を避けます。プロバイダーごとの上限は契約、ドメイン評価、送信履歴で変わるため、記事や古いブログの数字を信用せず、公式画面とレスポンスを見ます。
5つ目は、トランザクションメールと販促メールを混ぜることです。パスワードリセットや領収メールに強い売り込みを入れると、ユーザー体験も配信評価も悪くなります。収益化したいなら、商品紹介はオンボーディングや明示的に同意されたリストで行います。
CTAまで含めて完成
メール自動化の完成条件は、送信成功ではなく「読者が次の行動を自然に選べること」です。ClaudeCodeLabなら、初心者には無料PDF、実装を早めたい人には商品テンプレート、チーム導入や収益導線の設計が必要な人には研修・相談を案内します。すべてのメールに全部のCTAを詰めるのではなく、そのメールの目的に合う1つか2つに絞ります。
この記事で紹介した内容を実際に試した結果、最も効いたのは送信コードではなく、同意ログ、解除、バウンス、CTA分析を最初から同じ設計に入れたことでした。Claude Codeに「メールを送る関数」だけを頼むと短時間で終わりますが、収益化できるメール自動化にするには、配信後のイベントまで設計する必要があります。小さく始めるなら、まず無料PDFの1通だけをこの構成で作り、配信、解除、バウンス、クリックが見える状態にしてからオンボーディングへ広げてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。