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(courriel v3): pièces jointes + gabarits Groupe KA signés

Gabarits (src/lib/comms/mail-templates.ts) : six habillages éditorial
sharp — Simple, Officiel, Bienvenue, Suivi de dossier, Merci, Annonce
(bande encre + titre lime) — qui enveloppent le texte de l admin et le
signent (nom, Groupe KA · Québec, adresse, marque). Fichier pur chaînes :
le sélecteur client importe la liste, le serveur rend l HTML — le client
n envoie JAMAIS d HTML, seulement le texte et l id du gabarit.

Composeur : sélecteur visuel à mini-aperçus CSS, aperçu FIDÈLE en modal
(iframe sandbox, rendu par /api/admin/mail/preview avec la vraie
signature du compte), pièces jointes 3 × 8 Mo (15 Mo total, liste blanche
PDF/Office/CSV/image/TXT/ZIP) avec liste et retrait.

Envoi : /api/admin/mail/send passe en multipart — attachments Resend en
base64 + copie locale des fichiers (mail_attachments, data/mail-uploads,
servis par la route admin authentifiée). Toujours : from = adresse du
compte, écriture en base avant envoi.

Testé en réel : aperçu signé, envoi gabarit annonce + PDF accepté par
Resend et revenu par le webhook dans la réception (boucle complète),
.exe refusé 415, 9 Mo refusé 413, preview sans session 401. Limite
connue : le webhook email.received ne fournit pas le contenu base64 des
pièces jointes ENTRANTES (métadonnées seulement — géré défensivement).
Simon-Pierre Boucher committed 1 mo ago (Aug 26, 2026) parent 236dea3

4 changed files +625 −104

