#!/bin/bash
# SecuBox Mail Server Controller
# Unified mail + webmail management for Debian
# Three-fold architecture: Components, Status, Access

VERSION="2.2.0"
CONFIG_FILE="${CONFIG_FILE:-/etc/secubox/mail.toml}"
LIB_DIR="/usr/lib/secubox/mail/lib"
# Default-if-unset : préserve le comportement de production (rien n'exporte
# ces variables en usage réel) tout en laissant les tests bats les surcharger
# via `export DATA_PATH=...` avant de sourcer les fonctions (helpers.bash).
DATA_PATH="${DATA_PATH:-/data/volumes/mail}"
LXC_PATH="${LXC_PATH:-/var/lib/lxc}"

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

log() { echo -e "${GREEN}[MAIL]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }

# Load libraries
[ -f "$LIB_DIR/container.sh" ] && . "$LIB_DIR/container.sh"
[ -f "$LIB_DIR/users.sh" ] && . "$LIB_DIR/users.sh"

# ============================================================================
# Configuration (TOML parsing)
# ============================================================================

config_get() {
    local key="$1"
    local default="$2"
    if [ -f "$CONFIG_FILE" ]; then
        local value=$(grep "^${key} *=" "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2- | tr -d ' "')
        [ -n "$value" ] && echo "$value" || echo "$default"
    else
        echo "$default"
    fi
}

# config_get est un grep plat sur TOUT le fichier : une clé homonyme dans une
# autre section (ex. le `enabled` racine de [mail]) serait lue par erreur.
# config_get_section borne la lecture à la section demandée — nécessaire pour
# [mail.antivirus].enabled, qui partage son nom de clé avec [mail].enabled.
config_get_section() {
    local section="$1" key="$2" default="$3"
    if [ -f "$CONFIG_FILE" ]; then
        local value
        value=$(awk -v sec="[$section]" -v key="$key" '
            $0 == sec { infile=1; next }
            /^\[/ { infile=0 }
            infile && $0 ~ "^" key " *=" { sub(/^[^=]*=/, ""); gsub(/["[:space:]]/, ""); print; exit }
        ' "$CONFIG_FILE" 2>/dev/null)
        [ -n "$value" ] && echo "$value" || echo "$default"
    else
        echo "$default"
    fi
}

DOMAIN=$(config_get "domain" "secubox.local")
HOSTNAME=$(config_get "hostname" "mail")
CONTAINER=$(config_get "container" "mail")
# WEBMAIL_CONTAINER : le conteneur qui HÉBERGE Roundcube. Historiquement fondu
# dans le conteneur mail (single-container), mais la board réelle fait tourner un
# LXC roundcube SÉPARÉ (#1181). On le lit donc du TOML (`webmail_container`),
# défaut = le conteneur mail pour l'ancien modèle fondu.
WEBMAIL_CONTAINER="$(config_get "webmail_container" "$CONTAINER")"
LXC_IP=$(config_get "lxc_ip" "10.100.0.10")
LXC_BRIDGE=$(config_get "lxc_bridge" "br-lxc")
LXC_GATEWAY=$(config_get "lxc_gateway" "10.100.0.1")
# LXC_PATH DOIT venir du TOML (autoritatif). Les libs sourcees plus haut
# (users.sh) posent un LXC_PATH hérité de l'ancien nom « mailserver »
# (/data/lxc/mailserver) — non corrigé par le `:-` de la ligne 13 car non vide.
# Sans cette ligne, les gestes qui écrivent dans le rootfs (maildir-reconcile,
# sieve) visent /data/lxc/mailserver/mail/rootfs au lieu de /data/lxc/mail/rootfs
# et deviennent des no-op silencieux. Le TOML de la board porte lxc_path=/data/lxc.
LXC_PATH=$(config_get "lxc_path" "/var/lib/lxc")
ANTIVIRUS_ENABLED=$(config_get_section "mail.antivirus" "enabled" "false")
WEBMAIL_PORT=80  # Roundcube now on standard HTTP inside the LXC, proxied via host nginx :443

# ============================================================================
# LXC Helpers
# ============================================================================

lxc_running() {
    local name="$1"
    lxc-info -n "$name" 2>/dev/null | grep -q "State:.*RUNNING"
}

lxc_exists() {
    local name="$1"
    [ -d "$LXC_PATH/$name/rootfs" ]
}

lxc_attach() {
    local name="$1"
    shift
    lxc-attach -n "$name" -- "$@"
}

# Désactive le bac à sable systemd d'UN service du conteneur. Dans un LXC sans privilèges, les montages que demandent
# ProtectSystem, PrivateDevices, PrivateTmp, ProtectHome et ReadWritePaths échouent (226/NAMESPACE) : redis-server n'a pas
# démarré depuis la migration Trixie du 2026-10-05, et rspamd attendait 65 s par message (règle REPLIES_SET). dovecot, apache2
# et clamav-daemon portent la même surcharge ; redis-server n'en avait pas.
lxc_service_sans_sandbox() {
    local svc="${1:-}" cont="${2:-$CONTAINER}"
    case "$svc" in
        ""|*[!a-zA-Z0-9@._-]*) error "nom de service invalide : '$svc'"; return 1 ;;
    esac
    lxc_attach "$cont" sh -c "mkdir -p /etc/systemd/system/${svc}.service.d && cat > /etc/systemd/system/${svc}.service.d/10-lxc-sans-sandbox.conf" <<'EOF_SANDBOX'
[Service]
PrivateDevices=false
PrivateTmp=false
PrivateUsers=false
ProtectControlGroups=false
ProtectKernelTunables=false
ProtectKernelModules=false
ProtectSystem=false
ProtectHome=false
RestrictNamespaces=false
ProtectClock=false
ProtectHostname=false
ProtectKernelLogs=false
ProtectProc=default
ProcSubset=all
ReadWritePaths=
ReadWriteDirectories=
ReadOnlyPaths=
InaccessiblePaths=
ExecPaths=
NoExecPaths=
TemporaryFileSystem=
BindPaths=
BindReadOnlyPaths=
EOF_SANDBOX
    lxc_attach "$cont" systemctl daemon-reload
}

# ============================================================================
# THREE-FOLD ARCHITECTURE: Components (What)
# ============================================================================

cmd_components() {
    # Output JSON for API consumption
    local mail_installed=false
    local mail_running=false
    local webmail_installed=false
    local webmail_running=false

    lxc_exists "$CONTAINER" && mail_installed=true
    lxc_running "$CONTAINER" && mail_running=true
    lxc_exists "$WEBMAIL_CONTAINER" && webmail_installed=true
    lxc_running "$WEBMAIL_CONTAINER" && webmail_running=true

    cat <<EOF
{
  "components": [
    {
      "name": "Mail Server",
      "type": "lxc",
      "container": "$CONTAINER",
      "description": "Postfix + Dovecot mail server",
      "installed": $mail_installed,
      "running": $mail_running,
      "ports": [25, 587, 465, 143, 993, 110, 995],
      "ip": "$LXC_IP"
    },
    {
      "name": "Webmail",
      "type": "lxc",
      "container": "$WEBMAIL_CONTAINER",
      "description": "Roundcube webmail interface",
      "installed": $webmail_installed,
      "running": $webmail_running,
      "port": $WEBMAIL_PORT
    },
    {
      "name": "User Database",
      "type": "file",
      "path": "$DATA_PATH/config/users",
      "description": "Dovecot passwd-file users",
      "installed": true
    },
    {
      "name": "SSL Certificates",
      "type": "file",
      "path": "$DATA_PATH/ssl/fullchain.pem",
      "description": "TLS certificates for mail server",
      "installed": $([ -f "$DATA_PATH/ssl/fullchain.pem" ] && echo true || echo false)
    }
  ]
}
EOF
}

# ============================================================================
# THREE-FOLD ARCHITECTURE: Access (How to connect)
# ============================================================================

cmd_access() {
    local fqdn="$HOSTNAME.$DOMAIN"
    local ssl_enabled=false
    [ -f "$DATA_PATH/ssl/fullchain.pem" ] && ssl_enabled=true

    cat <<EOF
{
  "domain": "$DOMAIN",
  "mail_server": "$fqdn",
  "connections": {
    "imap": {
      "host": "$fqdn",
      "port": 993,
      "ssl": true,
      "starttls_port": 143
    },
    "smtp": {
      "host": "$fqdn",
      "port": 587,
      "ssl": false,
      "starttls": true,
      "ssl_port": 465
    },
    "pop3": {
      "host": "$fqdn",
      "port": 995,
      "ssl": true
    }
  },
  "webmail": {
    "url": "https://webmail.$DOMAIN",
    "local_url": "http://localhost:$WEBMAIL_PORT"
  },
  "ssl_enabled": $ssl_enabled,
  "client_configs": {
    "thunderbird": "https://autoconfig.$DOMAIN/mail/config-v1.1.xml",
    "outlook": "https://autodiscover.$DOMAIN/autodiscover/autodiscover.xml",
    "apple": "https://$fqdn/$DOMAIN.mobileconfig"
  }
}
EOF
}

# ============================================================================
# DNS Setup
# ============================================================================

cmd_dns_setup() {
    local fqdn="$HOSTNAME.$DOMAIN"
    log "Generating DNS records for $DOMAIN..."

    echo ""
    echo "============================================"
    echo "  DNS Records for $DOMAIN"
    echo "============================================"
    echo ""
    echo "MX Record:"
    echo "  $DOMAIN.  IN  MX  10  $fqdn."
    echo ""
    echo "A Records:"
    echo "  $HOSTNAME.$DOMAIN.  IN  A  <YOUR_IP>"
    echo "  webmail.$DOMAIN.    IN  A  <YOUR_IP>"
    echo ""
    echo "SPF Record:"
    echo "  $DOMAIN.  IN  TXT  \"v=spf1 mx a:$fqdn -all\""
    echo ""
    echo "DKIM Record (generate with: mailctl dkim-setup):"
    echo "  default._domainkey.$DOMAIN.  IN  TXT  \"v=DKIM1; k=rsa; p=...\""
    echo ""
    echo "DMARC Record:"
    echo "  _dmarc.$DOMAIN.  IN  TXT  \"v=DMARC1; p=quarantine; rua=mailto:postmaster@$DOMAIN\""
    echo ""
}

# ============================================================================
# User Repair (mailbox resync)
# ============================================================================

cmd_user_repair() {
    local email="$1"
    [ -z "$email" ] && { echo "Usage: mailctl user-repair <email>"; return 1; }

    if ! lxc_running "$CONTAINER"; then
        error "Mail container not running"
        return 1
    fi

    log "Repairing mailbox for $email..."
    lxc_attach "$CONTAINER" doveadm force-resync -u "$email" '*'
    log "Mailbox repair complete"
}

# ============================================================================
# Fix Ports (container port checking)
# ============================================================================

# Pose (ou repose) le greffon zipjointes DANS le conteneur (#1029).
#
# PAR L'INTERIEUR, ET PAS AUTREMENT. Le conteneur mail est non privilegie : un
# fichier ecrit depuis l hote y apparait avec une appartenance decalee, et
# `chown` depuis l hote est refuse. On pousse donc une archive dans
# `lxc-attach`, qui la deballe avec les droits du conteneur.
cmd_webmail_greffons() {
    local src="/usr/share/secubox/roundcube-plugins"
    [ -d "$src" ] || { echo "aucun greffon a poser ($src absent)"; return 0; }
    lxc-info -n "$CONTAINER" 2>/dev/null | grep -q RUNNING || {
        echo "conteneur arrete — greffons non poses"; return 1; }

    # php-zip conditionne zipjointes ET zipdownload : sans lui, le second se
    # desactive tout seul a son chargement et le bouton « telecharger en zip »
    # n apparait jamais. Il manquait depuis l origine sur cette board.
    if ! lxc-attach -n "$CONTAINER" -- php -r 'exit(class_exists("ZipArchive")?0:1);' 2>/dev/null; then
        echo "  installation de php8.2-zip (requis par zipjointes et zipdownload)"
        lxc-attach -n "$CONTAINER" -- sh -c \
            "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq php8.2-zip" >/dev/null 2>&1 || true
    fi

    ( cd "$src" && tar czf - . ) | lxc-attach -n "$CONTAINER" -- sh -c \
        "cd /usr/share/roundcube/plugins && tar xzf - && chown -R root:root zipjointes && chmod -R u=rwX,go=rX zipjointes"

    # ACTIVATION DANS LE FICHIER QUI FAIT FOI. `config.inc.php` ne porte que des
    # entrees commentees ; la liste effective vit dans `config.inc.php.local`.
    # Editer le premier ne changerait rien — en silence, ce qui est le pire.
    local conf="/etc/roundcube/config.inc.php.local"
    if lxc-attach -n "$CONTAINER" -- grep -q "zipjointes" "$conf" 2>/dev/null; then
        echo "  greffon deja actif"
    elif lxc-attach -n "$CONTAINER" -- grep -q '\$config\["plugins"\]' "$conf" 2>/dev/null; then
        lxc-attach -n "$CONTAINER" -- cp "$conf" "$conf.avant-zipjointes"
        lxc-attach -n "$CONTAINER" -- sed -i 's/\("ident_switch"\)\]/\1, "zipjointes"]/' "$conf"
        lxc-attach -n "$CONTAINER" -- grep -q "zipjointes" "$conf" \
            && echo "  zipjointes active" \
            || { echo "  ECHEC d activation — liste des greffons inattendue" >&2; return 1; }
    else
        echo "  liste des greffons introuvable dans $conf" >&2; return 1
    fi

    # `php -l` AVANT de recharger : une configuration cassee ferait tomber le
    # webmail entier, et l on ne s en apercevrait qu a la premiere connexion.
    lxc-attach -n "$CONTAINER" -- php -l "$conf" >/dev/null 2>&1 || {
        echo "  configuration invalide — restauration" >&2
        lxc-attach -n "$CONTAINER" -- cp "$conf.avant-zipjointes" "$conf" 2>/dev/null
        return 1
    }
    lxc-attach -n "$CONTAINER" -- systemctl restart php8.2-fpm >/dev/null 2>&1 || true
    echo "greffons webmail poses"
}

cmd_webmail_config() {
    # Reconcilie la configuration Roundcube d'un conteneur DEJA installe (#1014).
    #
    # POURQUOI UN VERBE ET PAS SEULEMENT LE GABARIT D'INSTALLATION : install.sh
    # n'ecrit qu'au provisionnement. Le conteneur de gk2 existe depuis des mois,
    # et un correctif qui ne profiterait qu'aux installations futures ne
    # corrigerait rien de ce qui est signale.
    #
    # Le defaut : sans `username_domain`, un operateur qui saisit « gk2 » envoie
    # « gk2 » a Dovecot, qui ne connait que « gk2@secubox.in ». La connexion
    # echoue et le webmail parait casse alors qu'il fonctionne.
    lxc_running "$CONTAINER" || { echo "conteneur a l'arret" >&2; return 1; }

    # LE DOMAINE VIENT DE CE QUI EST SERVI, pas du declaratif : les deux
    # divergent sur gk2 (declare gk2.secubox.in, servi secubox.in), et c'est le
    # servi qui decide si une authentification passe.
    # `lxc_attach` transmet ses mots tels quels a `lxc-attach ... --` : une
    # commande composee doit donc passer par `sh -c`, sinon la chaine entiere
    # est cherchee comme un nom de binaire.
    local dom
    dom=$(lxc_attach "$CONTAINER" sh -c 'postconf -h virtual_mailbox_domains' 2>/dev/null \
          | tr ',' ' ' | awk '{print $1}')
    [ -n "$dom" ] || { echo "domaine servi introuvable" >&2; return 1; }

    # Idempotent : on remplace la ligne si elle existe, on l'ajoute sinon.
    # Deux lignes `username_domain` laisseraient la valeur effective dependante
    # de l'ordre de lecture.
    lxc_attach "$CONTAINER" sh -c "
        f=/etc/roundcube/config.inc.php.local
        [ -f \$f ] || exit 1
        if grep -q \"username_domain\" \$f; then
            sed -i \"s|^.*username_domain.*\$|\\\$config['username_domain'] = '$dom';|\" \$f
        else
            printf \"\\\$config['username_domain'] = '%s';\\n\" '$dom' >> \$f
        fi
    " || { echo "ecriture refusee" >&2; return 1; }

    echo "username_domain = $dom"
}

# ── OpenPGP dans le webmail (#1738, phase 3) ──────────────────────────────
#
# PAR MAILVELOPE, DANS LE NAVIGATEUR. Roundcube 1.6 sait déléguer chiffrer,
# signer et déchiffrer à l'extension Mailvelope : la clé secrète reste chez la
# personne, le serveur ne voit que du chiffré. C'est la seule voie compatible
# avec la règle « aucune clé privée prêtée au webmail » — d'où le refus si le
# greffon enigma (clés tenues côté serveur) est actif.
#
# `mailvelope_main_keyring` : le trousseau PRINCIPAL de Mailvelope, celui où la
# personne a importé sa clé depuis « Mon coffre », plutôt qu'un trousseau
# propre au webmail qu'elle devrait remplir une seconde fois.
cmd_webmail_openpgp() {
    local wc="$WEBMAIL_CONTAINER" f=/etc/roundcube/config.inc.php.local
    lxc_running "$wc" || { error "conteneur webmail '$wc' a l'arret"; return 1; }
    if lxc_attach "$wc" sh -c "grep -qE \"^[^/]*plugins.*['\\\"]enigma['\\\"]\" $f"; then
        error "le greffon enigma est actif : il tient les clés privées côté serveur — retirez-le d'abord"
        return 1
    fi
    lxc_attach "$wc" sh -c "
        f=$f
        [ -f \$f ] || exit 1
        cp \$f \$f.avant-openpgp
        if grep -q mailvelope_main_keyring \$f; then
            sed -i \"s|^.*mailvelope_main_keyring.*\$|\\\$config['mailvelope_main_keyring'] = true;|\" \$f
        else
            printf '%s\\n' '// OpenPGP par Mailvelope (#1738, mailctl webmail-openpgp) : la clé reste dans le navigateur.' \
                \"\\\$config['mailvelope_main_keyring'] = true;\" >> \$f
        fi
        php -l \$f >/dev/null 2>&1 || { cp \$f.avant-openpgp \$f; exit 2; }
    " || { error "configuration refusée (php -l) — restaurée"; return 1; }
    lxc_attach "$wc" systemctl restart php8.2-fpm >/dev/null 2>&1 || true
    echo "webmail : OpenPGP par Mailvelope (trousseau principal) — enigma absent"
}

# ── OpenPGP dans le webmail par la page du Coffre (#1852, P5) ─────────────
#
# Pose le greffon `secubox_pgp` DANS le conteneur webmail et l'active, avec l'origine de la page OpenPGP du Coffre
# (pgp.<domaine>, posée par secubox-vault). Le greffon n'a aucune cryptographie : tout se fait dans cette page, d'une
# autre origine, avec la clé de la personne ouverte par sa connexion. Idempotent ; sans origine, il ne fait rien.
# `enigma` (clés côté serveur) reste exclu (#1738).
cmd_webmail_pgp() {
    local wc="$WEBMAIL_CONTAINER" src="/usr/share/secubox/roundcube-plugins/secubox_pgp" f=/etc/roundcube/config.inc.php.local dom
    [ -d "$src" ] || { echo "greffon secubox_pgp absent ($src)"; return 0; }
    command -v secubox-domaine >/dev/null 2>&1 || { echo "secubox-domaine absent — greffon non pose"; return 0; }
    dom="$(secubox-domaine pgp 2>/dev/null | tr 'A-Z' 'a-z' | head -1)"
    [[ "$dom" =~ ^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$ ]] || { echo "domaine de la page OpenPGP inconnu — greffon non pose"; return 0; }
    lxc_running "$wc" || { error "conteneur webmail '$wc' a l'arret"; return 1; }
    if lxc_attach "$wc" sh -c "grep -qE \"^[^/]*plugins.*['\\\"]enigma['\\\"]\" $f"; then
        error "le greffon enigma est actif : il tient les clés privées côté serveur — retirez-le d'abord"
        return 1
    fi
    # Par l'intérieur (conteneur non privilégié) : une archive poussée dans lxc-attach, déballée avec les droits du conteneur.
    ( cd "$(dirname "$src")" && tar czf - secubox_pgp ) | lxc_attach "$wc" sh -c \
        "cd /usr/share/roundcube/plugins && rm -rf secubox_pgp && tar xzf - && chown -R root:root secubox_pgp && chmod -R u=rwX,go=rX secubox_pgp" || return 1
    # Roundcube (Debian) charge ses greffons depuis /var/lib/roundcube/plugins, des LIENS vers /usr/share : sans le lien, « Failed to
    # load plugin file » dans les journaux du webmail (vu sur gk2, comme pour secubox_sso).
    lxc_attach "$wc" sh -c "[ -d /var/lib/roundcube/plugins ] && ln -sfn /usr/share/roundcube/plugins/secubox_pgp /var/lib/roundcube/plugins/secubox_pgp || true"
    lxc_attach "$wc" sh -c "
        f=$f
        [ -f \$f ] || exit 1
        cp \$f \$f.avant-secubox-pgp
        grep -q \"'secubox_pgp'\" \$f || printf '%s\\n' \"\\\$config['plugins'][] = 'secubox_pgp';  // #1852\" >> \$f
        sed -i '/secubox_pgp_origine/d' \$f
        printf '%s\\n' \"\\\$config['secubox_pgp_origine'] = 'https://$dom';  // #1852\" >> \$f
        php -l \$f >/dev/null 2>&1 || { cp \$f.avant-secubox-pgp \$f; exit 2; }
        php -l /usr/share/roundcube/plugins/secubox_pgp/secubox_pgp.php >/dev/null 2>&1 || { cp \$f.avant-secubox-pgp \$f; exit 3; }
    " || { error "greffon secubox_pgp refusé (php -l) — configuration restaurée"; return 1; }
    lxc_attach "$wc" systemctl restart php8.2-fpm >/dev/null 2>&1 || true
    echo "webmail : OpenPGP par la page du Coffre (https://$dom) — enigma absent"
}

# ── Autocrypt sortant (#1852, P2) ───────────────────────────────────────────
#
# L'en-tête `Autocrypt:` des courriels ENVOYÉS par une personne qui a publié sa clé PUBLIQUE dans l'annuaire de la box
# (secubox-openpgp). La table adresse → clé est lue dans l'annuaire (adresses VÉRIFIÉES seulement), poussée dans le LXC
# mail avec la règle rspamd `autocrypt.lua`. Aucune clé privée n'est lue : `sbx-openpgp autocrypt` ne sort que du public.
# Idempotent ; sans clé publiée la table est vide et la règle n'ajoute rien.
cmd_autocrypt_sync() {
    local ct="$CONTAINER" tpl="${TEMPLATES_DIR:-/usr/lib/secubox/mail/templates}/rspamd" table rc=0
    command -v sbx-openpgp >/dev/null 2>&1 || { error "secubox-openpgp absent : pas d'annuaire de clés"; return 1; }
    [ -f "$tpl/autocrypt.lua" ] || { error "gabarit autocrypt.lua introuvable ($tpl)"; return 1; }
    lxc_running "$ct" || { error "conteneur '$ct' a l'arret"; return 1; }
    table="$(sbx-openpgp autocrypt)" || { error "annuaire illisible"; return 1; }
    printf '%s' "$table" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert isinstance(d,dict)' 2>/dev/null \
        || { error "table Autocrypt invalide — rien n'est poussé"; return 1; }
    lxc_attach "$ct" tee /etc/rspamd/autocrypt.lua < "$tpl/autocrypt.lua" >/dev/null || return 1
    printf '%s\n' "$table" | lxc_attach "$ct" sh -c 'cat > /etc/rspamd/autocrypt.json.new && chmod 0644 /etc/rspamd/autocrypt.json.new && mv /etc/rspamd/autocrypt.json.new /etc/rspamd/autocrypt.json' || return 1
    # rspamd charge /etc/rspamd/rspamd.local.lua ; on n'y ajoute qu'UNE ligne, sans toucher au reste.
    lxc_attach "$ct" sh -c "touch /etc/rspamd/rspamd.local.lua; grep -q 'autocrypt.lua' /etc/rspamd/rspamd.local.lua || printf '%s\n' \"dofile('/etc/rspamd/autocrypt.lua')  -- SecuBox #1852\" >> /etc/rspamd/rspamd.local.lua"
    lxc_attach "$ct" sh -c 'rspamadm configtest >/dev/null 2>&1' || { error "rspamadm configtest refuse la configuration — rspamd NON rechargé"; return 1; }
    lxc_attach "$ct" systemctl reload rspamd >/dev/null 2>&1 || lxc_attach "$ct" systemctl restart rspamd >/dev/null 2>&1 || rc=1
    echo "autocrypt : $(printf '%s' "$table" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))') adresse(s) avec clé publique"
    return "$rc"
}

# ── Apprentissage antispam : le geste « indésirable » du webmail ───────────
#
# Trois pieces doivent tenir ensemble, et il en manquait DEUX sur la board :
#
#  1. un MAGASIN. Le classifieur bayesien de rspamd n'a pas de memoire propre :
#     sans redis, il accepte l'apprentissage et n'en garde rien. Le controleur
#     repond alors 204 — un succes de facade — pendant que `rspamc stat` reste
#     obstinement a « Messages learned: 0 ». C'est le pire des cas : un bouton
#     qui a l'air de marcher.
#  2. un CLASSIFIEUR declare. Deux, ici : le modele GLOBAL que tout le monde
#     alimente et dont tout le monde profite, et un modele PAR UTILISATEUR qui
#     n'engage que celui qui clique. Les deux notent le message, avec des
#     symboles distincts — on peut donc voir lequel a tranche.
#  3. le GESTE dans le webmail : le greffon markasjunk, qui poste le message au
#     controleur avec le mot de passe deja provisionne.
# Remet le webmail en marche : Apache (et les tâches de nettoyage Roundcube) ne démarrent pas dans le LXC sans privilèges quand
# AppArmor refuse le montage de leur bac à sable (226/NAMESPACE, constaté sur gk2 depuis le 2026-10-05 : webmail en 502).
cmd_webmail() {
    local wc="$WEBMAIL_CONTAINER" svc
    lxc_running "$wc" || { error "conteneur webmail '$wc' a l'arret"; return 1; }
    for svc in apache2 phpsessionclean roundcube-gc roundcube-cleandb; do
        lxc_service_sans_sandbox "$svc" "$wc" || return 1
    done
    lxc_attach "$wc" sh -c 'systemctl reset-failed apache2 phpsessionclean roundcube-gc roundcube-cleandb 2>/dev/null; systemctl enable --now apache2' >/dev/null 2>&1 \
        || { error "apache2 n'a pas demarre dans '$wc'"; return 1; }
    log "apache2 demarre dans '$wc'"
}

# Remet redis en marche dans le conteneur (surcharge du bac à sable, démarrage, puis rspamd qui le retrouve).
cmd_redis() {
    lxc_running "$CONTAINER" || { error "conteneur mail '$CONTAINER' a l'arret"; return 1; }
    # Les retardataires du décalage (redis écrit en « nobody » sinon), puis la surcharge du bac à sable.
    secubox-lxc-decaler "${LXC_PATH:-/var/lib/lxc}/$CONTAINER/rootfs" >/dev/null \
        || { error "decalage du rootfs de '$CONTAINER' impossible"; return 1; }
    lxc_service_sans_sandbox redis-server "$CONTAINER" || return 1
    lxc_attach "$CONTAINER" sh -c 'systemctl reset-failed redis-server 2>/dev/null; systemctl enable --now redis-server' >/dev/null 2>&1 \
        || { error "redis-server n'a pas demarre dans '$CONTAINER'"; return 1; }
    # Le résolveur du conteneur : /etc/resolv.conf pointe vers systemd-resolved, qui tombe pour la même raison que redis (AppArmor refuse
    # le montage dont son bac à sable a besoin ; même surchargé il réclame un espace de montage). Sans resolv.conf, rspamd attend ses
    # délais DNS sur chaque message (les envois du rapport WAF expiraient à 20 s). Un fichier STATIQUE, posé seulement s'il manque ou
    # si le lien est pendant, comme sur gk3 ; jamais par-dessus un fichier valide.
    lxc_attach "$CONTAINER" sh -c '[ ! -e /etc/resolv.conf ] && { rm -f /etc/resolv.conf; printf "nameserver 10.100.0.1\nnameserver 9.9.9.9\n" > /etc/resolv.conf; }; true' \
        || warn "resolv.conf non retabli dans '$CONTAINER'"
    lxc_attach "$CONTAINER" systemctl restart rspamd >/dev/null 2>&1 || warn "rspamd non relance"
    log "redis-server demarre ; rspamd relance"
}

cmd_antispam_apprentissage() {
    local wc="$WEBMAIL_CONTAINER"
    lxc_running "$CONTAINER" || { error "conteneur mail '$CONTAINER' a l'arret"; return 1; }
    lxc_running "$wc" || { error "conteneur webmail '$wc' a l'arret"; return 1; }

    local pw_file="/etc/secubox/secrets/rspamd-controller.pw"
    [ -r "$pw_file" ] || { error "mot de passe du controleur rspamd absent ($pw_file)"; return 1; }
    local pw; pw="$(cat "$pw_file")"

    # ── 1. le magasin ──────────────────────────────────────────────────────
    lxc_service_sans_sandbox redis-server "$CONTAINER" || return 1
    lxc_attach "$CONTAINER" sh -c 'systemctl reset-failed redis-server 2>/dev/null; systemctl enable --now redis-server' >/dev/null 2>&1 \
        || { error "redis-server n'a pas demarre — sans lui rien n'est retenu"; return 1; }
    printf '%s\n' 'servers = "127.0.0.1";' \
        | lxc_attach "$CONTAINER" sh -c "cat > /etc/rspamd/local.d/redis.conf"

    # ── 2. les classifieurs ────────────────────────────────────────────────
    # `autolearn` reste FERME : on apprend de ce qu'un humain designe, pas de ce
    # que le filtre croit deja. Autrement le modele confirme ses propres erreurs.
    printf '%s\n' \
        'backend = "redis";' \
        'autolearn = false;' \
        'min_learns = 20;' \
        | lxc_attach "$CONTAINER" sh -c "cat > /etc/rspamd/local.d/classifier-bayes.conf"

    # Le modele PAR UTILISATEUR vit a cote du global, avec ses propres symboles.
    # `local.d/` fusionne dans le classifieur existant : pour en declarer un
    # SECOND il faut passer par rspamd.conf.local.
    printf '%s\n' \
        '# Modele par utilisateur (#1195) : n'"'"'engage que celui qui clique.' \
        'classifier "bayes" {' \
        '  name = "utilisateur";' \
        '  backend = "redis";' \
        '  per_user = true;' \
        '  autolearn = false;' \
        '  min_learns = 5;' \
        '  symbol_spam = "BAYES_SPAM_USER";' \
        '  symbol_ham = "BAYES_HAM_USER";' \
        '}' \
        | lxc_attach "$CONTAINER" sh -c "cat > /etc/rspamd/rspamd.conf.local"

    lxc_attach "$CONTAINER" sh -c 'systemctl restart rspamd' >/dev/null 2>&1 || true

    # ── 3. le geste dans le webmail ────────────────────────────────────────
    # `Deliver-To` porte l'identite : c'est elle qui aiguille le modele par
    # utilisateur. Sans cet en-tete, tout retomberait dans le seul global.
    local conf="/etc/roundcube/config.inc.php.local"
    if lxc_attach "$wc" grep -q "markasjunk" "$conf" 2>/dev/null; then
        log "markasjunk deja actif dans $wc"
    else
        printf '%s\n' \
            "// #1195 — geste « indesirable » : apprentissage rspamd, global ET par utilisateur." \
            "\$config['plugins'][] = 'markasjunk';" \
            "\$config['markasjunk_learning_driver'] = 'cmd_learn';" \
            "\$config['markasjunk_spam_cmd'] = 'curl -s -m 10 -X POST -H \"Password: $pw\" -H \"Deliver-To: %u\" --data-binary @- http://$LXC_IP:11334/learnspam';" \
            "\$config['markasjunk_ham_cmd'] = 'curl -s -m 10 -X POST -H \"Password: $pw\" -H \"Deliver-To: %u\" --data-binary @- http://$LXC_IP:11334/learnham';" \
            | lxc_attach "$wc" sh -c "cat >> '$conf'" \
            || { error "ecriture de la config webmail refusee"; return 1; }
        lxc_attach "$wc" sh -c 'systemctl reload apache2 2>/dev/null \
            || systemctl restart apache2 2>/dev/null \
            || systemctl restart php*-fpm 2>/dev/null || true'
    fi

    # ── verification : on n'annonce pas un succes sans l'avoir constate ─────
    printf 'Subject: verification apprentissage\n\ncorps\n' > /tmp/.sbx-learn.eml
    curl -s -o /dev/null -m 10 -X POST -H "Password: $pw" \
        --data-binary @/tmp/.sbx-learn.eml "http://$LXC_IP:11334/learnspam" || true
    rm -f /tmp/.sbx-learn.eml
    local appris
    appris="$(lxc_attach "$CONTAINER" sh -c 'rspamc stat 2>/dev/null' | sed -n 's/^Messages learned: //p')"
    if [ "${appris:-0}" -gt 0 ] 2>/dev/null; then
        log "apprentissage operationnel — $appris message(s) retenu(s)"
    else
        error "le controleur accepte mais rien n'est retenu : verifier redis dans $CONTAINER"
        return 1
    fi
}

# WEBMAIL SANS MOT DE PASSE (#1562). La personne arrive avec sa session
# SecuBox : le greffon secubox_sso la fait VERIFIER par la box (/auth/verify,
# comme le BBS), puis ouvre SA boite par l'identifiant MAITRE Dovecot
# (autorisation SASL : authcid = maitre, authzid = la boite). Le maitre n'est
# accepte QUE depuis le conteneur webmail (allow_nets) et QUE pour une boite qui
# existe (pass=yes). Idempotent ; chaque etape verifiee, retour arriere sinon.
SSO_MAITRE="sbxwebmail"
SSO_SECRET_FILE="/etc/secubox/secrets/mail/webmail-sso"
cmd_webmail_sso() {
    local wc="$WEBMAIL_CONTAINER" src="/usr/share/secubox/roundcube-sso/secubox_sso"
    lxc_running "$CONTAINER" || { error "conteneur mail '$CONTAINER' a l'arret"; return 1; }
    lxc_running "$wc" || { error "conteneur webmail '$wc' a l'arret"; return 1; }
    [ -d "$src" ] || { error "greffon absent : $src"; return 1; }

    # 1. Le secret maitre : genere une fois, garde sur l'hote (0600).
    install -d -m 0700 "$(dirname "$SSO_SECRET_FILE")"
    if [ ! -s "$SSO_SECRET_FILE" ]; then
        (umask 077; head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 40 > "$SSO_SECRET_FILE")
    fi
    local secret; secret="$(cat "$SSO_SECRET_FILE")"
    [ ${#secret} -ge 32 ] || { error "secret maitre trop court"; return 1; }
    local wip; wip="$(lxc-info -n "$wc" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' )"
    [ -n "$wip" ] || { error "adresse du conteneur webmail introuvable"; return 1; }

    # 2. Dovecot (conteneur mail) : l'utilisateur maitre, restreint au webmail.
    local hash; hash="$(lxc_attach "$CONTAINER" doveadm pw -s SHA512-CRYPT -p "$secret")" \
        || { error "doveadm pw a echoue"; return 1; }
    local conf="/etc/dovecot/conf.d/15-secubox-sso.conf"
    lxc_attach "$CONTAINER" sh -c "cp -a $conf $conf.avant 2>/dev/null; umask 027; \
        printf '%s\n' '$SSO_MAITRE:$hash::::::allow_nets=$wip/32' > /etc/dovecot/secubox-maitre && \
        chgrp dovecot /etc/dovecot/secubox-maitre && chmod 0640 /etc/dovecot/secubox-maitre && \
        printf '%s\n' '# SecuBox #1562 : maitre du webmail (mailctl webmail-sso).' \
          'passdb {' '  driver = passwd-file' '  args = /etc/dovecot/secubox-maitre' \
          '  master = yes' '  pass = yes' '}' > $conf && chmod 0644 $conf"
    # dovecot.conf est MONOLITHIQUE ici (pas de « !include conf.d/*.conf ») :
    # sans cette ligne, le passdb maitre n'est jamais lu — « Master user login
    # attempted without master passdbs » (vu sur gk2).
    lxc_attach "$CONTAINER" sh -c "grep -q '15-secubox-sso.conf' /etc/dovecot/dovecot.conf || \
        { cp -a /etc/dovecot/dovecot.conf /etc/dovecot/dovecot.conf.avant-sso && \
          printf '%s\n' '!include_try /etc/dovecot/conf.d/15-secubox-sso.conf' >> /etc/dovecot/dovecot.conf; }"
    if ! lxc_attach "$CONTAINER" doveconf -n >/dev/null 2>&1 \
       || ! lxc_attach "$CONTAINER" sh -c "doveconf -n | grep -q secubox-maitre"; then
        lxc_attach "$CONTAINER" sh -c "if [ -f $conf.avant ]; then mv $conf.avant $conf; else rm -f $conf; fi; \
            [ -f /etc/dovecot/dovecot.conf.avant-sso ] && cp -a /etc/dovecot/dovecot.conf.avant-sso /etc/dovecot/dovecot.conf"
        error "doveconf refuse la configuration (ou n'a pas lu le maitre) — retour arriere"; return 1
    fi
    lxc_attach "$CONTAINER" doveadm reload
    log "Dovecot : maitre '$SSO_MAITRE' accepte depuis $wip seulement"

    # 3. Roundcube (conteneur webmail) : le greffon, sa configuration, l'activation.
    ( cd "$(dirname "$src")" && tar czf - secubox_sso ) | lxc_attach "$wc" sh -c \
        "cd /usr/share/roundcube/plugins && tar xzf - && chown -R root:root secubox_sso && chmod -R u=rwX,go=rX secubox_sso"
    # Roundcube (Debian) charge ses greffons depuis /var/lib/roundcube/plugins,
    # peuplé de LIENS vers /usr/share : sans le lien, « Failed to load plugin »
    # et le webmail retombe sur l'identification (#1644).
    lxc_attach "$wc" sh -c "[ -d /var/lib/roundcube/plugins ] && \
        ln -sfn /usr/share/roundcube/plugins/secubox_sso /var/lib/roundcube/plugins/secubox_sso || true"
    lxc_attach "$wc" sh -c "umask 027; cat > /usr/share/roundcube/plugins/secubox_sso/config.inc.php && \
        chgrp www-data /usr/share/roundcube/plugins/secubox_sso/config.inc.php && \
        chmod 0640 /usr/share/roundcube/plugins/secubox_sso/config.inc.php" <<EOF
<?php
// Ecrit par mailctl webmail-sso (#1562) — ne pas modifier a la main.
\$config['secubox_sso_verify'] = 'https://$LXC_GATEWAY/api/v1/auth/auth/verify';
\$config['secubox_sso_maitre'] = '$SSO_MAITRE';
\$config['secubox_sso_secret'] = '$secret';
EOF
    local rc="/etc/roundcube/config.inc.php.local"
    if ! lxc_attach "$wc" grep -q '"secubox_sso"' "$rc"; then
        lxc_attach "$wc" cp "$rc" "$rc.avant-sso"
        lxc_attach "$wc" sed -i 's/^\$config\["plugins"\] = \[/$config["plugins"] = ["secubox_sso", /' "$rc"
    fi
    if ! lxc_attach "$wc" php -l "$rc" >/dev/null 2>&1 \
       || ! lxc_attach "$wc" php -l /usr/share/roundcube/plugins/secubox_sso/secubox_sso.php >/dev/null 2>&1 \
       || ! lxc_attach "$wc" grep -q '"secubox_sso"' "$rc"; then
        lxc_attach "$wc" cp "$rc.avant-sso" "$rc" 2>/dev/null
        error "configuration Roundcube refusee — retour arriere"; return 1
    fi
    log "Roundcube : secubox_sso actif — le webmail s'ouvre par la session SecuBox"
}

cmd_webmail_sieve() {
    # Active l'EDITEUR DE FILTRES SIEVE dans Roundcube (#1181) : le plugin
    # `managesieve` (deja installe) pointe sur le Dovecot ManageSieve :4190 du
    # socle (#1169). Cible le conteneur WEBMAIL (LXC roundcube separe), pas mail.
    local wc="$WEBMAIL_CONTAINER"
    lxc_running "$wc" || { error "conteneur webmail '$wc' a l'arret"; return 1; }
    local conf="/etc/roundcube/config.inc.php.local"

    # Backend ManageSieve : la ou vivent les scripts sieve de l'utilisateur = le
    # Dovecot mail (:4190). Reglable via managesieve_host, defaut = IP du LXC mail.
    local ms_host
    ms_host="$(config_get "managesieve_host" "$LXC_IP")"
    [ -n "$ms_host" ] || ms_host="$LXC_IP"   # clé présente mais vide → IP LXC mail

    # Idempotent : si le plugin est deja active, on ne re-ajoute rien — mais on
    # REPARE la ligne TLS si elle porte l'ancienne valeur.
    #
    # POURQUOI TLS EST OBLIGATOIRE ICI. Dovecot n'annonce AUCUN mecanisme SASL
    # tant que la liaison est en clair (`disable_plaintext_auth`) : la
    # salutation dit `"SASL" ""` et `STARTTLS`. Roundcube configure sans TLS
    # n'a donc aucun moyen de s'authentifier et rend « la connexion au serveur
    # n'aboutit pas » — l'editeur de filtres s'ouvre mais aucun filtre ne peut
    # etre enregistre. Apres STARTTLS, le serveur annonce `"SASL" "PLAIN LOGIN"`.
    if lxc_attach "$wc" grep -q "'managesieve'" "$conf" 2>/dev/null; then
        if lxc_attach "$wc" grep -q "managesieve_usetls'\] = false" "$conf" 2>/dev/null; then
            lxc_attach "$wc" sed -i \
                "s/\\(managesieve_usetls'\\] = \\)false/\\1true/" "$conf" \
                && log "managesieve : TLS remis a true dans $wc (sans lui, aucune authentification possible)"
            lxc_attach "$wc" sh -c 'systemctl reload apache2 2>/dev/null \
                || systemctl restart apache2 2>/dev/null \
                || systemctl restart php*-fpm 2>/dev/null || true'
        else
            log "managesieve deja actif dans $wc"
        fi
        return 0
    fi

    # Bloc PHP genere cote hote, injecte via `cat >>` dans le conteneur (le
    # .local est inclus EN DERNIER par config.inc.php, donc il l'emporte). Pas de
    # heredoc dans un sh -c (enfer de quotes) : on pipe le bloc.
    printf '%s\n' \
        "// #1181 — editeur de filtres Sieve (plugin managesieve, backend socle :4190)." \
        "\$config['plugins'][] = 'managesieve';" \
        "\$config['managesieve_host'] = '$ms_host';" \
        "\$config['managesieve_port'] = 4190;" \
        "\$config['managesieve_usetls'] = true;" \
        | lxc_attach "$wc" sh -c "cat >> '$conf'" \
        || { error "ecriture de la config webmail refusee"; return 1; }

    # Recharge le serveur web du conteneur (Apache sur la board ; php-fpm sinon).
    lxc_attach "$wc" sh -c 'systemctl reload apache2 2>/dev/null \
        || systemctl restart apache2 2>/dev/null \
        || systemctl restart php*-fpm 2>/dev/null || true'
    log "Sieve activé dans le webmail ($wc) → managesieve :4190 sur $ms_host"
}

cmd_fix_ports() {
    if ! lxc_running "$CONTAINER"; then
        error "Mail container not running"
        return 1
    fi

    log "Checking mail ports..."
    local ports=$(lxc_attach "$CONTAINER" netstat -tln 2>/dev/null)
    local all_ok=true

    for port in 25 587 465 143 993; do
        if echo "$ports" | grep -q ":$port "; then
            echo -e "  Port $port: ${GREEN}OK${NC}"
        else
            echo -e "  Port $port: ${RED}NOT LISTENING${NC}"
            all_ok=false
        fi
    done

    if [ "$all_ok" = "false" ]; then
        warn "Some ports are not listening. Attempting restart..."
        lxc_attach "$CONTAINER" /opt/start-mail.sh &
        sleep 3
        log "Services restarted"
    else
        log "All ports OK"
    fi
}

# ============================================================================
# Install
# ============================================================================

cmd_install() {
    [ "$(id -u)" -eq 0 ] || { error "Root required"; return 1; }
    log "Installing SecuBox Mail Server (single LXC '$CONTAINER')..."

    : "${LIB_DIR:=/usr/lib/secubox/mail/lib}"
    if [ ! -f "$LIB_DIR/install.sh" ]; then
        local alt="$(dirname "$0")/../lib/mail"
        [ -f "$alt/install.sh" ] && LIB_DIR="$alt"
    fi
    # shellcheck source=/dev/null
    source "$LIB_DIR/lxc.sh"
    # shellcheck source=/dev/null
    source "$LIB_DIR/install.sh"

    # Persistent data dirs (host-side, bind-mounted into the LXC)
    mkdir -p "$DATA_PATH"/{vmail,config,ssl,backups}
    # Cibles de montage : au root DU CONTENEUR (100000), qui en cède ensuite la
    # propriété de l'intérieur (vmail…). En 0:0 il ne peut rien y écrire ; gk2
    # les avait à 100000, posé à la main (#1729). Premier niveau seulement.
    for _d in vmail config ssl; do
        [ "$(stat -c %u "$DATA_PATH/$_d")" -lt 100000 ] && chown 100000:100000 "$DATA_PATH/$_d"
    done
    mkdir -p "$LXC_PATH"

    # Bootstrap rootfs + render LXC config. Toujours appelé : il ne fait rien
    # sur un rootfs complet, et refait celui qui n'a pas d'init (#1729) — la
    # garde `lxc_exists` l'empêchait de jamais le voir.
    LXC_BASE="$LXC_PATH" bootstrap_debian "$CONTAINER" || { error "rootfs impossible"; return 1; }
    LXC_BASE="$LXC_PATH" lxc_create_config "$CONTAINER" "$LXC_IP" "$LXC_BRIDGE" "$LXC_GATEWAY"

    # Bring the container up so install steps can chroot/apt
    LXC_BASE="$LXC_PATH" lxc_start_safely "$CONTAINER" || {
        error "container failed to start"; return 1; }

    LXC_BASE="$LXC_PATH" install_mail_packages    "$CONTAINER"
    LXC_BASE="$LXC_PATH" install_webmail_packages "$CONTAINER"
    LXC_BASE="$LXC_PATH" DOMAIN="$DOMAIN" HOSTNAME="$HOSTNAME" \
        configure_postfix   "$CONTAINER"
    LXC_BASE="$LXC_PATH" configure_dovecot   "$CONTAINER"
    LXC_BASE="$LXC_PATH" DOMAIN="$DOMAIN" \
        configure_roundcube "$CONTAINER"

    # Antivirus optionnel — honore le drapeau [mail.antivirus].enabled du
    # provisioning (off par défaut, coûteux ~1 Go signatures sur ARM).
    if [ "$ANTIVIRUS_ENABLED" = "true" ]; then
        cmd_antivirus on
    fi

    log "Installation complete!"
    echo ""
    echo "Next steps:"
    echo "  1. Configure: edit $CONFIG_FILE"
    echo "  2. Add users: mailctl user add user@$DOMAIN"
    echo "  3. Start: mailctl start"
}

cmd_uninstall() {
    warn "This will remove mail containers. Data in $DATA_PATH will be preserved."
    read -p "Continue? (yes/no): " confirm
    [ "$confirm" != "yes" ] && { echo "Aborted"; return 1; }

    log "Stopping services..."
    systemctl stop secubox-mail 2>/dev/null

    log "Removing containers..."
    lxc-stop -n "$CONTAINER" 2>/dev/null
    lxc-stop -n "$WEBMAIL_CONTAINER" 2>/dev/null
    rm -rf "$LXC_PATH/$CONTAINER"
    rm -rf "$LXC_PATH/$WEBMAIL_CONTAINER"

    log "Mail server removed. Data preserved in $DATA_PATH"
}

# ============================================================================
# Service Control
# ============================================================================

cmd_start() {
    log "Starting mail LXC '$CONTAINER'..."
    if lxc_running "$CONTAINER"; then
        log "already running"
        return 0
    fi
    if ! lxc_exists "$CONTAINER"; then
        error "container '$CONTAINER' not present at $LXC_PATH/$CONTAINER"
        return 1
    fi
    # Rootfs debootstrappé en root pour un conteneur non privilégié (#1729).
    if [ "$(stat -c %u "$LXC_PATH/$CONTAINER/rootfs")" -lt 100000 ]; then
        secubox-lxc-decaler "$LXC_PATH/$CONTAINER/rootfs" || error "décalage du rootfs impossible"
    fi
    lxc-start -n "$CONTAINER" -P "$LXC_PATH" -d
    # Wait up to 10s for RUNNING state.
    for _ in 1 2 3 4 5 6 7 8 9 10; do
        lxc_running "$CONTAINER" && { log "mail LXC running"; return 0; }
        sleep 1
    done
    error "mail LXC failed to start within 10s"
    return 1
}

cmd_stop() {
    log "Stopping mail LXC '$CONTAINER'..."
    if ! lxc_running "$CONTAINER"; then
        log "already stopped"
        return 0
    fi
    lxc-stop -n "$CONTAINER" -t 30 || true
    log "mail LXC stopped"
}

cmd_restart() {
    cmd_stop
    sleep 2
    cmd_start
}

cmd_status() {
    echo ""
    echo "========================================"
    echo "  SecuBox Mail Server v$VERSION"
    echo "========================================"
    echo ""

    echo "Configuration:"
    echo "  Domain:      $DOMAIN"
    echo "  Hostname:    $HOSTNAME.$DOMAIN"
    echo "  Mail IP:     $LXC_IP"
    echo ""

    # Mail server status
    echo "Mail Server ($CONTAINER):"
    if lxc_running "$CONTAINER"; then
        echo -e "  Status:    ${GREEN}Running${NC}"
        # Check ports
        local ports=$(lxc_attach "$CONTAINER" netstat -tln 2>/dev/null)
        for port in 25 587 465 993 995; do
            if echo "$ports" | grep -q ":$port "; then
                echo -e "  Port $port:  ${GREEN}listening${NC}"
            else
                echo -e "  Port $port:  ${RED}closed${NC}"
            fi
        done
    elif lxc_exists "$CONTAINER"; then
        echo -e "  Status:    ${YELLOW}Stopped${NC}"
    else
        echo -e "  Status:    ${RED}Not installed${NC}"
    fi
    echo ""

    # Webmail status
    echo "Webmail ($WEBMAIL_CONTAINER):"
    if lxc_running "$WEBMAIL_CONTAINER"; then
        echo -e "  Status:    ${GREEN}Running${NC}"
        echo "  Port:      $WEBMAIL_PORT"
    elif lxc_exists "$WEBMAIL_CONTAINER"; then
        echo -e "  Status:    ${YELLOW}Stopped${NC}"
    else
        echo -e "  Status:    ${RED}Not installed${NC}"
    fi
    echo ""

    # User count
    local users_file="$DATA_PATH/config/users"
    if [ -f "$users_file" ]; then
        local user_count=$(wc -l < "$users_file")
        echo "Users:       $user_count"
    fi

    # Storage
    if [ -d "$DATA_PATH/mail" ]; then
        local storage=$(du -sh "$DATA_PATH/mail" 2>/dev/null | cut -f1)
        echo "Storage:     ${storage:-0}"
    fi
    echo ""
}

# ============================================================================
# User Management
# ============================================================================

cmd_sync() {
    # Phase 1 rev. 2: persistent config is bind-mounted from
    # $DATA_PATH/config into $LXC_PATH/$CONTAINER/rootfs/etc/mail-config,
    # so there is no copy step. Rebuild Postfix LMDB indexes if the
    # container is running; otherwise it's a no-op until next start.
    if lxc_running "$CONTAINER"; then
        for map in vmailbox virtual; do
            lxc_attach "$CONTAINER" postmap "lmdb:/etc/mail-config/$map" 2>/dev/null || true
        done
    fi
}

cmd_user() {
    local action="${1:-list}"
    shift

    case "$action" in
        add)
            local email="$1"
            local password="$2"
            [ -z "$email" ] && { echo "Usage: mailctl user add <email> [password]"; return 1; }

            if [ -z "$password" ]; then
                read -s -p "Password: " password
                echo ""
            fi

            user_add "$email" "$password"
            cmd_sync
            ;;
        del|delete|remove)
            local email="$1"
            [ -z "$email" ] && { echo "Usage: mailctl user del <email>"; return 1; }
            user_del "$email"
            cmd_sync
            ;;
        passwd|password)
            local email="$1"
            local password="$2"
            [ -z "$email" ] && { echo "Usage: mailctl user passwd <email> [password]"; return 1; }

            if [ -z "$password" ]; then
                read -s -p "New password: " password
                echo ""
            fi

            user_passwd "$email" "$password"
            cmd_sync
            ;;
        list)
            user_list
            ;;
        *)
            echo "User commands:"
            echo "  user add <email> [pass]   Add mail user"
            echo "  user del <email>          Delete mail user"
            echo "  user list                 List all users"
            echo "  user passwd <email>       Change password"
            ;;
    esac
}

cmd_alias() {
    local action="${1:-list}"
    shift

    case "$action" in
        add)
            alias_add "$@"
            cmd_sync
            ;;
        del|delete)
            alias_del "$@"
            cmd_sync
            ;;
        list)
            alias_list
            ;;
        repair)
            # POUR LES BOARDS DEJA INSTALLEES (#1025). Le correctif du code ne
            # rebranche pas un main.cf ecrit avant lui : sans ce verbe, il
            # faudrait ajouter puis retirer un alias pour declencher la
            # recompilation — une manoeuvre qu'il faut connaitre, donc qu'on
            # ne fait pas.
            alias_recompile && alias_list
            ;;
        *)
            echo "Alias commands:"
            echo "  alias add <alias> <target>  Add email alias"
            echo "  alias del <alias>           Delete email alias"
            echo "  alias list                  List aliases"
            echo "  alias repair                Rebrancher et recompiler la carte d\x27alias"
            ;;
    esac
}

