Bêta privéeMailbeam est en bêta privée : l'API publique n'est pas encore ouverte.Rejoindre la liste d'attente

Mailbeam
React + hook proprioIntermédiaire20 minutesMis à jour en janvier 2025

Vérification d'e-mails en React

Ce tutoriel construit un hook de vérification d'e-mails en temps réel pour React. Pendant que l'utilisateur saisit son adresse, le hook temporise les requêtes vers votre back-end, qui appelle Mailbeam. Le résultat pilote les états de l'interface en ligne : indicateur de chargement, message d'erreur ou coche verte.

Ce que vous allez construire

  • Un hook useEmailVerification temporisé
  • Un endpoint de vérification (Route Handler Next.js ou Express)
  • Un composant EmailInput accessible, avec état en ligne
  • Son intégration dans un formulaire d'inscription

Prérequis

  • Un projet React 18+
  • Un back-end auquel vous pouvez ajouter une route
  • Une clé d'API Mailbeam (inscription gratuite)

Étape 1 — Créer la route d'API

Le hook appelle votre back-end, qui appelle Mailbeam. La clé d'API reste ainsi côté serveur.

Route Handler Next.js :

// app/api/verify-email/route.ts
import { NextResponse } from "next/server";
// Pas encore de SDK : voici le wrapper de 12 lignes de /docs/quickstart.
import { verifyEmail } from "./lib/mailbeam";

export async function POST(request: Request) {
  const { email } = await request.json();

  if (!email || typeof email !== "string") {
    return NextResponse.json({ error: "email is required" }, { status: 400 });
  }

  try {
    const result = await verifyEmail(email);
    return NextResponse.json(result);
  } catch {
    // Échec permissif — on renvoie une réponse « valide » pour ne pas bloquer le formulaire
    return NextResponse.json({ valid: true, score: 50, reason: null });
  }
}

Équivalent Express :

// routes/verifyEmail.js
// Pas encore de SDK : voici le wrapper de 12 lignes de /docs/quickstart.
import { verifyEmail } from "./lib/mailbeam";

router.post("/api/verify-email", async (req, res) => {
  const { email } = req.body;
  try {
    const result = await verifyEmail(email);
    res.json(result);
  } catch {
    res.json({ valid: true, score: 50, reason: null }); // échec permissif
  }
});

Étape 2 — Construire le hook

// hooks/useEmailVerification.ts
import { useState, useEffect, useRef } from "react";

export type VerificationStatus = "idle" | "loading" | "valid" | "invalid";

export interface VerificationResult {
  valid: boolean;
  score: number;
  reason: string | null;
}

export interface UseEmailVerificationReturn {
  status: VerificationStatus;
  result: VerificationResult | null;
  errorMessage: string | null;
}

const REASON_MESSAGES: Record<string, string> = {
  invalid_syntax: "Merci de vérifier le format de l'adresse.",
  no_mx_records: "Ce domaine ne semble pas accepter de courrier.",
  smtp_rejected: "Cette adresse e-mail ne semble pas exister.",
  disposable_domain: "Merci d'utiliser une adresse permanente, pas une adresse temporaire.",
  role_address: "Merci d'utiliser une adresse e-mail personnelle.",
  catch_all_unverifiable: "Nous n'avons pas pu vérifier complètement cette adresse — merci de la relire.",
};

export function useEmailVerification(
  email: string,
  { debounceMs = 600, minScore = 60 }: { debounceMs?: number; minScore?: number } = {}
): UseEmailVerificationReturn {
  const [status, setStatus] = useState<VerificationStatus>("idle");
  const [result, setResult] = useState<VerificationResult | null>(null);
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    // Remise à zéro si l'adresse est vide ou visiblement incomplète
    if (!email || !email.includes("@") || !email.includes(".")) {
      setStatus("idle");
      setResult(null);
      return;
    }

    setStatus("loading");

    const timer = setTimeout(async () => {
      // Annule toute requête encore en vol
      abortRef.current?.abort();
      abortRef.current = new AbortController();

      try {
        const res = await fetch("/api/verify-email", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ email }),
          signal: abortRef.current.signal,
        });

        if (!res.ok) throw new Error(`HTTP ${res.status}`);

        const data: VerificationResult = await res.json();
        setResult(data);
        setStatus(data.valid && data.score >= minScore ? "valid" : "invalid");
      } catch (err) {
        if ((err as Error).name === "AbortError") return;
        // Échec permissif — ne bloquez pas le formulaire sur une erreur de fetch
        setStatus("idle");
      }
    }, debounceMs);

    return () => {
      clearTimeout(timer);
      abortRef.current?.abort();
    };
  }, [email, debounceMs, minScore]);

  const errorMessage =
    status === "invalid" && result?.reason
      ? (REASON_MESSAGES[result.reason] ?? "Merci de fournir une adresse e-mail valide.")
      : null;

  return { status, result, errorMessage };
}

