SPB Git

Repository Public

route.ts 8.0 KB 228 lines typescript

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
Files main
Menu

Theme