portal: trigger Nextcloud mail sync
This commit is contained in:
parent
c9ffcedceb
commit
7902d7658f
56
backend/atlas_portal/k8s.py
Normal file
56
backend/atlas_portal/k8s.py
Normal file
@ -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
|
||||||
|
|
||||||
123
backend/atlas_portal/nextcloud_mail_sync.py
Normal file
123
backend/atlas_portal/nextcloud_mail_sync.py
Normal file
@ -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}
|
||||||
|
|
||||||
@ -10,6 +10,7 @@ import httpx
|
|||||||
from . import settings
|
from . import settings
|
||||||
from .db import connect
|
from .db import connect
|
||||||
from .keycloak import admin_client
|
from .keycloak import admin_client
|
||||||
|
from .nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync
|
||||||
from .utils import random_password
|
from .utils import random_password
|
||||||
from .vaultwarden import invite_user
|
from .vaultwarden import invite_user
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
|
|||||||
"keycloak_groups",
|
"keycloak_groups",
|
||||||
"mailu_app_password",
|
"mailu_app_password",
|
||||||
"mailu_sync",
|
"mailu_sync",
|
||||||
|
"nextcloud_mail_sync",
|
||||||
"vaultwarden_invite",
|
"vaultwarden_invite",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -306,6 +308,20 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_upsert_task(conn, request_code, "mailu_sync", "error", _safe_error_detail(exc, "failed to sync mailu"))
|
_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)
|
# Task: ensure Vaultwarden account exists (invite flow)
|
||||||
try:
|
try:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
|||||||
@ -5,10 +5,11 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from flask import jsonify, g
|
from flask import jsonify, g, request
|
||||||
|
|
||||||
from .. import settings
|
from .. import settings
|
||||||
from ..keycloak import admin_client, require_auth, require_account_access
|
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
|
from ..utils import random_password
|
||||||
|
|
||||||
|
|
||||||
@ -35,6 +36,10 @@ def register(app) -> None:
|
|||||||
mailu_email = ""
|
mailu_email = ""
|
||||||
mailu_app_password = ""
|
mailu_app_password = ""
|
||||||
mailu_status = "ready"
|
mailu_status = "ready"
|
||||||
|
nextcloud_mail_status = "unknown"
|
||||||
|
nextcloud_mail_primary_email = ""
|
||||||
|
nextcloud_mail_account_count = ""
|
||||||
|
nextcloud_mail_synced_at = ""
|
||||||
jellyfin_status = "ready"
|
jellyfin_status = "ready"
|
||||||
jellyfin_sync_status = "unknown"
|
jellyfin_sync_status = "unknown"
|
||||||
jellyfin_sync_detail = ""
|
jellyfin_sync_detail = ""
|
||||||
@ -65,6 +70,21 @@ def register(app) -> None:
|
|||||||
mailu_app_password = str(raw_pw[0])
|
mailu_app_password = str(raw_pw[0])
|
||||||
elif isinstance(raw_pw, str) and raw_pw:
|
elif isinstance(raw_pw, str) and raw_pw:
|
||||||
mailu_app_password = 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
|
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):
|
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])
|
mailu_app_password = str(raw_pw[0])
|
||||||
elif isinstance(raw_pw, str) and raw_pw:
|
elif isinstance(raw_pw, str) and raw_pw:
|
||||||
mailu_app_password = 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:
|
except Exception:
|
||||||
mailu_status = "unavailable"
|
mailu_status = "unavailable"
|
||||||
|
nextcloud_mail_status = "unavailable"
|
||||||
jellyfin_status = "unavailable"
|
jellyfin_status = "unavailable"
|
||||||
jellyfin_sync_status = "unknown"
|
jellyfin_sync_status = "unknown"
|
||||||
jellyfin_sync_detail = "unavailable"
|
jellyfin_sync_detail = "unavailable"
|
||||||
@ -97,6 +136,16 @@ def register(app) -> None:
|
|||||||
if not mailu_app_password and mailu_status == "ready":
|
if not mailu_app_password and mailu_status == "ready":
|
||||||
mailu_status = "needs app password"
|
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":
|
if jellyfin_status == "ready":
|
||||||
ldap_reachable = _tcp_check(
|
ldap_reachable = _tcp_check(
|
||||||
settings.JELLYFIN_LDAP_HOST,
|
settings.JELLYFIN_LDAP_HOST,
|
||||||
@ -117,6 +166,12 @@ def register(app) -> None:
|
|||||||
{
|
{
|
||||||
"user": {"username": username, "email": keycloak_email, "groups": g.keycloak_groups},
|
"user": {"username": username, "email": keycloak_email, "groups": g.keycloak_groups},
|
||||||
"mailu": {"status": mailu_status, "username": mailu_username, "app_password": mailu_app_password},
|
"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": {
|
"jellyfin": {
|
||||||
"status": jellyfin_status,
|
"status": jellyfin_status,
|
||||||
"username": username,
|
"username": username,
|
||||||
@ -161,11 +216,40 @@ def register(app) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
sync_error = "sync request failed"
|
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(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"password": password,
|
"password": password,
|
||||||
"sync_enabled": sync_enabled,
|
"sync_enabled": sync_enabled,
|
||||||
"sync_ok": sync_ok,
|
"sync_ok": sync_ok,
|
||||||
"sync_error": sync_error,
|
"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
|
||||||
|
|||||||
@ -14,6 +14,7 @@ VM_BASE_URL = os.getenv(
|
|||||||
).rstrip("/")
|
).rstrip("/")
|
||||||
VM_QUERY_TIMEOUT_SEC = float(os.getenv("VM_QUERY_TIMEOUT_SEC", "2"))
|
VM_QUERY_TIMEOUT_SEC = float(os.getenv("VM_QUERY_TIMEOUT_SEC", "2"))
|
||||||
HTTP_CHECK_TIMEOUT_SEC = float(os.getenv("HTTP_CHECK_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"))
|
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")
|
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")
|
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",
|
"http://mailu-sync-listener.mailu-mailserver.svc.cluster.local:8080/events",
|
||||||
).rstrip("/")
|
).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_HOST = os.getenv("SMTP_HOST", "mailu-front.mailu-mailserver.svc.cluster.local").strip()
|
||||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
|
SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
|
||||||
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "").strip()
|
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "").strip()
|
||||||
|
|||||||
@ -89,6 +89,38 @@
|
|||||||
<div v-if="mailu.error" class="error-box">
|
<div v-if="mailu.error" class="error-box">
|
||||||
<div class="mono">{{ mailu.error }}</div>
|
<div class="mono">{{ mailu.error }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="module-head subhead">
|
||||||
|
<h3>Nextcloud Mail</h3>
|
||||||
|
<span class="pill mono">{{ nextcloudMail.status }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">
|
||||||
|
Syncs your Nextcloud Mail app with your Mailu mailbox (dedupes accounts and keeps the app password updated).
|
||||||
|
</p>
|
||||||
|
<div class="kv">
|
||||||
|
<div class="row">
|
||||||
|
<span class="k mono">Primary</span>
|
||||||
|
<span class="v mono">{{ nextcloudMail.primaryEmail || mailu.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="k mono">Accounts</span>
|
||||||
|
<span class="v mono">{{ nextcloudMail.accountCount || "0" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="k mono">Synced</span>
|
||||||
|
<span class="v mono">{{ nextcloudMail.syncedAt || "never" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="pill mono" type="button" :disabled="nextcloudMail.syncing" @click="syncNextcloudMail">
|
||||||
|
{{ nextcloudMail.syncing ? "Syncing..." : "Sync Nextcloud Mail now" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="nextcloudMail.error" class="error-box">
|
||||||
|
<div class="mono">{{ nextcloudMail.error }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card module">
|
<div class="card module">
|
||||||
@ -199,6 +231,15 @@ const jellyfin = reactive({
|
|||||||
error: "",
|
error: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nextcloudMail = reactive({
|
||||||
|
status: "loading",
|
||||||
|
primaryEmail: "",
|
||||||
|
accountCount: "",
|
||||||
|
syncedAt: "",
|
||||||
|
syncing: false,
|
||||||
|
error: "",
|
||||||
|
});
|
||||||
|
|
||||||
const admin = reactive({
|
const admin = reactive({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
@ -217,6 +258,7 @@ onMounted(() => {
|
|||||||
refreshAdminRequests();
|
refreshAdminRequests();
|
||||||
} else {
|
} else {
|
||||||
mailu.status = "login required";
|
mailu.status = "login required";
|
||||||
|
nextcloudMail.status = "login required";
|
||||||
jellyfin.status = "login required";
|
jellyfin.status = "login required";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -227,6 +269,7 @@ watch(
|
|||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
mailu.status = "login required";
|
mailu.status = "login required";
|
||||||
|
nextcloudMail.status = "login required";
|
||||||
jellyfin.status = "login required";
|
jellyfin.status = "login required";
|
||||||
admin.enabled = false;
|
admin.enabled = false;
|
||||||
admin.requests = [];
|
admin.requests = [];
|
||||||
@ -241,6 +284,7 @@ watch(
|
|||||||
async function refreshOverview() {
|
async function refreshOverview() {
|
||||||
mailu.error = "";
|
mailu.error = "";
|
||||||
jellyfin.error = "";
|
jellyfin.error = "";
|
||||||
|
nextcloudMail.error = "";
|
||||||
try {
|
try {
|
||||||
const resp = await authFetch("/api/account/overview", {
|
const resp = await authFetch("/api/account/overview", {
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
@ -254,17 +298,23 @@ async function refreshOverview() {
|
|||||||
mailu.status = data.mailu?.status || "ready";
|
mailu.status = data.mailu?.status || "ready";
|
||||||
mailu.username = data.mailu?.username || auth.email || auth.username;
|
mailu.username = data.mailu?.username || auth.email || auth.username;
|
||||||
mailu.currentPassword = data.mailu?.app_password || "";
|
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.status = data.jellyfin?.status || "ready";
|
||||||
jellyfin.username = data.jellyfin?.username || auth.username;
|
jellyfin.username = data.jellyfin?.username || auth.username;
|
||||||
jellyfin.syncStatus = data.jellyfin?.sync_status || "";
|
jellyfin.syncStatus = data.jellyfin?.sync_status || "";
|
||||||
jellyfin.syncDetail = data.jellyfin?.sync_detail || "";
|
jellyfin.syncDetail = data.jellyfin?.sync_detail || "";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
mailu.status = "unavailable";
|
mailu.status = "unavailable";
|
||||||
|
nextcloudMail.status = "unavailable";
|
||||||
jellyfin.status = "unavailable";
|
jellyfin.status = "unavailable";
|
||||||
jellyfin.syncStatus = "";
|
jellyfin.syncStatus = "";
|
||||||
jellyfin.syncDetail = "";
|
jellyfin.syncDetail = "";
|
||||||
const message = err?.message ? `Failed to load account status (${err.message})` : "Failed to load account status.";
|
const message = err?.message ? `Failed to load account status (${err.message})` : "Failed to load account status.";
|
||||||
mailu.error = message;
|
mailu.error = message;
|
||||||
|
nextcloudMail.error = message;
|
||||||
jellyfin.error = message;
|
jellyfin.error = message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -320,6 +370,7 @@ async function rotateMailu() {
|
|||||||
} else {
|
} else {
|
||||||
mailu.status = "updated";
|
mailu.status = "updated";
|
||||||
}
|
}
|
||||||
|
await refreshOverview();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
mailu.error = err.message || "Rotation failed";
|
mailu.error = err.message || "Rotation failed";
|
||||||
} finally {
|
} 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) {
|
function fallbackCopy(text) {
|
||||||
const textarea = document.createElement("textarea");
|
const textarea = document.createElement("textarea");
|
||||||
textarea.value = text;
|
textarea.value = text;
|
||||||
@ -424,6 +494,17 @@ async function copy(key, text) {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subhead h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user