Repository Public MIT
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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