modified src/app/admin/(panel)/courriel/MailClient.tsx +236 −57
@@ -1,11 +1,43 @@
1 1 "use client";
2 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";
3 +// /admin/courriel — composants interactifs. Composeur v3 : gabarits Groupe KA
4 +// (sélecteur visuel avec mini-aperçus, aperçu fidèle en iframe sandbox rendu
5 +// par le serveur), pièces jointes (3 × 8 Mo), envoi multipart. L'expéditeur
6 +// et l'HTML final sont TOUJOURS décidés serveur — le client n'envoie que du
7 +// texte et l'identifiant du gabarit.
8 +import { useRef, useState } from "react";
8 9 import { useRouter } from "next/navigation";
10 +import { MAIL_TEMPLATES } from "@/lib/comms/mail-templates";
11 +
12 +function fmtBytes(n: number): string {
13 + if (n < 1024) return `${n} o`;
14 + if (n < 1024 * 1024) return `${Math.round(n / 1024)} Ko`;
15 + return `${(n / 1024 / 1024).toFixed(1)} Mo`;
16 +}
17 +
18 +/** Mini-aperçu stylisé d'un gabarit (pur CSS — l'aperçu fidèle est l'iframe). */
19 +function TemplateThumb({ id, accent }: { id: string; accent: string }) {
20 + return (
21 + <span className="flex h-[52px] w-full flex-col justify-between rounded-md border border-[rgba(20,24,20,0.35)] bg-[#f5f3ee] p-[6px]" aria-hidden="true">
22 + <span className="flex items-center gap-1">
23 + <span className="gk-display text-[7px] leading-none font-bold">
24 + G<span className="rounded-[2px] bg-lime px-[2px]">KA</span>
25 + </span>
26 + {id === "annonce" ? (
27 + <span className="ml-auto h-[10px] w-[52%] rounded-[2px] bg-ink" />
28 + ) : null}
29 + </span>
30 + <span
31 + className={`flex flex-col gap-[3px] rounded-[3px] p-[4px] ${id === "simple" ? "" : "border border-ink bg-white shadow-[2px_2px_0_rgba(20,24,20,0.55)]"}`}
32 + >
33 + <span className="h-[3px] w-[38%] rounded-full" style={{ background: accent }} />
34 + <span className="h-[2.5px] w-[92%] rounded-full bg-[rgba(20,24,20,0.28)]" />
35 + <span className="h-[2.5px] w-[74%] rounded-full bg-[rgba(20,24,20,0.28)]" />
36 + <span className="mt-[2px] h-[3px] w-[30%] rounded-full bg-[rgba(28,92,65,0.55)]" />
37 + </span>
38 + </span>
39 + );
40 +}
9 41
10 42 export function MailComposer({
11 43 fromEmail,
@@ -25,8 +57,44 @@ export function MailComposer({
25 57 replyTo ? (replyTo.subject.startsWith("Re:") ? replyTo.subject : `Re: ${replyTo.subject}`) : "",
26 58 );
27 59 const [body, setBody] = useState("");
60 + const [template, setTemplate] = useState("simple");
61 + const [files, setFiles] = useState<File[]>([]);
28 62 const [busy, setBusy] = useState(false);
29 63 const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
64 + const [preview, setPreview] = useState<string | null>(null);
65 + const [previewBusy, setPreviewBusy] = useState(false);
66 + const fileRef = useRef<HTMLInputElement>(null);
67 +
68 + function addFiles(list: FileList | null) {
69 + if (!list) return;
70 + const next = [...files];
71 + for (const f of Array.from(list)) {
72 + if (next.length >= 3) break;
73 + if (f.size > 8 * 1024 * 1024) {
74 + setMsg({ ok: false, text: `« ${f.name} » dépasse 8 Mo.` });
75 + continue;
76 + }
77 + if (!next.some((x) => x.name === f.name && x.size === f.size)) next.push(f);
78 + }
79 + setFiles(next);
80 + if (fileRef.current) fileRef.current.value = "";
81 + }
82 +
83 + async function showPreview() {
84 + setPreviewBusy(true);
85 + try {
86 + const res = await fetch("/api/admin/mail/preview", {
87 + method: "POST",
88 + headers: { "Content-Type": "application/json" },
89 + body: JSON.stringify({ template, subject, body }),
90 + });
91 + const data = (await res.json().catch(() => ({}))) as { html?: string };
92 + if (res.ok && data.html) setPreview(data.html);
93 + } catch {
94 + setMsg({ ok: false, text: "Aperçu indisponible — réseau ?" });
95 + }
96 + setPreviewBusy(false);
97 + }
30 98
31 99 async function send(e: React.FormEvent) {
32 100 e.preventDefault();
@@ -34,21 +102,20 @@ export function MailComposer({
34 102 setBusy(true);
35 103 setMsg(null);
36 104 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 − });
105 + const fd = new FormData();
106 + fd.set("to", to);
107 + fd.set("cc", cc);
108 + fd.set("subject", subject);
109 + fd.set("body", body);
110 + fd.set("template", template);
111 + if (replyTo) fd.set("reply_to_id", String(replyTo.id));
112 + for (const f of files) fd.append("files", f);
113 + const res = await fetch("/api/admin/mail/send", { method: "POST", body: fd });
48 114 const data = (await res.json().catch(() => ({}))) as { error?: string };
49 115 if (res.ok) {
50 116 setMsg({ ok: true, text: "Courriel envoyé — il est dans vos Envoyés." });
51 117 setBody("");
118 + setFiles([]);
52 119 if (!replyTo) {
53 120 setTo("");
54 121 setCc("");
@@ -70,51 +137,163 @@ export function MailComposer({
70 137 );
71 138
72 139 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" />
140 + <>
141 + <form onSubmit={send} className="gk-card mt-2 w-full p-5 !shadow-none sm:p-6">
142 + <div className="flex items-center justify-between gap-3">
143 + <p className="kicker">{replyTo ? "Répondre" : "Nouveau courriel"}</p>
144 + <button
145 + type="button"
146 + className="gk-mono cursor-pointer text-[10.5px] font-bold tracking-[0.1em] text-ink-3 uppercase hover:text-ink"
147 + onClick={() => setOpen(false)}
148 + >
149 + Fermer ✕
150 + </button>
91 151 </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é).
152 + <p className="gk-mono mt-3 text-[11px] text-ink-3">
153 + De : <strong className="text-ink">{fromEmail}</strong> · signé automatiquement
110 154 </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}
155 +
156 + <div className="mt-4 grid gap-3 sm:grid-cols-2">
157 + <div className={replyTo ? "sm:col-span-2" : ""}>
158 + <label className="klabel mb-[4px] block" htmlFor="mc-to">À (virgules pour plusieurs)</label>
159 + <input id="mc-to" className="field !py-[9px]" value={to} onChange={(e) => setTo(e.target.value)} required placeholder="personne@exemple.com" />
160 + </div>
161 + {!replyTo ? (
162 + <div>
163 + <label className="klabel mb-[4px] block" htmlFor="mc-cc">Cc (facultatif)</label>
164 + <input id="mc-cc" className="field !py-[9px]" value={cc} onChange={(e) => setCc(e.target.value)} />
165 + </div>
166 + ) : null}
167 + </div>
168 + <label className="klabel mt-3 mb-[4px] block" htmlFor="mc-subject">Sujet</label>
169 + <input id="mc-subject" className="field !py-[9px]" value={subject} onChange={(e) => setSubject(e.target.value)} required />
170 +
171 + {/* ---------- gabarit ---------- */}
172 + <p className="klabel mt-5 mb-[6px]">Gabarit Groupe KA</p>
173 + <div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
174 + {MAIL_TEMPLATES.map((t) => (
175 + <button
176 + key={t.id}
177 + type="button"
178 + onClick={() => setTemplate(t.id)}
179 + title={t.desc}
180 + aria-pressed={template === t.id}
181 + className={`flex cursor-pointer flex-col gap-[6px] rounded-lg border-[1.5px] p-2 text-left transition-transform ${
182 + template === t.id
183 + ? "border-ink bg-lime-soft shadow-[3px_3px_0_rgba(20,24,20,0.85)]"
184 + : "border-[rgba(20,24,20,0.25)] bg-surface hover:-translate-y-[1px] hover:border-ink"
185 + }`}
186 + >
187 + <TemplateThumb id={t.id} accent={t.accent} />
188 + <span className="gk-display text-[11px] leading-tight font-bold">{t.label}</span>
189 + </button>
190 + ))}
191 + </div>
192 + <p className="gk-mono mt-[6px] text-[10px] leading-relaxed text-ink-3">
193 + {MAIL_TEMPLATES.find((t) => t.id === template)?.desc} Votre message est
194 + habillé aux couleurs Groupe KA et signé « {fromEmail} ».
115 195 </p>
196 +
197 + <label className="klabel mt-4 mb-[4px] block" htmlFor="mc-body">Message</label>
198 + <textarea
199 + id="mc-body"
200 + rows={9}
201 + className="field resize-y"
202 + value={body}
203 + onChange={(e) => setBody(e.target.value)}
204 + required
205 + placeholder={"Bonjour,\n\nVotre texte — les doubles sauts de ligne deviennent des paragraphes.\n\nLa signature est ajoutée automatiquement."}
206 + />
207 +
208 + {/* ---------- pièces jointes ---------- */}
209 + <p className="klabel mt-4 mb-[6px]">Pièces jointes (3 max · 8 Mo chacune)</p>
210 + <div className="flex flex-wrap items-center gap-2">
211 + {files.map((f, i) => (
212 + <span key={`${f.name}-${i}`} className="gk-mono inline-flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-surface px-3 py-[7px] text-[11px] font-bold">
213 + 📎 {f.name}
214 + <span className="font-normal text-ink-3">{fmtBytes(f.size)}</span>
215 + <button
216 + type="button"
217 + aria-label={`Retirer ${f.name}`}
218 + className="cursor-pointer text-danger hover:scale-110"
219 + onClick={() => setFiles(files.filter((_, j) => j !== i))}
220 + >
221 + ✕
222 + </button>
223 + </span>
224 + ))}
225 + {files.length < 3 ? (
226 + <label className="gk-mono inline-flex cursor-pointer items-center gap-2 rounded-lg border-[1.5px] border-dashed border-[rgba(20,24,20,0.45)] px-3 py-[7px] text-[11px] font-bold text-ink-2 hover:border-ink hover:bg-lime-soft">
227 + + Ajouter un fichier
228 + <input
229 + ref={fileRef}
230 + type="file"
231 + multiple
232 + className="sr-only"
233 + accept=".pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.png,.jpg,.jpeg,.webp,.zip"
234 + onChange={(e) => addFiles(e.target.files)}
235 + />
236 + </label>
237 + ) : null}
238 + </div>
239 +
240 + <div className="mt-5 flex flex-wrap items-center gap-3">
241 + <button type="submit" className="btn btn-primary !min-h-[42px] !px-6" disabled={busy}>
242 + {busy ? "Envoi…" : `Envoyer${files.length ? ` (${files.length} 📎)` : ""}`}
243 + </button>
244 + <button
245 + type="button"
246 + className="btn btn-ghost !min-h-[42px] !px-5"
247 + onClick={showPreview}
248 + disabled={previewBusy}
249 + >
250 + {previewBusy ? "Rendu…" : "Aperçu du courriel"}
251 + </button>
252 + <p className="gk-mono max-w-[260px] text-[9.5px] leading-relaxed text-ink-3">
253 + Envoyé par Resend depuis votre adresse — copie et pièces jointes
254 + conservées dans la base.
255 + </p>
256 + </div>
257 + {msg ? (
258 + <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"}`}>
259 + {msg.text}
260 + </p>
261 + ) : null}
262 + </form>
263 +
264 + {/* ---------- aperçu fidèle (rendu serveur, iframe sandbox) ---------- */}
265 + {preview !== null ? (
266 + <div
267 + className="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center bg-[rgba(20,24,20,0.55)] p-3 sm:p-8"
268 + role="dialog"
269 + aria-label="Aperçu du courriel"
270 + onClick={() => setPreview(null)}
271 + >
272 + <div
273 + className="flex h-full max-h-[860px] w-full max-w-[680px] flex-col overflow-hidden rounded-xl border-2 border-ink bg-paper shadow-[10px_10px_0_rgba(20,24,20,0.5)]"
274 + onClick={(e) => e.stopPropagation()}
275 + >
276 + <div className="flex items-center justify-between border-b-[1.5px] border-ink bg-ink px-4 py-3">
277 + <p className="gk-mono text-[10px] font-bold tracking-[0.18em] text-lime uppercase">
278 + Aperçu — ce que recevra le destinataire
279 + </p>
280 + <button
281 + className="gk-mono cursor-pointer text-[11px] font-bold tracking-[0.1em] text-paper uppercase hover:text-lime"
282 + onClick={() => setPreview(null)}
283 + >
284 + Fermer ✕
285 + </button>
286 + </div>
287 + <iframe
288 + srcDoc={preview}
289 + sandbox=""
290 + title="Aperçu du courriel"
291 + className="h-full w-full flex-1 bg-white"
292 + />
293 + </div>
294 + </div>
116 295 ) : null}
117 − </form>
296 + </>
118 297 );
119 298 }
120 299
added src/app/api/admin/mail/preview/route.ts +31 −0
@@ -0,0 +1,31 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// /api/admin/mail/preview — aperçu FIDÈLE d'un gabarit avant envoi : même
3 +// moteur de rendu que l'envoi (mail-templates.ts), avec la vraie signature
4 +// de l'admin connecté. Retourne l'HTML complet, affiché dans un iframe
5 +// sandbox côté composeur. Session admin requise.
6 +import { NextRequest, NextResponse } from "next/server";
7 +import { getAdminSession } from "@/lib/comms/admin-auth";
8 +import { findAdminById } from "@/lib/comms/db";
9 +import { renderMailTemplate, templateById } from "@/lib/comms/mail-templates";
10 +
11 +export async function POST(req: NextRequest) {
12 + const session = await getAdminSession();
13 + if (!session)
14 + return NextResponse.json({ error: "Non autorisé." }, { status: 401 });
15 + const admin = findAdminById(session.id);
16 + const body = (await req.json().catch(() => null)) as {
17 + template?: string;
18 + subject?: string;
19 + body?: string;
20 + } | null;
21 + const { html } = renderMailTemplate({
22 + templateId: templateById(body?.template ?? "simple").id,
23 + subject: (body?.subject ?? "").trim().slice(0, 300) || "Sujet de votre courriel",
24 + body:
25 + (body?.body ?? "").trim().slice(0, 100_000) ||
26 + "Votre message apparaîtra ici, mis en forme dans le gabarit choisi.",
27 + adminName: admin?.display_name ?? session.displayName,
28 + adminEmail: admin?.email ?? "vous@groupe-ka.com",
29 + });
30 + return NextResponse.json({ html });
31 +}
modified src/app/api/admin/mail/send/route.ts +153 −47
@@ -1,21 +1,58 @@
1 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.
2 +// /api/admin/mail/send — envoi depuis la boîte de l'admin connecté, v3 :
3 +// multipart (pièces jointes) + gabarits Groupe KA (src/lib/comms/mail-templates).
4 +// Le client fournit UNIQUEMENT du texte + l'identifiant du gabarit — l'HTML
5 +// est rendu serveur avec la signature du compte ; from = adresse du compte,
6 +// jamais choisie par le client. Le message et ses pièces jointes sont écrits
7 +// en base AVANT l'envoi (dossier Envoyés) — la DB reste la source de vérité.
6 8 import { NextRequest, NextResponse } from "next/server";
9 +import fs from "fs/promises";
10 +import path from "path";
11 +import crypto from "crypto";
7 12 import { getAdminSession } from "@/lib/comms/admin-auth";
8 −import { findAdminById } from "@/lib/comms/db";
9 −import { insertMail, findMail, canAccessMail, updateMail } from "@/lib/comms/mail";
13 +import { findAdminById, commsDb } from "@/lib/comms/db";
14 +import {
15 + insertMail,
16 + insertMailAttachment,
17 + findMail,
18 + canAccessMail,
19 + updateMail,
20 +} from "@/lib/comms/mail";
21 +import { renderMailTemplate, templateById } from "@/lib/comms/mail-templates";
10 22 import { sendEmail } from "@/lib/email";
11 −import { commsDb } from "@/lib/comms/db";
23 +
24 +const MAIL_UPLOAD_DIR = path.join(process.cwd(), "data", "mail-uploads");
25 +const MAX_FILES = 3;
26 +const MAX_FILE_BYTES = 8 * 1024 * 1024; // 8 Mo par fichier
27 +const MAX_TOTAL_BYTES = 15 * 1024 * 1024; // 15 Mo par courriel (limite Resend ~40)
28 +
29 +// Types autorisés en ENVOI (plus large que le formulaire public : usage interne).
30 +const ALLOWED_FILES: Record<string, string[]> = {
31 + ".pdf": ["application/pdf"],
32 + ".doc": ["application/msword"],
33 + ".docx": ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
34 + ".xls": ["application/vnd.ms-excel"],
35 + ".xlsx": ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
36 + ".csv": ["text/csv", "application/csv", "application/vnd.ms-excel"],
37 + ".txt": ["text/plain"],
38 + ".png": ["image/png"],
39 + ".jpg": ["image/jpeg"],
40 + ".jpeg": ["image/jpeg"],
41 + ".webp": ["image/webp"],
42 + ".zip": ["application/zip", "application/x-zip-compressed"],
43 +};
12 44
13 45 function validEmail(s: string): boolean {
14 46 return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(s) && s.length <= 200;
15 47 }
16 48
17 −function escapeHtml(s: string): string {
18 − return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
49 +function splitList(v: FormDataEntryValue | null): string[] {
50 + if (typeof v !== "string") return [];
51 + return v
52 + .split(",")
53 + .map((s) => s.trim().toLowerCase())
54 + .filter(Boolean)
55 + .slice(0, 10);
19 56 }
20 57
21 58 export async function POST(req: NextRequest) {
@@ -30,28 +67,21 @@ export async function POST(req: NextRequest) {
30 67 { status: 422 },
31 68 );
32 69
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)
70 + const fd = await req.formData().catch(() => null);
71 + if (!fd)
41 72 return NextResponse.json({ error: "Requête illisible." }, { status: 400 });
42 73
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);
74 + const to = splitList(fd.get("to"));
75 + const cc = splitList(fd.get("cc"));
76 + const subject = (typeof fd.get("subject") === "string" ? (fd.get("subject") as string) : "")
77 + .trim()
78 + .slice(0, 300);
79 + const text = (typeof fd.get("body") === "string" ? (fd.get("body") as string) : "")
80 + .trim()
81 + .slice(0, 100_000);
82 + const template = templateById(
83 + typeof fd.get("template") === "string" ? (fd.get("template") as string) : "simple",
84 + );
55 85
56 86 if (!to.length)
57 87 return NextResponse.json({ error: "Au moins un destinataire requis." }, { status: 422 });
@@ -63,19 +93,58 @@ export async function POST(req: NextRequest) {
63 93 if (!text)
64 94 return NextResponse.json({ error: "Message vide." }, { status: 422 });
65 95
66 − // fil de réponse (facultatif) : le message d'origine doit être accessible
96 + /* ---------- pièces jointes ---------- */
97 + type Staged = { name: string; mime: string; buf: Buffer };
98 + const staged: Staged[] = [];
99 + let totalBytes = 0;
100 + for (const entry of fd.getAll("files")) {
101 + if (!(entry instanceof File) || entry.size === 0) continue;
102 + if (staged.length >= MAX_FILES)
103 + return NextResponse.json({ error: "Trop de fichiers (3 max)." }, { status: 422 });
104 + if (entry.size > MAX_FILE_BYTES)
105 + return NextResponse.json(
106 + { error: `« ${entry.name} » dépasse 8 Mo.` },
107 + { status: 413 },
108 + );
109 + totalBytes += entry.size;
110 + if (totalBytes > MAX_TOTAL_BYTES)
111 + return NextResponse.json(
112 + { error: "Pièces jointes trop lourdes — 15 Mo au total maximum." },
113 + { status: 413 },
114 + );
115 + const ext = path.extname(entry.name || "").toLowerCase();
116 + const mimes = ALLOWED_FILES[ext];
117 + if (!mimes || !mimes.includes(entry.type))
118 + return NextResponse.json(
119 + { error: `Format refusé pour « ${entry.name} » — PDF, Office, CSV, image, TXT ou ZIP.` },
120 + { status: 415 },
121 + );
122 + staged.push({
123 + name: (entry.name || `fichier${ext}`).slice(0, 200),
124 + mime: entry.type,
125 + buf: Buffer.from(await entry.arrayBuffer()),
126 + });
127 + }
128 +
129 + // fil de réponse (facultatif)
67 130 let inReplyTo: number | null = null;
68 − if (body.reply_to_id != null) {
69 − const orig = findMail(Number(body.reply_to_id));
131 + const replyRaw = fd.get("reply_to_id");
132 + if (typeof replyRaw === "string" && replyRaw) {
133 + const orig = findMail(Number(replyRaw));
70 134 if (orig && canAccessMail(orig, session.id, session.roles.includes("super_admin")))
71 135 inReplyTo = orig.id;
72 136 }
73 137
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>`;
138 + /* ---------- rendu du gabarit (serveur) ---------- */
139 + const { html, text: textVersion } = renderMailTemplate({
140 + templateId: template.id,
141 + subject,
142 + body: text,
143 + adminName: admin!.display_name,
144 + adminEmail: from,
145 + });
78 146
147 + /* ---------- écriture en base d'abord ---------- */
79 148 const mailId = insertMail({
80 149 admin_id: session.id,
81 150 direction: "out",
@@ -91,25 +160,62 @@ export async function POST(req: NextRequest) {
91 160 in_reply_to: inReplyTo,
92 161 is_read: true,
93 162 });
163 + if (staged.length) {
164 + const year = String(new Date().getFullYear());
165 + await fs.mkdir(path.join(MAIL_UPLOAD_DIR, year), { recursive: true });
166 + for (const s of staged) {
167 + const stored = `${year}/out-${mailId}-${crypto.randomBytes(8).toString("hex")}${path.extname(s.name).toLowerCase()}`;
168 + await fs.writeFile(path.join(MAIL_UPLOAD_DIR, stored), s.buf);
169 + insertMailAttachment({
170 + message_id: mailId,
171 + original_name: s.name,
172 + stored_name: stored,
173 + mime: s.mime,
174 + size: s.buf.length,
175 + });
176 + }
177 + }
94 178
179 + /* ---------- envoi Resend ---------- */
95 180 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`,
181 + const key = process.env.RESEND_API_KEY;
182 + if (!key) throw new Error("RESEND_API_KEY manquant");
183 + const res = await fetch("https://api.resend.com/emails", {
184 + method: "POST",
185 + headers: {
186 + Authorization: `Bearer ${key}`,
187 + "Content-Type": "application/json",
188 + },
189 + body: JSON.stringify({
190 + from: `${admin!.display_name} — Groupe KA <${from}>`,
191 + to,
192 + ...(cc.length ? { cc } : {}),
193 + reply_to: from,
194 + subject,
195 + html,
196 + text: textVersion,
197 + ...(staged.length
198 + ? {
199 + attachments: staged.map((s) => ({
200 + filename: s.name,
201 + content: s.buf.toString("base64"),
202 + })),
203 + }
204 + : {}),
205 + }),
104 206 });
105 − if (providerId)
207 + if (!res.ok) {
208 + const detail = await res.text().catch(() => "");
209 + throw new Error(`Resend ${res.status} : ${detail.slice(0, 200)}`);
210 + }
211 + const data = (await res.json().catch(() => ({}))) as { id?: string };
212 + if (data.id)
106 213 commsDb
107 214 .prepare("UPDATE mail_messages SET provider_message_id = ? WHERE id = ?")
108 − .run(providerId, mailId);
215 + .run(data.id, mailId);
109 216 if (inReplyTo) updateMail(inReplyTo, { is_read: true });
110 217 return NextResponse.json({ ok: true, id: mailId });
111 218 } catch (e) {
112 − // l'envoi a échoué : le brouillon reste visible dans Envoyés avec mention
113 219 commsDb
114 220 .prepare("UPDATE mail_messages SET folder = 'trash' WHERE id = ?")
115 221 .run(mailId);
added src/lib/comms/mail-templates.ts +205 −0
@@ -0,0 +1,205 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// KA Communication Hub — gabarits de courriel de la boîte admin.
3 +// Six habillages « éditorial sharp » (l'identité exacte du site : encre
4 +// #141814, lime #d9f26b, mono uppercase, carte à ombre dure) qui enveloppent
5 +// le texte de l'admin et le signent (nom + adresse @groupe-ka.com + marque).
6 +// Fichier PUR (chaînes seulement) : importable côté client pour le sélecteur,
7 +// rendu côté serveur pour l'envoi et l'aperçu — le client ne fournit jamais
8 +// d'HTML, seulement l'identifiant du gabarit et le texte.
9 +
10 +const INK = "#141814";
11 +const INK_2 = "#4d5551";
12 +const INK_3 = "#8b928c";
13 +const PAPER = "#f5f3ee";
14 +const LIME = "#d9f26b";
15 +const GREEN = "#1c5c41";
16 +const GREEN_DEEP = "#123f2e";
17 +const F_DISPLAY = "'Space Grotesk',Arial,'Helvetica Neue',sans-serif";
18 +const F_BODY = "'Inter',Helvetica,Arial,sans-serif";
19 +const F_MONO = "'JetBrains Mono','SFMono-Regular',Menlo,Consolas,'Courier New',monospace";
20 +
21 +export type MailTemplateDef = {
22 + id: string;
23 + label: string;
24 + desc: string;
25 + /** kicker mono affiché dans l'habillage (vide = aucun) */
26 + kicker: string;
27 + /** couleur d'accent du mini-aperçu dans le sélecteur */
28 + accent: string;
29 + /** carte blanche à bordure encre autour du texte */
30 + card: boolean;
31 + /** bande d'en-tête encre avec le sujet en grand */
32 + band: boolean;
33 +};
34 +
35 +export const MAIL_TEMPLATES: MailTemplateDef[] = [
36 + {
37 + id: "simple",
38 + label: "Simple",
39 + desc: "Texte sobre, signature discrète — le quotidien.",
40 + kicker: "",
41 + accent: INK_3,
42 + card: false,
43 + band: false,
44 + },
45 + {
46 + id: "officiel",
47 + label: "Officiel",
48 + desc: "Papier à en-tête Groupe KA, carte à ombre dure.",
49 + kicker: "Correspondance officielle",
50 + accent: INK,
51 + card: true,
52 + band: false,
53 + },
54 + {
55 + id: "bienvenue",
56 + label: "Bienvenue",
57 + desc: "Accueillir un partenaire, un client, une recrue.",
58 + kicker: "Bienvenue chez Groupe KA",
59 + accent: LIME,
60 + card: true,
61 + band: false,
62 + },
63 + {
64 + id: "suivi",
65 + label: "Suivi de dossier",
66 + desc: "Faire le point — filet vert, ton posé.",
67 + kicker: "Suivi de votre dossier",
68 + accent: GREEN,
69 + card: true,
70 + band: false,
71 + },
72 + {
73 + id: "merci",
74 + label: "Merci",
75 + desc: "Remercier après une rencontre ou un échange.",
76 + kicker: "Merci",
77 + accent: LIME,
78 + card: true,
79 + band: false,
80 + },
81 + {
82 + id: "annonce",
83 + label: "Annonce",
84 + desc: "Grande nouvelle : bande encre, titre lime.",
85 + kicker: "Annonce · Groupe KA",
86 + accent: GREEN_DEEP,
87 + card: true,
88 + band: true,
89 + },
90 +];
91 +
92 +export function templateById(id: string): MailTemplateDef {
93 + return MAIL_TEMPLATES.find((t) => t.id === id) ?? MAIL_TEMPLATES[0];
94 +}
95 +
96 +/* ---------- rendu ---------- */
97 +
98 +function esc(s: string): string {
99 + return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
100 +}
101 +
102 +/** Texte brut → paragraphes HTML (double saut = ¶, simple = <br>). */
103 +function paragraphize(text: string): string {
104 + return text
105 + .split(/\n{2,}/)
106 + .map(
107 + (para) =>
108 + `<p style="margin:0 0 14px;font-family:${F_BODY};font-size:14.5px;line-height:1.7;color:${INK_2};">${esc(para).replace(/\n/g, "<br>")}</p>`,
109 + )
110 + .join("");
111 +}
112 +
113 +function wordmark(): string {
114 + return `<span style="font-family:${F_DISPLAY};font-weight:700;font-size:21px;letter-spacing:-0.03em;color:${INK};white-space:nowrap;">Groupe&nbsp;<span style="display:inline-block;background:${LIME};color:${INK};padding:0 7px 3px;border-radius:6px;">KA</span></span>`;
115 +}
116 +
117 +function signature(name: string, email: string): string {
118 + return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin-top:26px;">
119 + <tr>
120 + <td style="border-left:3px solid ${LIME};padding-left:14px;">
121 + <p style="margin:0;font-family:${F_DISPLAY};font-weight:700;font-size:15px;letter-spacing:-0.01em;color:${INK};">${esc(name)}</p>
122 + <p style="margin:3px 0 0;font-family:${F_MONO};font-size:10px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:${GREEN};">Groupe KA · Québec</p>
123 + <p style="margin:6px 0 0;font-family:${F_MONO};font-size:10.5px;line-height:1.7;color:${INK_3};">
124 + <a href="mailto:${esc(email)}" style="color:${INK_2};text-decoration:none;">${esc(email)}</a><br>
125 + <a href="https://www.groupe-ka.com" style="color:${GREEN};text-decoration:none;">www.groupe-ka.com</a> — l'écosystème ·Ka
126 + </p>
127 + </td>
128 + </tr>
129 + </table>`;
130 +}
131 +
132 +function footer(): string {
133 + return `<tr><td style="padding:20px 2px 0;font-family:${F_MONO};font-size:9px;font-weight:700;line-height:1.9;letter-spacing:0.1em;text-transform:uppercase;color:${INK_3};">
134 + Groupe KA — holding québécois d'agrégateurs automatisés<br>
135 + <a href="https://www.groupe-ka.com" style="color:${GREEN};text-decoration:none;">www.groupe-ka.com</a>
136 + &nbsp;·&nbsp;<a href="https://www.groupe-ka.com/contact" style="color:${GREEN};text-decoration:none;">nous joindre</a>
137 + </td></tr>`;
138 +}
139 +
140 +/**
141 + * Rend le courriel complet : gabarit + texte de l'admin + signature.
142 + * Retourne l'HTML (tables inline compatibles Gmail/Apple Mail/Outlook)
143 + * et la version texte.
144 + */
145 +export function renderMailTemplate(opts: {
146 + templateId: string;
147 + subject: string;
148 + body: string;
149 + adminName: string;
150 + adminEmail: string;
151 +}): { html: string; text: string } {
152 + const t = templateById(opts.templateId);
153 + const bodyHtml = paragraphize(opts.body);
154 + const sig = signature(opts.adminName, opts.adminEmail);
155 +
156 + let inner: string;
157 + if (t.id === "simple") {
158 + // sobre : pas de carte, wordmark discret en tête, signature filet lime
159 + inner = `<tr><td style="padding:6px 2px 18px;">${wordmark()}</td></tr>
160 + <tr><td style="background:#ffffff;border-radius:12px;padding:26px 26px 22px;border:1.5px solid rgba(20,24,20,0.16);">
161 + ${bodyHtml}${sig}
162 + </td></tr>`;
163 + } else if (t.band) {
164 + // annonce : bande encre avec le sujet en grand, puis carte
165 + inner = `<tr><td style="padding:0 2px 14px;">${wordmark()}</td></tr>
166 + <tr><td style="background:${INK};border-radius:12px 12px 0 0;padding:26px 28px 22px;">
167 + <p style="margin:0 0 8px;font-family:${F_MONO};font-size:10px;font-weight:700;letter-spacing:0.2em;text-transform:uppercase;color:rgba(217,242,107,0.7);">${esc(t.kicker)}</p>
168 + <p style="margin:0;font-family:${F_DISPLAY};font-weight:700;font-size:26px;line-height:1.1;letter-spacing:-0.03em;color:${LIME};">${esc(opts.subject)}</p>
169 + </td></tr>
170 + <tr><td style="background:#ffffff;border:2px solid ${INK};border-top:none;border-right-width:8px;border-bottom-width:8px;border-radius:0 0 12px 12px;padding:26px 28px 22px;">
171 + ${bodyHtml}${sig}
172 + </td></tr>`;
173 + } else {
174 + // carte signature du site : manchette + kicker + titre + corps + signature
175 + inner = `<tr><td style="padding:0 2px 16px;">
176 + <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>
177 + <td>${wordmark()}</td>
178 + <td align="right" style="font-family:${F_MONO};font-size:10px;font-weight:700;letter-spacing:0.16em;text-transform:uppercase;color:${INK_3};">${esc(t.kicker)}</td>
179 + </tr></table>
180 + </td></tr>
181 + <tr><td style="background:#ffffff;border:2px solid ${INK};border-right-width:8px;border-bottom-width:8px;border-radius:12px;padding:28px 28px 24px;">
182 + <p style="margin:0 0 4px;font-family:${F_MONO};font-size:11px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;color:${GREEN};"><span style="display:inline-block;width:22px;height:2px;background:${GREEN};vertical-align:middle;">&nbsp;</span>&nbsp;&nbsp;${esc(t.kicker)}</p>
183 + <p style="margin:0 0 16px;font-family:${F_DISPLAY};font-weight:700;font-size:24px;line-height:1.12;letter-spacing:-0.03em;color:${INK};">${esc(opts.subject)}</p>
184 + ${bodyHtml}${sig}
185 + </td></tr>`;
186 + }
187 +
188 + const html = `<!doctype html>
189 +<html lang="fr">
190 +<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="light"><title>${esc(opts.subject)}</title></head>
191 +<body style="margin:0;padding:0;background:${PAPER};">
192 + <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${PAPER};">
193 + <tr><td align="center" style="padding:36px 16px 32px;">
194 + <table role="presentation" cellpadding="0" cellspacing="0" style="width:560px;max-width:100%;">
195 + ${inner}
196 + ${footer()}
197 + </table>
198 + </td></tr>
199 + </table>
200 +</body>
201 +</html>`;
202 +
203 + const text = `${opts.body}\n\n—\n${opts.adminName}\nGroupe KA · Québec\n${opts.adminEmail}\nwww.groupe-ka.com`;
204 + return { html, text };
205 +}
206