init: projet complet copymail pour Raspberry Pi

Stack: Nginx + PHP 8.2 + MariaDB + Postfix + Dovecot + Samba
- install.sh: script installation automatisé pour RPi OS Bookworm
- Interface web: visionneuse mails + admin (comptes, domaine, dashboard)
- Chaque compte mail génère automatiquement un share Samba
- Processeur cron: extraction pièces jointes via zbateson/mail-mime-parser
- Zéro injection SQL (PDO + prepared statements)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jules
2026-05-04 09:36:35 +02:00
commit 0be1b9119a
21 changed files with 1627 additions and 0 deletions

198
web/admin/accounts.php Normal file
View File

@@ -0,0 +1,198 @@
<?php
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/layout.php';
auth_check();
$error = '';
// Création d'un compte
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
if ($_POST['action'] === 'create') {
$username = preg_replace('/[^a-z0-9._-]/', '', strtolower(trim($_POST['username'] ?? '')));
$password = trim($_POST['password'] ?? '');
$display = trim($_POST['display_name'] ?? '');
$domain = cfg('domain');
if (strlen($username) < 2) {
$error = 'Nom d\'utilisateur invalide (min 2 caractères, a-z0-9._-)';
} elseif (strlen($password) < 6) {
$error = 'Mot de passe trop court (min 6 caractères).';
} else {
$email = "{$username}@{$domain}";
$smbPath = "/srv/copymail/{$username}";
$st = db()->prepare('SELECT id FROM accounts WHERE username = ? OR email = ?');
$st->execute([$username, $email]);
if ($st->fetch()) {
$error = "Le compte « {$username} » existe déjà.";
} else {
db()->prepare(
'INSERT INTO accounts (username, email, display_name, smb_path) VALUES (?, ?, ?, ?)'
)->execute([$username, $email, $display, $smbPath]);
// Crée le dossier, l'utilisateur Dovecot et le share Samba
$cmd = sprintf(
'/opt/copymail/scripts/create_account.sh %s %s %s 2>&1',
escapeshellarg($username),
escapeshellarg($password),
escapeshellarg($domain)
);
shell_exec($cmd);
flash('success', "Compte <strong>{$email}</strong> créé. Share SMB : <code>\\\\serveur\\{$username}</code>");
header('Location: /admin/accounts.php');
exit;
}
}
}
if ($_POST['action'] === 'delete') {
$id = (int)$_POST['account_id'];
$st = db()->prepare('SELECT username FROM accounts WHERE id = ?');
$st->execute([$id]);
$row = $st->fetch();
if ($row) {
$cmd = sprintf('/opt/copymail/scripts/delete_account.sh %s 2>&1', escapeshellarg($row['username']));
shell_exec($cmd);
db()->prepare('DELETE FROM accounts WHERE id = ?')->execute([$id]);
flash('success', "Compte « {$row['username']} » supprimé.");
}
header('Location: /admin/accounts.php');
exit;
}
if ($_POST['action'] === 'toggle') {
$id = (int)$_POST['account_id'];
db()->prepare('UPDATE accounts SET active = 1 - active WHERE id = ?')->execute([$id]);
header('Location: /admin/accounts.php');
exit;
}
}
$accounts = db()->query(
'SELECT a.*, COUNT(sf.id) as mail_count FROM accounts a
LEFT JOIN source_file sf ON sf.account_id = a.id
GROUP BY a.id ORDER BY a.created_at DESC'
)->fetchAll();
$domain = cfg('domain');
html_head('Comptes');
html_navbar('accounts');
?>
<div class="container-fluid">
<?php flash_render(); ?>
<div class="row g-4">
<!-- Formulaire création -->
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-header fw-semibold"><i class="bi bi-person-plus-fill me-2"></i>Nouveau compte</div>
<div class="card-body">
<?php if ($error): ?>
<div class="alert alert-danger py-2 small"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post">
<input type="hidden" name="action" value="create">
<div class="mb-3">
<label class="form-label">Nom d'utilisateur</label>
<div class="input-group">
<input type="text" name="username" class="form-control" placeholder="copycaisse" pattern="[a-zA-Z0-9._-]+" required>
<span class="input-group-text text-muted small">@<?= htmlspecialchars($domain) ?></span>
</div>
<div class="form-text">Lettres, chiffres, . _ -</div>
</div>
<div class="mb-3">
<label class="form-label">Mot de passe IMAP/SMB</label>
<input type="password" name="password" class="form-control" required minlength="6">
</div>
<div class="mb-3">
<label class="form-label">Nom affiché <span class="text-muted">(optionnel)</span></label>
<input type="text" name="display_name" class="form-control" placeholder="Copy Caisse">
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-plus-circle me-1"></i>Créer le compte
</button>
</form>
<hr class="my-3">
<div class="small text-muted">
<i class="bi bi-info-circle me-1"></i>La création génère automatiquement :
<ul class="mt-1 mb-0">
<li>Boîte mail <code>username@<?= htmlspecialchars($domain) ?></code></li>
<li>Dossier <code>/srv/copymail/username/</code></li>
<li>Share SMB <code>\\serveur\username</code></li>
</ul>
</div>
</div>
</div>
</div>
<!-- Liste des comptes -->
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-people-fill me-2"></i>Comptes existants</span>
<span class="badge bg-secondary"><?= count($accounts) ?></span>
</div>
<?php if (empty($accounts)): ?>
<div class="card-body text-muted">Aucun compte créé.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Compte</th>
<th>Email</th>
<th>Share SMB</th>
<th class="text-center">Mails</th>
<th class="text-center">Statut</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($accounts as $acc): ?>
<tr>
<td>
<strong><?= htmlspecialchars($acc['username']) ?></strong>
<?php if ($acc['display_name']): ?>
<br><small class="text-muted"><?= htmlspecialchars($acc['display_name']) ?></small>
<?php endif; ?>
</td>
<td class="small"><?= htmlspecialchars($acc['email']) ?></td>
<td class="small font-monospace">\\serveur\<?= htmlspecialchars($acc['username']) ?></td>
<td class="text-center">
<span class="badge bg-light text-dark"><?= $acc['mail_count'] ?></span>
</td>
<td class="text-center">
<form method="post" class="d-inline">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="account_id" value="<?= $acc['id'] ?>">
<button class="badge border-0 <?= $acc['active'] ? 'bg-success' : 'bg-secondary' ?>" title="Cliquer pour basculer">
<?= $acc['active'] ? 'actif' : 'inactif' ?>
</button>
</form>
</td>
<td class="text-center">
<form method="post" onsubmit="return confirm('Supprimer le compte <?= htmlspecialchars($acc['username']) ?> et tous ses fichiers ?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="account_id" value="<?= $acc['id'] ?>">
<button class="btn btn-sm btn-outline-danger" title="Supprimer">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php html_foot(); ?>

