Compare commits
4
Commits
e951c839ca
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06d7e00e49 | ||
|
|
4711c55d89 | ||
|
|
b88c766dd6 | ||
|
|
dda5c746ce |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user