Guias16 min de leitura
Envie os envios de formulários HTML para o seu e-mail
Receba todo envio de formulário na caixa de entrada sem servidor de e-mail: por que mailto: e mail() do PHP falham, e uma configuração que filtra spam e permite responder.
Um formulário HTML não consegue enviar e-mail sozinho, porque o navegador não tem como falar com um servidor de e-mail. Aponte o action do formulário para um backend de formulários, que recebe o envio, filtra spam, armazena e manda um e-mail para você e sua equipe. Dê ao campo de e-mail o nome email e o Reply na sua caixa de entrada responde direto para quem preencheu o formulário.
A maioria dos sites precisa de uma coisa de um formulário de contato: a mensagem numa caixa de entrada que alguém lê. Isso acaba sendo mais difícil do que parece. O HTML tem o elemento <form>, mas nenhum jeito de enviar e-mail, e os atalhos antigos ou devolvem o trabalho para o visitante ou caem no spam. Este guia explica por quê, compara as opções com honestidade e configura um formulário que manda e-mail para você em qualquer site e qualquer stack.
Por que um formulário não consegue enviar e-mail sozinho
Quando um visitante clica em Send, o navegador faz uma requisição HTTP para a URL no action do formulário. É tudo o que um formulário consegue fazer. Enviar e-mail exige uma conversa SMTP com um servidor de e-mail, a partir de uma máquina em que o servidor de destino confia. O navegador não consegue abrir essa conexão e, mesmo que conseguisse, um e-mail saindo do notebook de um visitante qualquer seria rejeitado. Então sempre existe algo num servidor entre o formulário e a sua caixa de entrada: seu próprio código, um script no seu host ou um serviço.
Com um backend de formulários, esse algo é uma URL. O formulário envia para https://ikiform.com/f/<id> e o Ikiform faz o resto:
- O visitante envia o formulário, e o navegador ou o seu servidor o envia ao seu endpoint do Ikiform.
- O Ikiform confere os campos com a definição do formulário, dá uma pontuação de spam ao envio e o armazena.
- Um envio que atinge a pontuação de spam é armazenado como spam. Ele recebe a mesma resposta de sucesso, mas não dispara e-mail nem webhook.
- Todos os outros envios mandam um e-mail para cada destinatário, com o endereço do visitante como reply-to.
- O e-mail chega na sua caixa de entrada segundos depois. Clicar em Reply responde ao visitante.
Seis formas de levar um formulário até a sua caixa de entrada
Cada uma delas funciona para alguém. A tabela mostra o que cada uma custa para você quando visitantes de verdade, e bots de verdade, começam a usar o formulário.
| Abordagem | Bom em | Contras |
|---|---|---|
action="mailto:you@example.com" | Sem servidor, sem conta, um atributo. | Nada é enviado. O navegador abre o app de e-mail do visitante com um rascunho, ou não faz nada se não houver app de e-mail configurado. Seu endereço fica na página para os scrapers. Veja o guia do Gmail. |
Um script PHP com mail() | Roda em quase qualquer hospedagem PHP compartilhada, sem terceiros. | Depende da configuração de e-mail do host, e e-mails que dizem vir do seu domínio muitas vezes falham no SPF ou no DMARC e caem no spam. Sem filtro de spam, sem armazenamento, e nada de PHP em hosts estáticos. Veja sem PHP. |
| Um SDK de e-mail no navegador, como o EmailJS | Sem servidor. Modelos de e-mail que você desenha no painel deles. | As chaves e os ids dos modelos ficam no JavaScript da sua página, então qualquer um pode enviar seus modelos com o próprio texto. Veja a alternativa ao EmailJS. |
| Sua própria rota com uma API de e-mail, como Resend, Postmark, Amazon SES ou Nodemailer via SMTP | Controle total: seu assunto, seu HTML, uma confirmação para o visitante. | Você constrói validação, filtro de spam, limites de taxa, armazenamento e novas tentativas, verifica um domínio de envio e precisa de um servidor mesmo num site estático. Veja o guia do Next.js. |
| Google Apps Script como web app | Roda numa conta Google que você já tem. | As cotas de script limitam para quantos destinatários ele consegue mandar e-mail por dia, a URL não tem proteção contra spam e a manutenção do script fica com você. |
| Um backend de formulários, como o Ikiform, o Formspree ou o Web3Forms | Uma URL no action. Filtro de spam, armazenamento e e-mail sem servidor. | O layout e o assunto do e-mail são os do serviço. Com o Ikiform, nada nunca é enviado por e-mail ao visitante. |
Se você precisa de um e-mail totalmente com a sua marca, ou de uma confirmação para o visitante, escreva sua própria rota com uma API de e-mail. Se você quer a mensagem na sua caixa de entrada e um jeito de responder, com o spam do lado de fora, um backend de formulários é o que exige menos código. O resto deste guia usa o Ikiform.
O formulário
Três campos cobrem a maioria dos formulários de contato: quem é a pessoa, para onde responder e o que ela quer. Escolha sua stack abaixo; a escolha vale para todos os exemplos de código destes guias.
Este formulário funciona sem nenhum JavaScript: o navegador o envia, o Ikiform manda um e-mail para você e o visitante cai na página definida em _redirect. Cada campo tem um espaço vazio para erro logo abaixo, usado pelo script a seguir.
<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>O script mantém o visitante na página. Ele envia com fetch, escreve cada erro do servidor abaixo do campo correspondente e troca o formulário por uma frase que repete o endereço para o qual você vai responder, para que um erro de digitação fique fácil de notar.
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;
});Uma server action envia a mensagem com a sua chave secreta. Ela converte o error.fields do Ikiform numa mensagem por campo e devolve os valores digitados, para que nada do que o visitante escreveu se perca num erro.
"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>
);
}As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor. Cada exemplo de servidor repassa o header Origin do navegador quando existe, porque o fetch do próprio servidor não envia nenhum e o Ikiform usa esse header para contar os envios de teste gratuitos de localhost.
Quer ir além com Next.js? Leia Um formulário de contato em Next.js que envia e-mail.
O Zod confere cada campo na action antes de qualquer envio, e isInputError traz as mensagens dele por campo. A página precisa ser renderizada sob demanda, então mantenha prerender = false e adicione um adapter.
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>
)}As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor. Cada exemplo de servidor repassa o header Origin do navegador quando existe, porque o fetch do próprio servidor não envia nenhum e o Ikiform usa esse header para contar os envios de teste gratuitos de localhost.
A server function lê o FormData em .validator(), envia a partir do servidor e devolve o endereço de resposta ou um erro por campo.
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>
);
}As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor. Cada exemplo de servidor repassa o header Origin do navegador quando existe, porque o fetch do próprio servidor não envia nenhum e o Ikiform usa esse header para contar os envios de teste gratuitos de localhost.
A action da rota encaminha a mensagem e devolve os erros de campo do Ikiform via actionData, com o status code do Ikiform preservado por 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>
);
}As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor. Cada exemplo de servidor repassa o header Origin do navegador quando existe, porque o fetch do próprio servidor não envia nenhum e o Ikiform usa esse header para contar os envios de teste gratuitos de localhost.
A action padrão devolve fail() com os erros de campo. update({ reset: false }) mantém o que o visitante digitou quando o servidor responde com um erro. Declare IKIFORM_SECRET_KEY em src/env.ts com defineEnvVars para que $app/env/private consiga lê-la.
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}As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor. Cada exemplo de servidor repassa o header Origin do navegador quando existe, porque o fetch do próprio servidor não envia nenhum e o Ikiform usa esse header para contar os envios de teste gratuitos de localhost.
Uma server route guarda a chave secreta e repassa a mensagem do Ikiform como o message do createError, e os erros de campo no data dele; o $fetch devolve os dois no cliente como data.message e data.data.fields. O Nuxt preenche runtimeConfig.ikiformSecretKey a partir de NUXT_IKIFORM_SECRET_KEY.
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>As versões no servidor enviam com uma chave secreta, então o Ikiform pula Allowed domains e a verificação humana. Todo envio passa a vir do IP do seu servidor, e o limite Standard de 5 por minuto por visitante conta todos os seus visitantes juntos. Escolha o limite de taxa Relaxed ou Custom para um formulário que você envia por um servidor, e mantenha IKIFORM_SECRET_KEY em variáveis de ambiente só do servidor. Cada exemplo de servidor repassa o header Origin do navegador quando existe, porque o fetch do próprio servidor não envia nenhum e o Ikiform usa esse header para contar os envios de teste gratuitos de localhost.
Sem um servidor próprio, o navegador envia direto ao Ikiform. Nenhuma chave entra neste código; Allowed domains decide quais sites podem enviar.
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>
);
}O mesmo envio pelo navegador como componente Vue. O formulário mantém o action, então ainda envia se o script não carregar.
<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>- Troque
frm_xxxxxxxxxxxxxxxxpelo id do seu formulário, que está no painel. name="email"transforma esse campo no endereço de reply-to do e-mail de notificação. Mantenha o nome, mesmo que o rótulo diga "Seu e-mail".pageé um campo oculto comum. Ele aparece no e-mail, então você sabe de qual página veio a mensagem._gotchaé o honeypot. As pessoas nunca o veem; um bot que o preenche é armazenado como spam e não manda e-mail para ninguém._redirectsó funciona com uma URLhttps:em um dos Allowed domains do formulário. Deixe-o de fora e defina Redirect after submit nas configurações do formulário, ou o Ikiform mostra a própria página de agradecimento.
Os guias de frameworks vão além: Next.js, Astro, SvelteKit, Nuxt, React, Vue e HTML puro. Criadores de sites como o Webflow e o Carrd têm páginas próprias, e o modelo de formulário de contato adiciona um campo de assunto.
Defina os campos no painel também, e o Ikiform recusa um envio que não corresponde com 422 validation_failed e uma mensagem por campo, que o código acima mostra ao lado de cada input. Experimente aqui; isto roda o mesmo validador no seu navegador:
Prévia
Roda o validador do Ikiform no seu navegador. Nada é enviado.
Como é o e-mail
Todo envio aceito dispara um e-mail simples e legível. Esta é a versão em texto de um deles:
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_...- Ele vem de
notifications@ikiform.com. Você não configura registros DNS nem domínio de envio, porque o e-mail sai do domínio do Ikiform. - O assunto é sempre
New submission: <form name>._subjectnão muda o assunto dos e-mails de notificação. Ele é repassado aos webhooks e à API comometa.subject. Para diferenciar os formulários, dê a cada um um nome claro no painel, como "Contato, página de preços". - Os campos aparecem na ordem em que foram enviados, identificados pelo
name. Campos que começam com_ficam de fora. - Um formulário ou uma resposta muito longa é encurtada, e o e-mail diz quantos campos a mais estão no painel. A página de limites tem os números.
- Campos que parecem senhas ou números de cartão ficam de fora, e o e-mail de um envio que os contém fica retido para revisão. Não colete credenciais com um backend de formulários.
- Membros do workspace recebem um botão View submission. Pessoas de fora do workspace recebem os campos e um link para cancelar a inscrição.
Quem recebe o e-mail
Os destinatários são definidos por formulário no painel, nunca pelo próprio formulário. Um campo que indicasse um destinatário deixaria qualquer um usar seu formulário para mandar e-mail para qualquer pessoa, então o Ikiform não tem esse campo.
| Quem | Como é adicionado | Observações |
|---|---|---|
| O padrão | Nada a fazer | O membro escolhido como Notification email do workspace, ou todos os donos do workspace quando nenhum é escolhido |
| Membros escolhidos | Marque-os nas configurações de Notifications do formulário | Só membros com endereço de e-mail verificado |
| Pessoas de fora do workspace | Add someone outside the workspace, depois Send link | Só recebem e-mails depois de confirmar. Até 5 por formulário, e cada um pode cancelar a inscrição |
Cada formulário manda e-mail para no máximo 10 endereços. Cada destinatário recebe a própria cópia em vez de um CC compartilhado, então ninguém vê os endereços dos outros. Se a caixa de um destinatário devolve os e-mails, o Ikiform pausa os envios para esse endereço por um tempo e o painel mostra Emails bounced ao lado dele. Vários destinatários explica o roteamento por assunto, que os destinatários sozinhos não resolvem.
Responda à pessoa, não ao Ikiform
O Ikiform define o endereço de reply-to primeiro a partir de um campo _replyto, depois a partir de um campo chamado email, email_address, your_email, reply_to, contact_email. O valor precisa ser um único endereço de e-mail válido, ou é ignorado. Com um reply-to definido, clicar em Reply no Gmail, no Outlook ou no Apple Mail abre uma resposta para o visitante, e sua resposta sai do seu próprio endereço.
Sem ele, uma resposta vai para notifications@ikiform.com, que ninguém lê. Essa é a surpresa mais comum, e geralmente é um campo chamado user_email ou Email Address com espaço. Renomeie-o ou adicione _replyto. O guia de reply-to cobre formulários com dois campos de e-mail e criadores de sites que renomeiam os campos por você.
Quando chega muita coisa de uma vez
Cada formulário envia até 50 e-mails de notificação por hora, ou 10 enquanto o workspace tem menos de 7 dias. Passando disso, o resto dos envios da hora é armazenado normalmente e chega num único e-mail de resumo quando a hora termina. O limite protege sua caixa de entrada, e a reputação dos e-mails do Ikiform com o seu provedor, quando um formulário é divulgado em algum lugar grande ou um bot passa pelos filtros.
- Os 50 primeiros envios para um formulário numa hora cheia disparam um e-mail cada, como de costume.
- Os envios seguintes na mesma hora são armazenados e aparecem no painel na hora, sem um e-mail para cada um.
- Quando a hora termina, cada destinatário recebe um resumo que conta esses envios e leva ao painel.
O assunto do resumo é a contagem e o nome do formulário, e ele não lista os campos nem define reply-to, então responda essas mensagens pelo painel. Se você passa do limite com frequência, o e-mail não é o canal certo para esse formulário: envie para o Slack ou para uma caixa compartilhada por meio de um webhook.
Mantenha o spam fora da sua caixa de entrada
O e-mail deixa o spam caro: cada envio de bot é uma notificação que alguém abre. O Ikiform armazena spam com uma resposta de sucesso normal, então o bot não aprende nada, e não manda e-mail por ele. Quatro coisas decidem o que conta como spam:
| Defesa | O que ela pega | Configuração |
|---|---|---|
| Honeypot | Bots que preenchem todos os inputs | O input oculto _gotcha |
| Pontuação de spam | Links no campo de nome, três ou mais links, frases de spam conhecidas, endereços descartáveis e repetições idênticas. Com 5 o envio é spam | Nenhuma |
| Domínios permitidos | Envios de outros sites | Adicione seu domínio nas configurações do formulário |
| Verificação humana | Bots que rodam um navegador de verdade | Defina Spam check como Ikiform |
Confira o filtro Spam no painel de vez em quando. Se uma mensagem real cair lá, marque-a como não spam e o e-mail dela é enviado na hora. A documentação de spam explica cada sinal, inclusive quando ligar a verificação humana.
O que o e-mail não faz
O Ikiform não envia e-mail para quem preenche o formulário. Os e-mails de notificação vão só para você e sua equipe, então não há resposta automática, e-mail de confirmação nem double opt-in. Envie esses e-mails pela sua ferramenta de e-mail, disparados por um webhook. Se o seu site promete "recebemos sua mensagem", mostre isso na página depois do envio, onde o código acima já mostra.
- Uma caixa de entrada compartilhada da equipe. Adicione o endereço da equipe como destinatário ou encaminhe pelo seu provedor de e-mail.
- Um canal de chat. Envie cada envio para o Slack, o Discord ou o Microsoft Teams por webhook. O chat combina com formulários que recebem mais do que algumas mensagens por dia.
- Uma planilha ou CRM. O Google Sheets, ou qualquer ferramenta via Zapier ou n8n.
- Anexos. Upload de arquivos ainda não é suportado. Um envio com arquivo recebe
422 files_not_supported. Peça um link para um arquivo compartilhado.
Desligando o e-mail
Alguns formulários ficam melhores sem e-mail, como uma inscrição de newsletter movimentada. Desligue Email new submissions nas configurações de Notifications do formulário. Os envios continuam sendo armazenados, e os webhooks continuam disparando.
Escolha o seu caso
A configuração acima é a mesma em todo lugar. Estas páginas tratam de uma caixa de entrada específica, de uma stack específica ou da ferramenta que você está deixando para trás.
- Sem PHPSubstitua um script contact.php que chama mail() por um formulário que envia a um endpoint hospedado, mantendo seus nomes de campo, sua página de agradecimento e as respostas.
- Para o GmailReceba os envios de formulário no Gmail sem links mailto:, senhas de app ou Apps Script, e depois rotule, tire do spam e responda pelo Gmail.
- Vários destinatáriosMande um formulário por e-mail para a equipe toda, ou encaminhe cada mensagem à pessoa certa pelo assunto, sem deixar o próprio formulário escolher quem recebe.
- Responder pela caixa de entradaFaça o Reply de uma notificação de formulário responder a quem preencheu: quais nomes de campo funcionam, quando usar _replyto e por que as respostas se perdem.
- Alternativa ao EmailJSTire um formulário de contato do EmailJS: por que chaves de e-mail no navegador são um risco, o que muda no seu código e do que você abre mão, como a resposta automática.
- Next.jsDuas formas de mandar um formulário Next.js por e-mail com uma Server Action: enviar você mesmo com Resend ou Nodemailer, ou encaminhar a um backend de formulários, comparadas com honestidade.
Confira se funciona
Siga estes passos antes de colocar o formulário no ar. O Ikiform não tem plano gratuito. Testar é grátis: um workspace sem plano aceita 50 envios de teste por mês a partir de localhost. Para entrar no ar, escolha o Pro a partir de US$ 6/mês para 500 envios, ou pague uma vez: 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram. Veja os preços.
- Em Allowed domains do formulário, adicione o domínio do seu site, mais
localhostenquanto desenvolve. Deixe vazio para aceitar envios de qualquer lugar. - Nas configurações de Notifications do formulário, confira se Email new submissions está ligado e se as pessoas certas estão marcadas.
- Preencha o formulário na sua página e envie. Você cai na sua página de agradecimento, ou na hospedada pelo Ikiform.
- Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou.
- Abra o e-mail de notificação. O assunto é
New submission:mais o nome do formulário, e todos os campos que você enviou estão listados. - Clique em Reply. A linha To mostra o endereço que você digitou no formulário, não o do Ikiform.
- Envie com o honeypot preenchido,
_gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é armazenado como spam e não dispara e-mail nem webhook. - Não chegou nada? Mude o filtro de status do painel para Spam. Spam recebe a mesma resposta de sucesso de um envio real.
Solução de problemas
| Problema | O que fazer |
|---|---|
| O envio está no painel, mas nenhum e-mail chegou | Olhe primeiro na pasta de spam ou de promoções e adicione notifications@ikiform.com aos seus contatos. Depois confira as configurações de Notifications do formulário: o e-mail pode estar desligado, um endereço de fora pode ainda mostrar Waiting for confirmation, ou pode aparecer Emails bounced. |
| O envio está no painel, mas nenhum e-mail ou webhook chegou | Ele foi armazenado como spam. Spam não envia nada. Abra-o no filtro Spam para ver os motivos e marque-o como não spam para enviar o e-mail e os webhooks dele. |
| Reply vai para notifications@ikiform.com | Nenhum reply-to foi encontrado. Dê ao campo de e-mail um destes nomes: email, email_address, your_email, reply_to, contact_email, ou adicione um campo _replyto com o endereço do visitante. |
| Os e-mails pararam e chegou um resumo no lugar | O formulário passou de 50 e-mails numa hora (10 num workspace novo). Está tudo no painel. Procure uma rajada de bots em Spam, ou use um webhook para formulários movimentados. |
_subject não muda o assunto | _subject não muda o assunto dos e-mails de notificação. Ele é repassado aos webhooks e à API como meta.subject. |
403 origin_not_allowed, ou todo envio cai no spam | O domínio da página não está em Allowed domains do formulário. Adicione-o, mais localhost para testes locais. Subdomínios de um domínio listado são permitidos. |
422 validation_failed | O envio não corresponde à definição de campos do formulário. error.fields aponta cada campo e o motivo. Faça o name do input bater com o id do campo. |
402 test_limit_reached | O workspace não tem plano e já usou os 50 testes gratuitos de localhost deste mês. Escolha um plano ou use Send test no painel. |
Perguntas
Não sozinho. Um formulário só faz uma requisição HTTP, e enviar e-mail exige um servidor que converse com um servidor de e-mail. Um backend de formulários é esse servidor: aponte o action do formulário para ele e ele manda o e-mail para você, então o seu site pode continuar estático.
Um action mailto: não envia nada. Ele pede ao computador do visitante que abra um app de e-mail com um rascunho, o que falha para quem não tem um configurado, e o visitante ainda precisa clicar em Enviar. Muita gente desiste aí, e você nunca fica sabendo que a pessoa tentou.
Não. O assunto é sempre "New submission:" seguido do nome do formulário, então dê a cada formulário um nome claro. Um campo _subject é armazenado e repassado aos webhooks e à API, mas não muda o e-mail.
Até 10 por formulário: membros do workspace que você marca nas configurações do formulário, mais até 5 endereços de fora que confirmam por link. Cada um recebe a própria cópia.
Não. O Ikiform envia e-mail para você e sua equipe, não para quem enviou, então não há resposta automática nem e-mail de confirmação. Envie um webhook para sua ferramenta de e-mail e mande a confirmação de lá.
Eles saem do próprio domínio do Ikiform, então você não precisa de registros SPF ou DKIM para o seu domínio. O primeiro ainda pode cair em spam ou promoções; adicione notifications@ikiform.com aos seus contatos ou crie um filtro para que isso nunca aconteça.
Não, não há plano gratuito. Testar é grátis: você pode enviar 50 envios de teste por mês a partir de localhost antes de pagar. Para entrar no ar, o Pro começa em US$ 6/mês para 500 envios, ou você pode pagar uma vez 10.000 envios por US$ 49 ou 50.000 envios por US$ 149, com créditos que nunca expiram.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.