328 lines
10 KiB
Markdown
328 lines
10 KiB
Markdown
# Story 5.5: Traitement PHP et Envoi d'Email
|
||
|
||
## Status
|
||
|
||
review
|
||
|
||
## 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
|
||
|
||
- [x] **Task 1 : Créer l'endpoint api/contact.php** (AC: 7, 8)
|
||
- [x] Créer le fichier api/contact.php
|
||
- [x] Configurer les headers JSON (Content-Type, X-Content-Type-Options)
|
||
- [x] Gérer uniquement les requêtes POST (405 sinon)
|
||
|
||
- [x] **Task 2 : Valider le token CSRF** (AC: 4)
|
||
- [x] Récupérer le token de la requête JSON
|
||
- [x] Utiliser verifyCsrfToken() existante
|
||
- [x] Exception si invalide
|
||
|
||
- [x] **Task 3 : Vérifier reCAPTCHA** (AC: 2)
|
||
- [x] Créer verifyRecaptcha() dans functions.php
|
||
- [x] Appeler l'API Google siteverify
|
||
- [x] Rejeter si score < RECAPTCHA_THRESHOLD (0.5)
|
||
|
||
- [x] **Task 4 : Valider les données** (AC: 1, 3)
|
||
- [x] Créer validateContactData() dans functions.php
|
||
- [x] Valider required, format email, longueurs min/max
|
||
- [x] Nettoyer avec htmlspecialchars et trim
|
||
- [x] Exception avec messages détaillés
|
||
|
||
- [x] **Task 5 : Envoyer l'email** (AC: 5, 6)
|
||
- [x] Créer sendContactEmail() dans functions.php
|
||
- [x] Corps formaté avec tous les champs + IP + date
|
||
- [x] Headers avec Reply-To vers l'expéditeur
|
||
|
||
- [x] **Task 6 : Retourner la réponse** (AC: 7, 8)
|
||
- [x] JSON {"success": true, "message": "..."} si OK
|
||
- [x] JSON {"success": false, "error": "..."} si erreur
|
||
|
||
## Dev Notes
|
||
|
||
### Endpoint api/contact.php
|
||
|
||
```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)
|
||
|
||
```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
|
||
|
||
```php
|
||
/**
|
||
* 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 :**
|
||
```json
|
||
{
|
||
"success": true,
|
||
"message": "Votre message a bien été envoyé !"
|
||
}
|
||
```
|
||
|
||
**Erreur :**
|
||
```json
|
||
{
|
||
"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
|
||
GPT-5 Codex
|
||
|
||
### Implementation Plan
|
||
- Implémenter les tâches 1 à 6 dans l’ordre avec tests à chaque étape.
|
||
- Ajouter endpoint API et fonctions de validation/envoi.
|
||
|
||
### 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 + CONTACT_EMAIL |
|
||
| `.env` | Modified | Ajout CONTACT_EMAIL |
|
||
| `tests/contact-api.test.php` | Added | Tests endpoint contact |
|
||
| `tests/run.ps1` | Modified | Ajout du test contact-api |
|
||
|
||
### Completion Notes
|
||
- Endpoint api/contact.php avec gestion JSON complète
|
||
- verifyRecaptcha() : appel API Google + seuil 0.5 + dégradation
|
||
- validateContactData() : validation/normalisation complète
|
||
- sendContactEmail() : email formaté avec Reply-To, IP, date
|
||
- Sécurité : CSRF, reCAPTCHA, htmlspecialchars, filter_var
|
||
- Tests : `powershell -ExecutionPolicy Bypass -File tests/run.ps1`
|
||
|
||
### 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) |
|
||
| 2026-02-04 | 1.1 | Endpoint contact + validation serveur | Amelia (Dev) |
|