From 7902d7658f167b96c41c4ec73a070fee312f0a5b Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 3 Jan 2026 12:18:46 -0300 Subject: [PATCH] portal: trigger Nextcloud mail sync --- backend/atlas_portal/k8s.py | 56 +++++++++ backend/atlas_portal/nextcloud_mail_sync.py | 123 ++++++++++++++++++++ backend/atlas_portal/provisioning.py | 16 +++ backend/atlas_portal/routes/account.py | 86 +++++++++++++- backend/atlas_portal/settings.py | 6 + frontend/src/views/AccountView.vue | 81 +++++++++++++ 6 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 backend/atlas_portal/k8s.py create mode 100644 backend/atlas_portal/nextcloud_mail_sync.py diff --git a/backend/atlas_portal/k8s.py b/backend/atlas_portal/k8s.py new file mode 100644 index 0000000..26d689b --- /dev/null +++ b/backend/atlas_portal/k8s.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import httpx + +from . import settings + + +_K8S_BASE_URL = "https://kubernetes.default.svc" +_SA_PATH = Path("/var/run/secrets/kubernetes.io/serviceaccount") + + +def _read_service_account() -> tuple[str, str]: + token_path = _SA_PATH / "token" + ca_path = _SA_PATH / "ca.crt" + if not token_path.exists() or not ca_path.exists(): + raise RuntimeError("kubernetes service account token missing") + token = token_path.read_text().strip() + if not token: + raise RuntimeError("kubernetes service account token empty") + return token, str(ca_path) + + +def get_json(path: str) -> dict[str, Any]: + token, ca_path = _read_service_account() + url = f"{_K8S_BASE_URL}{path}" + with httpx.Client( + verify=ca_path, + timeout=settings.K8S_API_TIMEOUT_SEC, + headers={"Authorization": f"Bearer {token}"}, + ) as client: + resp = client.get(url) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError("unexpected kubernetes response") + return data + + +def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]: + token, ca_path = _read_service_account() + url = f"{_K8S_BASE_URL}{path}" + with httpx.Client( + verify=ca_path, + timeout=settings.K8S_API_TIMEOUT_SEC, + headers={"Authorization": f"Bearer {token}"}, + ) as client: + resp = client.post(url, json=payload) + resp.raise_for_status() + data = resp.json() + if not isinstance(data, dict): + raise RuntimeError("unexpected kubernetes response") + return data + diff --git a/backend/atlas_portal/nextcloud_mail_sync.py b/backend/atlas_portal/nextcloud_mail_sync.py new file mode 100644 index 0000000..7879728 --- /dev/null +++ b/backend/atlas_portal/nextcloud_mail_sync.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import re +import time +from typing import Any + +from . import settings +from .k8s import get_json, post_json + + +def _safe_name_fragment(value: str, max_len: int = 24) -> str: + cleaned = re.sub(r"[^a-z0-9-]+", "-", (value or "").lower()).strip("-") + if not cleaned: + cleaned = "user" + return cleaned[:max_len].rstrip("-") or "user" + + +def _job_from_cronjob(cronjob: dict[str, Any], username: str) -> dict[str, Any]: + spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {} + jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {} + job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {} + + now = int(time.time()) + safe_user = _safe_name_fragment(username) + job_name = f"nextcloud-mail-sync-{safe_user}-{now}" + + job: dict[str, Any] = { + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": { + "name": job_name, + "namespace": settings.NEXTCLOUD_NAMESPACE, + "labels": { + "app": "nextcloud-mail-sync", + "atlas.bstein.dev/trigger": "portal", + "atlas.bstein.dev/username": safe_user, + }, + }, + "spec": job_spec, + } + + if isinstance(settings.NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC, int) and settings.NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC > 0: + job.setdefault("spec", {}) + job["spec"]["ttlSecondsAfterFinished"] = int(settings.NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC) + + tpl = job.get("spec", {}).get("template", {}) + pod_spec = tpl.get("spec") if isinstance(tpl.get("spec"), dict) else {} + containers = pod_spec.get("containers") if isinstance(pod_spec.get("containers"), list) else [] + if containers and isinstance(containers[0], dict): + env = containers[0].get("env") + if not isinstance(env, list): + env = [] + env = [e for e in env if not (isinstance(e, dict) and e.get("name") == "ONLY_USERNAME")] + env.append({"name": "ONLY_USERNAME", "value": username}) + containers[0]["env"] = env + pod_spec["containers"] = containers + tpl["spec"] = pod_spec + job["spec"]["template"] = tpl + + return job + + +def _job_succeeded(job: dict[str, Any]) -> bool: + status = job.get("status") if isinstance(job.get("status"), dict) else {} + if int(status.get("succeeded") or 0) > 0: + return True + conditions = status.get("conditions") if isinstance(status.get("conditions"), list) else [] + for cond in conditions: + if not isinstance(cond, dict): + continue + if cond.get("type") == "Complete" and cond.get("status") == "True": + return True + return False + + +def _job_failed(job: dict[str, Any]) -> bool: + status = job.get("status") if isinstance(job.get("status"), dict) else {} + if int(status.get("failed") or 0) > 0: + return True + conditions = status.get("conditions") if isinstance(status.get("conditions"), list) else [] + for cond in conditions: + if not isinstance(cond, dict): + continue + if cond.get("type") == "Failed" and cond.get("status") == "True": + return True + return False + + +def trigger(username: str, wait: bool = True) -> dict[str, Any]: + username = (username or "").strip() + if not username: + raise RuntimeError("missing username") + + cronjob = get_json( + f"/apis/batch/v1/namespaces/{settings.NEXTCLOUD_NAMESPACE}/cronjobs/{settings.NEXTCLOUD_MAIL_SYNC_CRONJOB}" + ) + job_payload = _job_from_cronjob(cronjob, username) + created = post_json(f"/apis/batch/v1/namespaces/{settings.NEXTCLOUD_NAMESPACE}/jobs", job_payload) + + job_name = ( + created.get("metadata", {}).get("name") + if isinstance(created.get("metadata"), dict) + else job_payload.get("metadata", {}).get("name") + ) + if not isinstance(job_name, str) or not job_name: + raise RuntimeError("job name missing") + + if not wait: + return {"job": job_name, "status": "queued"} + + deadline = time.time() + float(settings.NEXTCLOUD_MAIL_SYNC_WAIT_TIMEOUT_SEC) + last_state = "running" + while time.time() < deadline: + job = get_json(f"/apis/batch/v1/namespaces/{settings.NEXTCLOUD_NAMESPACE}/jobs/{job_name}") + if _job_succeeded(job): + return {"job": job_name, "status": "ok"} + if _job_failed(job): + return {"job": job_name, "status": "error"} + time.sleep(2) + last_state = "running" + + return {"job": job_name, "status": last_state} + diff --git a/backend/atlas_portal/provisioning.py b/backend/atlas_portal/provisioning.py index cb8e8b3..a6fb30f 100644 --- a/backend/atlas_portal/provisioning.py +++ b/backend/atlas_portal/provisioning.py @@ -10,6 +10,7 @@ import httpx from . import settings from .db import connect from .keycloak import admin_client +from .nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync from .utils import random_password from .vaultwarden import invite_user @@ -22,6 +23,7 @@ REQUIRED_PROVISION_TASKS: tuple[str, ...] = ( "keycloak_groups", "mailu_app_password", "mailu_sync", + "nextcloud_mail_sync", "vaultwarden_invite", ) @@ -306,6 +308,20 @@ def provision_access_request(request_code: str) -> ProvisionResult: except Exception as exc: _upsert_task(conn, request_code, "mailu_sync", "error", _safe_error_detail(exc, "failed to sync mailu")) + # Task: trigger Nextcloud mail sync if configured + try: + if not settings.NEXTCLOUD_NAMESPACE or not settings.NEXTCLOUD_MAIL_SYNC_CRONJOB: + _upsert_task(conn, request_code, "nextcloud_mail_sync", "ok", "sync disabled") + else: + result = trigger_nextcloud_mail_sync(username, wait=True) + if isinstance(result, dict) and result.get("status") == "ok": + _upsert_task(conn, request_code, "nextcloud_mail_sync", "ok", None) + else: + status_val = result.get("status") if isinstance(result, dict) else "error" + _upsert_task(conn, request_code, "nextcloud_mail_sync", "error", str(status_val)) + except Exception as exc: + _upsert_task(conn, request_code, "nextcloud_mail_sync", "error", _safe_error_detail(exc, "failed to sync nextcloud")) + # Task: ensure Vaultwarden account exists (invite flow) try: if not user_id: diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py index a7bf8ef..18fa3b0 100644 --- a/backend/atlas_portal/routes/account.py +++ b/backend/atlas_portal/routes/account.py @@ -5,10 +5,11 @@ import time from typing import Any import httpx -from flask import jsonify, g +from flask import jsonify, g, request from .. import settings from ..keycloak import admin_client, require_auth, require_account_access +from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync from ..utils import random_password @@ -35,6 +36,10 @@ def register(app) -> None: mailu_email = "" mailu_app_password = "" mailu_status = "ready" + nextcloud_mail_status = "unknown" + nextcloud_mail_primary_email = "" + nextcloud_mail_account_count = "" + nextcloud_mail_synced_at = "" jellyfin_status = "ready" jellyfin_sync_status = "unknown" jellyfin_sync_detail = "" @@ -65,6 +70,21 @@ def register(app) -> None: mailu_app_password = str(raw_pw[0]) elif isinstance(raw_pw, str) and raw_pw: mailu_app_password = raw_pw + raw_primary = attrs.get("nextcloud_mail_primary_email") + if isinstance(raw_primary, list) and raw_primary: + nextcloud_mail_primary_email = str(raw_primary[0]) + elif isinstance(raw_primary, str) and raw_primary: + nextcloud_mail_primary_email = raw_primary + raw_count = attrs.get("nextcloud_mail_account_count") + if isinstance(raw_count, list) and raw_count: + nextcloud_mail_account_count = str(raw_count[0]) + elif isinstance(raw_count, str) and raw_count: + nextcloud_mail_account_count = raw_count + raw_synced = attrs.get("nextcloud_mail_synced_at") + if isinstance(raw_synced, list) and raw_synced: + nextcloud_mail_synced_at = str(raw_synced[0]) + elif isinstance(raw_synced, str) and raw_synced: + nextcloud_mail_synced_at = raw_synced user_id = user.get("id") if isinstance(user, dict) else None if user_id and (not keycloak_email or not mailu_email or not mailu_app_password): @@ -86,8 +106,27 @@ def register(app) -> None: mailu_app_password = str(raw_pw[0]) elif isinstance(raw_pw, str) and raw_pw: mailu_app_password = raw_pw + if not nextcloud_mail_primary_email: + raw_primary = attrs.get("nextcloud_mail_primary_email") + if isinstance(raw_primary, list) and raw_primary: + nextcloud_mail_primary_email = str(raw_primary[0]) + elif isinstance(raw_primary, str) and raw_primary: + nextcloud_mail_primary_email = raw_primary + if not nextcloud_mail_account_count: + raw_count = attrs.get("nextcloud_mail_account_count") + if isinstance(raw_count, list) and raw_count: + nextcloud_mail_account_count = str(raw_count[0]) + elif isinstance(raw_count, str) and raw_count: + nextcloud_mail_account_count = raw_count + if not nextcloud_mail_synced_at: + raw_synced = attrs.get("nextcloud_mail_synced_at") + if isinstance(raw_synced, list) and raw_synced: + nextcloud_mail_synced_at = str(raw_synced[0]) + elif isinstance(raw_synced, str) and raw_synced: + nextcloud_mail_synced_at = raw_synced except Exception: mailu_status = "unavailable" + nextcloud_mail_status = "unavailable" jellyfin_status = "unavailable" jellyfin_sync_status = "unknown" jellyfin_sync_detail = "unavailable" @@ -97,6 +136,16 @@ def register(app) -> None: if not mailu_app_password and mailu_status == "ready": mailu_status = "needs app password" + if nextcloud_mail_status == "unknown": + try: + count_val = int(nextcloud_mail_account_count) if nextcloud_mail_account_count else 0 + except ValueError: + count_val = 0 + if count_val > 0: + nextcloud_mail_status = "ready" + else: + nextcloud_mail_status = "needs sync" + if jellyfin_status == "ready": ldap_reachable = _tcp_check( settings.JELLYFIN_LDAP_HOST, @@ -117,6 +166,12 @@ def register(app) -> None: { "user": {"username": username, "email": keycloak_email, "groups": g.keycloak_groups}, "mailu": {"status": mailu_status, "username": mailu_username, "app_password": mailu_app_password}, + "nextcloud_mail": { + "status": nextcloud_mail_status, + "primary_email": nextcloud_mail_primary_email, + "account_count": nextcloud_mail_account_count, + "synced_at": nextcloud_mail_synced_at, + }, "jellyfin": { "status": jellyfin_status, "username": username, @@ -161,11 +216,40 @@ def register(app) -> None: except Exception: sync_error = "sync request failed" + nextcloud_sync: dict[str, Any] = {"status": "skipped"} + try: + nextcloud_sync = trigger_nextcloud_mail_sync(username, wait=True) + except Exception: + nextcloud_sync = {"status": "error"} + return jsonify( { "password": password, "sync_enabled": sync_enabled, "sync_ok": sync_ok, "sync_error": sync_error, + "nextcloud_sync": nextcloud_sync, } ) + + @app.route("/api/account/nextcloud/mail/sync", methods=["POST"]) + @require_auth + def account_nextcloud_mail_sync() -> Any: + ok, resp = require_account_access() + if not ok: + return resp + if not admin_client().ready(): + return jsonify({"error": "server not configured"}), 503 + + username = g.keycloak_username + if not username: + return jsonify({"error": "missing username"}), 400 + + payload = request.get_json(silent=True) or {} + wait = bool(payload.get("wait", True)) + + try: + result = trigger_nextcloud_mail_sync(username, wait=wait) + return jsonify(result) + except Exception: + return jsonify({"error": "failed to sync nextcloud mail"}), 502 diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index 4f08b8e..dd1729f 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -14,6 +14,7 @@ VM_BASE_URL = os.getenv( ).rstrip("/") VM_QUERY_TIMEOUT_SEC = float(os.getenv("VM_QUERY_TIMEOUT_SEC", "2")) HTTP_CHECK_TIMEOUT_SEC = float(os.getenv("HTTP_CHECK_TIMEOUT_SEC", "2")) +K8S_API_TIMEOUT_SEC = float(os.getenv("K8S_API_TIMEOUT_SEC", "5")) LAB_STATUS_CACHE_SEC = float(os.getenv("LAB_STATUS_CACHE_SEC", "30")) GRAFANA_HEALTH_URL = os.getenv("GRAFANA_HEALTH_URL", "https://metrics.bstein.dev/api/health") OCEANUS_NODE_EXPORTER_URL = os.getenv("OCEANUS_NODE_EXPORTER_URL", "http://192.168.22.24:9100/metrics") @@ -84,6 +85,11 @@ MAILU_SYNC_URL = os.getenv( "http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events", ).rstrip("/") +NEXTCLOUD_NAMESPACE = os.getenv("NEXTCLOUD_NAMESPACE", "nextcloud").strip() +NEXTCLOUD_MAIL_SYNC_CRONJOB = os.getenv("NEXTCLOUD_MAIL_SYNC_CRONJOB", "nextcloud-mail-sync").strip() +NEXTCLOUD_MAIL_SYNC_WAIT_TIMEOUT_SEC = float(os.getenv("NEXTCLOUD_MAIL_SYNC_WAIT_TIMEOUT_SEC", "90")) +NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC = int(os.getenv("NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC", "3600")) + SMTP_HOST = os.getenv("SMTP_HOST", "mailu-front.mailu-mailserver.svc.cluster.local").strip() SMTP_PORT = int(os.getenv("SMTP_PORT", "25")) SMTP_USERNAME = os.getenv("SMTP_USERNAME", "").strip() diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index da1ac92..6fb5be3 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -89,6 +89,38 @@
{{ mailu.error }}
+ +
+ +
+

Nextcloud Mail

+ {{ nextcloudMail.status }} +
+

+ Syncs your Nextcloud Mail app with your Mailu mailbox (dedupes accounts and keeps the app password updated). +

+
+
+ Primary + {{ nextcloudMail.primaryEmail || mailu.username }} +
+
+ Accounts + {{ nextcloudMail.accountCount || "0" }} +
+
+ Synced + {{ nextcloudMail.syncedAt || "never" }} +
+
+
+ +
+
+
{{ nextcloudMail.error }}
+
@@ -199,6 +231,15 @@ const jellyfin = reactive({ error: "", }); +const nextcloudMail = reactive({ + status: "loading", + primaryEmail: "", + accountCount: "", + syncedAt: "", + syncing: false, + error: "", +}); + const admin = reactive({ enabled: false, loading: false, @@ -217,6 +258,7 @@ onMounted(() => { refreshAdminRequests(); } else { mailu.status = "login required"; + nextcloudMail.status = "login required"; jellyfin.status = "login required"; } }); @@ -227,6 +269,7 @@ watch( if (!ready) return; if (!authenticated) { mailu.status = "login required"; + nextcloudMail.status = "login required"; jellyfin.status = "login required"; admin.enabled = false; admin.requests = []; @@ -241,6 +284,7 @@ watch( async function refreshOverview() { mailu.error = ""; jellyfin.error = ""; + nextcloudMail.error = ""; try { const resp = await authFetch("/api/account/overview", { headers: { Accept: "application/json" }, @@ -254,17 +298,23 @@ async function refreshOverview() { mailu.status = data.mailu?.status || "ready"; mailu.username = data.mailu?.username || auth.email || auth.username; mailu.currentPassword = data.mailu?.app_password || ""; + nextcloudMail.status = data.nextcloud_mail?.status || "unknown"; + nextcloudMail.primaryEmail = data.nextcloud_mail?.primary_email || ""; + nextcloudMail.accountCount = data.nextcloud_mail?.account_count || ""; + nextcloudMail.syncedAt = data.nextcloud_mail?.synced_at || ""; jellyfin.status = data.jellyfin?.status || "ready"; jellyfin.username = data.jellyfin?.username || auth.username; jellyfin.syncStatus = data.jellyfin?.sync_status || ""; jellyfin.syncDetail = data.jellyfin?.sync_detail || ""; } catch (err) { mailu.status = "unavailable"; + nextcloudMail.status = "unavailable"; jellyfin.status = "unavailable"; jellyfin.syncStatus = ""; jellyfin.syncDetail = ""; const message = err?.message ? `Failed to load account status (${err.message})` : "Failed to load account status."; mailu.error = message; + nextcloudMail.error = message; jellyfin.error = message; } } @@ -320,6 +370,7 @@ async function rotateMailu() { } else { mailu.status = "updated"; } + await refreshOverview(); } catch (err) { mailu.error = err.message || "Rotation failed"; } finally { @@ -327,6 +378,25 @@ async function rotateMailu() { } } +async function syncNextcloudMail() { + nextcloudMail.error = ""; + nextcloudMail.syncing = true; + try { + const resp = await authFetch("/api/account/nextcloud/mail/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ wait: true }), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.error || `status ${resp.status}`); + await refreshOverview(); + } catch (err) { + nextcloudMail.error = err.message || "Sync failed"; + } finally { + nextcloudMail.syncing = false; + } +} + function fallbackCopy(text) { const textarea = document.createElement("textarea"); textarea.value = text; @@ -424,6 +494,17 @@ async function copy(key, text) { gap: 24px; } +.divider { + height: 1px; + background: rgba(255, 255, 255, 0.08); + margin: 18px 0; +} + +.subhead h3 { + margin: 0; + font-size: 16px; +} + .eyebrow { text-transform: uppercase; letter-spacing: 0.08em;