Receber um formulário no e-mail

Receber um formulário no e-mail7 min de leitura

Um formulário de contato em Next.js que envia e-mail

Duas 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.

Preet SutharFundador, Ikiform

No App Router, uma Server Action recebe o FormData do formulário no servidor, onde uma chave de API de e-mail fica segura. Você pode enviar o e-mail você mesmo com Resend ou Nodemailer, ou encaminhar o envio a um backend de formulários que filtra spam, armazena e manda um e-mail para você. Enviar você mesmo dá controle total do e-mail; encaminhar poupa o filtro de spam, os limites de taxa e o armazenamento que você teria que construir.

O que muda aqui

O Next.js já tem um servidor, então o motivo habitual para um backend de formulários, "meu site não consegue rodar código", não se aplica. A questão é o que esse código precisa fazer. Enviar o e-mail é a parte fácil. Tudo em volta é o que toma tempo:

O que uma Server Action faz quando envia o e-mail, e quando encaminha o envioenviar você mesmoencaminharseuformuláriouseActionStatesuaactionvalida,confere ohoneypotResend ouSMTPseu domínioverificadosó caixadeentradasem cópiase ele seperderseuformuláriouseActionStatesuaactionadiciona achavesecretaIkiformvalida,pontua,armazenacaixa deentrada epainelmaiswebhooksFormDatachave de APIFormDataJSONO que uma Server Action faz quando envia o e-mail, e quando encaminha o envioenviar você mesmoencaminharseu formuláriouseActionStatesua actionvalida, confere o honeypotResend ou SMTPseu domínio verificadosó caixa de entradasem cópia se ele se perderseu formuláriouseActionStatesua actionadiciona a chave secretaIkiformvalida, pontua, armazenacaixa de entrada e painelmais webhooksFormDatachave de APIFormDataJSON
  1. Enviando você mesmo: a action valida os campos, confere o honeypot e chama seu provedor de e-mail com uma chave de API. A mensagem só existe como esse e-mail.
  2. Encaminhando: a action envia os campos ao Ikiform com sua chave secreta. O Ikiform valida com a definição do formulário, pontua o spam, armazena o envio e manda e-mail aos destinatários.
As duas mantêm todas as chaves no servidor. A diferença é quanto da caixa do meio você escreve e mantém.
TrabalhoServer Action com Resend ou NodemailerServer Action com Ikiform
ValidaçãoZod na actionA definição de campos do formulário, aplicada do lado do Ikiform
SpamSeu honeypot; qualquer coisa além disso fica por sua contaHoneypot, pontuação de spam e uma verificação humana opcional
Limites de taxaNenhum até você adicionar um armazenamento como o RedisEmbutidos, por visitante e por formulário
Domínio de envioVerifique o seu com registros SPF e DKIMNenhum: o e-mail vem de notifications@ikiform.com
Assunto e designO que você quiserFixo: New submission: <form name>
Confirmação para o visitanteMais uma chamada a sendNão disponível; envie pela sua ferramenta de e-mail
Uma cópia de cada mensagemSó se você adicionar um banco de dadosPainel, exportação CSV e API

Escolha a versão faça-você-mesmo para um e-mail com a sua marca ou uma confirmação para o visitante, se não se importa de cuidar do spam e do armazenamento. Escolha encaminhar para parar de pensar no formulário depois que ele funcionar.

As duas versões

O formulário é um pedido de projeto: nome, e-mail de trabalho, empresa, uma faixa de orçamento e uma descrição. O tipo do state e o client component são compartilhados; só o actions.ts muda.

app/inquiry/state.ts
export interface InquiryState {
  sent: boolean;
  errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}

export const initialInquiry: InquiryState = { sent: false, errors: {} };
app/inquiry/inquiry-form.tsx
"use client";

import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { sendInquiry } from "./actions";
import { initialInquiry } from "./state";

function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? "Sending" : "Send inquiry"}</button>;
}

export function InquiryForm() {
  const [state, action] = useActionState(sendInquiry, initialInquiry);

  if (state.sent) return <p role="status">Thanks. We reply to every inquiry within two working days.</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {state.errors.name ? <p role="alert">{state.errors.name}</p> : null}

      <label htmlFor="email">Work email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {state.errors.email ? <p role="alert">{state.errors.email}</p> : null}

      <label htmlFor="company">Company</label>
      <input id="company" name="company" autoComplete="organization" />

      <label htmlFor="budget">Budget</label>
      <select id="budget" name="budget" required defaultValue="">
        <option value="" disabled>Choose a range</option>
        <option value="under-5k">Under $5k</option>
        <option value="5k-20k">$5k to $20k</option>
        <option value="over-20k">Over $20k</option>
      </select>
      {state.errors.budget ? <p role="alert">{state.errors.budget}</p> : null}

      <label htmlFor="message">About the project</label>
      <textarea id="message" name="message" rows={6} required minLength={20} />
      {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}
      <SubmitButton />
    </form>
  );
}
"use server";

