--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:
duffyduck
2026-08-07 14:35:51 +02:00
co-authored by Claude Opus 5
parent c4883d42bb
commit da64a15486
3 changed files with 500 additions and 10 deletions
+269 -4
View File
@@ -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.
<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:
"""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)