"""Settings API — email accounts stored in PostgreSQL.""" from __future__ import annotations import smtplib import json from datetime import datetime from email.mime.text import MIMEText from typing import Any, Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from app.db import execute, fetch_all, fetch_one from app.services import agent_souls from app.services.social_publish import PLATFORMS, get_integration, test_connection settings_router = APIRouter(prefix="/api/settings", tags=["settings"]) class EmailAccountBody(BaseModel): label: str = Field(..., max_length=128) email_address: str = Field(..., max_length=255) provider: str = Field(default="custom", max_length=32) is_active: bool = False smtp_host: Optional[str] = None smtp_port: int = 587 smtp_user: Optional[str] = None smtp_password: Optional[str] = None imap_host: Optional[str] = None imap_port: int = 993 imap_user: Optional[str] = None imap_password: Optional[str] = None sync_enabled: bool = False def _mask_account(row: dict[str, Any] | None) -> dict[str, Any] | None: if not row: return None out = dict(row) for k, v in list(out.items()): if hasattr(v, "isoformat"): out[k] = v.isoformat() out["smtp_password_set"] = bool(row.get("smtp_password")) out["imap_password_set"] = bool(row.get("imap_password")) out.pop("smtp_password", None) out.pop("imap_password", None) return out def _deactivate_all() -> None: execute("UPDATE email_accounts SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE") def _test_smtp_config( smtp_host: str, smtp_port: int, smtp_user: str, smtp_pass: str, from_addr: str, ) -> tuple[bool, str]: if not smtp_host or not from_addr: return False, "SMTP host en from-adres zijn verplicht" try: msg = MIMEText("Foodlinkk SMTP test — Herman email settings OK.", "plain", "utf-8") msg["Subject"] = "Foodlinkk test email" msg["From"] = from_addr msg["To"] = from_addr with smtplib.SMTP(smtp_host, smtp_port, timeout=25) as server: server.ehlo() if smtp_port == 587: server.starttls() if smtp_user and smtp_pass: server.login(smtp_user, smtp_pass) server.sendmail(from_addr, [from_addr], msg.as_string()) return True, f"Testmail verstuurd naar {from_addr}" except Exception as exc: return False, str(exc) def _resolve_password(new: Optional[str], existing: Optional[str]) -> Optional[str]: if new is not None and new != "": return new return existing SOCIAL_PLATFORM_FIELDS: dict[str, tuple[str, ...]] = { "twitter": ("api_key", "api_secret", "access_token", "access_secret"), "linkedin": ("access_token", "person_urn"), "instagram": ("access_token", "page_id"), "facebook": ("access_token", "page_id"), "tiktok": ("access_token", "open_id"), "pinterest": ("access_token", "board_id"), } SOCIAL_SECRET_FIELDS = {"api_secret", "access_secret", "access_token", "api_key"} class SocialIntegrationBody(BaseModel): api_key: Optional[str] = None api_secret: Optional[str] = None access_token: Optional[str] = None access_secret: Optional[str] = None person_urn: Optional[str] = None page_id: Optional[str] = None open_id: Optional[str] = None board_id: Optional[str] = None is_active: Optional[bool] = None def _mask_social_row(row: dict[str, Any] | None) -> dict[str, Any] | None: if not row: return None out = dict(row) for k, v in list(out.items()): if hasattr(v, "isoformat"): out[k] = v.isoformat() config = out.get("config") or {} if isinstance(config, str): try: config = json.loads(config) except Exception: config = {} if not isinstance(config, dict): config = {} masked = dict(config) for key in SOCIAL_SECRET_FIELDS: if key in config: masked[f"{key}_set"] = bool(config.get(key)) masked.pop(key, None) out["config"] = masked return out def _normalize_social_platform(platform: str) -> str: value = (platform or "").strip().lower() if value not in PLATFORMS: raise HTTPException(status_code=400, detail=f"Unsupported platform: {platform}") return value @settings_router.get("/email") def list_email_accounts() -> dict[str, Any]: try: rows = fetch_all("SELECT * FROM email_accounts ORDER BY is_active DESC, updated_at DESC") except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc return {"accounts": [_mask_account(r) for r in rows]} @settings_router.post("/email") def create_email_account(body: EmailAccountBody) -> dict[str, Any]: if body.is_active: _deactivate_all() try: row = fetch_one( """ INSERT INTO email_accounts ( label, email_address, provider, is_active, smtp_host, smtp_port, smtp_user, smtp_password, imap_host, imap_port, imap_user, imap_password, sync_enabled ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING * """, ( body.label, body.email_address, body.provider, body.is_active, body.smtp_host, body.smtp_port, body.smtp_user or body.email_address, body.smtp_password or "", body.imap_host, body.imap_port, body.imap_user or body.email_address, body.imap_password or "", body.sync_enabled, ), ) except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc return {"ok": True, "account": _mask_account(row)} @settings_router.put("/email/{account_id}") def update_email_account(account_id: int, body: EmailAccountBody) -> dict[str, Any]: existing = fetch_one("SELECT * FROM email_accounts WHERE id = %s", (account_id,)) if not existing: raise HTTPException(status_code=404, detail="Account not found") if body.is_active: _deactivate_all() smtp_pass = _resolve_password(body.smtp_password, existing.get("smtp_password")) imap_pass = _resolve_password(body.imap_password, existing.get("imap_password")) try: row = fetch_one( """ UPDATE email_accounts SET label=%s, email_address=%s, provider=%s, is_active=%s, smtp_host=%s, smtp_port=%s, smtp_user=%s, smtp_password=%s, imap_host=%s, imap_port=%s, imap_user=%s, imap_password=%s, sync_enabled=%s, updated_at=NOW() WHERE id=%s RETURNING * """, ( body.label, body.email_address, body.provider, body.is_active, body.smtp_host, body.smtp_port, body.smtp_user or body.email_address, smtp_pass, body.imap_host, body.imap_port, body.imap_user or body.email_address, imap_pass, body.sync_enabled, account_id, ), ) except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc return {"ok": True, "account": _mask_account(row)} @settings_router.delete("/email/{account_id}") def delete_email_account(account_id: int) -> dict[str, Any]: execute("DELETE FROM email_accounts WHERE id = %s", (account_id,)) return {"ok": True} @settings_router.post("/email/{account_id}/activate") def activate_email_account(account_id: int) -> dict[str, Any]: existing = fetch_one("SELECT id FROM email_accounts WHERE id = %s", (account_id,)) if not existing: raise HTTPException(status_code=404, detail="Account not found") _deactivate_all() row = fetch_one( "UPDATE email_accounts SET is_active=TRUE, updated_at=NOW() WHERE id=%s RETURNING *", (account_id,), ) return {"ok": True, "account": _mask_account(row)} @settings_router.post("/email/{account_id}/test") def test_saved_email_account(account_id: int) -> dict[str, Any]: row = fetch_one("SELECT * FROM email_accounts WHERE id = %s", (account_id,)) if not row: raise HTTPException(status_code=404, detail="Account not found") ok, message = _test_smtp_config( row.get("smtp_host") or "", int(row.get("smtp_port") or 587), row.get("smtp_user") or row.get("email_address") or "", row.get("smtp_password") or "", row.get("email_address") or row.get("smtp_user") or "", ) status = "ok" if ok else "failed" execute( """ UPDATE email_accounts SET last_test_at=NOW(), last_test_status=%s, last_test_message=%s, updated_at=NOW() WHERE id=%s """, (status, message[:500], account_id), ) return {"ok": ok, "message": message} @settings_router.post("/email/test") def test_email_config(body: EmailAccountBody) -> dict[str, Any]: """Test SMTP without saving (form preview).""" if not body.smtp_password: raise HTTPException(status_code=400, detail="SMTP wachtwoord is verplicht voor test zonder opgeslagen account") ok, message = _test_smtp_config( body.smtp_host or "", body.smtp_port, body.smtp_user or body.email_address, body.smtp_password, body.email_address, ) return {"ok": ok, "message": message} @settings_router.get("/social") def list_social_integrations() -> dict[str, Any]: rows = fetch_all("SELECT * FROM social_integrations ORDER BY platform") return {"items": [_mask_social_row(r) for r in rows], "platforms": list(PLATFORMS)} @settings_router.put("/social/{platform}") def save_social_integration(platform: str, body: SocialIntegrationBody) -> dict[str, Any]: platform = _normalize_social_platform(platform) allowed_fields = set(SOCIAL_PLATFORM_FIELDS[platform]) incoming = body.model_dump(exclude_none=True) existing = fetch_one("SELECT * FROM social_integrations WHERE platform = %s", (platform,)) existing_config = {} if existing: existing_config = existing.get("config") or {} if isinstance(existing_config, str): try: existing_config = json.loads(existing_config) except Exception: existing_config = {} if not isinstance(existing_config, dict): existing_config = {} config = dict(existing_config) for field in allowed_fields: if field not in incoming: continue value = incoming.get(field) if field in SOCIAL_SECRET_FIELDS: if value is not None and value != "": config[field] = value else: config[field] = value is_active = body.is_active if is_active is None: is_active = bool(existing.get("is_active")) if existing else True row = fetch_one( """ INSERT INTO social_integrations (platform, config, is_active, updated_at) VALUES (%s, %s::jsonb, %s, NOW()) ON CONFLICT (platform) DO UPDATE SET config = EXCLUDED.config, is_active = EXCLUDED.is_active, updated_at = NOW() RETURNING * """, (platform, json.dumps(config), is_active), ) return {"ok": True, "integration": _mask_social_row(row)} @settings_router.post("/social/{platform}/test") def test_social_integration(platform: str) -> dict[str, Any]: platform = _normalize_social_platform(platform) integration = get_integration(platform) if not integration: raise HTTPException(status_code=404, detail="Integration not configured") result = test_connection(platform, integration) status = "ok" if result.get("ok") else "failed" message = (result.get("message") or result.get("error") or "")[:500] try: execute( """ UPDATE social_integrations SET last_test_at=NOW(), last_test_status=%s, last_test_message=%s, updated_at=NOW() WHERE platform=%s """, (status, message, platform), ) except Exception: pass return {"platform": platform, **result} class PermissionBody(BaseModel): granted: bool @settings_router.get("/permissions") def list_permissions() -> dict[str, Any]: items = agent_souls.list_permissions() granted = sum(1 for i in items if i.get("granted")) return {"items": items, "granted_count": granted, "total": len(items)} @settings_router.put("/permissions/{module_key}") def update_permission(module_key: str, body: PermissionBody) -> dict[str, Any]: row = agent_souls.update_permission(module_key, body.granted) if not row: raise HTTPException(404, "Module not found") return {"permission": row} @settings_router.post("/permissions/grant-all") def grant_all_permissions() -> dict[str, Any]: n = agent_souls.grant_all_permissions() return {"ok": True, "granted_count": n, "message": f"Herman heeft nu {n} module-rechten"}