import { Resend } from "resend";
import { z } from "zod";
import type { InquiryState } from "./state";

const resend = new Resend(process.env.RESEND_API_KEY);

const schema = z.object({
  name: z.string().trim().min(1, "Tell us your name.").max(120),
  email: z.email("Enter an address we can reply to."),
  company: z.string().trim().max(120).optional(),
  budget: z.enum(["under-5k", "5k-20k", "over-20k"], "Pick a budget range."),
  message: z.string().trim().min(20, "A few sentences help us reply properly.").max(5000),
});

export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
  if (formData.get("_gotcha")) return { sent: true, errors: {} };

  const parsed = schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    const fields = z.flattenError(parsed.error).fieldErrors;
    return {
      sent: false,
      errors: Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages?.[0]])),
    };
  }

  const inquiry = parsed.data;
  const { error } = await resend.emails.send({
    from: "Website <inquiries@yourdomain.com>",
    to: ["you@yourdomain.com"],
    replyTo: inquiry.email,
    subject: `Project inquiry from ${inquiry.name}`,
    text: [
      `Name: ${inquiry.name}`,
      `Email: ${inquiry.email}`,
      `Company: ${inquiry.company ?? "-"}`,
      `Budget: ${inquiry.budget}`,
      "",
      inquiry.message,
    ].join("\n"),
  });
  if (error) return { sent: false, errors: { form: "Couldn't send your inquiry. Try again." } };
  return { sent: true, errors: {} };
}
  • O tipo do state fica num arquivo próprio porque um arquivo "use server" só pode exportar funções assíncronas.
  • useFormStatus lê o estado pendente do formulário em que está, então o botão fica num componente próprio.
  • Na versão com Resend, um honeypot preenchido devolve sucesso sem enviar, então o bot não aprende nada. O Ikiform faz o mesmo quando armazena o envio como spam.
  • replyTo na versão com Resend, e o campo chamado email na versão com Ikiform, fazem o Reply responder ao visitante.
  • O Nodemailer funciona do mesmo jeito que o Resend: crie um transport com seus dados de SMTP e chame sendMail com from, to, replyTo, subject e text.
  • Para a versão com Ikiform, defina os cinco campos na aba Fields do formulário, com budget como select, para que um valor ruim receba 422 validation_failed com uma mensagem por campo.
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.

Há uma terceira opção: pular a action e deixar o navegador enviar direto ao Ikiform. Nenhuma chave secreta entra nisso, então o limite de taxa Standard e Allowed domains valem por visitante. O guia de Next.js da documentação mostra o client component.

Confira se funciona

  1. Envie com o orçamento vazio. O erro aparece abaixo do select e nada mais se perde.
  2. Envie um pedido de verdade. A frase de sucesso substitui o formulário.
  3. Abra o e-mail e clique em Reply. A linha To é o endereço que você digitou.
  4. Preencha o honeypot oculto com as ferramentas de desenvolvedor do navegador e envie. Você vê sucesso, e nenhum e-mail chega.
  5. Abra Submissions no painel. O novo envio está lá com todos os campos que você enviou. Este passo vale só para a versão com Ikiform.

Pegadinhas

  • Escape em e-mail HTML. A versão com Resend envia text. Se você mudar para html, escape cada valor antes, ou um visitante consegue colocar markup e links na sua caixa de entrada.
  • Injeção de headers. Nunca coloque um valor cru do formulário em from ou to. replyTo é seguro com o Resend porque o Zod já conferiu que é um e-mail.
  • Variáveis de ambiente. RESEND_API_KEY e IKIFORM_SECRET_KEY não podem começar com NEXT_PUBLIC_, ou o Next.js as envia para o navegador.
  • Envios duplicados. Um clique duplo pode rodar a action duas vezes. O estado pendente desativa o botão, mas uma rede lenta ainda pode causar uma corrida; a opção idempotencyKey do Resend, ou o header Idempotency-Key do Ikiform, faz uma repetição não ter efeito.
  • Server Actions são endpoints públicos. Qualquer um pode chamar uma diretamente, e é por isso que a validação e as verificações de spam precisam rodar dentro dela, não só no formulário.

Se algo der errado

ProblemaO que fazer
O Resend responde que o domínio não está verificadoAdicione os registros DNS do Resend para o domínio em from, ou envie pelo endereço de teste do Resend enquanto desenvolve.
429 rate_limitedUm visitante enviou mais do que o limite de taxa do formulário permite. O Standard permite 5 por minuto por visitante. Espere o Retry-After, ou escolha Relaxed ou Custom para formulários que enviam a partir de um único IP compartilhado.
422 validation_failedO 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.
A action funciona localmente, mas não em produçãoA chave está faltando nas variáveis de ambiente do deploy. Adicione-a ao ambiente de produção e faça o deploy de novo.

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.