Files
deploy-email-plesk-kerio-ne…/deploy.py
T
duffyduck 06d7e00e49 fix(kerio): korrekte Admin-API gemäß Delivery.idl + Pop3Account-Doku
- Methoden: Delivery.getPop3AccountList / addPop3AccountList /
  setPop3Account (vorher geraten als Pop3Accounts.set/.create →
  Method not found).
- Pop3Account-Felder mit den richtigen Namen: isActive (statt enabled),
  mode (statt sslMode), authentication (statt authType), und
  leaveOnServer.removeAfterPeriod als OptionalLong-Wrapper.
  Falsche Namen wurden von Kerio still ignoriert → Sammler war inaktiv.
- User-Struct: allowPasswordChange=false (statt mayChangePassword,
  das es nicht gibt). emailAddresses weggelassen, Kerio leitet die
  primäre Adresse aus loginName+domain ab.
- Kerio-Step in 2 Sub-Steps aufgeteilt: User (skip wenn vorhanden) +
  POP3 (upsert). Damit wird bei einem zweiten Lauf der Sammler nicht
  übersprungen, nur weil der User schon existiert.
- POP3-Sammler ist jetzt UPSERT: existierende werden via setPop3Account
  überschrieben → Selbstreparatur kaputter Einträge + Passwort-
  Änderungen aus der CSV ziehen sich von selbst nach.

GUI: 👁/🙈-Toggle pro Passwort-Feld (Klartext temporär einsehbar).

Filenames der Sammel-PDFs + Admin-Report ohne Zeitstempel –
erneuter Lauf überschreibt statt anzuhäufen.

README: Ablauf-Sektion + Idempotenz-Tabelle aktualisiert; Kerio-
Caveat ersetzt durch konkrete Methoden-/Feld-Liste mit Doku-Link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:09:06 +02:00

