first release

This commit is contained in:
2026-05-12 11:04:17 +02:00
parent d49cebf616
commit e8ef2081ed
13 changed files with 1403 additions and 0 deletions
+42
View File
@@ -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
+211
View File
@@ -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
```
View File
+181
View File
@@ -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}")
+78
View File
@@ -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')}"
)
+81
View File
@@ -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",
])
+112
View File
@@ -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()}"
)
+48
View File
@@ -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)
+269
View File
@@ -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()
+225
View File
@@ -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()
+56
View File
@@ -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)
+97
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
requests>=2.31
python-dotenv>=1.0
reportlab>=4.0