「登録者全員にお知らせを送りたい」——この一文を受け取ったエンジニアが最初にやりがちなのが、emails.send() を for ループで回すことです。動きはします。しかし数百通を超えるとレート制限に当たり、購読解除の導線が無いまま送り続けて苦情率が上がり、最悪の場合はドメインのレピュテーションを壊します。
Resend で「1通ずつ」以外の送り方は4つの別物です。バッチ、予約、Broadcasts、Automations。宛先の決まり方も、購読解除の実装義務も、冪等キーが使えるかも違います。この記事はその4つを公式ドキュメントに忠実に整理し、購読管理(Segments / Topics / 購読解除)と日本の特定電子メール法までつなぎます。コードは、このサイト自身が本番で動かしている実装——DB もクロンも持たない7通のメール講座——を教材にします。全体像はResend 本番運用ガイドにあります。
「大量に送る」は4つの別物
この4つを取り違えると、実装の手戻りではなくレピュテーションの毀損という取り返しのつかない損害になります。
| 送り方 | 何に使うか | 宛先の決まり方 | 購読解除 | 冪等キー |
|---|---|---|---|---|
単発 emails.send | 1イベント1通(パスワード再設定・注文確認) | コードが to を指定(最大50件) | 原則不要(後述の例外あり) | 使える |
バッチ batch.send | 内容が受信者ごとに違うトランザクションを一度に(最大100通) | コードが各要素で指定 | 単発と同じ扱い | 使える(バッチ全体で1つ) |
| Broadcasts | ニュースレター・製品アップデート等 | Segment を指定し、Topic で除外 | 必須。Resend が解除ページを提供 | 非対応 |
| Automations | イベント起点のドリップ | トリガーが指すコンタクト | テンプレートに解除タグを自分で入れる | 非対応 |
冪等キーの列には根拠があります。公式は「冪等キーは現在 POST /emails と POST /emails/batch でサポートされています」と対象を限定しており、Broadcasts と Automations には記載がありません(再送設計は冪等性・リトライ設計の記事へ)。
Resend 自身の分類も押さえておきます。公式はトランザクションを「ユーザーの行動または法的要請でトリガーされる、典型的には1対1のメッセージ」、マーケティングを「販促・情報提供・一般的な連絡で、典型的には1対多。CAN-SPAM や CASL の規制対象であり、受信者は購読解除の選択肢を持たなければならない」と定義しています。さらにグレーゾーンについても、「カート放棄リマインダーのようにマーケティングが1対1になることもある。適切な同意とコンプライアンスが守られている限り、トランザクション API と Automations で送ってよい」と明記しています。つまり規制は「どの API を使ったか」ではなく「内容が広告かどうか」でかかります。
バッチ送信:100通までを1リクエストで
resend.batch.send() は POST /emails/batch を叩きます。1リクエストで最大100通、各要素のパラメータは POST /emails と同一。ただし添付ファイルは非対応です——公式は「添付ファイル付きのメールはバッチエンドポイントでは送信できない」「インライン画像を含む添付は未対応」と明記しています。
ここに落とし穴があります。resend@6.4.1 の型は CreateBatchOptions = CreateEmailOptions[] のままで、attachments を型で弾いていません。渡してもコンパイルは通り、失敗するのは実行時です。この制約はレビューとテストで守るしかありません。
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
// 冪等キーは「バッチ全体」を表す値にする(公式の推奨形式は <event-type>/<entity-id>)
const { data, error } = await resend.batch.send(
[
{ from: "Acme <onboarding@resend.dev>", to: ["foo@example.com"], subject: "hello", html: "<h1>it works!</h1>" },
{ from: "Acme <onboarding@resend.dev>", to: ["bar@example.com"], subject: "world", html: "<p>it works!</p>" },
],
{ idempotencyKey: "team-quota/123456789" },
);
// SDK は throw しない。error を分岐するのが唯一の正しい書き方
if (error) return console.error("[batch] failed", { name: error.name, statusCode: error.statusCode });
// ⚠ 二重の data。data.data[i] が payload の i 番目に対応する(0-based)
data.data.forEach((sent, index) => console.info("[batch] queued", { index, id: sent.id }));
data.data という二重構造は SDK の型そのもの(CreateBatchSuccessResponse が { data: { id: string }[] })です。data[i].id と書いて undefined に悩む事故が起きやすいので、先に潰しておいてください。
strict と permissive の違いは「型」に出る
第2引数には batchValidation?: 'strict' | 'permissive' があり、既定は strict です。
| モード | 1件でも不正なとき | 戻り値 |
|---|---|---|
strict(既定) | リクエスト全体が失敗し、1通も送られない | data.data のみ |
permissive | 通った分だけ送信し、失敗を index で返す | data.data に加えて data.errors が型に生える |
型定義がこうなっています(resend@6.4.1 の dist/index.d.ts より要約)。
type CreateBatchSuccessResponse<Options extends CreateBatchRequestOptions> = {
data: { id: string }[];
} & (Options["batchValidation"] extends "permissive"
? { errors: { index: number; message: string }[] } // permissive のときだけ存在
: Record<string, never>);
条件型で分岐しているため、オプションはインラインのオブジェクトリテラルで渡してください。変数に切り出して CreateBatchRequestOptions 型を明示すると 'strict' | 'permissive' に広がり、条件が偽になって errors が型から消えます。
// インライン・リテラルなら Options["batchValidation"] が "permissive" に確定し、
// data.errors が型検査を通る。変数経由だと型が広がって errors が消える
const { data, error } = await resend.batch.send(payload, { batchValidation: "permissive" });
if (error) return;
// 部分失敗を index で拾い、元 payload と突き合わせて再送キューへ回す
for (const f of data.errors) console.warn("[batch] item failed", { index: f.index, message: f.message });
正直な注記:
permissiveは SDK と CLI に実装されている(HTTP ヘッダx-batch-validationとして送られる)一方、API リファレンスにも OpenAPI スペックにも記載がありません。errors[]の実際のレスポンス形状も公式には文書化されていないため、本番投入前にステージングで実レスポンスを確認してください。
そのほかの制約は、1リクエスト100通・各メールの to は最大50件・各メールは独立して処理され初期ステータスは queued・バッチ内の各メールを個別に予約できるの4点です。マーケティング用途では公式が Broadcasts を勧めています。
予約送信:scheduledAt / update / cancel
scheduledAt は ISO 8601 文字列を受け取ります。REST と公式サンプルでは自然言語("in 1 hour" / "tomorrow at 9am" / "Friday at 3pm ET" / "in 1 min" / "in 5 min")も使えますが、許容される言い回しの形式文法は公開されていません。これ以外の表現を「対応している」と思い込まないでください。サーバー側では toISOString() で ISO 8601 を作るほうが安全です。制限は3つ——最大30日先まで、SMTP 経由では予約できない、一度キャンセルしたメールは再スケジュールできない。
// 予約
const { data: scheduled, error } = await resend.emails.send({
from: "Acme <onboarding@resend.dev>",
to: ["user@example.com"],
subject: "リマインダー",
html: "<p>明日のイベントです</p>",
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
});
if (error || !scheduled) return;
// 再スケジュール(更新できるのは scheduled_at のみ。宛先や本文は変えられない)
await resend.emails.update({
id: scheduled.id,
scheduledAt: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
});
// 取り消し(POST であって DELETE ではない)
await resend.emails.cancel(scheduled.id);
リファレンスとガイドの食い違い:
PATCH /emails/:idのリファレンスはscheduled_atを「ISO 8601 形式」とだけ書き自然言語に触れませんが、Schedule email ガイドの再スケジュール節は SDK・cURL・CLI のすべてで'in 1 min'を使っています。更新時は ISO 8601 で書くのが両方を満たす安全策です。
本番で刺さる罠:APIキーを消すと予約が全部死ぬ
公式が挙げる「予約メールが送られない理由」は2つで、いずれも運用事故です。APIキーが削除・失効・停止された場合、そのキーで予約したメールは送られません。もう1つはアカウントが審査中で送信が一時停止された場合。つまりキーのローテーションは予約済みメールを道連れにします。30日先まで積んだ状態でキーを差し替えれば、購読者には何も届きません。旧キーで予約したメールが尽きるまで旧キーを残す運用が必要です。
実例:DBもクロンも持たない7通のメール講座
このサイトの /resources から登録できる7日間のメール講座は、データベースもスケジューラも使わず予約送信だけで実装しています(app/api/email-course/route.ts)。核は2つです。
1. 逆順に作る。 Day 7 から Day 2 までを予約作成し、Day 1 を最後に作って即時送信します。各メールの購読解除トークンに「それ以降の日のメールID」を埋め込む必要があるからです。Day 3 を作る時点で Day 4〜7 のIDが確定していれば、Day 3 の解除リンクは「4〜7をキャンセルする」情報を自分で運べます。DB が要らないのはこの一点に尽きます。
// lib/email-course-render.ts(抜粋)
/** 作成順 = 7 → 2(予約)→ 1(即時)。後続日のIDを先に確定させるための降順 */
export function orderDaysForCreation(days: readonly EmailCourseDay[]): EmailCourseDay[] {
return [...days].sort((a, b) => b.day - a.day);
}
/** Day N は now + (N-1)×24h。Day 1 だけ undefined = 即時送信 */
export function scheduledAtFor(day: EmailCourseDay, nowMs: number): string | undefined {
return day.day > 1 ? new Date(nowMs + (day.day - 1) * COURSE_DAY_MS).toISOString() : undefined;
}
Day 7 でも now + 6日 なので30日の上限に十分収まります。ここが破れる設計(30日を超える講座)なら、予約送信ではなくスケジューラが必要です。
2. 途中で失敗したら、作った分を best-effort でキャンセルする。 7回のAPI呼び出しの4回目で落ちたとき、予約済みの3通を放置すると「途中から始まる講座」が届きます。トランザクションが無い世界では補償処理を自分で書くしかありません。
// app/api/email-course/route.ts(抜粋・簡略化)
const createdIds: string[] = [];
for (const day of orderDaysForCreation(EMAIL_COURSE.days)) {
// 既に作成済みの「後続日ID」を解除トークンへ封入する
const token = buildUnsubscribeToken({ email: data.email, cancelIds: [...createdIds], expiresAtEpochSec }, secret);
const unsubUrl = `${SITE.url}/api/email-course/unsubscribe?token=${encodeURIComponent(token)}`;
const { html, text } = renderDay(day, unsubUrl);
const scheduledAt = scheduledAtFor(day, now);
const { data: created, error } = await client.emails.send({
from,
to: [data.email],
subject: day.subject,
html,
text,
...(scheduledAt ? { scheduledAt } : {}),
// RFC 8058:2ヘッダで初めてワンクリック解除が成立する
headers: {
"List-Unsubscribe": `<${unsubUrl}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
});
if (error || !created) {
// 部分的に予約済みの状態を残さない。取り消せない分はログに出して可視化する
await cancelBestEffort(client, createdIds);
return NextResponse.json({ error: "登録処理に失敗しました" }, { status: 502 });
}
createdIds.push(created.id);
}
cancelBestEffort は Promise.allSettled でキャンセルを投げ、失敗数を console.warn に残します。キャンセルにも失敗した予約メールは将来必ず届くため、握り潰さずログで追えるようにするのが要点です。これは公式の推奨ではなく、私の実務上の設計判断です。
Segments と Contacts:データモデルが変わった
ここは2024〜2025年の知識で書くとまるごと間違う領域です。
| 古い理解(捨てる) | 2026年の正しい理解(公式) |
|---|---|
| Contact は Audience に属する | Contact はグローバル。Segment には0個・1個・複数所属できる |
| 同じアドレスが Audience ごとに別オブジェクト | アドレス1つ=チーム全体で1 Contact。クォータも1件計上 |
Contacts の各エンドポイントに audience_id が必須 | audience_id は不要。POST /contacts を直接叩く |
| 購読解除は Audience 単位 | 解除時は設定ページが出て、Topic 単位か全停止を選べる |
| Audiences API を使う | Audiences は deprecated。Segments が正 |
公式の移行ガイドは「Audiences と呼ばれていたものが Segments になり、Contact はそれから独立した。Contact は0個・1個・複数の Segment に所属でき、クォータ計算上は1件として数えられる」と説明します。Audiences の API ページには「Segments を優先して非推奨。動作するが将来削除される」という警告が出ており、SDK の型でも resend.audiences に @deprecated が付いています(実体は Segments クラスと同一)。3つの概念の定義はこうです——Contact はアドレスに紐づくグローバルな実体、Segment はチームが送信を整理する内部的な区分、Topic はユーザーが受信設定を管理するユーザー向けツール。
// segments.create も {data,error} を返す。error を見ずに data を使ってはいけない
const { data: segment, error: segmentError } = await resend.segments.create({
name: "Registered Users",
});
if (segmentError) return;
// Contact を作る。audienceId は不要(グローバル Contacts モデル)
const { data: contact, error } = await resend.contacts.create({
email: "steve.wozniak@example.com",
firstName: "Steve",
lastName: "Wozniak",
});
if (error) return;
// Segment へ入れる。contactId でもメールアドレスでも指定できる
await resend.contacts.segments.add({ contactId: contact.id, segmentId: segment.id });
SDK とドキュメントの差分:
POST /contactsのドキュメントは body にsegments配列とtopics配列を受け付けると書いていますが、resend@6.4.1のCreateContactOptionsにはこの2つがありません(audienceId/unsubscribed/firstName/lastName/propertiesのみ)。SDK では上のように「作ってからcontacts.segments.add()」の2ステップになります。自分のバージョンの型を必ず確認してください。
カスタム属性(Contact Properties)の制約も硬めです。key は英数字とアンダースコアのみ・最大50文字・大文字小文字を区別。値は文字列か数値で、fallback_value は型が一致すること。そして属性キーが事前に存在しなければ Contact の作成・更新はエラーで失敗します(型不一致も同様)。先に resend.contactProperties.create({ key: "company_name", type: "string", fallbackValue: "Acme Corp" }) を通しておいてください。
リスト衛生:無効なアドレスに送り続けない
公式のナレッジベースが明言する、見落とされがちな一点があります。
バウンスやスパム報告があったアドレスへの以降の配信を Resend は自動的に抑止(suppress)しますが、購読解除の扱いにはしません。
抑止リストはチーム全体・全ドメインに効きますが、Contact の unsubscribed フラグは立ちません。バウンス・苦情を受けたら自分でコンタクトを更新する必要があります。Webhook で拾うのが定石です(Webhook・バウンス/苦情ハンドリングの記事)。
// email.bounced / email.complained を受けたら自分でフラグを立てる
await resend.contacts.update({ email: bouncedAddress, unsubscribed: true });
公式が挙げる他のリスト衛生策——登録フォームの CAPTCHA、ダブルオプトイン、第三者のアドレス検証サービス、そして**「非トランザクションの送信は、過去6か月以内に開封またはクリックした受信者に限るのがベストプラクティス」**というエンゲージメント絞り込み——も、そのまま実務のチェック項目になります。
Topics:購読カテゴリという「受信者との契約」
Topics は Segments と役割が正反対です。公式の対比表がそのまま答えです。
| 観点 | Topics | Segments |
|---|---|---|
| 誰が制御するか | 受信者 | 送信者(あなた) |
| 可視性 | 購読解除ページに表示される | 内部のみ。受信者には見えない |
| 目的 | ユーザーが受信設定を管理する | ターゲティングのためにコンタクトを整理する |
| 例 | 「ニュースレター」「製品アップデート」 | 「エンタープライズ顧客」「無料トライアル利用者」 |
公式の言葉を借りれば**「Topics は誰が受け取るかを決めるのではなく、誰が受け取らないと言ったかを定義する」**。Segments はターゲティング、Topics は設定の保護です。
const { data: topic } = await resend.topics.create({
name: "Weekly Newsletter", // 最大50文字
description: "週1回のまとめ", // 最大200文字
defaultSubscription: "opt_in", // ⚠ あとから変更できない
});
defaultSubscription は作成後に変更不可です。opt_in は「明示的に解除しない限り全員に届く(既存の全コンタクトに遡及)」、opt_out は「明示的に購読しない限り届かない」。visibility の既定は private で、public にすると全コンタクトが解除ページでその Topic を見られます。あとから編集できるのは name / description / visibility のみ。なお resend@6.4.1 の CreateTopicOptions と UpdateTopicOptions には visibility がそもそも存在しません。SDK からは設定できないので、ダッシュボードか汎用の resend.post() / resend.patch() を使うことになります。
トランザクション送信(emails.send)にも topicId を渡せます。判定ルールは3行——コンタクトがオプトインなら送信、オプトアウトなら送信されず failed としてマークされる、コンタクトでない相手にはTopic の既定が opt_in なら送信。to / cc / bcc は個別に判定されます。さらにグローバルの Subscribed フラグが優先され、これが false なら特定 Topic にオプトインしていても届きません。
Broadcasts では Topic の有無で購読解除の意味が変わります。公式の警告がそのまま設計指針です。
Topic を付けずに Broadcast を送り、誰かが購読解除すると、その人はあなたの送るすべてのメールから解除されます。
だから公式は「Broadcast には常に Topic を付ける」ことを推奨し、同時に「Topic は3〜5個に絞る(多すぎると受信者がチェックボックスの海に溺れる)」「マーケティングメールが月次ニュースレター1種類だけなら、Topics は複雑さを増すだけで恩恵が薄い」とも書いています。作る前に、自分が本当に何種類のメールを送るのかを数えてください。
// 受信者の設定を反映する(設定画面や解除ページの自前実装から呼ぶ)
await resend.contacts.topics.update({
id: contactId,
topics: [
{ id: newsletterTopicId, subscription: "opt_out" },
{ id: productUpdatesTopicId, subscription: "opt_in" },
],
});
ドキュメントの不整合:
PATCH /contacts/{id}/topicsのパラメータ定義と Node SDK サンプルは{ topics: [...] }を送りますが、同じページの cURL サンプルはボディに配列を裸のまま送っています。SDK の型は{ id, topics }なので SDK 経由なら迷いません。cURL で叩くなら実レスポンスで確認してください。
Broadcasts:create → send、下書き、予約
公式いわく「キューイング・スロットリング・スケジューリングを Resend が引き受けるので、自前のインフラを組む必要がない」。送信元は検証済みドメインである必要があります(ドメイン認証と到達率の記事)。
// 1. 下書きを作る(segmentId・from・subject は必須)
const { data: broadcast, error } = await resend.broadcasts.create({
segmentId: "78261eea-8f8b-4381-83c6-79fa7120f1cf",
from: "Acme <newsletter@example.com>",
subject: "今月のアップデート",
topicId: newsletterTopicId, // 付けないと解除=全停止になる
previewText: "新機能を3つ出しました",
html: "Hi {{{contact.first_name|there}}}, you can unsubscribe here: {{{RESEND_UNSUBSCRIBE_URL}}}",
});
if (error) return;
// 2. 送る/予約する(scheduledAt は send() 側のオプション)
await resend.broadcasts.send(broadcast.id, { scheduledAt: "in 1 hour" });
マージタグは三重波かっこです。{{{RESEND_UNSUBSCRIBE_URL}}} は受信者ごと・Broadcast ごとに一意のリンクへ展開され、{{{contact.first_name|there}}} のパイプ記号はフォールバック値の区切りを意味します。
SDK バージョンの差分(重要):公式ドキュメントの Node サンプルは
broadcasts.create({ ..., send: true, scheduledAt: 'in 1 hour' })と1コールで作成+予約しています。しかしresend@6.4.1のCreateBroadcastOptionsにはsendもscheduledAtも存在しません(scheduledAtはbroadcasts.send()側のオプション)。このバージョンでは上のとおり create → send の2コールになります。ドキュメントのサンプルはより新しい SDK を前提にしていると考えられるため、書く前に型定義を確認してください。なおPOST /broadcasts/{id}/sendは「API で作成した Broadcast にのみ使える」——ダッシュボードのエディタで作ったものは送れません。
| ステータス | 意味 | できること |
|---|---|---|
draft | 下書き | 編集・削除・予約 |
scheduled | 予約済み | 予約の取り消し(draft に戻る)・編集・削除 |
queued | 配信キュー投入中 | キャンセル(残りの配信のみ停止) |
sent | 送信済み | 名前の変更のみ |
公式の規則は「下書きまたは予約済みの Broadcast は内容とプロパティを編集できる。一度送信したら名前しか更新できない」「削除できるのは下書き(予約済みを含む)のみ」。送信中のキャンセルは「まだ受け取っていない受信者への配信を止めるだけで、送信済みのメールは回収できない」。ちなみに resend@6.4.1 の Broadcast.status は 'draft' | 'sent' | 'queued' の3値で、ドキュメントが説明する scheduled を含みません——ステータス文字列で分岐するなら実レスポンスを確認してから型を絞ってください。
指標としてダッシュボードで見られるのは配信数・購読解除数・クリック率・開封率です。公式自身が「受信側の処理の都合で開封率は不正確になることがある」と注記しているので、開封率を KPI の主軸に置かないでください。より詳細な GET /broadcasts/{id}/metrics と受信者一覧 API はプライベートベータで、GA 前にレスポンス形状が変わる可能性があると明記されています。そのセマンティクスには面白い点があり、「送信中は割合の分母がそれまでに送った数(delivered + bounced)、完了後は全体数。抑止は送信前に決まるので常に全体数が分母」——ダッシュボードの数字が途中で動いて見える理由がこれです。
購読解除を正しく実装する
Gmail は「1日5,000通以上を送る送信者は、マーケティングおよび購読型メッセージでワンクリック購読解除に対応しなければならない」と定めています。Resend も「2024年2月以降、一括メッセージには list-unsubscribe ヘッダの URL 版と List-Unsubscribe-Post: List-Unsubscribe=One-Click を含め、同じ URL への POST を許可しなければならない」と書いています。必要なのは2つのヘッダで、片方だけでは成立しません。
List-Unsubscribe: <https://example.com/unsubscribe/opaquepart>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
RFC 8058 の規範的要求のうち、実装に効くのは5点です。
List-UnsubscribeはHTTPS の URI を1つ含まなければならない(mailto:の併記は可)。- メッセージはこの2ヘッダを署名対象に含む有効な DKIM 署名を持たなければならない(
h=タグに含める)。無ければ受信側はワンクリック解除を提供すべきでない。 - URI は受信者とリストを一意に識別できる情報を含まなければならない。POST に追加引数は渡せないため、すべて URI に埋め込む。
- URI は不透明な識別子、または偽造しにくい構成要素を含むべきで、サーバー側はその妥当性を検証すべき。
- POST にはCookie・HTTP 認証・その他のコンテキスト情報を含めてはならない。そして送信者はPOST に対して HTTPS リダイレクトを返してはならない。
Resend 側の追加要求は「POST を受けたら空のページを 200 (OK) または 202 (Accepted) で返し、GET では通常の解除ページを表示する。このリクエストから48時間以内に配信を止めること」です。
公式サンプルの穴:Resend のトランザクションメール向け解除ページのコードサンプルは
List-Unsubscribeしか設定していません。本文で「ワンクリックには両方必要」と書いているのに、です。コピペするならList-Unsubscribe-Postを自分で足してください。また Broadcasts が2ヘッダを自動付与するという記述は公式ブログにはありますが、ドキュメントには見当たりません——ブログの主張として扱うのが正確です。
Broadcasts と Automations では、本文に {{{RESEND_UNSUBSCRIBE_URL}}} を入れれば Resend が解除フローを引き受けます。Topics が未設定なら「アカウントからの全メール停止」、Topics があれば設定ページが表示され public な Topic の一覧から選べます。ただし Automations は「send_email ステップから送るメールに解除リンクを自動では追加しない。{{{RESEND_UNSUBSCRIBE_URL}}} をテンプレート側に入れること(ステップの config ではなく)」と明記されています(テンプレート設計はテンプレートと変数の記事へ)。
自前で署名付きトークンを使う
自分でリストを管理するトランザクション系の配信では、解除エンドポイントを自作します。RFC が求める「偽造しにくい構成要素」に対する私の答えは、HMAC-SHA256 で署名した自己完結型トークンでした。
// lib/email-course-token.ts(抜粋)
export interface UnsubscribeTokenPayload {
readonly email: string; // Contact を unsubscribed にするため
readonly cancelIds: readonly string[]; // まだ送っていない後続日のメールID
readonly expiresAtEpochSec: number; // 最終送信日より十分あとに切れる
}
// 形式: base64url(JSON) + "." + base64url(HMAC-SHA256(body, secret))
export function buildUnsubscribeToken(payload: UnsubscribeTokenPayload, secret: string): string {
const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
return `${body}.${sign(body, secret)}`;
}
検証側は必ず timingSafeEqual で比較し、改ざん・期限切れ・秘密鍵違いのすべてを null に落とす全域関数にします。そして GET と POST を出し分けます。
// GET:人がメールのリンクをクリックした → 解除処理をして 303 でお礼ページへ
export async function GET(request: NextRequest) {
const payload = verifyFromRequest(request, secret);
if (!payload) return NextResponse.json(INVALID_TOKEN_RESPONSE, { status: 400 });
await processUnsubscribe(payload);
return NextResponse.redirect(new URL("/unsubscribed", request.url), 303);
}
// POST:メールクライアントによる RFC 8058 ワンクリック → 200 JSON。リダイレクトしない
export async function POST(request: NextRequest) {
const payload = verifyFromRequest(request, secret);
if (!payload) return NextResponse.json(INVALID_TOKEN_RESPONSE, { status: 400 });
await processUnsubscribe(payload);
return NextResponse.json({ ok: true }, { status: 200 });
}
POST でリダイレクトを返さないのは趣味ではなく RFC の要求です(リダイレクトされた POST は歴史的に正しく動かず、多くのブラウザが GET に変換してしまうため)。逆に GET は人間向けなので 303 で「解除しました」ページへ送って構いません。解除処理の本体は、後続の予約メールを全部キャンセルしてコンタクトを unsubscribed にするだけです。
async function processUnsubscribe(payload: UnsubscribeTokenPayload): Promise<void> {
// 既に送信済みの日はキャンセルできない。個別失敗は想定内なので allSettled で握る
await Promise.allSettled(payload.cancelIds.map((id) => client.emails.cancel(id)));
await client.contacts.update({ email: payload.email, unsubscribed: true });
}
なお解除エンドポイントは認証なしで叩ける公開URLです。トークンの署名検証が唯一の防壁なので、秘密鍵の管理と外部入力としての検証を手抜きしないでください(公開エンドポイントのレート制限はサーバーレスのレート制限の記事へ)。
日本の法令:特定電子メール法
RFC とメールボックスプロバイダの要求を満たしても、日本国内の送信には別の義務がかかります。根拠は特定電子メールの送信の適正化等に関する法律(平成14年法律第26号)で、以下は e-Gov 掲載の現行条文に基づきます。
誰が対象か(第2条)。「特定電子メール」とは、営利を目的とする団体および営業を営む場合の個人が、自己または他人の営業につき広告または宣伝を行うための手段として送信する電子メールです(国内の電気通信設備からの送信または国内設備への送信に限る)。フリーランスが自分のサービスを宣伝するメールも当然に含まれます。総務省の解説によれば、電話番号でメッセージを送受信するサービス(SMS 等)も規制対象です。
オプトイン規制(第3条)。送信者は、次のいずれかに該当する者以外に特定電子メールを送信してはなりません。
- あらかじめ送信を求める旨または同意する旨を通知した者
- 総務省令・内閣府令の定めるところにより自己のメールアドレスを通知した者
- 取引関係にある者
- 省令の定めるところにより自己のメールアドレスを公表している団体または個人(個人は営業を営む者に限る)
同条第2項は同意の記録の保存義務を課し、保存期間は施行規則第4条第2項により「最後に送信した日から1か月」(措置命令を受けた場合は1年)。ただし通信販売事業者には特定商取引法のオプトイン規制も重なり、そちらは「最後に電子メール広告を送信した日から3年間」の記録保存を求めます。長いほうに合わせて設計するのが実務的な安全策です。
第3項は受信拒否の通知を受けたら、その意思に反して送信してはならないと定めます。ただし施行規則第6条は例外を置き、契約の申込み・内容・履行に関する事項を通知するメールにおいて広告が付随的に行われる場合などはこの禁止の対象外です。「注文確認メールの末尾に関連商品を1行載せる」がセーフになる根拠がこれで、逆に言えば広告が主目的になった瞬間にアウトという線引きです。
表示義務(第4条・施行規則第7条・第9条)。受信者の画面に次の事項が正しく表示されるようにしなければなりません。総務省委託の迷惑メール相談センターがまとめる5項目が、そのまま実装チェックリストになります。
| 表示項目 | 置く場所(施行規則) |
|---|---|
| 送信者などの氏名または名称 | 受信者が容易に認識できる任意の場所 |
| 受信拒否の通知を受けるためのメールアドレスまたはURL | 受信者が容易に認識できる任意の場所 |
| 受信拒否の通知ができる旨 | 通知先の表示の直前または直後 |
| 送信者などの住所 | 任意の場所 |
| 苦情・問合せを受け付ける電話番号・メールアドレスまたはURL | 任意の場所 |
このサイトのメール講座のフッターは、この表をそのままコードにしたものです。
// lib/email-course-render.ts(抜粋)
const text = [
"----",
intro,
`送信者: ${SITE.author}`, // 氏名・名称
`お問い合わせ: ${contactUrl}`, // 苦情・問合せ窓口
`送信者情報(氏名・連絡先メールアドレス・住所): ${tokushohoUrl}`, // 住所は特商法ページで開示
`プライバシーポリシー: ${privacyUrl}`,
`配信停止はこちら(ワンクリックで解除できます): ${unsubUrl}`, // 拒否できる旨と通知先を隣接させる
].join("\n");
「配信停止はこちら(ワンクリックで解除できます)」という文言と URL を同じ行に隣接させているのは、「拒否できる旨は通知先の直前または直後」という施行規則第7条の要求を満たすためです。同条第2項は「本文と同一の文字コードで符号化して表示すること」も求めています——画像やリンク先だけで済ませてはいけません。
罰則(第34条・第37条)。送信者情報を偽って送信した場合や措置命令に従わない場合は、1年以下の拘禁刑または100万円以下の罰金。法人には行為者の処罰に加えて3,000万円以下の罰金が科されます。
知識の更新点:刑法等の改正に伴い、2025年6月1日施行の現行条文では刑名が**「懲役」から「拘禁刑」に変わっています**。2024年以前の解説記事や LLM の出力は「1年以下の懲役」と書きますが、現行法では誤りです。
判断軸は「どの API を使ったか」ではなく「そのメールの主目的が広告・宣伝か」です。パスワード再設定・注文確認・発送通知は契約の履行に関する通知なので特定電子メールに当たらず、広告が付随する場合も施行規則第6条の例外。ニュースレターやキャンペーン告知はオプトイン規制と表示義務が全部かかります。揉めるのは中間——本人が申し込んだ教育コンテンツのようなメールです。私の判断は「迷ったら広告として扱い、フッターを完全に実装する」。表示義務を満たすコストはフッター数行、満たさないコストは措置命令と罰金です。
本番投入前チェックリスト
- 送ろうとしているものが4種類(単発/バッチ/Broadcast/Automation)のどれかを言語化した
- バッチは100通・添付なしの制約を満たし、
data.data[i]で index 対応を取っている -
permissiveを使うならオプションをインライン・リテラルで渡し、実レスポンス形状を検証した - 予約送信が30日以内に収まり、APIキーのローテーション計画と衝突しない
- 予約の一括作成が途中で失敗したときの**補償処理(キャンセル)**を書いた
-
audienceIdではなくsegmentIdを使っている(既存コードは移行計画を持っている) - Broadcast に Topic を付けた(付けないと解除=全停止になる)
- Topic の
defaultSubscriptionはあとから変えられないことを理解して選んだ -
List-UnsubscribeとList-Unsubscribe-Postの両方を送っている - 解除の GET は解除ページ、POST は 200か202でリダイレクトなし
- 解除 URL に偽造しにくいトークンが入っており、サーバー側で検証している
- バウンス・苦情の Webhook でコンタクトを自分で unsubscribed にしている
- フッターに特電法の5項目が揃い、「拒否できる旨」が通知先の直前または直後にある
- 同意記録の保存期間を特商法の3年に合わせた(通信販売に該当する場合)
まとめ
「大量に送る」を一つの機能だと思っている限り、実装はどこかで必ず破綻します。要点は5つです。
- 4つの送り方は別物。宛先の決まり方・購読解除の義務・冪等キーの可否がそれぞれ違う。
- バッチは既定が strict。部分失敗を許すなら
permissiveだが、公式仕様書に記載が無いことを踏まえて検証してから使う。 - 予約送信は30日・キャンセルは一方通行・APIキーと運命を共にする。
- Audiences ではなく Segments。Segments(ターゲティング)と Topics(受信者の設定)を混同しない。
- 購読解除は2ヘッダ+署名付きURL+GET/POSTの出し分け。日本ではさらに特電法の表示義務5項目が乗る。
まず自分のプロダクトで「いま送っているメールを4分類に仕分ける」ことから始めてください。仕分けた瞬間に、フッターが足りないメールと、そもそも送ってはいけないメールが浮かび上がります。サービス選定そのものから見直すならメール配信サービスの比較記事もあわせてどうぞ。
この記事は Resend 公式ドキュメント(Batch / Schedule / Segments / Topics / Broadcasts / Unsubscribe、2026年8月時点)、インストール済み
resend@6.4.1の型定義、RFC 8058、および e-Gov 法令検索の特定電子メール法・同施行規則の現行条文に基づき、実運用の判断軸を加えて再構成したものです。仕様・上限・プランは更新されるため、本番採用時は各公式ページで最新値をご確認ください。法令の適用可否について確証が必要な場合は、専門家にご確認ください。