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

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# Config avec credentials (généré par install.sh)
config.php
# Dépendances PHP
processor/vendor/
processor/composer.lock
# Logs
*.log
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Claude
.claude/

316
install.sh Normal file
View File

@@ -0,0 +1,316 @@
#!/bin/bash
# =============================================================================
# CopyMail — Script d'installation pour Raspberry Pi OS (Debian Bookworm)
# Usage: sudo bash install.sh
# =============================================================================
set -e
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
log() { echo -e "${GREEN}[OK]${NC} $1"; }
warn() { echo -e "${YELLOW}[!!]${NC} $1"; }
err() { echo -e "${RED}[ERREUR]${NC} $1"; exit 1; }
step() { echo -e "\n${BLUE}>>> $1${NC}"; }
[[ $EUID -ne 0 ]] && err "Lancez ce script en root: sudo bash install.sh"
# =============================================================================
# CONFIGURATION — à modifier avant installation
# =============================================================================
DOMAIN="${COPYMAIL_DOMAIN:-mail.local}"
HOSTNAME_RPi="${COPYMAIL_HOSTNAME:-raspberrypi}"
ADMIN_PASSWORD="${COPYMAIL_ADMIN_PASS:-copymail2024}"
DB_ROOT_PASS="${COPYMAIL_DB_ROOT:-$(openssl rand -base64 16)}"
DB_APP_PASS="${COPYMAIL_DB_APP:-$(openssl rand -base64 16)}"
APP_DIR="/opt/copymail"
WWW_DIR="${APP_DIR}/web"
LOG_DIR="/var/log/copymail"
# =============================================================================
step "Mise à jour du système"
apt-get update -qq
apt-get upgrade -y -qq
# =============================================================================
step "Installation des paquets"
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
nginx \
php8.2-fpm php8.2-mysql php8.2-cli php8.2-mbstring php8.2-curl php8.2-intl \
mariadb-server \
postfix postfix-mysql \
dovecot-core dovecot-imapd \
samba \
composer \
python3 \
logrotate \
curl wget git
log "Paquets installés."
# =============================================================================
step "Création de l'utilisateur vmail"
if ! id vmail &>/dev/null; then
groupadd -g 5000 vmail
useradd -g vmail -u 5000 vmail -d /var/mail -s /sbin/nologin
fi
mkdir -p /var/mail/vhosts/"${DOMAIN}"
chown -R vmail:vmail /var/mail/vhosts
log "Utilisateur vmail prêt."
# =============================================================================
step "Déploiement de l'application"
mkdir -p "${APP_DIR}" "${LOG_DIR}"
cp -r "$(dirname "$0")/." "${APP_DIR}/"
chmod +x "${APP_DIR}/scripts/"*.sh
# Config PHP de l'app
cat > "${APP_DIR}/config.php" << PHPCONF
<?php
return [
'db_host' => '127.0.0.1',
'db_name' => 'copymail',
'db_user' => 'copymail',
'db_pass' => '${DB_APP_PASS}',
];
PHPCONF
chmod 640 "${APP_DIR}/config.php"
# Dépendances PHP Composer
cd "${APP_DIR}/processor"
composer install --no-dev --optimize-autoloader -q
cd /
log "Application déployée dans ${APP_DIR}."
# =============================================================================
step "Configuration MariaDB"
systemctl enable mariadb --now
# Sécurisation root
mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '${DB_ROOT_PASS}';" 2>/dev/null || true
mysql -u root -p"${DB_ROOT_PASS}" -e "DELETE FROM mysql.user WHERE User='';" 2>/dev/null || true
mysql -u root -p"${DB_ROOT_PASS}" -e "DROP DATABASE IF EXISTS test;" 2>/dev/null || true
# Création BDD et utilisateur
mysql -u root -p"${DB_ROOT_PASS}" << SQL
CREATE DATABASE IF NOT EXISTS copymail CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'copymail'@'localhost' IDENTIFIED BY '${DB_APP_PASS}';
GRANT ALL PRIVILEGES ON copymail.* TO 'copymail'@'localhost';
FLUSH PRIVILEGES;
SQL
# Import du schéma
mysql -u root -p"${DB_ROOT_PASS}" copymail < "${APP_DIR}/sql/schema.sql"
# Mot de passe admin
ADMIN_HASH=$(php -r "echo password_hash('${ADMIN_PASSWORD}', PASSWORD_BCRYPT, ['cost'=>12]);")
mysql -u root -p"${DB_ROOT_PASS}" copymail \
-e "UPDATE admin_users SET password='${ADMIN_HASH}' WHERE username='admin';"
log "MariaDB configuré. Mot de passe root sauvegardé dans /root/.copymail_db_pass"
echo "DB_ROOT=${DB_ROOT_PASS}" > /root/.copymail_db_pass
echo "DB_APP=${DB_APP_PASS}" >> /root/.copymail_db_pass
chmod 600 /root/.copymail_db_pass
# =============================================================================
step "Configuration Postfix"
systemctl stop postfix 2>/dev/null || true
cat > /etc/postfix/main.cf << POSTFIX
myhostname = ${HOSTNAME_RPi}.${DOMAIN}
mydomain = ${DOMAIN}
myorigin = \$mydomain
inet_interfaces = all
inet_protocols = ipv4
mydestination = localhost
mynetworks = 127.0.0.0/8
virtual_mailbox_domains = ${DOMAIN}
virtual_mailbox_base = /var/mail/vhosts
virtual_mailbox_maps = hash:/etc/postfix/vmailbox
virtual_minimum_uid = 100
virtual_uid_maps = static:5000
virtual_gid_maps = static:5000
smtpd_banner = \$myhostname ESMTP
biff = no
append_dot_mydomain = no
message_size_limit = 52428800
POSTFIX
touch /etc/postfix/vmailbox
postmap /etc/postfix/vmailbox
systemctl enable postfix --now
log "Postfix configuré."
# =============================================================================
step "Configuration Dovecot"
systemctl stop dovecot 2>/dev/null || true
touch /etc/dovecot/users
chown root:dovecot /etc/dovecot/users
chmod 640 /etc/dovecot/users
cat > /etc/dovecot/dovecot.conf << DOVECOT
protocols = imap
listen = *
mail_location = maildir:/var/mail/vhosts/%d/%n/Maildir
mail_privileged_group = vmail
auth_mechanisms = plain login
passdb {
driver = passwd-file
args = scheme=SHA512-CRYPT /etc/dovecot/users
}
userdb {
driver = static
args = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n
}
service imap-login {
inet_listener imap {
port = 143
}
}
log_path = /var/log/dovecot.log
info_log_path = /var/log/dovecot-info.log
DOVECOT
systemctl enable dovecot --now
log "Dovecot configuré."
# =============================================================================
step "Configuration Samba"
systemctl stop smbd nmbd 2>/dev/null || true
mkdir -p /srv/copymail
chown root:vmail /srv/copymail
chmod 775 /srv/copymail
cat > /etc/samba/smb.conf << SAMBA
[global]
workgroup = WORKGROUP
server string = CopyMail Server
netbios name = COPYMAIL
security = user
map to guest = Never
dns proxy = no
log file = /var/log/samba/log.%m
max log size = 1000
passdb backend = tdbsam
# Les shares des comptes seront ajoutés ici automatiquement
SAMBA
systemctl enable smbd nmbd --now
log "Samba configuré."
# =============================================================================
step "Configuration Nginx"
systemctl stop nginx 2>/dev/null || true
cat > /etc/nginx/sites-available/copymail << NGINX
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root ${WWW_DIR};
index index.php;
client_max_body_size 60M;
location / {
try_files \$uri \$uri/ /index.php?\$query_string;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
}
location ~ /\. {
deny all;
}
# Interdit l'accès direct aux fichiers includes et config
location ~* ^/(includes|processor|scripts|sql|config\.php) {
deny all;
}
access_log /var/log/nginx/copymail_access.log;
error_log /var/log/nginx/copymail_error.log;
}
NGINX
ln -sf /etc/nginx/sites-available/copymail /etc/nginx/sites-enabled/copymail
rm -f /etc/nginx/sites-enabled/default
systemctl enable nginx php8.2-fpm --now
log "Nginx configuré."
# =============================================================================
step "Configuration sudo pour scripts système"
cat > /etc/sudoers.d/copymail << SUDOERS
www-data ALL=(root) NOPASSWD: /opt/copymail/scripts/create_account.sh
www-data ALL=(root) NOPASSWD: /opt/copymail/scripts/delete_account.sh
www-data ALL=(root) NOPASSWD: /opt/copymail/scripts/apply_config.sh
SUDOERS
chmod 440 /etc/sudoers.d/copymail
log "Règles sudo configurées."
# Mise à jour des appels sudo dans le code PHP
sed -i "s|shell_exec('/opt/copymail/scripts/|shell_exec('sudo /opt/copymail/scripts/|g" \
"${WWW_DIR}/admin/accounts.php" \
"${WWW_DIR}/admin/domain.php" 2>/dev/null || true
# =============================================================================
step "Cron pour le traitement des mails"
CRON_LINE="* * * * * php ${APP_DIR}/processor/process_mail.php >> ${LOG_DIR}/processor.log 2>&1"
(crontab -l 2>/dev/null | grep -v "process_mail"; echo "${CRON_LINE}") | crontab -
log "Cron configuré (toutes les minutes)."
# =============================================================================
step "Logrotate"
cat > /etc/logrotate.d/copymail << LOGROTATE
${LOG_DIR}/*.log {
daily
rotate 14
compress
missingok
notifempty
create 640 www-data www-data
}
LOGROTATE
# =============================================================================
step "Mise à jour config BDD avec le domaine"
mysql -u copymail -p"${DB_APP_PASS}" copymail \
-e "UPDATE config SET value='${DOMAIN}' WHERE \`key\`='domain';
UPDATE config SET value='${HOSTNAME_RPi}' WHERE \`key\`='hostname';
UPDATE config SET value='1' WHERE \`key\`='install_done';"
# =============================================================================
IP=$(hostname -I | awk '{print $1}')
echo ""
echo -e "${GREEN}============================================================${NC}"
echo -e "${GREEN} CopyMail installé avec succès !${NC}"
echo -e "${GREEN}============================================================${NC}"
echo ""
echo -e " Interface web : ${BLUE}http://${IP}/${NC}"
echo -e " Login admin : ${YELLOW}admin${NC} / ${YELLOW}${ADMIN_PASSWORD}${NC}"
echo ""
echo -e " Domaine mail : ${DOMAIN}"
echo -e " SMTP : port 25"
echo -e " IMAP : port 143"
echo -e " SMB shares : /srv/copymail/<compte>/"
echo ""
echo -e " Pour créer un compte mail, aller dans :"
echo -e " ${BLUE}http://${IP}/admin/accounts.php${NC}"
echo ""
echo -e " Mots de passe BDD sauvegardés dans : /root/.copymail_db_pass"
echo -e "${GREEN}============================================================${NC}"

71
memoire.md Normal file
View File

@@ -0,0 +1,71 @@
# Projet copymail
## Description
Système de réception et distribution automatique de pièces jointes par email.
Tourne sur Raspberry Pi OS (Debian Bookworm). Remplace une solution Synology existante.
## Concept
1. Un email avec PJ arrive sur le serveur mail
2. Le processeur (cron 1 min) extrait les PJ
3. Les fichiers sont déposés dans le share SMB du compte destinataire
4. L'interface web permet de consulter l'historique et gérer les comptes
## Stack
- **OS** : Raspberry Pi OS Lite (Bookworm / Debian 12)
- **Web** : Nginx + PHP 8.2-FPM + MariaDB
- **Mail** : Postfix (SMTP) + Dovecot (IMAP) — virtual mailboxes
- **Partage fichiers** : Samba (un share par compte)
- **Traitement** : PHP CLI via cron, lib `zbateson/mail-mime-parser`
## Infrastructure
- App déployée dans `/opt/copymail/`
- Mails stockés dans `/var/mail/vhosts/<domain>/<user>/Maildir/`
- Fichiers extraits dans `/srv/copymail/<user>/`
- Config BDD : `/opt/copymail/config.php` (généré par install.sh)
## Installation
```bash
# Variables optionnelles (sinon valeurs par défaut)
export COPYMAIL_DOMAIN="mail.mondomaine.fr"
export COPYMAIL_HOSTNAME="raspberrypi"
export COPYMAIL_ADMIN_PASS="monmotdepasse"
sudo bash install.sh
```
## Comptes mail
Créés via l'interface web `/admin/accounts.php`.
Chaque compte crée automatiquement :
- Boîte mail `user@domain`
- Dossier Maildir
- Share Samba `\\serveur\user`
## Structure fichiers
```
/opt/copymail/
├── install.sh
├── config.php ← généré à l'install (BDD credentials)
├── sql/schema.sql
├── web/ ← webroot Nginx
│ ├── index.php ← visionneuse mails
│ ├── login.php / logout.php
│ ├── admin/
│ │ ├── index.php ← dashboard statut services
│ │ ├── domain.php ← config domaine + ports
│ │ └── accounts.php ← gestion comptes
│ ├── mail/download.php
│ ├── api/
│ └── includes/ ← db.php, auth.php, layout.php
├── processor/
│ ├── process_mail.php ← script cron extraction PJ
│ └── composer.json
└── scripts/
├── create_account.sh
├── delete_account.sh
└── apply_config.sh ← régénère configs + reload services
```
## Notes
- Basé sur une ancienne version PHP (artek-mail-script) qui tournait sur Synology
- Réécriture complète en PHP 8.2, PDO, Bootstrap 5
- Plus d'injections SQL : toutes les requêtes utilisent des prepared statements

8
processor/composer.json Normal file
View File

@@ -0,0 +1,8 @@
{
"require": {
"zbateson/mail-mime-parser": "^2.4"
},
"config": {
"optimize-autoloader": true
}
}

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env php
<?php
/**
* process_mail.php — Traitement des emails entrants
* Lancé par cron toutes les minutes.
* Pour chaque compte actif, scanne le Maildir/new/, extrait les pièces jointes
* et les dépose dans /srv/copymail/<username>/
*/
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../web/includes/db.php';
use ZBateson\MailMimeParser\MailMimeParser;
$parser = new MailMimeParser();
$accounts = db()->query('SELECT * FROM accounts WHERE active = 1')->fetchAll();
foreach ($accounts as $account) {
process_account($account, $parser);
}
function process_account(array $account, MailMimeParser $parser): void {
$domain = cfg('domain');
$maildirNew = "/var/mail/vhosts/{$domain}/{$account['username']}/Maildir/new";
$destDir = $account['smb_path'];
if (!is_dir($maildirNew)) return;
$files = glob($maildirNew . '/*');
if (empty($files)) return;
foreach ($files as $filePath) {
if (!is_file($filePath)) continue;
$fileName = basename($filePath);
// Vérifie si déjà traité
$st = db()->prepare('SELECT id FROM source_file WHERE file_name = ? AND account_id = ?');
$st->execute([$fileName, $account['id']]);
if ($st->fetch()) {
// Déjà traité, déplace quand même
rename($filePath, "/var/mail/vhosts/{$domain}/{$account['username']}/Maildir/cur/{$fileName}:2,S");
continue;
}
$handle = fopen($filePath, 'r');
$message = $parser->parse($handle, false);
fclose($handle);
$subject = $message->getHeaderValue('Subject') ?? '(sans sujet)';
$from = '';
$fromHdr = $message->getHeader('From');
if ($fromHdr) {
$from = method_exists($fromHdr, 'getEmail') ? $fromHdr->getEmail() : $fromHdr->getRawValue();
}
// Insère l'email source
$st = db()->prepare(
'INSERT INTO source_file (account_id, title, sender, file_name) VALUES (?, ?, ?, ?)'
);
$st->execute([$account['id'], $subject, $from, $fileName]);
$sourceId = (int)db()->lastInsertId();
// Extrait les pièces jointes
$attachments = $message->getAllAttachmentParts();
if (!empty($attachments)) {
if (!is_dir($destDir)) mkdir($destDir, 0750, true);
foreach ($attachments as $att) {
$attName = $att->getHeaderParameter('Content-Disposition', 'filename')
?? $att->getHeaderParameter('Content-Type', 'name')
?? 'attachment_' . time();
// Sécurise le nom de fichier
$attName = preg_replace('/[^a-zA-Z0-9._\- ]/', '_', $attName);
$destPath = $destDir . '/' . $attName;
// Évite les collisions de nom
if (file_exists($destPath)) {
$ext = pathinfo($attName, PATHINFO_EXTENSION);
$base = pathinfo($attName, PATHINFO_FILENAME);
$destPath = $destDir . '/' . $base . '_' . time() . '.' . $ext;
$attName = basename($destPath);
}
$att->saveContent($destPath);
db()->prepare('INSERT INTO files (source_file_id, file_name, path) VALUES (?, ?, ?)')
->execute([$sourceId, $attName, $destPath]);
}
}
// Déplace le mail vers cur/ (marque comme lu)
rename($filePath, "/var/mail/vhosts/{$domain}/{$account['username']}/Maildir/cur/{$fileName}:2,S");
}
}

