폼을 이메일로 보내기

폼을 이메일로 보내기4분 분량

키를 클라이언트에 두지 않는 EmailJS 대안

문의 폼을 EmailJS에서 옮기세요. 브라우저 측 이메일 키가 위험한 이유, 코드에서 바뀌는 점, 자동 응답처럼 포기하는 기능을 다룹니다.

Preet SutharIkiform 창업자

EmailJS는 브라우저에서 이메일을 보내므로 서비스 ID, 템플릿 ID, 퍼블릭 키가 페이지의 JavaScript에 담겨 누구나 복사할 수 있습니다. 폼 백엔드는 브라우저에 키가 필요 없습니다. 폼이 URL로 POST하면 백엔드가 스팸을 거르고 메시지를 저장한 뒤 이메일을 보냅니다. 방문자에게 보내는 EmailJS의 자동 응답은 잃게 되며, 이는 대신 여러분의 이메일 도구에서 보내면 됩니다.

이 경우가 다른 점

EmailJS가 인기 있는 데는 이유가 있습니다. 서버 없이 정적 사이트에서 실제 이메일을 보냅니다. 이를 위해 브라우저가 서비스 ID, 템플릿 ID, 퍼블릭 키라는 세 값으로 EmailJS를 직접 호출합니다. 이 값들은 번들에 들어 있어 페이지 소스나 네트워크 탭에서 읽을 수 있습니다.

EmailJS의 FAQ에 따르면 이 값을 복사한 사람은 임의의 이메일이 아니라 여러분의 템플릿과 콘텐츠만 보낼 수 있습니다. 템플릿의 고정 텍스트에 대해서는 맞는 말입니다. 하지만 템플릿 변수는 호출하는 쪽이 채우므로, 복사한 키로 이름, 메시지, 링크를 마음대로 넣은 템플릿을 할당량이 허락하는 만큼 보낼 수 있습니다. 그리고 방문자 주소로 보내는 자동 응답처럼 템플릿의 To가 변수라면 수신자까지 호출하는 쪽이 고릅니다.

브라우저 이메일 키와 폼 엔드포인트로 각각 누가 보낼 수 있는가브라우저 이메일 SDK폼 엔드포인트여러분의 페이지서비스, 템플릿, 퍼블릭 키누구의 스크립트든세 값을 복사여러분의 템플릿그들의 텍스트로, 템플릿의To에게여러분의 페이지폼 ID, 비밀 아님Ikiform스팸 점수, 레이트 리밋, 허용도메인여러분의 수신자에게만대시보드에서 설정소스 보기전송전송이메일브라우저 이메일 키와 폼 엔드포인트로 각각 누가 보낼 수 있는가브라우저 이메일 SDK폼 엔드포인트여러분의 페이지서비스, 템플릿, 퍼블릭 키누구의 스크립트든세 값을 복사여러분의 템플릿그들의 텍스트로, 템플릿의 To에게여러분의 페이지폼 ID, 비밀 아님Ikiform스팸 점수, 레이트 리밋, 허용 도메인여러분의 수신자에게만대시보드에서 설정소스 보기전송전송이메일
  1. 브라우저 이메일 SDK를 쓰면 보내는 데 필요한 모든 것이 페이지에 있습니다. 다른 곳의 스크립트가 ID와 키를 복사해 자기 텍스트로 여러분의 템플릿을 할당량까지 보낼 수 있습니다.
  2. 템플릿의 수신자가 변수에서 온다면 복사한 키로 받을 사람까지 고를 수 있습니다.
  3. 폼 엔드포인트를 쓰면 페이지에는 폼 ID만 있습니다. POST는 스팸 필터링과 레이트 리밋을 거치고, 이메일은 대시보드에서 설정한 수신자에게만 갑니다.
폼 ID도 비밀은 아닙니다. 차이는 모르는 사람이 그걸로 할 수 있는 일입니다. 여러분의 폼을 제출할 수는 있어도 여러분의 이메일을 보낼 수는 없습니다.

EmailJS에는 키를 쓸 수 있는 도메인을 제한하거나 템플릿에 캡차를 추가하는 등 이를 좁히는 설정이 있습니다. 계속 쓴다면 플랜에 어떤 설정이 포함되는지 확인하고 켜 두세요. 아래 비교는 설계에 관한 것이지, 서비스에 대한 평가가 아닙니다.

ItemEmailJSIkiform
브라우저가 가진 것서비스 ID, 템플릿 ID, 퍼블릭 키폼 ID
수신자를 정하는 쪽템플릿(변수를 읽을 수 있음)대시보드 설정만
스팸 필터링추가했다면 템플릿별 캡차모든 폼에 허니팟, 스팸 점수, 레이트 리밋, 그리고 선택적 사람 확인
이후 메시지 보관받은편지함받은편지함은 물론 대시보드, CSV 내보내기, API
방문자에게 보내는 이메일예, 자동 응답 템플릿으로아니요
직접 디자인한 이메일예, 해당 서비스 편집기의 템플릿아니요, 제목이 New submission: <form name>인 고정 레이아웃

자동 응답이나 브랜딩된 이메일이 핵심이라면 EmailJS나 이메일 API를 쓰는 자체 서버가 더 잘 맞습니다. 메시지를 안전하게 받은편지함으로 받고 어딘가에 보관하고 싶다면 폼 백엔드가 맞습니다.

폼 옮기기

EmailJS의 문의 폼 튜토리얼을 바탕으로 한 전형적인 React용 EmailJS 폼입니다.

ContactForm.tsx, 변경 전
import emailjs from "@emailjs/browser";
import { type FormEvent, useRef, useState } from "react";