# ============================================================================
# Migration from OpenWrt (192.168.255.1)
# ============================================================================

cmd_migrate() {
    local source="${1:-192.168.255.1}"

    log "Migrating mail data from $source..."

    # Check SSH connectivity
    if ! ssh -o ConnectTimeout=5 "root@$source" "echo ok" >/dev/null 2>&1; then
        error "Cannot connect to $source via SSH"
        echo "Ensure SSH key is configured: ssh-copy-id root@$source"
        return 1
    fi

    # Create backup directory
    local backup_dir="$DATA_PATH/migration_$(date +%Y%m%d_%H%M%S)"
    mkdir -p "$backup_dir"

    # Migrate mail data
    log "Migrating mailboxes..."
    rsync -avz --progress "root@$source:/srv/mailserver/mail/" "$DATA_PATH/mail/"

    # Migrate config (users, aliases)
    log "Migrating configuration..."
    rsync -avz --progress "root@$source:/srv/mailserver/config/" "$DATA_PATH/config/"

    # Migrate SSL certificates
    log "Migrating SSL certificates..."
    rsync -avz --progress "root@$source:/srv/mailserver/ssl/" "$DATA_PATH/ssl/"

    # Import UCI config
    log "Importing UCI configuration..."
    ssh "root@$source" "uci show mailserver" > "$backup_dir/mailserver.uci" 2>/dev/null || true

    # Convert UCI to TOML
    if [ -f "$backup_dir/mailserver.uci" ]; then
        local uci_domain=$(grep "main.domain=" "$backup_dir/mailserver.uci" | cut -d"'" -f2)
        local uci_hostname=$(grep "main.hostname=" "$backup_dir/mailserver.uci" | cut -d"'" -f2)

        if [ -n "$uci_domain" ]; then
            log "Setting domain: $uci_domain"
            sed -i "s/^domain = .*/domain = \"$uci_domain\"/" "$CONFIG_FILE" 2>/dev/null || \
                echo "domain = \"$uci_domain\"" >> "$CONFIG_FILE"
        fi

        if [ -n "$uci_hostname" ]; then
            log "Setting hostname: $uci_hostname"
            sed -i "s/^hostname = .*/hostname = \"$uci_hostname\"/" "$CONFIG_FILE" 2>/dev/null || \
                echo "hostname = \"$uci_hostname\"" >> "$CONFIG_FILE"
        fi
    fi

    log "Migration complete!"
    log "Data backed up to: $backup_dir"
    log "Restart services: systemctl restart secubox-mail"
}

