Formulaires
Le module Formulaires du Labo du Yeti repose sur une philosophie volontairement simple : le contenu HTML reste la source de vérité. Les intégrateurs et générateurs IA déposent des balises <form data-cms-form="..."> dans n'importe quelle page (page statique, article, cocon parent ou enfant) et le CMS prend le relais pour la détection, la configuration, la soumission AJAX, la protection anti-spam et la traçabilité.
forms (groupe Contenu). Tous les rôles (owner, admin, webmaster, editor) y ont accès par défaut. Owner peut désactiver la capability par rôle dans Réglages → Comptes.
Principes & vue d'ensemble
Le module repose sur cinq briques cohérentes qui forment une chaîne complète, depuis la rédaction du HTML jusqu'à la réception du mail :
- Auto-détection : le backend scanne en continu tous les HTML (pages, templates de cocon, articles) à la recherche de la balise
data-cms-form. - Configuration UI : chaque formulaire détecté est listé dans
/formsavec un badge d'état et un bouton "Configurer". - Runtime AJAX : un script unique
/api/forms/runtime.jsintercepte lessubmitcôté visiteur et délègue à l'API du CMS. - Backend de soumission :
POST /api/forms/:formIdvérifie le honeypot, applique le rate-limit puis envoie le mail via SMTP. - Journalisation : chaque tentative (envoi OK, spam détecté, échec SMTP) est inscrite dans la table
logs.
La balise data-cms-form
Pour qu'un formulaire soit reconnu par le CMS, il suffit d'ajouter l'attribut data-cms-form sur la balise <form>. La valeur de l'attribut sert d'identifiant fonctionnel (formId) et doit respecter une syntaxe restreinte : minuscules, chiffres, tirets et underscores, commençant par une lettre ou un chiffre — [a-z0-9][a-z0-9_-]*. Cette contrainte évite toute collision avec les sous-routes /config et /runtime.js.
Les champs internes du formulaire utilisent l'attribut data-cms-field pour expliciter leur rôle métier (nom, email, message…). Le CMS l'utilise pour reconstituer un objet structuré dans le mail et pour générer une preview lisible dans l'UI.
<form data-cms-form="contact">
<label>Nom
<input type="text" name="name" data-cms-field="name" required />
</label>
<label>Email
<input type="email" name="email" data-cms-field="email" required />
</label>
<label>Message
<textarea name="message" data-cms-field="message" required></textarea>
</label>
<!-- Honeypot : ne JAMAIS le retirer -->
<input type="text" name="website" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" />
<button type="submit">Envoyer</button>
</form>
[data-cms-form] au chargement.
Détection automatique & sources
Au démarrage du backend et à chaque modification de page (création, mise à jour, suppression), le service forms-service exécute detectForms(). Cette fonction parcourt l'ensemble des HTML connus du CMS via Cheerio et recense chaque data-cms-form rencontré.
Sources scannées
- Pages statiques : tous les templates HTML enregistrés dans la table
pages(et leurs overrides éventuels). - Cocon SEO : templates des
cocon_groups(parents et enfants confondus) — les variables{{service}}et{{ville}}sont ignorées par le détecteur, seul l'attributdata-cms-formcompte. - Articles de blog : templates HTML attachés aux articles, dans le cas où un CTA contact est intégré.
- Layouts globaux : si un formulaire est déclaré dans un header ou footer (ex. formulaire newsletter), il est lui aussi détecté.
Résultat de la détection
Pour chaque formId trouvé, detectForms() retourne un objet de cette forme :
{
"id": "contact",
"fields": ["name", "email", "message"],
"sources": [
{ "kind": "page", "id": "p_abcd1234", "title": "Contact" },
{ "kind": "cocon", "id": "g_efgh5678", "title": "Plombier · Bruxelles" }
],
"cocon_pages": 12,
"configured": true,
"orphan": false
}
Le champ cocon_pages indique combien de pages générées (variantes de cocon) embarquent ce formulaire — utile pour mesurer la portée d'un changement de configuration. Un formulaire est marqué orphan: true lorsqu'il existe en base (configuration enregistrée) mais qu'il n'est plus présent dans aucun HTML, par exemple après une refonte de template.
Configuration par formulaire
Chaque formulaire détecté peut être configuré via l'UI ou par appel direct à PUT /api/forms/config/:formId. Les champs disponibles sont :
| Champ | Type | Description |
|---|---|---|
email_to | string (email) | Destinataire principal. Plusieurs adresses séparées par virgule autorisées. |
subject | string | Sujet du mail. Peut contenir des tokens type {{name}} remplacés à l'envoi. |
success_message | string | Message affiché au visiteur après envoi réussi (rendu côté runtime). |
redirect_url | string (URL) | Optionnel : si défini, le runtime redirige le visiteur après succès au lieu d'afficher success_message. |
Endpoints de configuration
| Méthode | Route | Description |
|---|---|---|
| GET | /api/forms/detect | Liste tous les formulaires détectés avec leur état. |
| GET | /api/forms/config | Lit la configuration globale (SMTP, défauts). |
| GET | /api/forms/config/:formId | Lit la configuration d'un formulaire précis. |
| PUT | /api/forms/config/:formId | Met à jour la configuration. |
| DELETE | /api/forms/config/:formId | Supprime la configuration (le formulaire redevient "Non configuré"). |
| POST | /api/forms/config/:formId/test | Envoie un mail de test avec des valeurs factices. |
Runtime AJAX & intégration layout
Le CMS expose un script unique, public et sans authentification, qui prend en charge la soumission AJAX, l'affichage des messages de succès/erreur et le câblage du honeypot. Ce script doit être inclus une seule fois dans le layout global.
<!-- À placer juste avant </body> dans le layout global -->
<script src="/api/forms/runtime.js" defer></script>
Au chargement, le runtime :
- Parcourt le DOM à la recherche de
[data-cms-form]. - Bloque le
submitnatif (event.preventDefault()) sur chacun d'eux. - Sérialise les champs en JSON (en s'appuyant sur
nameetdata-cms-field). - Envoie une requête
POST /api/forms/:formIdenapplication/json. - Affiche le
success_messageconfiguré, ou redirige versredirect_urlsi défini. - Gère les erreurs réseau et serveur via un message d'échec accessible (rôle
alert).
Anti-spam : honeypot & rate-limit
Deux mécanismes complémentaires protègent l'API contre l'abus :
Honeypot
Tout formulaire intègre par convention un champ invisible aux humains mais visible aux bots :
<input type="text" name="website" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" />
Côté backend, si ce champ est rempli, la soumission est silencieusement rejetée (réponse 200 OK côté client pour ne pas alerter le bot) et un log status: "spam" est inscrit. Aucun mail n'est envoyé.
Rate-limit
Le middleware submitLimiter applique une limite de 10 soumissions par minute et par IP sur la route publique POST /api/forms/:formId. Au-delà, l'API renvoie un 429 Too Many Requests sans toucher au SMTP.
Backend de soumission
Le pipeline de traitement côté serveur enchaîne les étapes suivantes dans backend/src/services/forms-service.js :
- Validation du
formId: doit correspondre à la regex documentée. - Lookup configuration : la config est lue depuis le KV. Si absente, l'API répond
404 Not configuredou200silencieux selon la stratégie globale. - Honeypot : si rempli, court-circuit immédiat avec log
status: "spam". - Construction du mail : sujet rendu à partir des tokens, corps formaté en texte + HTML, expéditeur basé sur SMTP.
- Envoi SMTP : délégué au
mail-service(lui-même branché sur la config SMTP de Réglages → Général). - Journalisation :
logsInsert()avectype: "form_submission",action: "sent"ou"mail_failed", et meta contenantformId+ champs anonymisés si nécessaire.
// Pseudo-code synthétique
router.post('/:formId([a-z0-9][a-z0-9_-]*)', submitLimiter, async (req, res) => {
const { formId } = req.params;
const cfg = await formsService.getConfig(formId);
if (!cfg) return res.status(404).json({ error: 'not_configured' });
// Honeypot
if (req.body.website) {
await logsInsert({ type: 'form_submission', action: 'spam', resource_id: formId });
return res.json({ ok: true });
}
try {
await mailService.send({
to: cfg.email_to,
subject: renderTokens(cfg.subject, req.body),
text: formatPlain(req.body),
html: formatHtml(req.body),
});
await logsInsert({ type: 'form_submission', action: 'sent', resource_id: formId });
res.json({ ok: true });
} catch (err) {
await logsInsert({
type: 'form_submission', action: 'mail_failed', resource_id: formId,
message: err.message
});
res.status(500).json({ error: 'mail_failed' });
}
});
Logs & traçabilité
Chaque tentative de soumission produit une entrée dans la table logs, requêtable depuis /logs et exposée via GET /api/logs. Les états canoniques sont :
| Status | Type | Signification |
|---|---|---|
sent | form_submission | Mail accepté par le serveur SMTP (≠ "lu par le destinataire"). |
spam | form_submission | Honeypot rempli ou rate-limit franchi — aucun mail envoyé. |
mail_failed | form_submission | Échec SMTP : message capturé dans logs.message et meta. |
Les statistiques par formulaire (total, OK, spam, échec) sont calculées par agrégation sur ces logs et affichées dans la fiche détaillée de chaque formulaire.
UI Formulaires
L'écran /forms du back-office se compose de deux sections principales :
Section "Intégration runtime"
En tête de page, un bandeau rappelle comment intégrer le runtime dans le layout global, avec un bouton "Copier" pour le snippet <script src="/api/forms/runtime.js"></script>. Un indicateur vérifie si le layout actif contient bien ce script ; sinon, un callout d'alerte invite à le coller dans Configuration → Layout global.
Section "Formulaires détectés"
La liste regroupe l'ensemble des formId connus avec, pour chacun :
- Le nom du
formId. - Les sources d'apparition (pages, cocons, articles) sous forme de chips cliquables.
- Le nombre de pages générées où il apparaît (cocon variants).
- Un ou plusieurs badges d'état.
- Les statistiques de soumissions (total, OK, spam, échec) sur les 30 derniers jours.
- Les actions : Configurer, Tester l'envoi, Supprimer la configuration.
Badges
| Badge | Sens | Action recommandée |
|---|---|---|
| Détecté | Le formId est présent dans au moins un HTML. | Configurer si non encore fait. |
| Orphelin | Configuration enregistrée mais aucune occurrence HTML actuelle. | Supprimer la config ou restaurer le HTML. |
| Configuré | email_to + subject définis, mail prêt à partir. | Surveiller les stats et tester périodiquement. |
| Non configuré | Le formulaire est détecté mais n'a pas encore de destinataire. | Configurer avant la mise en ligne. |
Test d'envoi manuel
Un bouton "Tester l'envoi" dans la fiche d'un formulaire déclenche POST /api/forms/config/:formId/test. Le backend :
- Vérifie la présence d'une configuration SMTP valide.
- Construit un mail factice avec des valeurs marquées [TEST].
- Envoie le mail à
email_to. - Inscrit un log
type: "form_submission"avecaction: "sent"et un meta{ test: true }. - Renvoie le détail à l'UI (succès / message d'erreur SMTP brut pour faciliter le debug).
smtp_not_configured.
Bonnes pratiques & pièges fréquents
- Ne jamais coder un
onsubmitcustom dans le HTML : le runtime gère tout. Un handler manuel court-circuite le honeypot et le rate-limit. - Réutiliser un même
formIdsur plusieurs pages est valide : la configuration est partagée et les stats agrégées. - Toujours conserver le champ honeypot dans les templates IA — les générateurs ne doivent pas le supprimer pour "nettoyer" le HTML.
- Préférer
data-cms-fieldau seul attributname: le mail formaté en HTML est plus lisible pour le destinataire (libellés explicites). - Surveiller les logs
mail_failed: 99% du temps, l'erreur vient d'un SMTP mal configuré (auth, port, TLS) — pas du CMS.
Références
- Backend :
backend/src/services/forms-service.js,backend/src/services/mail-service.js,backend/src/routes/forms.js. - Runtime :
backend/src/public/forms-runtime.js(servi parGET /api/forms/runtime.js). - Frontend :
frontend/src/pages/Forms/. - Voir aussi : API REST, Sécurité, Réglages → Général (SMTP), Layout global.