ResendとReact Emailで作るトランザクションメール実装:登録・通知・再設定を確実に届ける
アプリからのメール送信をResendとReact Emailで実装。登録完了・通知・パスワード再設定の送り分け、配信失敗の再試行、SPF/DKIM/DMARCの触りまで、写して動くコードで解説。
「パスワード再設定のメール、届かないんですけど」
サービスを出した翌週、僕に最初に来た問い合わせがこれでした。コードは動いている。ログにも「送信成功」と出ている。なのにユーザーの受信箱には影も形もない。迷惑メールフォルダにすら入っていませんでした。
原因はドメイン認証でした。送信元を noreply@自分のドメイン にしたのに、DNS側の設定をしていなかった。受信側のメールサーバーが「こいつ、なりすましかも」と判断して、静かに捨てていたわけです。エラーも返ってこない。いちばんタチが悪いやつです。
アプリからのメール送信って、fetch を1回叩けば終わり、に見えます。でも実際は「届く」までが本番です。今日は、登録完了・通知・パスワード再設定みたいなトランザクションメールを、ResendとReact Emailで、ちゃんと届くところまで実装します。
この記事の要点
- トランザクションメールは「送信した」ではなく「受信箱に届いた」が成功。コードと同じくらいDNS(SPF/DKIM/DMARC)が効く。
- 送信プロバイダーはResendが最短。React EmailでHTMLとテキストを1つのコンポーネントから作ると、テンプレート管理が崩れない。
- APIキーは必ずサーバー側だけ。ブラウザに出した瞬間、誰でもあなたの名前でメールを撒けます。
429(送りすぎ)と5xx(一時障害)は指数バックオフで再試行。それ以外のエラーは再送しても無駄なので即あきらめる。- 通知の種類が増えてきたら、送信処理をキュー化して全体設計に寄せる。詳しくは通知システムの設計へ。
トランザクションメールって、販促メールと何が違う?
最初に整理します。アプリが送るメールは、大きく2種類です。
トランザクションメールは、ユーザーの行動に1対1で反応する1通です。登録したら「登録ありがとう」、注文したら「注文を受け付けました」、パスワードを忘れたら「再設定リンク」。相手が起こしたアクションへの返事なので、基本的に同意は要らないし、届かないと業務が止まります。
**販促メール(マーケティングメール)**は、こちらの都合で一斉に送るものです。新機能のお知らせ、キャンペーン、ニュースレター。これは明示的な同意と購読解除が必須で、扱いがまるで違います。
この記事が扱うのは前者です。両者を同じ仕組みに混ぜると、必ず事故ります。パスワード再設定メールに「今なら有料プラン20%オフ!」を差し込んだら、ユーザーは混乱するし、迷惑メール報告が増えてドメインの評価まで落ちる。下の表で頭を分けておきましょう。
| 観点 | トランザクションメール | 販促メール |
|---|---|---|
| きっかけ | ユーザーの個別アクション | 送信側のスケジュール |
| 例 | 登録完了、注文確認、再設定リンク | 新機能告知、キャンペーン |
| 同意 | 原則不要(業務上の連絡) | 明示的な同意が必須 |
| 解除リンク | 任意(通知設定で代替も可) | 必須 |
| 届かないと | 業務が止まる・サポートが燃える | 機会損失(致命的ではない) |
| 優先すべき指標 | 到達率・配信速度 | 開封・クリック・解除率 |
なぜResendとReact Emailなのか
送信プロバイダーは選択肢がいくつもあります。Resend、SendGrid、Amazon SES、Postmark。どれも実務で使えますが、個人開発や小〜中規模なら、僕はResendから始めるのをすすめます。理由は単純で、ドメイン認証の画面が分かりやすく、APIが素直だからです。fetch 1本で送れます。SendGridやSESは大規模・高機能ですが、最初の一歩としては設定項目が多い。
テンプレート側がReact Emailです。これは何が嬉しいのか。メールのHTMLって、Webと違って地獄なんです。Outlookは未だにテーブルレイアウトを要求するし、CSSの効き方がクライアントごとにバラバラ。素のHTMLを手書きすると、<table> の入れ子で気が狂います。React Emailは、Reactコンポーネントを書くと、各メールクライアントで崩れにくいHTMLに変換してくれる。しかもHTML版とテキスト版を同じソースから出せます。
僕が最初にやらかしたのは、テンプレートをただの文字列テンプレートリテラルで書いたことでした。`<p>${name}さん</p>` みたいなやつです。最初は楽でした。でもメールが5種類を超えたあたりで、ヘッダーやフッターの修正が全テンプレートに散らばって、変更漏れが出るようになった。React Emailに移したら、共通レイアウトを1コンポーネントにまとめられて、その問題が消えました。
まず届くようにする:ドメイン認証(ここが本丸)
コードの前に、これをやらないと冒頭の僕みたいに「送信成功なのに届かない」をやります。順番はこうです。
- Resendのダッシュボードで自分のドメイン(例:
yourapp.com)を登録する。 - 表示されたDNSレコードを、ドメインを管理しているサービス(お名前.com、Cloudflareなど)に追加する。
- Resend側で認証が「Verified」になるまで待つ(数分〜数十分)。
このとき出てくる3つの呪文を、平易に言い換えておきます。
- SPF: 「このサーバーは、うちのドメインの名前でメールを送っていい正規の差出人だよ」とDNSで宣言する仕組み。送信元のIPを許可リストに載せるイメージです。
- DKIM: 「この本文は途中で改ざんされてないよ」と電子署名で証明する仕組み。受信側が署名を検証します。
- DMARC: 「SPFやDKIMに失敗したメールを、どう扱ってほしいか(捨てる/隔離する/そのまま)」を受信側に伝える方針。
この3つが揃っていないと、Gmailは2024年以降、特に一定量以上を送る送信者のメールを容赦なく弾きます。要件はGoogleが公式にまとめているので、Googleのメール送信者ガイドラインを一度は読んでおいてください。設定手順そのものはプロバイダー側が案内してくれます。Resendならドメイン管理のドキュメントが出発点です。
補足: ローカルで試すだけなら、Resendは
[email protected]という検証済みの差出人を貸してくれます。自分のドメイン認証が終わるまでは、自分のテスト用アドレス宛てにこれで送って動作確認すると安全です。
コピーして動かせる最小実装
ここからコードです。Node.js 20以上を前提に、React Emailでテンプレートを書き、Resendで送る最小構成を作ります。バリデーションにzod、送信再試行も入れます。Claude Codeに実装を任せる場合も、いきなり「メール送信機能を作って」ではなく、下のように境界を渡すと薄い実装になりません。
このリポジトリにトランザクションメール送信を実装してください。
対象は、登録完了・パスワード再設定・汎用通知の3種類です。
制約:
- Node.js 20以上、TypeScriptで書く
- 送信はResendを使い、APIキーはサーバー側envのみで扱う
- テンプレートはReact Emailで書き、HTMLとテキストの両方を生成する
- 送信引数はzodで検証してから送る
- 429と5xxは指数バックオフで最大4回まで再試行、それ以外は即失敗
- 実行できるscriptを1つ付ける
まずファイル一覧を出し、承認後に実装してください。
まず必要なパッケージです。
npm install resend @react-email/components react zod
npm install -D tsx typescript @types/node @types/react
テンプレートをReact Emailで書きます。共通レイアウトを1つ作り、各メールはそれを使い回します。これでヘッダー・フッターの修正が1か所で済みます。
// src/email/templates.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
// 全メール共通のガワ。色やフッターはここだけ直せばよい
function Layout({ preview, children }: { preview: string; children: React.ReactNode }) {
return (
<Html lang="ja">
<Head />
<Preview>{preview}</Preview>
<Body style={{ backgroundColor: "#f3f4f6", fontFamily: "sans-serif" }}>
<Container style={{ backgroundColor: "#ffffff", padding: "32px", borderRadius: "8px" }}>
{children}
<Hr style={{ borderColor: "#e5e7eb", margin: "24px 0" }} />
<Text style={{ fontSize: "12px", color: "#6b7280" }}>
このメールはYourAppから自動送信されています。心当たりがない場合は破棄してください。
</Text>
</Container>
</Body>
</Html>
);
}
// 1. 登録完了メール
export function WelcomeEmail({ name }: { name: string }) {
return (
<Layout preview="YourAppへようこそ">
<Heading style={{ fontSize: "20px" }}>{name}さん、ようこそ</Heading>
<Text>登録ありがとうございます。さっそく使い始めましょう。</Text>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<Button
href="https://yourapp.com/dashboard"
style={{ backgroundColor: "#2563eb", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}
>
ダッシュボードを開く
</Button>
</Section>
</Layout>
);
}
// 2. パスワード再設定メール
export function PasswordResetEmail({ resetUrl }: { resetUrl: string }) {
return (
<Layout preview="パスワード再設定のご案内">
<Heading style={{ fontSize: "20px" }}>パスワードの再設定</Heading>
<Text>下のボタンから30分以内に再設定してください。心当たりがなければ無視して構いません。</Text>
<Section style={{ textAlign: "center", margin: "24px 0" }}>
<Button
href={resetUrl}
style={{ backgroundColor: "#dc2626", color: "#fff", padding: "12px 24px", borderRadius: "6px" }}
>
パスワードを再設定する
</Button>
</Section>
<Text style={{ fontSize: "12px", color: "#6b7280" }}>
ボタンが押せないときはこのURLをブラウザに貼ってください: {resetUrl}
</Text>
</Layout>
);
}
次に、テンプレートをHTMLとテキストに変換して、Resendで送る本体です。render でReactコンポーネントを文字列にし、plainText: true でテキスト版も作ります。
// src/email/send.ts
import { Resend } from "resend";
import { render } from "@react-email/components";
import { z } from "zod";
import type { ReactElement } from "react";
const resend = new Resend(process.env.RESEND_API_KEY);
// 送信前に必ず形を検証する。fromは認証済みドメインのアドレスにすること
const sendInput = z.object({
to: z.string().email(),
subject: z.string().min(1).max(120),
react: z.custom<ReactElement>(),
});
type SendInput = z.infer<typeof sendInput>;
// 再試行すべきエラーか判定する。送りすぎ(429)と一時障害(5xx)だけ再送する
function isRetryable(status: number | undefined): boolean {
return status === 429 || (status !== undefined && status >= 500);
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function sendTransactionalEmail(input: SendInput, maxAttempts = 4) {
const { to, subject, react } = sendInput.parse(input);
// 同じコンポーネントからHTML版とテキスト版の両方を作る
const html = await render(react);
const text = await render(react, { plainText: true });
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const { data, error } = await resend.emails.send({
from: "YourApp <[email protected]>",
to,
subject,
html,
text,
});
if (!error) {
return { id: data?.id, attempts: attempt };
}
// ResendのエラーはstatusCodeを持つ。再試行不能ならその場で投げる
const status = (error as { statusCode?: number }).statusCode;
if (!isRetryable(status) || attempt === maxAttempts) {
throw new Error(`メール送信に失敗しました (status ${status ?? "?"}): ${error.message}`);
}
// 指数バックオフ: 1s, 2s, 4s ... 最大15秒で頭打ち
const delay = Math.min(15_000, 2 ** (attempt - 1) * 1000);
console.warn(`送信失敗、${delay}ms後に再試行します (${attempt}/${maxAttempts})`);
await sleep(delay);
}
throw new Error("到達不能: ループを抜けました");
}
最後に、動かすためのスクリプトです。自分のテスト用アドレスに登録完了メールを1通送ります。
// scripts/send-welcome.ts
import { sendTransactionalEmail } from "../src/email/send";
import { WelcomeEmail } from "../src/email/templates";
import { createElement } from "react";
const to = process.env.EMAIL_TO;
if (!to) throw new Error("EMAIL_TO を環境変数に設定してから実行してください。");
const result = await sendTransactionalEmail({
to,
subject: "YourAppへようこそ",
react: createElement(WelcomeEmail, { name: "Masa" }),
});
console.log(`送信完了: id=${result.id} (試行${result.attempts}回)`);
実行はこうです。最初は必ず自分宛てに送って、受信箱・迷惑メールの両方を確認してください。
RESEND_API_KEY=re_xxx [email protected] npx tsx scripts/send-welcome.ts
ポイントは、テンプレートを1コンポーネントにまとめたこと、HTMLとテキストを同じソースから出したこと、そして送信を try で囲って再試行を1か所に閉じ込めたことです。新しいメールの種類が増えても、templates.tsx にコンポーネントを足すだけで済みます。
配信失敗とリトライ:何を再送し、何を捨てるか
メール送信で初心者がいちばん誤解するのが、「失敗したら全部もう一回送ればいい」です。これは半分正解で、半分は事故のもとです。
失敗には種類があります。再送すべき失敗は、429(短時間に送りすぎてプロバイダーに一時停止された)と、500系(プロバイダー側の一時的な不調)。これらは少し待てば直るので、指数バックオフ(1秒、2秒、4秒…と待ち時間を倍々にする)で再試行します。上のコードの isRetryable がこれを判定しています。
逆に、再送しても無駄な失敗もあります。宛先のアドレスが存在しない、APIキーが間違っている、本文の形式がおかしい(4xx系の多く)。これを再送ループに入れると、いつまでも失敗を繰り返してログを汚すだけです。だから上のコードは、再試行不能なら即座に例外を投げて止まります。
もう一段進むと、バウンスと**苦情(complaint)**の扱いが出てきます。バウンスは「宛先に届かず戻ってきた」状態。一時的なもの(相手のサーバーが混んでいる)と恒久的なもの(アドレスが存在しない)があり、恒久バウンスが出たアドレスには二度と送らないのが鉄則です。送り続けるとドメインの評価が落ち、まともなメールまで迷惑メール判定されるようになります。Resendはこうした配信結果をWebhookで通知してくれるので、恒久バウンスや苦情が来たアドレスは「送信停止リスト(suppression list)」に入れて除外します。
ただ、このあたり——複数チャネルの配信、二重送信の防止(冪等性)、Webhook受信、キューでの非同期処理——まで踏み込むと、メール単体の話を超えて通知システム全体の設計になります。そこは通知システムの設計で、キュー・冪等キー・再送を含めて別途まとめています。送信回数やチャネルが増えてきたら、そちらに寄せてください。
やりがちな落とし穴5つ
正直に、僕や周りが踏んだ地雷を並べます。
1. APIキーをブラウザに出す。 いちばん危険です。Next.jsで「NEXT_PUBLIC_RESEND_API_KEY にしたら動いた!」——それ、世界中に鍵を配っています。送信は必ずサーバー側(API Route、サーバーアクション、バックエンド)で。ブラウザのJavaScriptからResendを直接叩く構成は絶対にやめてください。
2. テキスト版を省いてHTMLだけ送る。 一部のメールクライアントや、画像オフ環境ではHTMLが崩れます。テキスト版がないと「真っ白なメール」が届く。上のコードのように plainText で両方出すのが安全です。
3. 件名が空、または120字超え。 件名なしはほぼ迷惑メール直行です。長すぎる件名は途中で切れて意味不明になります。zodで min(1).max(120) を強制しているのはこのためです。
4. ドメイン認証をせずに本番投入。 冒頭の僕です。SPF/DKIM/DMARCが揃わないと、Gmailは静かに捨てます。エラーすら返らないので「届かない理由が分からない」沼にハマります。コードを書く前にドメイン認証を終わらせてください。
5. 送信記録を残さない。 「誰に・いつ・どのメールを・成功したか」をDBに残していないと、「届いてない」という問い合わせに何も答えられません。最低限、宛先・種類・プロバイダー側のメッセージID・結果はログに残しましょう。
よくある質問
Q. ResendとSendGrid、結局どっちがいいですか? A. 個人〜中小規模で、まず動かしたいならResend。設定が素直で、React Emailとの相性も良いです。すでに大量送信していて細かい制御や実績が要るならSendGridやAmazon SESも検討する、くらいの温度感で十分です。最初の1通を出すハードルはResendが一番低いです。
Q. React Emailは必須ですか?素のHTMLじゃダメ? A. ダメではないです。メールが1〜2種類なら素のHTMLでも回ります。ただ種類が増えると、共通部分の修正漏れが必ず出ます。3種類を超えそうならReact Emailにしておくと、後で楽です。
Q. 無料で試せますか?
A. Resendには無料枠があり、ドメイン認証前でも [email protected] を差出人にして自分宛てに送れます。本番でユーザーに送る前に、まずこれで動作確認するのがおすすめです。最新の無料枠の条件はResend公式で確認してください。
Q. SPF/DKIM/DMARC、全部設定しないとダメ? A. 少量の社内テストなら認証なしでも届くことはあります。でもGmailなど主要プロバイダー宛てに安定して届けたいなら、SPFとDKIMは実質必須、DMARCも設定しておくべきです。プロバイダーが手順を案内してくれるので、登録時に一気に済ませてください。
Q. パスワード再設定メールに購読解除リンクは要りますか? A. トランザクションメールなので、原則は不要です。購読解除は販促メール(ニュースレター等)の話です。両者を混ぜないこと自体が、いちばんの対策になります。
実際に試した結果
冒頭の「再設定メールが届かない」事件のあと、僕がやったことは2つだけです。ひとつは、コードより先にドメイン認証を終わらせること。SPF/DKIMを入れた瞬間、Gmailにも普通に届くようになりました。あれだけ悩んだのに、本質はコードの外にありました。
もうひとつは、テンプレートをReact Emailの共通レイアウトに寄せたこと。最初は文字列で書いていて、メールが増えるたびに修正漏れに怯えていましたが、ガワを1コンポーネントにまとめてからは、フッターの文言を直すのも一瞬です。
トランザクションメールは、派手な機能じゃありません。でも届かないと、ユーザーはサービスに入れず、パスワードも戻せず、静かに離れていきます。だからこそ「送れた」で満足せず、「受信箱に届いた」を確認するところまでをワンセットにしてください。まずは上の最小実装を自分宛てに1通送って、ちゃんと届くことを目で見るところから始めるのがいいと思います。
複数チャネルやキューまで設計を広げたくなったら通知システムの設計へ、メールから先の計測を整えたいならアナリティクス実装へ。実装を一気に進めたい人は教材テンプレートも覗いてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。