My App
Intégrations

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 email). Reacher valide la délivrabilité avant enqueue. SMTP Gmail Workspace avec app password. Templates HTML responsive, logo inlined.

Stack

CoucheTechno
Validation pré-envoiReacher self-hosté (Docker)
TemplatesHTML avec placeholders inline
RenduFonctions pures packages/auth/src/mail/*.ts
TransportNodemailer + SMTP Gmail Workspace
QueueBullMQ email queue
Retries3 attempts, backoff exp 60s

Flow complet

Templates

5 templates maintenus :

TemplateSujetDestinataireDéclencheur
checkInBell — votre check-in à {hotel}Gueststaff clique "Send check-in email"
invitationBell — Invitation à rejoindre {hotel}Staff invitémanager invite un nouveau staff
magicLinkBell — Votre lien de connexionGuest / Staffuser demande magic link
passwordResetBell — Réinitialisation du mot de passeGuest / Staffuser demande reset
welcomeBienvenue sur BellGuest / Staffpremier signup

Tous en HTML responsive, logo HOAIY inlined en base64, palette Bell (brown + orange), texte principal en fallback plain-text.

Exemple : checkIn

packages/auth/src/mail/check-in.ts
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

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" };

    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

VerdictAction
safeEnvoyer
riskyEnvoyer + log warn
unknownEnvoyer + log warn
invalidBloquer + 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.com ou autres adresses connues
  • Test emails explicites (flag skipVerification: true dans options)

SMTP

Provider : Gmail Workspace

  • Host : smtp.gmail.com
  • Port : 587 (STARTTLS) ou 465 (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

packages/auth/src/mail/sender.ts
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 :

apps/worker/src/workers/email.worker.ts
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.send avec attributs template, smtp.duration, reacher.verdict
  • Metric business : compteur bell.emails.sent{template} et bell.emails.bounced
  • Alerte : si > 5 bounces en 1 heure → alert ops (signale un problème SMTP ou reputation)

Tests

  • Unitaires : renderCheckInHtml avec 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
docker-compose.yml (dev only)
mailhog:
  image: mailhog/mailhog:latest
  profiles: [email-dev]
  ports:
    - "1025:1025"           # SMTP
    - "8025:8025"           # Web UI

En 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.ts
  • user.locale détecte la langue
  • Fallback FR

Lien

On this page