問い合わせフォームでリードを静かに落とさない — スパム対策・通知・メール到達性の実装
フォームの失敗は3層で静かに起きます。スパム対策の誤爆、通知の握り潰し、迷惑メール行きの3つです。Cloudflare Workers + Gmail API(追加コスト0)で、honeypot の silent-discard から SPF/DKIM/DMARC の検証まで、リード獲得の心臓部を一人で運用できる形に通します。
by Shin
フォームは動いているのにリードが来ない——どこが壊れているのでしょうか。多くの場合、答えは「壊れていない」です。静かに落ちているだけです。スパム対策・通知・メール到達性の3層のどこで失敗しても、エラーは出ず、送信者には成功画面が表示され、ダッシュボードは平常のまま、リードだけが消えます。本記事は、当サイトを含む複数の個人運営サイトで実際に踏んだ失敗をもとに、3層それぞれの「静かな失敗」と、それを追加コスト0(Google Workspace の既存契約の範囲内)で全部塞ぐ実装を、動くコードで示します。
問い合わせフォームは、リード獲得サイトの心臓部です。広告も記事も SEO も、最後はこの1本のフォームに合流します。そして皮肉なことに、サイトの中で最も監視されていないのもフォームです。計測が静かに壊れる話は 計測設計の連載 で書きましたが、フォームはそれより手前——データになる前のリードそのものが消える場所です。
3層の静かな失敗 — どこでリードが消えるか
フォームの失敗が静かなのは、各層の「正常系」がそれぞれ失敗を隠す構造になっているからです。
| 層 | 静かな失敗 | なぜ気づけないか |
|---|---|---|
| ① スパム対策 | 誤爆で正規リードを弾く/逆に対策自体が bot に回避される | 弾かれた本人は去るだけで、こちらには何も残らない |
| ② 通知 | 保存は成功したが通知が失敗し、リードに気づかない | fire-and-forget で書くと、失敗はログにすら残らない |
| ③ 到達性 | 通知メールが迷惑メール行き/From が書き換わる | 送信 API は 200 を返す。「送信成功」と「受信」は別物 |
共通するのは、フォームは送信者に 200 を返し続けることです。ユーザー視点では常に「送信完了」。だから障害としては観測されず、「最近問い合わせが少ないな」という感覚だけが残ります。それが実装の不具合なのか、単に流入が減ったのかを、あとから切り分ける術はありません。以下、3層を順に塞ぎます。
スパム対策 — honeypot は「弾く」と壊れる
最初の層はスパム対策です。個人運営の問い合わせフォームなら、まずは honeypot(人間には見えない隠しフィールド。bot は機械的に埋める)が費用対効果の最良手です。ただし実装には1つ、直感に反する要点があります。バリデーションで弾いてはいけないのです。
Zod のスキーマで honeypot フィールドに max(0) のような制約をかけると、bot が値を入れた瞬間に 422 が返り、エラーレスポンスに罠フィールドの名前が載ります。
// NG: schema で弾くと、422 のエラーメッセージが罠の存在と名前を晒す
// => { "website": ["String must contain at most 0 character(s)"] }
website: z.string().max(0)
// OK: schema では素通しにして、制約をかけない
website: z.string().optional()エラーメッセージは bot 運用者へのフィードバックです。「website というフィールドを埋めると失敗する」と教えてしまえば、次のクロールからは空にされて終わりです。正解は、検証は通し、route 側で成功を装って静かに捨てる(silent-discard)ことです。
// app/api/contact/route.ts(抜粋)
const parsed = contactSchema.safeParse(json)
if (!parsed.success) {
return NextResponse.json({ ok: false, error: 'validation' }, { status: 422 })
}
const { website, ...lead } = parsed.data
// honeypot に値がある = bot。保存も通知もせず、成功だけ返す
if (website) {
return NextResponse.json({ ok: true })
}
// ここから先が正規リードの処理(保存 → 通知)bot は「送信成功」と認識して去り、こちらは何も保存せず、正規の検証エラーとも混ざりません。この設計のもう1つの利点は、誤爆が構造的に起きにくいことです。人間がこのフィールドを埋める経路は autofill の暴発くらいしかなく、reCAPTCHA のような「人間を疑う」方式と違って正規リードに摩擦を追加しません。流量が増えて honeypot で足りなくなったら、レート制限や Turnstile を重ねる——のが順序ですが、まず最初の1枚はこれで十分です。
通知の信頼性 — どこまで巻き添えにするかの線引き
第2層は通知です。フォームの処理は「保存」と「通知」の2段で、事故はこの2つの結合のさせ方で起きます。全部 await して直列にすると、Slack の一時障害でフォーム自体が 500 になります。逆に全部 fire-and-forget(投げっぱなし・await しない)にすると、通知が何週間止まっていても誰も気づきません。線引きの原則はこうです。
| 処理 | 方針 | 理由 |
|---|---|---|
| リードの保存(DB) | await・失敗なら 5xx | これが落ちたらリード消失。失敗を隠してはいけない |
| 運営宛の通知 | await して失敗をログに表面化 | 「保存は成功・通知だけ失敗」を検知可能な状態にする |
| 送信者への自動返信 | ベストエフォート | 落ちても本体(保存・運営通知)を巻き添えにしない |
| 複数チャネル(メール+Slack 等) | Promise.allSettled で並列 | 片方の失敗が他方を巻き込まない・どれが落ちたか個別に残る |
実装はこうなります。ポイントは Promise.all ではなく Promise.allSettled を使うことです。all は最初の reject で残りを巻き込みますが、allSettled は全チャネルの成否が個別に返るため、「メールは届いたが Slack が落ちていた」を1件ずつログにできます。
// lib/notify.ts — 通知チャネルを並列ベストエフォートで送り、失敗を個別ログする
export async function notifyContact(lead: Lead): Promise<void> {
const results = await Promise.allSettled([
notifyEmail(lead), // 運営宛メール
autoReplyEmail(lead), // 送信者への自動返信
notifySlack(lead), // Slack Incoming Webhook
])
for (const r of results) {
if (r.status === 'rejected') {
console.error('[contact] notification failed:', r.reason)
}
}
}route 側は、保存が成功した後にこの notifyContact を await します。どのチャネルが落ちてもレスポンスは成功のまま(リードは保存済みなので)、ただし失敗は必ずログに残る——「ユーザー影響ゼロ・運営には可視」という落とし所です。各 notifyXxx の先頭で env(Webhook URL・資格情報)が未設定なら何もせず return させておくと、キー投入前でもフォーム導線を通しで検証できます。
同じ「問い合わせを起点にした配管」でも、分類や CRM 登録まで自動化した配管全体の監視設計は マーケ運用の自動化に n8n は必要か ― API × Claude Code で組み直す に書きました。本記事の通知はその最小構成版にあたります。
送信手段 — Workers に nodemailer は無い
第3層に入る前に、そもそも「どうやってメールを送るか」を決める必要があります。ここで最初に効くのはライブラリ選定ではなく、ホスティングがどこかです。
Cloudflare Workers(OpenNext 経由の Next.js を含む)のランタイム workerd は、Node のネットワーク層を完全には実装していません。node:net は部分対応で net.Server は非対応と公式ドキュメントに明記されており、nodemailer のように生の TCP/TLS で SMTP を張るライブラリは、私が試した範囲ではトランスポート層で失敗しました。Route Handler に runtime = 'nodejs' と書いても、OpenNext では workerd 上で動くため回避できません。実際、Node ホストで動いていた SMTP 実装を Workers のサイトへ流用しようとして、ここで一度頓挫しています。
| ホスト | 使える送信方式 |
|---|---|
| Node ランタイム(Vercel 等) | SMTP(nodemailer)も HTTPS API も可 |
| Cloudflare Workers | HTTPS API 一択(SMTP ライブラリは前提から外す) |
つまり Workers では「fetch で叩ける送信手段」に絞られます。Resend 等の送信 SaaS はその代表ですが、無料枠は実質1ドメインで、複数サイトを運営すると課金が始まります。そこで当サイトが採ったのが、**Gmail API + サービスアカウント + ドメイン全体委任(DWD)**です。送信ドメインを Google Workspace で運用しているなら、追加コストは0。しかも認証から送信まで全部 fetch で書けるので、Node でも Workers でも同じコードが動きます。
仕組みは、サービスアカウントの秘密鍵で RS256 の JWT を署名し、Google の token endpoint で jwt-bearer グラントによりアクセストークンへ交換、DWD の sub クレームで実在メールボックスを代理して送信する——という Google 公式のサーバー間フローです。ユーザーの同意画面を通らない(いわゆる 2-legged OAuth)ため、同意画面まわりの失効リスクとも無縁です。ライブラリなしで書くとこうなります。署名は Web Crypto なので Node 依存がありません。
// lib/google-jwt.ts — SA 秘密鍵で RS256 JWT を署名し、アクセストークンを得る(全部 fetch / Web Crypto)
const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token'
function b64urlFromBytes(bytes: Uint8Array): string {
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
const b64urlFromString = (s: string) => b64urlFromBytes(new TextEncoder().encode(s))
function pemToPkcs8(pem: string): ArrayBuffer {
const body = pem
.replace(/\\n/g, '\n') // env に \n リテラルで入れた PEM を復元
.replace(/-----BEGIN [^-]+-----/, '')
.replace(/-----END [^-]+-----/, '')
.replace(/\s+/g, '')
const bin = atob(body)
const buf = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i)
return buf.buffer
}
async function signRs256(signingInput: string, privateKeyPem: string): Promise<string> {
const key = await crypto.subtle.importKey(
'pkcs8', pemToPkcs8(privateKeyPem),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'],
)
const sig = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(signingInput),
)
return b64urlFromBytes(new Uint8Array(sig))
}
export async function getAccessToken(subject: string, scope: string): Promise<string> {
const now = Math.floor(Date.now() / 1000)
const header = b64urlFromString(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
const claims = b64urlFromString(JSON.stringify({
iss: process.env.GOOGLE_SA_CLIENT_EMAIL, // サービスアカウントのメールアドレス
sub: subject, // DWD で代理する実在メールボックス
scope, // 'https://www.googleapis.com/auth/gmail.send'
aud: TOKEN_ENDPOINT,
iat: now,
exp: now + 3600,
}))
const signingInput = `${header}.${claims}`
const assertion = `${signingInput}.${await signRs256(signingInput, process.env.GOOGLE_SA_PRIVATE_KEY!)}`
const res = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
})
if (!res.ok) throw new Error(`Google token ${res.status}: ${await res.text()}`)
const data = (await res.json()) as { access_token: string }
return data.access_token
}送信側は、RFC 822 のメッセージを組み立てて base64url にし、users.messages.send に POST するだけです。日本語の Subject は RFC 2047 でエンコードしないと文字化けします。
// lib/gmail.ts — RFC 822 を組み立てて Gmail API で送信
function encodeHeaderWord(s: string): string {
return /^[\x00-\x7F]*$/.test(s) ? s : `=?UTF-8?B?${b64FromUtf8(s)}?=` // 日本語 Subject 対策
}
function buildRfc822(msg: GmailMessage): string {
const headers = [
`From: ${msg.from}`,
`To: ${msg.to}`,
...(msg.replyTo ? [`Reply-To: ${msg.replyTo}`] : []),
`Subject: ${encodeHeaderWord(msg.subject)}`,
'MIME-Version: 1.0',
'Content-Type: text/plain; charset="UTF-8"',
'Content-Transfer-Encoding: base64',
]
const body = b64FromUtf8(msg.text).replace(/(.{76})/g, '$1\r\n')
return `${headers.join('\r\n')}\r\n\r\n${body}`
}
export async function sendGmailMessage(msg: GmailMessage): Promise<void> {
const token = await getAccessToken(msg.impersonate, 'https://www.googleapis.com/auth/gmail.send')
const res = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/${encodeURIComponent(msg.impersonate)}/messages/send`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ raw: b64url(buildRfc822(msg)) }),
},
)
if (!res.ok) throw new Error(`Gmail send ${res.status}: ${await res.text()}`)
}このコードは JWT の署名・検証と RFC 822 / RFC 2047 の往復をローカルで検証済みです(実際の送信 API 呼び出しは各自の資格情報で)。運用上の要点が3つあります。
- 秘密鍵1本を全サイトで共用できる。 1つの GCP プロジェクト・1つのサービスアカウントに DWD を1回設定すれば、同じ Workspace に収容した複数ドメインすべてで使えます。サイトごとの差分は「誰として送るか」の env だけ。SMTP のアプリパスワードがメールボックス単位で分散するのと対照的で、マーケター一人分の予算=運用の手数で複数サイトを回せます。
sub(代理ユーザー)= From にしたいアドレスにする。 別の本体アカウントを代理して From だけcontact@...にすると、その From が代理ユーザーの Gmail に send-as 登録されていない限り、Gmail が From を本体アドレスに書き換えます(=出したくないアドレスが相手に露出する。実際に踏みました)。contact@...を出したいならcontact@...自体を実在メールボックスにして直接代理するのが確実です。なお DWD で代理できるのは実在ユーザーだけで、エイリアスは不可。token 取得がunauthorized_clientやinvalid_grantで落ちたら「その sub は実在ユーザーではない」を最初に疑ってください。- これはトランザクション専用。 Workspace の送信上限は1ユーザーあたりおよそ2,000通/日で、問い合わせ通知・自動返信(1:1・低頻度)には十分すぎますが、一斉配信には構造的に不向きです。ニュースレターを始めるなら配信専用プロバイダに分ける——「フォーム=Gmail、一斉配信=専用サービス」という役割分担が正解です。
到達性 — 「送れている」は「届いている」ではない
最後の層が到達性です。送信 API が 200 を返しても、それは「Google が送信を受け付けた」であって「相手の受信トレイに入った」ではありません。ここで実際に踏んだのが、社内では届くのに外部では迷惑メール行きという罠です。
From に出すドメインの SPF/DKIM/DMARC が揃っていないと、外部の Gmail はそのメールを迷惑メールに送ります。ところが同一 Workspace 内の受信ではこの判定にかからず素通りするため、自分宛のテストでは正常に見えます。経験上、社内テストでは一度も迷惑メール判定されず、外部の gmail.com 宛に送って初めて発覚しました。完了条件を「自分に届いた」に置くと、この失敗は永遠に見えません。完了条件は「外部アドレスの受信トレイで確認」です。
整備の手順は3点です。DNS の確認は DoH(DNS over HTTPS)を使うと、ローカルのリゾルバやキャッシュに惑わされずその場で検証できます。
# 現在の TXT レコードを DoH で確認(SPF・DKIM・DMARC すべてこの形で見られる)
curl -s -H 'accept: application/dns-json' \
'https://cloudflare-dns.com/dns-query?name=example.com&type=TXT'- SPF: apex の TXT を
v=spf1 include:_spf.google.com ~allに。ありがちな事故は、メールだけ Google に移して旧ホスティングの SPF が残っているケースです。SPF が旧ホストを指したまま Google から送ると不一致になります。 - DKIM: Workspace 管理コンソールの「メールの認証」で対象ドメインの鍵を生成し、
google._domainkey.<domain>に TXT を公開したうえで、「認証を開始」を押す。DNS にレコードを置いただけでは署名は始まりません(状態表示が「DKIM でメールを認証しています」になって有効)。 - DMARC:
_dmarc.<domain>にv=DMARC1; p=none; rua=mailto:...を置き、まず監視から始める。DKIM が From ドメインで揃っていれば DMARC は DKIM alignment で通ります。
判定は、外部の Gmail で受信し「メッセージのソースを表示」(PC ブラウザ版のみ)で SPF/DKIM/DMARC がすべて PASS になっていること、そして何より受信トレイに入っていることです。当サイトを含む複数サイトで、この手順の前は外部宛が迷惑メール行き、SPF の残骸削除と DKIM 有効化の後は受信トレイ入り、という before/after を実地で確認しています。
まとめ — 完了条件のチェックリスト
3層に共通する原則は1つです。静かな失敗を音にする。 フォームが送信者に成功を返し続ける以上、失敗はこちら側で意図的に可視化しない限り観測できません。定期バッチの世界で同じ原則を「fail loud」として実装した例は GA4 週次レポート自動化の記事 に書きました。フォームはその同型パターンの、リアルタイム版です。
- honeypot は schema で弾かず、route 側で silent-discard している(罠フィールド名を 422 で晒さない)
- 破棄件数をログしている(誤爆の検知手段がある)
- リード保存の失敗は 5xx で表面化する(成功を装わない)
- 通知は保存成功後に
awaitし、チャネルごとにallSettledで成否をログしている - 自動返信の失敗が本体を巻き添えにしない
- Workers なら SMTP ライブラリを前提にしていない(HTTPS API で送っている)
- From に出すアドレス自体を代理している(Gmail の From 書き換えを踏まない)
- SPF に旧ホストの残骸がない・DKIM は「認証を開始」まで押した・DMARC を置いた
- 完了条件を「外部アドレスの受信トレイで受信」に置いている
どれも一人で実装・運用できる範囲です。なぜマーケター一人でここまで持てるのか、その内製と外注の判断軸は、対になる なぜ今、マーケチーム1人でも「実装」で戦えるのか に整理しています。また、この仕組みを支える資格情報(サービスアカウント鍵や OAuth トークン)が「いつか必ず失効する」問題への備えは、姉妹記事 マーケ自動化は「1週間後」に静かに止まる で扱います。