# ============================================================================
# Backup / Restore
# ============================================================================

cmd_backup() {
    mkdir -p "$DATA_PATH/backups"
    local ts tb rootfs tar_err
    ts="$(date +%Y%m%d-%H%M%S)"
    tb="$DATA_PATH/backups/mail_$ts.tar.gz"
    # dovecot.conf vit dans le rootfs du LXC ; on le range à côté du reste.
    # LXC_PATH est déjà résolu (default-if-unset) en tête du script.
    rootfs="$LXC_PATH/$CONTAINER/rootfs"

    local tar_args=(czf "$tb" -C "$DATA_PATH" vmail config)
    [ -f "$rootfs/etc/dovecot/dovecot.conf" ] && \
        tar_args+=(-C "$rootfs" etc/dovecot/dovecot.conf)

    # Le filet de sûreté d'une migration destructrice ne doit JAMAIS annoncer
    # un succès qu'il n'a pas obtenu : on vérifie le code de retour de tar et
    # on capture sa sortie d'erreur au lieu de la jeter (2>/dev/null cachait
    # un tar échoué comme un tar réussi).
    if ! tar_err="$(tar "${tar_args[@]}" 2>&1)"; then
        error "backup échoué : $tar_err"
        return 1
    fi

    # Seul "$tb" doit sortir sur stdout : POST /backup de l'API capture la
    # sortie standard de la commande comme chemin d'archive (run_cmd().strip()).
    # Le log de succès va donc sur stderr.
    log "backup → $tb" >&2
    echo "$tb"
}