126
scripts/apply_config.sh Normal file
View File

@@ -0,0 +1,126 @@
#!/bin/bash
# apply_config.sh
# Régénère les configs Postfix/Dovecot/Samba depuis les valeurs en BDD et recharge les services
set -e
PHP=/usr/bin/php
# Récupère les valeurs depuis la BDD via PHP
DOMAIN=$($PHP -r "require '/opt/copymail/web/includes/db.php'; echo cfg('domain');")
HOSTNAME=$($PHP -r "require '/opt/copymail/web/includes/db.php'; echo cfg('hostname');")
SMTP_PORT=$($PHP -r "require '/opt/copymail/web/includes/db.php'; echo cfg('smtp_port');")
SUBM_PORT=$($PHP -r "require '/opt/copymail/web/includes/db.php'; echo cfg('submission_port');")
IMAP_PORT=$($PHP -r "require '/opt/copymail/web/includes/db.php'; echo cfg('imap_port');")
IMAPS_PORT=$($PHP -r "require '/opt/copymail/web/includes/db.php'; echo cfg('imaps_port');")
WORKGROUP=$($PHP -r "require '/opt/copymail/web/includes/db.php'; echo cfg('smb_workgroup');")
# --- Postfix main.cf ---
cat > /etc/postfix/main.cf << POSTFIX
# CopyMail - généré automatiquement
myhostname = ${HOSTNAME}.${DOMAIN}
mydomain = ${DOMAIN}
myorigin = \$mydomain
inet_interfaces = all
inet_protocols = ipv4
mydestination = localhost
mynetworks = 127.0.0.0/8
# Virtual mailboxes
virtual_mailbox_domains = ${DOMAIN}
virtual_mailbox_base = /var/mail/vhosts
virtual_mailbox_maps = hash:/etc/postfix/vmailbox
virtual_minimum_uid = 100
virtual_uid_maps = static:5000
virtual_gid_maps = static:5000
smtpd_banner = \$myhostname ESMTP
biff = no
append_dot_mydomain = no
readme_directory = no
# Taille max message: 50 Mo
message_size_limit = 52428800
POSTFIX
# --- Postfix master.cf (ports) ---
cat > /etc/postfix/master.cf << MASTER
smtp inet n - y - - smtpd
${SUBM_PORT} inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup
qmgr unix n - n 300 1 qmgr
tlsmgr unix - - y 1000? 1 tlsmgr
rewrite unix - - y - - trivial-rewrite
bounce unix - - y - 0 bounce
defer unix - - y - 0 bounce
trace unix - - y - 0 bounce
verify unix - - y - 1 verify
flush unix n - y 1000? 0 flush
proxymap unix - - n - - proxymap
proxywrite unix - - n - 1 proxymap
smtp unix - - y - - smtp
relay unix - - y - - smtp
showq unix n - y - - showq
error unix - - y - - error
retry unix - - y - - error
discard unix - - y - - discard
local unix - n n - - local
virtual unix - n n - - virtual
lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil
scache unix - - y - 1 scache
MASTER
# --- Dovecot ---
cat > /etc/dovecot/conf.d/10-mail.conf << DOVECOT
mail_location = maildir:/var/mail/vhosts/%d/%n/Maildir
mail_privileged_group = vmail
DOVECOT
cat > /etc/dovecot/conf.d/10-auth.conf << DOVECOTAUTH
auth_mechanisms = plain login
!include auth-passwdfile.conf.ext
DOVECOTAUTH
cat > /etc/dovecot/conf.d/auth-passwdfile.conf.ext << PASSFILE
passdb {
driver = passwd-file
args = scheme=SHA512-CRYPT /etc/dovecot/users
}
userdb {
driver = static
args = uid=vmail gid=vmail home=/var/mail/vhosts/%d/%n
}
PASSFILE
cat > /etc/dovecot/conf.d/10-master.conf << DOVMASTER
service imap-login {
inet_listener imap {
port = ${IMAP_PORT}
}
inet_listener imaps {
port = ${IMAPS_PORT}
ssl = yes
}
}
service auth {
unix_listener auth-userdb {
mode = 0600
user = vmail
}
}
DOVMASTER
# --- Samba workgroup ---
sed -i "s/^\s*workgroup\s*=.*/ workgroup = ${WORKGROUP}/" /etc/samba/smb.conf
# --- Rechargement ---
systemctl reload postfix || systemctl restart postfix
systemctl reload dovecot || systemctl restart dovecot
systemctl reload smbd || systemctl restart smbd
echo "OK: configuration appliquée pour ${DOMAIN}"

