From 0be1b9119aa56c478588d8c9d16d1fc995e89377 Mon Sep 17 00:00:00 2001 From: Jules Date: Mon, 4 May 2026 09:36:35 +0200 Subject: [PATCH] init: projet complet copymail pour Raspberry Pi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 20 +++ install.sh | 316 +++++++++++++++++++++++++++++++++++++ memoire.md | 71 +++++++++ processor/composer.json | 8 + processor/process_mail.php | 95 +++++++++++ scripts/apply_config.sh | 126 +++++++++++++++ scripts/create_account.sh | 70 ++++++++ scripts/delete_account.sh | 63 ++++++++ sql/schema.sql | 63 ++++++++ web/admin/accounts.php | 198 +++++++++++++++++++++++ web/admin/domain.php | 96 +++++++++++ web/admin/index.php | 116 ++++++++++++++ web/assets/css/style.css | 32 ++++ web/assets/js/app.js | 7 + web/includes/auth.php | 39 +++++ web/includes/db.php | 31 ++++ web/includes/layout.php | 65 ++++++++ web/index.php | 122 ++++++++++++++ web/login.php | 52 ++++++ web/logout.php | 4 + web/mail/download.php | 33 ++++ 21 files changed, 1627 insertions(+) create mode 100644 .gitignore create mode 100644 install.sh create mode 100644 memoire.md create mode 100644 processor/composer.json create mode 100644 processor/process_mail.php create mode 100644 scripts/apply_config.sh create mode 100644 scripts/create_account.sh create mode 100644 scripts/delete_account.sh create mode 100644 sql/schema.sql create mode 100644 web/admin/accounts.php create mode 100644 web/admin/domain.php create mode 100644 web/admin/index.php create mode 100644 web/assets/css/style.css create mode 100644 web/assets/js/app.js create mode 100644 web/includes/auth.php create mode 100644 web/includes/db.php create mode 100644 web/includes/layout.php create mode 100644 web/index.php create mode 100644 web/login.php create mode 100644 web/logout.php create mode 100644 web/mail/download.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05e31e5 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..b79db95 --- /dev/null +++ b/install.sh @@ -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 + '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//" +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}" diff --git a/memoire.md b/memoire.md new file mode 100644 index 0000000..a00022c --- /dev/null +++ b/memoire.md @@ -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///Maildir/` +- Fichiers extraits dans `/srv/copymail//` +- 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 diff --git a/processor/composer.json b/processor/composer.json new file mode 100644 index 0000000..a347656 --- /dev/null +++ b/processor/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "zbateson/mail-mime-parser": "^2.4" + }, + "config": { + "optimize-autoloader": true + } +} diff --git a/processor/process_mail.php b/processor/process_mail.php new file mode 100644 index 0000000..cbcf087 --- /dev/null +++ b/processor/process_mail.php @@ -0,0 +1,95 @@ +#!/usr/bin/env php +/ + */ + +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"); + } +} diff --git a/scripts/apply_config.sh b/scripts/apply_config.sh new file mode 100644 index 0000000..5145b3e --- /dev/null +++ b/scripts/apply_config.sh @@ -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}" diff --git a/scripts/create_account.sh b/scripts/create_account.sh new file mode 100644 index 0000000..98c5384 --- /dev/null +++ b/scripts/create_account.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# create_account.sh +# 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 " >&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éé." diff --git a/scripts/delete_account.sh b/scripts/delete_account.sh new file mode 100644 index 0000000..b1687ce --- /dev/null +++ b/scripts/delete_account.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# delete_account.sh + +set -e + +USERNAME="$1" +if [[ -z "$USERNAME" ]]; then + echo "Usage: $0 " >&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 - </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é." diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..9486b53 --- /dev/null +++ b/sql/schema.sql @@ -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; diff --git a/web/admin/accounts.php b/web/admin/accounts.php new file mode 100644 index 0000000..58369b2 --- /dev/null +++ b/web/admin/accounts.php @@ -0,0 +1,198 @@ +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 {$email} créé. Share SMB : \\\\serveur\\{$username}"); + 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'); +?> +
+ + +
+ + +
+
+
Nouveau compte
+
+ +
+ +
+ +
+ +
+ + @ +
+
Lettres, chiffres, . _ -
+
+
+ + +
+
+ + +
+ +
+
+
+ La création génère automatiquement : +
    +
  • Boîte mail username@
  • +
  • Dossier /srv/copymail/username/
  • +
  • Share SMB \\serveur\username
  • +
