Files
Portfolio-Codex/docs/stories/5.5.traitement-php-email.md

10 KiB

Story 5.5: Traitement PHP et Envoi d'Email

Status

Ready for Dev

Story

As a propriétaire du site, I want recevoir les messages par email de manière sécurisée, so that je puisse répondre aux visiteurs.

Acceptance Criteria

  1. Le backend PHP valide à nouveau tous les champs (ne jamais faire confiance au client)
  2. Le token reCAPTCHA est vérifié via l'API Google (score > 0.5)
  3. Les données sont nettoyées (htmlspecialchars, trim) contre XSS
  4. Un token CSRF est vérifié pour éviter les attaques cross-site
  5. L'email est envoyé via mail() PHP ou SMTP configuré
  6. L'email contient : tous les champs du formulaire, catégorie, date/heure, IP (optionnel)
  7. En cas de succès, une réponse JSON {"success": true} est renvoyée
  8. En cas d'erreur, une réponse JSON avec le message d'erreur est renvoyée

Tasks / Subtasks

  • [] Task 1 : Créer l'endpoint api/contact.php (AC: 7, 8)

    • [] Créer le fichier api/contact.php
    • [] Configurer les headers JSON (Content-Type, X-Content-Type-Options)
    • [] Gérer uniquement les requêtes POST (405 sinon)
  • [] Task 2 : Valider le token CSRF (AC: 4)

    • [] Récupérer le token de la requête JSON
    • [] Utiliser verifyCsrfToken() existante
    • [] Exception si invalide
  • [] Task 3 : Vérifier reCAPTCHA (AC: 2)

    • [] Créer verifyRecaptcha() dans functions.php
    • [] Appeler l'API Google siteverify
    • [] Rejeter si score < RECAPTCHA_THRESHOLD (0.5)
  • [] Task 4 : Valider les données (AC: 1, 3)

    • [] Créer validateContactData() dans functions.php
    • [] Valider required, format email, longueurs min/max
    • [] Nettoyer avec htmlspecialchars et trim
    • [] Exception avec messages détaillés
  • [] Task 5 : Envoyer l'email (AC: 5, 6)

    • [] Créer sendContactEmail() dans functions.php
    • [] Corps formaté avec tous les champs + IP + date
    • [] Headers avec Reply-To vers l'expéditeur
  • [] Task 6 : Retourner la réponse (AC: 7, 8)

    • [] JSON {"success": true, "message": "..."} si OK
    • [] JSON {"success": false, "error": "..."} si erreur

Dev Notes

Endpoint api/contact.php

<?php
/**
 * Endpoint de traitement du formulaire de contact
 */

require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/functions.php';

// Headers
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');

// Uniquement POST
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    echo json_encode(['success' => false, 'error' => 'Méthode non autorisée']);
    exit;
}

// Récupérer les données JSON
$input = json_decode(file_get_contents('php://input'), true);

if (!$input) {
    http_response_code(400);
    echo json_encode(['success' => false, 'error' => 'Données invalides']);
    exit;
}

try {
    // 1. Valider le token CSRF
    if (!validateCsrfToken($input['csrf_token'] ?? '')) {
        throw new Exception('Token de sécurité invalide. Veuillez rafraîchir la page.');
    }

    // 2. Vérifier reCAPTCHA
    $recaptchaScore = verifyRecaptcha($input['recaptcha_token'] ?? '');
    if ($recaptchaScore < RECAPTCHA_THRESHOLD) {
        error_log("reCAPTCHA score trop bas: {$recaptchaScore}");
        throw new Exception('Vérification anti-spam échouée. Veuillez réessayer.');
    }

    // 3. Valider et nettoyer les données
    $data = validateContactData($input);

    // 4. Envoyer l'email
    $sent = sendContactEmail($data);

    if (!$sent) {
        throw new Exception('Erreur lors de l\'envoi du message. Veuillez réessayer plus tard.');
    }

    // 5. Succès
    echo json_encode([
        'success' => true,
        'message' => 'Votre message a bien été envoyé ! Je vous répondrai dans les meilleurs délais.'
    ]);

} catch (Exception $e) {
    http_response_code(400);
    echo json_encode([
        'success' => false,
        'error' => $e->getMessage()
    ]);
}

Fonction de Validation (includes/functions.php)

/**
 * Valide et nettoie les données du formulaire de contact
 * @throws Exception si validation échoue
 */
