first release
This commit is contained in:
@@ -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
|
||||||
@@ -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 <key>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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_<timestamp>.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
|
||||||
|
```
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Kerio Connect Admin API Client (JSON-RPC).
|
||||||
|
|
||||||
|
Endpoint: POST https://<host>: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}")
|
||||||
@@ -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')}"
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
])
|
||||||
@@ -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 '<keine Ausgabe>'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()}"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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(
|
||||||
|
"<Configure>",
|
||||||
|
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")),
|
||||||
|
)
|
||||||
|
# Mousewheel-Scrollen
|
||||||
|
self.canvas.bind_all("<MouseWheel>",
|
||||||
|
lambda e: self.canvas.yview_scroll(int(-e.delta / 120), "units"))
|
||||||
|
self.canvas.bind_all("<Button-4>",
|
||||||
|
lambda e: self.canvas.yview_scroll(-1, "units"))
|
||||||
|
self.canvas.bind_all("<Button-5>",
|
||||||
|
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()
|
||||||
@@ -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)
|
||||||
@@ -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: <b>{account.emailadresse}</b>", 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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
requests>=2.31
|
||||||
|
python-dotenv>=1.0
|
||||||
|
reportlab>=4.0
|
||||||
Reference in New Issue
Block a user