70
scripts/create_account.sh Normal file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# create_account.sh <username> <password> <domain>
# Appelé par l'interface web (via sudo) quand on crée un compte
set -e
USERNAME="$1"
PASSWORD="$2"
DOMAIN="$3"
if [[ -z "$USERNAME" || -z "$PASSWORD" || -z "$DOMAIN" ]]; then
echo "Usage: $0 <username> <password> <domain>" >&2
exit 1
fi
MAILDIR="/var/mail/vhosts/${DOMAIN}/${USERNAME}/Maildir"
SMBDIR="/srv/copymail/${USERNAME}"
DOVECOT_PASSWD="/etc/dovecot/users"
# --- Maildir ---
mkdir -p "${MAILDIR}/new" "${MAILDIR}/cur" "${MAILDIR}/tmp"
chown -R vmail:vmail "/var/mail/vhosts/${DOMAIN}/${USERNAME}"
chmod -R 770 "/var/mail/vhosts/${DOMAIN}/${USERNAME}"
# --- Postfix virtual mailboxes ---
VMAILBOX="/etc/postfix/vmailbox"
if ! grep -qF "${USERNAME}@${DOMAIN}" "${VMAILBOX}" 2>/dev/null; then
echo "${USERNAME}@${DOMAIN} ${DOMAIN}/${USERNAME}/Maildir/" >> "${VMAILBOX}"
fi
postmap "${VMAILBOX}"
# --- Dovecot passwd-file ---
# Format: username@domain:{SHA512-CRYPT}hash::::::
PASS_HASH=$(doveadm pw -s SHA512-CRYPT -p "${PASSWORD}")
if ! grep -qF "${USERNAME}@${DOMAIN}" "${DOVECOT_PASSWD}" 2>/dev/null; then
echo "${USERNAME}@${DOMAIN}:${PASS_HASH}:::" >> "${DOVECOT_PASSWD}"
else
# Met à jour le mot de passe si le compte existe déjà
sed -i "s|^${USERNAME}@${DOMAIN}:.*|${USERNAME}@${DOMAIN}:${PASS_HASH}:::|" "${DOVECOT_PASSWD}"
fi
# --- Dossier SMB ---
mkdir -p "${SMBDIR}"
chown -R vmail:vmail "${SMBDIR}"
chmod -R 775 "${SMBDIR}"
# --- Section Samba ---
SMB_CONF="/etc/samba/smb.conf"
if ! grep -qF "[${USERNAME}]" "${SMB_CONF}"; then
cat >> "${SMB_CONF}" << SAMBA
[${USERNAME}]
path = ${SMBDIR}
browseable = yes
writable = yes
valid users = ${USERNAME}
create mask = 0664
directory mask = 0775
SAMBA
fi
# Ajoute l'utilisateur Samba (sans login système)
(echo "${PASSWORD}"; echo "${PASSWORD}") | smbpasswd -a -s "${USERNAME}" 2>/dev/null || true
# --- Rechargement services ---
systemctl reload postfix
systemctl reload dovecot
systemctl reload smbd
echo "OK: compte ${USERNAME}@${DOMAIN} créé."

