From da64a15486c18f5222cd22422122e86db77829ff Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 7 Aug 2026 14:35:51 +0200 Subject: [PATCH] --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 .abgeloest- 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) --- README.md | 69 ++++++++++- kerio_rename_user.py | 273 ++++++++++++++++++++++++++++++++++++++++++- tests/test_adopt.py | 168 ++++++++++++++++++++++++++ 3 files changed, 500 insertions(+), 10 deletions(-) create mode 100644 tests/test_adopt.py diff --git a/README.md b/README.md index 92d9910..eb6d789 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,16 @@ danach geprüft: | Verwaistes Verzeichnis unter dem alten Namen | keines | | 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`, `status.fld`, `index.fld`) — die schreibt der Server bei jedem Zugriff neu. @@ -37,6 +47,7 @@ Dazu Unit-Tests unter [tests/](tests/): ```bash python3 tests/test_store.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" ``` +## 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 + `.abgeloest-` 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 | Option | Bedeutung | | --- | --- | | `--list` | Benutzer aus `users.cfg` auflisten | +| `--adopt` | Verwaistes Verzeichnis einem bestehenden Benutzer zuordnen | | `--domain` | Mail-Domain, z. B. `firma.de` | | `--old-login`, `--new-login` | Login-Namen ohne `@domain` | | `--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 | | `--dry-run` | Nur anzeigen | | `--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 selbst gestoppt sein, bevor das Script läuft. +## Die Kontaktkarte + +`/.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 -Nach der Umbenennung steht der alte Name noch an ein paar Stellen, die reine -Anzeige betreffen und den Mailbetrieb nicht stören: +Der alte Name bleibt an zwei Stellen stehen, die reine Anzeige betreffen und den +Mailbetrieb nicht stören: -- `.personal` — die vCard des Benutzers, enthält noch die alte Adresse - `#public/Contacts/` — der Eintrag in der globalen Adressliste - `.caldav.db` / `.carddav.db` — die DAV-Datenbanken -Kerio schreibt `.personal` beim Start teilweise selbst neu. Wer die Adresse dort -sofort korrekt haben will, korrigiert den Kontakt in der Administrationskonsole. +Wer die sofort korrekt haben will, korrigiert den Kontakt in der +Administrationskonsole. 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 — diff --git a/kerio_rename_user.py b/kerio_rename_user.py index 5f7ede5..a809868 100755 --- a/kerio_rename_user.py +++ b/kerio_rename_user.py @@ -31,6 +31,16 @@ Beispiele: --full-name "Anna Schmidt" \\ --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). """ @@ -38,8 +48,10 @@ from __future__ import annotations import argparse import os +import re import shutil import sys +import time from pathlib import Path from kerio_common import ( @@ -105,10 +117,126 @@ def plan_store_renames(store_dir: Path, domain_name: str, old_login: str, 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. + + /.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: - """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: - 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( f"Zielverzeichnis '{target}' existiert bereits. " "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 " "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]]: @@ -173,6 +303,120 @@ def list_users(config: UsersConfig, domain: str | None) -> int: 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, service: ServiceController) -> int: store_dir = Path(args.store_dir) @@ -296,6 +540,14 @@ def rename_user(args: argparse.Namespace, config: UsersConfig, # Vorpruefung geaendert haben. check_rename_targets(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: print(f"\nFEHLER: {exc}", file=sys.stderr) revert_store_renames(done) @@ -360,6 +612,10 @@ def parse_args(argv: list[str]) -> argparse.Namespace: help='Neuer angezeigter Name, z. B. "Anna Schmidt"') parser.add_argument("--list", action="store_true", 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, 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) 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: parser.error("Benoetigt: " + ", ".join("--" + m.replace("_", "-") 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.") if not args.dry_run and os.geteuid() != 0: parser.error( @@ -410,6 +673,8 @@ def main(argv: list[str]) -> int: config = UsersConfig(config_path) if args.list: return list_users(config, args.domain) + if args.adopt: + return adopt_store(args, config, service) return rename_user(args, config, service) except AbortedError as exc: print(f"\nFEHLER: {exc}", file=sys.stderr) diff --git a/tests/test_adopt.py b/tests/test_adopt.py new file mode 100644 index 0000000..0d6d61a --- /dev/null +++ b/tests/test_adopt.py @@ -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)