96
web/admin/domain.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/layout.php';
auth_check();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$fields = ['domain', 'hostname', 'smtp_port', 'submission_port', 'imap_port', 'imaps_port', 'smb_workgroup'];
foreach ($fields as $f) {
$val = trim($_POST[$f] ?? '');
if ($val !== '') cfg_set($f, $val);
}
// Régénère les fichiers de config et recharge les services
$result = shell_exec('/opt/copymail/scripts/apply_config.sh 2>&1');
flash('success', 'Configuration sauvegardée et services rechargés.');
header('Location: /admin/domain.php');
exit;
}
$fields = ['domain', 'hostname', 'smtp_port', 'submission_port', 'imap_port', 'imaps_port', 'smb_workgroup'];
$vals = [];
foreach ($fields as $f) $vals[$f] = cfg($f);
html_head('Configuration');
html_navbar('domain');
?>
<div class="container" style="max-width:700px">
<?php flash_render(); ?>
<h5 class="mb-4"><i class="bi bi-gear-fill me-2"></i>Configuration du serveur</h5>
<form method="post">
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-globe me-2"></i>Réseau & Domaine</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Nom de domaine mail</label>
<input type="text" name="domain" class="form-control" value="<?= htmlspecialchars($vals['domain']) ?>" placeholder="mail.mondomaine.fr" required>
<div class="form-text">Ex: <code>mail.mondomaine.fr</code></div>
</div>
<div class="col-md-6">
<label class="form-label">Hostname RPi</label>
<input type="text" name="hostname" class="form-control" value="<?= htmlspecialchars($vals['hostname']) ?>" placeholder="raspberrypi">
</div>
</div>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-envelope-at me-2"></i>Ports mail</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">SMTP (réception)</label>
<input type="number" name="smtp_port" class="form-control" value="<?= htmlspecialchars($vals['smtp_port']) ?>" min="1" max="65535">
<div class="form-text">Défaut: 25</div>
</div>
<div class="col-md-3">
<label class="form-label">Submission</label>
<input type="number" name="submission_port" class="form-control" value="<?= htmlspecialchars($vals['submission_port']) ?>" min="1" max="65535">
<div class="form-text">Défaut: 587</div>
</div>
<div class="col-md-3">
<label class="form-label">IMAP</label>
<input type="number" name="imap_port" class="form-control" value="<?= htmlspecialchars($vals['imap_port']) ?>" min="1" max="65535">
<div class="form-text">Défaut: 143</div>
</div>
<div class="col-md-3">
<label class="form-label">IMAPS</label>
<input type="number" name="imaps_port" class="form-control" value="<?= htmlspecialchars($vals['imaps_port']) ?>" min="1" max="65535">
<div class="form-text">Défaut: 993</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-folder-symlink me-2"></i>Samba (SMB)</div>
<div class="card-body">
<div class="col-md-4">
<label class="form-label">Workgroup</label>
<input type="text" name="smb_workgroup" class="form-control" value="<?= htmlspecialchars($vals['smb_workgroup']) ?>" placeholder="WORKGROUP">
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy-fill me-1"></i>Sauvegarder & Appliquer
</button>
<a href="/admin/index.php" class="btn btn-outline-secondary">Annuler</a>
</div>
</form>
</div>
<?php html_foot(); ?>

