Recevoir un formulaire par e-mail

Recevoir un formulaire par e-mail6 min de lecture

Un formulaire de contact Next.js qui envoie un e-mail

Deux façons d'envoyer un formulaire Next.js par e-mail avec une Server Action : l'envoyer vous-même avec Resend ou Nodemailer, ou le transmettre à un backend.

Preet SutharFondateur, Ikiform

Dans l'App Router, une Server Action reçoit le FormData du formulaire côté serveur, où une clé d'API d'e-mail est en sécurité. Vous pouvez envoyer l'e-mail vous-même avec Resend ou Nodemailer, ou transmettre l'envoi à un backend de formulaires qui filtre le spam, le stocke et vous envoie un e-mail. L'envoyer vous-même vous donne le contrôle total de l'e-mail ; le transmettre vous épargne le filtrage du spam, les limites de débit et le stockage que vous auriez sinon à construire.

Ce qui change ici

Next.js a déjà un serveur : la raison habituelle d'utiliser un backend de formulaires, « mon site ne peut pas exécuter de code », ne s'applique donc pas. La question est ce que ce code doit faire. Envoyer l'e-mail est la partie facile. C'est tout ce qu'il y a autour qui prend du temps :

Ce que fait une Server Action quand elle envoie l'e-mail, et quand elle transmet l'envoil'envoyer vous-mêmele transmettrevotreformulaireuseActionStatevotreactionvalide,vérifie lehoneypotResend ouSMTPvotredomainevérifiéboîteuniquementaucunecopie siperduvotreformulaireuseActionStatevotreactionajoute laclé secrèteIkiformvalide,évalue,stockeboîte ettableaude bordplus leswebhooksFormDataclé d'APIFormDataJSONCe que fait une Server Action quand elle envoie l'e-mail, et quand elle transmet l'envoil'envoyer vous-mêmele transmettrevotre formulaireuseActionStatevotre actionvalide, vérifie le honeypotResend ou SMTPvotre domaine vérifiéboîte uniquementaucune copie si perduvotre formulaireuseActionStatevotre actionajoute la clé secrèteIkiformvalide, évalue, stockeboîte et tableau de bordplus les webhooksFormDataclé d'APIFormDataJSON
  1. L'envoyer vous-même : l'action valide les champs, vérifie le honeypot et appelle votre fournisseur d'e-mail avec une clé d'API. Le message n'existe que sous forme de cet e-mail.
  2. Le transmettre : l'action envoie les champs à Ikiform avec votre clé secrète. Ikiform valide selon la définition du formulaire, évalue le spam, stocke la soumission et écrit aux destinataires.
Les deux gardent toutes les clés sur le serveur. La différence, c'est la part du bloc central que vous écrivez et maintenez.
TâcheServer Action + Resend ou NodemailerServer Action avec Ikiform
ValidationZod dans l'actionLa définition des champs du formulaire, appliquée côté Ikiform
SpamVotre honeypot ; tout le reste est à ajouter vous-mêmeHoneypot, score de spam et vérification humaine facultative
Limites de débitAucune tant que vous n'ajoutez pas un stockage comme RedisIntégrées, par visiteur et par formulaire
Domaine d'envoiVérifiez le vôtre avec des enregistrements SPF et DKIMAucun : les e-mails viennent de notifications@ikiform.com
Objet et designTout ce que vous voulezFixe : New submission: <form name>
Confirmation au visiteurUn appel send de plusIndisponible ; envoyez-la depuis votre outil d'e-mailing
Une copie de chaque messageSeulement si vous ajoutez une base de donnéesTableau de bord, export CSV et API

Choisissez la version maison pour un e-mail à votre marque ou une confirmation au visiteur, si vous acceptez de gérer le spam et le stockage. Choisissez la transmission pour ne plus penser au formulaire une fois qu'il fonctionne.

Les deux versions

