Repository Public
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