diff --git a/.env.example b/.env.example index 7f92260..e4d8194 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 729eead..971c74e 100644 --- a/README.md +++ b/README.md @@ -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_.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_.pdf` | **Voll** pro Benutzer: Plesk-Mailpostfach + Kerio + Nextcloud | +| `zugangsdaten_minimal_.pdf` | **Minimal** pro Benutzer: nur Email (Kerio) + Cloud (Nextcloud) | +| `zugangsdaten_gesamt_.pdf` | Sammel-PDF aller voller Datensätze | +| `zugangsdaten_gesamt_minimal_.pdf` | Sammel-PDF aller minimalen Datensätze | +| `_admin_report_.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/` | ü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) diff --git a/config.py b/config.py index 36c3ca6..ee6e314 100644 --- a/config.py +++ b/config.py @@ -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) diff --git a/deploy.py b/deploy.py index 91f3e91..ca1bfa8 100644 --- a/deploy.py +++ b/deploy.py @@ -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}") diff --git a/gui.py b/gui.py index 25be047..ae206a3 100644 --- a/gui.py +++ b/gui.py @@ -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() diff --git a/pdf.py b/pdf.py index 6a45b57..442b320 100644 --- a/pdf.py +++ b/pdf.py @@ -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)