TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1// Transport courriel : Resend (SDK officiel, clé serveur) ou simulation locale (dev/tests : écrit dans data/outbox, jamais d'envoi réel).2import { Resend } from "resend";3import { appendFileSync, mkdirSync } from "node:fs";4import path from "node:path";5import { db } from "@/db/client";6import { emailDeliveries } from "@/db/schema";7import { emailHash } from "./crypto";89export interface Mail { to: string; subject: string; html: string; text: string; template: string }10export type Transport = { name: string; send(m: Mail): Promise<{ id: string | null }> };1112const outboxDir = path.join(process.env.STORAGE_DIR ? path.dirname(process.env.STORAGE_DIR) : path.join(process.cwd(), "data"), "outbox");13export const mockTransport: Transport = {14 name: "mock",15 async send(m) { mkdirSync(outboxDir, { recursive: true }); const id = `mock-${Date.now()}`; appendFileSync(path.join(outboxDir, "mails.jsonl"), JSON.stringify({ id, at: new Date().toISOString(), ...m }) + "\n"); if (process.env.NODE_ENV !== "production") console.log(`[mail:mock] → ${m.to} « ${m.subject} »`); return { id }; },16};17export const lastMockMail = { set: null as Mail | null }; // utilisé par les tests18function resendTransport(key: string): Transport {19 const r = new Resend(key); const from = process.env.RESEND_FROM_EMAIL ?? "connexion@toveli.ca";20 return { name: "resend", async send(m) { const res = await r.emails.send({ from: `Toveli <${from}>`, to: m.to, subject: m.subject, html: m.html, text: m.text }); if (res.error) throw new Error(res.error.message); return { id: res.data?.id ?? null }; } };21}22let override: Transport | null = null;23export function setTransportForTests(t: Transport | null) { override = t; }24export function transport(): Transport {25 if (override) return override;26 const key = process.env.RESEND_API_KEY;27 if (key && process.env.NODE_ENV === "production") return resendTransport(key);28 if (key && process.env.RESEND_FORCE === "1") return resendTransport(key);29 return mockTransport;30}31export async function sendMail(m: Mail): Promise<void> {32 const t = transport(); let status = "SENT", providerId: string | null = null, error: string | null = null;33 try { const r = await t.send(m); providerId = r.id; if (t.name === "mock") { status = "MOCKED"; lastMockMail.set = m; } }34 catch (e) { status = "FAILED"; error = (e as Error).message.slice(0, 300); console.error("[mail] échec", m.template, error); }35 try { await db().insert(emailDeliveries).values({ toHash: emailHash(m.to), template: m.template, providerId, status, error }); } catch { /* journal best-effort */ }36 if (status === "FAILED") throw new Error("mail_failed");37}3839const layout = (title: string, body: string) => `<!doctype html><html lang="fr"><body style="margin:0;background:#f6f7f9;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#15181d"><div style="max-width:520px;margin:0 auto;padding:32px 16px"><div style="font-size:28px;font-weight:800;letter-spacing:-.02em">toveli<span style="color:#e0263c">.</span></div><div style="background:#fff;border:1px solid #e6e8ec;border-radius:14px;padding:24px;margin-top:16px"><h1 style="font-size:20px;margin:0 0 12px">${title}</h1>${body}</div><p style="font-size:12px;color:#7a8190;margin-top:16px">Toveli · toveli.ca · Ce message est envoyé automatiquement, merci de ne pas y répondre.</p></div></body></html>`;4041export function otpMail(to: string, code: string): Mail {42 const html = layout("Votre code de connexion", `<p style="margin:0 0 16px;color:#4b5260">Entrez ce code sur Toveli pour vous connecter. Il est valable <strong>10 minutes</strong> et ne peut servir qu'une fois.</p><p style="font-size:36px;font-weight:800;letter-spacing:.3em;text-align:center;margin:8px 0 16px;font-variant-numeric:tabular-nums">${code}</p><p style="font-size:13px;color:#7a8190;margin:0">Si vous n'avez rien demandé, ignorez ce message : personne ne peut se connecter sans ce code.</p>`);43 return { to, subject: "Votre code de connexion Toveli", html, text: `Votre code de connexion Toveli : ${code}\nValable 10 minutes, usage unique.\nSi vous n'avez rien demandé, ignorez ce message.`, template: "otp" };44}45export function notifyMail(to: string, title: string, body: string, ctaUrl: string, ctaLabel: string, template: string): Mail {46 const html = layout(title, `<p style="margin:0 0 16px;color:#4b5260">${body}</p><p style="text-align:center;margin:8px 0"><a href="${ctaUrl}" style="display:inline-block;background:#e0263c;color:#fff;text-decoration:none;font-weight:600;padding:12px 20px;border-radius:999px">${ctaLabel}</a></p><p style="font-size:13px;color:#7a8190;margin:12px 0 0">Vous recevez ce message parce que vous avez un compte Toveli. Préférences : ${process.env.APP_BASE_URL ?? "https://www.toveli.ca"}/membre/profil</p>`);47 return { to, subject: `${title} · Toveli`, html, text: `${title}\n\n${body}\n\n${ctaUrl}`, template };48}49