My App

ADR-09 — Validation email via Reacher

Check de délivrabilité avant d'envoyer un check-in email — éviter les hard bounces

Statut : Accepté Date : 2026-04 Sujet : Validation de délivrabilité des emails avant envoi

Contexte

Le cœur du flow Bell démarre par un email de check-in envoyé par la staff à l'arrivée d'une réservation. Si cet email bounce (adresse invalide, domaine mort, typo staff), le guest ne reçoit jamais son lien et arrive à la réception sans pré-check-in.

Dans l'ancien projet :

  • Aucune validation pré-envoi
  • Les hard bounces étaient invisibles (pas de monitoring des retours SMTP)
  • La staff découvrait le problème quand le guest se plaignait à la réception
  • Réputation SMTP dégradée à force d'envoyer à des adresses mortes (Gmail, Outlook commencent à flaguer)

Statistique interne estimée : 3–5 % des check-in emails bouncent (emails mal tapés, @gmial.com au lieu de @gmail.com, domaines d'entreprise typés en panique au check-in à la réception).

Alternatives considérées

Option 1 — Ne rien faire

Statut quo, on envoie et on croise les doigts.

Contre : voir contexte. Mauvaise UX guest + réputation SMTP en berne.

Option 2 — Validation SaaS (ZeroBounce, Mailgun Validate, SendGrid Validation)

Pour :

  • Ultra-simple à intégrer (API REST)
  • Base de données de domaines/DNS maintenue par un tiers

Contre :

  • Coût au volume (Mailgun : ~$80/mois pour 10k validations)
  • Données PII envoyées à un tiers — question RGPD pour emails guests
  • Dépendance externe supplémentaire

Option 3 — Reacher (retenu)

Reacher est un open source email checker qui vérifie :

  • Syntaxe (format RFC, typos communs comme @gmial.com)
  • DNS (le domaine a-t-il un MX record valide ?)
  • SMTP (le serveur MX accepte-t-il effectivement ce mailbox ? — handshake sans envoyer l'email)
  • Disposable providers (mailinator, 10minutemail, etc.)
  • Role accounts (admin@, info@, support@)

Pour :

  • Open source, self-host en Docker (1 container)
  • API REST simple (POST /v0/check_email)
  • Gratuit à l'usage (pas de limite)
  • Données restent chez nous — RGPD clean
  • Mainteneur actif, projet sérieux

Contre :

  • Vérification SMTP prend 1–3 secondes par email (acceptable car async)
  • Certains serveurs (Yahoo notamment) bloquent les checks SMTP — Reacher retourne "Risky" dans ce cas
  • Maintenance Docker container (bénin)

Décision

Reacher self-hosté dans notre Docker Compose, appelé avant chaque envoi d'email critique (check-in, magic link, password reset, invitation staff).

Intégration

// packages/api/src/services/email-validator.ts
export class EmailValidator {
  constructor(private baseUrl: string, private enabled: boolean) {}

  async verify(email: string): Promise<EmailCheckResult> {
    if (!this.enabled) return { reachable: "unknown" };

    const res = await fetch(`${this.baseUrl}/v0/check_email`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ to_email: email, from_email: "noreply@hoaiy.com" }),
    });

    const data = await res.json();
    return {
      reachable: data.is_reachable, // "safe" | "risky" | "invalid" | "unknown"
      reason: data.mx?.records ?? data.smtp?.error_message,
    };
  }
}

Règles d'envoi

Verdict ReacherAction
safeEnvoyer
riskyEnvoyer avec warning logué (email valide mais serveur flaky, Yahoo, etc.)
unknownEnvoyer avec warning (Reacher n'a pas pu déterminer — ne pas bloquer)
invalidBloquer et remonter une erreur à la staff

Le check se fait avant l'enqueue BullMQ du job send-email :

// packages/api/src/modules/cardex/cardex.service.ts
export async function sendCheckInEmail(guestId: string) {
  const [g] = await db.select().from(guest).where(eq(guest.id, guestId));
  if (!g.email) throw new BadRequestError("Guest has no email");

  const check = await emailValidator.verify(g.email);

  if (check.reachable === "invalid") {
    throw new ValidationError({
      field: "email",
      message: `Adresse email invalide : ${check.reason ?? "unknown"}. Merci de corriger avant d'envoyer.`,
    });
  }

  if (check.reachable === "risky" || check.reachable === "unknown") {
    logger.warn({ email: hashEmail(g.email), verdict: check.reachable, reason: check.reason },
      "Sending check-in email with risky verdict");
  }

  await queues.email.add("check-in", {
    to: g.email,
    template: "checkIn",
    data: { firstName: g.firstName, hotelName: ..., checkInUrl: ... },
  });

  await db.update(guest).set({
    checkInStatus: "invited",
    checkInInvitedAt: new Date(),
  }).where(eq(guest.id, guestId));
}

Feedback UI staff

Côté dashboard, la mutation sendCheckInEmail peut retourner une ValidationError. On l'affiche en toast :

Adresse invalidealice@gmial.com : le domaine gmial.com n'existe pas. Correction suggérée : gmail.com.

Plus tard, on peut suggérer automatiquement le fix (Levenshtein distance sur les domaines top), mais pas MVP.

Configuration

apps/server/.env
REACHER_ENABLED=true
REACHER_URL=http://reacher:8080

En dev, Reacher dans le docker-compose.yml. En prod, même idée, container dédié.

Flag REACHER_ENABLED=false désactive le check entièrement (utile pour tests, dev hors ligne, ou si Reacher est en maintenance).

Cas où on ne check PAS

  • Emails envoyés à un user déjà authentifié (on a déjà validé à l'inscription)
  • Emails envoyés par la staff à elle-même (tests internes)
  • Emails de notifications système vers ops@hoaiy.com

Conséquences

Positives :

  • Taux de bounce divisé par ~5 attendu
  • Réputation SMTP préservée
  • Erreurs de saisie staff attrapées à la source
  • Données PII guest ne sortent pas de notre infra
  • Coût zéro (self-host)

Négatives :

  • Latence ajoutée au flow sendCheckInEmail (+1–3s) — acceptable car appel async déclenché par un clic staff, pas par un guest
  • Reacher à maintenir (container, upgrade, monitoring)
  • Yahoo et quelques providers retournent toujours "risky" — on devra tolérer ce bruit

Métriques à surveiller

  • Taux de bounce SMTP mesuré côté Gmail Workspace (cible : < 1 %, avant c'était estimé 3–5 %)
  • Distribution des verdicts Reacher (safe / risky / invalid / unknown) par hôtel
  • Latence p95 de Reacher (cible : < 5s, au-delà on envoie en fire-and-forget sans attendre le verdict)
  • Nombre de corrections staff post-invalid (combien de fois un email est fixé et renvoyé) — pour prioriser un auto-suggest futur
  • Uptime de Reacher (cible : > 99 %, car on ne bloque pas l'envoi si Reacher down)

On this page