Repository Public
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /api/admin/mail/send — envoi depuis la boîte de l'admin connecté, v3 :3// multipart (pièces jointes) + gabarits Groupe KA (src/lib/comms/mail-templates).4// Le client fournit UNIQUEMENT du texte + l'identifiant du gabarit — l'HTML5// est rendu serveur avec la signature du compte ; from = adresse du compte,6// jamais choisie par le client. Le message et ses pièces jointes sont écrits7// en base AVANT l'envoi (dossier Envoyés) — la DB reste la source de vérité.8import { NextRequest, NextResponse } from "next/server";9import fs from "fs/promises";10import path from "path";11import crypto from "crypto";12import { getAdminSession } from "@/lib/comms/admin-auth";13import { findAdminById, commsDb } from "@/lib/comms/db";14import {15 insertMail,16 insertMailAttachment,17 findMail,18 canAccessMail,19 updateMail,20} from "@/lib/comms/mail";21import { renderMailTemplate, templateById } from "@/lib/comms/mail-templates";22import { sendEmail } from "@/lib/email";2324const MAIL_UPLOAD_DIR = path.join(process.cwd(), "data", "mail-uploads");25const MAX_FILES = 3;26const MAX_FILE_BYTES = 8 * 1024 * 1024; // 8 Mo par fichier27const MAX_TOTAL_BYTES = 15 * 1024 * 1024; // 15 Mo par courriel (limite Resend ~40)2829// Types autorisés en ENVOI (plus large que le formulaire public : usage interne).30const ALLOWED_FILES: Record<string, string[]> = {31 ".pdf": ["application/pdf"],32 ".doc": ["application/msword"],33 ".docx": ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],34 ".xls": ["application/vnd.ms-excel"],35 ".xlsx": ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],36 ".csv": ["text/csv", "application/csv", "application/vnd.ms-excel"],37 ".txt": ["text/plain"],38 ".png": ["image/png"],39 ".jpg": ["image/jpeg"],40 ".jpeg": ["image/jpeg"],41 ".webp": ["image/webp"],42 ".zip": ["application/zip", "application/x-zip-compressed"],43};4445function validEmail(s: string): boolean {46 return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s) && s.length <= 200;47}4849function splitList(v: FormDataEntryValue | null): string[] {50 if (typeof v !== "string") return [];51 return v52 .split(",")53 .map((s) => s.trim().toLowerCase())54 .filter(Boolean)55 .slice(0, 10);56}5758export async function POST(req: NextRequest) {59 const session = await getAdminSession();60 if (!session)61 return NextResponse.json({ error: "Non autorisé." }, { status: 401 });62 const admin = findAdminById(session.id);63 const from = admin?.email?.toLowerCase() ?? "";64 if (!from || !from.endsWith("@groupe-ka.com"))65 return NextResponse.json(66 { error: "Votre compte n'a pas d'adresse @groupe-ka.com — demandez à un super admin de la définir dans Équipe & accès." },67 { status: 422 },68 );6970 const fd = await req.formData().catch(() => null);71 if (!fd)72 return NextResponse.json({ error: "Requête illisible." }, { status: 400 });7374 const to = splitList(fd.get("to"));75 const cc = splitList(fd.get("cc"));76 const subject = (typeof fd.get("subject") === "string" ? (fd.get("subject") as string) : "")77 .trim()78 .slice(0, 300);79 const text = (typeof fd.get("body") === "string" ? (fd.get("body") as string) : "")80 .trim()81 .slice(0, 100_000);82 const template = templateById(83 typeof fd.get("template") === "string" ? (fd.get("template") as string) : "simple",84 );8586 if (!to.length)87 return NextResponse.json({ error: "Au moins un destinataire requis." }, { status: 422 });88 for (const e of [...to, ...cc])89 if (!validEmail(e))90 return NextResponse.json({ error: `Adresse invalide : ${e}` }, { status: 422 });91 if (!subject)92 return NextResponse.json({ error: "Sujet requis." }, { status: 422 });93 if (!text)94 return NextResponse.json({ error: "Message vide." }, { status: 422 });9596 /* ---------- pièces jointes ---------- */97 type Staged = { name: string; mime: string; buf: Buffer };98 const staged: Staged[] = [];99 let totalBytes = 0;100 for (const entry of fd.getAll("files")) {101 if (!(entry instanceof File) || entry.size === 0) continue;102 if (staged.length >= MAX_FILES)103 return NextResponse.json({ error: "Trop de fichiers (3 max)." }, { status: 422 });104 if (entry.size > MAX_FILE_BYTES)105 return NextResponse.json(106 { error: `« ${entry.name} » dépasse 8 Mo.` },107 { status: 413 },108 );109 totalBytes += entry.size;110 if (totalBytes > MAX_TOTAL_BYTES)111 return NextResponse.json(112 { error: "Pièces jointes trop lourdes — 15 Mo au total maximum." },113 { status: 413 },114 );115 const ext = path.extname(entry.name || "").toLowerCase();116 const mimes = ALLOWED_FILES[ext];117 if (!mimes || !mimes.includes(entry.type))118 return NextResponse.json(119 { error: `Format refusé pour « ${entry.name} » — PDF, Office, CSV, image, TXT ou ZIP.` },120 { status: 415 },121 );122 staged.push({123 name: (entry.name || `fichier${ext}`).slice(0, 200),124 mime: entry.type,125 buf: Buffer.from(await entry.arrayBuffer()),126 });127 }128129 // fil de réponse (facultatif)130 let inReplyTo: number | null = null;131 const replyRaw = fd.get("reply_to_id");132 if (typeof replyRaw === "string" && replyRaw) {133 const orig = findMail(Number(replyRaw));134 if (orig && canAccessMail(orig, session.id, session.roles.includes("super_admin")))135 inReplyTo = orig.id;136 }137138 /* ---------- rendu du gabarit (serveur) ---------- */139 const { html, text: textVersion } = renderMailTemplate({140 templateId: template.id,141 subject,142 body: text,143 adminName: admin!.display_name,144 adminEmail: from,145 });146147 /* ---------- écriture en base d'abord ---------- */148 const mailId = insertMail({149 admin_id: session.id,150 direction: "out",151 folder: "sent",152 from_email: from,153 from_name: admin!.display_name,154 to_emails: to,155 cc_emails: cc,156 reply_to: from,157 subject,158 body_text: text,159 body_html: html,160 in_reply_to: inReplyTo,161 is_read: true,162 });163 if (staged.length) {164 const year = String(new Date().getFullYear());165 await fs.mkdir(path.join(MAIL_UPLOAD_DIR, year), { recursive: true });166 for (const s of staged) {167 const stored = `${year}/out-${mailId}-${crypto.randomBytes(8).toString("hex")}${path.extname(s.name).toLowerCase()}`;168 await fs.writeFile(path.join(MAIL_UPLOAD_DIR, stored), s.buf);169 insertMailAttachment({170 message_id: mailId,171 original_name: s.name,172 stored_name: stored,173 mime: s.mime,174 size: s.buf.length,175 });176 }177 }178179 /* ---------- envoi Resend ---------- */180 try {181 const key = process.env.RESEND_API_KEY;182 if (!key) throw new Error("RESEND_API_KEY manquant");183 const res = await fetch("https://api.resend.com/emails", {184 method: "POST",185 headers: {186 Authorization: `Bearer ${key}`,187 "Content-Type": "application/json",188 },189 body: JSON.stringify({190 from: `${admin!.display_name} — Groupe KA <${from}>`,191 to,192 ...(cc.length ? { cc } : {}),193 reply_to: from,194 subject,195 html,196 text: textVersion,197 ...(staged.length198 ? {199 attachments: staged.map((s) => ({200 filename: s.name,201 content: s.buf.toString("base64"),202 })),203 }204 : {}),205 }),206 });207 if (!res.ok) {208 const detail = await res.text().catch(() => "");209 throw new Error(`Resend ${res.status} : ${detail.slice(0, 200)}`);210 }211 const data = (await res.json().catch(() => ({}))) as { id?: string };212 if (data.id)213 commsDb214 .prepare("UPDATE mail_messages SET provider_message_id = ? WHERE id = ?")215 .run(data.id, mailId);216 if (inReplyTo) updateMail(inReplyTo, { is_read: true });217 return NextResponse.json({ ok: true, id: mailId });218 } catch (e) {219 commsDb220 .prepare("UPDATE mail_messages SET folder = 'trash' WHERE id = ?")221 .run(mailId);222 return NextResponse.json(223 { error: `Envoi refusé par Resend : ${(e as Error).message.slice(0, 200)}` },224 { status: 502 },225 );226 }227}228