SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%

fix(courriel): corps des messages entrants — hydratation via l API Resend

Constat (payload réel journalisé) : l événement email.received ne livre
QUE les métadonnées (from/to/subject/email_id) — ni text, ni html, ni
contenu de pièces jointes. Le corps se lit via l API Resend, qui exige
une clé avec accès lecture (la clé du repo est send-only).

Correctif (src/lib/comms/resend-fetch.ts) :
- fetchInboundContent(email_id) : lit corps + pièces jointes via l API
  (RESEND_READ_API_KEY prioritaire, sinon RESEND_API_KEY), deux endpoints
  tentés, null si clé insuffisante.
- Webhook : hydrate AVANT insertion — le corps alimente aussi la
  conversation du hub et le snippet ; pièces jointes payload + API
  fusionnées.
- hydrateInboundMail : rattrapage à l OUVERTURE d un message vide
  (self-healing, ne réécrit jamais un corps existant, range les pièces
  jointes) — les anciens messages se réparent seuls dès qu une clé
  lisante est posée.
- Vue message : encart explicite tant que la clé lecture manque
  (créer une clé Full access → RESEND_READ_API_KEY → restart).

Vérifié : encart affiché sur message vide (200), build ok.
Simon-Pierre Boucher committed 1 mo ago (Aug 26, 2026) parent a135b13

3 changed files +191 −8

