Acabe com o spam no formulário de contato7 min de leitura
Como acabar com o spam no formulário de contato de um site estático
Acabe com o spam num formulário de site estático mesmo sem guardar segredos nem contar envios, com um partial de honeypot, um script seguro e verificações no servidor.
Um site estático não tem servidor, então nada na página consegue guardar uma chave secreta, validar um token de captcha ou contar envios por visitante. Essas verificações precisam rodar em outro lugar: numa função no seu host, ou num backend de formulários para o qual seu formulário envia. Coloque um honeypot acessível no formulário de qualquer jeito e mantenha o formulário funcionando sem JavaScript.
O que muda num site estático
Num site estático, cada byte que você publica é público. Tudo no seu HTML ou JavaScript pode ser lido e pulado por um bot, então verificações de spam no cliente são conselho, não imposição. As três verificações que mais importam precisam de um servidor:
- Validar um token de captcha exige uma chave secreta, que não pode ficar num arquivo público.
- Contar envios por visitante exige um armazenamento que sobreviva entre requisições.
- Ler um honeypot exige código que rode depois que o envio chega, fora do alcance do bot.
Hosts estáticos também não aceitam envios para as suas páginas. A Vercel e o GitHub Pages respondem a um POST de formulário para uma página estática com 405 Method Not Allowed, então o formulário precisa de um endpoint antes mesmo de o spam virar questão.
- Uma página estática que envia para si mesma chega a um host que não roda código, então o envio falha com 405 ou não vai a lugar nenhum.
- Com uma função no host, a página envia a uma função que guarda o segredo, lê o honeypot e valida o token, e depois encaminha a mensagem.
- Com um backend de formulários, a página envia direto ao backend, que roda o honeypot, a pontuação, o limite de taxa e a verificação humana antes de mandar o e-mail para você.
Suas opções
| Opção | Verificações de spam | Prós e contras |
|---|---|---|
| Netlify Forms | Akismet em todo envio, mais um campo netlify-honeypot e o reCAPTCHA v2 se você os adicionar | Só na Netlify. Mudar de host significa refazer o formulário. Veja alternativas ao Netlify Forms |
| Uma função no seu host | O que você escrever: honeypot, validação de token, limite de taxa com armazenamento | Você escreve e mantém, e precisa de um serviço de e-mail ou de armazenamento por trás |
| Um backend de formulários, como o Ikiform | Honeypot, pontuação, limites de taxa, domínios permitidos e verificação humana, sem código | Mais um serviço, cobrado por envio |
O formulário, como um partial reutilizável
Seja qual for o endpoint escolhido, escreva o formulário uma vez como partial ou include, para que toda página use o mesmo honeypot. A versão abaixo envia ao Ikiform. Para usar uma função, mude o action para o caminho da função e tire o _redirect.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
<p role="status"></p>
<button>Send message</button>
</form><style>
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>- O honeypot fica fora da tela, fora da ordem de tabulação e oculto para leitores de tela. O guia do honeypot explica cada atributo.
_redirectleva as pessoas para a sua página de agradecimento. O Ikiform só o segue para um endereçohttps:num dos Allowed domains do formulário.- Os guias de Hugo, Jekyll e Eleventy cobrem configuração, shortcodes e GitHub Pages.
Melhore sem quebrar
Um script pode manter os visitantes na página, mas nunca pode ser o único jeito de o formulário funcionar. Este envia com fetch e volta para um envio normal do navegador em dois casos: quando a chamada de rede falha e quando a verificação humana do Ikiform pede um token com challenge_required. Um envio normal recebe a página de verificação do Ikiform, então você pode ligar a verificação sem mudar o script.
<script>
document.querySelectorAll("form.contact").forEach((form) => {
const status = form.querySelector("[role=status]");
const button = form.querySelector("button");
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
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) {
form.replaceChildren("Thanks. We'll reply by email.");
return;
}
if (result.error.code === "challenge_required") {
form.submit();
return;
}
const wait = response.headers.get("Retry-After");
if (response.status === 429 && wait) {
status.textContent = `Too many messages. Try again in ${wait} seconds.`;
} else {
status.textContent = result.error.message;
}
} catch {
form.submit();
return;
}
button.disabled = false;
});
});
</script>form.submit()envia o formulário sem disparar o eventosubmitde novo, então o fallback não entra em loop.- Um
429mostra quanto esperar a partir doRetry-After, que o Ikiform expõe ao código do navegador. - A linha de sucesso substitui o formulário, para ninguém enviá-lo duas vezes sem querer.
Se você escrever uma função no host
Netlify, Cloudflare Pages e Vercel rodam pequenas funções ao lado dos arquivos estáticos. Uma função é o seu próprio backend, então as verificações de spam ficam por conta dela. Esta Netlify Function lê o honeypot, descarta mensagens entupidas de links, valida um token do Turnstile e manda o e-mail para você pelo Resend. Todo envio barrado recebe o mesmo redirecionamento de agradecimento que um real.
import type { Config, Context } from "@netlify/functions";
const LINK = /https?:\/\/|www\./gi;
export default async (req: Request, context: Context) => {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const form = await req.formData();
const email = String(form.get("email") ?? "");
const message = String(form.get("message") ?? "");
const thanks = Response.redirect(new URL("/thanks/", req.url), 303);
if (String(form.get("_gotcha") ?? "").trim()) return thanks;
if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;
const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body: new URLSearchParams({
secret: process.env.TURNSTILE_SECRET_KEY ?? "",
response: String(form.get("cf-turnstile-response") ?? ""),
remoteip: context.ip,
}),
});
const verdict = (await check.json()) as { success: boolean };
if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
from: "Contact form <form@example.com>",
to: "you@example.com",
reply_to: email,
subject: "New message from the website",
text: message,
}),
});
return thanks;
};
export const config: Config = { path: "/api/contact" };context.ipé o IP do visitante, que o siteverify pode usar comoremoteip.- Defina
TURNSTILE_SECRET_KEYeRESEND_API_KEYnas variáveis de ambiente do site, nunca no repositório. - Ela descarta os envios barrados em vez de guardá-los, então uma mensagem real barrada por engano se perde. No mínimo, registre-os em log.
- Ela não tem limite de taxa, já que uma função não tem memória entre chamadas. O guia de limite de taxa adiciona um com Redis.
O que o Ikiform faz por um formulário estático
Com o formulário enviando direto ao Ikiform, o trabalho do lado do servidor acontece lá. O honeypot é _gotcha, a pontuação de conteúdo e os limites de taxa por visitante rodam em todo envio, e Allowed domains marca como spam os envios de outros sites. Mude Spam check para Ikiform para ter o Cloudflare Turnstile sem chaves: o envio HTML simples o roda numa página de verificação, e o script acima volta para essa página. Um envio julgado como spam recebe a mesma resposta de sucesso que um real, então o bot não aprende nada. Ele é guardado com o status spam, não dispara e-mail de notificação nem webhook, não conta no seu uso e é apagado após 30 dias. Toda segunda-feira, um workspace que bloqueou spam recebe um único e-mail de resumo com o total, os formulários mais visados e os principais motivos.
username.github.io. Deploy previews da Netlify e URLs de preview da Vercel não são subdomínios do endereço principal do seu site; adicione o host exato do preview enquanto testa um.Confira se funciona
- Desative o JavaScript e envie. O formulário ainda envia e você cai na sua página de agradecimento.
- Ative o JavaScript e envie. O formulário é substituído pela linha de agradecimento.
- Envie com o honeypot preenchido,
_gotcha=bot. Ele ainda recebe uma resposta de sucesso, mas o envio é guardado como spam e não dispara e-mail nem webhook. - Defina Spam check como Ikiform e envie com o script ligado. A página volta para um envio normal e mostra a página de verificação.
- Nada chegou? Mude o filtro de status do painel para Spam. O spam recebe a mesma resposta de sucesso que um envio real.
Pegadinhas
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, e tambémlocalhostpara testes locais. Subdomínios de um domínio listado são permitidos.- HTML minificado. Algumas etapas de build removem atributos que julgam vazios ou sem uso. Confira se a página gerada ainda tem
aria-hiddenetabindexno honeypot. - Páginas de agradecimento em cache. Se a página de agradecimento for a mesma URL para spam e envios reais, como deve ser, nunca coloque nela nada que dependa de o envio ser real.
- Chaves no repositório. Um segredo commitado no repositório de um site estático público é público. Troque-o se isso já aconteceu.
Perguntas
Você pode mostrar o widget, mas o token dele precisa ser validado com uma chave secreta num servidor. Use uma função no seu host, um backend de formulários que valide suas chaves ou a verificação gerenciada do Ikiform, que dispensa chaves.
Ele barra os bots mais comuns, mas só se algo num servidor o ler. Combine-o com um limite de taxa e pontuação de conteúdo, que também precisam de um servidor ou de um backend de formulários.
O formulário envia para uma página num host que só serve arquivos. Aponte o action do formulário para uma função ou para o endpoint de um backend de formulários.
Deveria. Envie primeiro com um formulário HTML simples e adicione um script que o melhore e volte para um envio normal quando algo der errado.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.