# HTML 폼 제출을 이메일로 받기

> 메일 서버 없이 모든 폼 제출을 받은편지함으로 받으세요. mailto:와 PHP mail()이 실패하는 이유와, 스팸을 거르고 바로 답장할 수 있는 설정을 다룹니다.

2026-10-25 게시 · https://ikiform.com/ko/guides/send-form-to-email

HTML 폼은 스스로 이메일을 보낼 수 없습니다. 브라우저에는 메일 서버와 통신할 방법이 없기 때문입니다. 폼의 `action`을 폼 백엔드로 지정하면, 폼 백엔드가 POST를 받아 스팸을 거르고 저장한 뒤 여러분과 팀에게 이메일을 보냅니다. 이메일 필드 이름을 `email`로 지정하면 받은편지함에서 **Reply**(답장)를 누르는 것만으로 폼을 작성한 사람에게 바로 답장할 수 있습니다.

대부분의 사이트가 문의 폼에 바라는 것은 하나입니다. 누군가 읽는 받은편지함에 메시지가 도착하는 것입니다. 그런데 이게 보기보다 어렵습니다. HTML에는 `<form>` 요소가 있지만 메일을 보낼 방법은 없고, 예전의 지름길은 일을 방문자에게 떠넘기거나 스팸함에 들어갑니다. 이 가이드에서는 그 이유를 설명하고, 선택지를 솔직하게 비교한 뒤, 어떤 사이트와 스택에서든 이메일을 보내는 폼을 설정합니다.

## 폼이 혼자서 이메일을 보낼 수 없는 이유

방문자가 **Send**를 누르면 브라우저는 폼의 `action`에 있는 URL로 HTTP 요청을 한 번 보냅니다. 폼이 할 수 있는 일은 그것뿐입니다. 이메일을 보내려면 수신 서버가 신뢰하는 머신에서 메일 서버와 SMTP로 통신해야 합니다. 브라우저는 그런 연결을 열 수 없고, 설령 열 수 있다 해도 아무 방문자의 노트북에서 보낸 메일은 거부됩니다. 그래서 폼과 받은편지함 사이에는 항상 서버의 무언가가 있습니다. 직접 작성한 코드, 호스팅의 스크립트, 또는 서비스입니다.

폼 백엔드를 쓰면 그 무언가는 URL 하나입니다. 폼이 `https://ikiform.com/f/<id>`로 POST하면 나머지는 Ikiform이 처리합니다.

다이어그램: 폼 POST가 받은편지함의 이메일이 되기까지

1. 방문자가 폼을 제출하면 브라우저나 서버가 Ikiform 엔드포인트로 POST합니다.
2. Ikiform은 폼 정의에 맞춰 필드를 확인하고, 스팸 점수를 매긴 뒤 저장합니다.
3. 스팸 점수에 도달한 POST는 스팸으로 저장됩니다. 같은 성공 응답을 받지만 이메일도 웹훅도 보내지 않습니다.
4. 그 밖의 모든 POST는 수신자마다 이메일을 한 통씩 보내며, 방문자의 주소를 회신 주소(reply-to)로 설정합니다.
5. 몇 초 뒤 이메일이 받은편지함에 도착합니다. 답장을 누르면 방문자에게 회신됩니다.

방문자는 이메일 주소를 볼 일도, 메일 앱을 열 일도 없습니다. 페이지에 있는 것은 폼 하나뿐입니다.

## 폼을 받은편지함으로 받는 여섯 가지 방법

모두 누군가에게는 통하는 방법입니다. 이 표는 실제 방문자와 실제 봇이 폼을 쓰기 시작했을 때 각 방법이 치르게 하는 비용을 정리한 것입니다.