cmd_restore() {
    local tb="$1"
    [ -f "$tb" ] || { error "archive introuvable : $tb"; return 1; }

    local tar_err
    if ! tar_err="$(tar xzf "$tb" -C "$DATA_PATH" vmail config 2>&1)"; then
        error "restauration échouée (extraction) : $tar_err"
        return 1
    fi

    # cmd_backup embarque dovecot.conf (rootfs du LXC) EN PLUS de vmail/config
    # (DATA_PATH) : un restore qui ne le réextrairait pas ne serait pas un
    # vrai rollback pour une régénération de conf cassée (#1169 revue finale,
    # defaut bloquant 2). Une archive plus ancienne peut ne pas le contenir :
    # `|| true` — ce n'est jamais un motif d'échec du restore.
    local rootfs="$LXC_PATH/$CONTAINER/rootfs"
    tar xzf "$tb" -C "$rootfs" etc/dovecot/dovecot.conf 2>/dev/null || true

    # Un restore qui extrait les données mais ne relance pas Dovecot n'est
    # pas un rollback réussi — le service resterait sur l'ancien état en
    # mémoire pendant que le Maildir sur disque aurait changé sous lui.
    local restart_err
    if ! restart_err="$(lxc_attach "$CONTAINER" systemctl restart dovecot 2>&1)"; then
        error "restauration échouée (redémarrage dovecot) : $restart_err"
        return 1
    fi

    log "restore depuis $tb"
}

