--adopt: verwaistes Mailbox-Verzeichnis einem Benutzer zuordnen
Fuer den Fall, dass der alte Benutzer schon geloescht und der neue von Hand angelegt wurde - dann liegt nur noch das Verzeichnis herum. users.cfg bleibt unangetastet, es wandert allein das Verzeichnis. Geprueft wird vorher, dass der Zielbenutzer existiert und der alte nicht mehr; gibt es ihn noch, verweist das Script auf die normale Umbenennung. Hat das Ziel schon ein Verzeichnis mit Mails, bricht es ab - mit --force wird das bestehende als <name>.abgeloest-<zeitstempel> beiseitegeschoben statt geloescht. Die Zielpruefung arbeitet jetzt als Folge statt paarweise: bei der Uebernahme raeumt der erste Schritt das Ziel, das der zweite belegt. Eine isolierte Pruefung haette das als Konflikt gemeldet. Dazu die Kontaktkarte: .personal traegt Namen und Adresse des vorherigen Eigentuemers. Sie wird jetzt umgeschrieben statt entfernt - Kerio legt sie naemlich nicht neu an, wenn sie fehlt, weder beim Neustart noch bei einer Aenderung ueber die Admin-API. Das gilt auch fuer die normale Umbenennung, wo bisher die alte Adresse stehenblieb. Beides gegen den Testserver verifiziert: nach der Uebernahme sieht der Zielbenutzer alle Mails samt Unterordnern und Flags, die Kontaktkarte stimmt, users.cfg ist unveraendert und neue Mail wird zugestellt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c4883d42bb
commit
da64a15486
@@ -29,6 +29,16 @@ danach geprüft:
|
|||||||
| Verwaistes Verzeichnis unter dem alten Namen | keines |
|
| Verwaistes Verzeichnis unter dem alten Namen | keines |
|
||||||
| GUID und Passwort-Hash des Benutzers | unverändert |
|
| GUID und Passwort-Hash des Benutzers | unverändert |
|
||||||
|
|
||||||
|
Und für `--adopt`, mit gelöschtem Altbenutzer und neu angelegtem Zielbenutzer:
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
| --- | --- |
|
||||||
|
| Alle 7 Mails beim neuen Benutzer sichtbar | ja, inklusive Unterordner und Flags |
|
||||||
|
| Kontaktkarte `.personal` | auf neuen Namen und Adresse umgeschrieben |
|
||||||
|
| `users.cfg` | unverändert |
|
||||||
|
| Neue Mail an den Zielbenutzer | zugestellt |
|
||||||
|
| Verwaistes Verzeichnis | keines übrig |
|
||||||
|
|
||||||
Geändert haben sich nur Kerios eigene Index- und Statusdateien (`.journal.db`,
|
Geändert haben sich nur Kerios eigene Index- und Statusdateien (`.journal.db`,
|
||||||
`status.fld`, `index.fld`) — die schreibt der Server bei jedem Zugriff neu.
|
`status.fld`, `index.fld`) — die schreibt der Server bei jedem Zugriff neu.
|
||||||
|
|
||||||
@@ -37,6 +47,7 @@ Dazu Unit-Tests unter [tests/](tests/):
|
|||||||
```bash
|
```bash
|
||||||
python3 tests/test_store.py
|
python3 tests/test_store.py
|
||||||
python3 tests/test_users_config.py
|
python3 tests/test_users_config.py
|
||||||
|
python3 tests/test_adopt.py
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -121,11 +132,47 @@ sudo ./kerio_rename_user.py --domain firma.de \
|
|||||||
--old-login anna.mueller --full-name "Anna Schmidt"
|
--old-login anna.mueller --full-name "Anna Schmidt"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Verwaistes Verzeichnis übernehmen (`--adopt`)
|
||||||
|
|
||||||
|
Für den Fall, dass der alte Benutzer **schon gelöscht** und der neue **schon
|
||||||
|
angelegt** ist — dann liegt nur noch das Mailbox-Verzeichnis herum und soll dem
|
||||||
|
neuen Benutzer zugeordnet werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ./kerio_rename_user.py --adopt \
|
||||||
|
--domain firma.de \
|
||||||
|
--old-login anna.mueller \
|
||||||
|
--new-login anna.schmidt
|
||||||
|
```
|
||||||
|
|
||||||
|
`--old-login` ist hier der Name des **verwaisten Verzeichnisses**, `--new-login`
|
||||||
|
der **bereits existierende** Zielbenutzer. `users.cfg` wird dabei nicht
|
||||||
|
angefasst — es wandert nur das Verzeichnis.
|
||||||
|
|
||||||
|
Geprüft wird vorher:
|
||||||
|
|
||||||
|
- Der Zielbenutzer existiert in `users.cfg` (sonst liefe die Mailbox ins Leere).
|
||||||
|
- Der alte Benutzer existiert **nicht** mehr. Gibt es ihn noch, bricht das
|
||||||
|
Script ab und verweist auf die normale Umbenennung, die auch `users.cfg`
|
||||||
|
mitzieht.
|
||||||
|
- Hat der Zielbenutzer schon ein Verzeichnis mit Mails darin, bricht das Script
|
||||||
|
ab. Mit `--force` wird das bestehende Verzeichnis als
|
||||||
|
`<name>.abgeloest-<zeitstempel>` beiseitegeschoben statt gelöscht — die Mails
|
||||||
|
bleiben also erhalten, werden aber **nicht** zusammengeführt.
|
||||||
|
|
||||||
|
Nach der Übernahme wird die Kontaktkarte des Benutzers (`.personal`) auf den
|
||||||
|
neuen Namen und die neue Adresse umgeschrieben, `stats.usr` beiseitegelegt —
|
||||||
|
die legt Kerio selbst neu an.
|
||||||
|
|
||||||
|
**Das Kennwort kommt nicht mit.** Der Zielbenutzer meldet sich weiterhin mit
|
||||||
|
seinem eigenen an; übernommen wird ausschließlich der Mailbestand.
|
||||||
|
|
||||||
## Optionen
|
## Optionen
|
||||||
|
|
||||||
| Option | Bedeutung |
|
| Option | Bedeutung |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `--list` | Benutzer aus `users.cfg` auflisten |
|
| `--list` | Benutzer aus `users.cfg` auflisten |
|
||||||
|
| `--adopt` | Verwaistes Verzeichnis einem bestehenden Benutzer zuordnen |
|
||||||
| `--domain` | Mail-Domain, z. B. `firma.de` |
|
| `--domain` | Mail-Domain, z. B. `firma.de` |
|
||||||
| `--old-login`, `--new-login` | Login-Namen ohne `@domain` |
|
| `--old-login`, `--new-login` | Login-Namen ohne `@domain` |
|
||||||
| `--full-name` | Neuer angezeigter Name |
|
| `--full-name` | Neuer angezeigter Name |
|
||||||
@@ -136,22 +183,32 @@ sudo ./kerio_rename_user.py --domain firma.de \
|
|||||||
| `--service-wait` | Sekunden Wartezeit aufs Stoppen, Standard 90 |
|
| `--service-wait` | Sekunden Wartezeit aufs Stoppen, Standard 90 |
|
||||||
| `--dry-run` | Nur anzeigen |
|
| `--dry-run` | Nur anzeigen |
|
||||||
| `--yes` | Rückfrage überspringen |
|
| `--yes` | Rückfrage überspringen |
|
||||||
| `--force` | Auch bei Benutzern aus einem Verzeichnisdienst |
|
| `--force` | Bei Verzeichnisdienst-Benutzern; mit `--adopt`: belegtes Ziel beiseiteschieben |
|
||||||
|
|
||||||
`--service-manager none` fasst den Dienst nicht an — dafür muss Kerio dann
|
`--service-manager none` fasst den Dienst nicht an — dafür muss Kerio dann
|
||||||
selbst gestoppt sein, bevor das Script läuft.
|
selbst gestoppt sein, bevor das Script läuft.
|
||||||
|
|
||||||
|
## Die Kontaktkarte
|
||||||
|
|
||||||
|
`<mailbox>/.personal` ist die persönliche vCard des Benutzers und trägt seinen
|
||||||
|
Namen und seine Adresse. Das Script schreibt sie in beiden Modi auf den neuen
|
||||||
|
Stand um — Adresse, `FN:` und die Betreffzeile.
|
||||||
|
|
||||||
|
Löschen wäre keine Alternative: **Kerio legt die Datei nicht neu an**, wenn sie
|
||||||
|
fehlt. Weder beim Dienstneustart noch bei einer Änderung am Benutzer über die
|
||||||
|
Admin-API (geprüft gegen 10.0.9). Der Benutzer stünde dauerhaft ohne eigene
|
||||||
|
Kontaktkarte da.
|
||||||
|
|
||||||
## Was das Script bewusst nicht anfasst
|
## Was das Script bewusst nicht anfasst
|
||||||
|
|
||||||
Nach der Umbenennung steht der alte Name noch an ein paar Stellen, die reine
|
Der alte Name bleibt an zwei Stellen stehen, die reine Anzeige betreffen und den
|
||||||
Anzeige betreffen und den Mailbetrieb nicht stören:
|
Mailbetrieb nicht stören:
|
||||||
|
|
||||||
- `.personal` — die vCard des Benutzers, enthält noch die alte Adresse
|
|
||||||
- `#public/Contacts/` — der Eintrag in der globalen Adressliste
|
- `#public/Contacts/` — der Eintrag in der globalen Adressliste
|
||||||
- `.caldav.db` / `.carddav.db` — die DAV-Datenbanken
|
- `.caldav.db` / `.carddav.db` — die DAV-Datenbanken
|
||||||
|
|
||||||
Kerio schreibt `.personal` beim Start teilweise selbst neu. Wer die Adresse dort
|
Wer die sofort korrekt haben will, korrigiert den Kontakt in der
|
||||||
sofort korrekt haben will, korrigiert den Kontakt in der Administrationskonsole.
|
Administrationskonsole.
|
||||||
|
|
||||||
Kommt der alte Name in `users.cfg` außerhalb der Benutzerlisten vor (etwa in
|
Kommt der alte Name in `users.cfg` außerhalb der Benutzerlisten vor (etwa in
|
||||||
einem Alias), meldet das Script das im Plan und ändert es **nicht** automatisch —
|
einem Alias), meldet das Script das im Plan und ändert es **nicht** automatisch —
|
||||||
|
|||||||
+269
-4
@@ -31,6 +31,16 @@ Beispiele:
|
|||||||
--full-name "Anna Schmidt" \\
|
--full-name "Anna Schmidt" \\
|
||||||
--dry-run
|
--dry-run
|
||||||
|
|
||||||
|
Mit --adopt laesst sich ein verwaistes Mailbox-Verzeichnis einem bereits
|
||||||
|
angelegten Benutzer zuordnen - fuer den Fall, dass der alte Benutzer schon
|
||||||
|
geloescht und der neue von Hand neu angelegt wurde. users.cfg bleibt dabei
|
||||||
|
unangetastet, es wandert nur das Verzeichnis:
|
||||||
|
|
||||||
|
sudo ./kerio_rename_user.py --adopt \\
|
||||||
|
--domain firma.de \\
|
||||||
|
--old-login anna.mueller \\
|
||||||
|
--new-login anna.schmidt
|
||||||
|
|
||||||
Ohne --dry-run wird vor dem Schreiben nachgefragt (ausser mit --yes).
|
Ohne --dry-run wird vor dem Schreiben nachgefragt (ausser mit --yes).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -38,8 +48,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from kerio_common import (
|
from kerio_common import (
|
||||||
@@ -105,10 +117,126 @@ def plan_store_renames(store_dir: Path, domain_name: str, old_login: str,
|
|||||||
return renames
|
return renames
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_personal_vcard(mailbox: Path, old_address: str, new_address: str,
|
||||||
|
new_fullname: str | None) -> bool:
|
||||||
|
"""Schreibt die persoenliche Kontaktkarte auf den neuen Benutzer um.
|
||||||
|
|
||||||
|
<mailbox>/.personal ist eine vCard im Mail-Format und traegt Adresse und
|
||||||
|
Namen des Benutzers, dem die Mailbox vorher gehoerte. Sie einfach zu
|
||||||
|
entfernen geht nicht: Kerio legt sie nicht neu an - weder beim Start noch
|
||||||
|
bei einer Aenderung am Benutzer (geprueft gegen 10.0.9). Der Benutzer
|
||||||
|
stuende dann ohne eigene Kontaktkarte da.
|
||||||
|
|
||||||
|
Gearbeitet wird auf Bytes, damit die CRLF-Zeilenenden erhalten bleiben.
|
||||||
|
"""
|
||||||
|
path = mailbox / ".personal"
|
||||||
|
if not path.is_file():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
updated = data.replace(old_address.encode("utf-8"), new_address.encode("utf-8"))
|
||||||
|
|
||||||
|
if new_fullname:
|
||||||
|
name = new_fullname.encode("utf-8")
|
||||||
|
# FN: und Subject: tragen den angezeigten Namen. [^\r\n]* laesst die
|
||||||
|
# Zeilenenden in Ruhe.
|
||||||
|
updated = re.sub(rb"(?m)^(FN:)[^\r\n]*", lambda m: m.group(1) + name, updated)
|
||||||
|
updated = re.sub(rb"(?mi)^(Subject:[ \t]*)[^\r\n]*",
|
||||||
|
lambda m: m.group(1) + name, updated)
|
||||||
|
|
||||||
|
if updated == data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
temp = mailbox / ".personal.tmp-rename"
|
||||||
|
temp.write_bytes(updated)
|
||||||
|
shutil.copystat(path, temp)
|
||||||
|
os.replace(temp, path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def count_mails(path: Path) -> int:
|
||||||
|
"""Zaehlt die Mail-Dateien unterhalb eines Verzeichnisses."""
|
||||||
|
try:
|
||||||
|
return sum(1 for _ in path.rglob("*.eml"))
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def plan_store_adoption(store_dir: Path, domain_name: str, source_name: str,
|
||||||
|
target_login: str, stamp: str,
|
||||||
|
force: bool) -> list[tuple[Path, Path]]:
|
||||||
|
"""Plant die Uebernahme eines verwaisten Verzeichnisses fuer einen Benutzer.
|
||||||
|
|
||||||
|
Liegt am Ziel schon ein Verzeichnis, wird es beiseitegeschoben statt
|
||||||
|
geloescht - erst danach rueckt die Quelle nach. Beides sind Umbenennungen,
|
||||||
|
deshalb reicht dieselbe (quelle, ziel)-Liste wie beim normalen Rename.
|
||||||
|
"""
|
||||||
|
if not store_dir.is_dir():
|
||||||
|
raise AbortedError(
|
||||||
|
f"Store-Verzeichnis '{store_dir}' existiert nicht. "
|
||||||
|
"Richtigen Pfad mit --store-dir angeben."
|
||||||
|
)
|
||||||
|
|
||||||
|
moves: list[tuple[Path, Path]] = []
|
||||||
|
found_any = False
|
||||||
|
|
||||||
|
for subtree in STORE_SUBTREES:
|
||||||
|
domain_dir = find_dir_ci(store_dir / subtree, domain_name)
|
||||||
|
if domain_dir is None:
|
||||||
|
continue
|
||||||
|
source = find_dir_ci(domain_dir, source_name)
|
||||||
|
if source is None:
|
||||||
|
continue
|
||||||
|
found_any = True
|
||||||
|
|
||||||
|
target_name = target_login.lower() if source.name.islower() else target_login
|
||||||
|
target = find_dir_ci(domain_dir, target_login)
|
||||||
|
|
||||||
|
if target is not None and target.resolve() != source.resolve():
|
||||||
|
existing_mails = count_mails(target)
|
||||||
|
if existing_mails and not force:
|
||||||
|
raise AbortedError(
|
||||||
|
f"Das Zielverzeichnis '{target}' enthaelt bereits "
|
||||||
|
f"{existing_mails} Mail(s). Die Uebernahme wuerde sie "
|
||||||
|
"beiseiteschieben statt zusammenzufuehren. Mit --force "
|
||||||
|
"trotzdem, dann bleiben sie unter "
|
||||||
|
f"'{target.name}.abgeloest-{stamp}' erhalten."
|
||||||
|
)
|
||||||
|
moves.append((target, domain_dir / f"{target.name}.abgeloest-{stamp}"))
|
||||||
|
|
||||||
|
moves.append((source, domain_dir / target_name))
|
||||||
|
|
||||||
|
if not found_any:
|
||||||
|
raise AbortedError(
|
||||||
|
f"Kein Verzeichnis '{source_name}' unter {store_dir} gefunden "
|
||||||
|
f"(gesucht in: {', '.join(STORE_SUBTREES)}/{domain_name}/)."
|
||||||
|
)
|
||||||
|
return moves
|
||||||
|
|
||||||
|
|
||||||
def check_rename_targets(renames: list[tuple[Path, Path]]) -> None:
|
def check_rename_targets(renames: list[tuple[Path, Path]]) -> None:
|
||||||
"""Stellt sicher, dass kein Ziel bereits belegt ist."""
|
"""Prueft die Verschiebungen als Folge, nicht einzeln.
|
||||||
|
|
||||||
|
Bei der Uebernahme eines verwaisten Verzeichnisses raeumt der erste
|
||||||
|
Schritt das Ziel, das der zweite dann belegt. Eine Pruefung, die jedes
|
||||||
|
Paar isoliert betrachtet, wuerde das faelschlich als Konflikt melden -
|
||||||
|
deshalb wird mitgefuehrt, welche Pfade unterwegs frei bzw. belegt werden.
|
||||||
|
"""
|
||||||
|
freed: set[str] = set()
|
||||||
|
used: set[str] = set()
|
||||||
|
|
||||||
for source, target in renames:
|
for source, target in renames:
|
||||||
if target.exists() and target.resolve() != source.resolve():
|
if str(target) in used:
|
||||||
|
raise AbortedError(
|
||||||
|
f"Zwei Verschiebungen zielen auf '{target}' - das kann nicht "
|
||||||
|
"aufgehen."
|
||||||
|
)
|
||||||
|
if target.exists() and target.resolve() != source.resolve() \
|
||||||
|
and str(target) not in freed:
|
||||||
raise AbortedError(
|
raise AbortedError(
|
||||||
f"Zielverzeichnis '{target}' existiert bereits. "
|
f"Zielverzeichnis '{target}' existiert bereits. "
|
||||||
"Bitte manuell pruefen und wegraeumen."
|
"Bitte manuell pruefen und wegraeumen."
|
||||||
@@ -122,6 +250,8 @@ def check_rename_targets(renames: list[tuple[Path, Path]]) -> None:
|
|||||||
f"'{source}' und '{target.parent}' liegen auf verschiedenen "
|
f"'{source}' und '{target.parent}' liegen auf verschiedenen "
|
||||||
"Dateisystemen - das wuerde ein Kopieren statt Verschieben bedeuten."
|
"Dateisystemen - das wuerde ein Kopieren statt Verschieben bedeuten."
|
||||||
)
|
)
|
||||||
|
freed.add(str(source))
|
||||||
|
used.add(str(target))
|
||||||
|
|
||||||
|
|
||||||
def apply_store_renames(renames: list[tuple[Path, Path]]) -> list[tuple[Path, Path]]:
|
def apply_store_renames(renames: list[tuple[Path, Path]]) -> list[tuple[Path, Path]]:
|
||||||
@@ -173,6 +303,120 @@ def list_users(config: UsersConfig, domain: str | None) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def adopt_store(args: argparse.Namespace, config: UsersConfig,
|
||||||
|
service: ServiceController) -> int:
|
||||||
|
"""Haengt ein verwaistes Mailbox-Verzeichnis an einen bestehenden Benutzer.
|
||||||
|
|
||||||
|
Fuer den Fall, dass der alte Benutzer schon geloescht und der neue bereits
|
||||||
|
angelegt wurde - dann ist nur noch das Verzeichnis uebrig. users.cfg wird
|
||||||
|
dabei nicht angefasst, der Zielbenutzer existiert ja bereits.
|
||||||
|
"""
|
||||||
|
store_dir = Path(args.store_dir)
|
||||||
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||||
|
|
||||||
|
domains = config.domains()
|
||||||
|
domain = next((d for d in domains if d.lower() == args.domain.lower()), None)
|
||||||
|
if domain is None:
|
||||||
|
raise AbortedError(
|
||||||
|
f"Domain '{args.domain}' kommt in users.cfg nicht vor. "
|
||||||
|
f"Vorhanden: {', '.join(domains) or '(keine)'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Der Zielbenutzer muss es geben - sonst laeuft die Mailbox ins Leere.
|
||||||
|
target_user = config.find_user(args.new_login, domain)
|
||||||
|
if target_user is None:
|
||||||
|
raise AbortedError(
|
||||||
|
f"Zielbenutzer '{args.new_login}' existiert nicht in Domain "
|
||||||
|
f"'{domain}'. Erst in der Administrationskonsole anlegen."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gibt es den alten Benutzer noch, ist die normale Umbenennung richtig.
|
||||||
|
if config.find_user(args.old_login, domain) is not None:
|
||||||
|
raise AbortedError(
|
||||||
|
f"Benutzer '{args.old_login}' existiert noch. Fuer diesen Fall "
|
||||||
|
"ist die normale Umbenennung gedacht (ohne --adopt), die auch "
|
||||||
|
"users.cfg mitzieht."
|
||||||
|
)
|
||||||
|
|
||||||
|
moves = plan_store_adoption(store_dir, domain, args.old_login,
|
||||||
|
args.new_login, stamp, args.force)
|
||||||
|
check_rename_targets(moves)
|
||||||
|
service.preflight()
|
||||||
|
|
||||||
|
source_mails = sum(count_mails(s) for s, _ in moves
|
||||||
|
if s.name.lower() == args.old_login.lower())
|
||||||
|
|
||||||
|
print("\nGeplante Uebernahme")
|
||||||
|
print("-------------------")
|
||||||
|
print(f" Domain : {domain}")
|
||||||
|
print(f" Zielbenutzer : {target_user['loginName']} "
|
||||||
|
f"({target_user['fullName']})")
|
||||||
|
print(f" Guid : {target_user['guid']}")
|
||||||
|
print(f" Mails in der Quelle: {source_mails}")
|
||||||
|
print(f" users.cfg : wird NICHT geaendert")
|
||||||
|
print(f" Dienst : {service.describe()}")
|
||||||
|
for source, target in moves:
|
||||||
|
marker = "beiseite" if ".abgeloest-" in target.name else "uebernehmen"
|
||||||
|
print(f" {marker:<12}: {source}")
|
||||||
|
print(f" -> {target}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n--dry-run: es wurde nichts geaendert.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not args.yes:
|
||||||
|
print("\nAchtung: der Kerio-Dienst wird dabei kurz gestoppt.")
|
||||||
|
if input("Uebernahme jetzt durchfuehren? [j/N] ").strip().lower() \
|
||||||
|
not in ("j", "ja", "y", "yes"):
|
||||||
|
print("Abgebrochen.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
was_running = service.is_running()
|
||||||
|
service.stop()
|
||||||
|
|
||||||
|
done: list[tuple[Path, Path]] = []
|
||||||
|
try:
|
||||||
|
check_rename_targets(moves)
|
||||||
|
done = apply_store_renames(moves)
|
||||||
|
|
||||||
|
# Die uebernommene Mailbox traegt noch die Identitaet des alten
|
||||||
|
# Benutzers. stats.usr legt Kerio selbst neu an, die Kontaktkarte
|
||||||
|
# nicht - die muss umgeschrieben werden.
|
||||||
|
for _, target in done:
|
||||||
|
if ".abgeloest-" in target.name:
|
||||||
|
continue
|
||||||
|
if rewrite_personal_vcard(
|
||||||
|
target,
|
||||||
|
f"{args.old_login}@{domain}",
|
||||||
|
f"{args.new_login}@{domain}",
|
||||||
|
target_user["fullName"]):
|
||||||
|
print(f" Kontaktkarte auf {target_user['fullName']} umgeschrieben")
|
||||||
|
stats = target / "stats.usr"
|
||||||
|
if stats.is_file():
|
||||||
|
stats.rename(target / f"stats.usr.alt-{stamp}")
|
||||||
|
print(" Beiseite: stats.usr (Kerio legt sie neu an)")
|
||||||
|
except (AbortedError, OSError) as exc:
|
||||||
|
print(f"\nFEHLER: {exc}", file=sys.stderr)
|
||||||
|
revert_store_renames(done)
|
||||||
|
if was_running:
|
||||||
|
service.start()
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if was_running:
|
||||||
|
service.start()
|
||||||
|
|
||||||
|
adopted = [t for _, t in done if ".abgeloest-" not in t.name]
|
||||||
|
print(f"\nFertig. '{args.new_login}' hat jetzt {source_mails} Mail(s) "
|
||||||
|
f"unter '{adopted[0] if adopted else '?'}'.")
|
||||||
|
print("Der Benutzer meldet sich unverändert mit seinem eigenen Kennwort an - "
|
||||||
|
"das Kennwort des alten Benutzers wird nicht uebernommen.")
|
||||||
|
beiseite = [t for _, t in done if ".abgeloest-" in t.name]
|
||||||
|
if beiseite:
|
||||||
|
print(f"Das vorherige Verzeichnis liegt als '{beiseite[0].name}' daneben.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def rename_user(args: argparse.Namespace, config: UsersConfig,
|
def rename_user(args: argparse.Namespace, config: UsersConfig,
|
||||||
service: ServiceController) -> int:
|
service: ServiceController) -> int:
|
||||||
store_dir = Path(args.store_dir)
|
store_dir = Path(args.store_dir)
|
||||||
@@ -296,6 +540,14 @@ def rename_user(args: argparse.Namespace, config: UsersConfig,
|
|||||||
# Vorpruefung geaendert haben.
|
# Vorpruefung geaendert haben.
|
||||||
check_rename_targets(renames)
|
check_rename_targets(renames)
|
||||||
done = apply_store_renames(renames)
|
done = apply_store_renames(renames)
|
||||||
|
|
||||||
|
# Die Kontaktkarte traegt noch die alte Adresse - Kerio zieht dort
|
||||||
|
# nur den Namen nach, nicht die Adresse.
|
||||||
|
display_name = args.full_name if needs_fullname else user["fullName"]
|
||||||
|
for _, target in done:
|
||||||
|
if rewrite_personal_vcard(target, f"{old_login}@{domain}",
|
||||||
|
f"{new_login}@{domain}", display_name):
|
||||||
|
print(" Kontaktkarte auf die neue Adresse umgeschrieben")
|
||||||
except (AbortedError, OSError) as exc:
|
except (AbortedError, OSError) as exc:
|
||||||
print(f"\nFEHLER: {exc}", file=sys.stderr)
|
print(f"\nFEHLER: {exc}", file=sys.stderr)
|
||||||
revert_store_renames(done)
|
revert_store_renames(done)
|
||||||
@@ -360,6 +612,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
|||||||
help='Neuer angezeigter Name, z. B. "Anna Schmidt"')
|
help='Neuer angezeigter Name, z. B. "Anna Schmidt"')
|
||||||
parser.add_argument("--list", action="store_true",
|
parser.add_argument("--list", action="store_true",
|
||||||
help="Benutzer aus users.cfg auflisten und beenden")
|
help="Benutzer aus users.cfg auflisten und beenden")
|
||||||
|
parser.add_argument("--adopt", action="store_true",
|
||||||
|
help="Verwaistes Mailbox-Verzeichnis (--old-login) einem "
|
||||||
|
"bereits angelegten Benutzer (--new-login) zuordnen. "
|
||||||
|
"users.cfg bleibt unangetastet.")
|
||||||
|
|
||||||
parser.add_argument("--service-name", default=DEFAULT_SERVICE,
|
parser.add_argument("--service-name", default=DEFAULT_SERVICE,
|
||||||
help=f"Name des Kerio-Dienstes (Standard: {DEFAULT_SERVICE})")
|
help=f"Name des Kerio-Dienstes (Standard: {DEFAULT_SERVICE})")
|
||||||
@@ -380,11 +636,18 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if not args.list:
|
if not args.list:
|
||||||
missing = [n for n in ("domain", "old_login") if not getattr(args, n)]
|
needed = ("domain", "old_login", "new_login") if args.adopt \
|
||||||
|
else ("domain", "old_login")
|
||||||
|
missing = [n for n in needed if not getattr(args, n)]
|
||||||
if missing:
|
if missing:
|
||||||
parser.error("Benoetigt: " + ", ".join("--" + m.replace("_", "-")
|
parser.error("Benoetigt: " + ", ".join("--" + m.replace("_", "-")
|
||||||
for m in missing))
|
for m in missing))
|
||||||
if not args.new_login and not args.full_name:
|
if args.adopt:
|
||||||
|
if args.full_name:
|
||||||
|
parser.error("--full-name ergibt mit --adopt keinen Sinn: der "
|
||||||
|
"Zielbenutzer existiert bereits und behaelt seine "
|
||||||
|
"Angaben.")
|
||||||
|
elif not args.new_login and not args.full_name:
|
||||||
parser.error("Mindestens --new-login oder --full-name angeben.")
|
parser.error("Mindestens --new-login oder --full-name angeben.")
|
||||||
if not args.dry_run and os.geteuid() != 0:
|
if not args.dry_run and os.geteuid() != 0:
|
||||||
parser.error(
|
parser.error(
|
||||||
@@ -410,6 +673,8 @@ def main(argv: list[str]) -> int:
|
|||||||
config = UsersConfig(config_path)
|
config = UsersConfig(config_path)
|
||||||
if args.list:
|
if args.list:
|
||||||
return list_users(config, args.domain)
|
return list_users(config, args.domain)
|
||||||
|
if args.adopt:
|
||||||
|
return adopt_store(args, config, service)
|
||||||
return rename_user(args, config, service)
|
return rename_user(args, config, service)
|
||||||
except AbortedError as exc:
|
except AbortedError as exc:
|
||||||
print(f"\nFEHLER: {exc}", file=sys.stderr)
|
print(f"\nFEHLER: {exc}", file=sys.stderr)
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Testet die Uebernahme eines verwaisten Mailbox-Verzeichnisses (--adopt)."""
|
||||||
|
import sys, tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
from kerio_rename_user import (
|
||||||
|
plan_store_adoption, check_rename_targets, apply_store_renames,
|
||||||
|
count_mails, revert_store_renames, AbortedError,
|
||||||
|
)
|
||||||
|
|
||||||
|
STAMP = "20260807-120000"
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, cond):
|
||||||
|
global ok
|
||||||
|
print(f" {'ok ' if cond else 'FEHLER'} {label}")
|
||||||
|
if not cond:
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
|
||||||
|
def build_store(with_target=True, target_mails=0):
|
||||||
|
"""Baut einen Store: verwaistes Verzeichnis + optional Ziel des neuen Benutzers."""
|
||||||
|
root = Path(tempfile.mkdtemp())
|
||||||
|
store = root / "store"
|
||||||
|
|
||||||
|
alt = store / "mail" / "firma.de" / "anna.mueller"
|
||||||
|
(alt / "INBOX" / "#msgs").mkdir(parents=True)
|
||||||
|
for i in (1, 2, 3):
|
||||||
|
(alt / "INBOX" / "#msgs" / f"0000000{i}.eml").write_text(f"Mail {i}")
|
||||||
|
(alt / ".personal").write_text("vCard von Anna Mueller")
|
||||||
|
(alt / "stats.usr").write_text("Statistik alt")
|
||||||
|
|
||||||
|
if with_target:
|
||||||
|
neu = store / "mail" / "firma.de" / "anna.schmidt"
|
||||||
|
(neu / "INBOX" / "#msgs").mkdir(parents=True)
|
||||||
|
(neu / ".personal").write_text("vCard von Anna Schmidt")
|
||||||
|
for i in range(target_mails):
|
||||||
|
(neu / "INBOX" / "#msgs" / f"9000000{i}.eml").write_text(f"Zielmail {i}")
|
||||||
|
|
||||||
|
(store / "mail" / "firma.de" / "fremder").mkdir(parents=True)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
print("== 1. Zielverzeichnis existiert, ist aber leer ==")
|
||||||
|
store = build_store(with_target=True, target_mails=0)
|
||||||
|
moves = plan_store_adoption(store, "firma.de", "anna.mueller", "anna.schmidt",
|
||||||
|
STAMP, force=False)
|
||||||
|
check("zwei Schritte geplant (beiseite + uebernehmen)", len(moves) == 2)
|
||||||
|
check("erst wird das Ziel beiseitegeschoben",
|
||||||
|
".abgeloest-" in moves[0][1].name and moves[0][0].name == "anna.schmidt")
|
||||||
|
check("dann rueckt die Quelle nach",
|
||||||
|
moves[1][0].name == "anna.mueller" and moves[1][1].name == "anna.schmidt")
|
||||||
|
check_rename_targets(moves)
|
||||||
|
print(" Sequenzpruefung akzeptiert die Abfolge")
|
||||||
|
|
||||||
|
done = apply_store_renames(moves)
|
||||||
|
ziel = store / "mail" / "firma.de" / "anna.schmidt"
|
||||||
|
check("3 Mails beim neuen Benutzer", count_mails(ziel) == 3)
|
||||||
|
check("alte Mail-Datei inhaltlich da",
|
||||||
|
(ziel / "INBOX" / "#msgs" / "00000001.eml").read_text() == "Mail 1")
|
||||||
|
check("abgeloestes Zielverzeichnis daneben",
|
||||||
|
(store / "mail" / "firma.de" / f"anna.schmidt.abgeloest-{STAMP}").is_dir())
|
||||||
|
check("Quellverzeichnis weg",
|
||||||
|
not (store / "mail" / "firma.de" / "anna.mueller").exists())
|
||||||
|
check("Fremder unberuehrt", (store / "mail" / "firma.de" / "fremder").is_dir())
|
||||||
|
|
||||||
|
print("\n== 2. Rollback nimmt beide Schritte zurueck ==")
|
||||||
|
revert_store_renames(done)
|
||||||
|
check("Quelle wieder da",
|
||||||
|
(store / "mail" / "firma.de" / "anna.mueller" / "INBOX" / "#msgs" /
|
||||||
|
"00000001.eml").read_text() == "Mail 1")
|
||||||
|
check("Ziel wieder am Platz",
|
||||||
|
(store / "mail" / "firma.de" / "anna.schmidt" / ".personal").read_text()
|
||||||
|
== "vCard von Anna Schmidt")
|
||||||
|
check("kein abgeloestes Verzeichnis mehr",
|
||||||
|
not (store / "mail" / "firma.de" / f"anna.schmidt.abgeloest-{STAMP}").exists())
|
||||||
|
|
||||||
|
print("\n== 3. Ziel enthaelt schon Mails -> Abbruch ohne --force ==")
|
||||||
|
store = build_store(with_target=True, target_mails=2)
|
||||||
|
try:
|
||||||
|
plan_store_adoption(store, "firma.de", "anna.mueller", "anna.schmidt",
|
||||||
|
STAMP, force=False)
|
||||||
|
check("haette abbrechen muessen", False)
|
||||||
|
except AbortedError as exc:
|
||||||
|
check("bricht ab und nennt die Anzahl", "2 Mail" in str(exc))
|
||||||
|
|
||||||
|
print("\n== 4. ... mit --force geht es, Mails bleiben erhalten ==")
|
||||||
|
moves = plan_store_adoption(store, "firma.de", "anna.mueller", "anna.schmidt",
|
||||||
|
STAMP, force=True)
|
||||||
|
check_rename_targets(moves)
|
||||||
|
apply_store_renames(moves)
|
||||||
|
abgeloest = store / "mail" / "firma.de" / f"anna.schmidt.abgeloest-{STAMP}"
|
||||||
|
check("die 2 Zielmails sind erhalten geblieben", count_mails(abgeloest) == 2)
|
||||||
|
check("die 3 uebernommenen Mails sind am Platz",
|
||||||
|
count_mails(store / "mail" / "firma.de" / "anna.schmidt") == 3)
|
||||||
|
|
||||||
|
print("\n== 5. Kein Zielverzeichnis vorhanden -> nur ein Schritt ==")
|
||||||
|
store = build_store(with_target=False)
|
||||||
|
moves = plan_store_adoption(store, "firma.de", "anna.mueller", "anna.schmidt",
|
||||||
|
STAMP, force=False)
|
||||||
|
check("genau ein Schritt", len(moves) == 1)
|
||||||
|
check_rename_targets(moves)
|
||||||
|
apply_store_renames(moves)
|
||||||
|
check("Mails angekommen",
|
||||||
|
count_mails(store / "mail" / "firma.de" / "anna.schmidt") == 3)
|
||||||
|
|
||||||
|
print("\n== 6. Quellverzeichnis gibt es nicht -> klare Meldung ==")
|
||||||
|
try:
|
||||||
|
plan_store_adoption(store, "firma.de", "gibtsnicht", "anna.schmidt",
|
||||||
|
STAMP, force=False)
|
||||||
|
check("haette abbrechen muessen", False)
|
||||||
|
except AbortedError as exc:
|
||||||
|
check("klare Fehlermeldung", "gibtsnicht" in str(exc))
|
||||||
|
|
||||||
|
print("\n== 7. Sequenzpruefung erkennt zwei Moves auf dasselbe Ziel ==")
|
||||||
|
store = build_store(with_target=False)
|
||||||
|
d = store / "mail" / "firma.de"
|
||||||
|
try:
|
||||||
|
check_rename_targets([(d / "anna.mueller", d / "ziel"),
|
||||||
|
(d / "fremder", d / "ziel")])
|
||||||
|
check("haette abbrechen muessen", False)
|
||||||
|
except AbortedError as exc:
|
||||||
|
check("Kollision erkannt", "Zwei Verschiebungen" in str(exc))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
print("\n== 8. Kontaktkarte (.personal) umschreiben ==")
|
||||||
|
from kerio_rename_user import rewrite_personal_vcard
|
||||||
|
|
||||||
|
mb = Path(tempfile.mkdtemp())
|
||||||
|
VCARD = (b"Subject: Maxi Musterfrau\r\n"
|
||||||
|
b"Date: Fri, 7 Aug 2026 11:33:20 +0000\r\n"
|
||||||
|
b'Content-Type: text/vcard; charset="utf-8"\r\n'
|
||||||
|
b"\r\n"
|
||||||
|
b"BEGIN:VCARD\r\n"
|
||||||
|
b"VERSION:3.0\r\n"
|
||||||
|
b"EMAIL;TYPE=PREF,WORK:maxi.musterfrau@local.lan\r\n"
|
||||||
|
b"FN:Maxi Musterfrau\r\n"
|
||||||
|
b"X-JABBER:maxi.musterfrau@local.lan\r\n"
|
||||||
|
b"END:VCARD\r\n")
|
||||||
|
(mb / ".personal").write_bytes(VCARD)
|
||||||
|
|
||||||
|
changed = rewrite_personal_vcard(mb, "maxi.musterfrau@local.lan",
|
||||||
|
"anna.neu@local.lan", "Anna Neu")
|
||||||
|
out = (mb / ".personal").read_bytes()
|
||||||
|
check("meldet Aenderung", changed is True)
|
||||||
|
check("EMAIL umgeschrieben", b"EMAIL;TYPE=PREF,WORK:anna.neu@local.lan\r\n" in out)
|
||||||
|
check("X-JABBER umgeschrieben", b"X-JABBER:anna.neu@local.lan\r\n" in out)
|
||||||
|
check("FN umgeschrieben", b"FN:Anna Neu\r\n" in out)
|
||||||
|
check("Subject umgeschrieben", b"Subject: Anna Neu\r\n" in out)
|
||||||
|
check("keine alte Adresse mehr", b"maxi.musterfrau" not in out)
|
||||||
|
check("CRLF erhalten", out.count(b"\r\n") == VCARD.count(b"\r\n"))
|
||||||
|
check("VCARD-Struktur intakt",
|
||||||
|
out.startswith(b"Subject:") and out.rstrip().endswith(b"END:VCARD"))
|
||||||
|
|
||||||
|
print("\n== 9. Kontaktkarte fehlt -> kein Fehler ==")
|
||||||
|
leer = Path(tempfile.mkdtemp())
|
||||||
|
check("meldet 'nichts geaendert'",
|
||||||
|
rewrite_personal_vcard(leer, "a@b.c", "d@e.f", "X") is False)
|
||||||
|
|
||||||
|
print("\n== 10. Nichts zu ersetzen -> Datei bleibt unveraendert ==")
|
||||||
|
mb2 = Path(tempfile.mkdtemp())
|
||||||
|
(mb2 / ".personal").write_bytes(VCARD)
|
||||||
|
rewrite_personal_vcard(mb2, "fremder@local.lan", "wer@local.lan", None)
|
||||||
|
check("Datei unveraendert", (mb2 / ".personal").read_bytes() == VCARD)
|
||||||
|
|
||||||
|
print("\n" + ("ALLE TESTS BESTANDEN" if ok else "FEHLGESCHLAGEN"))
|
||||||
|
sys.exit(0 if ok else 1)
|
||||||
Reference in New Issue
Block a user