// Transport courriel : Resend (SDK officiel, clé serveur) ou simulation locale (dev/tests : écrit dans data/outbox, jamais d'envoi réel). import { Resend } from "resend"; import { appendFileSync, mkdirSync } from "node:fs"; import path from "node:path"; import { db } from "@/db/client"; import { emailDeliveries } from "@/db/schema"; import { emailHash } from "./crypto"; export interface Mail { to: string; subject: string; html: string; text: string; template: string } export type Transport = { name: string; send(m: Mail): Promise<{ id: string | null }> }; const outboxDir = path.join(process.env.STORAGE_DIR ? path.dirname(process.env.STORAGE_DIR) : path.join(process.cwd(), "data"), "outbox"); export const mockTransport: Transport = { name: "mock", 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 }; }, }; export const lastMockMail = { set: null as Mail | null }; // utilisé par les tests function resendTransport(key: string): Transport { const r = new Resend(key); const from = process.env.RESEND_FROM_EMAIL ?? "connexion@toveli.ca"; 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 }; } }; } let override: Transport | null = null; export function setTransportForTests(t: Transport | null) { override = t; } export function transport(): Transport { if (override) return override; const key = process.env.RESEND_API_KEY; if (key && process.env.NODE_ENV === "production") return resendTransport(key); if (key && process.env.RESEND_FORCE === "1") return resendTransport(key); return mockTransport; } export async function sendMail(m: Mail): Promise { const t = transport(); let status = "SENT", providerId: string | null = null, error: string | null = null; try { const r = await t.send(m); providerId = r.id; if (t.name === "mock") { status = "MOCKED"; lastMockMail.set = m; } } catch (e) { status = "FAILED"; error = (e as Error).message.slice(0, 300); console.error("[mail] échec", m.template, error); } try { await db().insert(emailDeliveries).values({ toHash: emailHash(m.to), template: m.template, providerId, status, error }); } catch { /* journal best-effort */ } if (status === "FAILED") throw new Error("mail_failed"); } const layout = (title: string, body: string) => `
toveli.

${title}

${body}

Toveli · toveli.ca · Ce message est envoyé automatiquement, merci de ne pas y répondre.

`; export function otpMail(to: string, code: string): Mail { const html = layout("Votre code de connexion", `

Entrez ce code sur Toveli pour vous connecter. Il est valable 10 minutes et ne peut servir qu'une fois.

${code}

Si vous n'avez rien demandé, ignorez ce message : personne ne peut se connecter sans ce code.

`); 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" }; } export function notifyMail(to: string, title: string, body: string, ctaUrl: string, ctaLabel: string, template: string): Mail { const html = layout(title, `

${body}

${ctaLabel}

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

`); return { to, subject: `${title} · Toveli`, html, text: `${title}\n\n${body}\n\n${ctaUrl}`, template }; }