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é.

Capability requise : 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 :

  1. Auto-détection : le backend scanne en continu tous les HTML (pages, templates de cocon, articles) à la recherche de la balise data-cms-form.
  2. Configuration UI : chaque formulaire détecté est listé dans /forms avec un badge d'état et un bouton "Configurer".
  3. Runtime AJAX : un script unique /api/forms/runtime.js intercepte les submit côté visiteur et délègue à l'API du CMS.
  4. Backend de soumission : POST /api/forms/:formId vérifie le honeypot, applique le rate-limit puis envoie le mail via SMTP.
  5. 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>
Convention DOM : aucun script ne doit être ajouté dans le HTML pour câbler le formulaire. Le runtime du CMS s'attache automatiquement à toute balise [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

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 :

ChampTypeDescription
email_tostring (email)Destinataire principal. Plusieurs adresses séparées par virgule autorisées.
subjectstringSujet du mail. Peut contenir des tokens type {{name}} remplacés à l'envoi.
success_messagestringMessage affiché au visiteur après envoi réussi (rendu côté runtime).
redirect_urlstring (URL)Optionnel : si défini, le runtime redirige le visiteur après succès au lieu d'afficher success_message.

Endpoints de configuration

MéthodeRouteDescription
GET/api/forms/detectListe tous les formulaires détectés avec leur état.
GET/api/forms/configLit la configuration globale (SMTP, défauts).
GET/api/forms/config/:formIdLit la configuration d'un formulaire précis.
PUT/api/forms/config/:formIdMet à jour la configuration.
DELETE/api/forms/config/:formIdSupprime la configuration (le formulaire redevient "Non configuré").
POST/api/forms/config/:formId/testEnvoie 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 :

  1. Parcourt le DOM à la recherche de [data-cms-form].
  2. Bloque le submit natif (event.preventDefault()) sur chacun d'eux.
  3. Sérialise les champs en JSON (en s'appuyant sur name et data-cms-field).
  4. Envoie une requête POST /api/forms/:formId en application/json.
  5. Affiche le success_message configuré, ou redirige vers redirect_url si défini.
  6. Gère les erreurs réseau et serveur via un message d'échec accessible (rôle alert).
Zéro JavaScript côté template : aucune intégration ne demande au designer d'ajouter du JS. Tout est piloté par le runtime central, ce qui garantit une UX cohérente sur tous les sites clients.

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 :

  1. Validation du formId : doit correspondre à la regex documentée.
  2. Lookup configuration : la config est lue depuis le KV. Si absente, l'API répond 404 Not configured ou 200 silencieux selon la stratégie globale.
  3. Honeypot : si rempli, court-circuit immédiat avec log status: "spam".
  4. Construction du mail : sujet rendu à partir des tokens, corps formaté en texte + HTML, expéditeur basé sur SMTP.
  5. Envoi SMTP : délégué au mail-service (lui-même branché sur la config SMTP de Réglages → Général).
  6. Journalisation : logsInsert() avec type: "form_submission", action: "sent" ou "mail_failed", et meta contenant formId + 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 :

StatusTypeSignification
sentform_submissionMail accepté par le serveur SMTP (≠ "lu par le destinataire").
spamform_submissionHoneypot rempli ou rate-limit franchi — aucun mail envoyé.
mail_failedform_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 :

Badges

BadgeSensAction recommandée
DétectéLe formId est présent dans au moins un HTML.Configurer si non encore fait.
OrphelinConfiguration 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 :

  1. Vérifie la présence d'une configuration SMTP valide.
  2. Construit un mail factice avec des valeurs marquées [TEST].
  3. Envoie le mail à email_to.
  4. Inscrit un log type: "form_submission" avec action: "sent" et un meta { test: true }.
  5. Renvoie le détail à l'UI (succès / message d'erreur SMTP brut pour faciliter le debug).
Pré-requis : le SMTP doit être configuré dans Réglages → Général → SMTP. Sans cela, le test renvoie smtp_not_configured.

Bonnes pratiques & pièges fréquents

Références