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 ---
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
+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_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 |
---
@@ -95,7 +97,7 @@ case-insensitive. Beispiel: [`example.csv`](./example.csv).
| `Vorname` | ja | Vorname |
| `Name` | ja | Nachname |
| `emailadresse` | ja | volle Mailadresse, ist Login bei Plesk + Kerio |
| `pleskhost` | ja | Hostname Plesk-Mailserver (auch POP3-Server für Kerio-Sammler) |
| `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) |
| `nextcloudhost` | ja | Nextcloud-Hostname |
| `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
> 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
### 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
@@ -128,12 +154,32 @@ python deploy.py --gui
python deploy.py
```
In der GUI:
- Per **+ Zeile** beliebig viele Konten hinzufügen.
- **CSV laden** füllt die Felder aus einer bestehenden CSV.
- **Ausgabeordner** wählen, dann **Ausführen ▶**.
- Log unten zeigt Fortschritt; bei jedem Konto entsteht eine
Einzel-PDF, am Ende eine Sammel-PDF mit Zeitstempel.
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 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:
| 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_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:
- eine **Einzel-PDF** pro Konto + eine **Sammel-PDF** beide enthalten
ausschließlich Zugangsdaten und sind 1:1 an den Kunden weitergebbar.
- ein **`_admin_report_<timestamp>.txt`** mit Status pro Konto
(✓ angelegt / · übersprungen / ⚠ manuell / ✗ Fehler) **NICHT für
den Kunden**, das ist deine Admin-Übersicht.
Am Ende werden pro Lauf folgende Dateien geschrieben (alle in `output/`,
**keine Zeitstempel** im Namen → erneuter Lauf überschreibt):
Bestehende Konten werden **übersprungen** (kein Fehler) und im
Admin-Report entsprechend markiert. Damit kann eine CSV gefahrlos
zweimal laufen.
| Datei | Inhalt |
| -------------------------------------- | --------------------------------------------------------------------- |
| `zugangsdaten_<email>.pdf` | **Voll** pro Benutzer: Plesk-Mailpostfach + Kerio + Nextcloud |
| `zugangsdaten_minimal_<email>.pdf` | **Minimal** pro Benutzer: nur Email (Kerio) + Cloud (Nextcloud) |
| `zugangsdaten_gesamt.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)
@@ -179,11 +264,14 @@ zweimal laufen.
## 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
+127 -28
View File
@@ -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)
+14 -9
View File
@@ -31,8 +31,17 @@ class NextcloudClient:
raise NextcloudError(f"Nextcloud {op}: ungültige Antwort: {r.text[:300]}")
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:
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")
sc = ocs.get("meta", {}).get("statuscode")
return sc in (100, 200)
@@ -40,10 +49,8 @@ class NextcloudClient:
def ensure_group(self, group: str) -> None:
if not group:
return
r = self.session.post(
f"{self.base}/ocs/v2.php/cloud/groups",
data={"groupid": group},
)
r = self._request("POST", f"{self.base}/ocs/v2.php/cloud/groups",
op="group-create", data={"groupid": group})
ocs = self._meta(r, "group-create")
sc = ocs.get("meta", {}).get("statuscode")
# 100/200 = ok, 102 = exists already
@@ -66,10 +73,8 @@ class NextcloudClient:
body.append(("groups[]", group))
body.append(("quota", f"{quota_gb} GB" if quota_gb else "none"))
r = self.session.post(
f"{self.base}/ocs/v2.php/cloud/users",
data=body,
)
r = self._request("POST", f"{self.base}/ocs/v2.php/cloud/users",
op="user-create", data=body)
ocs = self._meta(r, "user-create")
sc = ocs.get("meta", {}).get("statuscode")
if sc not in (100, 200):
+11
View File
@@ -44,5 +44,16 @@ class Config:
POP3_PORT = int(os.getenv("POP3_PORT", "995"))
POP3_KEEP_DAYS = int(os.getenv("POP3_KEEP_DAYS", "14"))
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
IMAP_PORT = int(os.getenv("IMAP_PORT", "993"))
# SSL für POP3-Sammler:
# - explizit per POP3_SSL=true|false setzbar
# - sonst Auto-Erkennung anhand des Ports:
# 110 (Plain POP3) → SSL aus, alles andere (995, 465, …) → SSL an
_pop3_ssl_env = os.getenv("POP3_SSL")
if _pop3_ssl_env is None or _pop3_ssl_env == "":
POP3_SSL = POP3_PORT != 110
else:
POP3_SSL = _bool(_pop3_ssl_env, True)
VERIFY_TLS = _bool(os.getenv("VERIFY_TLS"), True)
+74 -20
View File
@@ -9,7 +9,10 @@ from typing import Callable, List, Tuple
from config import Config
from models import Account, AccountResult, StepResult
from pdf import write_combined_pdf, write_user_pdf
from pdf import (
write_combined_minimal_pdf, write_combined_pdf,
write_user_minimal_pdf, write_user_pdf,
)
def _detect_delimiter(path: Path) -> str:
@@ -118,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(
@@ -127,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=True,
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:
@@ -212,6 +225,33 @@ 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}")
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,
output_dir, log: Callable[[str], None] = print
) -> Tuple[List[AccountResult], Path]:
@@ -224,15 +264,21 @@ 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 = 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
@@ -245,6 +291,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 +307,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}")
+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
import csv
import threading
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext, ttk
from config import Config
from deploy import parse_csv, run_deploy
from deploy import parse_csv, pdf_only, run_deploy
from models import Account
FIELDS = [
("vorname", "Vorname", 14),
("name", "Name", 14),
("emailadresse", "Emailadresse", 22),
("pleskhost", "Plesk-Host", 18),
("keriohost", "Kerio-Host", 18),
("nextcloudhost", "Nextcloud-Host", 18),
("pleskemailkennwort", "Plesk Mail-PW", 14),
("kerioemailkennwort", "Kerio PW", 14),
("nextcloudkennwort", "Nextcloud PW", 14),
("nextcloudgruppe", "NC Gruppe", 14),
("nextcloudspeicher", "NC GB (leer=∞)", 12),
]
# Felder gruppiert: (key, label, breite, gruppe)
# gruppe = None → kommt in die obere "Person"-Zeile
GROUPS = {
"person": [
("vorname", "Vorname", 14),
("name", "Name", 14),
("emailadresse", "Emailadresse", 34),
],
"Plesk": [
("pleskhost", "Host", 20),
("pleskemailkennwort", "Passwort", 16),
],
"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):
def __init__(self, master, on_remove):
super().__init__(master)
def __init__(self, master, on_remove, index_label_var: tk.StringVar):
super().__init__(master, padding=(8, 6, 8, 8),
borderwidth=1, relief="solid")
self.entries: dict[str, ttk.Entry] = {}
for col, (key, _label, width) in enumerate(FIELDS):
e = ttk.Entry(self, width=width)
e.grid(row=0, column=col, padx=2, pady=2, sticky="ew")
self.entries[key] = e
ttk.Button(self, text="", width=3,
command=lambda: on_remove(self)).grid(
row=0, column=len(FIELDS), padx=4)
# ---------- obere Zeile: Index-Chip + Person + löschen ----------
top = ttk.Frame(self)
top.pack(fill="x", pady=(0, 6))
ttk.Label(top, textvariable=index_label_var,
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:
return {k: e.get().strip() for k, e in self.entries.items()}
@@ -51,30 +126,56 @@ class App(tk.Tk):
def __init__(self):
super().__init__()
self.title("Email / Cloud Account Deployment")
self.geometry("1500x800")
self.output_dir = Path("./output").resolve()
self.rows: list[AccountRow] = []
self.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.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 -----
def _build(self):
toolbar = ttk.Frame(self)
toolbar.pack(fill="x", padx=8, pady=6)
ttk.Button(toolbar, text="+ Zeile", command=self.add_row).pack(side="left", padx=2)
ttk.Button(toolbar, text="CSV laden …", command=self.load_csv).pack(side="left", padx=2)
ttk.Button(toolbar, text="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)
self.output_lbl = ttk.Label(toolbar, text=f"Output: {self.output_dir}")
self.output_lbl.pack(side="left", padx=8)
self.run_btn = ttk.Button(toolbar, text="Ausführen ▶", command=self.run)
self.run_btn.pack(side="right", padx=2)
# Header über die Eingabezeilen
header = ttk.Frame(self)
header.pack(fill="x", padx=8)
for col, (_k, label, width) in enumerate(FIELDS):
ttk.Label(header, text=label, font=("Helvetica", 9, "bold"),
width=width, anchor="w").grid(row=0, column=col, padx=2, sticky="w")
self.pdf_btn = ttk.Button(toolbar, text="📄 Nur PDF", command=self.run_pdf_only)
self.pdf_btn.pack(side="right", padx=2)
# Scrollbares Eingabefeld
wrap = ttk.Frame(self)
@@ -85,11 +186,17 @@ class App(tk.Tk):
self.canvas.pack(side="left", fill="both", expand=True)
sb.pack(side="right", fill="y")
self.rows_frame = ttk.Frame(self.canvas)
self.canvas.create_window((0, 0), window=self.rows_frame, anchor="nw")
self.rows_window = self.canvas.create_window(
(0, 0), window=self.rows_frame, anchor="nw")
self.rows_frame.bind(
"<Configure>",
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")),
)
# 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
self.canvas.bind_all("<MouseWheel>",
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))
# ----- 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):
row = AccountRow(self.rows_frame, on_remove=self.remove_row)
row.pack(fill="x", pady=1)
idx_var = tk.StringVar(value="")
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:
row.from_dict(data)
self.rows.append(row)
self._renumber()
def remove_row(self, row):
if len(self.rows) <= 1:
row.from_dict({}) # leere statt entfernen
return
self.rows.remove(row)
idx = self.rows.index(row)
self.rows.pop(idx)
self.row_index_vars.pop(idx)
row.destroy()
if not self.rows:
self.add_row()
else:
self._renumber()
# ----- Buttons -----
def load_csv(self):
@@ -132,6 +250,7 @@ class App(tk.Tk):
for r in self.rows:
r.destroy()
self.rows = []
self.row_index_vars = []
for acc in accounts:
self.add_row({
"vorname": acc.vorname,
@@ -151,6 +270,49 @@ class App(tk.Tk):
self.add_row()
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):
path = filedialog.askdirectory(initialdir=str(self.output_dir))
if path:
@@ -170,7 +332,7 @@ class App(tk.Tk):
"nextcloudkennwort"]
missing = [k for k in required if not d.get(k)]
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
accounts.append(Account(
name=d["name"], vorname=d["vorname"],
@@ -189,7 +351,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 +363,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()
+19
View File
@@ -2,6 +2,19 @@ from dataclasses import dataclass, field
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
class Account:
name: str
@@ -16,6 +29,12 @@ class Account:
nextcloudspeicher: Optional[int] = None # GB; None = unlimitiert
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
def vollname(self) -> str:
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
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)