+
+
+
+
+ + +
+
+
+ Comptes existants + +
+ +
Aucun compte créé.
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
CompteEmailShare SMBMailsStatutActions
+ + +
+ +
\\serveur\ + + +
+ + + +
+
+
+ + + +
+
+
+ +
+
+
+
+ diff --git a/web/admin/domain.php b/web/admin/domain.php new file mode 100644 index 0000000..6fedc5c --- /dev/null +++ b/web/admin/domain.php @@ -0,0 +1,96 @@ +&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'); +?> +
+ +
Configuration du serveur
+ +
+
+
Réseau & Domaine
+
+
+
+ + +
Ex: mail.mondomaine.fr
+
+
+ + +
+
+
+
+ +
+
Ports mail
+
+
+
+ + +
Défaut: 25
+
+
+ + +
Défaut: 587
+
+
+ + +
Défaut: 143
+
+
+ + +
Défaut: 993
+
+
+
+
+ +
+
Samba (SMB)
+
+
+ + +
+
+
+ +
+ + Annuler +
+
+
+ diff --git a/web/admin/index.php b/web/admin/index.php new file mode 100644 index 0000000..96887bd --- /dev/null +++ b/web/admin/index.php @@ -0,0 +1,116 @@ + ['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'); +?> +
+
Tableau de bord
+ + +
+
+
+
+
+
Comptes actifs
+
+
+
+
+
+
+
+
Emails traités
+
+
+
+
+
+
+
+
Fichiers extraits
+
+
+
+
+ + +
+
+ État des services + Domaine : +
+
+
+ $s): ?> +
+
+ +
+
+ +
+
+
+ +
+
+
+ + +
+
Derniers courriers reçus
+ +
Aucun courrier traité.
+ +
+ + + + + + + + + + + + +
DateCompteSujetExpéditeur
+
+ +
+
+ diff --git a/web/assets/css/style.css b/web/assets/css/style.css new file mode 100644 index 0000000..77d87ef --- /dev/null +++ b/web/assets/css/style.css @@ -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; + } +} diff --git a/web/assets/js/app.js b/web/assets/js/app.js new file mode 100644 index 0000000..147377d --- /dev/null +++ b/web/assets/js/app.js @@ -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); +}); diff --git a/web/includes/auth.php b/web/includes/auth.php new file mode 100644 index 0000000..4420795 --- /dev/null +++ b/web/includes/auth.php @@ -0,0 +1,39 @@ +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'] ?? ''; +} diff --git a/web/includes/db.php b/web/includes/db.php new file mode 100644 index 0000000..26af734 --- /dev/null +++ b/web/includes/db.php @@ -0,0 +1,31 @@ + 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]); +} diff --git a/web/includes/layout.php b/web/includes/layout.php new file mode 100644 index 0000000..5e5efc3 --- /dev/null +++ b/web/includes/layout.php @@ -0,0 +1,65 @@ + + + + + + {$title} — CopyMail + + + + + +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 ''; +} + +function html_foot(): void { + echo << + + + +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 "
"; + echo "{$f['msg']}"; + echo '
'; + } +} diff --git a/web/index.php b/web/index.php new file mode 100644 index 0000000..caadb48 --- /dev/null +++ b/web/index.php @@ -0,0 +1,122 @@ +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'); +?> +
+ +
+
Courriers traités
+ entrée(s) +
+ + +
+ Aucun courrier traité pour l'instant. +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
DateCompteSujetExpéditeurPièces jointesAction
+ + + + + + — + + + + + + + + + + + + + + + + + Aucune pièce jointe + + +
+ + +
+
+
+
+ +
+ + + diff --git a/web/login.php b/web/login.php new file mode 100644 index 0000000..b9de141 --- /dev/null +++ b/web/login.php @@ -0,0 +1,52 @@ + +
+
+
+
+ +

CopyMail

+
+ +
+ +
+
+ + +
+
+ + +
+ +
+
+
+
+ diff --git a/web/logout.php b/web/logout.php new file mode 100644 index 0000000..f3d555c --- /dev/null +++ b/web/logout.php @@ -0,0 +1,4 @@ +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']);