export function ContactForm() {
  const form = useRef<HTMLFormElement>(null);
  const [status, setStatus] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!form.current) return;
    try {
      await emailjs.sendForm("service_abc123", "template_xyz789", form.current, {
        publicKey: "aBcDeFgHiJkLmNoPq",
      });
      setStatus("Message sent.");
    } catch {
      setStatus("Couldn't send your message.");
    }
  }

  return (
    <form ref={form} onSubmit={onSubmit}>
      <input name="user_name" required />
      <input name="user_email" type="email" required />
      <textarea name="message" required />
      <button>Send</button>
      <p role="status">{status}</p>
    </form>
  );
}

같은 폼이 @ikiform/sdk의 submit()으로 Ikiform에 POST하는 모습입니다.

ContactForm.tsx, 변경 후
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

export function ContactForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
  const [fields, setFields] = useState<Record<string, string[]>>({});
  const [error, setError] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setFields({});
    setError("");
    const result = await submit(FORM_ID, new FormData(event.currentTarget));
    if (result.ok) {
      setStatus("sent");
      return;
    }
    setFields(result.error.fields ?? {});
    setError(result.error.code === "network_error" ? "You're offline. Try again." : result.error.message);
    setStatus("idle");
  }

  if (status === "sent") return <p role="status">Message sent. We'll reply by email.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="user_name">Name</label>
      <input id="user_name" name="user_name" required />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" required />
      {fields.email ? <p role="alert">{fields.email[0]}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" required />
      {fields.message ? <p role="alert">{fields.message[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {error && !Object.keys(fields).length ? <p role="alert">{error}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send"}</button>
    </form>
  );
}
  • @emailjs/browser와 세 ID는 사라집니다. 패키지를 제거하고, 새 폼이 동작하면 EmailJS 템플릿이나 서비스를 삭제해서 캐시된 페이지 사본이 더 이상 보내지 못하게 하세요.
  • user_email은 email이 됩니다. EmailJS는 이름을 신경 쓰지 않지만 Ikiform은 email이라는 필드를 회신 주소로 씁니다. 알림은 필드를 도착한 그대로 나열하므로 다른 이름은 그대로 둬도 됩니다.
  • submit()은 절대 예외를 던지지 않습니다. 오류 객체를 반환하므로, 폼 정의에서 나온 필드 오류가 해당 입력 아래에 표시됩니다.
  • 허니팟이 새로 추가되었습니다. EmailJS에는 없었고, 이 숨김 입력은 설정 없이 가장 단순한 봇을 잡아냅니다.
  • 다른 사이트에서 온 POST가 스팸으로 표시되도록 Allowed domains에 도메인을 추가하세요.
사람 확인을 쓰나요? 폼의 Spam check를 Ikiform으로 설정하고, submit 전에 challenge(FORM_ID)를 호출해 토큰을 turnstileToken으로 넘기세요. React 가이드에서 보여 줍니다.

자동 응답을 쓰고 있었다면

Ikiform은 폼을 작성한 사람에게 이메일을 보내지 않습니다. 알림 이메일은 여러분과 팀에게만 가므로 자동 응답, 확인 이메일, 더블 옵트인은 없습니다. 이런 메일은 웹훅으로 트리거해 이메일 도구에서 보내세요. Resend나 Postmark처럼 서버에서 제어하는 발송 서비스가 submission.created 웹훅마다 보낼 수 있으며, 방문자의 주소는 브라우저가 아니라 신뢰할 수 있는 페이로드에서 가져옵니다. 응용할 수 있는 핸들러는 여러 수신자 가이드에 있습니다.

정말 필요한지 생각해 보세요. 문의 폼은 페이지의 감사 메시지로 제출이 됐다는 걸 방문자에게 알릴 수 있고, 한 시간 뒤 여러분이 직접 보내는 답장이 템플릿보다 낫습니다.

동작 확인하기

  1. 폼의 Allowed domains에 사이트 도메인을 추가하고, 개발하는 동안에는 localhost도 추가하세요. 비워 두면 어디서 온 POST든 받습니다.
  2. 빌드된 사이트에서 emailjs와 예전 서비스 ID를 검색하세요. 아무것도 나오지 않아야 합니다.
  3. 페이지에서 폼을 작성해 제출하세요. 여러분의 감사 페이지나 Ikiform의 호스팅 감사 페이지로 이동합니다.
  4. 각 알림 수신자의 받은편지함을 확인하세요. 모두 필드가 담긴 이메일을 받고, 폼에 email 필드가 있으면 Reply는 제출자에게 갑니다.
  5. 허니팟을 채워서 _gotcha=bot으로 POST하세요. 성공 응답은 받지만 제출은 스팸으로 저장되고 이메일도 웹훅도 보내지 않습니다.

문제가 생기면

문제해결 방법
답장이 여전히 Ikiform으로 감필드가 아직 user_email입니다. email로 바꾸거나 email, email_address, your_email, reply_to, contact_email 중 하나로 바꾸세요.
422 validation_failedPOST가 폼의 필드 정의와 맞지 않습니다. error.fields에 각 필드와 이유가 있습니다. 입력의 name을 필드 ID와 맞추세요.
403 challenge_required폼의 스팸 검사가 Ikiform으로 설정되어 있는데 fetch POST에 토큰이 없었습니다. 제출할 때마다 @ikiform/sdk의 challenge(formId)로 토큰을 받거나 검사를 끄세요.
403 origin_not_allowed가 나거나 모든 POST가 스팸으로 감페이지의 도메인이 폼의 Allowed domains에 없습니다. 도메인을 추가하고, 로컬 테스트용으로 localhost도 추가하세요. 등록한 도메인의 하위 도메인도 허용됩니다.

질문

막히는 단계가 있거나 내 상황에 맞는 가이드가 필요하다면 이메일을 보내세요: hello@ikiform.com.