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 Reacher | Action |
|---|---|
safe | Envoyer |
risky | Envoyer avec warning logué (email valide mais serveur flaky, Yahoo, etc.) |
unknown | Envoyer avec warning (Reacher n'a pas pu déterminer — ne pas bloquer) |
invalid | Bloquer 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 invalide —
alice@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
REACHER_ENABLED=true
REACHER_URL=http://reacher:8080En 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)
ADR-08 — Monitoring via Signoz + OpenTelemetry
Signoz self-hosté comme stack observabilité unifiée (traces, metrics, logs)
ADR-10 — AI provider + RAG strategy
Claude Haiku 4.5 default concierge, Sonnet 4.6 Enterprise, quotas par plan, prompt caching ON, RAG via pgvector prêt mais désactivé MVP, pas de fine-tuning