SPB Git

Repository Public MIT

mail.ts 12.2 KB 230 lines typescript

1/**2 * Envoi de courriels transactionnels via Resend (API HTTP, sans SDK).3 * Gabarits HTML de marque VQuant + repli texte brut.4 */5import { logger } from "../utils/logger";67const RESEND_API_KEY = process.env.RESEND_API_KEY || "";8const MAIL_FROM = process.env.MAIL_FROM || "VQuant <no-reply@vquant.ai>";9const APP_URL = (process.env.APP_URL || "https://www.vquant.ai").replace(/\/$/, "");1011export interface MailMessage {12  to: string;13  subject: string;14  html: string;15  text: string;16  tags?: Record<string, string>;17}1819export class MailError extends Error {20  constructor(message: string, public status?: number) {21    super(message);22    this.name = "MailError";23  }24}2526export function isMailConfigured(): boolean {27  return RESEND_API_KEY.length > 0;28}2930export async function sendMail(msg: MailMessage): Promise<{ id: string }> {31  if (!RESEND_API_KEY) throw new MailError("RESEND_API_KEY manquante");32  const body = {33    from: MAIL_FROM,34    to: [msg.to],35    subject: msg.subject,36    html: msg.html,37    text: msg.text,38    tags: msg.tags ? Object.entries(msg.tags).map(([name, value]) => ({ name, value })) : undefined,39  };40  let lastErr: unknown;41  for (let attempt = 0; attempt < 3; attempt++) {42    try {43      const res = await fetch("https://api.resend.com/emails", {44        method: "POST",45        headers: { Authorization: `Bearer ${RESEND_API_KEY}`, "Content-Type": "application/json" },46        body: JSON.stringify(body),47        signal: AbortSignal.timeout(12_000),48      });49      if (res.ok) {50        const data = (await res.json()) as { id: string };51        logger.info(`mail sent → ${maskEmail(msg.to)} (${msg.subject}) id=${data.id}`);52        return data;53      }54      const txt = await res.text();55      // 4xx (sauf 429) : inutile de réessayer56      if (res.status >= 400 && res.status < 500 && res.status !== 429) {57        throw new MailError(`Resend ${res.status}: ${txt.slice(0, 200)}`, res.status);58      }59      lastErr = new MailError(`Resend ${res.status}: ${txt.slice(0, 200)}`, res.status);60    } catch (e) {61      if (e instanceof MailError && e.status && e.status < 500 && e.status !== 429) throw e;62      lastErr = e;63    }64    await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));65  }66  throw lastErr instanceof Error ? lastErr : new MailError("Envoi impossible");67}6869export function maskEmail(email: string): string {70  const [user, domain] = email.split("@");71  if (!domain) return email;72  const head = user.slice(0, Math.min(2, user.length));73  return `${head}${"•".repeat(Math.max(2, Math.min(6, user.length - 2)))}@${domain}`;74}7576/* ─── Gabarits ───────────────────────────────────────────────────────────── */7778function esc(s: string) {79  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string);80}8182interface LayoutOpts {83  preheader: string;84  title: string;85  intro: string;86  code?: string;87  expiresMin?: number;88  ctaLabel?: string;89  ctaUrl?: string;90  footnote?: string;91  details?: { label: string; value: string }[];92}9394function layout(o: LayoutOpts): string {95  const codeBlock = o.code96    ? `<tr><td style="padding:8px 0 4px">97         <div style="margin:0 auto;display:inline-block;padding:16px 28px;border-radius:14px;background:#0f1117;color:#fff;font-family:'JetBrains Mono',SFMono-Regular,Menlo,monospace;font-size:34px;letter-spacing:10px;font-weight:600;line-height:1">${esc(o.code)}</div>98         ${o.expiresMin ? `<p style="margin:12px 0 0;color:#6b7280;font-size:13px">Ce code expire dans <strong style="color:#111827">${o.expiresMin} minutes</strong>. Ne le partagez avec personne.</p>` : ""}99       </td></tr>`100    : "";101  const cta = o.ctaLabel && o.ctaUrl102    ? `<tr><td style="padding:12px 0 4px"><a href="${esc(o.ctaUrl)}" style="display:inline-block;padding:12px 22px;border-radius:12px;background:linear-gradient(135deg,#ff6a1a,#ff2d78);color:#fff;text-decoration:none;font-weight:600;font-size:14px">${esc(o.ctaLabel)}</a></td></tr>`103    : "";104  const details = o.details && o.details.length105    ? `<tr><td style="padding:14px 0 0"><table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border:1px solid #e5e7eb;border-radius:12px;font-size:13px;color:#374151">106        ${o.details.map((d, i) => `<tr><td style="padding:9px 14px;border-top:${i ? "1px solid #f1f5f9" : "0"};color:#6b7280;width:40%">${esc(d.label)}</td><td style="padding:9px 14px;border-top:${i ? "1px solid #f1f5f9" : "0"};font-weight:600">${esc(d.value)}</td></tr>`).join("")}107       </table></td></tr>`108    : "";109  return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(o.title)}</title></head>110<body style="margin:0;padding:0;background:#f4f5f8;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#111827">111<span style="display:none;max-height:0;overflow:hidden;opacity:0">${esc(o.preheader)}</span>112<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f5f8;padding:32px 12px">113<tr><td align="center">114<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;background:#ffffff;border-radius:20px;overflow:hidden;border:1px solid #e5e7eb">115  <tr><td style="height:6px;background:linear-gradient(90deg,#ff6a1a,#ff2d78)"></td></tr>116  <tr><td style="padding:28px 32px 8px">117    <table role="presentation" cellpadding="0" cellspacing="0"><tr>118      <td style="width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,#ff6a1a,#ff2d78);text-align:center;vertical-align:middle;color:#fff;font-weight:800;font-size:18px;font-family:Manrope,Inter,sans-serif">V</td>119      <td style="padding-left:10px;font-weight:800;font-size:17px;letter-spacing:-.02em;font-family:Manrope,Inter,sans-serif">VQuant</td>120    </tr></table>121  </td></tr>122  <tr><td style="padding:16px 32px 0">123    <h1 style="margin:0 0 10px;font-size:22px;line-height:1.25;letter-spacing:-.02em;font-family:Manrope,Inter,sans-serif">${esc(o.title)}</h1>124    <p style="margin:0;font-size:15px;line-height:1.6;color:#374151">${o.intro}</p>125  </td></tr>126  <tr><td style="padding:18px 32px 0;text-align:center">127    <table role="presentation" width="100%" cellpadding="0" cellspacing="0">${codeBlock}${cta}${details}</table>128  </td></tr>129  <tr><td style="padding:22px 32px 28px">130    <p style="margin:0;font-size:13px;line-height:1.6;color:#6b7280">${o.footnote ?? "Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer ce courriel : aucun changement ne sera effectué sans ce code."}</p>131  </td></tr>132</table>133<p style="max-width:520px;margin:16px auto 0;font-size:12px;line-height:1.6;color:#9ca3af;text-align:center">134  VQuant · Intelligence financière propulsée par l'IA · <a href="${APP_URL}" style="color:#9ca3af">www.vquant.ai</a><br>135  Courriel automatique, merci de ne pas y répondre.136</p>137</td></tr></table></body></html>`;138}139140function textVersion(o: LayoutOpts): string {141  const lines = [`VQuant — ${o.title}`, "", o.intro.replace(/<[^>]+>/g, "")];142  if (o.code) lines.push("", `Code : ${o.code}`, o.expiresMin ? `Expire dans ${o.expiresMin} minutes.` : "");143  if (o.ctaUrl) lines.push("", `${o.ctaLabel} : ${o.ctaUrl}`);144  if (o.details?.length) lines.push("", ...o.details.map((d) => `${d.label} : ${d.value}`));145  lines.push("", o.footnote ?? "Si vous n'êtes pas à l'origine de cette demande, ignorez ce courriel.", "", "www.vquant.ai");146  return lines.join("\n");147}148149function build(to: string, subject: string, o: LayoutOpts, tag: string): MailMessage {150  return { to, subject, html: layout(o), text: textVersion(o), tags: { category: tag } };151}152153export interface CodeContext {154  ip?: string;155  userAgent?: string;156  when?: Date;157}158159function ctxDetails(ctx?: CodeContext) {160  if (!ctx) return [];161  const d: { label: string; value: string }[] = [];162  d.push({ label: "Date", value: (ctx.when ?? new Date()).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short", timeZone: "America/Toronto" }) + " (heure de Montréal)" });163  if (ctx.ip) d.push({ label: "Adresse IP", value: ctx.ip });164  if (ctx.userAgent) d.push({ label: "Appareil", value: summarizeUA(ctx.userAgent) });165  return d;166}167168export function summarizeUA(ua: string): string {169  const browser = /Edg\//.test(ua) ? "Edge" : /OPR\//.test(ua) ? "Opera" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) && /Version\//.test(ua) ? "Safari" : /Firefox\//.test(ua) ? "Firefox" : "Navigateur";170  const os = /iPhone|iPad/.test(ua) ? "iOS" : /Android/.test(ua) ? "Android" : /Mac OS X/.test(ua) ? "macOS" : /Windows/.test(ua) ? "Windows" : /Linux/.test(ua) ? "Linux" : "";171  return [browser, os].filter(Boolean).join(" · ") || "Inconnu";172}173174export const templates = {175  verifyEmail(to: string, name: string, code: string, expiresMin: number): MailMessage {176    return build(to, `${code} — confirmez votre adresse VQuant`, {177      preheader: `Votre code de vérification est ${code}`,178      title: `Bienvenue, ${name}. Confirmez votre courriel.`,179      intro: `Saisissez ce code dans VQuant pour activer votre compte et retrouver vos analyses sur tous vos appareils.`,180      code, expiresMin,181    }, "verify_email");182  },183  loginCode(to: string, name: string, code: string, expiresMin: number, ctx?: CodeContext): MailMessage {184    return build(to, `${code} — votre code de sécurité VQuant`, {185      preheader: `Code de sécurité : ${code}`,186      title: "Code de sécurité pour votre connexion",187      intro: `Bonjour ${esc(name)}, une connexion à votre compte VQuant est en cours depuis un nouvel appareil. Entrez ce code pour la confirmer.`,188      code, expiresMin,189      details: ctxDetails(ctx),190      footnote: "Vous n'essayez pas de vous connecter ? Quelqu'un connaît peut-être votre mot de passe : changez-le dès maintenant depuis www.vquant.ai/auth (Mot de passe oublié).",191    }, "login_mfa");192  },193  resetPassword(to: string, name: string, code: string, expiresMin: number, ctx?: CodeContext): MailMessage {194    return build(to, `${code} — réinitialisation de votre mot de passe`, {195      preheader: `Code de réinitialisation : ${code}`,196      title: "Réinitialisez votre mot de passe",197      intro: `Bonjour ${esc(name)}, saisissez ce code dans VQuant pour choisir un nouveau mot de passe.`,198      code, expiresMin,199      details: ctxDetails(ctx),200    }, "reset_password");201  },202  welcome(to: string, name: string): MailMessage {203    return build(to, "Votre compte VQuant est prêt", {204      preheader: "Compte activé. Bonnes analyses !",205      title: `Compte activé. Bonnes analyses, ${esc(name)} !`,206      intro: `Votre courriel est confirmé. Vos analyses, figures et exports sont maintenant synchronisés sur tous vos appareils. Un code de sécurité vous sera demandé à chaque connexion depuis un nouvel appareil.`,207      ctaLabel: "Ouvrir VQuant", ctaUrl: `${APP_URL}/`,208      footnote: "Conseil : dans Mon compte, vous pouvez révoquer à tout moment les appareils de confiance.",209    }, "welcome");210  },211  passwordChanged(to: string, name: string, ctx?: CodeContext): MailMessage {212    return build(to, "Votre mot de passe VQuant a été modifié", {213      preheader: "Confirmation de changement de mot de passe",214      title: "Mot de passe modifié",215      intro: `Bonjour ${esc(name)}, le mot de passe de votre compte VQuant vient d'être changé. Tous les appareils de confiance ont été déconnectés par précaution.`,216      details: ctxDetails(ctx),217      footnote: "Ce n'était pas vous ? Réinitialisez immédiatement votre mot de passe depuis www.vquant.ai/auth et contactez-nous.",218    }, "password_changed");219  },220  newDevice(to: string, name: string, ctx?: CodeContext): MailMessage {221    return build(to, "Nouvel appareil connecté à VQuant", {222      preheader: "Un nouvel appareil a été ajouté à votre compte",223      title: "Nouvel appareil de confiance",224      intro: `Bonjour ${esc(name)}, un appareil vient d'être marqué comme fiable pour 30 jours : le code de sécurité ne lui sera plus demandé.`,225      details: ctxDetails(ctx),226      footnote: "Ce n'était pas vous ? Révoquez cet appareil depuis Mon compte → Sécurité, puis changez votre mot de passe.",227    }, "new_device");228  },229};230
Files main
Menu

Theme