Étape 3 — Créer le composant EmailInput

// components/EmailInput.tsx
import { useId } from "react";
import { type UseEmailVerificationReturn } from "@/hooks/useEmailVerification";

interface EmailInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  verification: UseEmailVerificationReturn;
  label?: string;
}

export function EmailInput({
  verification,
  label = "Adresse e-mail",
  ...inputProps
}: EmailInputProps) {
  const id = useId();
  const errorId = `${id}-error`;
  const { status, errorMessage } = verification;

  return (
    <div className="space-y-1">
      <label
        htmlFor={id}
        className="block text-sm font-medium text-foreground"
      >
        {label}
      </label>

      <div className="relative">
        <input
          id={id}
          type="email"
          autoComplete="email"
          aria-invalid={status === "invalid"}
          aria-describedby={status === "invalid" ? errorId : undefined}
          className={`
            w-full rounded-lg border px-3 py-2 pr-9 text-sm bg-background
            focus:outline-none focus:ring-2 focus:ring-ring
            ${status === "invalid" ? "border-destructive" : ""}
            ${status === "valid" ? "border-green-500" : "border-border"}
          `}
          {...inputProps}
        />

        {/* Indicateur d'état */}
        <span
          className="absolute right-3 top-1/2 -translate-y-1/2 text-sm"
          aria-hidden="true"
        >
          {status === "loading" && (
            <span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
          )}
          {status === "valid" && <span className="text-green-500">✓</span>}
          {status === "invalid" && <span className="text-destructive">✗</span>}
        </span>
      </div>

      {errorMessage && (
        <p id={errorId} role="alert" className="text-xs text-destructive">
          {errorMessage}
        </p>
      )}
    </div>
  );
}

Étape 4 — L'utiliser dans un formulaire d'inscription

// app/signup/page.tsx
"use client";

import { useState } from "react";
import { EmailInput } from "@/components/EmailInput";
import { useEmailVerification } from "@/hooks/useEmailVerification";

export default function SignupPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const verification = useEmailVerification(email);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    // Bloque l'envoi pendant la vérification ou après un échec
    if (verification.status === "loading" || verification.status === "invalid") {
      return;
    }

    const res = await fetch("/api/auth/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });

    if (res.ok) {
      window.location.href = "/dashboard";
    }
  }

  const canSubmit =
    verification.status === "valid" || verification.status === "idle";

  return (
    <main className="flex min-h-screen items-center justify-center px-4">
      <form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
        <h1 className="text-2xl font-bold">Créer un compte</h1>

        <EmailInput
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          verification={verification}
        />

        <div>
          <label htmlFor="password" className="block text-sm font-medium mb-1">
            Mot de passe
          </label>
          <input
            id="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full rounded-lg border border-border px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
          />
        </div>

        <button
          type="submit"
          disabled={!canSubmit || !email || !password}
          className="w-full rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
        >
          Créer le compte
        </button>
      </form>
    </main>
  );
}

Bonnes pratiques

PratiquePourquoi
Temporiser à 600 msAttend que l'utilisateur ait fini de taper avant de lancer une requête
Annuler les requêtes en volÉvite les conditions de course quand l'adresse change vite
Échec permissif sur les erreurs de fetchUn souci réseau ne doit pas bloquer le formulaire
aria-invalid et aria-describedbyÉtat d'erreur accessible aux lecteurs d'écran
Conditionner l'envoi à "valid" | "idle"Interdit l'envoi en pleine vérification

Liste de contrôle avant la mise en production

  • La route d'API ne vit que côté serveur (clé d'API absente du bundle client)
  • Temporisation ajustée à votre expérience utilisateur (600 ms est un bon point de départ)
  • Messages d'erreur exploitables (« utilisez une adresse permanente » plutôt que « adresse invalide »)
  • Formulaire accessible : aria-invalid, aria-describedby, rôles définis
  • Échec permissif testé : une erreur de fetch doit toujours permettre l'envoi

Prochaines étapes