444 lines
17 KiB
Python
444 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import psycopg
|
|
|
|
from ..k8s.exec import ExecError, PodExecutor
|
|
from ..k8s.pods import PodSelectionError
|
|
from ..settings import settings
|
|
from ..utils.logging import get_logger
|
|
from ..utils.passwords import random_password
|
|
from .keycloak_admin import keycloak_admin
|
|
from .nextcloud_maintenance import run_maintenance as run_nextcloud_maintenance
|
|
from .nextcloud_mail_models import MailSyncCounters
|
|
from .nextcloud_mail_models import display_name as _display_name
|
|
from .nextcloud_mail_models import _extract_attr
|
|
from .nextcloud_mail_models import _parse_mail_export
|
|
from .nextcloud_mail_models import _resolve_mailu_email
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class NextcloudService:
|
|
"""Synchronize user mail configuration inside the Nextcloud pod."""
|
|
|
|
def __init__(self) -> None:
|
|
self._executor = PodExecutor(
|
|
settings.nextcloud_namespace,
|
|
settings.nextcloud_pod_label,
|
|
settings.nextcloud_container,
|
|
)
|
|
|
|
def _exec_with_fallback(self, primary: list[str], fallback: list[str], env: dict[str, str] | None = None, check: bool = True) -> ExecResult:
|
|
try:
|
|
result = self._executor.exec(
|
|
primary,
|
|
env=env,
|
|
timeout_sec=settings.nextcloud_exec_timeout_sec,
|
|
check=check,
|
|
)
|
|
except ExecError as exc:
|
|
if "runuser: may not be used by non-root users" not in str(exc):
|
|
raise
|
|
return self._executor.exec(
|
|
fallback,
|
|
env=env,
|
|
timeout_sec=settings.nextcloud_exec_timeout_sec,
|
|
check=check,
|
|
)
|
|
|
|
if not result.ok and "runuser: may not be used by non-root users" in result.stderr:
|
|
return self._executor.exec(
|
|
fallback,
|
|
env=env,
|
|
timeout_sec=settings.nextcloud_exec_timeout_sec,
|
|
check=check,
|
|
)
|
|
return result
|
|
|
|
def _occ_exec(self, args: list[str], env: dict[str, str] | None = None, check: bool = True) -> ExecResult:
|
|
command = ["runuser", "-u", "www-data", "--", "php", "/var/www/html/occ", *args]
|
|
fallback = ["php", "/var/www/html/occ", *args]
|
|
return self._exec_with_fallback(command, fallback, env=env, check=check)
|
|
|
|
def _occ(self, args: list[str]) -> str:
|
|
result = self._occ_exec(args, check=True)
|
|
return result.stdout
|
|
|
|
def _ensure_nextcloud_user(self, username: str, mailu_email: str, display_name: str) -> None:
|
|
result = self._occ_exec(["user:info", username], check=False)
|
|
if result.ok:
|
|
return
|
|
|
|
detail = f"{result.stdout}\n{result.stderr}".strip().lower()
|
|
missing_markers = ("not found", "not exist", "does not exist", "unknown user")
|
|
if detail and not any(marker in detail for marker in missing_markers):
|
|
raise RuntimeError(f"nextcloud user lookup failed: {detail[:200]}")
|
|
|
|
password = random_password(24)
|
|
env = {"OC_PASS": password}
|
|
args = ["user:add", "--password-from-env"]
|
|
name = display_name or username
|
|
if name:
|
|
args += ["--display-name", name]
|
|
if mailu_email:
|
|
args += ["--email", mailu_email]
|
|
args.append(username)
|
|
self._occ_exec(args, env=env, check=True)
|
|
|
|
def run_cron(self) -> dict[str, Any]:
|
|
if not settings.nextcloud_namespace:
|
|
raise RuntimeError("nextcloud cron not configured")
|
|
try:
|
|
self._exec_with_fallback(
|
|
["runuser", "-u", "www-data", "--", "php", "-f", "/var/www/html/cron.php"],
|
|
["php", "-f", "/var/www/html/cron.php"],
|
|
)
|
|
except (ExecError, PodSelectionError, TimeoutError) as exc:
|
|
return {"status": "error", "detail": str(exc)}
|
|
return {"status": "ok"}
|
|
|
|
def _list_mail_accounts(self, username: str) -> list[tuple[str, str]]:
|
|
output = self._occ(["mail:account:export", username])
|
|
return _parse_mail_export(output)
|
|
|
|
def _set_editor_mode_richtext(self, account_ids: list[str]) -> None:
|
|
safe_ids = [item for item in account_ids if item.isdigit()]
|
|
if not safe_ids:
|
|
return
|
|
if not settings.nextcloud_db_host or not settings.nextcloud_db_password:
|
|
logger.info(
|
|
"nextcloud editor_mode skipped",
|
|
extra={"event": "nextcloud_mail_editor_mode", "status": "skip", "reason": "missing db config"},
|
|
)
|
|
return
|
|
ids_csv = ",".join(safe_ids)
|
|
query = (
|
|
"UPDATE oc_mail_accounts SET editor_mode='richtext' "
|
|
f"WHERE id IN ({ids_csv}) AND editor_mode <> 'richtext';"
|
|
)
|
|
try:
|
|
with psycopg.connect(
|
|
host=settings.nextcloud_db_host,
|
|
port=settings.nextcloud_db_port,
|
|
dbname=settings.nextcloud_db_name,
|
|
user=settings.nextcloud_db_user,
|
|
password=settings.nextcloud_db_password,
|
|
) as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(query)
|
|
except Exception as exc:
|
|
logger.info(
|
|
"nextcloud editor_mode update failed",
|
|
extra={"event": "nextcloud_mail_editor_mode", "status": "error", "detail": str(exc)},
|
|
)
|
|
|
|
def _set_user_mail_meta(self, user_id: str, primary_email: str, account_count: int) -> None:
|
|
synced_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
attrs = {
|
|
"nextcloud_mail_primary_email": [primary_email],
|
|
"nextcloud_mail_account_count": [str(account_count)],
|
|
"nextcloud_mail_synced_at": [synced_at],
|
|
}
|
|
try:
|
|
keycloak_admin.update_user_safe(user_id, {"attributes": attrs})
|
|
except Exception:
|
|
return
|
|
|
|
def _collect_users(self, username: str | None) -> list[dict[str, Any]]:
|
|
if username is not None:
|
|
user = keycloak_admin.find_user(username)
|
|
return [user] if user else []
|
|
return list(keycloak_admin.iter_users(page_size=200, brief=False))
|
|
|
|
def _normalize_user(self, user: dict[str, Any]) -> tuple[str, str, dict[str, Any]] | None:
|
|
username_val = user.get("username") if isinstance(user.get("username"), str) else ""
|
|
username_val = username_val.strip()
|
|
if not username_val:
|
|
return None
|
|
if user.get("enabled") is False:
|
|
return None
|
|
if user.get("serviceAccountClientId") or username_val.startswith("service-account-"):
|
|
return None
|
|
|
|
user_id = user.get("id") if isinstance(user.get("id"), str) else ""
|
|
full_user = user
|
|
if user_id:
|
|
try:
|
|
full_user = keycloak_admin.get_user(user_id)
|
|
except Exception:
|
|
full_user = user
|
|
return username_val, user_id, full_user
|
|
|
|
def _list_mail_accounts_safe(self, username: str, counters: MailSyncCounters) -> list[tuple[str, str]] | None:
|
|
try:
|
|
return self._list_mail_accounts(username)
|
|
except Exception as exc:
|
|
detail = f"mail export failed: {exc}"
|
|
counters.record_failure(detail)
|
|
logger.info(
|
|
"nextcloud mail export failed",
|
|
extra={"event": "nextcloud_mail_export", "status": "error", "detail": detail},
|
|
)
|
|
return None
|
|
|
|
def _select_primary_account(self, mailu_accounts: list[tuple[str, str]], mailu_email: str) -> tuple[str, str]:
|
|
primary_id = ""
|
|
primary_email = ""
|
|
for account_id, account_email in mailu_accounts:
|
|
if not primary_id:
|
|
primary_id = account_id
|
|
primary_email = account_email
|
|
if account_email.lower() == mailu_email.lower():
|
|
primary_id = account_id
|
|
primary_email = account_email
|
|
break
|
|
return primary_id, primary_email
|
|
|
|
def _update_mail_account(self, username: str, primary_id: str, mailu_email: str, app_pw: str) -> str | None:
|
|
try:
|
|
self._occ(
|
|
[
|
|
"mail:account:update",
|
|
"-q",
|
|
primary_id,
|
|
"--name",
|
|
username,
|
|
"--email",
|
|
mailu_email,
|
|
"--imap-host",
|
|
settings.mailu_host,
|
|
"--imap-port",
|
|
"993",
|
|
"--imap-ssl-mode",
|
|
"ssl",
|
|
"--imap-user",
|
|
mailu_email,
|
|
"--imap-password",
|
|
app_pw,
|
|
"--smtp-host",
|
|
settings.mailu_host,
|
|
"--smtp-port",
|
|
"587",
|
|
"--smtp-ssl-mode",
|
|
"tls",
|
|
"--smtp-user",
|
|
mailu_email,
|
|
"--smtp-password",
|
|
app_pw,
|
|
"--auth-method",
|
|
"password",
|
|
]
|
|
)
|
|
return None
|
|
except Exception as exc:
|
|
return str(exc)
|
|
|
|
def _create_mail_account(self, username: str, mailu_email: str, app_pw: str) -> str | None:
|
|
try:
|
|
self._occ(
|
|
[
|
|
"mail:account:create",
|
|
"-q",
|
|
username,
|
|
username,
|
|
mailu_email,
|
|
settings.mailu_host,
|
|
"993",
|
|
"ssl",
|
|
mailu_email,
|
|
app_pw,
|
|
settings.mailu_host,
|
|
"587",
|
|
"tls",
|
|
mailu_email,
|
|
app_pw,
|
|
"password",
|
|
]
|
|
)
|
|
return None
|
|
except Exception as exc:
|
|
return str(exc)
|
|
|
|
def _delete_extra_accounts(self, mailu_accounts: list[tuple[str, str]], primary_id: str, counters: MailSyncCounters) -> int:
|
|
deleted = 0
|
|
for account_id, _account_email in mailu_accounts:
|
|
if account_id == primary_id:
|
|
continue
|
|
try:
|
|
self._occ(["mail:account:delete", "-q", account_id])
|
|
deleted += 1
|
|
except Exception as exc:
|
|
counters.record_failure(f"mail account delete failed: {exc}")
|
|
return deleted
|
|
|
|
def _mailu_accounts(self, accounts: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
|
return [
|
|
(account_id, email)
|
|
for account_id, email in accounts
|
|
if email.lower().endswith(f"@{settings.mailu_domain.lower()}")
|
|
]
|
|
|
|
def _summarize_mail_accounts(self, accounts: list[tuple[str, str]], mailu_email: str) -> tuple[int, str, list[str]]:
|
|
mailu_accounts = self._mailu_accounts(accounts)
|
|
account_count = len(mailu_accounts)
|
|
primary_email = ""
|
|
editor_mode_ids: list[str] = []
|
|
for account_id, account_email in mailu_accounts:
|
|
editor_mode_ids.append(account_id)
|
|
if account_email.lower() == mailu_email.lower():
|
|
primary_email = account_email
|
|
break
|
|
if not primary_email:
|
|
primary_email = account_email
|
|
return account_count, primary_email, editor_mode_ids
|
|
|
|
def _mail_sync_context(self, user: dict[str, Any], counters: MailSyncCounters) -> tuple[str, str, str, str, dict[str, Any]] | None:
|
|
normalized = self._normalize_user(user)
|
|
if not normalized:
|
|
counters.skipped += 1
|
|
return None
|
|
username, user_id, full_user = normalized
|
|
attrs = full_user.get("attributes") if isinstance(full_user.get("attributes"), dict) else {}
|
|
mailu_email = _resolve_mailu_email(username, full_user)
|
|
app_pw = _extract_attr(attrs, "mailu_app_password")
|
|
if not mailu_email or not app_pw:
|
|
counters.skipped += 1
|
|
return None
|
|
if mailu_email and not _extract_attr(attrs, "mailu_email"):
|
|
try:
|
|
keycloak_admin.set_user_attribute(username, "mailu_email", mailu_email)
|
|
except Exception:
|
|
pass
|
|
return username, user_id, mailu_email, app_pw, full_user
|
|
|
|
def _sync_mail_accounts(self, username: str, mailu_email: str, app_pw: str, accounts: list[tuple[str, str]], counters: MailSyncCounters) -> bool:
|
|
mailu_accounts = self._mailu_accounts(accounts)
|
|
if mailu_accounts:
|
|
primary_id, _primary_email = self._select_primary_account(mailu_accounts, mailu_email)
|
|
error = self._update_mail_account(username, primary_id, mailu_email, app_pw)
|
|
if error:
|
|
counters.record_failure(f"mail account update failed: {error}")
|
|
return False
|
|
counters.updated += 1
|
|
counters.deleted += self._delete_extra_accounts(mailu_accounts, primary_id, counters)
|
|
else:
|
|
error = self._create_mail_account(username, mailu_email, app_pw)
|
|
if error:
|
|
counters.record_failure(f"mail account create failed: {error}")
|
|
return False
|
|
counters.created += 1
|
|
return True
|
|
|
|
def _apply_mail_metadata(self, user_id: str, mailu_email: str, accounts: list[tuple[str, str]]) -> None:
|
|
account_count, primary_email, editor_mode_ids = self._summarize_mail_accounts(accounts, mailu_email)
|
|
self._set_editor_mode_richtext(editor_mode_ids)
|
|
if user_id:
|
|
self._set_user_mail_meta(user_id, primary_email, account_count)
|
|
|
|
def _sync_user_mail(self, user: dict[str, Any], counters: MailSyncCounters) -> None:
|
|
context = self._mail_sync_context(user, counters)
|
|
if not context:
|
|
return
|
|
username, user_id, mailu_email, app_pw, full_user = context
|
|
|
|
try:
|
|
display_name = _display_name(full_user)
|
|
self._ensure_nextcloud_user(username, mailu_email, display_name)
|
|
except Exception as exc:
|
|
counters.record_failure(f"nextcloud user ensure failed: {exc}")
|
|
return
|
|
|
|
accounts = self._list_mail_accounts_safe(username, counters)
|
|
if accounts is None:
|
|
return
|
|
|
|
counters.processed += 1
|
|
if not self._sync_mail_accounts(username, mailu_email, app_pw, accounts, counters):
|
|
return
|
|
|
|
accounts_after = self._list_mail_accounts_safe(username, counters)
|
|
if accounts_after is None:
|
|
return
|
|
|
|
self._apply_mail_metadata(user_id, mailu_email, accounts_after)
|
|
|
|
def sync_mail(self, username: str | None = None, wait: bool = True) -> dict[str, Any]:
|
|
if not settings.nextcloud_namespace:
|
|
raise RuntimeError("nextcloud mail sync not configured")
|
|
cleaned_username = None
|
|
if username is not None:
|
|
cleaned_username = username.strip()
|
|
if not cleaned_username:
|
|
raise RuntimeError("missing username")
|
|
if not keycloak_admin.ready():
|
|
return {"status": "error", "detail": "keycloak admin not configured"}
|
|
|
|
users = self._collect_users(cleaned_username)
|
|
if cleaned_username is not None and not users:
|
|
return {"status": "ok", "detail": "no matching user"}
|
|
|
|
counters = MailSyncCounters()
|
|
for user in users:
|
|
self._sync_user_mail(user, counters)
|
|
|
|
summary = counters.summary()
|
|
summary_payload = {
|
|
"processed": summary.processed,
|
|
"created": summary.created,
|
|
"updated": summary.updated,
|
|
"deleted": summary.deleted,
|
|
"skipped": summary.skipped,
|
|
"failures": summary.failures,
|
|
"detail": summary.detail,
|
|
}
|
|
|
|
logger.info(
|
|
"nextcloud mail sync finished",
|
|
extra={
|
|
"event": "nextcloud_mail_sync",
|
|
"status": counters.status(),
|
|
"processed_count": counters.processed,
|
|
"created_count": counters.created,
|
|
"updated_count": counters.updated,
|
|
"deleted_count": counters.deleted,
|
|
"skipped_count": counters.skipped,
|
|
"failures_count": counters.failures,
|
|
"detail": summary.detail,
|
|
},
|
|
)
|
|
|
|
return {"status": counters.status(), "summary": summary_payload, "detail": summary.detail}
|
|
|
|
def _external_api(self, method: str, path: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
if not settings.nextcloud_url:
|
|
raise RuntimeError("nextcloud url not configured")
|
|
if not settings.nextcloud_admin_user or not settings.nextcloud_admin_password:
|
|
raise RuntimeError("nextcloud admin credentials missing")
|
|
url = f"{settings.nextcloud_url}/ocs/v2.php/apps/external/api/v1{path}"
|
|
headers = {"OCS-APIRequest": "true"}
|
|
with httpx.Client(timeout=settings.nextcloud_exec_timeout_sec) as client:
|
|
resp = client.request(
|
|
method,
|
|
url,
|
|
headers=headers,
|
|
auth=(settings.nextcloud_admin_user, settings.nextcloud_admin_password),
|
|
data=data,
|
|
)
|
|
resp.raise_for_status()
|
|
try:
|
|
return resp.json()
|
|
except Exception:
|
|
return {}
|
|
|
|
def run_maintenance(self) -> dict[str, Any]:
|
|
return run_nextcloud_maintenance(self)
|
|
|
|
|
|
nextcloud = NextcloudService()
|