116
web/admin/index.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/layout.php';
auth_check();
$services = [
'postfix' => ['Postfix (SMTP)', 'bi-envelope-at'],
'dovecot' => ['Dovecot (IMAP)', 'bi-inbox'],
'smbd' => ['Samba (SMB)', 'bi-folder-symlink'],
'nginx' => ['Nginx (Web)', 'bi-globe'],
'mariadb' => ['MariaDB', 'bi-database'],
];
$statuses = [];
foreach ($services as $svc => [$label, $icon]) {
$out = shell_exec("systemctl is-active " . escapeshellarg($svc) . " 2>/dev/null");
$statuses[$svc] = ['label' => $label, 'icon' => $icon, 'active' => trim($out) === 'active'];
}
$stats = [
'accounts' => (int)db()->query('SELECT COUNT(*) FROM accounts WHERE active=1')->fetchColumn(),
'emails' => (int)db()->query('SELECT COUNT(*) FROM source_file')->fetchColumn(),
'files' => (int)db()->query('SELECT COUNT(*) FROM files')->fetchColumn(),
];
$domain = cfg('domain');
$last5 = db()->query(
'SELECT sf.title, sf.sender, sf.date_processing, a.username
FROM source_file sf LEFT JOIN accounts a ON sf.account_id = a.id
ORDER BY sf.date_processing DESC LIMIT 5'
)->fetchAll();
html_head('Tableau de bord');
html_navbar('status');
?>
<div class="container-fluid">
<h5 class="mb-4"><i class="bi bi-activity me-2"></i>Tableau de bord</h5>
<!-- Stats -->
<div class="row g-3 mb-4">
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center">
<div class="card-body">
<div class="display-6 fw-bold text-primary"><?= $stats['accounts'] ?></div>
<div class="text-muted small">Comptes actifs</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center">
<div class="card-body">
<div class="display-6 fw-bold text-success"><?= $stats['emails'] ?></div>
<div class="text-muted small">Emails traités</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="card border-0 shadow-sm text-center">
<div class="card-body">
<div class="display-6 fw-bold text-info"><?= $stats['files'] ?></div>
<div class="text-muted small">Fichiers extraits</div>
</div>
</div>
</div>
</div>
<!-- Services -->
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-server me-2"></i>État des services</span>
<small class="text-muted">Domaine : <strong><?= htmlspecialchars($domain) ?></strong></small>
</div>
<div class="card-body">
<div class="row g-2">
<?php foreach ($statuses as $svc => $s): ?>
<div class="col-md-4">
<div class="d-flex align-items-center p-2 rounded border <?= $s['active'] ? 'border-success bg-success bg-opacity-10' : 'border-danger bg-danger bg-opacity-10' ?>">
<i class="bi <?= $s['icon'] ?> fs-4 me-3 <?= $s['active'] ? 'text-success' : 'text-danger' ?>"></i>
<div>
<div class="fw-semibold"><?= $s['label'] ?></div>
<span class="badge <?= $s['active'] ? 'bg-success' : 'bg-danger' ?>"><?= $s['active'] ? 'actif' : 'arrêté' ?></span>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- Derniers emails -->
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-clock-history me-2"></i>Derniers courriers reçus</div>
<?php if (empty($last5)): ?>
<div class="card-body text-muted">Aucun courrier traité.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light"><tr><th>Date</th><th>Compte</th><th>Sujet</th><th>Expéditeur</th></tr></thead>
<tbody>
<?php foreach ($last5 as $r): ?>
<tr>
<td class="small text-muted"><?= date('d/m H:i', strtotime($r['date_processing'])) ?></td>
<td><span class="badge bg-primary"><?= htmlspecialchars($r['username'] ?? '?') ?></span></td>
<td><?= htmlspecialchars($r['title'] ?? '—') ?></td>
<td class="small text-muted"><?= htmlspecialchars($r['sender'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?php html_foot(); ?>