# ============================================================================
# Maildir reconciliation (bascule mbox → Maildir, idempotente)
# ============================================================================

# Detecte le mail_location Dovecot effectif et, s'il n'est pas deja en
# Maildir, sauvegarde, regenere dovecot.conf (SSL-aware, Task 2) et cree les
# Maildir des comptes existants. Sans effet si deja conforme.
cmd_maildir_reconcile() {
    local loc
    loc="$(lxc_attach "$CONTAINER" doveconf -h mail_location 2>/dev/null)"
    case "$loc" in
        maildir:*) log "mail_location déjà conforme ($loc)"; return 0 ;;
    esac
    warn "mail_location = '$loc' → bascule Maildir"
    # La sauvegarde precede TOUJOURS la bascule destructrice : si elle
    # echoue, on n'a plus de filet et il faut annuler avant de toucher a
    # quoi que ce soit (regenerer dovecot.conf, redemarrer, creer des
    # Maildir) plutot que de continuer sur un backup absent.
    local _bk
    if ! _bk="$(cmd_backup)"; then
        error "backup préalable échoué — bascule Maildir annulée"
        return 1
    fi

    # configure_dovecot vit dans la lib d'install ; on la source si besoin.
    if ! type configure_dovecot >/dev/null 2>&1; then
        local install_lib="$(dirname "$0")/../lib/mail/install.sh"
        [ -f "$install_lib" ] || install_lib="/usr/lib/secubox/mail/lib/install.sh"
        # shellcheck disable=SC1090
        source "$install_lib"
    fi
    LXC_BASE="${LXC_PATH:-/data/lxc}" DATA_PATH="$DATA_PATH" configure_dovecot "$CONTAINER"

    # VALIDER AVANT DE REDÉMARRER. Un dovecot.conf régénéré invalide ne doit
    # JAMAIS provoquer un redémarrage qui casse le service : `doveconf -n`
    # relit la conf sur disque sans toucher au Dovecot en mémoire. Si elle est
    # invalide, on restaure le backup (qui contient l'ancien dovecot.conf) et
    # on abandonne — pas d'outage. C'est ce filet qui transforme « redémarrage
    # dans une conf cassée » en « abandon propre, service intact ».
    if ! lxc_attach "$CONTAINER" doveconf -n >/dev/null 2>&1; then
        error "conf Dovecot régénérée invalide — restauration du backup et abandon"
        cmd_restore "$_bk" >/dev/null 2>&1
        return 1
    fi

    # Le redemarrage fait foi de la bascule : s'il echoue, la conf a change
    # sur disque mais Dovecot tourne encore sur l'ancienne en memoire — un
    # etat divergent qu'on ne doit jamais annoncer comme un succes silencieux.
    if ! lxc_attach "$CONTAINER" systemctl restart dovecot; then
        error "bascule Maildir échouée (redémarrage dovecot) — restauration"
        cmd_restore "$_bk" >/dev/null 2>&1
        return 1
    fi

    # Cree les Maildir des comptes existants (boites vides = simple creation).
    # Un compte en echec ne doit pas interrompre les suivants : on log et on
    # continue plutot que d'abandonner la reconciliation en cours de route.
    while IFS=: read -r user _; do
        [ -n "$user" ] || continue
        lxc_attach "$CONTAINER" doveadm mailbox create -u "$user" INBOX 2>/dev/null \
            || warn "création du Maildir échouée pour $user (ignoré)"
    done < <(lxc_attach "$CONTAINER" cat /etc/mail-config/users 2>/dev/null)

    log "bascule Maildir effectuée"
}