Le formulaire est une demande de projet : nom, e-mail professionnel, entreprise, une fourchette de budget et une description. Le type d'état et le composant client sont partagés ; seul actions.ts change.

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: {} };
}
  • Le type d'état a son propre fichier car un fichier "use server" ne peut exporter que des fonctions async.
  • useFormStatus lit l'état en attente du formulaire dans lequel il se trouve : le bouton a donc son propre composant.
  • Dans la version Resend, un honeypot rempli renvoie un succès sans rien envoyer, pour que le bot n'apprenne rien. Ikiform fait de même quand il stocke l'envoi comme spam.
  • replyTo dans la version Resend, et le champ nommé email dans la version Ikiform, font tous deux que Reply écrit au visiteur.
  • Nodemailer fonctionne comme Resend : créez un transport avec vos paramètres SMTP et appelez sendMail avec from, to, replyTo, subject et text.
  • Pour la version Ikiform, définissez les cinq champs dans l'onglet Fields du formulaire avec budget en select, pour qu'une mauvaise valeur reçoive 422 validation_failed avec un message par champ.
Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur. Chaque exemple serveur transmet l'en-tête Origin du navigateur quand il existe, car le fetch d'un serveur n'en envoie aucun et Ikiform s'en sert pour compter les soumissions de test gratuites depuis localhost.

Il existe une troisième option : se passer de l'action et laisser le navigateur envoyer directement à Ikiform. Aucune clé secrète n'intervient, donc la limite de débit Standard et Allowed domains s'appliquent par visiteur. Le guide Next.js de la documentation montre le composant client.

Vérifiez que tout fonctionne

  1. Envoyez avec un budget vide. L'erreur s'affiche sous le select et rien d'autre n'est perdu.
  2. Envoyez une vraie demande. La phrase de succès remplace le formulaire.
  3. Ouvrez l'e-mail et cliquez sur Reply. La ligne To contient l'adresse que vous avez saisie.
  4. Remplissez le honeypot masqué avec les outils de développement du navigateur et envoyez. Vous voyez un succès, et aucun e-mail n'arrive.
  5. Ouvrez Submissions dans le tableau de bord. La nouvelle soumission y est, avec tous les champs envoyés. Cette étape ne concerne que la version Ikiform.

Pièges

  • Échappement dans les e-mails HTML. La version Resend envoie text. Si vous passez à html, échappez d'abord chaque valeur, sinon un visiteur peut glisser du markup et des liens dans votre boîte.
  • Injection d'en-têtes. Ne mettez jamais une valeur brute du formulaire dans from ou to. replyTo est sûr avec Resend car Zod a déjà vérifié qu'il s'agit d'un e-mail.
  • Variables d'environnement. RESEND_API_KEY et IKIFORM_SECRET_KEY ne doivent pas commencer par NEXT_PUBLIC_, sinon Next.js les envoie au navigateur.
  • Envois en double. Un double clic peut exécuter l'action deux fois. L'état en attente désactive le bouton, mais un réseau lent peut quand même provoquer une course ; l'option idempotencyKey de Resend, ou l'en-tête Idempotency-Key d'Ikiform, fait d'une répétition une opération sans effet.
  • Les Server Actions sont des endpoints publics. N'importe qui peut en appeler une directement : c'est pourquoi la validation et les vérifications anti-spam doivent s'exécuter à l'intérieur, pas seulement dans le formulaire.

Si quelque chose ne va pas

ProblèmeQue faire
Resend répond que le domaine n'est pas vérifiéAjoutez les enregistrements DNS de Resend pour le domaine utilisé dans from, ou envoyez depuis l'adresse de test de Resend pendant le développement.
429 rate_limitedUn visiteur a envoyé plus que ce que permet la limite de débit du formulaire. Standard autorise 5 envois par minute et par visiteur. Attendez la durée de Retry-After, ou choisissez Relaxed ou Custom pour les formulaires qui envoient depuis une seule IP partagée.
422 validation_failedL'envoi ne correspond pas à la définition des champs du formulaire. error.fields nomme chaque champ et la raison. Faites correspondre le name de l'input à l'id du champ.
L'action fonctionne en local mais pas en productionLa clé manque dans les variables d'environnement du déploiement. Ajoutez-la à l'environnement de production et redéployez.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.