function validateContactData(array $input): array
{
    $errors = [];

    // Champs requis
    $required = ['nom', 'prenom', 'email', 'categorie', 'objet', 'message'];
    foreach ($required as $field) {
        if (empty(trim($input[$field] ?? ''))) {
            $errors[] = "Le champ {$field} est requis";
        }
    }

    // Validation email
    $email = trim($input['email'] ?? '');
    if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors[] = "L'adresse email n'est pas valide";
    }

    // Validation catégorie
    $validCategories = ['projet', 'poste', 'autre'];
    $categorie = $input['categorie'] ?? '';
    if ($categorie && !in_array($categorie, $validCategories)) {
        $errors[] = "Catégorie invalide";
    }

    // Validation longueurs
    if (strlen($input['nom'] ?? '') > 100) {
        $errors[] = "Le nom est trop long (max 100 caractères)";
    }
    if (strlen($input['prenom'] ?? '') > 100) {
        $errors[] = "Le prénom est trop long (max 100 caractères)";
    }
    if (strlen($input['objet'] ?? '') > 200) {
        $errors[] = "L'objet est trop long (max 200 caractères)";
    }
    if (strlen($input['message'] ?? '') > 5000) {
        $errors[] = "Le message est trop long (max 5000 caractères)";
    }

    // Si erreurs, les lancer
    if (!empty($errors)) {
        throw new Exception(implode('. ', $errors));
    }

    // Nettoyer et retourner
    return [
        'nom' => htmlspecialchars(trim($input['nom']), ENT_QUOTES, 'UTF-8'),
        'prenom' => htmlspecialchars(trim($input['prenom']), ENT_QUOTES, 'UTF-8'),
        'email' => filter_var(trim($input['email']), FILTER_SANITIZE_EMAIL),
        'entreprise' => htmlspecialchars(trim($input['entreprise'] ?? ''), ENT_QUOTES, 'UTF-8'),
        'categorie' => $input['categorie'],
        'objet' => htmlspecialchars(trim($input['objet']), ENT_QUOTES, 'UTF-8'),
        'message' => htmlspecialchars(trim($input['message']), ENT_QUOTES, 'UTF-8'),
        'ip' => $_SERVER['REMOTE_ADDR'] ?? 'inconnue',
        'date' => date('d/m/Y à H:i:s'),
    ];
}

Fonction d'Envoi d'Email

/**
 * Envoie l'email de contact
 */
function sendContactEmail(array $data): bool
{
    $categorieLabels = [
        'projet' => 'Projet freelance',
        'poste' => 'Proposition de poste',
        'autre' => 'Autre demande'
    ];

    $subject = "[Portfolio] {$categorieLabels[$data['categorie']]} - {$data['objet']}";

    $body = <<<EMAIL
═══════════════════════════════════════════
NOUVEAU MESSAGE - PORTFOLIO
═══════════════════════════════════════════

DE: {$data['prenom']} {$data['nom']}
EMAIL: {$data['email']}
ENTREPRISE: {$data['entreprise']}
CATÉGORIE: {$categorieLabels[$data['categorie']]}

───────────────────────────────────────────
OBJET: {$data['objet']}
───────────────────────────────────────────

MESSAGE:

{$data['message']}

═══════════════════════════════════════════
Envoyé le {$data['date']}
IP: {$data['ip']}
═══════════════════════════════════════════
EMAIL;

    $headers = implode("\r\n", [
        'From: ' . CONTACT_EMAIL,
        'Reply-To: ' . $data['email'],
        'Content-Type: text/plain; charset=UTF-8',
        'X-Mailer: PHP/' . phpversion(),
        'X-Priority: 1'
    ]);

    $result = mail(CONTACT_EMAIL, $subject, $body, $headers);

    if (!$result) {
        error_log("Échec envoi email contact: " . print_r($data, true));
    }

    return $result;
}

Sécurité Implémentée

Menace Protection
XSS htmlspecialchars() sur toutes les entrées
CSRF Token vérifié en session
Spam reCAPTCHA v3 avec seuil 0.5
Injection filter_var() pour l'email
Email header injection Pas de \r\n dans les champs utilisateur

Structure de la Réponse JSON

Succès :

{
  "success": true,
  "message": "Votre message a bien été envoyé !"
}

Erreur :

{
  "success": false,
  "error": "Message d'erreur explicite"
}

Testing

  • [] Les données sont validées côté serveur (validateContactData)
  • [] Le token CSRF est vérifié (verifyCsrfToken)
  • [] Le score reCAPTCHA est vérifié (verifyRecaptcha)
  • [] Les données sont nettoyées (htmlspecialchars, filter_var)
  • [] L'email est envoyé avec tous les champs (sendContactEmail)
  • [] La réponse JSON est correcte (succès)
  • [] La réponse JSON est correcte (erreur avec message)
  • [] Les erreurs sont loggées (error_log)

Dev Agent Record

Agent Model Used

Claude Opus 4.5 (claude-opus-4-5-20251101)

File List

File Action Description
api/contact.php Created Endpoint de traitement du formulaire
includes/functions.php Modified Ajout verifyRecaptcha, validateContactData, sendContactEmail
includes/config.php Modified Ajout RECAPTCHA_THRESHOLD

Completion Notes

  • Endpoint api/contact.php avec gestion JSON complète
  • verifyRecaptcha() : appel API Google avec dégradation gracieuse (0.3 si échec)
  • validateContactData() : validation complète (required, email, longueurs min/max, catégorie)
  • sendContactEmail() : email formaté avec tous les champs, Reply-To, IP, date
  • Sécurité : CSRF, reCAPTCHA, htmlspecialchars, filter_var
  • Réponses JSON standardisées {success, message/error}
  • Logging des erreurs via error_log()

Debug Log References

  • Correction syntaxe heredoc (EMAIL: interprété comme label)

Change Log

Date Version Description Author
2026-01-22 0.1 Création initiale Sarah (PO)
2026-01-24 1.0 Implémentation complète James (Dev)