cmd_sieve() {
    case "${1:-status}" in
        enable)
            # configure_dovecot ne fait apparaitre les blocs Sieve QUE si le
            # rootfs porte deja le plugin (#1169 revue finale, defaut bloquant
            # 1) : on l'installe donc dans le conteneur EN PREMIER, avant toute
            # reconciliation/regeneration — sinon la conf regeneree resterait
            # sans Sieve meme apres cette commande.
            if ! lxc_attach "$CONTAINER" apt-get install -y dovecot-sieve dovecot-managesieved; then
                error "installation de dovecot-sieve/dovecot-managesieved échouée"
                return 1
            fi

            # Sieve stocke ses scripts par boite : on garantit ensuite le
            # Maildir (no-op si deja conforme), PUIS on regenere la conf
            # sans condition — cmd_maildir_reconcile seul ne suffirait pas
            # a faire apparaitre les blocs Sieve sur une box deja Maildir.
            cmd_maildir_reconcile || { error "réconciliation Maildir échouée, activation Sieve annulée"; return 1; }

            # configure_dovecot vit dans la lib d'install ; on la source si besoin
            # (meme pattern de repli que cmd_maildir_reconcile, pour rester coherent).
            if ! type configure_dovecot >/dev/null 2>&1; then
                local install_lib="$(dirname "$0")/../lib/mail/install.sh"
                [ -f "$install_lib" ] || install_lib="/usr/lib/secubox/mail/lib/install.sh"
                # shellcheck disable=SC1090
                source "$install_lib"
            fi
            local _sbk; _sbk="$(cmd_backup)"
            LXC_BASE="${LXC_PATH:-/data/lxc}" DATA_PATH="$DATA_PATH" configure_dovecot "$CONTAINER"

            # Valider AVANT de redémarrer (même filet que maildir-reconcile) :
            # une conf Sieve invalide ne doit pas casser le service.
            if ! lxc_attach "$CONTAINER" doveconf -n >/dev/null 2>&1; then
                error "conf Dovecot (Sieve) invalide — restauration et abandon"
                [ -n "$_sbk" ] && cmd_restore "$_sbk" >/dev/null 2>&1
                return 1
            fi

            # Le redemarrage fait foi de l'activation : s'il echoue, la conf a
            # change sur disque mais Dovecot tourne encore sur l'ancienne en
            # memoire — jamais annoncer un succes silencieux dans ce cas.
            if ! lxc_attach "$CONTAINER" systemctl restart dovecot; then
                error "activation Sieve échouée (redémarrage dovecot) — restauration"
                [ -n "$_sbk" ] && cmd_restore "$_sbk" >/dev/null 2>&1
                return 1
            fi
            # Pré-compiler le sieve par défaut SOUS vmail : install_default_sieve
            # l'a déposé côté hôte avec le bon propriétaire mappé (cf. install.sh),
            # donc vmail peut écrire le .svbin à côté. Sans .svbin compilable, le
            # `sieve_default` de Pigeonhole ne range jamais le spam dans Junk.
            lxc_attach "$CONTAINER" runuser -u vmail -- \
                sievec /var/vmail/sieve/default.sieve 2>/dev/null || true
            log "Sieve activé"
            cmd_sieve status ;;
        status)
            local l
            l="$(lxc_attach "$CONTAINER" ss -tlnp 2>/dev/null | grep ':4190' || true)"
            if [ -n "$l" ]; then
                log "ManageSieve écoute : $l"
            else
                warn "ManageSieve (:4190) absent"
            fi
            return 0 ;;
        *) error "usage: mailctl sieve enable|status"; return 1 ;;
    esac
}

# ============================================================================
# SSL
# ============================================================================

# mail_ssl_fqdn() derives the FQDN the ACME cert must cover. `$HOSTNAME.$DOMAIN`
# is a provisioning artifact ("mail.gk2.secubox.in") that never matched the
# cert actually deployed on the board ("mail.secubox.in") — #1169 revue
# finale, defaut bloquant 3. Priorité :
#   1. le CN du cert DEJA déployé (source de vérité vivante) ;
#   2. la clé TOML explicite `mail_fqdn` du provisioning ;
#   3. repli `mail.$DOMAIN`.
mail_ssl_fqdn() {
    local cn
    if [ -f "$DATA_PATH/ssl/fullchain.pem" ]; then
        cn="$(openssl x509 -in "$DATA_PATH/ssl/fullchain.pem" -noout -subject 2>/dev/null \
              | sed -n 's/^subject=.*CN *= *//p' | sed 's/[[:space:]]*$//')"
        [ -n "$cn" ] && { echo "$cn"; return 0; }
    fi

    local cfg_fqdn
    cfg_fqdn="$(config_get "mail_fqdn" "")"
    [ -n "$cfg_fqdn" ] && { echo "$cfg_fqdn"; return 0; }

    echo "mail.$DOMAIN"
}

