Compare commits

..
4 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.7 06d7e00e49 fix(kerio): korrekte Admin-API gemäß Delivery.idl + Pop3Account-Doku
- 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) <noreply@anthropic.com>
2026-05-12 14:09:06 +02:00
duffyduckandClaude Opus 4.7 4711c55d89 gui: Karten-Layout, Vollbild-Start, CSV-Speichern-Button
- Eine Karte pro Account mit zwei Zeilen: Person oben (Vorname/Name/Email
  + Lösch-Button), Service-Felder unten in drei Gruppen (Plesk/Kerio/
  Nextcloud) mit blauen Überschriften und vertikalen Trennlinien.
- Karten haben Rand, füllen die volle Fensterbreite und sind mit #1, #2, …
  durchnummeriert; Pflichtfeld-Fehlermeldungen referenzieren diese Nummern.
- Passwort-Felder maskiert (•••).
- Fenster startet maximiert: -zoomed (Linux/X11) → state(zoomed) (Windows)
  → Fallback Bildschirmgeometrie.
- Neuer Toolbar-Button "CSV speichern …" exportiert die ausgefüllten
  Karten im selben Format wie example.csv (Round-Trip-fähig).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:13:41 +02:00
duffyduckandClaude Opus 4.7 b88c766dd6 fix: Hostnamen normalisieren (Schema/Slash strippen) + freundlichere Nextcloud-Fehler
Bug: nextcloudhost mit https://-Präfix in CSV/GUI führte zu Doppel-Schema
(https://https://cloud.foo.de) und damit zu DNS-Fehler "host=https".

- models.clean_host() entfernt http(s):// und Trailing-Slash; Account
  ruft das im __post_init__ für plesk-/kerio-/nextcloudhost auf.
- NextcloudClient wickelt requests-Connection-Fehler in NextcloudError
  ein, damit die Log-Ausgabe lesbar bleibt statt HTTPSConnectionPool-Stacktrace.
- README: Hostname-Eingabe als tolerant dokumentiert; pleskhost-Pflicht
  im manual-Modus klarer (POP3-Sammler braucht es trotzdem).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:05:51 +02:00
duffyduckandClaude Opus 4.7 dda5c746ce 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>
2026-05-12 12:15:39 +02:00
9 changed files with 644 additions and 136 deletions
+8
View File
@@ -36,7 +36,15 @@ NEXTCLOUD_ADMIN_PASSWORD=
# --- Mailserver-Ports für POP3-Sammler in Kerio --- # --- Mailserver-Ports für POP3-Sammler in Kerio ---
POP3_PORT=995 POP3_PORT=995
POP3_KEEP_DAYS=14 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 SMTP_PORT=465
IMAP_PORT=993
# TLS-Zertifikate prüfen? false nur bei selbstsignierten Test-Zertifikaten. # TLS-Zertifikate prüfen? false nur bei selbstsignierten Test-Zertifikaten.
VERIFY_TLS=true VERIFY_TLS=true
+116 -28
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_USER` | Admin-User (App-Passwort empfohlen) |
| `NEXTCLOUD_ADMIN_PASSWORD` | Admin- oder App-Passwort | | `NEXTCLOUD_ADMIN_PASSWORD` | Admin- oder App-Passwort |
| `POP3_PORT` | POP3-Sammler in Kerio (default `995` = POP3S) | | `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`) | | `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 | | `VERIFY_TLS` | `false` nur bei Test/selbst signierten Zertifikaten |
--- ---
@@ -95,7 +97,7 @@ case-insensitive. Beispiel: [`example.csv`](./example.csv).
| `Vorname` | ja | Vorname | | `Vorname` | ja | Vorname |
| `Name` | ja | Nachname | | `Name` | ja | Nachname |
| `emailadresse` | ja | volle Mailadresse, ist Login bei Plesk + Kerio | | `emailadresse` | ja | volle Mailadresse, ist Login bei Plesk + Kerio |
| `pleskhost` | ja | Hostname Plesk-Mailserver (auch POP3-Server für Kerio-Sammler) | | `pleskhost` | ja | Hostname Plesk-Mailserver **auch im `manual`-Modus Pflicht**, weil Kerio davon per POP3 abholt |
| `keriohost` | ja | Hostname Kerio Connect (Admin-API auf Port 4040, Webmail auf 443) | | `keriohost` | ja | Hostname Kerio Connect (Admin-API auf Port 4040, Webmail auf 443) |
| `nextcloudhost` | ja | Nextcloud-Hostname | | `nextcloudhost` | ja | Nextcloud-Hostname |
| `kerioemailkennwort` | ja | Passwort für den Kerio-User | | `kerioemailkennwort` | ja | Passwort für den Kerio-User |
@@ -108,16 +110,40 @@ case-insensitive. Beispiel: [`example.csv`](./example.csv).
> (`vorname.nachname`, lowercase, ä→ae, ö→oe, ü→ue, ß→ss). Die Emailadresse > (`vorname.nachname`, lowercase, ä→ae, ö→oe, ü→ue, ß→ss). Die Emailadresse
> aus der CSV wird im Nextcloud-Profil als E-Mail eingetragen. > aus der CSV wird im Nextcloud-Profil als E-Mail eingetragen.
> **Hostnamen-Eingabe ist tolerant**: `cloud.foo.de`, `https://cloud.foo.de`
> und `https://cloud.foo.de/` werden alle akzeptiert Schema (`http(s)://`)
> und Trailing-Slash werden automatisch gestrippt, sodass beim URL-Bau
> kein doppeltes `https://https://…` entsteht.
--- ---
## Aufruf ## Aufruf
### CLI ### CLI
Voller Lauf (Plesk + Kerio + Nextcloud + PDFs):
```bash ```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`. Exit-Code `0` wenn alle Konten ohne Fehler verarbeitet wurden, sonst `1`.
### GUI ### GUI
@@ -128,12 +154,32 @@ python deploy.py --gui
python deploy.py python deploy.py
``` ```
In der GUI: Das Fenster startet maximiert. Jeder Account ist eine **Karte** mit zwei
- Per **+ Zeile** beliebig viele Konten hinzufügen. Zeilen: oben die Person (Vorname, Name, Email) + Lösch-Button, unten drei
- **CSV laden** füllt die Felder aus einer bestehenden CSV. Service-Gruppen (Plesk / Kerio / Nextcloud) mit Host + Passwort. Passwort-
- **Ausgabeordner** wählen, dann **Ausführen ▶**. Felder sind per Default maskiert (`•`); ein **👁-Toggle** neben jedem
- Log unten zeigt Fortschritt; bei jedem Konto entsteht eine Passwort schaltet temporär auf Klartext (🙈) und wieder zurück. Die Karten
Einzel-PDF, am Ende eine Sammel-PDF mit Zeitstempel. sind mit `#1, #2, …` durchnummeriert, Fehlermeldungen referenzieren diese
Nummern.
Buttons in der Toolbar:
| Button | Wirkung |
| ------------------ | ------------------------------------------------------------------ |
| `+ Zeile` | Neue leere Karte am Ende anhängen |
| `CSV laden …` | Felder aus einer bestehenden CSV befüllen |
| `CSV speichern …` | Aktuell ausgefüllte Karten als CSV exportieren (Round-Trip-fähig) |
| `Ausgabeordner …` | Zielverzeichnis für PDFs + Admin-Report wählen |
| `📄 Nur PDF` | Nur PDFs schreiben, keine API-Aufrufe |
| `Ausführen ▶` | Konten anlegen + PDFs + Admin-Report |
| `✕ löschen` (rot) | Karte entfernen (auch die letzte es bleibt eine leere) |
> Die per `CSV speichern …` exportierte Datei enthält **Klartext-Passwörter**
> sicher ablegen / verschlüsselt versenden, und die `*.csv` ist via
> `.gitignore` (Ausnahme `example.csv`) standardmäßig vom Repo ausgeschlossen.
Log unten im Fenster zeigt Fortschritt; nach dem Lauf öffnet eine Hinweis-Box
mit dem Pfad zur Sammel-PDF.
--- ---
@@ -142,27 +188,66 @@ In der GUI:
``` ```
Plesk: Mailpostfach anlegen Plesk: Mailpostfach anlegen
- PLESK_BACKEND=manual → übersprungen (manuell in Plesk-GUI 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 Kerio User: anlegen oder überspringen wenn schon vorhanden.
(Port aus POP3_PORT=995, SSL, 14 Tage auf Server belassen). Beim Anlegen wird `allowPasswordChange=false` gesetzt
Kennwort-ändern ist für den User gesperrt (mayChangePassword=False). (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 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: Am Ende werden pro Lauf folgende Dateien geschrieben (alle in `output/`,
- eine **Einzel-PDF** pro Konto + eine **Sammel-PDF** beide enthalten **keine Zeitstempel** im Namen → erneuter Lauf überschreibt):
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 | Datei | Inhalt |
Admin-Report entsprechend markiert. Damit kann eine CSV gefahrlos | -------------------------------------- | --------------------------------------------------------------------- |
zweimal laufen. | `zugangsdaten_<email>.pdf` | **Voll** pro Benutzer: Plesk-Mailpostfach + Kerio + Nextcloud |
| `zugangsdaten_minimal_<email>.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 oder aktualisiert
Jeder Sub-Schritt wird **einzeln** geprüft. Im Admin-Report stehen die
Schritte separat (z.B. `Kerio User` und `Kerio POP3` als zwei Zeilen):
| 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/<vorname.nachname>` | übersprungen (keine Quota-/Gruppen-Änderung) |
→ Eine CSV kann gefahrlos mehrfach laufen. Statuswerte:
`✓ angelegt` / `✓ aktualisiert` / `· übersprungen` / `⚠ manuell` / `✗ Fehler`.
**Caveats**:
- 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) ## Workflow bei `PLESK_BACKEND=manual` (Shared Host)
@@ -179,11 +264,14 @@ zweimal laufen.
## Hinweise / Caveats ## Hinweise / Caveats
- **Kerio API-Methoden**: die JSON-RPC-Methodennamen für POP3-Sammler - **Kerio API-Methoden** entsprechen der offiziellen Kerio-Connect-Doku
(`Pop3Accounts.create`) entsprechen Kerio Connect 9.x. Falls deine (Delivery.idl + User-Struct, siehe [Kerio Admin API Reference](https://manuals.gfi.com/en/kerio/api/connect/admin/reference/jsonrpc_specification.html)).
Version andere Methoden nutzt, ist die Originalmeldung von Kerio in Konkret verwendet: `Session.login`, `Domains.get`, `Users.get`,
der Fehlerausgabe (mit Methodenname) sichtbar dann in `Users.create`, `Delivery.getPop3AccountList`,
[`clients/kerio.py`](clients/kerio.py) anpassen. `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 REST-CLI-Wrapper**: nutzt `/api/v2/cli/mail/call` (entspricht
`plesk bin mail`). Verfügbar ab Plesk Obsidian. `plesk bin mail`). Verfügbar ab Plesk Obsidian.
- **Nextcloud OCS**: die Admin-Credentials brauchen das Recht „Benutzer - **Nextcloud OCS**: die Admin-Credentials brauchen das Recht „Benutzer
+127 -28
View File
@@ -108,12 +108,13 @@ class KerioClient:
raise KerioError(f"Kerio: Domain '{domain_name}' nicht in Kerio konfiguriert") raise KerioError(f"Kerio: Domain '{domain_name}' nicht in Kerio konfiguriert")
return items[0]["id"] 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) local, domain = email.split("@", 1)
try: try:
domain_id = self.get_domain_id(domain) domain_id = self.get_domain_id(domain)
except KerioError: except KerioError:
return False return None
result = self._call("Users.get", { result = self._call("Users.get", {
"query": { "query": {
"fields": ["id", "loginName"], "fields": ["id", "loginName"],
@@ -129,23 +130,75 @@ class KerioClient:
}, },
"domainId": domain_id, "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: def create_user(self, email: str, password: str, full_name: str) -> str:
local, domain = email.split("@", 1) local, domain = email.split("@", 1)
domain_id = self.get_domain_id(domain) 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 = { user_def = {
"loginName": local, "loginName": local,
"fullName": full_name, "fullName": full_name,
"domainId": domain_id, "domainId": domain_id,
"password": password, "password": password,
"authType": "Internal",
"isEnabled": True, "isEnabled": True,
"role": "UserRole", "allowPasswordChange": False,
"emailAddresses": [email],
# User darf sein Passwort NICHT selbst ändern
"mayChangePassword": False,
"forceChangePassword": False,
} }
result = self._call("Users.create", {"users": [user_def]}) result = self._call("Users.create", {"users": [user_def]})
errors = result.get("errors") or [] errors = result.get("errors") or []
@@ -158,24 +211,70 @@ class KerioClient:
# ----- POP3 Sammler ----- # ----- POP3 Sammler -----
def add_pop3_collection(self, *, kerio_user_id: str, def upsert_pop3_collection(self, *, deliver_to_email: str,
server: str, login_name: str, password: str, server: str, login_name: str, password: str,
port: int = 465, ssl: bool = True, port: int = 995, ssl: bool = True,
leave_days: int = 14) -> None: leave_days: int = 14) -> str:
account = { """Legt einen POP3-Sammler an oder aktualisiert ihn (Idempotent).
"enabled": True,
"deliverTo": kerio_user_id, Returns: "angelegt" | "aktualisiert"
"server": server, """
"loginName": login_name, existing = self.find_pop3_collector(
"password": password, deliver_to_email=deliver_to_email,
"port": port, server=server, login_name=login_name,
"ssl": ssl, )
"useSpecificPort": True, new_account = self._build_pop3_account(
"leaveOnServer": True, deliver_to_email=deliver_to_email, server=server,
"deleteOnServer": True, login_name=login_name, password=password,
"deleteOnServerDays": leave_days, port=port, ssl=ssl, leave_days=leave_days,
} )
result = self._call("Pop3Accounts.create", {"accounts": [account]}) 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 [] errors = result.get("errors") or []
if errors: 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)
+14 -9
View File
@@ -31,8 +31,17 @@ class NextcloudClient:
raise NextcloudError(f"Nextcloud {op}: ungültige Antwort: {r.text[:300]}") raise NextcloudError(f"Nextcloud {op}: ungültige Antwort: {r.text[:300]}")
return data.get("ocs", {}) return data.get("ocs", {})
def _request(self, method: str, url: str, op: str, **kw):
try:
return self.session.request(method, url, timeout=30, **kw)
except requests.RequestException as e:
raise NextcloudError(
f"Nextcloud {op} Verbindung zu {url} fehlgeschlagen: {e}"
) from e
def user_exists(self, userid: str) -> bool: def user_exists(self, userid: str) -> bool:
r = self.session.get(f"{self.base}/ocs/v2.php/cloud/users/{userid}") r = self._request("GET", f"{self.base}/ocs/v2.php/cloud/users/{userid}",
op="user-lookup")
ocs = self._meta(r, "user-lookup") ocs = self._meta(r, "user-lookup")
sc = ocs.get("meta", {}).get("statuscode") sc = ocs.get("meta", {}).get("statuscode")
return sc in (100, 200) return sc in (100, 200)
@@ -40,10 +49,8 @@ class NextcloudClient:
def ensure_group(self, group: str) -> None: def ensure_group(self, group: str) -> None:
if not group: if not group:
return return
r = self.session.post( r = self._request("POST", f"{self.base}/ocs/v2.php/cloud/groups",
f"{self.base}/ocs/v2.php/cloud/groups", op="group-create", data={"groupid": group})
data={"groupid": group},
)
ocs = self._meta(r, "group-create") ocs = self._meta(r, "group-create")
sc = ocs.get("meta", {}).get("statuscode") sc = ocs.get("meta", {}).get("statuscode")
# 100/200 = ok, 102 = exists already # 100/200 = ok, 102 = exists already
@@ -66,10 +73,8 @@ class NextcloudClient:
body.append(("groups[]", group)) body.append(("groups[]", group))
body.append(("quota", f"{quota_gb} GB" if quota_gb else "none")) body.append(("quota", f"{quota_gb} GB" if quota_gb else "none"))
r = self.session.post( r = self._request("POST", f"{self.base}/ocs/v2.php/cloud/users",
f"{self.base}/ocs/v2.php/cloud/users", op="user-create", data=body)
data=body,
)
ocs = self._meta(r, "user-create") ocs = self._meta(r, "user-create")
sc = ocs.get("meta", {}).get("statuscode") sc = ocs.get("meta", {}).get("statuscode")
if sc not in (100, 200): if sc not in (100, 200):
+11
View File
@@ -44,5 +44,16 @@ class Config:
POP3_PORT = int(os.getenv("POP3_PORT", "995")) POP3_PORT = int(os.getenv("POP3_PORT", "995"))
POP3_KEEP_DAYS = int(os.getenv("POP3_KEEP_DAYS", "14")) POP3_KEEP_DAYS = int(os.getenv("POP3_KEEP_DAYS", "14"))
SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) 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) VERIFY_TLS = _bool(os.getenv("VERIFY_TLS"), True)
+74 -20
View File
@@ -9,7 +9,10 @@ from typing import Callable, List, Tuple
from config import Config from config import Config
from models import Account, AccountResult, StepResult 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: def _detect_delimiter(path: Path) -> str:
@@ -118,7 +121,8 @@ def _deploy_one(account: Account, cfg: Config, log: Callable[[str], None]) -> Ac
log(f" ✗ FEHLER: {e}") log(f" ✗ FEHLER: {e}")
# Wir machen mit Kerio/Nextcloud trotzdem weiter die sind unabhängig. # 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}") log(f" → Kerio User+POP3-Sammler: {account.emailadresse} @ {account.keriohost}")
try: try:
kerio = KerioClient( kerio = KerioClient(
@@ -127,25 +131,34 @@ def _deploy_one(account: Account, cfg: Config, log: Callable[[str], None]) -> Ac
port=cfg.KERIO_PORT, verify=cfg.VERIFY_TLS, port=cfg.KERIO_PORT, verify=cfg.VERIFY_TLS,
) )
try: try:
if kerio.user_exists(account.emailadresse): # 2a) User
result.steps.append(StepResult("Kerio", "übersprungen", "User existiert bereits")) uid = kerio.find_user_id(account.emailadresse)
log(" · existiert bereits, POP3-Sammler nicht neu angelegt") if uid:
result.steps.append(StepResult(
"Kerio User", "übersprungen", "existiert bereits"))
log(" · User existiert bereits")
else: else:
uid = kerio.create_user( uid = kerio.create_user(
account.emailadresse, account.emailadresse,
account.kerioemailkennwort, account.kerioemailkennwort,
account.vollname, account.vollname,
) )
kerio.add_pop3_collection( result.steps.append(StepResult("Kerio User", "angelegt"))
kerio_user_id=uid, log(" ✓ User angelegt")
server=account.pleskhost,
login_name=account.emailadresse, # 2b) POP3-Sammler upsert (anlegen wenn neu, sonst aktualisieren).
password=account.pleskemailkennwort, # So werden auch alte / kaputt konfigurierte Sammler beim
port=cfg.POP3_PORT, ssl=True, # nächsten Lauf automatisch repariert.
leave_days=cfg.POP3_KEEP_DAYS, pop_status = kerio.upsert_pop3_collection(
) deliver_to_email=account.emailadresse,
result.steps.append(StepResult("Kerio", "angelegt", "inkl. POP3-Sammler")) server=account.pleskhost,
log(" ✓ angelegt + POP3-Sammler") 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: finally:
kerio.logout() kerio.logout()
except Exception as e: except Exception as e:
@@ -212,6 +225,33 @@ def _write_admin_report(path: Path, results: List[AccountResult],
path.write_text("\n".join(lines), encoding="utf-8") 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}")
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}")
return combined
def run_deploy(accounts: List[Account], cfg: Config, def run_deploy(accounts: List[Account], cfg: Config,
output_dir, log: Callable[[str], None] = print output_dir, log: Callable[[str], None] = print
) -> Tuple[List[AccountResult], Path]: ) -> Tuple[List[AccountResult], Path]:
@@ -224,15 +264,21 @@ def run_deploy(accounts: List[Account], cfg: Config,
results.append(result) results.append(result)
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in 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_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: {per_pdf}")
log(f" ⤷ PDF (minimal): {per_min}")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") combined = output_dir / "zugangsdaten_gesamt.pdf"
combined = output_dir / f"zugangsdaten_gesamt_{timestamp}.pdf" combined_min = output_dir / "zugangsdaten_gesamt_minimal.pdf"
write_combined_pdf(combined, accounts, cfg.SMTP_PORT, cfg.POP3_PORT) 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: {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) _write_admin_report(report, results, cfg, timestamp)
log(f"✓ Admin-Report (nicht für Kunden!): {report}") log(f"✓ Admin-Report (nicht für Kunden!): {report}")
return results, combined return results, combined
@@ -245,6 +291,8 @@ def main():
p.add_argument("--csv", help="Pfad zur CSV-Datei (CLI-Modus)") 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("--output", default="./output", help="Verzeichnis für PDFs (default ./output)")
p.add_argument("--gui", action="store_true", help="GUI starten") 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() args = p.parse_args()
if args.gui or not args.csv: if args.gui or not args.csv:
@@ -259,6 +307,12 @@ def main():
print(f"CSV-Fehler: {e}", file=sys.stderr) print(f"CSV-Fehler: {e}", file=sys.stderr)
sys.exit(2) sys.exit(2)
print(f"{len(accounts)} Account(s) eingelesen.\n") 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) results, combined = run_deploy(accounts, cfg, args.output)
failed = sum(1 for r in results if r.has_errors) failed = sum(1 for r in results if r.has_errors)
print(f"\nFertig. {len(results) - failed}/{len(results)} ohne Fehler. Gesamt-PDF: {combined}") print(f"\nFertig. {len(results) - failed}/{len(results)} ohne Fehler. Gesamt-PDF: {combined}")
+218 -45
View File
@@ -1,41 +1,116 @@
"""Tkinter-GUI mit Endlosfeldern (eine Zeile = ein Account).""" """Tkinter-GUI mit Endlosfeldern (eine Karte = ein Account)."""
from __future__ import annotations from __future__ import annotations
import csv
import threading import threading
from pathlib import Path from pathlib import Path
import tkinter as tk import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext, ttk from tkinter import filedialog, messagebox, scrolledtext, ttk
from config import Config from config import Config
from deploy import parse_csv, run_deploy from deploy import parse_csv, pdf_only, run_deploy
from models import Account from models import Account
FIELDS = [ # Felder gruppiert: (key, label, breite, gruppe)
("vorname", "Vorname", 14), # gruppe = None → kommt in die obere "Person"-Zeile
("name", "Name", 14), GROUPS = {
("emailadresse", "Emailadresse", 22), "person": [
("pleskhost", "Plesk-Host", 18), ("vorname", "Vorname", 14),
("keriohost", "Kerio-Host", 18), ("name", "Name", 14),
("nextcloudhost", "Nextcloud-Host", 18), ("emailadresse", "Emailadresse", 34),
("pleskemailkennwort", "Plesk Mail-PW", 14), ],
("kerioemailkennwort", "Kerio PW", 14), "Plesk": [
("nextcloudkennwort", "Nextcloud PW", 14), ("pleskhost", "Host", 20),
("nextcloudgruppe", "NC Gruppe", 14), ("pleskemailkennwort", "Passwort", 16),
("nextcloudspeicher", "NC GB (leer=∞)", 12), ],
] "Kerio": [
("keriohost", "Host", 20),
("kerioemailkennwort", "Passwort", 16),
],
"Nextcloud": [
("nextcloudhost", "Host", 20),
("nextcloudkennwort", "Passwort", 16),
("nextcloudgruppe", "Gruppe", 12),
("nextcloudspeicher", "GB (∞=leer)", 10),
],
}
ALL_FIELD_KEYS = [k for grp in GROUPS.values() for (k, _l, _w) in grp]
class AccountRow(ttk.Frame): class AccountRow(ttk.Frame):
def __init__(self, master, on_remove): def __init__(self, master, on_remove, index_label_var: tk.StringVar):
super().__init__(master) super().__init__(master, padding=(8, 6, 8, 8),
borderwidth=1, relief="solid")
self.entries: dict[str, ttk.Entry] = {} self.entries: dict[str, ttk.Entry] = {}
for col, (key, _label, width) in enumerate(FIELDS):
e = ttk.Entry(self, width=width) # ---------- obere Zeile: Index-Chip + Person + löschen ----------
e.grid(row=0, column=col, padx=2, pady=2, sticky="ew") top = ttk.Frame(self)
self.entries[key] = e top.pack(fill="x", pady=(0, 6))
ttk.Button(self, text="", width=3,
command=lambda: on_remove(self)).grid( ttk.Label(top, textvariable=index_label_var,
row=0, column=len(FIELDS), padx=4) font=("Helvetica", 10, "bold"),
foreground="#666").pack(side="left", padx=(0, 12))
for key, label, width in GROUPS["person"]:
self._make_field(top, key, label, width, side="left")
ttk.Button(top, text="✕ löschen", style="Remove.TButton",
command=lambda: on_remove(self)).pack(side="right")
# ---------- untere Zeile: drei Service-Gruppen ----------
bot = ttk.Frame(self)
bot.pack(fill="x")
for i, group_name in enumerate(("Plesk", "Kerio", "Nextcloud")):
if i > 0:
ttk.Separator(bot, orient="vertical").pack(
side="left", fill="y", padx=10, pady=2)
grp = ttk.Frame(bot)
grp.pack(side="left", anchor="n")
ttk.Label(grp, text=group_name,
font=("Helvetica", 9, "bold"),
foreground="#3060a0").grid(row=0, column=0,
columnspan=4, sticky="w")
for col, (key, label, width) in enumerate(GROUPS[group_name]):
cell = ttk.Frame(grp)
cell.grid(row=1, column=col, padx=2, sticky="w")
ttk.Label(cell, text=label,
font=("Helvetica", 8)).pack(anchor="w")
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)
cell.pack(side=side, padx=4)
ttk.Label(cell, text=label, font=("Helvetica", 8)).pack(anchor="w")
e = ttk.Entry(cell, width=width)
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: def to_dict(self) -> dict:
return {k: e.get().strip() for k, e in self.entries.items()} return {k: e.get().strip() for k, e in self.entries.items()}
@@ -51,30 +126,56 @@ class App(tk.Tk):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.title("Email / Cloud Account Deployment") self.title("Email / Cloud Account Deployment")
self.geometry("1500x800")
self.output_dir = Path("./output").resolve() self.output_dir = Path("./output").resolve()
self.rows: list[AccountRow] = [] self.rows: list[AccountRow] = []
self.row_index_vars: list[tk.StringVar] = []
# Roter Lösch-Button-Style
style = ttk.Style(self)
try:
style.configure("Remove.TButton", foreground="#b00020")
except tk.TclError:
pass
self._maximize()
self._build() self._build()
self.add_row() self.add_row()
# ----- Window-Modi -----
def _maximize(self):
"""Fenster auf Vollbild bzw. maximiert öffnen, plattform-unabhängig."""
# Linux X11
try:
self.attributes("-zoomed", True)
return
except tk.TclError:
pass
# Windows
try:
self.state("zoomed")
return
except tk.TclError:
pass
# Fallback: Bildschirmgröße
self.update_idletasks()
sw = self.winfo_screenwidth()
sh = self.winfo_screenheight()
self.geometry(f"{sw}x{sh}+0+0")
# ----- Layout ----- # ----- Layout -----
def _build(self): def _build(self):
toolbar = ttk.Frame(self) toolbar = ttk.Frame(self)
toolbar.pack(fill="x", padx=8, pady=6) 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="+ 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="CSV laden …", command=self.load_csv).pack(side="left", padx=2)
ttk.Button(toolbar, text="CSV speichern …", command=self.save_csv).pack(side="left", padx=2)
ttk.Button(toolbar, text="Ausgabeordner …", command=self.choose_output).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 = ttk.Label(toolbar, text=f"Output: {self.output_dir}")
self.output_lbl.pack(side="left", padx=8) self.output_lbl.pack(side="left", padx=8)
self.run_btn = ttk.Button(toolbar, text="Ausführen ▶", command=self.run) self.run_btn = ttk.Button(toolbar, text="Ausführen ▶", command=self.run)
self.run_btn.pack(side="right", padx=2) self.run_btn.pack(side="right", padx=2)
self.pdf_btn = ttk.Button(toolbar, text="📄 Nur PDF", command=self.run_pdf_only)
# Header über die Eingabezeilen self.pdf_btn.pack(side="right", padx=2)
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 # Scrollbares Eingabefeld
wrap = ttk.Frame(self) wrap = ttk.Frame(self)
@@ -85,11 +186,17 @@ class App(tk.Tk):
self.canvas.pack(side="left", fill="both", expand=True) self.canvas.pack(side="left", fill="both", expand=True)
sb.pack(side="right", fill="y") sb.pack(side="right", fill="y")
self.rows_frame = ttk.Frame(self.canvas) self.rows_frame = ttk.Frame(self.canvas)
self.canvas.create_window((0, 0), window=self.rows_frame, anchor="nw") self.rows_window = self.canvas.create_window(
(0, 0), window=self.rows_frame, anchor="nw")
self.rows_frame.bind( self.rows_frame.bind(
"<Configure>", "<Configure>",
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")), lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")),
) )
# Karten füllen die volle Breite des Canvas
self.canvas.bind(
"<Configure>",
lambda e: self.canvas.itemconfigure(self.rows_window, width=e.width),
)
# Mousewheel-Scrollen # Mousewheel-Scrollen
self.canvas.bind_all("<MouseWheel>", self.canvas.bind_all("<MouseWheel>",
lambda e: self.canvas.yview_scroll(int(-e.delta / 120), "units")) lambda e: self.canvas.yview_scroll(int(-e.delta / 120), "units"))
@@ -104,19 +211,30 @@ class App(tk.Tk):
self.log.pack(fill="x", padx=8, pady=(0, 8)) self.log.pack(fill="x", padx=8, pady=(0, 8))
# ----- Reihen-Handling ----- # ----- Reihen-Handling -----
def _renumber(self):
for i, var in enumerate(self.row_index_vars, 1):
var.set(f"#{i}")
def add_row(self, data: dict | None = None): def add_row(self, data: dict | None = None):
row = AccountRow(self.rows_frame, on_remove=self.remove_row) idx_var = tk.StringVar(value="")
row.pack(fill="x", pady=1) row = AccountRow(self.rows_frame, on_remove=self.remove_row,
index_label_var=idx_var)
row.pack(fill="x", pady=4, padx=2)
self.row_index_vars.append(idx_var)
self.rows.append(row)
if data: if data:
row.from_dict(data) row.from_dict(data)
self.rows.append(row) self._renumber()
def remove_row(self, row): def remove_row(self, row):
if len(self.rows) <= 1: idx = self.rows.index(row)
row.from_dict({}) # leere statt entfernen self.rows.pop(idx)
return self.row_index_vars.pop(idx)
self.rows.remove(row)
row.destroy() row.destroy()
if not self.rows:
self.add_row()
else:
self._renumber()
# ----- Buttons ----- # ----- Buttons -----
def load_csv(self): def load_csv(self):
@@ -132,6 +250,7 @@ class App(tk.Tk):
for r in self.rows: for r in self.rows:
r.destroy() r.destroy()
self.rows = [] self.rows = []
self.row_index_vars = []
for acc in accounts: for acc in accounts:
self.add_row({ self.add_row({
"vorname": acc.vorname, "vorname": acc.vorname,
@@ -151,6 +270,49 @@ class App(tk.Tk):
self.add_row() self.add_row()
self._log(f"CSV geladen: {len(accounts)} Account(s)") self._log(f"CSV geladen: {len(accounts)} Account(s)")
# Spalten-Reihenfolge wie in example.csv das, was parse_csv erwartet.
CSV_COLUMNS = [
("Vorname", "vorname"),
("Name", "name"),
("emailadresse", "emailadresse"),
("pleskhost", "pleskhost"),
("keriohost", "keriohost"),
("nextcloudhost", "nextcloudhost"),
("kerioemailkennwort", "kerioemailkennwort"),
("pleskemailkennwort", "pleskemailkennwort"),
("nextcloudgruppe", "nextcloudgruppe"),
("nextcloudspeicher", "nextcloudspeicher"),
("nextcloudkennwort", "nextcloudkennwort"),
]
def save_csv(self):
rows_with_data = [r for r in self.rows
if r.to_dict().get("emailadresse")]
if not rows_with_data:
messagebox.showinfo("Hinweis",
"Keine ausgefüllten Karten zum Speichern (Emailadresse leer).")
return
path = filedialog.asksaveasfilename(
defaultextension=".csv",
initialfile="accounts.csv",
filetypes=[("CSV (Semikolon)", "*.csv")])
if not path:
return
try:
with open(path, "w", encoding="utf-8", newline="") as f:
w = csv.writer(f, delimiter=";")
w.writerow([h for h, _k in self.CSV_COLUMNS])
for row in rows_with_data:
d = row.to_dict()
w.writerow([d.get(k, "") for _h, k in self.CSV_COLUMNS])
except OSError as e:
messagebox.showerror("Speicher-Fehler", str(e))
return
self._log(f"CSV gespeichert: {path} ({len(rows_with_data)} Zeile(n))")
messagebox.showinfo("Gespeichert",
f"{len(rows_with_data)} Zeile(n) in\n{path}\ngespeichert.\n\n"
"⚠ Datei enthält Klartext-Passwörter sicher ablegen / verschlüsselt versenden.")
def choose_output(self): def choose_output(self):
path = filedialog.askdirectory(initialdir=str(self.output_dir)) path = filedialog.askdirectory(initialdir=str(self.output_dir))
if path: if path:
@@ -170,7 +332,7 @@ class App(tk.Tk):
"nextcloudkennwort"] "nextcloudkennwort"]
missing = [k for k in required if not d.get(k)] missing = [k for k in required if not d.get(k)]
if missing: if missing:
raise ValueError(f"Zeile {idx}: Pflichtfelder leer: {', '.join(missing)}") raise ValueError(f"Karte #{idx}: Pflichtfelder leer: {', '.join(missing)}")
quota = int(d["nextcloudspeicher"]) if d.get("nextcloudspeicher") else None quota = int(d["nextcloudspeicher"]) if d.get("nextcloudspeicher") else None
accounts.append(Account( accounts.append(Account(
name=d["name"], vorname=d["vorname"], name=d["name"], vorname=d["vorname"],
@@ -189,7 +351,7 @@ class App(tk.Tk):
self.log.insert("end", msg + "\n") self.log.insert("end", msg + "\n")
self.log.see("end") self.log.see("end")
def run(self): def _start_worker(self, fn, success_title: str):
try: try:
accounts = self._collect_accounts() accounts = self._collect_accounts()
except ValueError as e: except ValueError as e:
@@ -201,21 +363,32 @@ class App(tk.Tk):
cfg = Config() cfg = Config()
self.log.delete("1.0", "end") self.log.delete("1.0", "end")
self.run_btn.config(state="disabled") self.run_btn.config(state="disabled")
self.pdf_btn.config(state="disabled")
def worker(): def worker():
try: try:
_, combined = run_deploy(accounts, cfg, self.output_dir, combined = fn(accounts, cfg, self.output_dir,
log=lambda m: self.after(0, self._log, m)) 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( 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: except Exception as e:
self.after(0, lambda: self._log(f"FEHLER: {e}")) self.after(0, lambda: self._log(f"FEHLER: {e}"))
self.after(0, lambda: messagebox.showerror("Fehler", str(e))) self.after(0, lambda: messagebox.showerror("Fehler", str(e)))
finally: finally:
self.after(0, lambda: self.run_btn.config(state="normal")) 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() 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(): def launch():
App().mainloop() App().mainloop()
+19
View File
@@ -2,6 +2,19 @@ from dataclasses import dataclass, field
from typing import List, Optional from typing import List, Optional
def clean_host(s: str) -> str:
"""`https://cloud.foo.de/` → `cloud.foo.de`.
Akzeptiert die Eingaben unabhängig davon, ob der User Schema/Slash
mit eingetippt hat. Verhindert Doppel-Schemas wie
`https://https://cloud.foo.de` beim Bauen von URLs.
"""
s = (s or "").strip()
if "://" in s:
s = s.split("://", 1)[1]
return s.rstrip("/")
@dataclass @dataclass
class Account: class Account:
name: str name: str
@@ -16,6 +29,12 @@ class Account:
nextcloudspeicher: Optional[int] = None # GB; None = unlimitiert nextcloudspeicher: Optional[int] = None # GB; None = unlimitiert
nextcloudkennwort: str = "" nextcloudkennwort: str = ""
def __post_init__(self):
self.pleskhost = clean_host(self.pleskhost)
self.keriohost = clean_host(self.keriohost)
self.nextcloudhost = clean_host(self.nextcloudhost)
self.emailadresse = (self.emailadresse or "").strip()
@property @property
def vollname(self) -> str: def vollname(self) -> str:
return f"{self.vorname} {self.name}".strip() return f"{self.vorname} {self.name}".strip()
+57 -6
View File
@@ -31,7 +31,8 @@ def _section(title: str, rows, col_widths=(5 * cm, 11 * cm)):
return elements 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() styles = getSampleStyleSheet()
elements = [ elements = [
Paragraph(f"Zugangsdaten {account.vollname}", styles["Title"]), 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."), ("Hinweis", "Anmeldung nur am Mailserver keine Plesk-Webinterface-Anmeldung."),
]) ])
pop_proto = "SSL" if pop_ssl else "unverschlüsselt"
elements += _section("Kerio Connect (Hauptmailkonto)", [ elements += _section("Kerio Connect (Hauptmailkonto)", [
("Webmail", f"https://{account.keriohost}/webmail/"), ("Webmail", f"https://{account.keriohost}/webmail/"),
("Benutzername", account.emailadresse), ("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)"), ("SMTP-Server (Versand)", f"{account.keriohost}:{smtp_port} (SSL)"),
("IMAP/POP-Server", f"{account.keriohost}"), ("IMAP/POP-Server", f"{account.keriohost}"),
("POP3-Sammler", ("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"), 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, 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( doc = SimpleDocTemplate(
str(path), pagesize=A4, str(path), pagesize=A4,
leftMargin=2 * cm, rightMargin=2 * cm, leftMargin=2 * cm, rightMargin=2 * cm,
topMargin=2 * cm, bottomMargin=2 * cm, topMargin=2 * cm, bottomMargin=2 * cm,
title=f"Zugangsdaten {account.vollname}", 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], 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( doc = SimpleDocTemplate(
str(path), pagesize=A4, str(path), pagesize=A4,
leftMargin=2 * cm, rightMargin=2 * cm, leftMargin=2 * cm, rightMargin=2 * cm,
@@ -91,7 +93,56 @@ def write_combined_pdf(path: Path, accounts: List[Account],
) )
flow = [] flow = []
for i, acc in enumerate(accounts): 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: if i < len(accounts) - 1:
flow.append(PageBreak()) flow.append(PageBreak())
doc.build(flow) doc.build(flow)