diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7f92260 --- /dev/null +++ b/.env.example @@ -0,0 +1,42 @@ +# ===================================================================== +# Plesk-Backend wählen: +# manual – Tool legt nichts an. Mailpostfach wird vom Admin im +# Plesk-Webinterface angelegt. Tool druckt im PDF die +# Daten aus, die einzutragen sind. <-- Shared-Host-Fall +# api – Plesk REST-API (PLESK_API_KEY oder PLESK_USER+_PASSWORD) +# ssh – SSH zum Plesk-Server, ruft `plesk bin mail` auf +# ===================================================================== +PLESK_BACKEND=manual + +# --- nur für PLESK_BACKEND=api --- +PLESK_API_KEY= +PLESK_USER= +PLESK_PASSWORD= +PLESK_PORT=8443 + +# --- nur für PLESK_BACKEND=ssh --- +PLESK_SSH_PORT=22 +PLESK_SSH_USER= +PLESK_SSH_PASSWORD= +PLESK_SSH_KEY= +PLESK_SSH_KEY_PASSPHRASE= +# true, falls der SSH-User `plesk bin` nur mit `sudo` ausführen darf +PLESK_SSH_USE_SUDO=false + +# --- Kerio Connect (Admin) --- +KERIO_ADMIN_USER=Admin +KERIO_ADMIN_PASSWORD= +KERIO_ADMIN_PORT=4040 + +# --- Nextcloud (Admin) --- +# App-Passwort empfohlen (Settings → Security → App passwords). +NEXTCLOUD_ADMIN_USER=admin +NEXTCLOUD_ADMIN_PASSWORD= + +# --- Mailserver-Ports für POP3-Sammler in Kerio --- +POP3_PORT=995 +POP3_KEEP_DAYS=14 +SMTP_PORT=465 + +# TLS-Zertifikate prüfen? false nur bei selbstsignierten Test-Zertifikaten. +VERIFY_TLS=true diff --git a/README.md b/README.md new file mode 100644 index 0000000..729eead --- /dev/null +++ b/README.md @@ -0,0 +1,211 @@ +# deploy-email-plesk-kerio-nextcloud + +Tool, um neue Mitarbeiter:innen-Konten in **einem** Rutsch über drei Systeme +auszurollen und am Ende eine PDF mit den Zugangsdaten zu erzeugen: + +1. **Plesk** – Mailpostfach anlegen (nur Mailserver-Login, keine Plesk-GUI). +2. **Kerio Connect** – Benutzer anlegen + POP3-Sammler einrichten, der die + Mails alle paar Minuten vom Plesk-Server abholt + (14 Tage auf dem Server belassen, SSL). +3. **Nextcloud** – Benutzer (Username = `vorname.nachname`, lowercase, + Umlaute transliteriert) mit Gruppe und Speicherquota anlegen. +4. **PDF** – pro Benutzer eine PDF + eine Sammel-PDF mit allen Zugangsdaten. + +CLI **und** Tkinter-GUI mit „+ Zeile"-Endlosfeldern. + +--- + +## Installation + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +$EDITOR .env +``` + +Tkinter ist Teil der Python-Standardbibliothek, unter Debian/Ubuntu ggf. +`sudo apt install python3-tk`. + +## Konfiguration (`.env`) + +### Plesk-Backend wählen (`PLESK_BACKEND`) + +Dreistufige Wahl, je nachdem welchen Zugriff du auf das Plesk hast: + +| Backend | Wann? | Was passiert | +| -------- | ---------------------------------------------------------- | ------------------------------------------------------------------------- | +| `manual` | **Shared-Host beim Kunden** – kein API, kein SSH (Default) | Tool legt nichts in Plesk an. Im Webinterface manuell anlegen, dann Tool laufen lassen (oder andersrum – Kerio-POP3-Sammler funktioniert erst, wenn das Postfach existiert). | +| `api` | Eigener/dedizierter Plesk mit REST-API | Anlegen via `/api/v2/cli/mail/call` (entspricht `plesk bin mail`). Auth über `PLESK_API_KEY` (bevorzugt) oder `PLESK_USER` / `PLESK_PASSWORD`. | +| `ssh` | Eigener Plesk, API blockiert, aber SSH offen | `paramiko` öffnet SSH und ruft `plesk bin mail` direkt. Auth über Key (`PLESK_SSH_KEY`) oder Passwort (`PLESK_SSH_PASSWORD`). Bei Bedarf `PLESK_SSH_USE_SUDO=true`. | + +> SSH-Backend braucht zusätzlich `paramiko`: `pip install paramiko`. +> Für `manual` reicht das Standard-`requirements.txt`. + +### Restliche Variablen + +| Variable | Beschreibung | +| -------------------------- | ------------------------------------------------------------------- | +| `KERIO_ADMIN_USER` | i.d.R. `Admin` | +| `KERIO_ADMIN_PASSWORD` | Adminpasswort | +| `KERIO_ADMIN_PORT` | Standard `4040` | +| `NEXTCLOUD_ADMIN_USER` | Admin-User (App-Passwort empfohlen) | +| `NEXTCLOUD_ADMIN_PASSWORD` | Admin- oder App-Passwort | +| `POP3_PORT` | POP3-Sammler in Kerio (default `995` = POP3S) | +| `POP3_KEEP_DAYS` | Tage, die Mails auf Plesk verbleiben (default `14`) | +| `SMTP_PORT` | Nur für die PDF-Anzeige (default `465`) | +| `VERIFY_TLS` | `false` nur bei Test/selbst signierten Zertifikaten | + +--- + +## Plesk-API-Key per SSH erzeugen (nur für `PLESK_BACKEND=api`) + +Plesk lässt das Anlegen von API-Keys **nicht** in der Web-GUI zu. +Per SSH auf dem Plesk-Host: + +```bash +# Login als root oder Plesk-Admin +plesk bin secret_key --create -ip-address 0.0.0.0/0 -description "deploy-tool" +# Ausgabe-Beispiel: +# API key was successfully generated. Key: +# 1a2b3c4d-1234-5678-9abc-deadbeef0001 +``` + +Den Key in `.env` als `PLESK_API_KEY=…` eintragen. +Tipp: `-ip-address` möglichst auf die IP einschränken, von der das Tool +ausgeführt wird (z.B. `-ip-address 192.0.2.10`). + +Vorhandene Keys listen / löschen: + +```bash +plesk bin secret_key --list +plesk bin secret_key --delete +``` + +--- + +## CSV-Format + +UTF-8, Trennzeichen `;` *oder* `,` (wird automatisch erkannt). Kopfzeile +case-insensitive. Beispiel: [`example.csv`](./example.csv). + +| Spalte | Pflicht | Bedeutung | +| --------------------- | :-----: | ------------------------------------------------------------------ | +| `Vorname` | ja | Vorname | +| `Name` | ja | Nachname | +| `emailadresse` | ja | volle Mailadresse, ist Login bei Plesk + Kerio | +| `pleskhost` | ja | Hostname Plesk-Mailserver (auch POP3-Server für Kerio-Sammler) | +| `keriohost` | ja | Hostname Kerio Connect (Admin-API auf Port 4040, Webmail auf 443) | +| `nextcloudhost` | ja | Nextcloud-Hostname | +| `kerioemailkennwort` | ja | Passwort für den Kerio-User | +| `pleskemailkennwort` | ja | Passwort für das Plesk-Mailpostfach | +| `nextcloudgruppe` | nein | Gruppe in Nextcloud (wird angelegt falls nicht vorhanden) | +| `nextcloudspeicher` | nein | Speicher in **GB**. Leer = unlimitiert | +| `nextcloudkennwort` | ja | Passwort für den Nextcloud-User | + +> Der **Nextcloud-Username** wird aus Vor-/Nachname abgeleitet +> (`vorname.nachname`, lowercase, ä→ae, ö→oe, ü→ue, ß→ss). Die Emailadresse +> aus der CSV wird im Nextcloud-Profil als E-Mail eingetragen. + +--- + +## Aufruf + +### CLI + +```bash +python deploy.py --csv example.csv --output ./output +``` + +Exit-Code `0` wenn alle Konten ohne Fehler verarbeitet wurden, sonst `1`. + +### GUI + +```bash +python deploy.py --gui +# oder einfach +python deploy.py +``` + +In der GUI: +- Per **+ Zeile** beliebig viele Konten hinzufügen. +- **CSV laden** füllt die Felder aus einer bestehenden CSV. +- **Ausgabeordner** wählen, dann **Ausführen ▶**. +- Log unten zeigt Fortschritt; bei jedem Konto entsteht eine + Einzel-PDF, am Ende eine Sammel-PDF mit Zeitstempel. + +--- + +## Ablauf je Konto + +``` +Plesk: Mailpostfach anlegen + - PLESK_BACKEND=manual → übersprungen (manuell in Plesk-GUI anlegen) + - PLESK_BACKEND=api/ssh → automatisch + ↓ +Kerio: User anlegen + POP3-Sammler eintragen, der vom Plesk-Host abholt + (Port aus POP3_PORT=995, SSL, 14 Tage auf Server belassen). + Kennwort-ändern ist für den User gesperrt (mayChangePassword=False). + ↓ +Nextcloud: User vorname.nachname mit Gruppe + Quota anlegen + ↓ +PDF (einzeln) schreiben +``` + +Am Ende: +- eine **Einzel-PDF** pro Konto + eine **Sammel-PDF** – beide enthalten + ausschließlich Zugangsdaten und sind 1:1 an den Kunden weitergebbar. +- ein **`_admin_report_.txt`** mit Status pro Konto + (✓ angelegt / · übersprungen / ⚠ manuell / ✗ Fehler) – **NICHT für + den Kunden**, das ist deine Admin-Übersicht. + +Bestehende Konten werden **übersprungen** (kein Fehler) und im +Admin-Report entsprechend markiert. Damit kann eine CSV gefahrlos +zweimal laufen. + +## Workflow bei `PLESK_BACKEND=manual` (Shared Host) + +1. CSV vorbereiten / in der GUI eintippen. +2. Tool laufen lassen → Kerio-User + POP3-Sammler + Nextcloud-User werden + angelegt, PDFs geschrieben, Admin-Report aufgelistet welche Mailpostfächer + manuell anzulegen sind. +3. Im Plesk-Webinterface des Kunden für jeden Eintrag die Mailadresse mit + exakt dem in der CSV vergebenen Plesk-Mail-Passwort einrichten. +4. Sobald das Postfach existiert, holt der Kerio-Sammler automatisch + eingehende Mails ab. + +--- + +## Hinweise / Caveats + +- **Kerio API-Methoden**: die JSON-RPC-Methodennamen für POP3-Sammler + (`Pop3Accounts.create`) entsprechen Kerio Connect 9.x. Falls deine + Version andere Methoden nutzt, ist die Originalmeldung von Kerio in + der Fehlerausgabe (mit Methodenname) sichtbar – dann in + [`clients/kerio.py`](clients/kerio.py) anpassen. +- **Plesk REST-CLI-Wrapper**: nutzt `/api/v2/cli/mail/call` (entspricht + `plesk bin mail`). Verfügbar ab Plesk Obsidian. +- **Nextcloud OCS**: die Admin-Credentials brauchen das Recht „Benutzer + verwalten". App-Passwort empfohlen, damit der Account nicht 2FA-geschützt + bleibt. +- **TLS**: `VERIFY_TLS=false` nur in Testumgebungen. Self-signed + Zertifikate gehören in einen lokalen Trust-Store, nicht ignoriert. +- **Passwörter** stehen sowohl in der CSV als auch in den PDFs im Klartext. + CSV nach erfolgreichem Lauf löschen, PDFs verschlüsselt versenden. + +## Dateien + +``` +deploy.py CLI-Entry + Orchestrierung +gui.py Tkinter-GUI mit Endlosfeldern +config.py .env-Loader +models.py Account / Result Datentypen +pdf.py ReportLab PDF-Erzeugung (kunden-tauglich) +clients/plesk.py Plesk REST-CLI-Wrapper (PLESK_BACKEND=api) +clients/plesk_ssh.py Plesk SSH-Wrapper, paramiko (PLESK_BACKEND=ssh) +clients/kerio.py Kerio JSON-RPC Admin-Client +clients/nextcloud.py Nextcloud OCS-Client +example.csv Beispiel-Eingabedatei +.env.example Beispiel-Konfiguration +``` diff --git a/clients/__init__.py b/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clients/kerio.py b/clients/kerio.py new file mode 100644 index 0000000..14b95ca --- /dev/null +++ b/clients/kerio.py @@ -0,0 +1,181 @@ +"""Kerio Connect Admin API Client (JSON-RPC). + +Endpoint: POST https://:4040/admin/api/jsonrpc/ +Login liefert ein Token, das via X-Token-Header bei jedem Folgeaufruf gesetzt wird. + +Hinweis: die exakten Methodennamen (Pop3Accounts.create vs RemotePop3.set etc.) +können sich zwischen Kerio Connect Versionen leicht unterscheiden. Die hier +verwendeten Namen entsprechen Kerio Connect 9.x. Bei Fehlern wirft der Client +die Originalmeldung mit Methodenname – so siehst du sofort, was anzupassen ist. +""" +from typing import Optional +import requests +import urllib3 + + +class KerioError(Exception): + pass + + +class KerioClient: + def __init__(self, host: str, *, user: str, password: str, + port: int = 4040, verify: bool = True): + if not host: + raise KerioError("Kerio-Host ist leer") + self.base = f"https://{host}:{port}" + self.session = requests.Session() + self.session.verify = verify + if not verify: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self.token: Optional[str] = None + self._req_id = 0 + self._login(user, password) + + def _next_id(self) -> int: + self._req_id += 1 + return self._req_id + + def _call(self, method: str, params: Optional[dict] = None) -> dict: + payload = { + "jsonrpc": "2.0", + "id": self._next_id(), + "method": method, + "params": params or {}, + } + headers = {"Content-Type": "application/json"} + if self.token: + headers["X-Token"] = self.token + try: + r = self.session.post( + f"{self.base}/admin/api/jsonrpc/", + json=payload, headers=headers, timeout=30, + ) + except requests.RequestException as e: + raise KerioError(f"Kerio Verbindung fehlgeschlagen: {e}") + if not r.ok: + raise KerioError(f"Kerio HTTP {r.status_code} bei {method}: {r.text[:300]}") + try: + data = r.json() + except ValueError: + raise KerioError(f"Kerio: ungültige Antwort: {r.text[:300]}") + if "error" in data: + err = data["error"] + raise KerioError( + f"Kerio {method} → {err.get('code')} {err.get('message')} " + f"({err.get('data', {})})" + ) + return data.get("result") or {} + + def _login(self, user: str, password: str) -> None: + result = self._call("Session.login", { + "userName": user, + "password": password, + "application": { + "name": "deploy-email-plesk-kerio-nextcloud", + "vendor": "intern", + "version": "1.0", + }, + }) + self.token = result.get("token") + if not self.token: + raise KerioError("Kerio: Login lieferte kein Token") + + def logout(self) -> None: + try: + self._call("Session.logout") + except Exception: + pass + + # ----- Domains / Users ----- + + def get_domain_id(self, domain_name: str) -> str: + result = self._call("Domains.get", { + "query": { + "fields": ["id", "name"], + "conditions": [{ + "fieldName": "name", + "comparator": "Eq", + "value": domain_name, + }], + "combining": "And", + "start": 0, + "limit": 50, + "orderBy": [], + }, + }) + items = result.get("list", []) + if not items: + raise KerioError(f"Kerio: Domain '{domain_name}' nicht in Kerio konfiguriert") + return items[0]["id"] + + def user_exists(self, email: str) -> bool: + local, domain = email.split("@", 1) + try: + domain_id = self.get_domain_id(domain) + except KerioError: + return False + result = self._call("Users.get", { + "query": { + "fields": ["id", "loginName"], + "conditions": [{ + "fieldName": "loginName", + "comparator": "Eq", + "value": local, + }], + "combining": "And", + "start": 0, + "limit": 5, + "orderBy": [], + }, + "domainId": domain_id, + }) + return bool(result.get("list")) + + def create_user(self, email: str, password: str, full_name: str) -> str: + local, domain = email.split("@", 1) + domain_id = self.get_domain_id(domain) + user_def = { + "loginName": local, + "fullName": full_name, + "domainId": domain_id, + "password": password, + "authType": "Internal", + "isEnabled": True, + "role": "UserRole", + "emailAddresses": [email], + # User darf sein Passwort NICHT selbst ändern + "mayChangePassword": False, + "forceChangePassword": False, + } + result = self._call("Users.create", {"users": [user_def]}) + errors = result.get("errors") or [] + if errors: + raise KerioError(f"Kerio Users.create errors: {errors}") + items = result.get("result") or [] + if not items: + raise KerioError("Kerio Users.create lieferte keinen Datensatz zurück") + return items[0].get("id") + + # ----- POP3 Sammler ----- + + def add_pop3_collection(self, *, kerio_user_id: str, + server: str, login_name: str, password: str, + port: int = 465, ssl: bool = True, + leave_days: int = 14) -> None: + account = { + "enabled": True, + "deliverTo": kerio_user_id, + "server": server, + "loginName": login_name, + "password": password, + "port": port, + "ssl": ssl, + "useSpecificPort": True, + "leaveOnServer": True, + "deleteOnServer": True, + "deleteOnServerDays": leave_days, + } + result = self._call("Pop3Accounts.create", {"accounts": [account]}) + errors = result.get("errors") or [] + if errors: + raise KerioError(f"Kerio Pop3Accounts.create errors: {errors}") diff --git a/clients/nextcloud.py b/clients/nextcloud.py new file mode 100644 index 0000000..cc5d545 --- /dev/null +++ b/clients/nextcloud.py @@ -0,0 +1,78 @@ +"""Nextcloud Provisioning API (OCS) Client.""" +from typing import Optional +import requests +import urllib3 + + +class NextcloudError(Exception): + pass + + +class NextcloudClient: + def __init__(self, host: str, *, admin_user: str, admin_password: str, + verify: bool = True, scheme: str = "https"): + if not host: + raise NextcloudError("Nextcloud-Host ist leer") + self.base = f"{scheme}://{host}" + self.session = requests.Session() + self.session.verify = verify + if not verify: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self.session.auth = (admin_user, admin_password) + self.session.headers["OCS-APIRequest"] = "true" + self.session.headers["Accept"] = "application/json" + + def _meta(self, r: requests.Response, op: str) -> dict: + if not r.ok and r.status_code not in (400, 401, 403, 404): + raise NextcloudError(f"Nextcloud {op} HTTP {r.status_code}: {r.text[:300]}") + try: + data = r.json() + except ValueError: + raise NextcloudError(f"Nextcloud {op}: ungültige Antwort: {r.text[:300]}") + return data.get("ocs", {}) + + def user_exists(self, userid: str) -> bool: + r = self.session.get(f"{self.base}/ocs/v2.php/cloud/users/{userid}") + ocs = self._meta(r, "user-lookup") + sc = ocs.get("meta", {}).get("statuscode") + return sc in (100, 200) + + def ensure_group(self, group: str) -> None: + if not group: + return + r = self.session.post( + f"{self.base}/ocs/v2.php/cloud/groups", + data={"groupid": group}, + ) + ocs = self._meta(r, "group-create") + sc = ocs.get("meta", {}).get("statuscode") + # 100/200 = ok, 102 = exists already + if sc not in (100, 200, 102): + raise NextcloudError( + f"Nextcloud Gruppe '{group}': {sc} {ocs.get('meta', {}).get('message')}" + ) + + def create_user(self, *, userid: str, password: str, + email: Optional[str] = None, + display_name: Optional[str] = None, + group: Optional[str] = None, + quota_gb: Optional[int] = None) -> None: + body = [("userid", userid), ("password", password)] + if email: + body.append(("email", email)) + if display_name: + body.append(("displayName", display_name)) + if group: + body.append(("groups[]", group)) + body.append(("quota", f"{quota_gb} GB" if quota_gb else "none")) + + r = self.session.post( + f"{self.base}/ocs/v2.php/cloud/users", + data=body, + ) + ocs = self._meta(r, "user-create") + sc = ocs.get("meta", {}).get("statuscode") + if sc not in (100, 200): + raise NextcloudError( + f"Nextcloud user-create: {sc} {ocs.get('meta', {}).get('message')}" + ) diff --git a/clients/plesk.py b/clients/plesk.py new file mode 100644 index 0000000..aff263c --- /dev/null +++ b/clients/plesk.py @@ -0,0 +1,81 @@ +"""Plesk REST API Client. + +Nutzt den CLI-Wrapper /api/v2/cli/{utility}/call. Vorteile: +- mappt 1:1 auf `plesk bin mail` +- stabil über Plesk-Versionen hinweg +- Fehlertexte sind die gleichen wie auf der Shell. +""" +from typing import Optional +import requests +import urllib3 + + +class PleskError(Exception): + pass + + +class PleskClient: + def __init__(self, host: str, *, api_key: Optional[str] = None, + user: Optional[str] = None, password: Optional[str] = None, + port: int = 8443, verify: bool = True): + if not host: + raise PleskError("Plesk-Host ist leer") + self.base = f"https://{host}:{port}" + self.session = requests.Session() + self.session.verify = verify + if not verify: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + if api_key: + self.session.headers["X-API-Key"] = api_key + elif user and password: + self.session.auth = (user, password) + else: + raise PleskError( + "Plesk: Weder API-Key noch User/Passwort gesetzt. " + "Siehe README → Plesk-API-Key per SSH erzeugen." + ) + self.session.headers["Content-Type"] = "application/json" + self.session.headers["Accept"] = "application/json" + + def close(self) -> None: + try: + self.session.close() + except Exception: + pass + + def _cli(self, utility: str, params: list) -> dict: + url = f"{self.base}/api/v2/cli/{utility}/call" + try: + r = self.session.post(url, json={"params": params}, timeout=30) + except requests.RequestException as e: + raise PleskError(f"Plesk Verbindung fehlgeschlagen: {e}") + if r.status_code == 401: + raise PleskError("Plesk: Authentifizierung fehlgeschlagen (API-Key/Login prüfen)") + if not r.ok: + raise PleskError(f"Plesk HTTP {r.status_code}: {r.text[:300]}") + try: + data = r.json() + except ValueError: + raise PleskError(f"Plesk: ungültige Antwort: {r.text[:300]}") + if data.get("code", 0) != 0: + err = (data.get("stderr") or data.get("stdout") or "").strip() + raise PleskError(f"Plesk CLI exit {data.get('code')}: {err}") + return data + + def mail_exists(self, email: str) -> bool: + try: + self._cli("mail", ["--info", email]) + return True + except PleskError as e: + msg = str(e).lower() + if "does not exist" in msg or "not found" in msg or "unknown mailname" in msg: + return False + raise + + def create_mail(self, email: str, password: str) -> None: + # plesk bin mail --create user@dom -passwd 'pw' -mailbox true + self._cli("mail", [ + "--create", email, + "-passwd", password, + "-mailbox", "true", + ]) diff --git a/clients/plesk_ssh.py b/clients/plesk_ssh.py new file mode 100644 index 0000000..fa780c0 --- /dev/null +++ b/clients/plesk_ssh.py @@ -0,0 +1,112 @@ +"""Plesk SSH-Backend. + +Wird genutzt, wenn die Plesk-Instanz keinen REST-API-Zugriff erlaubt. +Verbindet sich per SSH zum Plesk-Server und ruft `plesk bin mail …` auf. +Funktioniert auch ohne API-Key – nur SSH-Login (Key oder Passwort) nötig. +""" +from __future__ import annotations +import shlex +from typing import Optional + +import paramiko + + +class PleskSshError(Exception): + pass + + +class PleskSshClient: + def __init__(self, host: str, *, ssh_port: int = 22, + ssh_user: str, ssh_password: Optional[str] = None, + ssh_key: Optional[str] = None, + ssh_key_passphrase: Optional[str] = None, + use_sudo: bool = False): + if not host: + raise PleskSshError("Plesk-Host ist leer") + if not ssh_user: + raise PleskSshError("PLESK_SSH_USER ist nicht gesetzt") + if not (ssh_password or ssh_key): + raise PleskSshError( + "Weder PLESK_SSH_PASSWORD noch PLESK_SSH_KEY gesetzt" + ) + self.host = host + self.use_sudo = use_sudo + self.client = paramiko.SSHClient() + self.client.load_system_host_keys() + # AutoAdd: praktisch beim erstmaligen Lauf. Wer's strenger will, + # füllt vorher ~/.ssh/known_hosts und setzt RejectPolicy hier. + self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + kwargs = { + "hostname": host, "port": ssh_port, "username": ssh_user, + "timeout": 15, "allow_agent": True, "look_for_keys": True, + } + if ssh_key: + kwargs["pkey"] = self._load_key(ssh_key, ssh_key_passphrase or None) + elif ssh_password: + kwargs["password"] = ssh_password + kwargs["allow_agent"] = False + kwargs["look_for_keys"] = False + try: + self.client.connect(**kwargs) + except paramiko.AuthenticationException as e: + raise PleskSshError(f"SSH-Login {ssh_user}@{host}:{ssh_port} abgelehnt: {e}") from e + except Exception as e: + raise PleskSshError(f"SSH-Verbindung {ssh_user}@{host}:{ssh_port}: {e}") from e + + @staticmethod + def _load_key(path: str, passphrase: Optional[str]): + last_err = None + for KeyClass in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey): + try: + return KeyClass.from_private_key_file(path, password=passphrase) + except paramiko.SSHException as e: + last_err = e + continue + raise PleskSshError(f"SSH-Key {path} konnte nicht geladen werden: {last_err}") + + def close(self) -> None: + try: + self.client.close() + except Exception: + pass + + def _run_mail(self, args: list) -> tuple: + cmd = ["plesk", "bin", "mail", *args] + if self.use_sudo: + cmd = ["sudo", "-n", *cmd] + cmd_str = " ".join(shlex.quote(c) for c in cmd) + try: + _stdin, stdout, stderr = self.client.exec_command(cmd_str, timeout=60) + except Exception as e: + raise PleskSshError(f"SSH exec fehlgeschlagen: {e}") from e + rc = stdout.channel.recv_exit_status() + out = stdout.read().decode("utf-8", "replace") + err = stderr.read().decode("utf-8", "replace") + return rc, out, err + + def mail_exists(self, email: str) -> bool: + rc, out, err = self._run_mail(["--info", email]) + if rc == 0: + return True + msg = (err + " " + out).lower() + if any(s in msg for s in ( + "does not exist", "not found", "unknown mailname", + "no such mail", "non existent", + )): + return False + raise PleskSshError( + f"plesk bin mail --info {email} → rc={rc}: " + f"{(err or out).strip() or ''}" + ) + + def create_mail(self, email: str, password: str) -> None: + rc, out, err = self._run_mail([ + "--create", email, + "-passwd", password, + "-mailbox", "true", + ]) + if rc != 0: + raise PleskSshError( + f"plesk bin mail --create {email} → rc={rc}: " + f"{(err or out).strip()}" + ) diff --git a/config.py b/config.py new file mode 100644 index 0000000..36c3ca6 --- /dev/null +++ b/config.py @@ -0,0 +1,48 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + + +def _bool(v: str, default: bool = False) -> bool: + if v is None: + return default + return v.strip().lower() in ("1", "true", "yes", "on", "ja") + + +class Config: + # Plesk-Backend: + # "manual" – Tool legt nichts an, das Mailpostfach muss von Hand + # im Plesk-Webinterface angelegt werden (Shared-Host-Fall). + # Die Zugangsdaten werden trotzdem im PDF ausgegeben, damit + # der Admin sie 1:1 ins Plesk-Webinterface eintragen kann. + # "api" – Plesk REST-API (braucht API-Key oder Admin-Login) + # "ssh" – SSH zum Plesk-Server, ruft `plesk bin mail` auf + PLESK_BACKEND = (os.getenv("PLESK_BACKEND") or "manual").strip().lower() + + # Plesk REST-API + PLESK_API_KEY = os.getenv("PLESK_API_KEY") or "" + PLESK_USER = os.getenv("PLESK_USER") or "" + PLESK_PASSWORD = os.getenv("PLESK_PASSWORD") or "" + PLESK_PORT = int(os.getenv("PLESK_PORT", "8443")) + + # Plesk SSH-Backend + PLESK_SSH_PORT = int(os.getenv("PLESK_SSH_PORT", "22")) + PLESK_SSH_USER = os.getenv("PLESK_SSH_USER") or "" + PLESK_SSH_PASSWORD = os.getenv("PLESK_SSH_PASSWORD") or "" + PLESK_SSH_KEY = os.getenv("PLESK_SSH_KEY") or "" + PLESK_SSH_KEY_PASSPHRASE = os.getenv("PLESK_SSH_KEY_PASSPHRASE") or "" + PLESK_SSH_USE_SUDO = _bool(os.getenv("PLESK_SSH_USE_SUDO"), False) + + KERIO_USER = os.getenv("KERIO_ADMIN_USER") or "Admin" + KERIO_PASSWORD = os.getenv("KERIO_ADMIN_PASSWORD") or "" + KERIO_PORT = int(os.getenv("KERIO_ADMIN_PORT", "4040")) + + NEXTCLOUD_USER = os.getenv("NEXTCLOUD_ADMIN_USER") or "" + NEXTCLOUD_PASSWORD = os.getenv("NEXTCLOUD_ADMIN_PASSWORD") or "" + + POP3_PORT = int(os.getenv("POP3_PORT", "995")) + POP3_KEEP_DAYS = int(os.getenv("POP3_KEEP_DAYS", "14")) + SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) + + VERIFY_TLS = _bool(os.getenv("VERIFY_TLS"), True) diff --git a/deploy.py b/deploy.py new file mode 100644 index 0000000..91f3e91 --- /dev/null +++ b/deploy.py @@ -0,0 +1,269 @@ +#!/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_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 + 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: + if kerio.user_exists(account.emailadresse): + result.steps.append(StepResult("Kerio", "übersprungen", "User existiert bereits")) + log(" · existiert bereits, POP3-Sammler nicht neu angelegt") + else: + uid = kerio.create_user( + account.emailadresse, + account.kerioemailkennwort, + account.vollname, + ) + kerio.add_pop3_collection( + kerio_user_id=uid, + server=account.pleskhost, + login_name=account.emailadresse, + password=account.pleskemailkennwort, + port=cfg.POP3_PORT, ssl=True, + leave_days=cfg.POP3_KEEP_DAYS, + ) + result.steps.append(StepResult("Kerio", "angelegt", "inkl. POP3-Sammler")) + log(" ✓ angelegt + POP3-Sammler") + 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 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" + write_user_pdf(per_pdf, acc, cfg.SMTP_PORT, cfg.POP3_PORT) + log(f" ⤷ PDF: {per_pdf}") + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + combined = output_dir / f"zugangsdaten_gesamt_{timestamp}.pdf" + write_combined_pdf(combined, accounts, cfg.SMTP_PORT, cfg.POP3_PORT) + log(f"✓ Gesamt-PDF: {combined}") + + report = output_dir / f"_admin_report_{timestamp}.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") + 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") + 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() diff --git a/gui.py b/gui.py new file mode 100644 index 0000000..25be047 --- /dev/null +++ b/gui.py @@ -0,0 +1,225 @@ +"""Tkinter-GUI mit Endlosfeldern (eine Zeile = ein Account).""" +from __future__ import annotations +import threading +from pathlib import Path +import tkinter as tk +from tkinter import filedialog, messagebox, scrolledtext, ttk + +from config import Config +from deploy import parse_csv, run_deploy +from models import Account + + +FIELDS = [ + ("vorname", "Vorname", 14), + ("name", "Name", 14), + ("emailadresse", "Emailadresse", 22), + ("pleskhost", "Plesk-Host", 18), + ("keriohost", "Kerio-Host", 18), + ("nextcloudhost", "Nextcloud-Host", 18), + ("pleskemailkennwort", "Plesk Mail-PW", 14), + ("kerioemailkennwort", "Kerio PW", 14), + ("nextcloudkennwort", "Nextcloud PW", 14), + ("nextcloudgruppe", "NC Gruppe", 14), + ("nextcloudspeicher", "NC GB (leer=∞)", 12), +] + + +class AccountRow(ttk.Frame): + def __init__(self, master, on_remove): + super().__init__(master) + self.entries: dict[str, ttk.Entry] = {} + for col, (key, _label, width) in enumerate(FIELDS): + e = ttk.Entry(self, width=width) + e.grid(row=0, column=col, padx=2, pady=2, sticky="ew") + self.entries[key] = e + ttk.Button(self, text="✕", width=3, + command=lambda: on_remove(self)).grid( + row=0, column=len(FIELDS), padx=4) + + def to_dict(self) -> dict: + return {k: e.get().strip() for k, e in self.entries.items()} + + def from_dict(self, d: dict) -> None: + for k, e in self.entries.items(): + e.delete(0, "end") + v = d.get(k, "") + e.insert(0, "" if v is None else str(v)) + + +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title("Email / Cloud Account Deployment") + self.geometry("1500x800") + self.output_dir = Path("./output").resolve() + self.rows: list[AccountRow] = [] + self._build() + self.add_row() + + # ----- Layout ----- + def _build(self): + toolbar = ttk.Frame(self) + toolbar.pack(fill="x", padx=8, pady=6) + ttk.Button(toolbar, text="+ Zeile", command=self.add_row).pack(side="left", padx=2) + ttk.Button(toolbar, text="CSV laden …", command=self.load_csv).pack(side="left", padx=2) + ttk.Button(toolbar, text="Ausgabeordner …", command=self.choose_output).pack(side="left", padx=2) + self.output_lbl = ttk.Label(toolbar, text=f"Output: {self.output_dir}") + self.output_lbl.pack(side="left", padx=8) + self.run_btn = ttk.Button(toolbar, text="Ausführen ▶", command=self.run) + self.run_btn.pack(side="right", padx=2) + + # Header über die Eingabezeilen + header = ttk.Frame(self) + header.pack(fill="x", padx=8) + for col, (_k, label, width) in enumerate(FIELDS): + ttk.Label(header, text=label, font=("Helvetica", 9, "bold"), + width=width, anchor="w").grid(row=0, column=col, padx=2, sticky="w") + + # Scrollbares Eingabefeld + wrap = ttk.Frame(self) + wrap.pack(fill="both", expand=True, padx=8, pady=4) + self.canvas = tk.Canvas(wrap, highlightthickness=0) + sb = ttk.Scrollbar(wrap, orient="vertical", command=self.canvas.yview) + self.canvas.configure(yscrollcommand=sb.set) + self.canvas.pack(side="left", fill="both", expand=True) + sb.pack(side="right", fill="y") + self.rows_frame = ttk.Frame(self.canvas) + self.canvas.create_window((0, 0), window=self.rows_frame, anchor="nw") + self.rows_frame.bind( + "", + lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")), + ) + # Mousewheel-Scrollen + self.canvas.bind_all("", + lambda e: self.canvas.yview_scroll(int(-e.delta / 120), "units")) + self.canvas.bind_all("", + lambda e: self.canvas.yview_scroll(-1, "units")) + self.canvas.bind_all("", + lambda e: self.canvas.yview_scroll(1, "units")) + + ttk.Label(self, text="Log:").pack(anchor="w", padx=8) + self.log = scrolledtext.ScrolledText(self, height=14, wrap="word", + font=("Monospace", 9)) + self.log.pack(fill="x", padx=8, pady=(0, 8)) + + # ----- Reihen-Handling ----- + def add_row(self, data: dict | None = None): + row = AccountRow(self.rows_frame, on_remove=self.remove_row) + row.pack(fill="x", pady=1) + if data: + row.from_dict(data) + self.rows.append(row) + + def remove_row(self, row): + if len(self.rows) <= 1: + row.from_dict({}) # leere statt entfernen + return + self.rows.remove(row) + row.destroy() + + # ----- Buttons ----- + def load_csv(self): + path = filedialog.askopenfilename( + filetypes=[("CSV", "*.csv"), ("Alle Dateien", "*.*")]) + if not path: + return + try: + accounts = parse_csv(path) + except Exception as e: + messagebox.showerror("CSV-Fehler", str(e)) + return + for r in self.rows: + r.destroy() + self.rows = [] + for acc in accounts: + self.add_row({ + "vorname": acc.vorname, + "name": acc.name, + "emailadresse": acc.emailadresse, + "pleskhost": acc.pleskhost, + "keriohost": acc.keriohost, + "nextcloudhost": acc.nextcloudhost, + "pleskemailkennwort": acc.pleskemailkennwort, + "kerioemailkennwort": acc.kerioemailkennwort, + "nextcloudkennwort": acc.nextcloudkennwort, + "nextcloudgruppe": acc.nextcloudgruppe, + "nextcloudspeicher": "" if acc.nextcloudspeicher is None + else str(acc.nextcloudspeicher), + }) + if not self.rows: + self.add_row() + self._log(f"CSV geladen: {len(accounts)} Account(s)") + + def choose_output(self): + path = filedialog.askdirectory(initialdir=str(self.output_dir)) + if path: + self.output_dir = Path(path) + self.output_lbl.config(text=f"Output: {self.output_dir}") + + # ----- Ausführen ----- + def _collect_accounts(self) -> list[Account]: + accounts = [] + for idx, r in enumerate(self.rows, 1): + d = r.to_dict() + if not d.get("emailadresse"): + continue + required = ["name", "vorname", "emailadresse", + "pleskhost", "keriohost", "nextcloudhost", + "pleskemailkennwort", "kerioemailkennwort", + "nextcloudkennwort"] + missing = [k for k in required if not d.get(k)] + if missing: + raise ValueError(f"Zeile {idx}: Pflichtfelder leer: {', '.join(missing)}") + quota = int(d["nextcloudspeicher"]) if d.get("nextcloudspeicher") else None + accounts.append(Account( + name=d["name"], vorname=d["vorname"], + emailadresse=d["emailadresse"], + pleskhost=d["pleskhost"], keriohost=d["keriohost"], + nextcloudhost=d["nextcloudhost"], + kerioemailkennwort=d["kerioemailkennwort"], + pleskemailkennwort=d["pleskemailkennwort"], + nextcloudgruppe=d.get("nextcloudgruppe", ""), + nextcloudspeicher=quota, + nextcloudkennwort=d["nextcloudkennwort"], + )) + return accounts + + def _log(self, msg: str): + self.log.insert("end", msg + "\n") + self.log.see("end") + + def run(self): + try: + accounts = self._collect_accounts() + except ValueError as e: + messagebox.showerror("Eingabe ungültig", str(e)) + return + if not accounts: + messagebox.showinfo("Hinweis", "Keine Accounts eingetragen.") + return + cfg = Config() + self.log.delete("1.0", "end") + self.run_btn.config(state="disabled") + + def worker(): + try: + _, combined = run_deploy(accounts, cfg, self.output_dir, + log=lambda m: self.after(0, self._log, m)) + self.after(0, lambda: messagebox.showinfo( + "Fertig", f"Verarbeitung abgeschlossen.\n\nGesamt-PDF:\n{combined}")) + except Exception as e: + self.after(0, lambda: self._log(f"FEHLER: {e}")) + self.after(0, lambda: messagebox.showerror("Fehler", str(e))) + finally: + self.after(0, lambda: self.run_btn.config(state="normal")) + + threading.Thread(target=worker, daemon=True).start() + + +def launch(): + App().mainloop() + + +if __name__ == "__main__": + launch() diff --git a/models.py b/models.py new file mode 100644 index 0000000..d3586ad --- /dev/null +++ b/models.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class Account: + name: str + vorname: str + emailadresse: str + pleskhost: str + keriohost: str + nextcloudhost: str + kerioemailkennwort: str + pleskemailkennwort: str + nextcloudgruppe: str = "" + nextcloudspeicher: Optional[int] = None # GB; None = unlimitiert + nextcloudkennwort: str = "" + + @property + def vollname(self) -> str: + return f"{self.vorname} {self.name}".strip() + + @property + def nextcloud_username(self) -> str: + return derive_nc_username(self.vorname, self.name) + + +def derive_nc_username(vorname: str, name: str) -> str: + """vorname.nachname, lowercase, Umlaute transliteriert, sichere Zeichen.""" + s = f"{vorname.strip().lower()}.{name.strip().lower()}" + s = (s.replace("ä", "ae").replace("ö", "oe") + .replace("ü", "ue").replace("ß", "ss")) + out = [] + for ch in s: + if ch.isalnum() or ch in "._-": + out.append(ch) + else: + out.append("-") + return "".join(out).strip("-.") + + +@dataclass +class StepResult: + service: str + status: str # "angelegt" | "übersprungen" | "Fehler" + detail: str = "" + + +@dataclass +class AccountResult: + account: Account + steps: List[StepResult] = field(default_factory=list) + + @property + def has_errors(self) -> bool: + return any(s.status == "Fehler" for s in self.steps) diff --git a/pdf.py b/pdf.py new file mode 100644 index 0000000..6a45b57 --- /dev/null +++ b/pdf.py @@ -0,0 +1,97 @@ +from pathlib import Path +from typing import List + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib.units import cm +from reportlab.platypus import ( + PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, +) + +from models import Account + + +def _section(title: str, rows, col_widths=(5 * cm, 11 * cm)): + styles = getSampleStyleSheet() + elements = [Paragraph(title, styles["Heading2"])] + data = [[k, v] for k, v in rows] + t = Table(data, colWidths=list(col_widths)) + t.setStyle(TableStyle([ + ("BOX", (0, 0), (-1, -1), 0.5, colors.grey), + ("INNERGRID", (0, 0), (-1, -1), 0.25, colors.lightgrey), + ("BACKGROUND", (0, 0), (0, -1), colors.whitesmoke), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("FONT", (0, 0), (-1, -1), "Helvetica", 10), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ])) + elements.append(t) + elements.append(Spacer(1, 0.4 * cm)) + return elements + + +def _build_user_section(account: Account, smtp_port: int, pop_port: int): + styles = getSampleStyleSheet() + elements = [ + Paragraph(f"Zugangsdaten – {account.vollname}", styles["Title"]), + Spacer(1, 0.3 * cm), + Paragraph(f"Hauptemail: {account.emailadresse}", styles["Normal"]), + Spacer(1, 0.4 * cm), + ] + + elements += _section("Plesk Mailpostfach (Backend, eingehend für POP3-Sammler)", [ + ("Mailserver", account.pleskhost), + ("Benutzername", account.emailadresse), + ("Passwort", account.pleskemailkennwort), + ("Hinweis", "Anmeldung nur am Mailserver – keine Plesk-Webinterface-Anmeldung."), + ]) + + elements += _section("Kerio Connect (Hauptmailkonto)", [ + ("Webmail", f"https://{account.keriohost}/webmail/"), + ("Benutzername", account.emailadresse), + ("Passwort", account.kerioemailkennwort), + ("SMTP-Server (Versand)", f"{account.keriohost}:{smtp_port} (SSL)"), + ("IMAP/POP-Server", f"{account.keriohost}"), + ("POP3-Sammler", + f"holt automatisch von {account.pleskhost}:{pop_port} (SSL), " + f"behält 14 Tage auf dem Server"), + ]) + + quota = f"{account.nextcloudspeicher} GB" if account.nextcloudspeicher else "unlimitiert" + elements += _section("Nextcloud", [ + ("URL", f"https://{account.nextcloudhost}"), + ("Benutzername", account.nextcloud_username), + ("Passwort", account.nextcloudkennwort), + ("Email (hinterlegt)", account.emailadresse), + ("Gruppe", account.nextcloudgruppe or "–"), + ("Speicher", quota), + ]) + return elements + + +def write_user_pdf(path: Path, account: Account, + smtp_port: int, pop_port: int) -> None: + doc = SimpleDocTemplate( + str(path), pagesize=A4, + leftMargin=2 * cm, rightMargin=2 * cm, + topMargin=2 * cm, bottomMargin=2 * cm, + title=f"Zugangsdaten {account.vollname}", + ) + doc.build(_build_user_section(account, smtp_port, pop_port)) + + +def write_combined_pdf(path: Path, accounts: List[Account], + smtp_port: int, pop_port: int) -> None: + doc = SimpleDocTemplate( + str(path), pagesize=A4, + leftMargin=2 * cm, rightMargin=2 * cm, + topMargin=2 * cm, bottomMargin=2 * cm, + title="Zugangsdaten (Sammel-PDF)", + ) + flow = [] + for i, acc in enumerate(accounts): + flow += _build_user_section(acc, smtp_port, pop_port) + if i < len(accounts) - 1: + flow.append(PageBreak()) + doc.build(flow) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed7def9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.31 +python-dotenv>=1.0 +reportlab>=4.0