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).
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, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 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, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 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 <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 | + · <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;"> </span> ${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 | ||