From 06d7e00e495b1fc9b705d556af073838da7ce741 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 12 May 2026 14:09:06 +0200 Subject: [PATCH] =?UTF-8?q?fix(kerio):=20korrekte=20Admin-API=20gem=C3=A4?= =?UTF-8?q?=C3=9F=20Delivery.idl=20+=20Pop3Account-Doku?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Methoden: Delivery.getPop3AccountList / addPop3AccountList / setPop3Account (vorher geraten als Pop3Accounts.set/.create → Method not found). - Pop3Account-Felder mit den richtigen Namen: isActive (statt enabled), mode (statt sslMode), authentication (statt authType), und leaveOnServer.removeAfterPeriod als OptionalLong-Wrapper. Falsche Namen wurden von Kerio still ignoriert → Sammler war inaktiv. - User-Struct: allowPasswordChange=false (statt mayChangePassword, das es nicht gibt). emailAddresses weggelassen, Kerio leitet die primäre Adresse aus loginName+domain ab. - Kerio-Step in 2 Sub-Steps aufgeteilt: User (skip wenn vorhanden) + POP3 (upsert). Damit wird bei einem zweiten Lauf der Sammler nicht übersprungen, nur weil der User schon existiert. - POP3-Sammler ist jetzt UPSERT: existierende werden via setPop3Account überschrieben → Selbstreparatur kaputter Einträge + Passwort- Änderungen aus der CSV ziehen sich von selbst nach. GUI: 👁/🙈-Toggle pro Passwort-Feld (Klartext temporär einsehbar). Filenames der Sammel-PDFs + Admin-Report ohne Zeitstempel – erneuter Lauf überschreibt statt anzuhäufen. README: Ablauf-Sektion + Idempotenz-Tabelle aktualisiert; Kerio- Caveat ersetzt durch konkrete Methoden-/Feld-Liste mit Doku-Link. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 85 ++++++++++++++++---------- clients/kerio.py | 155 ++++++++++++++++++++++++++++++++++++++--------- deploy.py | 51 +++++++++------- gui.py | 30 +++++++-- 4 files changed, 235 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index e9d078a..6d9b099 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,10 @@ python deploy.py Das Fenster startet maximiert. Jeder Account ist eine **Karte** mit zwei Zeilen: oben die Person (Vorname, Name, Email) + Lösch-Button, unten drei Service-Gruppen (Plesk / Kerio / Nextcloud) mit Host + Passwort. Passwort- -Felder sind maskiert. Die Karten sind mit `#1, #2, …` durchnummeriert, -Fehlermeldungen referenzieren diese Nummern. +Felder sind per Default maskiert (`•`); ein **👁-Toggle** neben jedem +Passwort schaltet temporär auf Klartext (🙈) und wieder zurück. Die Karten +sind mit `#1, #2, …` durchnummeriert, Fehlermeldungen referenzieren diese +Nummern. Buttons in der Toolbar: @@ -186,52 +188,66 @@ mit dem Pfad zur Sammel-PDF. ``` Plesk: Mailpostfach anlegen - PLESK_BACKEND=manual → übersprungen (manuell in Plesk-GUI anlegen) - - PLESK_BACKEND=api/ssh → automatisch + - PLESK_BACKEND=api/ssh → automatisch via `plesk bin mail` ↓ -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). +Kerio User: anlegen oder überspringen wenn schon vorhanden. + Beim Anlegen wird `allowPasswordChange=false` gesetzt + (User darf sein Passwort nicht selbst ändern). + ↓ +Kerio POP3-Sammler: UPSERT – existierender wird via Delivery.setPop3Account + mit aktuellen Werten überschrieben, sonst neu via + Delivery.addPop3AccountList. So werden alte/kaputte Sammler + automatisch repariert und Passwort-Änderungen aus der CSV + ziehen sich beim nächsten Lauf von selbst nach. + (Port aus POP3_PORT=995, SSL Auto-Erkennung, + 14 Tage auf Plesk belassen.) ↓ Nextcloud: User vorname.nachname mit Gruppe + Quota anlegen + (übersprungen wenn vorhanden). ↓ -PDF (einzeln) schreiben +PDFs schreiben (Einzel + Sammel, voll + minimal) und Admin-Report. ``` -Am Ende werden pro Lauf vier Dokument-Sorten geschrieben (alle in `output/`): +Am Ende werden pro Lauf folgende Dateien geschrieben (alle in `output/`, +**keine Zeitstempel** im Namen → erneuter Lauf überschreibt): -| 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. | +| 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 (✓/·/⚠/✗) inkl. Zeitstempel im Header. **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 +### Idempotenz – bestehende Konten werden übersprungen oder aktualisiert -Jeder Dienst wird **einzeln** geprüft, bevor angelegt wird: +Jeder Sub-Schritt wird **einzeln** geprüft. Im Admin-Report stehen die +Schritte separat (z.B. `Kerio User` und `Kerio POP3` als zwei Zeilen): -| 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** | +| Sub-Schritt | Prüfung | Wenn vorhanden | +| ------------- | ------------------------------------------------------------- | ----------------------------------------------- | +| Plesk | `mail --info` (api/ssh) bzw. ohnehin Manual-Modus | übersprungen | +| Kerio User | `Users.get` mit `loginName` + `domainId` | übersprungen | +| Kerio POP3 | `Delivery.getPop3AccountList`, Match auf (deliveryAddress, server, userName) | **aktualisiert** via `Delivery.setPop3Account` (kein Skip! repariert kaputte Einträge + zieht CSV-Änderungen mit) | +| 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`. +→ Eine CSV kann gefahrlos mehrfach laufen. Statuswerte: +`✓ angelegt` / `✓ aktualisiert` / `· übersprungen` / `⚠ manuell` / `✗ Fehler`. **Caveats**: -- Kerio prüft `loginName` *in der zur Mailadresse passenden Domain* – Aliase - auf einer anderen Domain werden nicht erkannt. +- Kerio-User-Prüfung matcht `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. +- Kerio-POP3 wird absichtlich aktualisiert statt übersprungen: ohne diese + Selbstreparatur bliebe ein einmal kaputt angelegter Sammler dauerhaft + defekt. Nebeneffekt: ein in der Kerio-Webadmin manuell verstellter + Sammler wird beim nächsten Tool-Lauf wieder auf die CSV-Werte gezogen. ## Workflow bei `PLESK_BACKEND=manual` (Shared Host) @@ -248,11 +264,14 @@ willst. Status pro Konto und Dienst steht im Admin-Report als ## 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. +- **Kerio API-Methoden** entsprechen der offiziellen Kerio-Connect-Doku + (Delivery.idl + User-Struct, siehe [Kerio Admin API Reference](https://manuals.gfi.com/en/kerio/api/connect/admin/reference/jsonrpc_specification.html)). + Konkret verwendet: `Session.login`, `Domains.get`, `Users.get`, + `Users.create`, `Delivery.getPop3AccountList`, + `Delivery.addPop3AccountList`, `Delivery.setPop3Account`. Pop3Account- + Felder: `isActive`, `server`, `port`, `mode` (NoSsl/SpecialPort/StlsCommand), + `authentication` (PlainPop3/Apop), `userName`, `password`, + `deliveryAddress`, `leaveOnServer{enabled, removeAfterPeriod{enabled,value}}`. - **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 diff --git a/clients/kerio.py b/clients/kerio.py index 14b95ca..dbe4379 100644 --- a/clients/kerio.py +++ b/clients/kerio.py @@ -108,12 +108,13 @@ class KerioClient: raise KerioError(f"Kerio: Domain '{domain_name}' nicht in Kerio konfiguriert") return items[0]["id"] - def user_exists(self, email: str) -> bool: + def find_user_id(self, email: str) -> Optional[str]: + """User-ID aus E-Mail finden, oder None wenn nicht vorhanden.""" local, domain = email.split("@", 1) try: domain_id = self.get_domain_id(domain) except KerioError: - return False + return None result = self._call("Users.get", { "query": { "fields": ["id", "loginName"], @@ -129,23 +130,75 @@ class KerioClient: }, "domainId": domain_id, }) - return bool(result.get("list")) + items = result.get("list") or [] + if not items: + return None + return items[0].get("id") + + def user_exists(self, email: str) -> bool: + return self.find_user_id(email) is not None + + # ----- POP3 Download Accounts ----- + # Korrekte API laut Kerio Connect Doku (Delivery.idl): + # Delivery.getPop3AccountList() → {"list": [Pop3Account, …]} + # Delivery.addPop3AccountList(accs) → {"errors": [...]} + # Delivery.removePop3AccountList(ids) + # Delivery.setPop3Account(id, acc) + # Delivery.runPop3Downloads() + + def _pop3_get_all(self) -> list: + # SearchQuery ist Pflicht-Input – ohne den liefert Kerio eine leere Liste + # (still!), und unser Existenz-Check würde immer "nicht vorhanden" sagen. + got = self._call("Delivery.getPop3AccountList", { + "query": { + "fields": [], + "start": 0, + "limit": 100000, + "orderBy": [], + "conditions": [], + "combining": "And", + }, + }) + return got.get("list") or [] + + def find_pop3_collector(self, *, deliver_to_email: str, + server: str, login_name: str) -> Optional[dict]: + """Sucht einen Sammler über (deliveryAddress, server, userName). + Gibt das Pop3Account-Dict zurück (inkl. id) oder None.""" + try: + accounts = self._pop3_get_all() + except KerioError: + return None + server_l = (server or "").lower() + login_l = (login_name or "").lower() + deliver_l = (deliver_to_email or "").lower() + for acc in accounts: + if ((acc.get("deliveryAddress") or "").lower() == deliver_l + and (acc.get("server") or "").lower() == server_l + and (acc.get("userName") or "").lower() == login_l): + return acc + return None + + def pop3_collector_exists(self, **kw) -> bool: + return self.find_pop3_collector(**kw) is not None def create_user(self, email: str, password: str, full_name: str) -> str: local, domain = email.split("@", 1) domain_id = self.get_domain_id(domain) + # Minimal-Set: + # - authType/role weggelassen (versions-spezifische Enums, Default reicht) + # - emailAddresses weggelassen: das ist die Alias-Liste in Kerio. + # Die primäre Adresse leitet Kerio automatisch aus loginName@domain + # ab, also `local@domain`. + # - allowPasswordChange=false: User kann sein Passwort NICHT selbst + # ändern (Feldname laut User-Struct in der Kerio-Doku). 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, + "allowPasswordChange": False, } result = self._call("Users.create", {"users": [user_def]}) errors = result.get("errors") or [] @@ -158,24 +211,70 @@ class KerioClient: # ----- 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]}) + def upsert_pop3_collection(self, *, deliver_to_email: str, + server: str, login_name: str, password: str, + port: int = 995, ssl: bool = True, + leave_days: int = 14) -> str: + """Legt einen POP3-Sammler an oder aktualisiert ihn (Idempotent). + + Returns: "angelegt" | "aktualisiert" + """ + existing = self.find_pop3_collector( + deliver_to_email=deliver_to_email, + server=server, login_name=login_name, + ) + new_account = self._build_pop3_account( + deliver_to_email=deliver_to_email, server=server, + login_name=login_name, password=password, + port=port, ssl=ssl, leave_days=leave_days, + ) + if existing: + account_id = existing.get("id") + if not account_id: + raise KerioError("Kerio: vorhandener Sammler ohne id zurückgegeben") + new_account["id"] = account_id + self._call("Delivery.setPop3Account", + {"accountId": account_id, "account": new_account}) + return "aktualisiert" + self._add_pop3_account_list([new_account]) + return "angelegt" + + def _add_pop3_account_list(self, accounts: list) -> None: + result = self._call("Delivery.addPop3AccountList", + {"accounts": accounts}) errors = result.get("errors") or [] if errors: - raise KerioError(f"Kerio Pop3Accounts.create errors: {errors}") + raise KerioError(f"Kerio Delivery.addPop3AccountList errors: {errors}") + + def _build_pop3_account(self, *, deliver_to_email: str, + server: str, login_name: str, password: str, + port: int, ssl: bool, leave_days: int) -> dict: + # Pop3Account-Felder, exakt wie in der Kerio-Doku + # (struct Pop3Account + LeaveOnServer): + # isActive (bool, NICHT "enabled"!) – sonst defaultet auf inaktiv + # server, port, userName, password, deliveryAddress + # mode: SslMode = NoSsl | SpecialPort | StlsCommand + # authentication: Pop3Authentication = PlainPop3 | Apop + # leaveOnServer { enabled, removeAfterPeriod: OptionalLong{enabled,value} } + new_account = { + "isActive": True, + "server": server, + "port": port, + "mode": "SpecialPort" if ssl else "NoSsl", + "authentication": "PlainPop3", + "userName": login_name, + "password": password, + "deliveryAddress": deliver_to_email, + "leaveOnServer": { + "enabled": True, + "removeAfterPeriod": { + "enabled": True, + "value": leave_days, + }, + }, + } + return new_account + + # Backwards-kompatibler Alias + def add_pop3_collection(self, **kw) -> None: + self.upsert_pop3_collection(**kw) diff --git a/deploy.py b/deploy.py index ca1bfa8..87ed15a 100644 --- a/deploy.py +++ b/deploy.py @@ -121,7 +121,8 @@ def _deploy_one(account: Account, cfg: Config, log: Callable[[str], None]) -> Ac log(f" ✗ FEHLER: {e}") # Wir machen mit Kerio/Nextcloud trotzdem weiter – die sind unabhängig. - # 2) Kerio + # 2) Kerio – User und POP3-Sammler werden GETRENNT geprüft, damit ein + # bereits angelegter User nicht den Sammler-Schritt überspringt. log(f" → Kerio User+POP3-Sammler: {account.emailadresse} @ {account.keriohost}") try: kerio = KerioClient( @@ -130,25 +131,34 @@ def _deploy_one(account: Account, cfg: Config, log: Callable[[str], None]) -> Ac 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") + # 2a) User + uid = kerio.find_user_id(account.emailadresse) + if uid: + result.steps.append(StepResult( + "Kerio User", "übersprungen", "existiert bereits")) + log(" · User existiert bereits") 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=cfg.POP3_SSL, - leave_days=cfg.POP3_KEEP_DAYS, - ) - result.steps.append(StepResult("Kerio", "angelegt", "inkl. POP3-Sammler")) - log(" ✓ angelegt + POP3-Sammler") + result.steps.append(StepResult("Kerio User", "angelegt")) + log(" ✓ User angelegt") + + # 2b) POP3-Sammler – upsert (anlegen wenn neu, sonst aktualisieren). + # So werden auch alte / kaputt konfigurierte Sammler beim + # nächsten Lauf automatisch repariert. + pop_status = kerio.upsert_pop3_collection( + deliver_to_email=account.emailadresse, + server=account.pleskhost, + login_name=account.emailadresse, + password=account.pleskemailkennwort, + port=cfg.POP3_PORT, ssl=cfg.POP3_SSL, + leave_days=cfg.POP3_KEEP_DAYS, + ) + result.steps.append(StepResult("Kerio POP3", pop_status)) + log(f" ✓ POP3-Sammler {pop_status}") finally: kerio.logout() except Exception as e: @@ -233,9 +243,8 @@ def pdf_only(accounts: List[Account], cfg: Config, 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" + combined = output_dir / "zugangsdaten_gesamt.pdf" + combined_min = output_dir / "zugangsdaten_gesamt_minimal.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}") @@ -261,15 +270,15 @@ def run_deploy(accounts: List[Account], cfg: Config, 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" + combined = output_dir / "zugangsdaten_gesamt.pdf" + combined_min = output_dir / "zugangsdaten_gesamt_minimal.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" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + report = output_dir / "_admin_report.txt" _write_admin_report(report, results, cfg, timestamp) log(f"✓ Admin-Report (nicht für Kunden!): {report}") return results, combined diff --git a/gui.py b/gui.py index 329b35c..fc6c92e 100644 --- a/gui.py +++ b/gui.py @@ -77,10 +77,12 @@ class AccountRow(ttk.Frame): cell.grid(row=1, column=col, padx=2, sticky="w") ttk.Label(cell, text=label, font=("Helvetica", 8)).pack(anchor="w") - show = "•" if "kennwort" in key else None - e = ttk.Entry(cell, width=width, show=show) - e.pack() - self.entries[key] = e + if "kennwort" in key: + self._make_password_entry(cell, key, width) + else: + e = ttk.Entry(cell, width=width) + e.pack() + self.entries[key] = e def _make_field(self, parent, key, label, width, side="left"): cell = ttk.Frame(parent) @@ -90,6 +92,26 @@ class AccountRow(ttk.Frame): e.pack() self.entries[key] = e + def _make_password_entry(self, parent, key, width): + """Passwort-Entry + Auge-Toggle nebenan.""" + row = ttk.Frame(parent) + row.pack() + e = ttk.Entry(row, width=width, show="•") + e.pack(side="left") + btn = ttk.Button(row, text="👁", width=2) + btn.config(command=lambda: self._toggle_password(e, btn)) + btn.pack(side="left", padx=(2, 0)) + self.entries[key] = e + + @staticmethod + def _toggle_password(entry, btn): + if entry.cget("show"): + entry.config(show="") + btn.config(text="🙈") + else: + entry.config(show="•") + btn.config(text="👁") + def to_dict(self) -> dict: return {k: e.get().strip() for k, e in self.entries.items()}