modified src/app/admin/(panel)/courriel/[id]/page.tsx +36 −4
@@ -14,6 +14,7 @@ import {
14 14 mailAttachmentsOf,
15 15 MAIL_FOLDERS,
16 16 } from "@/lib/comms/mail";
17 +import { hydrateInboundMail } from "@/lib/comms/resend-fetch";
17 18 import { MailComposer, MailActions } from "../MailClient";
18 19 import { timeAgo, fmtDateFull, initialsOf, avatarStyle } from "../../format";
19 20
@@ -54,12 +55,26 @@ export default async function MailViewPage({
54 55 if (!mail || !canAccessMail(mail, session.id, superAdmin)) notFound();
55 56
56 57 if (!mail.is_read) updateMail(mail.id, { is_read: true });
58 +
59 + // Rattrapage : un entrant sans corps (webhook = métadonnées seulement)
60 + // est hydraté via l'API Resend à l'ouverture — si une clé lisante existe.
61 + let bodyText = mail.body_text;
62 + let bodyHtml = mail.body_html;
63 + let hydrationFailed = false;
64 + if (mail.direction === "in" && !bodyText && !bodyHtml) {
65 + const hydrated = await hydrateInboundMail(mail).catch(() => null);
66 + if (hydrated) {
67 + bodyText = hydrated.text;
68 + bodyHtml = hydrated.html;
69 + } else hydrationFailed = true;
70 + }
71 +
57 72 const atts = mailAttachmentsOf(mail.id);
58 73 const admin = findAdminById(session.id);
59 74 const to = JSON.parse(mail.to_emails) as string[];
60 75 const cc = mail.cc_emails ? (JSON.parse(mail.cc_emails) as string[]) : [];
61 76 const body =
62 − mail.body_text?.trim() || (mail.body_html ? htmlToText(mail.body_html) : "");
77 + bodyText?.trim() || (bodyHtml ? htmlToText(bodyHtml) : "");
63 78 const linked = mail.submission_id ? findSubmissionById(mail.submission_id) : undefined;
64 79 const isOut = mail.direction === "out";
65 80 const counterpartEmail = isOut ? (to[0] ?? "?") : mail.from_email;
@@ -126,9 +141,26 @@ export default async function MailViewPage({
126 141
127 142 {/* corps */}
128 143 <div className="px-5 py-5 sm:px-7 sm:py-6">
129 − <p className="max-w-[68ch] text-[15px] leading-[1.75] whitespace-pre-wrap">
130 − {body || "(message vide)"}
131 − </p>
144 + {body ? (
145 + <p className="max-w-[68ch] text-[15px] leading-[1.75] whitespace-pre-wrap">{body}</p>
146 + ) : hydrationFailed ? (
147 + <div className="rounded-lg border-[1.5px] border-amber bg-amber-soft px-4 py-3">
148 + <p className="gk-mono text-[10.5px] font-bold tracking-[0.08em] text-[#7a4d0d] uppercase">
149 + Contenu non transmis par le webhook Resend
150 + </p>
151 + <p className="mt-2 text-[13px] leading-relaxed text-ink-2">
152 + Resend ne livre que les métadonnées dans l&apos;événement — le
153 + corps se lit via son API, qui exige une clé avec accès
154 + lecture. Créez une clé «&nbsp;Full access&nbsp;» dans le
155 + dashboard Resend, collez-la dans{" "}
156 + <code className="gk-mono text-[11.5px] font-bold">RESEND_READ_API_KEY</code>{" "}
157 + (.env.local) puis redémarrez : ce message sera récupéré
158 + automatiquement à sa prochaine ouverture.
159 + </p>
160 + </div>
161 + ) : (
162 + <p className="gk-mono text-[12px] text-ink-3">(message vide)</p>
163 + )}
132 164 </div>
133 165
134 166 {/* pièces jointes */}
modified src/app/api/mail/inbound/route.ts +31 −4
@@ -25,6 +25,7 @@ import {
25 25 updateSubmission,
26 26 commsDb,
27 27 } from "@/lib/comms/db";
28 +import { fetchInboundContent } from "@/lib/comms/resend-fetch";
28 29
29 30 const MAIL_UPLOAD_DIR = path.join(process.cwd(), "data", "mail-uploads");
30 31 const MAX_ATT_BYTES = 10 * 1024 * 1024;
@@ -89,6 +90,12 @@ export async function POST(req: NextRequest) {
89 90 } catch {
90 91 return NextResponse.json({ error: "JSON invalide." }, { status: 400 });
91 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 + );
92 99 if (event.type !== "email.received")
93 100 // Autres événements (delivered, bounced…) : accusés sans traitement.
94 101 return NextResponse.json({ ok: true, ignored: event.type ?? "?" });
@@ -99,10 +106,24 @@ export async function POST(req: NextRequest) {
99 106 const cc = toList(d.cc);
100 107 const subject =
101 108 typeof d.subject === "string" ? d.subject.slice(0, 500) : null;
102 − const text = typeof d.text === "string" ? d.text.slice(0, 200_000) : null;
103 − const html = typeof d.html === "string" ? d.html.slice(0, 500_000) : 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;
104 111 const providerId = d.email_id ?? svixId!;
105 112
113 + // 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 requise
115 + // (RESEND_READ_API_KEY). Sans clé lisante, le corps sera récupéré plus
116 + // 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 + }
126 +
106 127 // dédup (Svix rejoue tant qu'il ne reçoit pas 200)
107 128 const dup = commsDb
108 129 .prepare("SELECT id FROM mail_messages WHERE provider_message_id = ?")
@@ -148,8 +169,14 @@ export async function POST(req: NextRequest) {
148 169 submission_id: submission?.id ?? null,
149 170 });
150 171
151 − /* ---------- pièces jointes (base64 dans le payload, bornées) ---------- */
152 − const atts = Array.isArray(d.attachments) ? d.attachments.slice(0, 5) : [];
172 + /* ---------- 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);
153 180 for (const a of atts) {
154 181 if (!a?.content || typeof a.content !== "string") continue;
155 182 let buf: Buffer;
added src/lib/comms/resend-fetch.ts +124 −0
@@ -0,0 +1,124 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA Communication Hub — récupération du CONTENU d'un courriel entrant.
3 +// Le webhook email.received de Resend ne livre que les métadonnées (vérifié
4 +// 2026-08-26 : from/to/subject/email_id, ni text ni html) : le corps et les
5 +// pièces jointes se lisent via l'API. Nécessite une clé qui peut LIRE —
6 +// RESEND_READ_API_KEY (recommandé : clé « reading access » dédiée) ou, à
7 +// défaut, RESEND_API_KEY si elle est full access. Une clé send-only → null.
8 +import { commsDb } from "./db";
9 +import { insertMailAttachment } from "./mail";
10 +import fs from "fs/promises";
11 +import path from "path";
12 +import crypto from "crypto";
13 +
14 +const MAIL_UPLOAD_DIR = path.join(process.cwd(), "data", "mail-uploads");
15 +const MAX_ATT_BYTES = 10 * 1024 * 1024;
16 +
17 +type InboundContent = {
18 + html: string | null;
19 + text: string | null;
20 + attachments: { filename: string; content_type: string; content: string }[];
21 +};
22 +
23 +/** Lit le contenu d'un courriel entrant via l'API Resend (deux endpoints
24 + tentés : receiving puis générique). null si clé absente/insuffisante. */
25 +export async function fetchInboundContent(
26 + emailId: string,
27 +): Promise<InboundContent | null> {
28 + const key = process.env.RESEND_READ_API_KEY || process.env.RESEND_API_KEY;
29 + if (!key || !/^[0-9a-f-]{36}$/.test(emailId)) return null;
30 + for (const url of [
31 + `https://api.resend.com/emails/receiving/${emailId}`,
32 + `https://api.resend.com/emails/${emailId}`,
33 + ]) {
34 + try {
35 + const res = await fetch(url, {
36 + headers: { Authorization: `Bearer ${key}` },
37 + });
38 + if (!res.ok) continue;
39 + const d = (await res.json()) as {
40 + html?: unknown;
41 + text?: unknown;
42 + attachments?: { filename?: string; content_type?: string; content?: string }[];
43 + };
44 + return {
45 + html: typeof d.html === "string" ? d.html.slice(0, 500_000) : null,
46 + text: typeof d.text === "string" ? d.text.slice(0, 200_000) : null,
47 + attachments: Array.isArray(d.attachments)
48 + ? d.attachments
49 + .filter(
50 + (a): a is { filename: string; content_type: string; content: string } =>
51 + typeof a?.content === "string" && !!a.content,
52 + )
53 + .slice(0, 5)
54 + : [],
55 + };
56 + } catch {
57 + /* réseau : on tente l'endpoint suivant */
58 + }
59 + }
60 + return null;
61 +}
62 +
63 +/**
64 + * Hydrate un message entrant dont le corps manque : lit le contenu via
65 + * l'API, met à jour la base et range les pièces jointes. Retourne le corps
66 + * (text/html) ou null si la récupération est impossible (clé send-only).
67 + * Sûr à appeler plusieurs fois — ne réécrit jamais un corps existant.
68 + */
69 +export async function hydrateInboundMail(mail: {
70 + id: number;
71 + provider_message_id: string | null;
72 + body_text: string | null;
73 + body_html: string | null;
74 +}): Promise<{ text: string | null; html: string | null } | null> {
75 + if (mail.body_text || mail.body_html)
76 + return { text: mail.body_text, html: mail.body_html };
77 + if (!mail.provider_message_id) return null;
78 + const content = await fetchInboundContent(mail.provider_message_id);
79 + if (!content || (!content.text && !content.html && !content.attachments.length))
80 + return null;
81 +
82 + const snippet = (content.text ?? "")
83 + .replace(/\s+/g, " ")
84 + .trim()
85 + .slice(0, 160);
86 + commsDb
87 + .prepare(
88 + `UPDATE mail_messages
89 + SET body_text = ?, body_html = ?, snippet = COALESCE(NULLIF(?, ''), snippet)
90 + WHERE id = ? AND body_text IS NULL AND body_html IS NULL`,
91 + )
92 + .run(content.text, content.html, snippet, mail.id);
93 +
94 + // pièces jointes livrées par l'API (base64) — seulement si aucune déjà rangée
95 + const existing = (
96 + commsDb
97 + .prepare("SELECT COUNT(*) AS c FROM mail_attachments WHERE message_id = ?")
98 + .get(mail.id) as { c: number }
99 + ).c;
100 + if (!existing) {
101 + for (const a of content.attachments) {
102 + let buf: Buffer;
103 + try {
104 + buf = Buffer.from(a.content, "base64");
105 + } catch {
106 + continue;
107 + }
108 + if (!buf.length || buf.length > MAX_ATT_BYTES) continue;
109 + const original = (a.filename || "piece-jointe").slice(0, 200);
110 + const year = String(new Date().getFullYear());
111 + const stored = `${year}/mail-${mail.id}-${crypto.randomBytes(8).toString("hex")}${path.extname(original).toLowerCase().slice(0, 10)}`;
112 + await fs.mkdir(path.join(MAIL_UPLOAD_DIR, year), { recursive: true });
113 + await fs.writeFile(path.join(MAIL_UPLOAD_DIR, stored), buf);
114 + insertMailAttachment({
115 + message_id: mail.id,
116 + original_name: original,
117 + stored_name: stored,
118 + mime: (a.content_type || "application/octet-stream").slice(0, 100),
119 + size: buf.length,
120 + });
121 + }
122 + }
123 + return { text: content.text, html: content.html };
124 +}
125