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:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
316
install.sh
Normal 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
71
memoire.md
Normal 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
8
processor/composer.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"require": {
|
||||
"zbateson/mail-mime-parser": "^2.4"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true
|
||||
}
|
||||
}
|
||||
95
processor/process_mail.php
Normal file
95
processor/process_mail.php
Normal 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
126
scripts/apply_config.sh
Normal 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
70
scripts/create_account.sh
Normal 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
63
scripts/delete_account.sh
Normal 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
63
sql/schema.sql
Normal 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
198
web/admin/accounts.php
Normal 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
96
web/admin/domain.php
Normal 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
116
web/admin/index.php
Normal 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
32
web/assets/css/style.css
Normal 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
7
web/assets/js/app.js
Normal 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
39
web/includes/auth.php
Normal 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
31
web/includes/db.php
Normal 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
65
web/includes/layout.php
Normal 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
122
web/index.php
Normal 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
52
web/login.php
Normal 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
4
web/logout.php
Normal 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
33
web/mail/download.php
Normal 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']);
|
||||
Reference in New Issue
Block a user