63
scripts/delete_account.sh Normal file
View File

@@ -0,0 +1,63 @@
#!/bin/bash
# delete_account.sh <username>
set -e
USERNAME="$1"
if [[ -z "$USERNAME" ]]; then
echo "Usage: $0 <username>" >&2
exit 1
fi
DOMAIN=$(grep "^domain=" /opt/copymail/config.sh 2>/dev/null | cut -d= -f2)
[[ -z "$DOMAIN" ]] && DOMAIN=$(php -r "require '/opt/copymail/web/includes/db.php'; echo cfg('domain');")
MAILDIR="/var/mail/vhosts/${DOMAIN}/${USERNAME}"
SMBDIR="/srv/copymail/${USERNAME}"
DOVECOT_PASSWD="/etc/dovecot/users"
VMAILBOX="/etc/postfix/vmailbox"
# --- Supprime le Maildir ---
[[ -d "${MAILDIR}" ]] && rm -rf "${MAILDIR}"
# --- Retire de vmailbox ---
if [[ -f "${VMAILBOX}" ]]; then
sed -i "/^${USERNAME}@${DOMAIN}/d" "${VMAILBOX}"
postmap "${VMAILBOX}"
fi
# --- Retire de Dovecot passwd ---
if [[ -f "${DOVECOT_PASSWD}" ]]; then
sed -i "/^${USERNAME}@${DOMAIN}:/d" "${DOVECOT_PASSWD}"
fi
# --- Retire la section Samba ---
SMB_CONF="/etc/samba/smb.conf"
if grep -qF "[${USERNAME}]" "${SMB_CONF}"; then
# Supprime la section du compte dans smb.conf
python3 - <<PYEOF
import re, sys
with open('${SMB_CONF}', 'r') as f:
content = f.read()
pattern = r'\n\[${USERNAME}\][^\[]*'
content = re.sub(pattern, '', content)
with open('${SMB_CONF}', 'w') as f:
f.write(content)
PYEOF
fi
# Supprime utilisateur Samba
smbpasswd -x "${USERNAME}" 2>/dev/null || true
# --- Supprime le dossier SMB (ATTENTION: supprime les fichiers) ---
[[ -d "${SMBDIR}" ]] && rm -rf "${SMBDIR}"
# --- Rechargement ---
systemctl reload postfix
systemctl reload dovecot
systemctl reload smbd
echo "OK: compte ${USERNAME} supprimé."