| 방법 | 장점 | 단점 |
| --- | --- | --- |
| `action="mailto:you@example.com"` | 서버도 계정도 필요 없고, 속성 하나면 됩니다. | 아무것도 전송되지 않습니다. 브라우저가 방문자의 메일 앱을 초안과 함께 열거나, 메일 앱이 설정되어 있지 않으면 아무 일도 일어나지 않습니다. 주소가 페이지에 그대로 노출되어 스크레이퍼에 수집됩니다. [Gmail 가이드](https://ikiform.com/ko/guides/send-form-to-email/gmail.md)를 참고하세요. |
| `mail()`을 쓰는 PHP 스크립트 | 서드파티 없이 거의 모든 공유 PHP 호스팅에서 동작합니다. | 호스팅의 메일 설정에 좌우되며, 여러분의 도메인에서 보냈다고 주장하는 메일은 SPF나 DMARC를 통과하지 못해 스팸함에 들어가는 경우가 많습니다. 스팸 필터링도 저장도 없고, 정적 호스팅에서는 PHP 자체가 없습니다. [PHP 없이 보내기](https://ikiform.com/ko/guides/send-form-to-email/without-php.md)를 참고하세요. |
| EmailJS 같은 브라우저용 이메일 SDK | 서버가 필요 없습니다. 이메일 템플릿을 해당 서비스의 대시보드에서 디자인합니다. | 키와 템플릿 ID가 페이지의 JavaScript에 들어 있으므로, 누구나 자기 텍스트로 여러분의 템플릿을 보낼 수 있습니다. [EmailJS 대안](https://ikiform.com/ko/guides/send-form-to-email/emailjs-alternative.md)을 참고하세요. |
| 직접 만든 라우트와 이메일 API(Resend, Postmark, Amazon SES, SMTP를 통한 Nodemailer 등) | 완전한 제어: 제목도 HTML도 직접 정하고, 방문자에게 확인 메일도 보낼 수 있습니다. | 검증, 스팸 필터링, 레이트 리밋, 저장, 재시도를 직접 만들어야 하고, 발신 도메인을 인증해야 하며, 정적 사이트라도 서버가 필요합니다. [Next.js 가이드](https://ikiform.com/ko/guides/send-form-to-email/nextjs.md)를 참고하세요. |
| 웹 앱으로 배포한 Google Apps Script | 이미 가진 Google 계정에서 실행됩니다. | 스크립트 할당량 때문에 하루에 이메일을 보낼 수 있는 수신자 수가 제한되고, URL에 스팸 방지가 없으며, 스크립트를 직접 유지보수해야 합니다. |
| Ikiform, [Formspree](https://ikiform.com/alternatives/formspree), [Web3Forms](https://ikiform.com/alternatives/web3forms) 같은 폼 백엔드 | `action`에 URL 하나. 서버 없이 스팸 필터링, 저장, 이메일까지. | 이메일의 레이아웃과 제목은 서비스가 정합니다. Ikiform은 방문자에게 어떤 이메일도 보내지 않습니다. |

완전히 브랜딩된 이메일이나 방문자용 확인 메일이 필요하다면 이메일 API로 라우트를 직접 작성하세요. 받은편지함으로 메시지를 받고 답장할 수 있으면서 스팸은 막고 싶다면, 폼 백엔드가 코드가 가장 적게 듭니다. 이 가이드의 나머지 부분은 Ikiform을 사용합니다.

## 폼

대부분의 문의 폼은 세 필드면 충분합니다. 누구인지, 어디로 답장할지, 무엇을 원하는지입니다. 아래에서 스택을 고르세요. 선택한 스택은 이 가이드의 모든 코드 예제에 적용됩니다.

**HTML**

이 폼은 JavaScript 없이도 동작합니다. 브라우저가 POST하고, Ikiform이 이메일을 보내고, 방문자는 `_redirect`의 페이지로 이동합니다. 각 필드 아래에는 아래 스크립트가 쓸 빈 오류 영역이 있습니다.

`contact.html`:

```html
<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하고, 서버 오류를 각 필드 아래에 표시하며, 성공하면 폼을 답장할 주소를 다시 보여 주는 문구로 바꿔서 오타를 쉽게 찾을 수 있게 합니다.

`contact.js`:

```js
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;
});
```

**Next.js**

서버 액션이 시크릿 키로 메시지를 POST합니다. Ikiform의 `error.fields`를 필드마다 메시지 하나로 매핑하고 입력한 값을 돌려주므로, 오류가 나도 방문자가 쓴 내용이 사라지지 않습니다.

`app/contact/actions.ts`:

```ts
"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 };
}
```

`app/contact/contact-form.tsx`:

```tsx
"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 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요. 서버 자체의 `fetch`는 이 헤더를 보내지 않고 Ikiform은 무료 `localhost` 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 `Origin` 헤더가 있으면 그대로 전달합니다.

Next.js에서 더 자세히 알아보려면 [이메일을 보내는 Next.js 문의 폼](https://ikiform.com/ko/guides/send-form-to-email/nextjs.md)을(를) 읽어 보세요.

**Astro**

Zod가 전송 전에 액션 안에서 각 필드를 확인하고, `isInputError`로 필드별 메시지를 얻을 수 있습니다. 페이지는 요청 시 렌더링되어야 하므로 `prerender = false`를 유지하고 어댑터를 추가하세요.

`src/actions/index.ts`:

```ts
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 };
    },
  }),
};
```

`src/pages/contact.astro`:

```astro
---
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 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요. 서버 자체의 `fetch`는 이 헤더를 보내지 않고 Ikiform은 무료 `localhost` 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 `Origin` 헤더가 있으면 그대로 전달합니다.

**TanStack Start**

서버 함수가 `.validator()`에서 `FormData`를 읽어 서버에서 POST하고, 답장 주소나 필드별 오류를 돌려줍니다.

`src/routes/contact.tsx`:

```tsx
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 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요. 서버 자체의 `fetch`는 이 헤더를 보내지 않고 Ikiform은 무료 `localhost` 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 `Origin` 헤더가 있으면 그대로 전달합니다.

**React Router**

라우트 `action`이 메시지를 전달하고 Ikiform의 필드 오류를 `actionData`로 돌려주며, Ikiform의 상태 코드는 `data()`로 유지합니다.

`app/routes/contact.tsx`:

```tsx
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 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요. 서버 자체의 `fetch`는 이 헤더를 보내지 않고 Ikiform은 무료 `localhost` 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 `Origin` 헤더가 있으면 그대로 전달합니다.

**SvelteKit**

기본 액션은 필드 오류와 함께 `fail()`을 반환합니다. `update({ reset: false })`는 서버가 오류로 응답했을 때 방문자가 입력한 내용을 유지합니다. `$app/env/private`에서 읽을 수 있도록 `src/env.ts`에서 `defineEnvVars`로 `IKIFORM_SECRET_KEY`를 선언하세요.

`src/routes/contact/+page.server.ts`:

```ts
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;
```

`src/routes/contact/+page.svelte`:

```svelte
<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 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요. 서버 자체의 `fetch`는 이 헤더를 보내지 않고 Ikiform은 무료 `localhost` 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 `Origin` 헤더가 있으면 그대로 전달합니다.

**Nuxt**

서버 라우트가 시크릿 키를 보관하고, Ikiform의 메시지는 `createError`의 `message`로, 필드 오류는 `data`로 넘깁니다. 클라이언트에서는 `$fetch`가 둘을 `data.message`와 `data.data.fields`로 돌려줍니다. Nuxt는 `NUXT_IKIFORM_SECRET_KEY`에서 `runtimeConfig.ikiformSecretKey`를 채웁니다.

`server/api/contact.post.ts`:

```ts
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 };
});
```

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    ikiformSecretKey: "",
  },
});
```

`app/components/ContactForm.vue`:

```vue
<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 [레이트 리밋](https://ikiform.com/ko/docs/rate-limits.md)을 고르고, `IKIFORM_SECRET_KEY`는 서버 전용 환경 변수에 두세요. 서버 자체의 `fetch`는 이 헤더를 보내지 않고 Ikiform은 무료 `localhost` 테스트 제출을 이 헤더로 집계하므로, 각 서버 예제는 브라우저의 `Origin` 헤더가 있으면 그대로 전달합니다.

**React**

자체 서버가 없으면 브라우저가 Ikiform으로 바로 POST합니다. 이 코드에는 키를 넣지 않으며, 어떤 사이트가 POST할 수 있는지는 **Allowed domains**가 정합니다.

`src/ContactForm.tsx`:

```tsx
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**

같은 브라우저 측 POST를 Vue 컴포넌트로 작성한 것입니다. 폼이 `action`을 유지하므로 스크립트 로드에 실패해도 전송됩니다.

`src/components/ContactForm.vue`:

```vue
<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](https://ikiform.com/ko/docs/guides/nextjs.md), [Astro](https://ikiform.com/ko/docs/guides/astro.md), [SvelteKit](https://ikiform.com/ko/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/ko/docs/guides/nuxt.md), [React](https://ikiform.com/ko/docs/guides/react.md), [Vue](https://ikiform.com/ko/docs/guides/vue.md), [일반 HTML](https://ikiform.com/ko/docs/html.md). [Webflow](https://ikiform.com/ko/docs/guides/webflow.md)와 [Carrd](https://ikiform.com/ko/docs/guides/carrd.md) 같은 사이트 빌더에는 별도 페이지가 있고, [문의 폼 템플릿](https://ikiform.com/forms/contact-form)에는 주제 필드가 추가되어 있습니다.

대시보드에서도 필드를 정의하면 Ikiform은 정의와 맞지 않는 POST를 `422 validation_failed`와 필드별 메시지로 거부하고, 위 코드는 그 메시지를 각 입력 옆에 표시합니다. 여기서 직접 해 보세요. 같은 검증기가 브라우저에서 실행됩니다.

폼: Contact

- **이름** (`name`, text, 필수)
- **이메일** (`email`, email, 필수)
- **메시지** (`message`, textarea, 필수)

이 정의를 폼의 **Fields** 탭에 붙여 넣으세요. 정의가 없으면 Ikiform은 어떤 필드 이름이든 받아서 도착한 내용을 그대로 이메일로 보냅니다.

## 이메일은 이렇게 생겼습니다

승인된 제출마다 읽기 쉬운 단순한 이메일이 발송됩니다. 다음은 그 텍스트 버전입니다.

`알림 이메일`:

```text
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`을 라벨로 표시됩니다. `_`로 시작하는 필드는 제외됩니다.
- 아주 긴 폼이나 답변은 줄여서 표시되며, 대시보드에 필드가 몇 개 더 있는지 이메일에 적혀 있습니다. 구체적인 수치는 [한도 페이지](https://ikiform.com/ko/docs/limits.md#notifications)에 있습니다.
- 비밀번호나 카드 번호처럼 보이는 필드는 제외되고, 그런 필드가 포함된 POST의 이메일은 검토를 위해 보류됩니다. 폼 백엔드로 자격 증명을 수집하지 마세요.
- 워크스페이스 멤버에게는 **View submission** 버튼이 표시됩니다. 워크스페이스 외부 사람은 필드 내용과 수신 거부 링크를 받습니다.

## 이메일을 받는 사람

수신자는 폼 자체가 아니라 대시보드에서 폼마다 설정합니다. 수신자를 지정하는 필드가 있으면 누구나 여러분의 폼으로 아무에게나 이메일을 보낼 수 있으므로, Ikiform에는 그런 필드가 없습니다.

| 누구 | 추가 방법 | 참고 |
| --- | --- | --- |
| 기본값 | 할 일 없음 | 워크스페이스의 **Notification email**로 선택한 멤버, 선택하지 않았다면 모든 워크스페이스 소유자 |
| 선택한 멤버 | 폼의 **Notifications** 설정에서 체크 | 이메일 주소가 인증된 멤버만 |
| 워크스페이스 외부 사람 | **Add someone outside the workspace**를 누른 뒤 **Send link** | 확인한 뒤에만 이메일을 받습니다. 폼당 최대 5명이며, 각자 수신을 거부할 수 있습니다 |

폼마다 최대 10개 주소로 이메일을 보냅니다. 수신자마다 공유 CC가 아닌 개별 사본을 받으므로 서로의 주소를 볼 수 없습니다. 수신자의 메일함에서 반송되면 Ikiform은 한동안 그 주소로의 이메일을 멈추고, 대시보드의 해당 주소 옆에 **Emails bounced**를 표시합니다. 수신자 설정만으로는 할 수 없는 주제별 라우팅은 [여러 수신자](https://ikiform.com/ko/guides/send-form-to-email/multiple-recipients.md)에서 다룹니다.

## 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`를 추가하세요. 이메일 필드가 두 개인 폼이나 필드 이름을 자동으로 바꾸는 빌더는 [회신 주소 가이드](https://ikiform.com/ko/guides/send-form-to-email/reply-to.md)에서 다룹니다.

## 한꺼번에 많이 들어올 때

폼마다 시간당 최대 50통의 알림 이메일을 보내며, 생성된 지 7일이 안 된 워크스페이스는 10통입니다. 이를 넘으면 그 시간의 나머지 제출은 평소처럼 저장되고, 해당 시간이 끝난 뒤 다이제스트 이메일 한 통으로 도착합니다. 이 한도는 폼이 큰 곳에 게시되거나 봇이 필터를 통과했을 때 여러분의 받은편지함과, Ikiform 메일에 대한 메일 제공업체의 평가를 함께 보호합니다.

다이어그램: 바쁜 시간대에 알림 이메일은 이렇게 처리됩니다

1. 정각 기준 한 시간 동안 폼에 들어온 처음 50건의 제출은 평소처럼 각각 이메일을 보냅니다.
2. 같은 시간의 이후 제출은 저장되어 대시보드에 바로 표시되지만, 건마다 이메일을 보내지는 않습니다.
3. 해당 시간이 끝나면 각 수신자는 건수를 세고 대시보드로 연결되는 다이제스트 한 통을 받습니다.

한도는 폼마다 집계되므로 한 폼에 몰려도 다른 폼은 느려지지 않습니다. 웹훅에는 한도가 없으며 모든 제출마다 계속 발송됩니다.

다이제스트의 제목은 건수와 폼 이름이며, 필드 목록이나 회신 주소가 없으므로 해당 메시지에는 대시보드에서 답하세요. 한도를 자주 넘는다면 그 폼에는 이메일이 맞는 채널이 아닙니다. 대신 [웹훅](https://ikiform.com/ko/docs/webhooks.md)으로 [Slack](https://ikiform.com/blog/form-submissions-to-slack.md)이나 공유 받은편지함으로 보내세요.

## 받은편지함에서 스팸 막기

이메일은 스팸의 비용을 키웁니다. 봇의 POST 하나하나가 누군가 열어 보는 알림이 되기 때문입니다. Ikiform은 스팸을 평소와 같은 성공 응답으로 저장하므로 봇은 아무것도 알 수 없고, 스팸에는 이메일을 보내지 않습니다. 무엇을 스팸으로 볼지는 네 가지가 결정합니다.

| 방어 수단 | 막는 대상 | 설정 |
| --- | --- | --- |
| 허니팟 | 모든 입력을 채우는 봇 | 숨겨진 `_gotcha` 입력 |
| 스팸 점수 | 이름 필드의 링크, 세 개 이상의 링크, 알려진 스팸 문구, 일회용 주소, 동일한 반복 제출. 5점이 되면 스팸으로 처리 | 없음 |
| 허용 도메인 | 다른 사이트에서 온 POST | 폼 설정에 도메인 추가 |
| 사람 확인 | 실제 브라우저를 구동하는 봇 | **Spam check**를 **Ikiform**으로 설정 |

가끔 대시보드의 **Spam** 필터를 확인하세요. 실제 메시지가 들어가 있다면 스팸이 아님으로 표시하세요. 그때 이메일이 발송됩니다. 사람 확인을 언제 켜야 하는지를 포함해 각 신호는 [스팸 문서](https://ikiform.com/ko/docs/spam.md)에서 설명합니다.

## 이메일로 할 수 없는 것

Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 여러분과 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인은 없습니다. 이런 메일은 [웹훅](https://ikiform.com/ko/docs/webhooks.md)으로 트리거해 이메일 도구에서 보내세요. 사이트에서 "메시지를 받았습니다"라고 알려 주고 싶다면 제출 후 페이지에 표시하세요. 위 코드가 이미 그렇게 합니다.

- **공유 팀 받은편지함.** 팀 주소를 수신자로 추가하거나 메일 제공업체에서 전달하세요.
- **채팅 채널.** 웹훅으로 각 제출을 [Slack](https://ikiform.com/blog/form-submissions-to-slack.md), [Discord](https://ikiform.com/blog/form-submissions-to-discord.md), [Microsoft Teams](https://ikiform.com/blog/form-submissions-to-microsoft-teams.md)로 보내세요. 하루에 메시지가 몇 건 이상 오는 폼에는 채팅이 잘 맞습니다.
- **스프레드시트나 CRM.** [Google Sheets](https://ikiform.com/blog/form-submissions-to-google-sheets.md), 또는 [Zapier](https://ikiform.com/blog/form-submissions-to-zapier.md)나 [n8n](https://ikiform.com/blog/form-submissions-to-n8n.md)을 통한 어떤 도구든 가능합니다.
- **첨부 파일.** 파일 업로드는 아직 지원하지 않습니다. 파일이 포함된 POST는 `422 files_not_supported`를 받습니다. 대신 공유 파일 링크를 요청하세요.

### 이메일 끄기

구독자가 많은 뉴스레터 가입 폼처럼 이메일이 없는 편이 나은 폼도 있습니다. 폼의 **Notifications** 설정에서 **Email new submissions**를 끄세요. 제출은 계속 저장되고 웹훅도 계속 발송됩니다.

> 새 워크스페이스는 한도가 더 낮게 시작합니다. 처음 7일 동안은 폼당 시간당 10통입니다. 스크립트로 테스트하고 있다면 나중보다 다이제스트가 더 빨리 올 수 있습니다.

## 상황별 가이드

위 설정은 어디서나 같습니다. 아래 페이지는 특정 받은편지함, 특정 스택, 또는 옮겨 가려는 도구를 다룹니다.

- [PHP 없이](https://ikiform.com/ko/guides/send-form-to-email/without-php.md): mail()을 호출하는 contact.php 스크립트를 호스팅 엔드포인트로 POST하는 폼으로 바꾸세요. 필드 이름, 감사 페이지, 답장은 그대로 유지됩니다.
- [Gmail로](https://ikiform.com/ko/guides/send-form-to-email/gmail.md): mailto: 링크, 앱 비밀번호, Apps Script 없이 Gmail에서 폼 제출을 받고, 라벨을 붙이고, 스팸함을 피하고, Gmail에서 답장하세요.
- [여러 수신자](https://ikiform.com/ko/guides/send-form-to-email/multiple-recipients.md): 폼이 직접 수신자를 고르게 하지 않고도, 폼 하나를 팀 전체에 이메일로 보내거나 주제별로 메시지를 적절한 사람에게 보내세요.
- [받은편지함에서 답장하기](https://ikiform.com/ko/guides/send-form-to-email/reply-to.md): 폼 알림에서 답장을 누르면 폼을 작성한 사람에게 가도록 하세요. 어떤 필드 이름이 동작하는지, _replyto는 언제 쓰는지, 답장이 엉뚱한 곳으로 가는 이유를 다룹니다.
- [EmailJS 대안](https://ikiform.com/ko/guides/send-form-to-email/emailjs-alternative.md): 문의 폼을 EmailJS에서 옮기세요. 브라우저 측 이메일 키가 위험한 이유, 코드에서 바뀌는 점, 자동 응답처럼 포기하는 기능을 다룹니다.
- [Next.js](https://ikiform.com/ko/guides/send-form-to-email/nextjs.md): Server Action으로 Next.js 폼을 이메일로 보내는 두 가지 방법: Resend나 Nodemailer로 직접 보내기와 폼 백엔드로 전달하기를 솔직하게 비교합니다.

## 동작 확인하기

폼을 공개하기 전에 다음을 확인하세요. Ikiform에는 무료 플랜이 없습니다. 테스트는 무료입니다. 플랜이 없는 워크스페이스도 `localhost`에서 보낸 테스트 제출을 매월 50건 받습니다. 공개하려면 제출 500건에 월 $6부터인 Pro를 고르거나, 한 번만 결제하세요. 제출 10,000건에 $49, 50,000건에 $149이며 크레딧은 만료되지 않습니다. [요금](https://ikiform.com/ko/pricing)을 참고하세요.

1. 폼의 **Allowed domains**에 사이트 도메인을 추가하고, 개발하는 동안에는 `localhost`도 추가하세요. 비워 두면 어디서 온 POST든 받습니다.
2. 폼의 **Notifications** 설정에서 **Email new submissions**가 켜져 있고 올바른 사람이 체크되어 있는지 확인하세요.
3. 페이지에서 폼을 작성해 제출하세요. 여러분의 감사 페이지나 Ikiform의 호스팅 감사 페이지로 이동합니다.
4. 대시보드에서 **Submissions**를 여세요. 보낸 모든 필드가 담긴 새 제출이 있습니다.
5. 알림 이메일을 여세요. 제목은 `New submission:` 뒤에 폼 이름이 붙은 형태이고, 보낸 모든 필드가 나열되어 있습니다.
6. **Reply**를 누르세요. **To** 줄에 Ikiform의 주소가 아니라 폼에 입력한 주소가 표시됩니다.
7. 허니팟을 채워서 `_gotcha=bot`으로 POST하세요. 성공 응답은 받지만 제출은 스팸으로 저장되고 이메일도 웹훅도 보내지 않습니다.
8. 아무것도 도착하지 않았나요? 대시보드의 상태 필터를 **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**를 사용하세요. |

## 질문

### HTML 폼은 서버 없이 이메일을 보낼 수 있나요?

혼자서는 못 합니다. 폼은 HTTP 요청만 보내고, 메일을 보내려면 메일 서버와 통신하는 서버가 필요합니다. 폼 백엔드가 바로 그 서버입니다. 폼의 action을 폼 백엔드로 지정하면 이메일이 오므로, 사이트는 정적인 상태로 둘 수 있습니다.

### 폼 action에 mailto:를 쓰면 안 되나요?

mailto: action은 아무것도 보내지 않습니다. 방문자의 컴퓨터에 초안이 담긴 메일 앱을 열어 달라고 요청할 뿐이라, 메일 앱이 설정되지 않은 사람에게는 실패하고, 방문자가 직접 보내기를 눌러야 합니다. 많은 사람이 거기서 포기하고, 여러분은 그들이 시도했다는 사실조차 알 수 없습니다.

### 알림 이메일의 제목을 바꿀 수 있나요?

아니요. 제목은 항상 "New submission:" 뒤에 폼 이름이 붙으므로 폼마다 이름을 분명하게 지으세요. _subject 필드는 저장되어 웹훅과 API로 전달되지만 이메일은 바꾸지 않습니다.

### 몇 명까지 이메일을 받을 수 있나요?

폼당 최대 10명입니다. 폼 설정에서 체크한 워크스페이스 멤버와, 링크로 확인한 외부 주소 최대 5개입니다. 각자 개별 사본을 받습니다.

### Ikiform이 폼을 제출한 사람에게 이메일을 보낼 수 있나요?

아니요. Ikiform은 제출자가 아니라 여러분과 팀에게 이메일을 보내므로 자동 응답이나 확인 이메일은 없습니다. 웹훅을 이메일 도구로 보내고 거기서 확인 메일을 보내세요.

### 폼 이메일이 스팸함에 들어가나요?

Ikiform 자체 도메인에서 발송되므로 여러분의 도메인에 SPF나 DKIM 레코드가 필요 없습니다. 그래도 첫 메일은 스팸함이나 프로모션으로 갈 수 있으니, notifications@ikiform.com을 연락처에 추가하거나 필터를 만들어 두세요.

### Ikiform은 무료인가요?

아니요, 무료 플랜은 없습니다. 테스트는 무료입니다. 결제 전에 localhost에서 매월 50건의 테스트 제출을 보낼 수 있습니다. 공개하려면 Pro가 제출 500건에 월 $6부터이며, 한 번만 결제해 제출 10,000건에 $49 또는 50,000건에 $149를 낼 수도 있습니다. 크레딧은 만료되지 않습니다.

## 가이드 더 보기

- [PHP 없이](https://ikiform.com/ko/guides/send-form-to-email/without-php.md): mail()을 호출하는 contact.php 스크립트를 호스팅 엔드포인트로 POST하는 폼으로 바꾸세요. 필드 이름, 감사 페이지, 답장은 그대로 유지됩니다.
- [Gmail로](https://ikiform.com/ko/guides/send-form-to-email/gmail.md): mailto: 링크, 앱 비밀번호, Apps Script 없이 Gmail에서 폼 제출을 받고, 라벨을 붙이고, 스팸함을 피하고, Gmail에서 답장하세요.
- [여러 수신자](https://ikiform.com/ko/guides/send-form-to-email/multiple-recipients.md): 폼이 직접 수신자를 고르게 하지 않고도, 폼 하나를 팀 전체에 이메일로 보내거나 주제별로 메시지를 적절한 사람에게 보내세요.
- [받은편지함에서 답장하기](https://ikiform.com/ko/guides/send-form-to-email/reply-to.md): 폼 알림에서 답장을 누르면 폼을 작성한 사람에게 가도록 하세요. 어떤 필드 이름이 동작하는지, _replyto는 언제 쓰는지, 답장이 엉뚱한 곳으로 가는 이유를 다룹니다.
- [EmailJS 대안](https://ikiform.com/ko/guides/send-form-to-email/emailjs-alternative.md): 문의 폼을 EmailJS에서 옮기세요. 브라우저 측 이메일 키가 위험한 이유, 코드에서 바뀌는 점, 자동 응답처럼 포기하는 기능을 다룹니다.
- [Next.js](https://ikiform.com/ko/guides/send-form-to-email/nextjs.md): Server Action으로 Next.js 폼을 이메일로 보내는 두 가지 방법: Resend나 Nodemailer로 직접 보내기와 폼 백엔드로 전달하기를 솔직하게 비교합니다.
- [문의 폼 스팸 막기](https://ikiform.com/ko/guides/spam-protection.md): 허니팟, 시간 검사, 콘텐츠 점수, 레이트 리밋, 필요한 곳에만 사람 확인까지, 실제 방문자에게 부담 없는 여러 겹의 검사로 문의 폼 스팸을 막으세요.
- [빌더 폼 한계 넘어서기](https://ikiform.com/ko/guides/website-builder-forms.md): Webflow, Netlify, Carrd, Framer 폼이 한도나 빠진 기능에 부딪히면, 디자인한 폼은 그대로 두고 대신 폼 백엔드를 가리키게 하세요.
- [웹사이트나 앱용 피드백 폼](https://ikiform.com/ko/guides/feedback-form.md): 어떤 페이지나 앱 안에든 피드백 폼을 둡니다. 평점, 코멘트, 보낸 페이지, 스팸 필터링, 낮은 점수 알림까지 다룹니다.
