add manual/api/ssh Plesk-Backends, Minimal-PDF, --pdf-only und GUI-Verbesserungen

- PLESK_BACKEND={manual,api,ssh}: manual als Default für Shared Hosts,
  api unverändert, ssh ruft `plesk bin mail` per paramiko auf.
- POP3_PORT default 995, POP3_SSL mit Auto-Erkennung anhand Port.
- Kerio: User wird mit mayChangePassword=False angelegt.
- Zusätzliche Minimal-PDF (nur Email + Cloud) pro Konto + Sammel-Variante,
  IMAP-Port konfigurierbar.
- CLI-Flag --pdf-only und entsprechender GUI-Button "📄 Nur PDF".
- GUI: Lösch-Button "✕ löschen" sichtbarer, letzte Zeile löschbar.
- PDFs sind kunden-tauglich (kein Status-Block, kein ACHTUNG-Hinweis);
  Anlage-Status separat in _admin_report_<ts>.txt.
- README dokumentiert die Skip-Logik pro Dienst und ihre Caveats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 12:15:39 +02:00
parent e951c839ca
commit dda5c746ce
6 changed files with 216 additions and 34 deletions
+8
View File
@@ -36,7 +36,15 @@ NEXTCLOUD_ADMIN_PASSWORD=
# --- Mailserver-Ports für POP3-Sammler in Kerio ---
POP3_PORT=995
POP3_KEEP_DAYS=14
# POP3_SSL leer lassen → Auto-Erkennung anhand POP3_PORT
# 110 → SSL aus (Plain POP3)
# alles andere → SSL an (POP3S, auch 465)
# Manuell überschreibbar mit POP3_SSL=true|false.
POP3_SSL=
# Nur für die Anzeige im Kunden-PDF (Mailprogramm-Konfig).
SMTP_PORT=465
IMAP_PORT=993
# TLS-Zertifikate prüfen? false nur bei selbstsignierten Test-Zertifikaten.
VERIFY_TLS=true
+63 -15
View File
@@ -53,8 +53,10 @@ Dreistufige Wahl, je nachdem welchen Zugriff du auf das Plesk hast:
| `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_SSL` | Leer → Auto: `110` ⇒ aus, sonst an. Manuell `true`/`false`. |
| `POP3_KEEP_DAYS` | Tage, die Mails auf Plesk verbleiben (default `14`) |
| `SMTP_PORT` | Nur für die PDF-Anzeige (default `465`) |
| `SMTP_PORT` | Nur für die PDF-Anzeige (default `465`, Mailprogramm-Konfig) |
| `IMAP_PORT` | Nur für die Minimal-PDF-Anzeige (default `993` = IMAPS) |
| `VERIFY_TLS` | `false` nur bei Test/selbst signierten Zertifikaten |
---
@@ -114,10 +116,29 @@ case-insensitive. Beispiel: [`example.csv`](./example.csv).
### CLI
Voller Lauf (Plesk + Kerio + Nextcloud + PDFs):
```bash
python deploy.py --csv example.csv --output ./output
python deploy.py --csv kunden.csv --output ./output
```
Nur PDFs aus der CSV neu erzeugen, ohne Plesk/Kerio/Nextcloud anzufassen
(z.B. wenn die Konten schon angelegt sind oder eine PDF wiederholt
ausgedruckt werden soll):
```bash
python deploy.py --csv kunden.csv --pdf-only
```
Weitere Flags:
| Flag | Bedeutung |
| ------------ | --------------------------------------------------------------- |
| `--csv PATH` | CSV-Datei einlesen |
| `--output D` | Verzeichnis für PDFs + Admin-Report (default `./output`) |
| `--pdf-only` | Nur PDFs schreiben, keine Account-Anlage |
| `--gui` | GUI starten (auch ohne `--csv` aufrufbar) |
Exit-Code `0` wenn alle Konten ohne Fehler verarbeitet wurden, sonst `1`.
### GUI
@@ -129,11 +150,13 @@ python deploy.py
```
In der GUI:
- Per **+ Zeile** beliebig viele Konten hinzufügen.
- **+ Zeile** fügt eine neue Eingabezeile hinzu (Endlosfelder).
- **✕ löschen** entfernt eine Zeile (rote Schrift).
- **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.
- **Ausgabeordner** wählen.
- **Ausführen ▶** legt die Konten an + erzeugt PDFs + Admin-Report.
- **📄 Nur PDF** schreibt nur die PDFs ohne API-Aufrufe.
- Log unten zeigt Fortschritt.
---
@@ -153,16 +176,41 @@ 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.
Am Ende werden pro Lauf vier Dokument-Sorten geschrieben (alle in `output/`):
Bestehende Konten werden **übersprungen** (kein Fehler) und im
Admin-Report entsprechend markiert. Damit kann eine CSV gefahrlos
zweimal laufen.
| Datei | Inhalt |
| ---------------------------------------------- | --------------------------------------------------------------------- |
| `zugangsdaten_<email>.pdf` | **Voll** pro Benutzer: Plesk-Mailpostfach + Kerio + Nextcloud |
| `zugangsdaten_minimal_<email>.pdf` | **Minimal** pro Benutzer: nur Email (Kerio) + Cloud (Nextcloud) |
| `zugangsdaten_gesamt_<ts>.pdf` | Sammel-PDF aller voller Datensätze |
| `zugangsdaten_gesamt_minimal_<ts>.pdf` | Sammel-PDF aller minimalen Datensätze |
| `_admin_report_<ts>.txt` | Status pro Konto (✓/·/⚠/✗). **NICHT für den Kunden** Admin-Doku. |
Die **Minimal-PDF** enthält genau das, was der Endkunde fürs Mailprogramm
und die Cloud-Anmeldung braucht (Mailadresse, Kennwort, SMTP, IMAP, Cloud-URL,
Cloud-Username, Cloud-Kennwort) ohne Backend-Details wie POP3-Sammler oder
Plesk-Mailserver.
### Idempotenz bestehende Konten werden übersprungen
Jeder Dienst wird **einzeln** geprüft, bevor angelegt wird:
| Dienst | Prüfung | Wenn vorhanden |
| --------- | -------------------------------------------------------------------- | ----------------------------------------------------------------- |
| Plesk | `mail --info` (api/ssh) bzw. ohnehin Manual-Modus | übersprungen |
| Kerio | `Users.get` mit `loginName` + `domainId` | übersprungen, **kein neuer POP3-Sammler** (sonst doppelt!) |
| Nextcloud | `GET /ocs/v2.php/cloud/users/<vorname.nachname>` | übersprungen, **keine Quota-/Gruppen-Änderung** |
→ Eine CSV kann gefahrlos zwei- oder dreimal durchgejagt werden, z.B. wenn
beim ersten Lauf nur Plesk geklappt hat und du Kerio + Nextcloud nachziehen
willst. Status pro Konto und Dienst steht im Admin-Report als
`✓ angelegt` / `· übersprungen` / `⚠ manuell` / `✗ Fehler`.
**Caveats**:
- Kerio prüft `loginName` *in der zur Mailadresse passenden Domain* Aliase
auf einer anderen Domain werden nicht erkannt.
- Nextcloud prüft den abgeleiteten Username `vorname.nachname` existiert
derselbe Mensch dort unter abweichendem Username, wird das nicht gefunden.
## Workflow bei `PLESK_BACKEND=manual` (Shared Host)
+11
View File
@@ -44,5 +44,16 @@ class Config:
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"))
IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
# SSL für POP3-Sammler:
# - explizit per POP3_SSL=true|false setzbar
# - sonst Auto-Erkennung anhand des Ports:
# 110 (Plain POP3) → SSL aus, alles andere (995, 465, …) → SSL an
_pop3_ssl_env = os.getenv("POP3_SSL")
if _pop3_ssl_env is None or _pop3_ssl_env == "":
POP3_SSL = POP3_PORT != 110
else:
POP3_SSL = _bool(_pop3_ssl_env, True)
VERIFY_TLS = _bool(os.getenv("VERIFY_TLS"), True)
+49 -4
View File
@@ -9,7 +9,10 @@ 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
from pdf import (
write_combined_minimal_pdf, write_combined_pdf,
write_user_minimal_pdf, write_user_pdf,
)
def _detect_delimiter(path: Path) -> str:
@@ -141,7 +144,7 @@ def _deploy_one(account: Account, cfg: Config, log: Callable[[str], None]) -> Ac
server=account.pleskhost,
login_name=account.emailadresse,
password=account.pleskemailkennwort,
port=cfg.POP3_PORT, ssl=True,
port=cfg.POP3_PORT, ssl=cfg.POP3_SSL,
leave_days=cfg.POP3_KEEP_DAYS,
)
result.steps.append(StepResult("Kerio", "angelegt", "inkl. POP3-Sammler"))
@@ -212,6 +215,34 @@ def _write_admin_report(path: Path, results: List[AccountResult],
path.write_text("\n".join(lines), encoding="utf-8")
def pdf_only(accounts: List[Account], cfg: Config,
output_dir, log: Callable[[str], None] = print) -> Path:
"""Nur PDFs schreiben KEIN Plesk/Kerio/Nextcloud-Aufruf.
Praktisch wenn die Konten schon angelegt sind oder die CSV nochmal
durchgejagt werden soll, um die PDFs neu zu generieren.
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
for i, acc in enumerate(accounts, 1):
log(f"[{i}/{len(accounts)}] {acc.vollname} <{acc.emailadresse}>")
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in acc.emailadresse)
per_pdf = output_dir / f"zugangsdaten_{safe}.pdf"
per_min = output_dir / f"zugangsdaten_minimal_{safe}.pdf"
write_user_pdf(per_pdf, acc, cfg.SMTP_PORT, cfg.POP3_PORT, cfg.POP3_SSL)
write_user_minimal_pdf(per_min, acc, cfg.SMTP_PORT, cfg.IMAP_PORT)
log(f" ⤷ PDF: {per_pdf}")
log(f" ⤷ PDF (minimal): {per_min}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
combined = output_dir / f"zugangsdaten_gesamt_{timestamp}.pdf"
combined_min = output_dir / f"zugangsdaten_gesamt_minimal_{timestamp}.pdf"
write_combined_pdf(combined, accounts, cfg.SMTP_PORT, cfg.POP3_PORT, cfg.POP3_SSL)
write_combined_minimal_pdf(combined_min, accounts, cfg.SMTP_PORT, cfg.IMAP_PORT)
log(f"✓ Gesamt-PDF: {combined}")
log(f"✓ Gesamt-PDF (minimal): {combined_min}")
return combined
def run_deploy(accounts: List[Account], cfg: Config,
output_dir, log: Callable[[str], None] = print
) -> Tuple[List[AccountResult], Path]:
@@ -224,13 +255,19 @@ def run_deploy(accounts: List[Account], cfg: Config,
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)
per_min = output_dir / f"zugangsdaten_minimal_{safe}.pdf"
write_user_pdf(per_pdf, acc, cfg.SMTP_PORT, cfg.POP3_PORT, cfg.POP3_SSL)
write_user_minimal_pdf(per_min, acc, cfg.SMTP_PORT, cfg.IMAP_PORT)
log(f" ⤷ PDF: {per_pdf}")
log(f" ⤷ PDF (minimal): {per_min}")
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)
combined_min = output_dir / f"zugangsdaten_gesamt_minimal_{timestamp}.pdf"
write_combined_pdf(combined, accounts, cfg.SMTP_PORT, cfg.POP3_PORT, cfg.POP3_SSL)
write_combined_minimal_pdf(combined_min, accounts, cfg.SMTP_PORT, cfg.IMAP_PORT)
log(f"✓ Gesamt-PDF: {combined}")
log(f"✓ Gesamt-PDF (minimal): {combined_min}")
report = output_dir / f"_admin_report_{timestamp}.txt"
_write_admin_report(report, results, cfg, timestamp)
@@ -245,6 +282,8 @@ def main():
p.add_argument("--csv", help="Pfad zur CSV-Datei (CLI-Modus)")
p.add_argument("--output", default="./output", help="Verzeichnis für PDFs (default ./output)")
p.add_argument("--gui", action="store_true", help="GUI starten")
p.add_argument("--pdf-only", action="store_true",
help="Nur PDFs aus der CSV erzeugen, keine Anlage in Plesk/Kerio/Nextcloud")
args = p.parse_args()
if args.gui or not args.csv:
@@ -259,6 +298,12 @@ def main():
print(f"CSV-Fehler: {e}", file=sys.stderr)
sys.exit(2)
print(f"{len(accounts)} Account(s) eingelesen.\n")
if args.pdf_only:
combined = pdf_only(accounts, cfg, args.output)
print(f"\nFertig (nur PDF). Gesamt-PDF: {combined}")
sys.exit(0)
results, combined = run_deploy(accounts, cfg, args.output)
failed = sum(1 for r in results if r.has_errors)
print(f"\nFertig. {len(results) - failed}/{len(results)} ohne Fehler. Gesamt-PDF: {combined}")
+28 -9
View File
@@ -6,7 +6,7 @@ import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext, ttk
from config import Config
from deploy import parse_csv, run_deploy
from deploy import parse_csv, pdf_only, run_deploy
from models import Account
@@ -33,7 +33,8 @@ class AccountRow(ttk.Frame):
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,
ttk.Button(self, text=" löschen", width=10,
style="Remove.TButton",
command=lambda: on_remove(self)).grid(
row=0, column=len(FIELDS), padx=4)
@@ -54,6 +55,12 @@ class App(tk.Tk):
self.geometry("1500x800")
self.output_dir = Path("./output").resolve()
self.rows: list[AccountRow] = []
# Roter Lösch-Button-Style
style = ttk.Style(self)
try:
style.configure("Remove.TButton", foreground="#b00020")
except tk.TclError:
pass
self._build()
self.add_row()
@@ -68,6 +75,8 @@ class App(tk.Tk):
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)
self.pdf_btn = ttk.Button(toolbar, text="📄 Nur PDF", command=self.run_pdf_only)
self.pdf_btn.pack(side="right", padx=2)
# Header über die Eingabezeilen
header = ttk.Frame(self)
@@ -112,11 +121,10 @@ class App(tk.Tk):
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()
if not self.rows:
self.add_row() # immer mindestens eine leere Zeile
# ----- Buttons -----
def load_csv(self):
@@ -189,7 +197,7 @@ class App(tk.Tk):
self.log.insert("end", msg + "\n")
self.log.see("end")
def run(self):
def _start_worker(self, fn, success_title: str):
try:
accounts = self._collect_accounts()
except ValueError as e:
@@ -201,21 +209,32 @@ class App(tk.Tk):
cfg = Config()
self.log.delete("1.0", "end")
self.run_btn.config(state="disabled")
self.pdf_btn.config(state="disabled")
def worker():
try:
_, combined = run_deploy(accounts, cfg, self.output_dir,
log=lambda m: self.after(0, self._log, m))
combined = fn(accounts, cfg, self.output_dir,
log=lambda m: self.after(0, self._log, m))
# run_deploy gibt (results, combined) zurück, pdf_only nur combined
if isinstance(combined, tuple):
combined = combined[1]
self.after(0, lambda: messagebox.showinfo(
"Fertig", f"Verarbeitung abgeschlossen.\n\nGesamt-PDF:\n{combined}"))
success_title, f"Fertig.\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"))
self.after(0, lambda: self.pdf_btn.config(state="normal"))
threading.Thread(target=worker, daemon=True).start()
def run(self):
self._start_worker(run_deploy, "Verarbeitung abgeschlossen")
def run_pdf_only(self):
self._start_worker(pdf_only, "Nur PDFs erzeugt")
def launch():
App().mainloop()
+57 -6
View File
@@ -31,7 +31,8 @@ def _section(title: str, rows, col_widths=(5 * cm, 11 * cm)):
return elements
def _build_user_section(account: Account, smtp_port: int, pop_port: int):
def _build_user_section(account: Account, smtp_port: int,
pop_port: int, pop_ssl: bool):
styles = getSampleStyleSheet()
elements = [
Paragraph(f"Zugangsdaten {account.vollname}", styles["Title"]),
@@ -47,6 +48,7 @@ def _build_user_section(account: Account, smtp_port: int, pop_port: int):
("Hinweis", "Anmeldung nur am Mailserver keine Plesk-Webinterface-Anmeldung."),
])
pop_proto = "SSL" if pop_ssl else "unverschlüsselt"
elements += _section("Kerio Connect (Hauptmailkonto)", [
("Webmail", f"https://{account.keriohost}/webmail/"),
("Benutzername", account.emailadresse),
@@ -54,7 +56,7 @@ def _build_user_section(account: Account, smtp_port: int, pop_port: int):
("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"holt automatisch von {account.pleskhost}:{pop_port} ({pop_proto}), "
f"behält 14 Tage auf dem Server"),
])
@@ -71,18 +73,18 @@ def _build_user_section(account: Account, smtp_port: int, pop_port: int):
def write_user_pdf(path: Path, account: Account,
smtp_port: int, pop_port: int) -> None:
smtp_port: int, pop_port: int, pop_ssl: bool) -> 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))
doc.build(_build_user_section(account, smtp_port, pop_port, pop_ssl))
def write_combined_pdf(path: Path, accounts: List[Account],
smtp_port: int, pop_port: int) -> None:
smtp_port: int, pop_port: int, pop_ssl: bool) -> None:
doc = SimpleDocTemplate(
str(path), pagesize=A4,
leftMargin=2 * cm, rightMargin=2 * cm,
@@ -91,7 +93,56 @@ def write_combined_pdf(path: Path, accounts: List[Account],
)
flow = []
for i, acc in enumerate(accounts):
flow += _build_user_section(acc, smtp_port, pop_port)
flow += _build_user_section(acc, smtp_port, pop_port, pop_ssl)
if i < len(accounts) - 1:
flow.append(PageBreak())
doc.build(flow)
# ---------- Minimal-PDF (nur Email + Cloud, ohne Plesk/POP3-Sammler) ----------
def _build_minimal_section(account: Account, smtp_port: int, imap_port: int):
styles = getSampleStyleSheet()
elements = [
Paragraph(f"Zugangsdaten {account.vollname}", styles["Title"]),
Spacer(1, 0.4 * cm),
]
elements += _section("Email", [
("Emailadresse", account.emailadresse),
("Kennwort", account.kerioemailkennwort),
("SMTP-Server", f"{account.keriohost}:{smtp_port} (SSL)"),
("IMAP-Server", f"{account.keriohost}:{imap_port} (SSL)"),
])
elements += _section("Cloud", [
("Cloud URL", f"https://{account.nextcloudhost}"),
("Benutzername", account.nextcloud_username),
("Kennwort", account.nextcloudkennwort),
])
return elements
def write_user_minimal_pdf(path: Path, account: Account,
smtp_port: int, imap_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_minimal_section(account, smtp_port, imap_port))
def write_combined_minimal_pdf(path: Path, accounts: List[Account],
smtp_port: int, imap_port: int) -> None:
doc = SimpleDocTemplate(
str(path), pagesize=A4,
leftMargin=2 * cm, rightMargin=2 * cm,
topMargin=2 * cm, bottomMargin=2 * cm,
title="Zugangsdaten minimal (Sammel-PDF)",
)
flow = []
for i, acc in enumerate(accounts):
flow += _build_minimal_section(acc, smtp_port, imap_port)
if i < len(accounts) - 1:
flow.append(PageBreak())
doc.build(flow)