# acme_renew() actually renews the LE cert for $1 (fqdn) and writes the
# renewed pair into $DATA_PATH/ssl/{fullchain,privkey}.pem — same mechanism
# `ssl setup` uses. Factored out so `mailctl ssl renew` tests can override it
# without touching the network (bats mocks this function directly).
acme_renew() {
    local fqdn="$1"

    if command -v certbot >/dev/null 2>&1; then
        # secubox-certbot (#1819) démarre certbot même quand une dérive pip casse sa pile.
        $(command -v secubox-certbot || echo certbot) renew --cert-name "$fqdn" --quiet || return 1
        if [ -d "/etc/letsencrypt/live/$fqdn" ]; then
            cp "/etc/letsencrypt/live/$fqdn/fullchain.pem" "$DATA_PATH/ssl/fullchain.pem" \
                && cp "/etc/letsencrypt/live/$fqdn/privkey.pem" "$DATA_PATH/ssl/privkey.pem"
        fi
    elif command -v acme.sh >/dev/null 2>&1; then
        acme.sh --renew -d "$fqdn" --force || return 1
        acme.sh --install-cert -d "$fqdn" \
            --fullchain-file "$DATA_PATH/ssl/fullchain.pem" \
            --key-file "$DATA_PATH/ssl/privkey.pem"
    else
        error "No ACME client found. Install certbot or acme.sh"
        return 1
    fi
}

# deploy_haproxy_cert() copies the renewed pair into the HAProxy cert store
# on the HOST (concatenated cert+key, as HAProxy `bind ... crt <dir>`
# expects). No-op if the store doesn't exist (e.g. dev/test box, or HAProxy
# not fronting this host) — never fatal for the renewal itself.
deploy_haproxy_cert() {
    local fqdn="${1:-$HOSTNAME.$DOMAIN}"
    local haproxy_dir="${HAPROXY_CERT_DIR:-/data/haproxy/certs}"
    [ -d "$haproxy_dir" ] || return 0
    cat "$DATA_PATH/ssl/fullchain.pem" "$DATA_PATH/ssl/privkey.pem" \
        > "$haproxy_dir/$fqdn.pem" 2>/dev/null \
        && chmod 600 "$haproxy_dir/$fqdn.pem"
}

# reload_mail_tls() applies a freshly deployed cert: dovecot+postfix live
# INSIDE the mail LXC (lxc_attach), HAProxy lives on the HOST — never
# lxc_attach for it. Factored out so tests can stub it (no LXC, no haproxy
# in the bats sandbox).
reload_mail_tls() {
    lxc_attach "$CONTAINER" systemctl restart dovecot postfix 2>/dev/null || true
    if command -v systemctl >/dev/null 2>&1; then
        systemctl reload haproxy 2>/dev/null || systemctl restart haproxy 2>/dev/null || true
    fi
}

cmd_ssl() {
    local action="${1:-status}"
    shift

    case "$action" in
        status)
            if [ -f "$DATA_PATH/ssl/fullchain.pem" ]; then
                echo "SSL Certificate:"
                openssl x509 -in "$DATA_PATH/ssl/fullchain.pem" -noout -subject -dates 2>/dev/null | sed 's/^/  /'
            else
                echo "No SSL certificate installed"
                echo "Use: mailctl ssl setup"
            fi
            ;;
        setup)
            local fqdn
            fqdn="$(mail_ssl_fqdn)"
            log "Setting up SSL for $fqdn..."

            if command -v certbot >/dev/null 2>&1; then
                $(command -v secubox-certbot || echo certbot) certonly --standalone -d "$fqdn" \
                    --cert-path "$DATA_PATH/ssl/fullchain.pem" \
                    --key-path "$DATA_PATH/ssl/privkey.pem"
            elif command -v acme.sh >/dev/null 2>&1; then
                acme.sh --issue -d "$fqdn" --standalone
                acme.sh --install-cert -d "$fqdn" \
                    --fullchain-file "$DATA_PATH/ssl/fullchain.pem" \
                    --key-file "$DATA_PATH/ssl/privkey.pem"
            else
                error "No ACME client found. Install certbot or acme.sh"
                return 1
            fi

            chmod 600 "$DATA_PATH/ssl/"*.pem
            log "SSL certificate installed"
            cmd_restart
            ;;
        renew)
            local fqdn
            fqdn="$(mail_ssl_fqdn)"
            local ssl_dir="$DATA_PATH/ssl"
            mkdir -p "$ssl_dir"

            # RIEN À RENOUVELER (#1819) : ni certificat déployé, ni lignée ACME
            # pour ce nom ici — une box dont le courriel n'a jamais eu de
            # certificat (gk3) échouait chaque jour. On le dit, sans échec.
            if [ ! -f "$ssl_dir/fullchain.pem" ] && [ ! -d "/etc/letsencrypt/live/$fqdn" ] \
                    && [ ! -d "${ACME_HOME:-/root/.acme.sh}/$fqdn" ]; then
                log "Aucun certificat à renouveler pour $fqdn — l'obtenir : mailctl ssl setup"
                return 0
            fi

            # Rollback safety: keep a timestamped copy of the CURRENT cert
            # before touching anything, so a failed renewal never leaves us
            # without a working pair (spec: "if renew fails, do NOT deploy a
            # broken cert; keep the old one").
            local ts
            ts=$(date +%Y%m%d%H%M%S)
            if [ -f "$ssl_dir/fullchain.pem" ]; then
                cp "$ssl_dir/fullchain.pem" "$ssl_dir/fullchain.pem.$ts"
            fi
            if [ -f "$ssl_dir/privkey.pem" ]; then
                cp "$ssl_dir/privkey.pem" "$ssl_dir/privkey.pem.$ts"
            fi

            log "Renewing SSL for $fqdn..."
            if ! acme_renew "$fqdn"; then
                error "Certificate renewal failed for $fqdn — keeping previous certificate"
                return 1
            fi

            chmod 600 "$ssl_dir/"*.pem 2>/dev/null || true
            deploy_haproxy_cert "$fqdn"
            reload_mail_tls
            log "SSL certificate renewed"
            ;;
        *)
            echo "SSL commands:"
            echo "  ssl status   Show certificate info"
            echo "  ssl setup    Obtain Let's Encrypt certificate"
            echo "  ssl renew    Renew certificate + redeploy + reload (dovecot/postfix/haproxy)"
            ;;
    esac
}

# ============================================================================
# DKIM
# ============================================================================

cmd_dkim() {
    local action="${1:-status}"
    shift

    case "$action" in
        setup|generate)
            # OpenDKIM setup is deferred to Phase 2 (Rspamd replaces it).
            # Phase 1 stub: tell the user where to look.
            warn "DKIM setup will move to Rspamd in Phase 2."
            warn "Phase 1: configure DKIM manually inside the LXC if needed."
            ;;
        status)
            if [ -f "$DATA_PATH/dkim/default.txt" ]; then
                log "DKIM key exists"
                cat "$DATA_PATH/dkim/default.txt"
            else
                warn "DKIM not configured. Run: mailctl dkim setup"
            fi
            ;;
        *)
            echo "DKIM commands:"
            echo "  dkim setup    Generate DKIM keys"
            echo "  dkim status   Show DKIM DNS record"
            ;;
    esac
}

# ============================================================================
# Antivirus (ClamAV, optionnel — drapeau [mail.antivirus].enabled, off par
# défaut). clamd ne tient PAS dans le budget mémoire du conteneur mail (384 Mo, #496) :
# depuis #1912 il vit dans le LXC dédié `clamav` (paquet secubox-clamav), éveillé à la
# demande. `on` ne pose donc que le module Rspamd, qui pointe sur le mandataire de l'hôte
# (10.100.0.1:3310) ; rien n'est installé dans le conteneur mail. `off` retire le module.
# ============================================================================

cmd_antivirus() {
    case "${1:-status}" in
        on)
            if ! command -v clamavctl >/dev/null 2>&1; then
                error "paquet secubox-clamav absent : apt install secubox-clamav (ClamAV tourne dans un LXC dédié)"
                return 1
            fi
            local templates="${TEMPLATES_DIR:-/usr/lib/secubox/mail/templates}/rspamd/local.d"
            if [ ! -f "$templates/antivirus.conf" ]; then
                error "gabarit antivirus.conf introuvable ($templates) — module Rspamd non déposé"
                return 1
            fi
            # Même mécanique que configure_rspamd_milter (lib/mail/rspamd.sh) : le gabarit
            # packagé est poussé dans le LXC via `lxc-attach -- tee`.
            lxc_attach "$CONTAINER" install -d -m 0755 /etc/rspamd/local.d
            lxc_attach "$CONTAINER" tee /etc/rspamd/local.d/antivirus.conf < "$templates/antivirus.conf" >/dev/null
            lxc_attach "$CONTAINER" chmod 0644 /etc/rspamd/local.d/antivirus.conf
            # task_timeout (8 s par défaut) coupait le scan pendant le démarrage à froid du LXC clamav :
            # le message passait SANS verdict. Porté à 150 s (idempotent). ÉCRIT CÔTÉ HÔTE, dans le
            # rootfs : options.inc appartient à l'uid 0 de l'HÔTE (« nobody » dans le conteneur), root
            # du conteneur ne peut pas le modifier — l'ajout par lxc-attach échouait en silence.
            local opt="${LXC_PATH:-/data/lxc}/$CONTAINER/rootfs/etc/rspamd/local.d/options.inc"
            if [ -f "$opt" ]; then
                grep -q '^task_timeout' "$opt" || echo 'task_timeout = 150s;' >> "$opt"
            else
                warn "options.inc introuvable ($opt) — task_timeout non posé : un démarrage à froid du scanner serait coupé à 8 s"
            fi
            lxc_attach "$CONTAINER" sh -c 'rspamadm configtest >/dev/null' \
                && lxc_attach "$CONTAINER" systemctl restart rspamd
            log "antivirus activé (clamd : LXC clamav, éveillé à la demande)"
            ;;
        off)
            lxc_attach "$CONTAINER" rm -f /etc/rspamd/local.d/antivirus.conf 2>/dev/null || true
            lxc_attach "$CONTAINER" systemctl restart rspamd 2>/dev/null || true
            log "antivirus désactivé (défaut)"
            ;;
        status)
            if command -v clamavctl >/dev/null 2>&1; then
                clamavctl status
            else
                echo "secubox-clamav absent (apt install secubox-clamav)"
            fi
            lxc_attach "$CONTAINER" sh -c 'test -f /etc/rspamd/local.d/antivirus.conf && echo "module Rspamd : déposé" || echo "module Rspamd : absent"'
            ;;
        *)
            error "usage: mailctl antivirus on|off|status"
            return 1
            ;;
    esac
}

# ============================================================================
# Logs
# ============================================================================

cmd_logs() {
    local lines="${1:-50}"

    if lxc_running "$CONTAINER"; then
        lxc_attach "$CONTAINER" tail -n "$lines" /var/log/mail.log 2>/dev/null
    else
        error "Mail container not running"
    fi
}

# ============================================================================
# Help
# ============================================================================

