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%

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é).
Simon-Pierre Boucher committed 1 mo ago (Aug 26, 2026) parent ea19df8

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&apos;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(/&nbsp;/g, " ")
39 + .replace(/&amp;/g, "&")
40 + .replace(/&lt;/g, "<")
41 + .replace(/&gt;/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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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