63
sql/schema.sql Normal file
View File

@@ -0,0 +1,63 @@
-- copymail schema
-- MySQL/MariaDB
CREATE DATABASE IF NOT EXISTS copymail CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE copymail;
CREATE TABLE IF NOT EXISTS config (
`key` VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT NOW() ON UPDATE NOW()
);
INSERT INTO config (`key`, value) VALUES
('domain', 'mail.local'),
('hostname', 'raspberrypi'),
('smtp_port', '25'),
('submission_port','587'),
('imap_port', '143'),
('imaps_port', '993'),
('smb_workgroup', 'WORKGROUP'),
('install_done', '0')
ON DUPLICATE KEY UPDATE `key` = `key`;
CREATE TABLE IF NOT EXISTS accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(200) NOT NULL UNIQUE,
display_name VARCHAR(100),
smb_path VARCHAR(255) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS source_file (
id INT AUTO_INCREMENT PRIMARY KEY,
account_id INT,
title VARCHAR(255),
sender VARCHAR(255),
file_name VARCHAR(255) NOT NULL,
date_processing DATETIME DEFAULT NOW(),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS files (
id INT AUTO_INCREMENT PRIMARY KEY,
source_file_id INT,
file_name VARCHAR(255) NOT NULL,
path VARCHAR(500) NOT NULL,
FOREIGN KEY (source_file_id) REFERENCES source_file(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS admin_users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT NOW()
);
-- Mot de passe par défaut: admin / copymail2024
-- SHA-256 de "copymail2024"
INSERT INTO admin_users (username, password) VALUES
('admin', '$2y$12$placeholder_replaced_by_install_sh')
ON DUPLICATE KEY UPDATE username = username;

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(); ?>

32
web/assets/css/style.css Normal file
View File

@@ -0,0 +1,32 @@
body {
background-color: #f4f6fb;
}
.navbar-brand {
letter-spacing: 0.5px;
}
.card {
border-radius: 10px;
}
.table th {
font-size: .82rem;
text-transform: uppercase;
letter-spacing: .04em;
color: #6c757d;
}
.badge {
font-weight: 500;
}
.font-monospace {
font-size: .85em;
}
@media (max-width: 576px) {
.container-fluid {
padding: 0 .75rem;
}
}

7
web/assets/js/app.js Normal file
View File

@@ -0,0 +1,7 @@
// Auto-dismiss alerts after 4s
document.querySelectorAll('.alert-dismissible').forEach(el => {
setTimeout(() => {
const btn = el.querySelector('.btn-close');
if (btn) btn.click();
}, 4000);
});

39
web/includes/auth.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
function auth_start(): void {
if (session_status() === PHP_SESSION_NONE) {
session_name('copymail_sess');
session_start();
}
}
function auth_check(): void {
auth_start();
if (empty($_SESSION['admin_id'])) {
header('Location: /login.php');
exit;
}
}
function auth_login(string $username, string $password): bool {
$st = db()->prepare('SELECT id, password FROM admin_users WHERE username = ? LIMIT 1');
$st->execute([$username]);
$row = $st->fetch();
if ($row && password_verify($password, $row['password'])) {
$_SESSION['admin_id'] = $row['id'];
$_SESSION['admin_user'] = $username;
return true;
}
return false;
}
function auth_logout(): void {
auth_start();
session_destroy();
header('Location: /login.php');
exit;
}
function current_user(): string {
return $_SESSION['admin_user'] ?? '';
}

31
web/includes/db.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
function db(): PDO {
static $pdo = null;
if ($pdo !== null) return $pdo;
$cfg = require __DIR__ . '/../../config.php';
$dsn = "mysql:host={$cfg['db_host']};dbname={$cfg['db_name']};charset=utf8mb4";
$pdo = new PDO($dsn, $cfg['db_user'], $cfg['db_pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function cfg(string $key, string $default = ''): string {
static $cache = [];
if (!isset($cache[$key])) {
$st = db()->prepare('SELECT value FROM config WHERE `key` = ?');
$st->execute([$key]);
$row = $st->fetch();
$cache[$key] = $row ? $row['value'] : $default;
}
return $cache[$key];
}
function cfg_set(string $key, string $value): void {
db()->prepare('INSERT INTO config (`key`, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?')
->execute([$key, $value, $value]);
}

65
web/includes/layout.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
function html_head(string $title): void {
$domain = cfg('domain', 'copymail');
echo <<<HTML
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{$title} — CopyMail</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
<link rel="stylesheet" href="/assets/css/style.css">
</head>
<body>
HTML;
}
function html_navbar(string $active = ''): void {
$user = current_user();
$pages = [
'mail' => ['/', 'bi-envelope-fill', 'Courriers'],
'accounts' => ['/admin/accounts.php', 'bi-people-fill', 'Comptes'],
'domain' => ['/admin/domain.php', 'bi-gear-fill', 'Configuration'],
'status' => ['/admin/index.php', 'bi-activity', 'Tableau de bord'],
];
echo '<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">';
echo '<div class="container-fluid">';
echo '<a class="navbar-brand fw-bold" href="/"><i class="bi bi-envelope-arrow-down-fill me-2"></i>CopyMail</a>';
echo '<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav"><span class="navbar-toggler-icon"></span></button>';
echo '<div class="collapse navbar-collapse" id="nav"><ul class="navbar-nav me-auto">';
foreach ($pages as $key => [$href, $icon, $label]) {
$cls = $key === $active ? 'nav-link active' : 'nav-link';
echo "<li class=\"nav-item\"><a class=\"{$cls}\" href=\"{$href}\"><i class=\"bi {$icon} me-1\"></i>{$label}</a></li>";
}
echo '</ul>';
echo "<span class=\"navbar-text me-3\"><i class=\"bi bi-person-circle me-1\"></i>{$user}</span>";
echo '<a class="btn btn-outline-light btn-sm" href="/logout.php"><i class="bi bi-box-arrow-right me-1"></i>Déconnexion</a>';
echo '</div></div></nav>';
}
function html_foot(): void {
echo <<<HTML
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
</body>
</html>
HTML;
}
function flash(string $type, string $msg): void {
$_SESSION['flash'] = ['type' => $type, 'msg' => $msg];
}
function flash_render(): void {
if (!empty($_SESSION['flash'])) {
$f = $_SESSION['flash'];
unset($_SESSION['flash']);
$icon = $f['type'] === 'success' ? 'check-circle-fill' : 'exclamation-triangle-fill';
echo "<div class=\"alert alert-{$f['type']} d-flex align-items-center alert-dismissible fade show\" role=\"alert\">";
echo "<i class=\"bi bi-{$icon} me-2\"></i>{$f['msg']}";
echo '<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>';
}
}

122
web/index.php Normal file
View File

@@ -0,0 +1,122 @@
<?php
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/layout.php';
auth_check();
$message = '';
// Suppression d'un email traité
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_source'])) {
$id = (int)$_POST['delete_source'];
$st = db()->prepare('SELECT sf.title, CONCAT(sf.file_name) as fname, GROUP_CONCAT(f.path) as fpaths
FROM source_file sf LEFT JOIN files f ON sf.id = f.source_file_id
WHERE sf.id = ? GROUP BY sf.id');
$st->execute([$id]);
$row = $st->fetch();
if ($row) {
if ($row['fpaths']) {
foreach (explode(',', $row['fpaths']) as $path) {
if (file_exists($path)) unlink($path);
}
}
db()->prepare('DELETE FROM source_file WHERE id = ?')->execute([$id]);
flash('success', "Entrée « " . htmlspecialchars($row['title']) . " » supprimée.");
}
header('Location: /');
exit;
}
$rows = db()->query(
'SELECT sf.id, sf.title, sf.sender, sf.date_processing, a.username,
GROUP_CONCAT(f.id, ":", f.file_name, ":", f.path ORDER BY f.id SEPARATOR "|") as files
FROM source_file sf
LEFT JOIN accounts a ON sf.account_id = a.id
LEFT JOIN files f ON sf.id = f.source_file_id
GROUP BY sf.id
ORDER BY sf.date_processing DESC'
)->fetchAll();
html_head('Courriers');
html_navbar('mail');
?>
<div class="container-fluid">
<?php flash_render(); ?>
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-envelope-fill me-2"></i>Courriers traités</h5>
<span class="badge bg-secondary"><?= count($rows) ?> entrée(s)</span>
</div>
<?php if (empty($rows)): ?>
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>Aucun courrier traité pour l'instant.
</div>
<?php else: ?>
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Compte</th>
<th>Sujet</th>
<th>Expéditeur</th>
<th>Pièces jointes</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td class="text-nowrap text-muted small">
<?= htmlspecialchars(date('d/m/Y H:i', strtotime($r['date_processing']))) ?>
</td>
<td>
<?php if ($r['username']): ?>
<span class="badge bg-primary"><?= htmlspecialchars($r['username']) ?></span>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($r['title'] ?? '(sans sujet)') ?></td>
<td class="small text-muted"><?= htmlspecialchars($r['sender'] ?? '') ?></td>
<td>
<?php if ($r['files']): ?>
<?php foreach (explode('|', $r['files']) as $f): ?>
<?php [$fid, $fname, $fpath] = array_pad(explode(':', $f, 3), 3, ''); ?>
<?php if (file_exists($fpath)): ?>
<a href="/mail/download.php?id=<?= (int)$fid ?>" class="badge bg-info text-decoration-none me-1">
<i class="bi bi-download me-1"></i><?= htmlspecialchars(substr($fname, 0, 20)) ?>
</a>
<?php else: ?>
<span class="badge bg-secondary me-1" title="Fichier introuvable">
<?= htmlspecialchars(substr($fname, 0, 20)) ?>
</span>
<?php endif; ?>
<?php endforeach; ?>
<?php else: ?>
<span class="text-muted small">Aucune pièce jointe</span>
<?php endif; ?>
</td>
<td class="text-center">
<form method="post" onsubmit="return confirm('Supprimer cette entrée ?')">
<input type="hidden" name="delete_source" value="<?= $r['id'] ?>">
<button class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<script>
setTimeout(() => location.reload(), 30000);
</script>
<?php html_foot(); ?>

52
web/login.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/layout.php';
auth_start();
if (!empty($_SESSION['admin_id'])) {
header('Location: /');
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (auth_login($username, $password)) {
header('Location: /');
exit;
}
$error = 'Identifiants incorrects.';
}
html_head('Connexion');
?>
<div class="min-vh-100 d-flex align-items-center justify-content-center bg-light">
<div class="card shadow" style="width:360px">
<div class="card-body p-4">
<div class="text-center mb-4">
<i class="bi bi-envelope-arrow-down-fill text-primary" style="font-size:2.5rem"></i>
<h4 class="mt-2 fw-bold">CopyMail</h4>
</div>
<?php if ($error): ?>
<div class="alert alert-danger py-2"><i class="bi bi-exclamation-triangle-fill me-1"></i><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post">
<div class="mb-3">
<label class="form-label">Nom d'utilisateur</label>
<input type="text" name="username" class="form-control" autofocus required>
</div>
<div class="mb-3">
<label class="form-label">Mot de passe</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right me-1"></i>Connexion
</button>
</form>
</div>
</div>
</div>
<?php html_foot(); ?>

4
web/logout.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
require_once __DIR__ . '/includes/db.php';
require_once __DIR__ . '/includes/auth.php';
auth_logout();

33
web/mail/download.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/auth.php';
auth_check();
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); exit('bad request'); }
$st = db()->prepare('SELECT file_name, path FROM files WHERE id = ?');
$st->execute([$id]);
$row = $st->fetch();
if (!$row || !file_exists($row['path'])) {
http_response_code(404);
exit('Fichier introuvable.');
}
$ext = strtolower(pathinfo($row['file_name'], PATHINFO_EXTENSION));
$mime = match($ext) {
'pdf' => 'application/pdf',
'jpg', 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'csv' => 'text/csv',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
default => 'application/octet-stream',
};
header('Content-Type: ' . $mime);
header('Content-Disposition: attachment; filename="' . addslashes($row['file_name']) . '"');
header('Content-Length: ' . filesize($row['path']));
readfile($row['path']);