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