feat(communication hub): boîtes courriel admin — erikabc@ et spboucher@groupe-ka.com via Resend
/admin/courriel : chaque compte admin possède sa boîte @groupe-ka.com (admin_users.email) — dossiers réception/envoyés/archives/corbeille, lecture (HTML entrant réduit en texte, jamais rendu brut), composition et réponse (from = adresse du compte connecté, décidé serveur), pièces jointes entrantes bornées 10 Mo hors public, badge non-lus dans la nav. Isolation stricte : jamais la boîte d un autre admin ; les messages non routés du domaine sont visibles des super admins (attribuables en un clic). Réception : webhook Resend email.received sur /api/mail/inbound — signature Svix vérifiée sur le corps brut (RESEND_WEBHOOK_SECRET), fenêtre anti-rejeu 5 min, dédup des retries par email_id. Routage par adresse ; un sujet portant une référence KA-XXX-AAAA-NNNN verse AUSSI le message dans la conversation de la demande du hub (les courriels du hub partent maintenant avec reply-to hub@groupe-ka.com pour boucler le fil). Tables mail_messages + mail_attachments (ka-comms.db) ; sendEmail étendu (from/replyTo/cc personnalisés, id Resend retourné). Testé : signature invalide 401, routage, dédup, rattachement hub, isolation inter-boîtes 404, envoi réel Resend accepté depuis spboucher@groupe-ka.com, UI vérifiée en prod https. Reste côté dashboard Resend (clé API send-only, pas d accès ici) : MX groupe-ka.com → mx.resend.com (remplace inbound-smtp AWS SES actuel), webhook email.received → https://www.groupe-ka.com/api/mail/inbound, coller le whsec_ réel dans .env.local (placeholder généré).
13 changed files +1,215 −18
modified
src/app/admin/(panel)/AdminNav.tsx
+23 −13
@@ -10,7 +10,8 @@ import { ADMIN_ROLES } from "@/lib/comms/categories"; | ||
| 10 | 10 | |
| 11 | 11 | const LINKS = [ |
| 12 | 12 | { href: "/admin", label: "Dashboard", exact: true }, |
| 13 | − { href: "/admin/inbox", label: "Inbox", badge: true }, | |
| 13 | + { href: "/admin/inbox", label: "Inbox", badge: "inbox" }, | |
| 14 | + { href: "/admin/courriel", label: "Courriel", badge: "mail" }, | |
| 14 | 15 | { href: "/admin/candidatures", label: "Candidatures" }, |
| 15 | 16 | { href: "/admin/equipe", label: "Équipe & accès" }, |
| 16 | 17 | ]; |
@@ -20,28 +21,33 @@ export default function AdminNav({ | ||
| 20 | 21 | username, |
| 21 | 22 | roles, |
| 22 | 23 | initialUnread, |
| 24 | + initialMail, | |
| 23 | 25 | }: { |
| 24 | 26 | displayName: string; |
| 25 | 27 | username: string; |
| 26 | 28 | roles: string[]; |
| 27 | 29 | initialUnread: number; |
| 30 | + initialMail: number; | |
| 28 | 31 | }) { |
| 29 | 32 | const pathname = usePathname(); |
| 30 | 33 | const router = useRouter(); |
| 31 | 34 | const [unread, setUnread] = useState(initialUnread); |
| 35 | + const [mail, setMail] = useState(initialMail); | |
| 32 | 36 | const [open, setOpen] = useState(false); |
| 33 | 37 | |
| 34 | 38 | useEffect(() => { |
| 35 | 39 | setUnread(initialUnread); |
| 36 | − }, [initialUnread]); | |
| 40 | + setMail(initialMail); | |
| 41 | + }, [initialUnread, initialMail]); | |
| 37 | 42 | |
| 38 | 43 | useEffect(() => { |
| 39 | 44 | const t = setInterval(async () => { |
| 40 | 45 | try { |
| 41 | 46 | const res = await fetch("/api/admin/badge"); |
| 42 | 47 | if (res.ok) { |
| 43 | − const data = (await res.json()) as { unread: number }; | |
| 48 | + const data = (await res.json()) as { unread: number; mail?: number }; | |
| 44 | 49 | setUnread(data.unread); |
| 50 | + setMail(data.mail ?? 0); | |
| 45 | 51 | } |
| 46 | 52 | } catch { |
| 47 | 53 | /* réseau : on garde la dernière valeur */ |
@@ -68,15 +74,19 @@ export default function AdminNav({ | ||
| 68 | 74 | className={`adm-nav-link ${active ? "adm-nav-link--active" : ""}`} |
| 69 | 75 | > |
| 70 | 76 | {l.label} |
| 71 | − {l.badge && unread > 0 ? ( | |
| 72 | − <span | |
| 73 | − className={`gk-mono rounded-full px-2 py-[1px] text-[10.5px] font-bold ${ | |
| 74 | − active ? "bg-ink text-lime" : "bg-lime text-ink" | |
| 75 | − }`} | |
| 76 | − > | |
| 77 | − {unread} | |
| 78 | − </span> | |
| 79 | − ) : null} | |
| 77 | + {(() => { | |
| 78 | + const n = | |
| 79 | + l.badge === "inbox" ? unread : l.badge === "mail" ? mail : 0; | |
| 80 | + return n > 0 ? ( | |
| 81 | + <span | |
| 82 | + className={`gk-mono rounded-full px-2 py-[1px] text-[10.5px] font-bold ${ | |
| 83 | + active ? "bg-ink text-lime" : "bg-lime text-ink" | |
| 84 | + }`} | |
| 85 | + > | |
| 86 | + {n} | |
| 87 | + </span> | |
| 88 | + ) : null; | |
| 89 | + })()} | |
| 80 | 90 | </a> |
| 81 | 91 | ); |
| 82 | 92 | })} |
@@ -123,7 +133,7 @@ export default function AdminNav({ | ||
| 123 | 133 | onClick={() => setOpen((o) => !o)} |
| 124 | 134 | aria-expanded={open} |
| 125 | 135 | > |
| 126 | − Menu{unread > 0 ? ` · ${unread}` : ""} | |
| 136 | + Menu{unread + mail > 0 ? ` · ${unread + mail}` : ""} | |
| 127 | 137 | </button> |
| 128 | 138 | </div> |
| 129 | 139 | <div className={`${open ? "flex" : "hidden"} flex-col px-4 pb-6 lg:flex lg:flex-1 lg:px-0 lg:pb-0`}> |
added
src/app/admin/(panel)/courriel/MailClient.tsx
+174 −0
@@ -0,0 +1,174 @@ | ||
| 1 | +"use client"; | |
| 2 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 3 | +// /admin/courriel — composants interactifs : composeur (nouveau courriel / | |
| 4 | +// réponse) et actions sur un message (lu, archiver, corbeille, restaurer, | |
| 5 | +// s'attribuer un non-routé). L'expéditeur est TOUJOURS l'adresse du compte | |
| 6 | +// connecté — décidé serveur, jamais par le client. | |
| 7 | +import { useState } from "react"; | |
| 8 | +import { useRouter } from "next/navigation"; | |
| 9 | + | |
| 10 | +export function MailComposer({ | |
| 11 | + fromEmail, | |
| 12 | + replyTo, | |
| 13 | + startOpen = false, | |
| 14 | +}: { | |
| 15 | + fromEmail: string; | |
| 16 | + /** préremplissage en mode réponse */ | |
| 17 | + replyTo?: { id: number; to: string; subject: string } | null; | |
| 18 | + startOpen?: boolean; | |
| 19 | +}) { | |
| 20 | + const router = useRouter(); | |
| 21 | + const [open, setOpen] = useState(startOpen); | |
| 22 | + const [to, setTo] = useState(replyTo?.to ?? ""); | |
| 23 | + const [cc, setCc] = useState(""); | |
| 24 | + const [subject, setSubject] = useState( | |
| 25 | + replyTo ? (replyTo.subject.startsWith("Re:") ? replyTo.subject : `Re: ${replyTo.subject}`) : "", | |
| 26 | + ); | |
| 27 | + const [body, setBody] = useState(""); | |
| 28 | + const [busy, setBusy] = useState(false); | |
| 29 | + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); | |
| 30 | + | |
| 31 | + async function send(e: React.FormEvent) { | |
| 32 | + e.preventDefault(); | |
| 33 | + if (busy) return; | |
| 34 | + setBusy(true); | |
| 35 | + setMsg(null); | |
| 36 | + try { | |
| 37 | + const res = await fetch("/api/admin/mail/send", { | |
| 38 | + method: "POST", | |
| 39 | + headers: { "Content-Type": "application/json" }, | |
| 40 | + body: JSON.stringify({ | |
| 41 | + to: to.split(",").map((s) => s.trim()).filter(Boolean), | |
| 42 | + cc: cc.split(",").map((s) => s.trim()).filter(Boolean), | |
| 43 | + subject, | |
| 44 | + body, | |
| 45 | + reply_to_id: replyTo?.id, | |
| 46 | + }), | |
| 47 | + }); | |
| 48 | + const data = (await res.json().catch(() => ({}))) as { error?: string }; | |
| 49 | + if (res.ok) { | |
| 50 | + setMsg({ ok: true, text: "Courriel envoyé — il est dans vos Envoyés." }); | |
| 51 | + setBody(""); | |
| 52 | + if (!replyTo) { | |
| 53 | + setTo(""); | |
| 54 | + setCc(""); | |
| 55 | + setSubject(""); | |
| 56 | + } | |
| 57 | + router.refresh(); | |
| 58 | + } else setMsg({ ok: false, text: data.error ?? "Envoi refusé." }); | |
| 59 | + } catch { | |
| 60 | + setMsg({ ok: false, text: "Réseau indisponible." }); | |
| 61 | + } | |
| 62 | + setBusy(false); | |
| 63 | + } | |
| 64 | + | |
| 65 | + if (!open) | |
| 66 | + return ( | |
| 67 | + <button className="btn btn-primary" onClick={() => setOpen(true)}> | |
| 68 | + {replyTo ? "Répondre" : "Nouveau courriel"} | |
| 69 | + </button> | |
| 70 | + ); | |
| 71 | + | |
| 72 | + return ( | |
| 73 | + <form onSubmit={send} className="gk-card mt-2 w-full p-5 !shadow-none"> | |
| 74 | + <div className="flex items-center justify-between gap-3"> | |
| 75 | + <p className="kicker">{replyTo ? "Répondre" : "Nouveau courriel"}</p> | |
| 76 | + <button | |
| 77 | + type="button" | |
| 78 | + className="gk-mono cursor-pointer text-[10.5px] font-bold tracking-[0.1em] text-ink-3 uppercase hover:text-ink" | |
| 79 | + onClick={() => setOpen(false)} | |
| 80 | + > | |
| 81 | + Fermer ✕ | |
| 82 | + </button> | |
| 83 | + </div> | |
| 84 | + <p className="gk-mono mt-3 text-[11px] text-ink-3"> | |
| 85 | + De : <strong className="text-ink">{fromEmail}</strong> | |
| 86 | + </p> | |
| 87 | + <div className="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 88 | + <div className={replyTo ? "sm:col-span-2" : ""}> | |
| 89 | + <label className="klabel mb-[4px] block" htmlFor="mc-to">À (virgules pour plusieurs)</label> | |
| 90 | + <input id="mc-to" className="field !py-[8px]" value={to} onChange={(e) => setTo(e.target.value)} required placeholder="personne@exemple.com" /> | |
| 91 | + </div> | |
| 92 | + {!replyTo ? ( | |
| 93 | + <div> | |
| 94 | + <label className="klabel mb-[4px] block" htmlFor="mc-cc">Cc (facultatif)</label> | |
| 95 | + <input id="mc-cc" className="field !py-[8px]" value={cc} onChange={(e) => setCc(e.target.value)} /> | |
| 96 | + </div> | |
| 97 | + ) : null} | |
| 98 | + </div> | |
| 99 | + <label className="klabel mt-3 mb-[4px] block" htmlFor="mc-subject">Sujet</label> | |
| 100 | + <input id="mc-subject" className="field !py-[8px]" value={subject} onChange={(e) => setSubject(e.target.value)} required /> | |
| 101 | + <label className="klabel mt-3 mb-[4px] block" htmlFor="mc-body">Message</label> | |
| 102 | + <textarea id="mc-body" rows={9} className="field resize-y" value={body} onChange={(e) => setBody(e.target.value)} required /> | |
| 103 | + <div className="mt-4 flex items-center gap-4"> | |
| 104 | + <button type="submit" className="btn btn-primary !min-h-[40px] !px-6" disabled={busy}> | |
| 105 | + {busy ? "Envoi…" : "Envoyer"} | |
| 106 | + </button> | |
| 107 | + <p className="gk-mono text-[10px] leading-relaxed text-ink-3"> | |
| 108 | + Envoyé par Resend depuis votre adresse — copie conservée dans la base | |
| 109 | + (source de vérité). | |
| 110 | + </p> | |
| 111 | + </div> | |
| 112 | + {msg ? ( | |
| 113 | + <p className={`gk-mono mt-3 rounded-lg border-[1.5px] px-3 py-2 text-[11px] font-bold ${msg.ok ? "border-green bg-lime-soft text-green" : "border-danger bg-[var(--danger-soft)] text-danger"}`}> | |
| 114 | + {msg.text} | |
| 115 | + </p> | |
| 116 | + ) : null} | |
| 117 | + </form> | |
| 118 | + ); | |
| 119 | +} | |
| 120 | + | |
| 121 | +export function MailActions({ | |
| 122 | + id, | |
| 123 | + folder, | |
| 124 | + isRead, | |
| 125 | + unrouted, | |
| 126 | +}: { | |
| 127 | + id: number; | |
| 128 | + folder: string; | |
| 129 | + isRead: boolean; | |
| 130 | + unrouted: boolean; | |
| 131 | +}) { | |
| 132 | + const router = useRouter(); | |
| 133 | + const [busy, setBusy] = useState(false); | |
| 134 | + | |
| 135 | + async function act(patch: Record<string, unknown>, thenList = false) { | |
| 136 | + setBusy(true); | |
| 137 | + await fetch(`/api/admin/mail/${id}`, { | |
| 138 | + method: "PATCH", | |
| 139 | + headers: { "Content-Type": "application/json" }, | |
| 140 | + body: JSON.stringify(patch), | |
| 141 | + }).catch(() => {}); | |
| 142 | + setBusy(false); | |
| 143 | + if (thenList) router.push("/admin/courriel"); | |
| 144 | + router.refresh(); | |
| 145 | + } | |
| 146 | + | |
| 147 | + const b = "btn btn-ghost !min-h-[34px] !px-4 !text-[12px]"; | |
| 148 | + return ( | |
| 149 | + <div className="flex flex-wrap gap-2"> | |
| 150 | + {unrouted ? ( | |
| 151 | + <button className={`${b} !bg-lime`} disabled={busy} onClick={() => act({ claim: true })}> | |
| 152 | + M'attribuer ce message | |
| 153 | + </button> | |
| 154 | + ) : null} | |
| 155 | + <button className={b} disabled={busy} onClick={() => act({ is_read: !isRead })}> | |
| 156 | + {isRead ? "Marquer non lu" : "Marquer lu"} | |
| 157 | + </button> | |
| 158 | + {folder !== "archive" ? ( | |
| 159 | + <button className={b} disabled={busy} onClick={() => act({ folder: "archive" }, true)}> | |
| 160 | + Archiver | |
| 161 | + </button> | |
| 162 | + ) : null} | |
| 163 | + {folder !== "trash" ? ( | |
| 164 | + <button className={b} disabled={busy} onClick={() => act({ folder: "trash" }, true)}> | |
| 165 | + Corbeille | |
| 166 | + </button> | |
| 167 | + ) : ( | |
| 168 | + <button className={b} disabled={busy} onClick={() => act({ folder: "inbox" })}> | |
| 169 | + Restaurer | |
| 170 | + </button> | |
| 171 | + )} | |
| 172 | + </div> | |
| 173 | + ); | |
| 174 | +} | |
added
src/app/admin/(panel)/courriel/[id]/page.tsx
+166 −0
@@ -0,0 +1,166 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /admin/courriel/[id] — lecture d'un courriel : en-têtes, corps (texte | |
| 3 | +// prioritaire, HTML assaini en dernier recours), pièces jointes, actions et | |
| 4 | +// réponse. L'ouverture marque le message lu. Accès : propriétaire de la | |
| 5 | +// boîte, ou super admin pour les non-routés (404 sinon). | |
| 6 | +import { notFound, redirect } from "next/navigation"; | |
| 7 | +import { getAdminSession } from "@/lib/comms/admin-auth"; | |
| 8 | +import { findAdminById, findSubmissionById } from "@/lib/comms/db"; | |
| 9 | +import { | |
| 10 | + findMail, | |
| 11 | + canAccessMail, | |
| 12 | + updateMail, | |
| 13 | + mailAttachmentsOf, | |
| 14 | + MAIL_FOLDERS, | |
| 15 | +} from "@/lib/comms/mail"; | |
| 16 | +import { MailComposer, MailActions } from "../MailClient"; | |
| 17 | + | |
| 18 | +export const dynamic = "force-dynamic"; | |
| 19 | + | |
| 20 | +function fmtDate(iso: string): string { | |
| 21 | + return iso.replace("T", " ").slice(0, 16); | |
| 22 | +} | |
| 23 | + | |
| 24 | +function fmtBytes(n: number): string { | |
| 25 | + if (n < 1024) return `${n} o`; | |
| 26 | + if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`; | |
| 27 | + return `${(n / 1024 / 1024).toFixed(1)} Mo`; | |
| 28 | +} | |
| 29 | + | |
| 30 | +/** HTML entrant : on n'affiche JAMAIS le HTML brut — texte extrait seulement. */ | |
| 31 | +function htmlToText(html: string): string { | |
| 32 | + return html | |
| 33 | + .replace(/<style[\s\S]*?<\/style>/gi, " ") | |
| 34 | + .replace(/<script[\s\S]*?<\/script>/gi, " ") | |
| 35 | + .replace(/<br\s*\/?>/gi, "\n") | |
| 36 | + .replace(/<\/(p|div|tr|li|h[1-6])>/gi, "\n") | |
| 37 | + .replace(/<[^>]+>/g, "") | |
| 38 | + .replace(/ /g, " ") | |
| 39 | + .replace(/&/g, "&") | |
| 40 | + .replace(/</g, "<") | |
| 41 | + .replace(/>/g, ">") | |
| 42 | + .replace(/\n{3,}/g, "\n\n") | |
| 43 | + .trim(); | |
| 44 | +} | |
| 45 | + | |
| 46 | +export default async function MailViewPage({ | |
| 47 | + params, | |
| 48 | +}: { | |
| 49 | + params: Promise<{ id: string }>; | |
| 50 | +}) { | |
| 51 | + const session = await getAdminSession(); | |
| 52 | + if (!session) redirect("/admin/connexion"); | |
| 53 | + const id = Number((await params).id); | |
| 54 | + const mail = Number.isInteger(id) ? findMail(id) : undefined; | |
| 55 | + const superAdmin = session.roles.includes("super_admin"); | |
| 56 | + if (!mail || !canAccessMail(mail, session.id, superAdmin)) notFound(); | |
| 57 | + | |
| 58 | + if (!mail.is_read) updateMail(mail.id, { is_read: true }); | |
| 59 | + const atts = mailAttachmentsOf(mail.id); | |
| 60 | + const admin = findAdminById(session.id); | |
| 61 | + const to = JSON.parse(mail.to_emails) as string[]; | |
| 62 | + const cc = mail.cc_emails ? (JSON.parse(mail.cc_emails) as string[]) : []; | |
| 63 | + const body = | |
| 64 | + mail.body_text?.trim() || (mail.body_html ? htmlToText(mail.body_html) : ""); | |
| 65 | + const linked = mail.submission_id ? findSubmissionById(mail.submission_id) : undefined; | |
| 66 | + | |
| 67 | + return ( | |
| 68 | + <> | |
| 69 | + <nav aria-label="Fil d'Ariane"> | |
| 70 | + <a | |
| 71 | + href={`/admin/courriel?folder=${mail.folder}`} | |
| 72 | + className="gk-mono text-[11px] font-bold tracking-[0.1em] text-ink-3 uppercase no-underline hover:text-green" | |
| 73 | + > | |
| 74 | + ← {MAIL_FOLDERS[mail.folder] ?? "Courriel"} | |
| 75 | + </a> | |
| 76 | + </nav> | |
| 77 | + | |
| 78 | + <div className="mt-5 flex flex-wrap items-center gap-3"> | |
| 79 | + <h1 className="gk-display min-w-0 flex-1 text-[clamp(19px,3vw,27px)] leading-tight font-bold tracking-[-0.03em]"> | |
| 80 | + {mail.subject || "(sans sujet)"} | |
| 81 | + </h1> | |
| 82 | + <span className="adm-chip adm-chip--ghost">{mail.direction === "in" ? "Reçu" : "Envoyé"}</span> | |
| 83 | + {mail.admin_id === null ? ( | |
| 84 | + <span className="adm-chip adm-chip--haute">Non routé</span> | |
| 85 | + ) : null} | |
| 86 | + </div> | |
| 87 | + | |
| 88 | + <div className="gk-card mt-5 p-5 !shadow-none"> | |
| 89 | + <div className="grid gap-x-6 gap-y-2 text-[13px] sm:grid-cols-2"> | |
| 90 | + <p> | |
| 91 | + <span className="klabel mr-2">De</span> | |
| 92 | + {mail.from_name ? `${mail.from_name} · ` : ""} | |
| 93 | + <span className="gk-mono">{mail.from_email}</span> | |
| 94 | + </p> | |
| 95 | + <p> | |
| 96 | + <span className="klabel mr-2">Date</span> | |
| 97 | + <span className="gk-mono">{fmtDate(mail.created_at)}</span> | |
| 98 | + </p> | |
| 99 | + <p> | |
| 100 | + <span className="klabel mr-2">À</span> | |
| 101 | + <span className="gk-mono">{to.join(", ")}</span> | |
| 102 | + </p> | |
| 103 | + {cc.length ? ( | |
| 104 | + <p> | |
| 105 | + <span className="klabel mr-2">Cc</span> | |
| 106 | + <span className="gk-mono">{cc.join(", ")}</span> | |
| 107 | + </p> | |
| 108 | + ) : null} | |
| 109 | + </div> | |
| 110 | + {linked ? ( | |
| 111 | + <p className="mt-3 border-t border-line pt-3 text-[12.5px]"> | |
| 112 | + <span className="klabel mr-2">Demande liée</span> | |
| 113 | + <a href={`/admin/demande/${linked.reference}`} className="gk-mono font-bold text-green underline underline-offset-4"> | |
| 114 | + {linked.reference} | |
| 115 | + </a> | |
| 116 | + <span className="text-ink-3"> — ce courriel a aussi été versé dans sa conversation.</span> | |
| 117 | + </p> | |
| 118 | + ) : null} | |
| 119 | + <div className="mt-5 border-t-[1.5px] border-ink pt-5"> | |
| 120 | + <p className="text-[14.5px] leading-relaxed whitespace-pre-wrap">{body || "(message vide)"}</p> | |
| 121 | + </div> | |
| 122 | + {atts.length ? ( | |
| 123 | + <div className="mt-5 border-t border-line pt-4"> | |
| 124 | + <p className="klabel">Pièces jointes</p> | |
| 125 | + <ul className="mt-2 flex flex-wrap gap-2"> | |
| 126 | + {atts.map((a) => ( | |
| 127 | + <li key={a.id}> | |
| 128 | + <a | |
| 129 | + href={`/api/admin/mail/attachments/${a.id}`} | |
| 130 | + className="gk-mono inline-flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-surface px-3 py-2 text-[11.5px] font-bold no-underline hover:bg-lime-soft" | |
| 131 | + > | |
| 132 | + ⭳ {a.original_name} | |
| 133 | + <span className="font-normal text-ink-3">{fmtBytes(a.size)}</span> | |
| 134 | + </a> | |
| 135 | + </li> | |
| 136 | + ))} | |
| 137 | + </ul> | |
| 138 | + </div> | |
| 139 | + ) : null} | |
| 140 | + </div> | |
| 141 | + | |
| 142 | + <div className="mt-5 flex flex-wrap items-start gap-3"> | |
| 143 | + <MailActions | |
| 144 | + id={mail.id} | |
| 145 | + folder={mail.folder} | |
| 146 | + isRead={true} | |
| 147 | + unrouted={mail.admin_id === null} | |
| 148 | + /> | |
| 149 | + </div> | |
| 150 | + | |
| 151 | + {mail.direction === "in" ? ( | |
| 152 | + <div className="mt-6"> | |
| 153 | + <MailComposer | |
| 154 | + fromEmail={admin?.email ?? ""} | |
| 155 | + replyTo={{ | |
| 156 | + id: mail.id, | |
| 157 | + to: mail.from_email, | |
| 158 | + subject: mail.subject ?? "", | |
| 159 | + }} | |
| 160 | + startOpen={false} | |
| 161 | + /> | |
| 162 | + </div> | |
| 163 | + ) : null} | |
| 164 | + </> | |
| 165 | + ); | |
| 166 | +} | |
added
src/app/admin/(panel)/courriel/page.tsx
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /admin/courriel — la boîte courriel de l'admin connecté (adresse | |
| 3 | +// @groupe-ka.com personnelle) : réception via le webhook Resend, envoi via | |
| 4 | +// Resend, tout archivé en base. Les super admins voient aussi les messages | |
| 5 | +// « non routés » (adressés à une adresse du domaine sans boîte). | |
| 6 | +import { redirect } from "next/navigation"; | |
| 7 | +import { getAdminSession } from "@/lib/comms/admin-auth"; | |
| 8 | +import { findAdminById } from "@/lib/comms/db"; | |
| 9 | +import { listMail, MAIL_FOLDERS } from "@/lib/comms/mail"; | |
| 10 | +import { MailComposer } from "./MailClient"; | |
| 11 | + | |
| 12 | +export const dynamic = "force-dynamic"; | |
| 13 | + | |
| 14 | +function fmtDate(iso: string): string { | |
| 15 | + return iso.replace("T", " ").slice(0, 16); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export default async function MailboxPage({ | |
| 19 | + searchParams, | |
| 20 | +}: { | |
| 21 | + searchParams: Promise<Record<string, string | string[] | undefined>>; | |
| 22 | +}) { | |
| 23 | + const session = await getAdminSession(); | |
| 24 | + if (!session) redirect("/admin/connexion"); | |
| 25 | + const sp = await searchParams; | |
| 26 | + const folder = | |
| 27 | + typeof sp.folder === "string" && sp.folder in MAIL_FOLDERS ? sp.folder : "inbox"; | |
| 28 | + const page = Math.max(1, Number(sp.page) || 1); | |
| 29 | + const superAdmin = session.roles.includes("super_admin"); | |
| 30 | + const { rows, total } = listMail(session.id, superAdmin, folder, page); | |
| 31 | + const admin = findAdminById(session.id); | |
| 32 | + const myEmail = admin?.email ?? ""; | |
| 33 | + const pages = Math.max(1, Math.ceil(total / 40)); | |
| 34 | + | |
| 35 | + return ( | |
| 36 | + <> | |
| 37 | + <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 38 | + <div> | |
| 39 | + <p className="kicker">Courriel</p> | |
| 40 | + <h1 className="gk-display mt-2 text-[clamp(22px,3vw,30px)] font-bold tracking-[-0.03em]"> | |
| 41 | + {MAIL_FOLDERS[folder]} · {total} | |
| 42 | + </h1> | |
| 43 | + <p className="gk-mono mt-1 text-[11px] text-ink-3"> | |
| 44 | + Votre adresse : <strong className="text-green">{myEmail || "non définie"}</strong> | |
| 45 | + {superAdmin ? " · les messages non routés du domaine apparaissent aussi ici" : ""} | |
| 46 | + </p> | |
| 47 | + </div> | |
| 48 | + <MailComposer fromEmail={myEmail} /> | |
| 49 | + </div> | |
| 50 | + | |
| 51 | + <div className="mt-6 flex flex-wrap gap-2"> | |
| 52 | + {Object.entries(MAIL_FOLDERS).map(([k, v]) => ( | |
| 53 | + <a | |
| 54 | + key={k} | |
| 55 | + href={`/admin/courriel?folder=${k}`} | |
| 56 | + className={`adm-chip no-underline ${folder === k ? "adm-chip--lime" : ""}`} | |
| 57 | + > | |
| 58 | + {v} | |
| 59 | + </a> | |
| 60 | + ))} | |
| 61 | + </div> | |
| 62 | + | |
| 63 | + <div className="gk-card mt-6 overflow-hidden !shadow-none"> | |
| 64 | + {rows.length === 0 ? ( | |
| 65 | + <p className="gk-mono p-6 text-[12px] text-ink-3"> | |
| 66 | + {folder === "inbox" | |
| 67 | + ? "Boîte vide. La réception exige le MX Resend + le webhook (voir Équipe & accès si rien n'arrive)." | |
| 68 | + : "Aucun message dans ce dossier."} | |
| 69 | + </p> | |
| 70 | + ) : ( | |
| 71 | + rows.map((m) => { | |
| 72 | + const counterpart = | |
| 73 | + m.direction === "out" | |
| 74 | + ? `À : ${(JSON.parse(m.to_emails) as string[]).join(", ")}` | |
| 75 | + : m.from_name | |
| 76 | + ? `${m.from_name} · ${m.from_email}` | |
| 77 | + : m.from_email; | |
| 78 | + return ( | |
| 79 | + <a | |
| 80 | + key={m.id} | |
| 81 | + href={`/admin/courriel/${m.id}`} | |
| 82 | + className={`adm-row ${m.is_read ? "" : "adm-row--unread"}`} | |
| 83 | + > | |
| 84 | + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> | |
| 85 | + <span className="max-w-[45%] truncate text-[13px] font-semibold">{counterpart}</span> | |
| 86 | + {m.admin_id === null ? ( | |
| 87 | + <span className="adm-chip adm-chip--haute !text-[9px]">Non routé</span> | |
| 88 | + ) : null} | |
| 89 | + {m.submission_id ? ( | |
| 90 | + <span className="adm-chip adm-chip--lime !text-[9px]">Demande liée</span> | |
| 91 | + ) : null} | |
| 92 | + <span className="gk-mono ml-auto flex-none text-[10.5px] text-ink-3"> | |
| 93 | + {fmtDate(m.created_at)} | |
| 94 | + {m.is_read ? null : <span className="ml-2 font-bold text-green">●</span>} | |
| 95 | + </span> | |
| 96 | + </div> | |
| 97 | + <p className="mt-[2px] truncate text-[13.5px]"> | |
| 98 | + <strong>{m.subject || "(sans sujet)"}</strong> | |
| 99 | + {m.snippet ? <span className="text-ink-3"> — {m.snippet}</span> : null} | |
| 100 | + </p> | |
| 101 | + </a> | |
| 102 | + ); | |
| 103 | + }) | |
| 104 | + )} | |
| 105 | + </div> | |
| 106 | + | |
| 107 | + {pages > 1 ? ( | |
| 108 | + <div className="gk-mono mt-5 flex items-center gap-4 text-[12px] font-bold"> | |
| 109 | + {page > 1 ? ( | |
| 110 | + <a href={`/admin/courriel?folder=${folder}&page=${page - 1}`} className="underline underline-offset-4">← Précédente</a> | |
| 111 | + ) : null} | |
| 112 | + <span className="text-ink-3">Page {page} / {pages}</span> | |
| 113 | + {page < pages ? ( | |
| 114 | + <a href={`/admin/courriel?folder=${folder}&page=${page + 1}`} className="underline underline-offset-4">Suivante →</a> | |
| 115 | + ) : null} | |
| 116 | + </div> | |
| 117 | + ) : null} | |
| 118 | + </> | |
| 119 | + ); | |
| 120 | +} | |
modified
src/app/admin/(panel)/layout.tsx
+3 −0
@@ -5,6 +5,7 @@ | ||
| 5 | 5 | import { redirect } from "next/navigation"; |
| 6 | 6 | import { getAdminSession } from "@/lib/comms/admin-auth"; |
| 7 | 7 | import { unreadCount } from "@/lib/comms/queries"; |
| 8 | +import { unreadMailCount } from "@/lib/comms/mail"; | |
| 8 | 9 | import AdminNav from "./AdminNav"; |
| 9 | 10 | |
| 10 | 11 | export const metadata = { |
@@ -17,6 +18,7 @@ export default async function AdminLayout({ | ||
| 17 | 18 | const session = await getAdminSession(); |
| 18 | 19 | if (!session) redirect("/admin/connexion"); |
| 19 | 20 | const unread = unreadCount(session); |
| 21 | + const mailUnread = unreadMailCount(session.id, session.roles.includes("super_admin")); | |
| 20 | 22 | return ( |
| 21 | 23 | <div data-admin-root className="flex min-h-[100dvh] flex-col lg:flex-row"> |
| 22 | 24 | <AdminNav |
@@ -24,6 +26,7 @@ export default async function AdminLayout({ | ||
| 24 | 26 | username={session.username} |
| 25 | 27 | roles={session.roles} |
| 26 | 28 | initialUnread={unread} |
| 29 | + initialMail={mailUnread} | |
| 27 | 30 | /> |
| 28 | 31 | <main className="min-w-0 flex-1 px-4 py-6 sm:px-8 lg:py-8">{children}</main> |
| 29 | 32 | </div> |
modified
src/app/api/admin/badge/route.ts
+5 −1
@@ -4,10 +4,14 @@ | ||
| 4 | 4 | import { NextResponse } from "next/server"; |
| 5 | 5 | import { getAdminSession } from "@/lib/comms/admin-auth"; |
| 6 | 6 | import { unreadCount } from "@/lib/comms/queries"; |
| 7 | +import { unreadMailCount } from "@/lib/comms/mail"; | |
| 7 | 8 | |
| 8 | 9 | export async function GET() { |
| 9 | 10 | const session = await getAdminSession(); |
| 10 | 11 | if (!session) |
| 11 | 12 | return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); |
| 12 | − return NextResponse.json({ unread: unreadCount(session) }); | |
| 13 | + return NextResponse.json({ | |
| 14 | + unread: unreadCount(session), | |
| 15 | + mail: unreadMailCount(session.id, session.roles.includes("super_admin")), | |
| 16 | + }); | |
| 13 | 17 | } |
added
src/app/api/admin/mail/[id]/route.ts
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/mail/[id] — actions sur un courriel de SA boîte (lu/non-lu, | |
| 3 | +// archiver, corbeille, restaurer) ; un super admin peut aussi s'attribuer | |
| 4 | +// un message non routé. Jamais d'accès à la boîte d'un autre admin. | |
| 5 | +import { NextRequest, NextResponse } from "next/server"; | |
| 6 | +import { getAdminSession } from "@/lib/comms/admin-auth"; | |
| 7 | +import { findMail, canAccessMail, updateMail, MAIL_FOLDERS } from "@/lib/comms/mail"; | |
| 8 | + | |
| 9 | +export async function PATCH( | |
| 10 | + req: NextRequest, | |
| 11 | + { params }: { params: Promise<{ id: string }> }, | |
| 12 | +) { | |
| 13 | + const session = await getAdminSession(); | |
| 14 | + if (!session) | |
| 15 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 16 | + const id = Number((await params).id); | |
| 17 | + const mail = Number.isInteger(id) ? findMail(id) : undefined; | |
| 18 | + const superAdmin = session.roles.includes("super_admin"); | |
| 19 | + if (!mail || !canAccessMail(mail, session.id, superAdmin)) | |
| 20 | + return NextResponse.json({ error: "Message introuvable." }, { status: 404 }); | |
| 21 | + | |
| 22 | + const body = (await req.json().catch(() => null)) as { | |
| 23 | + is_read?: boolean; | |
| 24 | + folder?: string; | |
| 25 | + claim?: boolean; | |
| 26 | + } | null; | |
| 27 | + if (!body) | |
| 28 | + return NextResponse.json({ error: "Requête illisible." }, { status: 400 }); | |
| 29 | + | |
| 30 | + if (typeof body.is_read === "boolean") | |
| 31 | + updateMail(mail.id, { is_read: body.is_read }); | |
| 32 | + if (typeof body.folder === "string") { | |
| 33 | + if (!(body.folder in MAIL_FOLDERS)) | |
| 34 | + return NextResponse.json({ error: "Dossier inconnu." }, { status: 422 }); | |
| 35 | + updateMail(mail.id, { folder: body.folder }); | |
| 36 | + } | |
| 37 | + // s'attribuer un message non routé (super admin) | |
| 38 | + if (body.claim === true && mail.admin_id === null && superAdmin) | |
| 39 | + updateMail(mail.id, { admin_id: session.id }); | |
| 40 | + | |
| 41 | + return NextResponse.json({ ok: true }); | |
| 42 | +} | |
added
src/app/api/admin/mail/attachments/[id]/route.ts
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/mail/attachments/[id] — pièce jointe d'un courriel entrant. | |
| 3 | +// Mêmes règles que le message : propriétaire de la boîte (ou super admin | |
| 4 | +// pour les non-routés), fichiers hors de /public, nom aléatoire. | |
| 5 | +import { NextRequest, NextResponse } from "next/server"; | |
| 6 | +import fs from "fs/promises"; | |
| 7 | +import path from "path"; | |
| 8 | +import { getAdminSession } from "@/lib/comms/admin-auth"; | |
| 9 | +import { findMail, canAccessMail, findMailAttachment } from "@/lib/comms/mail"; | |
| 10 | + | |
| 11 | +const MAIL_UPLOAD_DIR = path.join(process.cwd(), "data", "mail-uploads"); | |
| 12 | + | |
| 13 | +export async function GET( | |
| 14 | + _req: NextRequest, | |
| 15 | + { params }: { params: Promise<{ id: string }> }, | |
| 16 | +) { | |
| 17 | + const session = await getAdminSession(); | |
| 18 | + if (!session) | |
| 19 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 20 | + const id = Number((await params).id); | |
| 21 | + const att = Number.isInteger(id) ? findMailAttachment(id) : undefined; | |
| 22 | + const mail = att ? findMail(att.message_id) : undefined; | |
| 23 | + if (!att || !mail || !canAccessMail(mail, session.id, session.roles.includes("super_admin"))) | |
| 24 | + return NextResponse.json({ error: "Fichier introuvable." }, { status: 404 }); | |
| 25 | + | |
| 26 | + const safe = path.normalize(att.stored_name).replace(/^(\.\.[/\\])+/, ""); | |
| 27 | + const full = path.join(MAIL_UPLOAD_DIR, safe); | |
| 28 | + if (!full.startsWith(MAIL_UPLOAD_DIR)) | |
| 29 | + return NextResponse.json({ error: "Chemin refusé." }, { status: 400 }); | |
| 30 | + let buf: Buffer; | |
| 31 | + try { | |
| 32 | + buf = await fs.readFile(full); | |
| 33 | + } catch { | |
| 34 | + return NextResponse.json({ error: "Fichier manquant sur le disque." }, { status: 404 }); | |
| 35 | + } | |
| 36 | + return new NextResponse(new Uint8Array(buf), { | |
| 37 | + headers: { | |
| 38 | + "Content-Type": att.mime, | |
| 39 | + "Content-Disposition": `attachment; filename="${att.original_name.replace(/["\r\n]/g, "")}"`, | |
| 40 | + "Cache-Control": "private, no-store", | |
| 41 | + }, | |
| 42 | + }); | |
| 43 | +} | |
added
src/app/api/admin/mail/send/route.ts
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/admin/mail/send — envoi d'un courriel depuis la boîte de l'admin | |
| 3 | +// connecté (from = SON adresse @groupe-ka.com, jamais choisie par le client). | |
| 4 | +// Le message est D'ABORD écrit en base (dossier Envoyés), puis part par | |
| 5 | +// Resend ; l'identifiant Resend est conservé pour le suivi. | |
| 6 | +import { NextRequest, NextResponse } from "next/server"; | |
| 7 | +import { getAdminSession } from "@/lib/comms/admin-auth"; | |
| 8 | +import { findAdminById } from "@/lib/comms/db"; | |
| 9 | +import { insertMail, findMail, canAccessMail, updateMail } from "@/lib/comms/mail"; | |
| 10 | +import { sendEmail } from "@/lib/email"; | |
| 11 | +import { commsDb } from "@/lib/comms/db"; | |
| 12 | + | |
| 13 | +function validEmail(s: string): boolean { | |
| 14 | + return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s) && s.length <= 200; | |
| 15 | +} | |
| 16 | + | |
| 17 | +function escapeHtml(s: string): string { | |
| 18 | + return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 19 | +} | |
| 20 | + | |
| 21 | +export async function POST(req: NextRequest) { | |
| 22 | + const session = await getAdminSession(); | |
| 23 | + if (!session) | |
| 24 | + return NextResponse.json({ error: "Non autorisé." }, { status: 401 }); | |
| 25 | + const admin = findAdminById(session.id); | |
| 26 | + const from = admin?.email?.toLowerCase() ?? ""; | |
| 27 | + if (!from || !from.endsWith("@groupe-ka.com")) | |
| 28 | + return NextResponse.json( | |
| 29 | + { error: "Votre compte n'a pas d'adresse @groupe-ka.com — demandez à un super admin de la définir dans Équipe & accès." }, | |
| 30 | + { status: 422 }, | |
| 31 | + ); | |
| 32 | + | |
| 33 | + const body = (await req.json().catch(() => null)) as { | |
| 34 | + to?: string[]; | |
| 35 | + cc?: string[]; | |
| 36 | + subject?: string; | |
| 37 | + body?: string; | |
| 38 | + reply_to_id?: number; | |
| 39 | + } | null; | |
| 40 | + if (!body) | |
| 41 | + return NextResponse.json({ error: "Requête illisible." }, { status: 400 }); | |
| 42 | + | |
| 43 | + const to = (body.to ?? []) | |
| 44 | + .filter((x): x is string => typeof x === "string") | |
| 45 | + .map((x) => x.trim().toLowerCase()) | |
| 46 | + .filter(Boolean) | |
| 47 | + .slice(0, 10); | |
| 48 | + const cc = (body.cc ?? []) | |
| 49 | + .filter((x): x is string => typeof x === "string") | |
| 50 | + .map((x) => x.trim().toLowerCase()) | |
| 51 | + .filter(Boolean) | |
| 52 | + .slice(0, 10); | |
| 53 | + const subject = (body.subject ?? "").trim().slice(0, 300); | |
| 54 | + const text = (body.body ?? "").trim().slice(0, 100_000); | |
| 55 | + | |
| 56 | + if (!to.length) | |
| 57 | + return NextResponse.json({ error: "Au moins un destinataire requis." }, { status: 422 }); | |
| 58 | + for (const e of [...to, ...cc]) | |
| 59 | + if (!validEmail(e)) | |
| 60 | + return NextResponse.json({ error: `Adresse invalide : ${e}` }, { status: 422 }); | |
| 61 | + if (!subject) | |
| 62 | + return NextResponse.json({ error: "Sujet requis." }, { status: 422 }); | |
| 63 | + if (!text) | |
| 64 | + return NextResponse.json({ error: "Message vide." }, { status: 422 }); | |
| 65 | + | |
| 66 | + // fil de réponse (facultatif) : le message d'origine doit être accessible | |
| 67 | + let inReplyTo: number | null = null; | |
| 68 | + if (body.reply_to_id != null) { | |
| 69 | + const orig = findMail(Number(body.reply_to_id)); | |
| 70 | + if (orig && canAccessMail(orig, session.id, session.roles.includes("super_admin"))) | |
| 71 | + inReplyTo = orig.id; | |
| 72 | + } | |
| 73 | + | |
| 74 | + const fromDisplay = `${admin!.display_name} — Groupe KA <${from}>`; | |
| 75 | + // habillage sobre : texte pré-formaté + signature, pas de gros gabarit | |
| 76 | + const html = `<div style="font-family:'Inter',Helvetica,Arial,sans-serif;font-size:14.5px;line-height:1.65;color:#141814;white-space:pre-wrap;">${escapeHtml(text)}</div> | |
| 77 | +<p style="margin:22px 0 0;font-family:'JetBrains Mono',Menlo,Consolas,monospace;font-size:10.5px;color:#8b928c;">${escapeHtml(admin!.display_name)} · Groupe KA — <a href="https://www.groupe-ka.com" style="color:#1c5c41;text-decoration:none;">www.groupe-ka.com</a></p>`; | |
| 78 | + | |
| 79 | + const mailId = insertMail({ | |
| 80 | + admin_id: session.id, | |
| 81 | + direction: "out", | |
| 82 | + folder: "sent", | |
| 83 | + from_email: from, | |
| 84 | + from_name: admin!.display_name, | |
| 85 | + to_emails: to, | |
| 86 | + cc_emails: cc, | |
| 87 | + reply_to: from, | |
| 88 | + subject, | |
| 89 | + body_text: text, | |
| 90 | + body_html: html, | |
| 91 | + in_reply_to: inReplyTo, | |
| 92 | + is_read: true, | |
| 93 | + }); | |
| 94 | + | |
| 95 | + try { | |
| 96 | + const { id: providerId } = await sendEmail({ | |
| 97 | + from: fromDisplay, | |
| 98 | + to, | |
| 99 | + cc, | |
| 100 | + replyTo: from, | |
| 101 | + subject, | |
| 102 | + html, | |
| 103 | + text: `${text}\n\n—\n${admin!.display_name} · Groupe KA — www.groupe-ka.com`, | |
| 104 | + }); | |
| 105 | + if (providerId) | |
| 106 | + commsDb | |
| 107 | + .prepare("UPDATE mail_messages SET provider_message_id = ? WHERE id = ?") | |
| 108 | + .run(providerId, mailId); | |
| 109 | + if (inReplyTo) updateMail(inReplyTo, { is_read: true }); | |
| 110 | + return NextResponse.json({ ok: true, id: mailId }); | |
| 111 | + } catch (e) { | |
| 112 | + // l'envoi a échoué : le brouillon reste visible dans Envoyés avec mention | |
| 113 | + commsDb | |
| 114 | + .prepare("UPDATE mail_messages SET folder = 'trash' WHERE id = ?") | |
| 115 | + .run(mailId); | |
| 116 | + return NextResponse.json( | |
| 117 | + { error: `Envoi refusé par Resend : ${(e as Error).message.slice(0, 200)}` }, | |
| 118 | + { status: 502 }, | |
| 119 | + ); | |
| 120 | + } | |
| 121 | +} | |
added
src/app/api/mail/inbound/route.ts
+182 −0
@@ -0,0 +1,182 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /api/mail/inbound — webhook Resend « email.received » : la porte d'entrée | |
| 3 | +// du courriel @groupe-ka.com. Signature Svix vérifiée sur le corps BRUT | |
| 4 | +// (RESEND_WEBHOOK_SECRET, whsec_…) avant tout traitement ; retries dédupliqués | |
| 5 | +// par identifiant. Le message est routé vers la boîte de l'admin dont | |
| 6 | +// l'adresse correspond (erikabc@, spboucher@…) ; sans correspondance il va | |
| 7 | +// dans « non routé » (visible des super admins). Si le sujet porte une | |
| 8 | +// référence KA-XXX-AAAA-NNNN, le message est AUSSI versé dans la | |
| 9 | +// conversation de la demande du hub. | |
| 10 | +import { NextRequest, NextResponse } from "next/server"; | |
| 11 | +import fs from "fs/promises"; | |
| 12 | +import path from "path"; | |
| 13 | +import crypto from "crypto"; | |
| 14 | +import { | |
| 15 | + insertMail, | |
| 16 | + insertMailAttachment, | |
| 17 | + routeByAddresses, | |
| 18 | + extractReference, | |
| 19 | + verifySvixSignature, | |
| 20 | +} from "@/lib/comms/mail"; | |
| 21 | +import { | |
| 22 | + findSubmissionByRef, | |
| 23 | + insertMessage, | |
| 24 | + logEvent, | |
| 25 | + updateSubmission, | |
| 26 | + commsDb, | |
| 27 | +} from "@/lib/comms/db"; | |
| 28 | + | |
| 29 | +const MAIL_UPLOAD_DIR = path.join(process.cwd(), "data", "mail-uploads"); | |
| 30 | +const MAX_ATT_BYTES = 10 * 1024 * 1024; | |
| 31 | + | |
| 32 | +/** « Prénom Nom <a@b.co> » → { name, email } */ | |
| 33 | +function parseAddress(raw: unknown): { name: string | null; email: string } { | |
| 34 | + if (typeof raw !== "string") return { name: null, email: "inconnu@invalide" }; | |
| 35 | + const m = raw.match(/^\s*(?:"?([^"<]*)"?\s*)?<([^>]+)>\s*$/); | |
| 36 | + if (m) return { name: m[1]?.trim() || null, email: m[2].trim().toLowerCase() }; | |
| 37 | + return { name: null, email: raw.trim().toLowerCase() }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function toList(raw: unknown): string[] { | |
| 41 | + if (Array.isArray(raw)) | |
| 42 | + return raw | |
| 43 | + .filter((x): x is string => typeof x === "string") | |
| 44 | + .map((x) => parseAddress(x).email); | |
| 45 | + if (typeof raw === "string") return [parseAddress(raw).email]; | |
| 46 | + return []; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export async function POST(req: NextRequest) { | |
| 50 | + const secret = process.env.RESEND_WEBHOOK_SECRET; | |
| 51 | + if (!secret) | |
| 52 | + // Pas encore configuré (dashboard Resend → coller le whsec_ dans .env.local). | |
| 53 | + return NextResponse.json( | |
| 54 | + { error: "Webhook non configuré (RESEND_WEBHOOK_SECRET manquant)." }, | |
| 55 | + { status: 503 }, | |
| 56 | + ); | |
| 57 | + | |
| 58 | + const payload = await req.text(); | |
| 59 | + const svixId = req.headers.get("svix-id"); | |
| 60 | + const ok = verifySvixSignature({ | |
| 61 | + secret, | |
| 62 | + svixId, | |
| 63 | + svixTimestamp: req.headers.get("svix-timestamp"), | |
| 64 | + svixSignature: req.headers.get("svix-signature"), | |
| 65 | + payload, | |
| 66 | + }); | |
| 67 | + if (!ok) | |
| 68 | + return NextResponse.json({ error: "Signature invalide." }, { status: 401 }); | |
| 69 | + | |
| 70 | + let event: { | |
| 71 | + type?: string; | |
| 72 | + data?: { | |
| 73 | + email_id?: string; | |
| 74 | + from?: unknown; | |
| 75 | + to?: unknown; | |
| 76 | + cc?: unknown; | |
| 77 | + subject?: unknown; | |
| 78 | + text?: unknown; | |
| 79 | + html?: unknown; | |
| 80 | + attachments?: { | |
| 81 | + filename?: string; | |
| 82 | + content_type?: string; | |
| 83 | + content?: string; | |
| 84 | + }[]; | |
| 85 | + }; | |
| 86 | + }; | |
| 87 | + try { | |
| 88 | + event = JSON.parse(payload); | |
| 89 | + } catch { | |
| 90 | + return NextResponse.json({ error: "JSON invalide." }, { status: 400 }); | |
| 91 | + } | |
| 92 | + if (event.type !== "email.received") | |
| 93 | + // Autres événements (delivered, bounced…) : accusés sans traitement. | |
| 94 | + return NextResponse.json({ ok: true, ignored: event.type ?? "?" }); | |
| 95 | + | |
| 96 | + const d = event.data ?? {}; | |
| 97 | + const from = parseAddress(d.from); | |
| 98 | + const to = toList(d.to); | |
| 99 | + const cc = toList(d.cc); | |
| 100 | + const subject = | |
| 101 | + 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; | |
| 104 | + const providerId = d.email_id ?? svixId!; | |
| 105 | + | |
| 106 | + // dédup (Svix rejoue tant qu'il ne reçoit pas 200) | |
| 107 | + const dup = commsDb | |
| 108 | + .prepare("SELECT id FROM mail_messages WHERE provider_message_id = ?") | |
| 109 | + .get(providerId) as { id: number } | undefined; | |
| 110 | + if (dup) return NextResponse.json({ ok: true, deduplicated: true }); | |
| 111 | + | |
| 112 | + /* ---------- rattachement à une demande du hub (référence en sujet) ---------- */ | |
| 113 | + const reference = extractReference(subject); | |
| 114 | + const submission = reference ? findSubmissionByRef(reference) : undefined; | |
| 115 | + if (submission) { | |
| 116 | + insertMessage({ | |
| 117 | + submission_id: submission.id, | |
| 118 | + sender_type: "user", | |
| 119 | + body: | |
| 120 | + (text ?? (html ? html.replace(/<[^>]+>/g, " ") : "")).slice(0, 12000) || | |
| 121 | + "(message vide)", | |
| 122 | + channel: "email", | |
| 123 | + provider_message_id: providerId, | |
| 124 | + }); | |
| 125 | + updateSubmission(submission.id, { is_read: 0 }); | |
| 126 | + logEvent( | |
| 127 | + submission.id, | |
| 128 | + null, | |
| 129 | + "reponse-entrante", | |
| 130 | + `Courriel reçu de ${from.email}`, | |
| 131 | + ); | |
| 132 | + } | |
| 133 | + | |
| 134 | + /* ---------- boîte personnelle ---------- */ | |
| 135 | + const adminId = routeByAddresses([...to, ...cc]); | |
| 136 | + const mailId = insertMail({ | |
| 137 | + admin_id: adminId, | |
| 138 | + direction: "in", | |
| 139 | + folder: "inbox", | |
| 140 | + from_email: from.email, | |
| 141 | + from_name: from.name, | |
| 142 | + to_emails: to, | |
| 143 | + cc_emails: cc, | |
| 144 | + subject, | |
| 145 | + body_text: text, | |
| 146 | + body_html: html, | |
| 147 | + provider_message_id: providerId, | |
| 148 | + submission_id: submission?.id ?? null, | |
| 149 | + }); | |
| 150 | + | |
| 151 | + /* ---------- pièces jointes (base64 dans le payload, bornées) ---------- */ | |
| 152 | + const atts = Array.isArray(d.attachments) ? d.attachments.slice(0, 5) : []; | |
| 153 | + for (const a of atts) { | |
| 154 | + if (!a?.content || typeof a.content !== "string") continue; | |
| 155 | + let buf: Buffer; | |
| 156 | + try { | |
| 157 | + buf = Buffer.from(a.content, "base64"); | |
| 158 | + } catch { | |
| 159 | + continue; | |
| 160 | + } | |
| 161 | + if (!buf.length || buf.length > MAX_ATT_BYTES) continue; | |
| 162 | + const original = (a.filename || "piece-jointe").slice(0, 200); | |
| 163 | + const ext = path.extname(original).toLowerCase().slice(0, 10); | |
| 164 | + const year = String(new Date().getFullYear()); | |
| 165 | + const stored = `${year}/mail-${mailId}-${crypto.randomBytes(8).toString("hex")}${ext}`; | |
| 166 | + await fs.mkdir(path.join(MAIL_UPLOAD_DIR, year), { recursive: true }); | |
| 167 | + await fs.writeFile(path.join(MAIL_UPLOAD_DIR, stored), buf); | |
| 168 | + insertMailAttachment({ | |
| 169 | + message_id: mailId, | |
| 170 | + original_name: original, | |
| 171 | + stored_name: stored, | |
| 172 | + mime: (a.content_type || "application/octet-stream").slice(0, 100), | |
| 173 | + size: buf.length, | |
| 174 | + }); | |
| 175 | + } | |
| 176 | + | |
| 177 | + return NextResponse.json({ | |
| 178 | + ok: true, | |
| 179 | + routed: adminId != null, | |
| 180 | + linked: submission?.reference ?? null, | |
| 181 | + }); | |
| 182 | +} | |
added
src/lib/comms/mail.ts
+322 −0
@@ -0,0 +1,322 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA Communication Hub — la boîte courriel du panel (/admin/courriel). | |
| 3 | +// Chaque admin possède une adresse @groupe-ka.com (admin_users.email) : | |
| 4 | +// l'envoi part par Resend, la réception arrive par le webhook Resend | |
| 5 | +// (email.received, signé Svix) et est routée vers la boîte du destinataire. | |
| 6 | +// Un courriel entrant dont le sujet porte une référence KA-XXX-AAAA-NNNN est | |
| 7 | +// AUSSI rattaché à la demande du hub (la conversation se referme en DB). | |
| 8 | +import crypto from "crypto"; | |
| 9 | +import { commsDb } from "./db"; | |
| 10 | + | |
| 11 | +/* ---------- schéma (idempotent, même base ka-comms.db) ---------- */ | |
| 12 | + | |
| 13 | +commsDb.exec(` | |
| 14 | + CREATE TABLE IF NOT EXISTS mail_messages ( | |
| 15 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 16 | + admin_id INTEGER, -- boîte propriétaire (NULL = non routé) | |
| 17 | + direction TEXT NOT NULL, -- 'in' | 'out' | |
| 18 | + folder TEXT NOT NULL DEFAULT 'inbox', -- inbox | sent | archive | trash | |
| 19 | + from_email TEXT NOT NULL, | |
| 20 | + from_name TEXT, | |
| 21 | + to_emails TEXT NOT NULL, -- JSON array | |
| 22 | + cc_emails TEXT, -- JSON array | |
| 23 | + reply_to TEXT, | |
| 24 | + subject TEXT, | |
| 25 | + body_text TEXT, | |
| 26 | + body_html TEXT, | |
| 27 | + snippet TEXT, | |
| 28 | + provider_message_id TEXT, -- id Resend (sortant) / email_id ou svix-id (entrant) | |
| 29 | + in_reply_to INTEGER, -- mail_messages.id local (fil de réponse) | |
| 30 | + submission_id INTEGER, -- demande du hub rattachée (réf. détectée) | |
| 31 | + is_read INTEGER NOT NULL DEFAULT 0, | |
| 32 | + created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 33 | + ); | |
| 34 | + CREATE INDEX IF NOT EXISTS mm_box ON mail_messages(admin_id, folder, created_at); | |
| 35 | + -- dédup des retries du webhook (Svix rejoue en cas de non-200) | |
| 36 | + CREATE UNIQUE INDEX IF NOT EXISTS mm_provider | |
| 37 | + ON mail_messages(provider_message_id) WHERE provider_message_id IS NOT NULL; | |
| 38 | + | |
| 39 | + CREATE TABLE IF NOT EXISTS mail_attachments ( | |
| 40 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 41 | + message_id INTEGER NOT NULL, | |
| 42 | + original_name TEXT NOT NULL, | |
| 43 | + stored_name TEXT NOT NULL, | |
| 44 | + mime TEXT NOT NULL, | |
| 45 | + size INTEGER NOT NULL, | |
| 46 | + created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 47 | + ); | |
| 48 | + CREATE INDEX IF NOT EXISTS ma_message ON mail_attachments(message_id); | |
| 49 | +`); | |
| 50 | + | |
| 51 | +// Adresses des boîtes maîtresses — posées ici pour que le routage marche | |
| 52 | +// dès le déploiement ; modifiables ensuite via /admin/equipe. | |
| 53 | +try { | |
| 54 | + commsDb | |
| 55 | + .prepare( | |
| 56 | + "UPDATE admin_users SET email = ? WHERE username = ? AND (email IS NULL OR email = '')", | |
| 57 | + ) | |
| 58 | + .run("erikabc@groupe-ka.com", "erikabc"); | |
| 59 | + commsDb | |
| 60 | + .prepare( | |
| 61 | + "UPDATE admin_users SET email = ? WHERE username = ? AND (email IS NULL OR email = '')", | |
| 62 | + ) | |
| 63 | + .run("spboucher@groupe-ka.com", "spboucher"); | |
| 64 | +} catch { | |
| 65 | + /* course entre workers de build : sans conséquence */ | |
| 66 | +} | |
| 67 | + | |
| 68 | +/* ---------- types ---------- */ | |
| 69 | + | |
| 70 | +export type MailRow = { | |
| 71 | + id: number; | |
| 72 | + admin_id: number | null; | |
| 73 | + direction: string; | |
| 74 | + folder: string; | |
| 75 | + from_email: string; | |
| 76 | + from_name: string | null; | |
| 77 | + to_emails: string; | |
| 78 | + cc_emails: string | null; | |
| 79 | + reply_to: string | null; | |
| 80 | + subject: string | null; | |
| 81 | + body_text: string | null; | |
| 82 | + body_html: string | null; | |
| 83 | + snippet: string | null; | |
| 84 | + provider_message_id: string | null; | |
| 85 | + in_reply_to: number | null; | |
| 86 | + submission_id: number | null; | |
| 87 | + is_read: number; | |
| 88 | + created_at: string; | |
| 89 | +}; | |
| 90 | + | |
| 91 | +export type MailAttachmentRow = { | |
| 92 | + id: number; | |
| 93 | + message_id: number; | |
| 94 | + original_name: string; | |
| 95 | + stored_name: string; | |
| 96 | + mime: string; | |
| 97 | + size: number; | |
| 98 | + created_at: string; | |
| 99 | +}; | |
| 100 | + | |
| 101 | +export const MAIL_FOLDERS: Record<string, string> = { | |
| 102 | + inbox: "Boîte de réception", | |
| 103 | + sent: "Envoyés", | |
| 104 | + archive: "Archives", | |
| 105 | + trash: "Corbeille", | |
| 106 | +}; | |
| 107 | + | |
| 108 | +/* ---------- CRUD ---------- */ | |
| 109 | + | |
| 110 | +export function insertMail(m: { | |
| 111 | + admin_id: number | null; | |
| 112 | + direction: "in" | "out"; | |
| 113 | + folder: string; | |
| 114 | + from_email: string; | |
| 115 | + from_name?: string | null; | |
| 116 | + to_emails: string[]; | |
| 117 | + cc_emails?: string[] | null; | |
| 118 | + reply_to?: string | null; | |
| 119 | + subject?: string | null; | |
| 120 | + body_text?: string | null; | |
| 121 | + body_html?: string | null; | |
| 122 | + provider_message_id?: string | null; | |
| 123 | + in_reply_to?: number | null; | |
| 124 | + submission_id?: number | null; | |
| 125 | + is_read?: boolean; | |
| 126 | +}): number { | |
| 127 | + const snippet = (m.body_text ?? "") | |
| 128 | + .replace(/\s+/g, " ") | |
| 129 | + .trim() | |
| 130 | + .slice(0, 160); | |
| 131 | + const info = commsDb | |
| 132 | + .prepare( | |
| 133 | + `INSERT INTO mail_messages | |
| 134 | + (admin_id, direction, folder, from_email, from_name, to_emails, | |
| 135 | + cc_emails, reply_to, subject, body_text, body_html, snippet, | |
| 136 | + provider_message_id, in_reply_to, submission_id, is_read) | |
| 137 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 138 | + ) | |
| 139 | + .run( | |
| 140 | + m.admin_id, | |
| 141 | + m.direction, | |
| 142 | + m.folder, | |
| 143 | + m.from_email, | |
| 144 | + m.from_name ?? null, | |
| 145 | + JSON.stringify(m.to_emails), | |
| 146 | + m.cc_emails?.length ? JSON.stringify(m.cc_emails) : null, | |
| 147 | + m.reply_to ?? null, | |
| 148 | + m.subject ?? null, | |
| 149 | + m.body_text ?? null, | |
| 150 | + m.body_html ?? null, | |
| 151 | + snippet || null, | |
| 152 | + m.provider_message_id ?? null, | |
| 153 | + m.in_reply_to ?? null, | |
| 154 | + m.submission_id ?? null, | |
| 155 | + m.is_read ? 1 : 0, | |
| 156 | + ); | |
| 157 | + return Number(info.lastInsertRowid); | |
| 158 | +} | |
| 159 | + | |
| 160 | +export function findMail(id: number): MailRow | undefined { | |
| 161 | + return commsDb.prepare("SELECT * FROM mail_messages WHERE id = ?").get(id) as | |
| 162 | + | MailRow | |
| 163 | + | undefined; | |
| 164 | +} | |
| 165 | + | |
| 166 | +/** Boîte d'un admin : ses messages + (super_admin) les non-routés. */ | |
| 167 | +export function listMail( | |
| 168 | + adminId: number, | |
| 169 | + includeUnrouted: boolean, | |
| 170 | + folder: string, | |
| 171 | + page = 1, | |
| 172 | + pageSize = 40, | |
| 173 | +): { rows: MailRow[]; total: number } { | |
| 174 | + const owner = includeUnrouted | |
| 175 | + ? "(admin_id = ? OR admin_id IS NULL)" | |
| 176 | + : "admin_id = ?"; | |
| 177 | + const total = ( | |
| 178 | + commsDb | |
| 179 | + .prepare( | |
| 180 | + `SELECT COUNT(*) AS c FROM mail_messages WHERE ${owner} AND folder = ?`, | |
| 181 | + ) | |
| 182 | + .get(adminId, folder) as { c: number } | |
| 183 | + ).c; | |
| 184 | + const rows = commsDb | |
| 185 | + .prepare( | |
| 186 | + `SELECT * FROM mail_messages WHERE ${owner} AND folder = ? | |
| 187 | + ORDER BY created_at DESC LIMIT ? OFFSET ?`, | |
| 188 | + ) | |
| 189 | + .all(adminId, folder, pageSize, (page - 1) * pageSize) as MailRow[]; | |
| 190 | + return { rows, total }; | |
| 191 | +} | |
| 192 | + | |
| 193 | +export function unreadMailCount(adminId: number, includeUnrouted: boolean): number { | |
| 194 | + const owner = includeUnrouted | |
| 195 | + ? "(admin_id = ? OR admin_id IS NULL)" | |
| 196 | + : "admin_id = ?"; | |
| 197 | + return ( | |
| 198 | + commsDb | |
| 199 | + .prepare( | |
| 200 | + `SELECT COUNT(*) AS c FROM mail_messages | |
| 201 | + WHERE ${owner} AND folder = 'inbox' AND is_read = 0`, | |
| 202 | + ) | |
| 203 | + .get(adminId) as { c: number } | |
| 204 | + ).c; | |
| 205 | +} | |
| 206 | + | |
| 207 | +/** L'admin peut-il agir sur ce message ? (propriétaire, ou super_admin pour | |
| 208 | + les non-routés — jamais la boîte d'un autre). */ | |
| 209 | +export function canAccessMail( | |
| 210 | + mail: MailRow, | |
| 211 | + adminId: number, | |
| 212 | + superAdmin: boolean, | |
| 213 | +): boolean { | |
| 214 | + if (mail.admin_id === adminId) return true; | |
| 215 | + return mail.admin_id === null && superAdmin; | |
| 216 | +} | |
| 217 | + | |
| 218 | +export function updateMail( | |
| 219 | + id: number, | |
| 220 | + patch: { folder?: string; is_read?: boolean; admin_id?: number }, | |
| 221 | +): void { | |
| 222 | + if (patch.folder !== undefined && patch.folder in MAIL_FOLDERS) | |
| 223 | + commsDb | |
| 224 | + .prepare("UPDATE mail_messages SET folder = ? WHERE id = ?") | |
| 225 | + .run(patch.folder, id); | |
| 226 | + if (patch.is_read !== undefined) | |
| 227 | + commsDb | |
| 228 | + .prepare("UPDATE mail_messages SET is_read = ? WHERE id = ?") | |
| 229 | + .run(patch.is_read ? 1 : 0, id); | |
| 230 | + if (patch.admin_id !== undefined) | |
| 231 | + commsDb | |
| 232 | + .prepare("UPDATE mail_messages SET admin_id = ? WHERE id = ?") | |
| 233 | + .run(patch.admin_id, id); | |
| 234 | +} | |
| 235 | + | |
| 236 | +export function insertMailAttachment(a: { | |
| 237 | + message_id: number; | |
| 238 | + original_name: string; | |
| 239 | + stored_name: string; | |
| 240 | + mime: string; | |
| 241 | + size: number; | |
| 242 | +}): void { | |
| 243 | + commsDb | |
| 244 | + .prepare( | |
| 245 | + `INSERT INTO mail_attachments (message_id, original_name, stored_name, mime, size) | |
| 246 | + VALUES (?, ?, ?, ?, ?)`, | |
| 247 | + ) | |
| 248 | + .run(a.message_id, a.original_name, a.stored_name, a.mime, a.size); | |
| 249 | +} | |
| 250 | + | |
| 251 | +export function mailAttachmentsOf(messageId: number): MailAttachmentRow[] { | |
| 252 | + return commsDb | |
| 253 | + .prepare("SELECT * FROM mail_attachments WHERE message_id = ?") | |
| 254 | + .all(messageId) as MailAttachmentRow[]; | |
| 255 | +} | |
| 256 | + | |
| 257 | +export function findMailAttachment(id: number): MailAttachmentRow | undefined { | |
| 258 | + return commsDb | |
| 259 | + .prepare("SELECT * FROM mail_attachments WHERE id = ?") | |
| 260 | + .get(id) as MailAttachmentRow | undefined; | |
| 261 | +} | |
| 262 | + | |
| 263 | +/* ---------- routage entrant ---------- */ | |
| 264 | + | |
| 265 | +/** adresse locale → admin (par admin_users.email, insensible à la casse). */ | |
| 266 | +export function routeByAddresses(addresses: string[]): number | null { | |
| 267 | + for (const raw of addresses) { | |
| 268 | + const email = raw.toLowerCase().trim(); | |
| 269 | + const row = commsDb | |
| 270 | + .prepare( | |
| 271 | + "SELECT id FROM admin_users WHERE lower(email) = ? AND active = 1", | |
| 272 | + ) | |
| 273 | + .get(email) as { id: number } | undefined; | |
| 274 | + if (row) return row.id; | |
| 275 | + } | |
| 276 | + return null; | |
| 277 | +} | |
| 278 | + | |
| 279 | +/** Référence du hub dans un sujet (« Re: KA-INV-2026-0012 — … »). */ | |
| 280 | +export function extractReference(subject: string | null): string | null { | |
| 281 | + const m = (subject ?? "").match(/KA-[A-Z]{3}-\d{4}-\d{4}/); | |
| 282 | + return m ? m[0] : null; | |
| 283 | +} | |
| 284 | + | |
| 285 | +/* ---------- vérification Svix (webhooks Resend) ---------- */ | |
| 286 | + | |
| 287 | +/** | |
| 288 | + * Resend signe ses webhooks au format Svix : HMAC-SHA256 de | |
| 289 | + * « {svix-id}.{svix-timestamp}.{corps brut} » avec le secret whsec_ (base64). | |
| 290 | + * Implémentation locale, zéro dépendance — même philosophie que scrypt. | |
| 291 | + */ | |
| 292 | +export function verifySvixSignature(opts: { | |
| 293 | + secret: string; | |
| 294 | + svixId: string | null; | |
| 295 | + svixTimestamp: string | null; | |
| 296 | + svixSignature: string | null; | |
| 297 | + payload: string; | |
| 298 | +}): boolean { | |
| 299 | + if (!opts.svixId || !opts.svixTimestamp || !opts.svixSignature) return false; | |
| 300 | + // fenêtre anti-rejeu : ±5 minutes | |
| 301 | + const ts = Number(opts.svixTimestamp); | |
| 302 | + if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false; | |
| 303 | + let key: Buffer; | |
| 304 | + try { | |
| 305 | + key = Buffer.from(opts.secret.replace(/^whsec_/, ""), "base64"); | |
| 306 | + } catch { | |
| 307 | + return false; | |
| 308 | + } | |
| 309 | + if (!key.length) return false; | |
| 310 | + const expected = crypto | |
| 311 | + .createHmac("sha256", key) | |
| 312 | + .update(`${opts.svixId}.${opts.svixTimestamp}.${opts.payload}`) | |
| 313 | + .digest("base64"); | |
| 314 | + const exp = Buffer.from(expected); | |
| 315 | + // l'en-tête peut contenir plusieurs signatures : « v1,xxx v1,yyy » | |
| 316 | + return opts.svixSignature.split(" ").some((part) => { | |
| 317 | + const [version, sig] = part.split(","); | |
| 318 | + if (version !== "v1" || !sig) return false; | |
| 319 | + const given = Buffer.from(sig); | |
| 320 | + return given.length === exp.length && crypto.timingSafeEqual(given, exp); | |
| 321 | + }); | |
| 322 | +} | |
modified
src/lib/comms/receipt-email.ts
+2 −0
@@ -80,6 +80,7 @@ export async function sendReceipt(opts: { | ||
| 80 | 80 | ${p(`Conservez ce numéro : il identifie votre demande dans tous nos échanges.`)}`; |
| 81 | 81 | await sendEmail({ |
| 82 | 82 | to: opts.to, |
| 83 | + replyTo: "hub@groupe-ka.com", | |
| 83 | 84 | subject: `${opts.reference} — votre demande est reçue · Groupe KA`, |
| 84 | 85 | html: shell({ |
| 85 | 86 | kicker: "Accusé de réception", |
@@ -105,6 +106,7 @@ export async function sendAdminReply(opts: { | ||
| 105 | 106 | ${p(`Pour poursuivre l'échange, répondez simplement à ce courriel en conservant le numéro <strong style="color:${INK};">${esc(opts.reference)}</strong> dans l'objet.`)}`; |
| 106 | 107 | await sendEmail({ |
| 107 | 108 | to: opts.to, |
| 109 | + replyTo: "hub@groupe-ka.com", | |
| 108 | 110 | subject: `${opts.reference} — réponse de Groupe KA`, |
| 109 | 111 | html: shell({ |
| 110 | 112 | kicker: "Réponse à votre demande", |
modified
src/lib/email.ts
+12 −4
@@ -11,11 +11,15 @@ export const MAIL_FROM = | ||
| 11 | 11 | process.env.RESEND_FROM ?? "KA ID — Groupe Ka <ka-id@groupe-ka.com>"; |
| 12 | 12 | |
| 13 | 13 | export async function sendEmail(opts: { |
| 14 | − to: string; | |
| 14 | + to: string | string[]; | |
| 15 | 15 | subject: string; |
| 16 | 16 | html: string; |
| 17 | 17 | text: string; |
| 18 | −}): Promise<void> { | |
| 18 | + /** expéditeur personnalisé (boîtes admin @groupe-ka.com) — défaut : MAIL_FROM */ | |
| 19 | + from?: string; | |
| 20 | + replyTo?: string; | |
| 21 | + cc?: string[]; | |
| 22 | +}): Promise<{ id: string | null }> { | |
| 19 | 23 | const key = process.env.RESEND_API_KEY; |
| 20 | 24 | if (!key) throw new Error("RESEND_API_KEY manquant"); |
| 21 | 25 | const res = await fetch(RESEND_ENDPOINT, { |
@@ -25,8 +29,10 @@ export async function sendEmail(opts: { | ||
| 25 | 29 | "Content-Type": "application/json", |
| 26 | 30 | }, |
| 27 | 31 | body: JSON.stringify({ |
| 28 | − from: MAIL_FROM, | |
| 29 | − to: [opts.to], | |
| 32 | + from: opts.from ?? MAIL_FROM, | |
| 33 | + to: Array.isArray(opts.to) ? opts.to : [opts.to], | |
| 34 | + ...(opts.cc?.length ? { cc: opts.cc } : {}), | |
| 35 | + ...(opts.replyTo ? { reply_to: opts.replyTo } : {}), | |
| 30 | 36 | subject: opts.subject, |
| 31 | 37 | html: opts.html, |
| 32 | 38 | text: opts.text, |
@@ -36,6 +42,8 @@ export async function sendEmail(opts: { | ||
| 36 | 42 | const detail = await res.text().catch(() => ""); |
| 37 | 43 | throw new Error(`Resend ${res.status} : ${detail.slice(0, 300)}`); |
| 38 | 44 | } |
| 45 | + const data = (await res.json().catch(() => ({}))) as { id?: string }; | |
| 46 | + return { id: data.id ?? null }; | |
| 39 | 47 | } |
| 40 | 48 | |
| 41 | 49 | /* ---------- système de design courriel (identité Groupe Ka) ---------- */ |
| 42 | 50 | |