show_help() {
    cat << EOF
SecuBox Mail Server v$VERSION
Three-fold architecture: Components, Status, Access

Usage: mailctl <command> [options]

Information (Three-fold):
  components           List system components (JSON)
  status               Show service status
  access               Show connection info (JSON)

Setup:
  install              Install mail server + webmail
  uninstall            Remove mail server
  migrate [host]       Migrate data from OpenWrt (default: 192.168.255.1)

Service:
  start                Start mail services
  stop                 Stop mail services
  restart              Restart mail services

Users:
  user add <email>     Add mail user
  user del <email>     Delete mail user
  user list            List all users
  user passwd <email>  Change password
  user-repair <email>  Repair user mailbox

Aliases:
  alias add <a> <t>    Add email alias
  alias del <alias>    Delete alias
  alias list           List aliases

Backup:
  backup                Create backup (vmail + config + dovecot.conf)
  restore <file>        Restore from backup + redémarre Dovecot
  maildir-reconcile     Bascule mbox → Maildir (idempotent)

SSL & DNS:
  ssl status           Show certificate info
  ssl setup            Obtain Let's Encrypt certificate
  ssl renew            Renew + redeploy + reload (also run weekly by timer)
  dns-setup            Show DNS records to configure

Antivirus (optionnel, off par défaut) :
  antivirus on          Activer le module Rspamd vers le LXC clamav (secubox-clamav)
  antivirus off         Désactiver le module Rspamd (n'installe rien)
  antivirus status      État du LXC clamav et du module Rspamd

Diagnostics:
  logs [lines]         View mail logs
  fix-ports            Check and fix listening ports

Examples:
  mailctl install
  mailctl components                 # List what's installed
  mailctl access                     # Show connection details
  mailctl user add admin@example.com
  mailctl migrate 192.168.255.1
  mailctl dns-setup                  # Show DNS records

EOF
}

# ============================================================================
# Phase 1 config migration (rev. 2)
# ============================================================================

cmd_migrate_config() {
    [ "$(id -u)" -eq 0 ] || { error "Root required"; return 1; }
    : "${LIB_DIR:=/usr/lib/secubox/mail/lib}"
    # Installed layout: /usr/lib/secubox/mail/lib/migrate.sh
    # In-tree layout:   packages/secubox-mail/lib/mail/migrate.sh
    if [ ! -f "$LIB_DIR/migrate.sh" ]; then
        local alt="$(dirname "$0")/../lib/mail"
        [ -f "$alt/migrate.sh" ] && LIB_DIR="$alt"
    fi
    # shellcheck source=/dev/null
    source "$LIB_DIR/migrate.sh"

    local cfg="${CONFIG_FILE:-/etc/secubox/mail.toml}"
    [ -f "$cfg" ] || { warn "no config to migrate at $cfg"; return 0; }

    if grep -q "^container *=" "$cfg" 2>/dev/null; then
        log "config already migrated (has 'container =' key)"
        return 0
    fi

    if ! detect_legacy_toml_keys "$cfg" >/dev/null; then
        log "no legacy keys to migrate"
        return 0
    fi

    local backup="${cfg}.pre-phase1.$(date +%s).bak"
    cp "$cfg" "$backup"
    log "backup written to $backup"

    python3 - "$cfg" <<'PY'
import sys, re
path = sys.argv[1]
src = open(path).read()
inject = (
    'container = "mail"\n'
    'lxc_ip = "10.100.0.10"\n'
    'lxc_bridge = "br-lxc"\n'
    'lxc_gateway = "10.100.0.1"\n'
    'data_path = "/data/volumes/mail"\n'
    'lxc_path = "/var/lib/lxc"\n'
)
# Comment out legacy keys FIRST so we don't end up with duplicates of
# data_path / lxc_path after injection.
for k in ("mail_container", "webmail_container", "mail_ip", "webmail_ip",
         "webmail_port", "data_path", "lxc_path"):
    src = re.sub(rf"^({k} *=.*)$", r"# DEPRECATED Phase 1: \1", src, flags=re.MULTILINE)
src = re.sub(r"(\[mail\]\n)", r"\1" + inject, src, count=1)
open(path, "w").write(src)
PY
    log "config migrated to single-container schema"
}

# ============================================================================
# Phase 2 — Rspamd subcommand
# ============================================================================

cmd_rspamd() {
    [ "$(id -u)" -eq 0 ] || { error "Root required"; return 1; }
    : "${LIB_DIR:=/usr/lib/secubox/mail/lib}"
    [ -f "$LIB_DIR/rspamd.sh" ] || LIB_DIR="$(dirname "$0")/../lib/mail"
    # shellcheck source=/dev/null
    source "$LIB_DIR/rspamd.sh"

    local sub="${1:-status}"
    shift || true
    case "$sub" in
        install)
            LXC_BASE="$LXC_PATH" install_rspamd "$CONTAINER"
            LXC_BASE="$LXC_PATH" configure_rspamd_milter "$CONTAINER"
            LXC_BASE="$LXC_PATH" configure_rspamd_controller "$CONTAINER"
            LXC_BASE="$LXC_PATH" configure_rspamd_dkim "$CONTAINER" "$DOMAIN" "default"
            LXC_BASE="$LXC_PATH" configure_rspamd_postfix_milter "$CONTAINER"
            log "Rspamd installed. Start with: mailctl rspamd start"
            ;;
        start|restart)
            lxc-attach -n "$CONTAINER" -- systemctl restart rspamd
            ;;
        stop)
            lxc-attach -n "$CONTAINER" -- systemctl stop rspamd
            ;;
        reload)
            lxc-attach -n "$CONTAINER" -- systemctl reload rspamd
            ;;
        status)
            lxc-attach -n "$CONTAINER" -- rspamc stat 2>&1 | head -40
            ;;
        milter)
            # Câble Rspamd en milter Postfix. Le proxy worker :11332 écoute déjà
            # (milter=yes, self_scan=yes) ; il manque juste smtpd_milters côté
            # Postfix + l'en-tête X-Spam-Status que le sieve par défaut consomme.
            # SURGICAL via `postconf -e` sur le VRAI /etc/postfix/main.cf du
            # conteneur (configure_rspamd_postfix_milter historique appendait au
            # mauvais fichier $DATA_PATH/config/main.cf ≠ /etc/postfix/main.cf,
            # d'où un milter jamais câblé malgré `rspamd install`).
            local _mh="${TEMPLATES_DIR:-/usr/lib/secubox/mail/templates}/rspamd/local.d/milter_headers.conf"
            if [ -f "$_mh" ]; then
                lxc-attach -n "$CONTAINER" -- tee /etc/rspamd/local.d/milter_headers.conf < "$_mh" >/dev/null
            else
                warn "template milter_headers.conf absent ($_mh) — X-Spam-Status non garanti"
            fi
            lxc-attach -n "$CONTAINER" -- postconf -e \
                "smtpd_milters=inet:127.0.0.1:11332" \
                "non_smtpd_milters=inet:127.0.0.1:11332" \
                "milter_default_action=accept" \
                "milter_protocol=6" \
                "milter_mail_macros=i {auth_authen} {auth_type} {client_addr} {client_name} {mail_addr}"
            lxc-attach -n "$CONTAINER" -- systemctl restart rspamd postfix
            local _m; _m="$(lxc-attach -n "$CONTAINER" -- postconf -h smtpd_milters 2>/dev/null)"
            if [ "$_m" = "inet:127.0.0.1:11332" ]; then
                log "Rspamd câblé en milter Postfix ($_m) — X-Spam-Status → sieve → Junk"
            else
                error "câblage milter échoué (smtpd_milters='$_m')"; return 1
            fi
            ;;
        dkim-keygen)
            local domain="${1:-$DOMAIN}"
            local sel="${2:-default}"
            # Delegate to the lib function — runs rspamadm inside the LXC and
            # resolves _rspamd uid via kernel idmap (image-agnostic).
            LXC_BASE="$LXC_PATH" DATA_PATH="$DATA_PATH" \
                rspamd_keygen "$CONTAINER" "$domain" "$sel"
            log "DKIM keypair generated for $domain (selector $sel)"
            ;;
        dns-records)
            local domain="${1:-$DOMAIN}"
            local sel="${2:-default}"
            cat "$DATA_PATH/rspamd/dkim/$domain/$sel.txt" 2>/dev/null \
                || error "no DNS record for $domain/$sel"
            ;;
        learn-spam)
            local target="${1:-}"
            [ -n "$target" ] || { echo "Usage: mailctl rspamd learn-spam <maildir-or-file>"; return 1; }
            lxc-attach -n "$CONTAINER" -- rspamc learn_spam "$target"
            ;;
        learn-ham)
            local target="${1:-}"
            [ -n "$target" ] || { echo "Usage: mailctl rspamd learn-ham <maildir-or-file>"; return 1; }
            lxc-attach -n "$CONTAINER" -- rspamc learn_ham "$target"
            ;;
        purge-legacy)
            LXC_BASE="$LXC_PATH" rspamd_purge_legacy "$CONTAINER"
            ;;
        *)
            cat <<USAGE
Usage:
  mailctl rspamd install
  mailctl rspamd start | stop | restart | reload
  mailctl rspamd status
  mailctl rspamd dkim-keygen [<domain>] [<selector>]
  mailctl rspamd dns-records [<domain>] [<selector>]
  mailctl rspamd learn-spam <maildir-or-file>
  mailctl rspamd learn-ham  <maildir-or-file>
  mailctl rspamd purge-legacy            # purge SA/OpenDKIM after Rspamd verified
USAGE
            ;;
    esac
}

# ============================================================================
# Main
# ============================================================================

case "${1:-}" in
    # Three-fold architecture
    components)  cmd_components ;;
    access)      cmd_access ;;
    status)      shift; cmd_status "$@" ;;
    # Setup
    install)     shift; cmd_install "$@" ;;
    uninstall)   shift; cmd_uninstall "$@" ;;
    migrate)     shift; cmd_migrate "$@" ;;
    migrate-config) shift; cmd_migrate_config "$@" ;;
    # Phase 2 — Rspamd
    rspamd)      shift; cmd_rspamd "$@" ;;
    # Service control
    start)       shift; cmd_start "$@" ;;
    stop)        shift; cmd_stop "$@" ;;
    restart)     shift; cmd_restart "$@" ;;
    # Users and aliases
    user)        shift; cmd_user "$@" ;;
    user-repair) shift; cmd_user_repair "$@" ;;
    alias)       shift; cmd_alias "$@" ;;
    # Backup
    backup)      shift; cmd_backup "$@" ;;
    restore)     shift; cmd_restore "$@" ;;
    maildir-reconcile) cmd_maildir_reconcile ;;
    sieve)       shift; cmd_sieve "$@" ;;
    # SSL, DNS, DKIM
    ssl)         shift; cmd_ssl "$@" ;;
    dkim)        shift; cmd_dkim "$@" ;;
    antivirus)   shift; cmd_antivirus "$@" ;;
    dns-setup)   cmd_dns_setup ;;
    # Diagnostics
    logs)        shift; cmd_logs "$@" ;;
    fix-ports)   cmd_fix_ports ;;
    webmail-config) cmd_webmail_config ;;
    webmail-greffons) cmd_webmail_greffons ;;
    webmail-sso) cmd_webmail_sso ;;
    webmail-sieve) cmd_webmail_sieve ;;
    webmail-openpgp) cmd_webmail_openpgp ;;
    autocrypt-sync) cmd_autocrypt_sync ;;
    webmail-pgp) cmd_webmail_pgp ;;
    antispam-apprentissage) cmd_antispam_apprentissage ;;
    redis)       cmd_redis ;;
    webmail)     cmd_webmail ;;
    sync)        cmd_sync ;;
    # Help
    help|--help|-h|'') show_help ;;
    *)           error "Unknown command: $1"; show_help >&2; exit 1 ;;
esac

# Le code de la commande, pas 0 d'office : `install` qui échouait rendait
# « réussi » (#1729).
exit $?
