Emails (SMTP + Reacher)
Templates Bell (check-in, invitation, magic link, password reset), validation Reacher avant envoi, SMTP Gmail Workspace
Tout email transactionnel passe par BullMQ (queue
Stack
| Couche | Techno |
|---|---|
| Validation pré-envoi | Reacher self-hosté (Docker) |
| Templates | HTML avec placeholders inline |
| Rendu | Fonctions pures packages/auth/src/mail/*.ts |
| Transport | Nodemailer + SMTP Gmail Workspace |
| Queue | BullMQ email queue |
| Retries | 3 attempts, backoff exp 60s |
Flow complet
Templates
5 templates maintenus :
| Template | Sujet | Destinataire | Déclencheur |
|---|---|---|---|
checkIn | Bell — votre check-in à {hotel} | Guest | staff clique "Send check-in email" |
invitation | Bell — Invitation à rejoindre {hotel} | Staff invité | manager invite un nouveau staff |
magicLink | Bell — Votre lien de connexion | Guest / Staff | user demande magic link |
passwordReset | Bell — Réinitialisation du mot de passe | Guest / Staff | user demande reset |
welcome | Bienvenue sur Bell | Guest / Staff | premier signup |
Tous en HTML responsive, logo HOAIY inlined en base64, palette Bell (brown + orange), texte principal en fallback plain-text.
Exemple : checkIn
export function checkInEmail(params: {
guestName: string;
hotelName: string;
roomNumber?: string;
checkInDate: string;
checkOutDate: string;
checkInUrl: string;
}): { subject: string; html: string; text: string } {
return {
subject: `Bell — votre check-in à ${params.hotelName}`,
html: renderCheckInHtml(params),
text: renderCheckInText(params),
};
}Le HTML est un template literal avec styles inline (gmail n'aime pas <style> en head), logo HOAIY base64, CTA button orange arrondi.
Reacher
Pourquoi avant SMTP
Stats internes estimées : 3-5 % des emails check-in bouncent à cause de typos staff (@gmial.com, @outlok.com). Sans Reacher :
- L'email disparaît silencieusement
- Le guest arrive sans pré-check-in
- Staff découvre le bug à la réception
- Réputation Gmail Workspace dégradée (trop de hard bounces → flagué spammeur)
Avec Reacher :
- Pré-check syntaxe + DNS + SMTP handshake
- Verdict
invalid= on bloque l'envoi, on remonte une erreur UI claire au staff - Verdict
risky= on log + on envoie (yahoo, etc. bloquent les checks SMTP)
Wrapper
export class EmailValidator {
constructor(private baseUrl: string, private enabled: boolean) {}
async verify(email: string): Promise<EmailCheckResult> {
if (!this.enabled) return { reachable: "unknown" };
try {
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",
}),
signal: AbortSignal.timeout(8_000), // Reacher lent parfois
});
if (!res.ok) return { reachable: "unknown" };
const data = await res.json();
return {
reachable: data.is_reachable, // "safe" | "risky" | "invalid" | "unknown"
reason: data.mx?.records ?? data.smtp?.error_message,
};
} catch (err) {
logger.warn({ err, email: hashEmail(email) }, "Reacher verify failed, defaulting to unknown");
return { reachable: "unknown" };
}
}
}Si Reacher est down ou lent, on n'empêche jamais l'envoi — on log et on continue en unknown. Reacher est une assurance, pas un bottleneck.
Règles d'action
| Verdict | Action |
|---|---|
safe | Envoyer |
risky | Envoyer + log warn |
unknown | Envoyer + log warn |
invalid | Bloquer + throw ValidationError |
Cas où on skip la vérif
- Emails à un user déjà authentifié (on a validé à l'inscription)
- Emails internes vers
ops@hoaiy.comou autres adresses connues - Test emails explicites (flag
skipVerification: truedans options)
SMTP
Provider : Gmail Workspace
- Host :
smtp.gmail.com - Port :
587(STARTTLS) ou465(SSL) - Auth : App Password (pas le mot de passe Gmail normal — généré dans Google Account → 2FA → App passwords)
- From :
Bell <noreply@hoaiy.com> - Reply-To :
ops@hoaiy.com
Limites
- 500 emails/jour sur un compte Gmail Workspace standard
- À 3 hôtels × 10 check-in emails/jour = 30/jour → pas de souci
- À 20 hôtels × 30 emails/jour = 600/jour → upgrade vers un SMTP dédié (SendGrid, Postmark, Mailgun) ou passer à un plan Workspace supérieur
Code
import nodemailer from "nodemailer";
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: Number(process.env.SMTP_PORT) === 465,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
export async function sendMail(params: {
to: string;
subject: string;
html: string;
text: string;
replyTo?: string;
}) {
return transporter.sendMail({
from: process.env.SMTP_FROM,
replyTo: params.replyTo ?? "ops@hoaiy.com",
to: params.to,
subject: params.subject,
html: params.html,
text: params.text,
});
}Queue email
Toute l'orchestration est dans le worker BullMQ :
import { Worker } from "bullmq";
import { sendMail } from "@bell/auth/mail";
import { emailValidator } from "@bell/api/services";
import { renderTemplate } from "@bell/auth/mail/templates";
import { logger, hashEmail } from "@bell/observability";
export const emailWorker = new Worker(
"email",
async (job) => {
const { template, to, data, skipValidation } = job.data;
if (!skipValidation) {
const check = await emailValidator.verify(to);
if (check.reachable === "invalid") {
// Not retryable
throw new NonRetriableEmailError(`invalid email: ${check.reason}`);
}
if (check.reachable === "risky" || check.reachable === "unknown") {
logger.warn({ emailHash: hashEmail(to), verdict: check.reachable }, "Sending with risky verdict");
}
}
const { subject, html, text } = renderTemplate(template, data);
await sendMail({ to, subject, html, text });
logger.info({ emailHash: hashEmail(to), template }, "Email sent");
return { ok: true };
},
{
connection: redis,
concurrency: Number(process.env.WORKER_CONCURRENCY_EMAIL ?? 20),
},
);Observabilité
- Log chaque email : template name + email hash + timestamp (jamais l'email en clair)
- Trace OpenTelemetry : span
email.sendavec attributstemplate,smtp.duration,reacher.verdict - Metric business : compteur
bell.emails.sent{template}etbell.emails.bounced - Alerte : si > 5 bounces en 1 heure → alert ops (signale un problème SMTP ou reputation)
Tests
- Unitaires :
renderCheckInHtmlavec différents inputs, vérifier la présence du CTA et l'échappement XSS - Intégration : via Mailhog (container dev) qui simule un SMTP et expose un web UI pour voir les emails reçus
- E2E : Playwright consulte Mailhog pour vérifier qu'un check-in email a bien été généré après action staff
mailhog:
image: mailhog/mailhog:latest
profiles: [email-dev]
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UIEn dev, on point SMTP_HOST=mailhog, SMTP_PORT=1025, SMTP_USER=, SMTP_PASS= pour capturer tous les emails sans les envoyer vraiment.
Internationalisation (futur)
Pas MVP. Templates FR uniquement. Si on doit supporter EN pour un hôtel étranger :
packages/auth/src/mail/templates/<template>.fr.ts+.en.tsuser.localedétecte la langue- Fallback FR
Lien
- ADR-09 Reacher
- ADR-04 jobs BullMQ
- Operations concurrency
- Onboarding hôtel — comment le first check-in est envoyé