324 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""CLI / Orchestrierung: Plesk-Mail → Kerio-User+POP3-Sammler → Nextcloud-User → PDF."""
import argparse
import csv
import sys
from datetime import datetime
from pathlib import Path
from typing import Callable, List, Tuple
from config import Config
from models import Account, AccountResult, StepResult
from pdf import (
write_combined_minimal_pdf, write_combined_pdf,
write_user_minimal_pdf, write_user_pdf,
)
def _detect_delimiter(path: Path) -> str:
with open(path, "r", encoding="utf-8-sig") as f:
sample = f.read(4096)
if sample.count(";") > sample.count(","):
return ";"
return ","
def parse_csv(path) -> List[Account]:
path = Path(path)
delim = _detect_delimiter(path)
accounts: List[Account] = []
with open(path, "r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f, delimiter=delim)
for i, raw in enumerate(reader, 2): # row 1 = header
row = {(k or "").strip().lower(): (v or "").strip() for k, v in raw.items()}
try:
quota_raw = row.get("nextcloudspeicher", "")
quota = int(quota_raw) if quota_raw else None
accounts.append(Account(
name=row["name"],
vorname=row["vorname"],
emailadresse=row["emailadresse"],
pleskhost=row["pleskhost"],
keriohost=row["keriohost"],
nextcloudhost=row["nextcloudhost"],
kerioemailkennwort=row["kerioemailkennwort"],
pleskemailkennwort=row["pleskemailkennwort"],
nextcloudgruppe=row.get("nextcloudgruppe", ""),
nextcloudspeicher=quota,
nextcloudkennwort=row["nextcloudkennwort"],
))
except KeyError as e:
raise ValueError(f"CSV Zeile {i}: Spalte fehlt → {e}") from e
except ValueError as e:
raise ValueError(f"CSV Zeile {i}: ungültiger Wert → {e}") from e
return accounts
def _make_plesk_client(account: Account, cfg: Config):
"""Liefert (client, error_class). client=None → Manual-Modus."""
backend = cfg.PLESK_BACKEND
if backend == "manual":
return None, None
if backend == "ssh":
from clients.plesk_ssh import PleskSshClient, PleskSshError
return PleskSshClient(
host=account.pleskhost,
ssh_port=cfg.PLESK_SSH_PORT,
ssh_user=cfg.PLESK_SSH_USER,
ssh_password=cfg.PLESK_SSH_PASSWORD or None,
ssh_key=cfg.PLESK_SSH_KEY or None,
ssh_key_passphrase=cfg.PLESK_SSH_KEY_PASSPHRASE or None,
use_sudo=cfg.PLESK_SSH_USE_SUDO,
), PleskSshError
if backend == "api":
from clients.plesk import PleskClient, PleskError
return PleskClient(
host=account.pleskhost,
api_key=cfg.PLESK_API_KEY or None,
user=cfg.PLESK_USER or None,
password=cfg.PLESK_PASSWORD or None,
port=cfg.PLESK_PORT,
verify=cfg.VERIFY_TLS,
), PleskError
raise ValueError(f"Unbekanntes PLESK_BACKEND={backend!r} (erlaubt: manual|api|ssh)")
def _deploy_one(account: Account, cfg: Config, log: Callable[[str], None]) -> AccountResult:
# Importe lokal, damit die GUI ohne Netzwerk-Modulladen startet.
from clients.kerio import KerioClient, KerioError
from clients.nextcloud import NextcloudClient, NextcloudError
result = AccountResult(account=account)
# 1) Plesk
if cfg.PLESK_BACKEND == "manual":
log(f" → Plesk: MANUELL anzulegen ({account.emailadresse} @ {account.pleskhost})")
result.steps.append(StepResult(
"Plesk", "manuell",
f"Bitte im Plesk-Webinterface anlegen: {account.emailadresse}, "
f"Passwort wie unten Tool fährt mit Kerio/Nextcloud fort.",
))
else:
log(f" → Plesk Mail anlegen ({cfg.PLESK_BACKEND}): "
f"{account.emailadresse} @ {account.pleskhost}")
try:
plesk, plesk_error_cls = _make_plesk_client(account, cfg)
try:
if plesk.mail_exists(account.emailadresse):
result.steps.append(StepResult(
"Plesk", "übersprungen", "Mail existiert bereits"))
log(" · existiert bereits, übersprungen")
else:
plesk.create_mail(account.emailadresse, account.pleskemailkennwort)
result.steps.append(StepResult("Plesk", "angelegt"))
log(" ✓ angelegt")
finally:
close = getattr(plesk, "close", None)
if callable(close):
close()
except Exception as e:
result.steps.append(StepResult("Plesk", "Fehler", str(e)))
log(f" ✗ FEHLER: {e}")
# Wir machen mit Kerio/Nextcloud trotzdem weiter die sind unabhängig.
# 2) Kerio User und POP3-Sammler werden GETRENNT geprüft, damit ein
# bereits angelegter User nicht den Sammler-Schritt überspringt.
log(f" → Kerio User+POP3-Sammler: {account.emailadresse} @ {account.keriohost}")
try:
kerio = KerioClient(
host=account.keriohost,
user=cfg.KERIO_USER, password=cfg.KERIO_PASSWORD,
port=cfg.KERIO_PORT, verify=cfg.VERIFY_TLS,
)
try:
# 2a) User
uid = kerio.find_user_id(account.emailadresse)
if uid:
result.steps.append(StepResult(
"Kerio User", "übersprungen", "existiert bereits"))
log(" · User existiert bereits")
else:
uid = kerio.create_user(
account.emailadresse,
account.kerioemailkennwort,
account.vollname,
)
result.steps.append(StepResult("Kerio User", "angelegt"))
log(" ✓ User angelegt")
# 2b) POP3-Sammler upsert (anlegen wenn neu, sonst aktualisieren).
# So werden auch alte / kaputt konfigurierte Sammler beim
# nächsten Lauf automatisch repariert.
pop_status = kerio.upsert_pop3_collection(
deliver_to_email=account.emailadresse,
server=account.pleskhost,
login_name=account.emailadresse,
password=account.pleskemailkennwort,
port=cfg.POP3_PORT, ssl=cfg.POP3_SSL,
leave_days=cfg.POP3_KEEP_DAYS,
)
result.steps.append(StepResult("Kerio POP3", pop_status))
log(f" ✓ POP3-Sammler {pop_status}")
finally:
kerio.logout()
except Exception as e:
result.steps.append(StepResult("Kerio", "Fehler", str(e)))
log(f" ✗ FEHLER: {e}")
# 3) Nextcloud
log(f" → Nextcloud User: {account.nextcloud_username} @ {account.nextcloudhost}")
try:
nc = NextcloudClient(
host=account.nextcloudhost,
admin_user=cfg.NEXTCLOUD_USER,
admin_password=cfg.NEXTCLOUD_PASSWORD,
verify=cfg.VERIFY_TLS,
)
if nc.user_exists(account.nextcloud_username):
result.steps.append(StepResult("Nextcloud", "übersprungen", "User existiert bereits"))
log(" · existiert bereits, übersprungen")
else:
if account.nextcloudgruppe:
nc.ensure_group(account.nextcloudgruppe)
nc.create_user(
userid=account.nextcloud_username,
password=account.nextcloudkennwort,
email=account.emailadresse,
display_name=account.vollname,
group=account.nextcloudgruppe or None,
quota_gb=account.nextcloudspeicher,
)
result.steps.append(StepResult("Nextcloud", "angelegt"))
log(" ✓ angelegt")
except Exception as e:
result.steps.append(StepResult("Nextcloud", "Fehler", str(e)))
log(f" ✗ FEHLER: {e}")
return result
def _write_admin_report(path: Path, results: List[AccountResult],
cfg: Config, timestamp: str) -> None:
lines = [
f"Deploy-Report {timestamp}",
f"Plesk-Backend: {cfg.PLESK_BACKEND}",
"=" * 70,
"",
]
for r in results:
a = r.account
lines.append(f"{a.vollname} <{a.emailadresse}>")
for s in r.steps:
marker = {"angelegt": "", "übersprungen": "·",
"manuell": "", "Fehler": ""}.get(s.status, "?")
detail = f" {s.detail}" if s.detail else ""
lines.append(f" {marker} {s.service:10s} {s.status}{detail}")
lines.append("")
if cfg.PLESK_BACKEND == "manual":
lines += [
"=" * 70,
"PLESK MANUELL ANZULEGEN (Shared-Host kein API/SSH):",
" → im Plesk-Webinterface jede Mailadresse oben mit dem",
" in der CSV vergebenen Plesk-Mail-Passwort einrichten,",
" sonst kann der Kerio-POP3-Sammler die Mails nicht abholen.",
]
path.write_text("\n".join(lines), encoding="utf-8")
def pdf_only(accounts: List[Account], cfg: Config,
output_dir, log: Callable[[str], None] = print) -> Path:
"""Nur PDFs schreiben KEIN Plesk/Kerio/Nextcloud-Aufruf.
Praktisch wenn die Konten schon angelegt sind oder die CSV nochmal
durchgejagt werden soll, um die PDFs neu zu generieren.
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
for i, acc in enumerate(accounts, 1):
log(f"[{i}/{len(accounts)}] {acc.vollname} <{acc.emailadresse}>")
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in acc.emailadresse)
per_pdf = output_dir / f"zugangsdaten_{safe}.pdf"
per_min = output_dir / f"zugangsdaten_minimal_{safe}.pdf"
write_user_pdf(per_pdf, acc, cfg.SMTP_PORT, cfg.POP3_PORT, cfg.POP3_SSL)
write_user_minimal_pdf(per_min, acc, cfg.SMTP_PORT, cfg.IMAP_PORT)
log(f" ⤷ PDF: {per_pdf}")
log(f" ⤷ PDF (minimal): {per_min}")
combined = output_dir / "zugangsdaten_gesamt.pdf"
combined_min = output_dir / "zugangsdaten_gesamt_minimal.pdf"
write_combined_pdf(combined, accounts, cfg.SMTP_PORT, cfg.POP3_PORT, cfg.POP3_SSL)
write_combined_minimal_pdf(combined_min, accounts, cfg.SMTP_PORT, cfg.IMAP_PORT)
log(f"✓ Gesamt-PDF: {combined}")
log(f"✓ Gesamt-PDF (minimal): {combined_min}")
return combined
def run_deploy(accounts: List[Account], cfg: Config,
output_dir, log: Callable[[str], None] = print
) -> Tuple[List[AccountResult], Path]:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
results: List[AccountResult] = []
for i, acc in enumerate(accounts, 1):
log(f"[{i}/{len(accounts)}] {acc.vollname} <{acc.emailadresse}>")
result = _deploy_one(acc, cfg, log)
results.append(result)
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in acc.emailadresse)
per_pdf = output_dir / f"zugangsdaten_{safe}.pdf"
per_min = output_dir / f"zugangsdaten_minimal_{safe}.pdf"
write_user_pdf(per_pdf, acc, cfg.SMTP_PORT, cfg.POP3_PORT, cfg.POP3_SSL)
write_user_minimal_pdf(per_min, acc, cfg.SMTP_PORT, cfg.IMAP_PORT)
log(f" ⤷ PDF: {per_pdf}")
log(f" ⤷ PDF (minimal): {per_min}")
combined = output_dir / "zugangsdaten_gesamt.pdf"
combined_min = output_dir / "zugangsdaten_gesamt_minimal.pdf"
write_combined_pdf(combined, accounts, cfg.SMTP_PORT, cfg.POP3_PORT, cfg.POP3_SSL)
write_combined_minimal_pdf(combined_min, accounts, cfg.SMTP_PORT, cfg.IMAP_PORT)
log(f"✓ Gesamt-PDF: {combined}")
log(f"✓ Gesamt-PDF (minimal): {combined_min}")
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
report = output_dir / "_admin_report.txt"
_write_admin_report(report, results, cfg, timestamp)
log(f"✓ Admin-Report (nicht für Kunden!): {report}")
return results, combined
def main():
p = argparse.ArgumentParser(
description="Email/Cloud-Accounts auf Plesk + Kerio + Nextcloud deployen.",
)
p.add_argument("--csv", help="Pfad zur CSV-Datei (CLI-Modus)")
p.add_argument("--output", default="./output", help="Verzeichnis für PDFs (default ./output)")
p.add_argument("--gui", action="store_true", help="GUI starten")
p.add_argument("--pdf-only", action="store_true",
help="Nur PDFs aus der CSV erzeugen, keine Anlage in Plesk/Kerio/Nextcloud")
args = p.parse_args()
if args.gui or not args.csv:
from gui import launch
launch()
return
cfg = Config()
try:
accounts = parse_csv(args.csv)
except (FileNotFoundError, ValueError) as e:
print(f"CSV-Fehler: {e}", file=sys.stderr)
sys.exit(2)
print(f"{len(accounts)} Account(s) eingelesen.\n")
if args.pdf_only:
combined = pdf_only(accounts, cfg, args.output)
print(f"\nFertig (nur PDF). Gesamt-PDF: {combined}")
sys.exit(0)
results, combined = run_deploy(accounts, cfg, args.output)
failed = sum(1 for r in results if r.has_errors)
print(f"\nFertig. {len(results) - failed}/{len(results)} ohne Fehler. Gesamt-PDF: {combined}")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()