가이드10분 분량
HTML 폼 제출을 이메일로 받기
메일 서버 없이 모든 폼 제출을 받은편지함으로 받으세요. mailto:와 PHP mail()이 실패하는 이유와, 스팸을 거르고 바로 답장할 수 있는 설정을 다룹니다.
HTML 폼은 스스로 이메일을 보낼 수 없습니다. 브라우저에는 메일 서버와 통신할 방법이 없기 때문입니다. 폼의 action을 폼 백엔드로 지정하면, 폼 백엔드가 POST를 받아 스팸을 거르고 저장한 뒤 여러분과 팀에게 이메일을 보냅니다. 이메일 필드 이름을 email로 지정하면 받은편지함에서 Reply(답장)를 누르는 것만으로 폼을 작성한 사람에게 바로 답장할 수 있습니다.
대부분의 사이트가 문의 폼에 바라는 것은 하나입니다. 누군가 읽는 받은편지함에 메시지가 도착하는 것입니다. 그런데 이게 보기보다 어렵습니다. HTML에는 <form> 요소가 있지만 메일을 보낼 방법은 없고, 예전의 지름길은 일을 방문자에게 떠넘기거나 스팸함에 들어갑니다. 이 가이드에서는 그 이유를 설명하고, 선택지를 솔직하게 비교한 뒤, 어떤 사이트와 스택에서든 이메일을 보내는 폼을 설정합니다.
폼이 혼자서 이메일을 보낼 수 없는 이유
방문자가 Send를 누르면 브라우저는 폼의 action에 있는 URL로 HTTP 요청을 한 번 보냅니다. 폼이 할 수 있는 일은 그것뿐입니다. 이메일을 보내려면 수신 서버가 신뢰하는 머신에서 메일 서버와 SMTP로 통신해야 합니다. 브라우저는 그런 연결을 열 수 없고, 설령 열 수 있다 해도 아무 방문자의 노트북에서 보낸 메일은 거부됩니다. 그래서 폼과 받은편지함 사이에는 항상 서버의 무언가가 있습니다. 직접 작성한 코드, 호스팅의 스크립트, 또는 서비스입니다.
폼 백엔드를 쓰면 그 무언가는 URL 하나입니다. 폼이 https://ikiform.com/f/<id>로 POST하면 나머지는 Ikiform이 처리합니다.
- 방문자가 폼을 제출하면 브라우저나 서버가 Ikiform 엔드포인트로 POST합니다.
- Ikiform은 폼 정의에 맞춰 필드를 확인하고, 스팸 점수를 매긴 뒤 저장합니다.
- 스팸 점수에 도달한 POST는 스팸으로 저장됩니다. 같은 성공 응답을 받지만 이메일도 웹훅도 보내지 않습니다.
- 그 밖의 모든 POST는 수신자마다 이메일을 한 통씩 보내며, 방문자의 주소를 회신 주소(reply-to)로 설정합니다.
- 몇 초 뒤 이메일이 받은편지함에 도착합니다. 답장을 누르면 방문자에게 회신됩니다.
폼을 받은편지함으로 받는 여섯 가지 방법
모두 누군가에게는 통하는 방법입니다. 이 표는 실제 방문자와 실제 봇이 폼을 쓰기 시작했을 때 각 방법이 치르게 하는 비용을 정리한 것입니다.
| 방법 | 장점 | 단점 |
|---|---|---|
action="mailto:you@example.com" | 서버도 계정도 필요 없고, 속성 하나면 됩니다. | 아무것도 전송되지 않습니다. 브라우저가 방문자의 메일 앱을 초안과 함께 열거나, 메일 앱이 설정되어 있지 않으면 아무 일도 일어나지 않습니다. 주소가 페이지에 그대로 노출되어 스크레이퍼에 수집됩니다. Gmail 가이드를 참고하세요. |
mail()을 쓰는 PHP 스크립트 | 서드파티 없이 거의 모든 공유 PHP 호스팅에서 동작합니다. | 호스팅의 메일 설정에 좌우되며, 여러분의 도메인에서 보냈다고 주장하는 메일은 SPF나 DMARC를 통과하지 못해 스팸함에 들어가는 경우가 많습니다. 스팸 필터링도 저장도 없고, 정적 호스팅에서는 PHP 자체가 없습니다. PHP 없이 보내기를 참고하세요. |
| EmailJS 같은 브라우저용 이메일 SDK | 서버가 필요 없습니다. 이메일 템플릿을 해당 서비스의 대시보드에서 디자인합니다. | 키와 템플릿 ID가 페이지의 JavaScript에 들어 있으므로, 누구나 자기 텍스트로 여러분의 템플릿을 보낼 수 있습니다. EmailJS 대안을 참고하세요. |
| 직접 만든 라우트와 이메일 API(Resend, Postmark, Amazon SES, SMTP를 통한 Nodemailer 등) | 완전한 제어: 제목도 HTML도 직접 정하고, 방문자에게 확인 메일도 보낼 수 있습니다. | 검증, 스팸 필터링, 레이트 리밋, 저장, 재시도를 직접 만들어야 하고, 발신 도메인을 인증해야 하며, 정적 사이트라도 서버가 필요합니다. Next.js 가이드를 참고하세요. |
| 웹 앱으로 배포한 Google Apps Script | 이미 가진 Google 계정에서 실행됩니다. | 스크립트 할당량 때문에 하루에 이메일을 보낼 수 있는 수신자 수가 제한되고, URL에 스팸 방지가 없으며, 스크립트를 직접 유지보수해야 합니다. |
| Ikiform, Formspree, Web3Forms 같은 폼 백엔드 | action에 URL 하나. 서버 없이 스팸 필터링, 저장, 이메일까지. | 이메일의 레이아웃과 제목은 서비스가 정합니다. Ikiform은 방문자에게 어떤 이메일도 보내지 않습니다. |
완전히 브랜딩된 이메일이나 방문자용 확인 메일이 필요하다면 이메일 API로 라우트를 직접 작성하세요. 받은편지함으로 메시지를 받고 답장할 수 있으면서 스팸은 막고 싶다면, 폼 백엔드가 코드가 가장 적게 듭니다. 이 가이드의 나머지 부분은 Ikiform을 사용합니다.
폼
대부분의 문의 폼은 세 필드면 충분합니다. 누구인지, 어디로 답장할지, 무엇을 원하는지입니다. 아래에서 스택을 고르세요. 선택한 스택은 이 가이드의 모든 코드 예제에 적용됩니다.
이 폼은 JavaScript 없이도 동작합니다. 브라우저가 POST하고, 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로 POST하고, 서버 오류를 각 필드 아래에 표시하며, 성공하면 폼을 답장할 주소를 다시 보여 주는 문구로 바꿔서 오타를 쉽게 찾을 수 있게 합니다.
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;
});서버 액션이 시크릿 키로 메시지를 POST합니다. 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>
);
}서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains(허용 도메인)와 사람 확인을 건너뜁니다. 그 대신 모든 POST가 서버의 IP에서 오기 때문에, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 집계됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed나 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요. 서버 자체의 fetch는 이 헤더를 보내지 않고 Ikiform은 무료 localhost 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 Origin 헤더가 있으면 그대로 전달합니다.
Next.js에서 더 자세히 알아보려면 이메일을 보내는 Next.js 문의 폼을(를) 읽어 보세요.
Zod가 전송 전에 액션 안에서 각 필드를 확인하고, 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>
)}서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains(허용 도메인)와 사람 확인을 건너뜁니다. 그 대신 모든 POST가 서버의 IP에서 오기 때문에, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 집계됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed나 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요. 서버 자체의 fetch는 이 헤더를 보내지 않고 Ikiform은 무료 localhost 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 Origin 헤더가 있으면 그대로 전달합니다.
서버 함수가 .validator()에서 FormData를 읽어 서버에서 POST하고, 답장 주소나 필드별 오류를 돌려줍니다.
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>
);
}서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains(허용 도메인)와 사람 확인을 건너뜁니다. 그 대신 모든 POST가 서버의 IP에서 오기 때문에, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 집계됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed나 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요. 서버 자체의 fetch는 이 헤더를 보내지 않고 Ikiform은 무료 localhost 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 Origin 헤더가 있으면 그대로 전달합니다.
라우트 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>
);
}서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains(허용 도메인)와 사람 확인을 건너뜁니다. 그 대신 모든 POST가 서버의 IP에서 오기 때문에, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 집계됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed나 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요. 서버 자체의 fetch는 이 헤더를 보내지 않고 Ikiform은 무료 localhost 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 Origin 헤더가 있으면 그대로 전달합니다.
기본 액션은 필드 오류와 함께 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}서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains(허용 도메인)와 사람 확인을 건너뜁니다. 그 대신 모든 POST가 서버의 IP에서 오기 때문에, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 집계됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed나 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요. 서버 자체의 fetch는 이 헤더를 보내지 않고 Ikiform은 무료 localhost 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 Origin 헤더가 있으면 그대로 전달합니다.
서버 라우트가 시크릿 키를 보관하고, 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>서버 측 버전은 시크릿 키로 POST하므로 Ikiform이 Allowed domains(허용 도메인)와 사람 확인을 건너뜁니다. 그 대신 모든 POST가 서버의 IP에서 오기 때문에, 방문자당 분당 5회인 Standard 한도가 모든 방문자를 합쳐서 집계됩니다. 서버를 거쳐 POST하는 폼에는 Relaxed나 Custom 레이트 리밋을 고르고, IKIFORM_SECRET_KEY는 서버 전용 환경 변수에 두세요. 서버 자체의 fetch는 이 헤더를 보내지 않고 Ikiform은 무료 localhost 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 Origin 헤더가 있으면 그대로 전달합니다.
자체 서버가 없으면 브라우저가 Ikiform으로 바로 POST합니다. 이 코드에는 키를 넣지 않으며, 어떤 사이트가 POST할 수 있는지는 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>
);
}같은 브라우저 측 POST를 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"이면 그 필드가 알림 이메일의 회신 주소가 됩니다. 라벨이 "Your email"이라도 이름은 그대로 두세요.page는 일반 숨김 필드입니다. 이메일에 표시되므로 메시지가 어느 페이지에서 왔는지 알 수 있습니다._gotcha는 허니팟입니다. 사람에게는 보이지 않으며, 이 칸을 채운 봇은 스팸으로 저장되고 아무에게도 이메일이 가지 않습니다._redirect는 폼의 Allowed domains 중 하나에 있는https:URL일 때만 동작합니다. 생략하고 대신 폼 설정에서 Redirect after submit을 지정하세요. 둘 다 없으면 Ikiform 자체 감사 페이지가 표시됩니다.
프레임워크 가이드에서 더 자세히 다룹니다: Next.js, Astro, SvelteKit, Nuxt, React, Vue, 일반 HTML. Webflow와 Carrd 같은 사이트 빌더에는 별도 페이지가 있고, 문의 폼 템플릿에는 주제 필드가 추가되어 있습니다.
대시보드에서도 필드를 정의하면 Ikiform은 정의와 맞지 않는 POST를 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는 알림 이메일의 제목을 바꾸지 않습니다. 웹훅과 API에meta.subject로 전달됩니다. 폼을 구분하려면 대시보드에서 "Contact, pricing page"처럼 폼마다 분명한 이름을 붙이세요. - 필드는 POST된 순서대로
name을 라벨로 표시됩니다._로 시작하는 필드는 제외됩니다. - 아주 긴 폼이나 답변은 줄여서 표시되며, 대시보드에 필드가 몇 개 더 있는지 이메일에 적혀 있습니다. 구체적인 수치는 한도 페이지에 있습니다.
- 비밀번호나 카드 번호처럼 보이는 필드는 제외되고, 그런 필드가 포함된 POST의 이메일은 검토를 위해 보류됩니다. 폼 백엔드로 자격 증명을 수집하지 마세요.
- 워크스페이스 멤버에게는 View submission 버튼이 표시됩니다. 워크스페이스 외부 사람은 필드 내용과 수신 거부 링크를 받습니다.
이메일을 받는 사람
수신자는 폼 자체가 아니라 대시보드에서 폼마다 설정합니다. 수신자를 지정하는 필드가 있으면 누구나 여러분의 폼으로 아무에게나 이메일을 보낼 수 있으므로, Ikiform에는 그런 필드가 없습니다.
| 누구 | 추가 방법 | 참고 |
|---|---|---|
| 기본값 | 할 일 없음 | 워크스페이스의 Notification email로 선택한 멤버, 선택하지 않았다면 모든 워크스페이스 소유자 |
| 선택한 멤버 | 폼의 Notifications 설정에서 체크 | 이메일 주소가 인증된 멤버만 |
| 워크스페이스 외부 사람 | Add someone outside the workspace를 누른 뒤 Send link | 확인한 뒤에만 이메일을 받습니다. 폼당 최대 5명이며, 각자 수신을 거부할 수 있습니다 |
폼마다 최대 10개 주소로 이메일을 보냅니다. 수신자마다 공유 CC가 아닌 개별 사본을 받으므로 서로의 주소를 볼 수 없습니다. 수신자의 메일함에서 반송되면 Ikiform은 한동안 그 주소로의 이메일을 멈추고, 대시보드의 해당 주소 옆에 Emails bounced를 표시합니다. 수신자 설정만으로는 할 수 없는 주제별 라우팅은 여러 수신자에서 다룹니다.
Ikiform이 아니라 작성자에게 답장하기
Ikiform은 먼저 _replyto 필드에서, 그다음 이름이 email, email_address, your_email, reply_to, contact_email인 필드에서 회신 주소를 가져옵니다. 값은 유효한 이메일 주소 하나여야 하며, 아니면 무시됩니다. 회신 주소가 설정되어 있으면 Gmail, Outlook, Apple Mail에서 Reply를 눌렀을 때 방문자에게 보내는 답장이 열리고, 답장은 여러분의 주소에서 발송됩니다.
회신 주소가 없으면 답장은 아무도 읽지 않는 notifications@ikiform.com으로 갑니다. 가장 흔히 놀라는 부분이며, 대개 필드 이름이 user_email이거나 공백이 들어간 Email Address인 경우입니다. 이름을 바꾸거나 _replyto를 추가하세요. 이메일 필드가 두 개인 폼이나 필드 이름을 자동으로 바꾸는 빌더는 회신 주소 가이드에서 다룹니다.
한꺼번에 많이 들어올 때
폼마다 시간당 최대 50통의 알림 이메일을 보내며, 생성된 지 7일이 안 된 워크스페이스는 10통입니다. 이를 넘으면 그 시간의 나머지 제출은 평소처럼 저장되고, 해당 시간이 끝난 뒤 다이제스트 이메일 한 통으로 도착합니다. 이 한도는 폼이 큰 곳에 게시되거나 봇이 필터를 통과했을 때 여러분의 받은편지함과, Ikiform 메일에 대한 메일 제공업체의 평가를 함께 보호합니다.
- 정각 기준 한 시간 동안 폼에 들어온 처음 50건의 제출은 평소처럼 각각 이메일을 보냅니다.
- 같은 시간의 이후 제출은 저장되어 대시보드에 바로 표시되지만, 건마다 이메일을 보내지는 않습니다.
- 해당 시간이 끝나면 각 수신자는 건수를 세고 대시보드로 연결되는 다이제스트 한 통을 받습니다.
다이제스트의 제목은 건수와 폼 이름이며, 필드 목록이나 회신 주소가 없으므로 해당 메시지에는 대시보드에서 답하세요. 한도를 자주 넘는다면 그 폼에는 이메일이 맞는 채널이 아닙니다. 대신 웹훅으로 Slack이나 공유 받은편지함으로 보내세요.
받은편지함에서 스팸 막기
이메일은 스팸의 비용을 키웁니다. 봇의 POST 하나하나가 누군가 열어 보는 알림이 되기 때문입니다. Ikiform은 스팸을 평소와 같은 성공 응답으로 저장하므로 봇은 아무것도 알 수 없고, 스팸에는 이메일을 보내지 않습니다. 무엇을 스팸으로 볼지는 네 가지가 결정합니다.
| 방어 수단 | 막는 대상 | 설정 |
|---|---|---|
| 허니팟 | 모든 입력을 채우는 봇 | 숨겨진 _gotcha 입력 |
| 스팸 점수 | 이름 필드의 링크, 세 개 이상의 링크, 알려진 스팸 문구, 일회용 주소, 동일한 반복 제출. 5점이 되면 스팸으로 처리 | 없음 |
| 허용 도메인 | 다른 사이트에서 온 POST | 폼 설정에 도메인 추가 |
| 사람 확인 | 실제 브라우저를 구동하는 봇 | Spam check를 Ikiform으로 설정 |
가끔 대시보드의 Spam 필터를 확인하세요. 실제 메시지가 들어가 있다면 스팸이 아님으로 표시하세요. 그때 이메일이 발송됩니다. 사람 확인을 언제 켜야 하는지를 포함해 각 신호는 스팸 문서에서 설명합니다.
이메일로 할 수 없는 것
Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 여러분과 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인은 없습니다. 이런 메일은 웹훅으로 트리거해 이메일 도구에서 보내세요. 사이트에서 "메시지를 받았습니다"라고 알려 주고 싶다면 제출 후 페이지에 표시하세요. 위 코드가 이미 그렇게 합니다.
- 공유 팀 받은편지함. 팀 주소를 수신자로 추가하거나 메일 제공업체에서 전달하세요.
- 채팅 채널. 웹훅으로 각 제출을 Slack, Discord, Microsoft Teams로 보내세요. 하루에 메시지가 몇 건 이상 오는 폼에는 채팅이 잘 맞습니다.
- 스프레드시트나 CRM. Google Sheets, 또는 Zapier나 n8n을 통한 어떤 도구든 가능합니다.
- 첨부 파일. 파일 업로드는 아직 지원하지 않습니다. 파일이 포함된 POST는
422 files_not_supported를 받습니다. 대신 공유 파일 링크를 요청하세요.
이메일 끄기
구독자가 많은 뉴스레터 가입 폼처럼 이메일이 없는 편이 나은 폼도 있습니다. 폼의 Notifications 설정에서 Email new submissions를 끄세요. 제출은 계속 저장되고 웹훅도 계속 발송됩니다.
상황별 가이드
위 설정은 어디서나 같습니다. 아래 페이지는 특정 받은편지함, 특정 스택, 또는 옮겨 가려는 도구를 다룹니다.
- PHP 없이mail()을 호출하는 contact.php 스크립트를 호스팅 엔드포인트로 POST하는 폼으로 바꾸세요. 필드 이름, 감사 페이지, 답장은 그대로 유지됩니다.
- Gmail로mailto: 링크, 앱 비밀번호, Apps Script 없이 Gmail에서 폼 제출을 받고, 라벨을 붙이고, 스팸함을 피하고, Gmail에서 답장하세요.
- 여러 수신자폼이 직접 수신자를 고르게 하지 않고도, 폼 하나를 팀 전체에 이메일로 보내거나 주제별로 메시지를 적절한 사람에게 보내세요.
- 받은편지함에서 답장하기폼 알림에서 답장을 누르면 폼을 작성한 사람에게 가도록 하세요. 어떤 필드 이름이 동작하는지, _replyto는 언제 쓰는지, 답장이 엉뚱한 곳으로 가는 이유를 다룹니다.
- EmailJS 대안문의 폼을 EmailJS에서 옮기세요. 브라우저 측 이메일 키가 위험한 이유, 코드에서 바뀌는 점, 자동 응답처럼 포기하는 기능을 다룹니다.
- Next.jsServer Action으로 Next.js 폼을 이메일로 보내는 두 가지 방법: Resend나 Nodemailer로 직접 보내기와 폼 백엔드로 전달하기를 솔직하게 비교합니다.
동작 확인하기
폼을 공개하기 전에 다음을 확인하세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도 localhost에서 보낸 테스트 제출을 매월 50건 받습니다. 공개하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번만 결제하세요. 제출 10,000건에 $49, 50,000건에 $149이며 크레딧은 만료되지 않습니다. 요금을 참고하세요.
- 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발하는 동안에는
localhost도 추가하세요. 비워 두면 어디서 온 POST든 받습니다. - 폼의 Notifications 설정에서 Email new submissions가 켜져 있고 올바른 사람이 체크되어 있는지 확인하세요.
- 페이지에서 폼을 작성해 제출하세요. 여러분의 감사 페이지나 Ikiform의 호스팅 감사 페이지로 이동합니다.
- 대시보드에서 Submissions를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
- 알림 이메일을 여세요. 제목은
New submission:뒤에 폼 이름이 붙은 형태이고, 보낸 모든 필드가 나열되어 있습니다. - Reply를 누르세요. To 줄에 Ikiform의 주소가 아니라 폼에 입력한 주소가 표시됩니다.
- 허니팟을 채워서
_gotcha=bot으로 POST하세요. 성공 응답은 받지만 제출은 스팸으로 저장되고 이메일도 웹훅도 보내지 않습니다. - 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 Spam으로 바꿔 보세요. 스팸도 실제 제출과 같은 성공 응답을 받습니다.
문제 해결
| 문제 | 해결 방법 |
|---|---|
| 대시보드에는 제출이 있는데 이메일이 오지 않음 | 먼저 스팸함이나 프로모션 폴더를 확인하고 notifications@ikiform.com을 연락처에 추가하세요. 그다음 폼의 Notifications 설정을 확인하세요. 이메일이 꺼져 있거나, 외부 주소가 아직 Waiting for confirmation 상태이거나, Emails bounced가 표시되어 있을 수 있습니다. |
| 대시보드에는 제출이 있는데 이메일도 웹훅도 오지 않음 | 스팸으로 저장된 것입니다. 스팸은 아무것도 보내지 않습니다. Spam 필터에서 열어 이유를 확인하고, 스팸이 아님으로 표시하면 이메일과 웹훅이 발송됩니다. |
| Reply가 notifications@ikiform.com으로 감 | 회신 주소를 찾지 못했습니다. 이메일 필드 이름을 email, email_address, your_email, reply_to, contact_email 중 하나로 바꾸거나, 방문자 주소가 담긴 _replyto 필드를 추가하세요. |
| 이메일이 멈추고 대신 다이제스트가 옴 | 폼이 한 시간에 50통(새 워크스페이스는 10통)을 넘었습니다. 모든 내용은 대시보드에 있습니다. Spam에서 봇이 몰려든 흔적을 찾아보거나, 제출이 많은 폼에는 웹훅을 사용하세요. |
_subject가 제목을 바꾸지 않음 | _subject는 알림 이메일의 제목을 바꾸지 않습니다. 웹훅과 API에 meta.subject로 전달됩니다. |
403 origin_not_allowed가 나거나 모든 POST가 스팸으로 감 | 페이지의 도메인이 폼의 Allowed domains에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 localhost도 추가하세요. 등록한 도메인의 하위 도메인도 허용됩니다. |
422 validation_failed | POST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 있습니다. 입력의 name을 필드 ID와 맞추세요. |
402 test_limit_reached | 워크스페이스에 플랜이 없고 이번 달 무료 localhost 테스트 50건을 다 썼습니다. 플랜을 고르거나 대시보드의 Send test를 사용하세요. |
질문
혼자서는 못 합니다. 폼은 HTTP 요청만 보내고, 메일을 보내려면 메일 서버와 통신하는 서버가 필요합니다. 폼 백엔드가 바로 그 서버입니다. 폼의 action을 폼 백엔드로 지정하면 이메일이 오므로, 사이트는 정적인 상태로 둘 수 있습니다.
mailto: action은 아무것도 보내지 않습니다. 방문자의 컴퓨터에 초안이 담긴 메일 앱을 열어 달라고 요청할 뿐이라, 메일 앱이 설정되지 않은 사람에게는 실패하고, 방문자가 직접 보내기를 눌러야 합니다. 많은 사람이 거기서 포기하고, 여러분은 그들이 시도했다는 사실조차 알 수 없습니다.
아니요. 제목은 항상 "New submission:" 뒤에 폼 이름이 붙으므로 폼마다 이름을 분명하게 지으세요. _subject 필드는 저장되어 웹훅과 API로 전달되지만 이메일은 바꾸지 않습니다.
폼당 최대 10명입니다. 폼 설정에서 체크한 워크스페이스 멤버와, 링크로 확인한 외부 주소 최대 5개입니다. 각자 개별 사본을 받습니다.
아니요. Ikiform은 제출자가 아니라 여러분과 팀에게 이메일을 보내므로 자동 응답이나 확인 이메일은 없습니다. 웹훅을 이메일 도구로 보내고 거기서 확인 메일을 보내세요.
Ikiform 자체 도메인에서 발송되므로 여러분의 도메인에 SPF나 DKIM 레코드가 필요 없습니다. 그래도 첫 메일은 스팸함이나 프로모션으로 갈 수 있으니, notifications@ikiform.com을 연락처에 추가하거나 필터를 만들어 두세요.
아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매월 50건의 테스트 제출을 보낼 수 있습니다. 공개하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수도 있습니다. 크레딧은 만료되지 않습니다.
막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.