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.
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'événement — le | |
| 153 | + corps se lit via son API, qui exige une clé avec accès | |
| 154 | + lecture. Créez une clé « Full access » 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 | ||