ガイド3 分で読めます
HTML フォームの送信内容を自分のメールに届ける
メールサーバーなしで、すべての送信データを受信トレイに届けます。mailto: と PHP の mail() がうまくいかない理由と、スパムを除外して返信もできる構成を解説します。
ブラウザーにはメールサーバーとやり取りする手段がないので、HTML フォームは単独ではメールを送れません。フォームの action をフォームバックエンドに向ければ、バックエンドが送信を受け取り、スパムを除外して保存し、あなたとチームにメールで知らせます。メールアドレスのフィールド名を email にすれば、受信トレイで Reply を押すだけでフォームを入力した人に直接返信できます。
ほとんどのサイトがお問い合わせフォームに求めることは 1 つ、誰かが読む受信トレイにメッセージが届くことです。これは見た目より難しいことがわかります。HTML には <form> 要素はあってもメールを送る手段がなく、昔ながらの近道は訪問者に作業を押し戻すか、迷惑メールフォルダーに入ってしまいます。このガイドではその理由を説明し、選択肢を率直に比較して、どのサイトでもどのスタックでもメールが届くフォームを設定します。
フォームが単独でメールを送れない理由
訪問者が Send を押すと、ブラウザーはフォームの action にある URL に HTTP リクエストを 1 回送ります。フォームにできるのはそれだけです。メールを送るには、受信側のサーバーが信頼するマシンからメールサーバーと SMTP でやり取りする必要があります。ブラウザーはその接続を開けませんし、仮に開けたとしても、どこかの訪問者のノートパソコンから届いたメールは拒否されます。そのため、フォームと受信トレイの間には必ずサーバー上の何かが入ります。自分のコード、ホスト上のスクリプト、あるいはサービスです。
フォームバックエンドなら、その何かは URL です。フォームは https://ikiform.com/f/<id> に送信し、残りは Ikiform が行います:
- 訪問者がフォームを送信し、ブラウザーかあなたのサーバーが Ikiform のエンドポイントに送ります。
- Ikiform はフィールドをフォームの定義と照らし合わせ、スパムスコアを付けて保存します。
- スパムスコアに達した送信はスパムとして保存されます。同じ成功の応答を返しますが、メールも Webhook も送りません。
- それ以外の送信では、訪問者のアドレスを reply-to にして、受信者ごとにメールを 1 通送ります。
- 数秒後にメールが受信トレイに届きます。Reply を押すと訪問者宛てに書けます。
フォームを受信トレイに届ける 6 つの方法
どれも誰かにとっては機能します。この表は、実際の訪問者と実際のボットがフォームを使い始めたときに、それぞれがどんなコストを生むかをまとめたものです。
| 方法 | 得意なこと | トレードオフ |
|---|---|---|
action="mailto:you@example.com" | サーバーもアカウントも不要、属性 1 つだけ。 | 何も送信されません。ブラウザーは訪問者のメールアプリで下書きを開くか、メールアプリが設定されていなければ何もしません。アドレスはページに載ったままなので、収集ボットに拾われます。Gmail のガイド を参照してください。 |
mail() を使う PHP スクリプト | ほぼどの共有 PHP ホストでも動き、第三者は不要。 | ホストのメール設定に左右され、自分のドメインから送ったことになっているメールは SPF や DMARC に通らず迷惑メールになりがちです。スパム除外も保存もなく、静的ホストでは PHP 自体がありません。PHP なしで送るを参照してください。 |
| EmailJS などのブラウザー用メール SDK | サーバー不要。メールテンプレートは相手のダッシュボードでデザインできる。 | キーとテンプレート ID がページの JavaScript に含まれるので、誰でも自分の文面であなたのテンプレートを送れてしまいます。EmailJS の代替を参照してください。 |
| 自前のルートと、Resend、Postmark、Amazon SES、SMTP 経由の Nodemailer などのメール API | 件名も HTML も自由に決められ、訪問者に確認メールも送れる。 | バリデーション、スパム除外、レート制限、保存、リトライを自分で作り、送信ドメインを認証する必要があり、静的サイトでもサーバーが要ります。Next.js のガイド を参照してください。 |
| Web アプリとして公開する Google Apps Script | すでに持っている Google アカウントで動く。 | スクリプトの割り当て上限で 1 日にメールを送れる宛先数が限られ、URL にスパム対策はなく、スクリプトの保守も自分で行います。 |
| Ikiform、Formspree、Web3Forms などのフォームバックエンド | action に URL を 1 つ書くだけ。サーバーなしでスパム除外、保存、メール通知。 | メールのレイアウトと件名はサービス側のものです。Ikiform が訪問者にメールを送ることはありません。 |
完全に自社ブランドのメールや訪問者への確認メールが必要なら、メール API を使って自分でルートを書いてください。スパムを除いたメッセージが受信トレイに届き、返信できればよいのなら、フォームバックエンドがいちばん少ないコードで済みます。このガイドの残りでは Ikiform を使います。
フォーム
ほとんどのお問い合わせフォームは 3 つのフィールドで足ります。誰なのか、どこに返信するか、何を求めているかです。下でスタックを選んでください。その選択は、これらのガイドのすべてのコードサンプルに引き継がれます。
このフォームは JavaScript なしで動きます。ブラウザーが送信し、Ikiform があなたにメールを送り、訪問者は _redirect のページに移動します。各フィールドの下には、下のスクリプト用の空のエラー表示欄があります。
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" />
<p data-error="name"></p>
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p data-error="email"></p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
<p data-error="message"></p>
<input type="hidden" name="page" value="/contact" />
<input type="hidden" name="_redirect" value="https://example.com/contact/sent" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send message</button>
<p id="contact-status" role="status"></p>
</form>スクリプトは訪問者をページにとどめます。fetch で送信し、サーバーのエラーを各フィールドの下に書き、フォームを返信先のアドレスを繰り返す一文に置き換えるので、入力ミスにすぐ気づけます。
const form = document.getElementById("contact");
const status = document.getElementById("contact-status");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
for (const slot of form.querySelectorAll("[data-error]")) slot.textContent = "";
button.disabled = true;
status.textContent = "Sending";
try {
const response = await fetch(form.action, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const result = await response.json();
if (result.ok) {
const email = form.elements.namedItem("email").value;
const done = document.createElement("p");
done.setAttribute("role", "status");
done.textContent = `Sent. We'll reply to ${email}.`;
form.replaceWith(done);
return;
}
for (const [field, messages] of Object.entries(result.error.fields ?? {})) {
const slot = form.querySelector(`[data-error="${field}"]`);
if (slot) slot.textContent = messages[0];
}
status.textContent = result.error.fields ? "Check the fields above." : result.error.message;
} catch {
status.textContent = "Couldn't send. Check your connection and try again.";
}
button.disabled = false;
});サーバーアクションがシークレットキーでメッセージを送信します。Ikiform の error.fields をフィールドごとのメッセージに対応させ、入力された値を返すので、エラーのときも訪問者が書いた内容は失われません。
"use server";
import { headers } from "next/headers";
export interface ContactState {
sent: boolean;
email?: string;
errors: Record<string, string>;
values?: { name: string; email: string; message: string };
}
export async function sendMessage(_previous: ContactState, formData: FormData): Promise<ContactState> {
const values = {
name: String(formData.get("name") ?? "").trim(),
email: String(formData.get("email") ?? "").trim(),
message: String(formData.get("message") ?? "").trim(),
};
const origin = (await headers()).get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...values, page: "/contact", _gotcha: String(formData.get("_gotcha") ?? "") }),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, email: values.email, errors: {} };
const errors: Record<string, string> = {};
for (const [field, messages] of Object.entries(result?.error?.fields ?? {})) {
errors[field] = (messages as string[])[0] ?? "Check this field.";
}
if (!Object.keys(errors).length) errors.form = result?.error?.message ?? "Couldn't send your message. Try again.";
return { sent: false, errors, values };
}"use client";
import { useActionState } from "react";
import { type ContactState, sendMessage } from "./actions";
const initial: ContactState = { sent: false, errors: {} };
export function ContactForm() {
const [state, action, pending] = useActionState(sendMessage, initial);
if (state.sent) return <p role="status">Sent. We'll reply to {state.email}.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required defaultValue={state.values?.name} />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email} />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} defaultValue={state.values?.message} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains(許可ドメイン)と人間確認をスキップします。すると送信はすべてサーバーの IP から届くことになり、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計で数えられます。サーバー経由で送信するフォームでは Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。各サーバーのサンプルは、ブラウザーの Origin ヘッダーがあればそれを引き継ぎます。サーバー自身の fetch はこのヘッダーを送らず、Ikiform は無料の localhost テスト送信をこのヘッダーで判定するからです。
Next.js でさらに詳しく知るには メールを送る Next.js のお問い合わせフォーム を読んでください。
Zod が送信前に Action 内で各フィールドを確認し、isInputError でフィールドごとのメッセージを得られます。ページはオンデマンドでレンダリングする必要があるので、prerender = false のままにしてアダプターを追加してください。
import { ActionError, defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
message: z.string().trim().min(10, "Write at least a sentence."),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...input, page: "/contact" }),
});
const result = await response.json();
if (!result.ok) throw new ActionError({ code: "BAD_REQUEST", message: result.error.message });
return { email: input.email };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---
{result?.data ? (
<p role="status">Sent. We'll reply to {result.data.email}.</p>
) : (
<form method="POST" action={actions.sendMessage}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{fields.name && <p role="alert">{fields.name[0]}</p>}
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{fields.email && <p role="alert">{fields.email[0]}</p>}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{fields.message && <p role="alert">{fields.message[0]}</p>}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
<button>Send message</button>
</form>
)}サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains(許可ドメイン)と人間確認をスキップします。すると送信はすべてサーバーの IP から届くことになり、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計で数えられます。サーバー経由で送信するフォームでは Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。各サーバーのサンプルは、ブラウザーの Origin ヘッダーがあればそれを引き継ぎます。サーバー自身の fetch はこのヘッダーを送らず、Ikiform は無料の localhost テスト送信をこのヘッダーで判定するからです。
サーバー関数は .validator() で FormData を読み、サーバーから送信して、返信先のアドレスかフィールドごとのエラーを返します。
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Outcome = { sent: true; email: string } | { sent: false; errors: Record<string, string> };
const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
_gotcha: String(data.get("_gotcha") ?? ""),
};
})
.handler(async ({ data }): Promise<Outcome> => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...data, page: "/contact" }),
});
const result = await response.json();
if (result.ok) return { sent: true, email: data.email };
const errors: Record<string, string> = { form: String(result.error.message) };
for (const [field, messages] of Object.entries(result.error.fields ?? {})) {
errors[field] = String((messages as string[])[0]);
}
return { sent: false, errors };
});
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const send = useServerFn(sendMessage);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
const errors = outcome && !outcome.sent ? outcome.errors : {};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
setPending(true);
try {
setOutcome(await send({ data }));
} catch {
setOutcome({ sent: false, errors: { form: "Couldn't send your message. Try again." } });
} finally {
setPending(false);
}
}
if (outcome?.sent) return <p role="status">Sent. We'll reply to {outcome.email}.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{errors.name ? <p role="alert">{errors.name}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{errors.email ? <p role="alert">{errors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{errors.message ? <p role="alert">{errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{errors.form && !errors.name && !errors.email && !errors.message ? <p role="alert">{errors.form}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
);
}サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains(許可ドメイン)と人間確認をスキップします。すると送信はすべてサーバーの IP から届くことになり、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計で数えられます。サーバー経由で送信するフォームでは Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。各サーバーのサンプルは、ブラウザーの Origin ヘッダーがあればそれを引き継ぎます。サーバー自身の fetch はこのヘッダーを送らず、Ikiform は無料の localhost テスト送信をこのヘッダーで判定するからです。
ルートの action がメッセージを転送し、Ikiform のフィールドエラーを actionData で返します。Ikiform のステータスコードは data() で保たれます。
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "").trim();
const origin = request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
name: String(formData.get("name") ?? "").trim(),
email,
message: String(formData.get("message") ?? "").trim(),
page: "/contact",
_gotcha: String(formData.get("_gotcha") ?? ""),
}),
});
const result = await response.json();
if (result.ok) return { sent: true as const, email };
const fields: Record<string, string[]> = result.error.fields ?? {};
return data(
{ sent: false as const, message: String(result.error.message), fields },
{ status: response.status },
);
}
export default function Contact({ actionData }: Route.ComponentProps) {
const navigation = useNavigation();
const pending = navigation.state === "submitting";
if (actionData?.sent) return <p role="status">Sent. We'll reply to {actionData.email}.</p>;
const fields = actionData?.fields ?? {};
return (
<Form method="post">
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{fields.name ? <p role="alert">{fields.name[0]}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{fields.email ? <p role="alert">{fields.email[0]}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{fields.message ? <p role="alert">{fields.message[0]}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData && !Object.keys(fields).length ? <p role="alert">{actionData.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</Form>
);
}サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains(許可ドメイン)と人間確認をスキップします。すると送信はすべてサーバーの IP から届くことになり、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計で数えられます。サーバー経由で送信するフォームでは Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。各サーバーのサンプルは、ブラウザーの Origin ヘッダーがあればそれを引き継ぎます。サーバー自身の fetch はこのヘッダーを送らず、Ikiform は無料の localhost テスト送信をこのヘッダーで判定するからです。
デフォルトの action は、フィールドエラー付きの fail() を返します。update({ reset: false }) により、サーバーがエラーを返しても訪問者の入力は残ります。$app/env/private から読めるように、src/env.ts で defineEnvVars を使って IKIFORM_SECRET_KEY を宣言してください。
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const data = await request.formData();
const values = {
name: String(data.get("name") ?? "").trim(),
email: String(data.get("email") ?? "").trim(),
message: String(data.get("message") ?? "").trim(),
};
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({ ...values, page: "/contact", _gotcha: String(data.get("_gotcha") ?? "") }),
});
const result = await response.json();
if (!result.ok) {
const fields: Record<string, string[]> = result.error.fields ?? {};
return fail(response.status, { values, fields, message: String(result.error.message) });
}
return { sentTo: values.email };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let pending = $state(false);
const fields = $derived<Record<string, string[]>>(form?.fields ?? {});
</script>
{#if form?.sentTo}
<p role="status">Sent. We'll reply to {form.sentTo}.</p>
{:else}
<form
method="POST"
use:enhance={() => {
pending = true;
return async ({ update }) => {
await update({ reset: false });
pending = false;
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
{#if fields.name}<p role="alert">{fields.name[0]}</p>{/if}
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
{#if fields.email}<p role="alert">{fields.email[0]}</p>{/if}
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10"></textarea>
{#if fields.message}<p role="alert">{fields.message[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
{#if form?.message && !Object.keys(fields).length}<p role="alert">{form.message}</p>{/if}
<button disabled={pending}>{pending ? "Sending" : "Send message"}</button>
</form>
{/if}サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains(許可ドメイン)と人間確認をスキップします。すると送信はすべてサーバーの IP から届くことになり、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計で数えられます。サーバー経由で送信するフォームでは Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。各サーバーのサンプルは、ブラウザーの Origin ヘッダーがあればそれを引き継ぎます。サーバー自身の fetch はこのヘッダーを送らず、Ikiform は無料の localhost テスト送信をこのヘッダーで判定するからです。
サーバールートがシークレットキーを持ち、Ikiform のメッセージを createError の message に、フィールドエラーを data に入れて渡します。クライアントでは $fetch がそれらを data.message と data.data.fields として返します。Nuxt は NUXT_IKIFORM_SECRET_KEY から runtimeConfig.ikiformSecretKey を埋めます。
export default defineEventHandler(async (event) => {
const body = await readBody<{ name?: string; email?: string; message?: string; _gotcha?: string }>(event);
const config = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${config.ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
name: body.name?.trim() ?? "",
email: body.email?.trim() ?? "",
message: body.message?.trim() ?? "",
page: "/contact",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
data: { fields: result.error.fields ?? {} },
});
}
return { sentTo: body.email };
});export default defineNuxtConfig({
runtimeConfig: {
ikiformSecretKey: "",
},
});<script setup lang="ts">
const values = reactive({ name: "", email: "", message: "", _gotcha: "" });
const fields = ref<Record<string, string[]>>({});
const error = ref("");
const sentTo = ref("");
const sending = ref(false);
async function send() {
sending.value = true;
fields.value = {};
error.value = "";
try {
const result = await $fetch("/api/contact", { method: "POST", body: values });
sentTo.value = result.sentTo ?? values.email;
} catch (failure) {
const data = (failure as { data?: { message?: string; data?: { fields?: Record<string, string[]> } } }).data;
fields.value = data?.data?.fields ?? {};
error.value = data?.message ?? "Couldn't send your message. Try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sentTo" role="status">Sent. We'll reply to {{ sentTo }}.</p>
<form v-else @submit.prevent="send">
<label for="name">Name</label>
<input id="name" v-model="values.name" name="name" autocomplete="name" required />
<p v-if="fields.name" role="alert">{{ fields.name[0] }}</p>
<label for="email">Your email</label>
<input id="email" v-model="values.email" name="email" type="email" autocomplete="email" required />
<p v-if="fields.email" role="alert">{{ fields.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" v-model="values.message" name="message" rows="6" required minlength="10" />
<p v-if="fields.message" role="alert">{{ fields.message[0] }}</p>
<input v-model="values._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error && !Object.keys(fields).length" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>サーバー側の版はシークレットキーで送信するので、Ikiform は Allowed domains(許可ドメイン)と人間確認をスキップします。すると送信はすべてサーバーの IP から届くことになり、訪問者ごとに 1 分あたり 5 件という Standard の制限が全訪問者の合計で数えられます。サーバー経由で送信するフォームでは Relaxed か Custom のレート制限を選び、IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。各サーバーのサンプルは、ブラウザーの Origin ヘッダーがあればそれを引き継ぎます。サーバー自身の fetch はこのヘッダーを送らず、Ikiform は無料の localhost テスト送信をこのヘッダーで判定するからです。
自分のサーバーがない場合、ブラウザーが直接 Ikiform に送信します。このコードにキーは入れません。どのサイトから送信できるかは Allowed domains が決めます。
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const [sentTo, setSentTo] = useState("");
const [sending, setSending] = useState(false);
const [fields, setFields] = useState<Record<string, string[]>>({});
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const body = new FormData(event.currentTarget);
setSending(true);
setFields({});
setError("");
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
setSentTo(String(body.get("email")));
return;
}
setFields(result.error.fields ?? {});
setError(result.error.message);
} catch {
setError("Couldn't reach the server. Try again.");
} finally {
setSending(false);
}
}
if (sentTo) return <p role="status">Sent. We'll reply to {sentTo}.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{fields.name ? <p role="alert">{fields.name[0]}</p> : null}
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{fields.email ? <p role="alert">{fields.email[0]}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={6} required minLength={10} />
{fields.message ? <p role="alert">{fields.message[0]}</p> : null}
<input type="hidden" name="page" value="/contact" />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{error && !Object.keys(fields).length ? <p role="alert">{error}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
);
}同じブラウザー側の送信を Vue コンポーネントで書いたものです。フォームは action を持ったままなので、スクリプトの読み込みに失敗しても送信できます。
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sentTo = ref("");
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const error = ref("");
async function send(event: SubmitEvent) {
const body = new FormData(event.target as HTMLFormElement);
sending.value = true;
fields.value = {};
error.value = "";
try {
const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
const result = await response.json();
if (result.ok) {
sentTo.value = String(body.get("email"));
return;
}
fields.value = result.error.fields ?? {};
error.value = result.error.message;
} catch {
error.value = "Couldn't reach the server. Try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sentTo" role="status">Sent. We'll reply to {{ sentTo }}.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required />
<p v-if="fields.name" role="alert">{{ fields.name[0] }}</p>
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<p v-if="fields.email" role="alert">{{ fields.email[0] }}</p>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required minlength="10" />
<p v-if="fields.message" role="alert">{{ fields.message[0] }}</p>
<input type="hidden" name="page" value="/contact" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="error && !Object.keys(fields).length" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
</form>
</template>frm_xxxxxxxxxxxxxxxxをダッシュボードにある自分のフォームの ID に置き換えます。name="email"を付けたフィールドが、通知メールの reply-to アドレスになります。ラベルが「Your email」などでも、name はこのままにしてください。pageは普通の hidden フィールドです。メールに表示されるので、どのページから届いたメッセージかがわかります。_gotchaはハニーポットです。人には見えず、ここに入力したボットはスパムとして保存され、誰にもメールは送られません。_redirectは、フォームの Allowed domains にあるhttps:の URL でしか機能しません。省略してフォーム設定の Redirect after submit を使うこともでき、どちらもなければ Ikiform 自身のサンクスページが表示されます。
フレームワーク別のガイドではさらに詳しく説明しています: Next.js、Astro、SvelteKit、Nuxt、React、Vue、プレーンな HTML。Webflow や Carrd などのサイトビルダーには専用のページがあり、お問い合わせフォームのテンプレートには件名のフィールドが追加されています。
ダッシュボードでもフィールドを定義すると、Ikiform は定義に合わない送信を 422 validation_failed とフィールドごとのメッセージで拒否し、上のコードがそれを各入力欄の横に表示します。ここで試せます。同じバリデーターがブラウザーで動きます:
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
メールの見た目
受け付けた送信データごとに、シンプルで読みやすいメールが送られます。テキスト版の例です:
From: Ikiform <notifications@ikiform.com>
To: you@yourcompany.com
Reply-To: ada@example.com
Subject: New submission: Contact
Contact got a new submission on Oct 25, 2026, 9:14 AM UTC.
name: Ada Lovelace
email: ada@example.com
message: Do you ship to Canada? I'd like to order 40 units before December.
page: /contact
View it in Ikiform:
https://ikiform.com/dashboard/submissions?form=frm_xxxxxxxxxxxxxxxx&submission=sub_...- 送信元は
notifications@ikiform.comです。メールは Ikiform のドメインから送られるので、DNS レコードや送信ドメインを設定する必要はありません。 - 件名は常に
New submission: <form name>です。_subjectは通知メールの件名を変えません。meta.subjectとして Webhook と API に渡されます。フォームを見分けるには、ダッシュボードで「Contact, pricing page」のようにフォームごとにわかりやすい名前を付けてください。 - フィールドは送信された順に、
nameをラベルにして並びます。_で始まるフィールドは含まれません。 - とても長いフォームや回答は短縮され、メールにはダッシュボードに残りのフィールドがいくつあるかが書かれます。具体的な数値は制限のページ にあります。
- パスワードやカード番号のように見えるフィールドは含まれず、それを含む送信のメールは確認のために保留されます。フォームバックエンドで認証情報を集めないでください。
- ワークスペースのメンバーには View submission ボタンが表示されます。ワークスペース外の人には、フィールドと配信停止のリンクが届きます。
メールを受け取る人
受信者はダッシュボードでフォームごとに設定し、フォーム自体で指定することはありません。受信者を指定できるフィールドがあれば誰でもあなたのフォームを使って誰にでもメールを送れてしまうので、Ikiform にはそのようなフィールドはありません。
| 対象 | 追加方法 | 注意 |
|---|---|---|
| デフォルト | 何もしなくてよい | ワークスペースの Notification email に選ばれたメンバー。選ばれていなければワークスペースのオーナー全員 |
| 選んだメンバー | フォームの Notifications 設定でチェックを入れる | メールアドレスが認証済みのメンバーのみ |
| ワークスペース外の人 | Add someone outside the workspace を選び、Send link を押す | 本人が確認するまでメールは届かない。フォームごとに最大 5 人で、それぞれ配信停止できる |
1 つのフォームがメールを送る宛先は最大 10 件です。CC で共有するのではなく受信者ごとに個別に届くので、他の人のアドレスは見えません。受信者のメールボックスでバウンスすると、Ikiform はそのアドレスへのメールをしばらく停止し、ダッシュボードの横に Emails bounced と表示します。受信者の設定だけではできない、件名ごとの振り分けは複数の受信者で説明しています。
Ikiform ではなく、本人に返信する
Ikiform は reply-to のアドレスを、まず _replyto フィールドから、次に email、email_address、your_email、reply_to、contact_email という名前のフィールドから設定します。値は有効なメールアドレス 1 つでなければならず、そうでなければ無視されます。reply-to が設定されていれば、Gmail、Outlook、Apple Mail で Reply を押すと訪問者への返信が開き、返信は自分のアドレスから送られます。
reply-to がなければ、返信は誰も読まない notifications@ikiform.com に送られます。これがいちばんよくある想定外の動きで、たいていは user_email や空白入りの Email Address という名前のフィールドが原因です。名前を変えるか、_replyto を追加してください。メールアドレスのフィールドが 2 つあるフォームや、フィールド名を勝手に変えるビルダーについては reply-to のガイドで説明しています。
一度に大量に届いたとき
各フォームが送る通知メールは 1 時間に最大 50 通、ワークスペースの作成から 7 日未満なら 10 通です。それを超えると、その時間の残りの送信データは通常どおり保存され、1 時間が終わった後にまとめて 1 通のダイジェストメールで届きます。この上限は、フォームが大きな場所に掲載されたときやボットがフィルターをすり抜けたときに、あなたの受信トレイと、メールプロバイダーから見た Ikiform のメールの評判を守ります。
- 毎正時から 1 時間のうち、フォームへの最初の 50 件の送信は通常どおりそれぞれメールを送ります。
- 同じ 1 時間のそれ以降の送信は保存されてすぐにダッシュボードに表示されますが、1 件ごとのメールは送られません。
- 1 時間が終わると、各受信者に件数とダッシュボードへのリンクを載せたダイジェストが 1 通届きます。
ダイジェストの件名は件数とフォーム名で、フィールドの一覧も reply-to もないので、それらのメッセージにはダッシュボードから返信してください。上限を頻繁に超えるなら、そのフォームにはメールが向いていません。代わりに Webhook で Slack や共有の受信トレイに送ってください。
受信トレイにスパムを入れない
メールではスパムのコストが高くつきます。ボットの送信 1 件ごとに、誰かが開く通知が 1 通届くからです。Ikiform はスパムを通常の成功の応答とともに保存するのでボットは何も学べず、スパムについてはメールを送りません。何をスパムとみなすかは 4 つの仕組みで決まります:
| 防御策 | 防げるもの | 設定 |
|---|---|---|
| ハニーポット | すべての入力欄を埋めるボット | 非表示の _gotcha 入力欄 |
| スパムスコア | 名前欄のリンク、3 つ以上のリンク、既知のスパム文句、使い捨てアドレス、まったく同じ再送信。5 でスパムになる | 不要 |
| 許可ドメイン | 他のサイトからの送信 | フォームの設定に自分のドメインを追加 |
| 人間確認 | 本物のブラウザーを動かすボット | Spam check を Ikiform に設定 |
ときどきダッシュボードの Spam フィルターを確認してください。本物のメッセージが入っていたら、スパムではないとマークすれば、その時点でメールが送られます。人間確認をオンにすべきときも含め、各シグナルはスパム対策のドキュメントで説明しています。
メールでできないこと
Ikiform はフォームを入力した本人にはメールを送りません。通知メールが届くのはあなたとチームだけなので、自動返信、確認メール、ダブルオプトインはありません。それらは Webhook をきっかけに、自分のメールツールから送ってください。サイトで「メッセージを受け付けました」と伝えたいなら、送信後のページに表示してください。上のコードはすでにそうしています。
- チームの共有受信トレイ。 チームのアドレスを受信者に追加するか、メールプロバイダーで転送してください。
- チャットのチャンネル。 各送信データを Webhook で Slack、Discord、Microsoft Teams に送ります。1 日に数件以上届くフォームにはチャットが向いています。
- スプレッドシートや CRM。 Google Sheets や、Zapier または n8n 経由の任意のツールに送れます。
- 添付ファイル。 ファイルのアップロードにはまだ対応していません。ファイル付きの送信には
422 files_not_supportedが返ります。代わりに共有ファイルへのリンクを尋ねてください。
メールをオフにする
にぎわうニュースレター登録のように、メールがないほうがよいフォームもあります。フォームの Notifications 設定で Email new submissions をオフにしてください。送信データは保存され、Webhook も発火し続けます。
状況に合わせて選ぶ
上の設定はどこでも同じです。以下のページでは、特定の受信トレイ、特定のスタック、または乗り換え元のツールについて説明しています。
- PHP なしでmail() を呼ぶ contact.php スクリプトを、ホストされたエンドポイントに送信するフォームに置き換えます。フィールド名、サンクスページ、返信はそのままです。
- Gmail へmailto: リンクもアプリパスワードも Apps Script も使わずに、フォームの送信データを Gmail で受け取り、ラベルを付け、迷惑メールを避け、Gmail から返信します。
- 複数の受信者1 つのフォームをチーム全員にメールで送ったり、トピックごとに適切な担当者に振り分けたり。ただし、誰に送るかをフォーム自身には決めさせません。
- 受信トレイから返信フォームの通知メールで Reply を押すと、フォームに入力した人宛てになるようにします。使えるフィールド名、_replyto を使う場面、返信が迷子になる理由を説明します。
- EmailJS の代替お問い合わせフォームを EmailJS から移行。ブラウザー側のメールキーのリスク、コードの変更点、自動返信など失うものを解説します。
- Next.jsServer Action で Next.js のフォームをメールで送る 2 つの方法を公平に比較します。Resend や Nodemailer で自分で送るか、フォームバックエンドに転送するかです。
動作を確認する
フォームを公開する前に、次の手順を確認してください。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースでも localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 で 500 件からの Pro を選ぶか、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを購入します。クレジットに有効期限はありません。料金を参照してください。
- フォームの Allowed domains に自分のサイトのドメインと、開発中は
localhostを追加します。空にしておくと、どこからの送信でも受け付けます。 - フォームの Notifications 設定で、Email new submissions がオンで、正しい人にチェックが入っていることを確認します。
- ページのフォームに入力して送信します。自分のサンクスページか、Ikiform のサンクスページに移動します。
- ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
- 通知メールを開きます。件名は
New submission:とフォーム名で、送ったすべてのフィールドが並んでいます。 - Reply を押します。To の欄には、Ikiform ではなくフォームに入力したアドレスが表示されます。
- ハニーポットを埋めて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - 何も届かない場合は、ダッシュボードのステータスフィルターを Spam に切り替えてください。スパムにも本物の送信と同じ成功の応答が返ります。
トラブルシューティング
| 問題 | 対処法 |
|---|---|
| 送信データはダッシュボードにあるのに、メールが届かない | まず迷惑メールやプロモーションのフォルダーを確認し、notifications@ikiform.com を連絡先に追加してください。次にフォームの Notifications 設定を確認します。メールがオフになっているか、外部のアドレスがまだ Waiting for confirmation のままか、Emails bounced と表示されている可能性があります。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムでは何も送られません。Spam フィルターで開いて理由を確認し、スパムではないとマークすればメールと Webhook が送られます。 |
| Reply の宛先が notifications@ikiform.com になる | reply-to が見つかりませんでした。メールアドレスのフィールド名を email、email_address、your_email、reply_to、contact_email のいずれかにするか、訪問者のアドレスを入れた _replyto フィールドを追加してください。 |
| メールが止まり、代わりにダイジェストが届いた | フォームが 1 時間に 50 通(新しいワークスペースでは 10 通)を超えました。すべてダッシュボードにあります。Spam でボットの集中送信がないか確認するか、忙しいフォームには Webhook を使ってください。 |
_subject で件名が変わらない | _subject は通知メールの件名を変えません。meta.subject として Webhook と API に渡されます。 |
403 origin_not_allowed が返るか、すべての送信がスパムになる | ページのドメインがフォームの Allowed domains にありません。追加し、ローカルでのテスト用に localhost も追加してください。登録したドメインのサブドメインは許可されます。 |
422 validation_failed | 送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。 |
402 test_limit_reached | ワークスペースにプランがなく、今月の無料の localhost テスト 50 件を使い切っています。プランを選ぶか、ダッシュボードの Send test を使ってください。 |
よくある質問
単独では送れません。フォームは HTTP リクエストを送るだけで、メールを送るにはメールサーバーとやり取りするサーバーが必要です。フォームバックエンドがそのサーバーです。フォームの action をそこに向ければメールが届くので、自分のサイトは静的なままにできます。
mailto: の action は何も送信しません。訪問者のコンピューターに下書き付きでメールアプリを開くよう頼むだけなので、メールアプリを設定していない人には使えず、設定していても訪問者が自分で送信を押す必要があります。多くの人はそこで諦め、あなたは試みがあったことすら知りません。
いいえ。件名は常に「New submission:」にフォーム名が続く形なので、フォームにはわかりやすい名前を付けてください。_subject フィールドは保存されて Webhook と API に渡されますが、メールは変わりません。
フォームごとに最大 10 人です。フォームの設定でチェックしたワークスペースのメンバーに加え、リンクで確認した外部のアドレスを最大 5 件まで追加できます。それぞれに個別のメールが届きます。
いいえ。Ikiform がメールを送るのはあなたとチームで、送信者には送らないので、自動返信や確認メールはありません。Webhook でメールツールに送り、そこから確認メールを送ってください。
Ikiform 自身のドメインから送られるので、自分のドメインに SPF や DKIM のレコードは不要です。それでも最初の 1 通が迷惑メールやプロモーションに入ることはあります。notifications@ikiform.com を連絡先に追加するかフィルターを作れば、そうならなくなります。
いいえ、無料プランはありません。テストは無料で、支払い前でも localhost からテスト送信を月 50 件送れます。本番運用には、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックがあり、クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com