SPB Git

Repository Public

route.ts 7.0 KB 210 lines typescript

1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// /api/mail/inbound — webhook Resend « email.received » : la porte d'entrée3// du courriel @groupe-ka.com. Signature Svix vérifiée sur le corps BRUT4// (RESEND_WEBHOOK_SECRET, whsec_…) avant tout traitement ; retries dédupliqués5// par identifiant. Le message est routé vers la boîte de l'admin dont6// l'adresse correspond (erikabc@, spboucher@…) ; sans correspondance il va7// dans « non routé » (visible des super admins). Si le sujet porte une8// référence KA-XXX-AAAA-NNNN, le message est AUSSI versé dans la9// conversation de la demande du hub.10import { NextRequest, NextResponse } from "next/server";11import fs from "fs/promises";12import path from "path";13import crypto from "crypto";14import {15  insertMail,16  insertMailAttachment,17  routeByAddresses,18  extractReference,19  verifySvixSignature,20} from "@/lib/comms/mail";21import {22  findSubmissionByRef,23  insertMessage,24  logEvent,25  updateSubmission,26  commsDb,27} from "@/lib/comms/db";28import { fetchInboundContent } from "@/lib/comms/resend-fetch";2930const MAIL_UPLOAD_DIR = path.join(process.cwd(), "data", "mail-uploads");31const MAX_ATT_BYTES = 10 * 1024 * 1024;3233/** « Prénom Nom <a@b.co> » → { name, email } */34function parseAddress(raw: unknown): { name: string | null; email: string } {35  if (typeof raw !== "string") return { name: null, email: "inconnu@invalide" };36  const m = raw.match(/^\s*(?:"?([^"<]*)"?\s*)?<([^>]+)>\s*$/);37  if (m) return { name: m[1]?.trim() || null, email: m[2].trim().toLowerCase() };38  return { name: null, email: raw.trim().toLowerCase() };39}4041function toList(raw: unknown): string[] {42  if (Array.isArray(raw))43    return raw44      .filter((x): x is string => typeof x === "string")45      .map((x) => parseAddress(x).email);46  if (typeof raw === "string") return [parseAddress(raw).email];47  return [];48}4950export async function POST(req: NextRequest) {51  const secret = process.env.RESEND_WEBHOOK_SECRET;52  if (!secret)53    // Pas encore configuré (dashboard Resend → coller le whsec_ dans .env.local).54    return NextResponse.json(55      { error: "Webhook non configuré (RESEND_WEBHOOK_SECRET manquant)." },56      { status: 503 },57    );5859  const payload = await req.text();60  const svixId = req.headers.get("svix-id");61  const ok = verifySvixSignature({62    secret,63    svixId,64    svixTimestamp: req.headers.get("svix-timestamp"),65    svixSignature: req.headers.get("svix-signature"),66    payload,67  });68  if (!ok)69    return NextResponse.json({ error: "Signature invalide." }, { status: 401 });7071  let event: {72    type?: string;73    data?: {74      email_id?: string;75      from?: unknown;76      to?: unknown;77      cc?: unknown;78      subject?: unknown;79      text?: unknown;80      html?: unknown;81      attachments?: {82        filename?: string;83        content_type?: string;84        content?: string;85      }[];86    };87  };88  try {89    event = JSON.parse(payload);90  } catch {91    return NextResponse.json({ error: "JSON invalide." }, { status: 400 });92  }93  // Journal de diagnostic : forme réelle du payload (clés seulement).94  if (process.env.KA_MAIL_DEBUG === "1")95    console.error(96      "[inbound] payload:",97      JSON.stringify(event).slice(0, 3000),98    );99  if (event.type !== "email.received")100    // Autres événements (delivered, bounced…) : accusés sans traitement.101    return NextResponse.json({ ok: true, ignored: event.type ?? "?" });102103  const d = event.data ?? {};104  const from = parseAddress(d.from);105  const to = toList(d.to);106  const cc = toList(d.cc);107  const subject =108    typeof d.subject === "string" ? d.subject.slice(0, 500) : null;109  let text = typeof d.text === "string" ? d.text.slice(0, 200_000) : null;110  let html = typeof d.html === "string" ? d.html.slice(0, 500_000) : null;111  const providerId = d.email_id ?? svixId!;112113  // Le payload email.received ne livre QUE les métadonnées (vérifié114  // 2026-08-26) : le corps se lit via l'API Resend — clé lecture requise115  // (RESEND_READ_API_KEY). Sans clé lisante, le corps sera récupéré plus116  // tard, à l'ouverture du message (hydrateInboundMail).117  let apiAtts: { filename: string; content_type: string; content: string }[] = [];118  if (!text && !html && d.email_id) {119    const content = await fetchInboundContent(d.email_id).catch(() => null);120    if (content) {121      text = content.text;122      html = content.html;123      apiAtts = content.attachments;124    }125  }126127  // dédup (Svix rejoue tant qu'il ne reçoit pas 200)128  const dup = commsDb129    .prepare("SELECT id FROM mail_messages WHERE provider_message_id = ?")130    .get(providerId) as { id: number } | undefined;131  if (dup) return NextResponse.json({ ok: true, deduplicated: true });132133  /* ---------- rattachement à une demande du hub (référence en sujet) ---------- */134  const reference = extractReference(subject);135  const submission = reference ? findSubmissionByRef(reference) : undefined;136  if (submission) {137    insertMessage({138      submission_id: submission.id,139      sender_type: "user",140      body:141        (text ?? (html ? html.replace(/<[^>]+>/g, " ") : "")).slice(0, 12000) ||142        "(message vide)",143      channel: "email",144      provider_message_id: providerId,145    });146    updateSubmission(submission.id, { is_read: 0 });147    logEvent(148      submission.id,149      null,150      "reponse-entrante",151      `Courriel reçu de ${from.email}`,152    );153  }154155  /* ---------- boîte personnelle ---------- */156  const adminId = routeByAddresses([...to, ...cc]);157  const mailId = insertMail({158    admin_id: adminId,159    direction: "in",160    folder: "inbox",161    from_email: from.email,162    from_name: from.name,163    to_emails: to,164    cc_emails: cc,165    subject,166    body_text: text,167    body_html: html,168    provider_message_id: providerId,169    submission_id: submission?.id ?? null,170  });171172  /* ---------- pièces jointes (payload + API, base64, bornées) ---------- */173  const payloadAtts = Array.isArray(d.attachments) ? d.attachments : [];174  const atts = [...payloadAtts, ...apiAtts]175    .filter(176      (a, i, arr) =>177        arr.findIndex((b) => b?.filename === a?.filename) === i,178    )179    .slice(0, 5);180  for (const a of atts) {181    if (!a?.content || typeof a.content !== "string") continue;182    let buf: Buffer;183    try {184      buf = Buffer.from(a.content, "base64");185    } catch {186      continue;187    }188    if (!buf.length || buf.length > MAX_ATT_BYTES) continue;189    const original = (a.filename || "piece-jointe").slice(0, 200);190    const ext = path.extname(original).toLowerCase().slice(0, 10);191    const year = String(new Date().getFullYear());192    const stored = `${year}/mail-${mailId}-${crypto.randomBytes(8).toString("hex")}${ext}`;193    await fs.mkdir(path.join(MAIL_UPLOAD_DIR, year), { recursive: true });194    await fs.writeFile(path.join(MAIL_UPLOAD_DIR, stored), buf);195    insertMailAttachment({196      message_id: mailId,197      original_name: original,198      stored_name: stored,199      mime: (a.content_type || "application/octet-stream").slice(0, 100),200      size: buf.length,201    });202  }203204  return NextResponse.json({205    ok: true,206    routed: adminId != null,207    linked: submission?.reference ?? null,208  });209}210
Files main
Menu

Theme