diff --git a/app/playbooks/chatwoot.py b/app/playbooks/chatwoot.py index 7ca3c29..e0a9208 100644 --- a/app/playbooks/chatwoot.py +++ b/app/playbooks/chatwoot.py @@ -100,3 +100,108 @@ async def create_chatwoot_setup_task( await db.refresh(task) return task + + +# ============================================================================= +# Initial Setup via Playwright +# ============================================================================= + + +def build_chatwoot_initial_setup_step( + *, + base_url: str, + admin_name: str, + company_name: str, + admin_email: str, + admin_password: str | None = None, +) -> dict[str, Any]: + """ + Build a PLAYWRIGHT task payload for Chatwoot initial setup. + + This creates the super admin account on a fresh Chatwoot installation. + + Args: + base_url: The base URL for Chatwoot (e.g., "https://chatwoot.example.com") + admin_name: Full name for the admin account + company_name: Company/organization name + admin_email: Email address for the admin account + admin_password: Password for admin (auto-generated if None) + + Returns: + Task payload dict with type="PLAYWRIGHT" + """ + from urllib.parse import urlparse + + # Extract domain from URL for allowlist + parsed = urlparse(base_url) + allowed_domain = parsed.netloc + + inputs: dict[str, Any] = { + "base_url": base_url, + "admin_name": admin_name, + "company_name": company_name, + "admin_email": admin_email, + } + + # Only include password if provided + if admin_password: + inputs["admin_password"] = admin_password + + return { + "scenario": "chatwoot_initial_setup", + "inputs": inputs, + "options": { + "allowed_domains": [allowed_domain], + }, + "timeout": 120, + } + + +async def create_chatwoot_initial_setup_task( + *, + db: AsyncSession, + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + base_url: str, + admin_name: str, + company_name: str, + admin_email: str, + admin_password: str | None = None, +) -> Task: + """ + Create and persist a PLAYWRIGHT task for Chatwoot initial setup. + + Args: + db: Async database session + tenant_id: UUID of the tenant + agent_id: UUID of the agent to assign the task to + base_url: The base URL for Chatwoot + admin_name: Full name for the admin account + company_name: Company/organization name + admin_email: Email address for the admin account + admin_password: Password for admin (auto-generated if None) + + Returns: + The created Task object with type="PLAYWRIGHT" + """ + payload = build_chatwoot_initial_setup_step( + base_url=base_url, + admin_name=admin_name, + company_name=company_name, + admin_email=admin_email, + admin_password=admin_password, + ) + + task = Task( + tenant_id=tenant_id, + agent_id=agent_id, + type="PLAYWRIGHT", + payload=payload, + status=TaskStatus.PENDING.value, + ) + + db.add(task) + await db.commit() + await db.refresh(task) + + return task diff --git a/app/playbooks/poste.py b/app/playbooks/poste.py new file mode 100644 index 0000000..1770664 --- /dev/null +++ b/app/playbooks/poste.py @@ -0,0 +1,111 @@ +"""Poste.io mail server deployment playbook. + +Defines the steps required to: +1. Perform initial setup via Playwright automation (configure hostname, create admin account) + +Tenant servers must have stacks and env templates under /opt/letsbe. +""" + +import uuid +from typing import Any +from urllib.parse import urlparse + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.task import Task, TaskStatus + + +# LetsBe standard paths +POSTE_STACK_DIR = "/opt/letsbe/stacks/poste" + + +# ============================================================================= +# Initial Setup via Playwright +# ============================================================================= + + +def build_poste_initial_setup_step( + *, + base_url: str, + admin_email: str, + admin_password: str | None = None, +) -> dict[str, Any]: + """ + Build a PLAYWRIGHT task payload for Poste.io initial setup. + + This configures the mail server hostname and creates the admin account + on a fresh Poste.io installation. + + Args: + base_url: The base URL for Poste.io (e.g., "https://mail.example.com") + admin_email: Email address for the admin account (e.g., admin@example.com) + admin_password: Password for the admin account (auto-generated if None) + + Returns: + Task payload dict with type="PLAYWRIGHT" + """ + # Extract domain from URL for allowlist + parsed = urlparse(base_url) + allowed_domain = parsed.netloc # e.g., "mail.example.com" + + inputs: dict[str, Any] = { + "base_url": base_url, + "admin_email": admin_email, + } + + # Only include password if provided - scenario will auto-generate if missing + if admin_password: + inputs["admin_password"] = admin_password + + return { + "scenario": "poste_initial_setup", + "inputs": inputs, + "options": { + "allowed_domains": [allowed_domain], + }, + "timeout": 120, + } + + +async def create_poste_initial_setup_task( + *, + db: AsyncSession, + tenant_id: uuid.UUID, + agent_id: uuid.UUID, + base_url: str, + admin_email: str, + admin_password: str | None = None, +) -> Task: + """ + Create and persist a PLAYWRIGHT task for Poste.io initial setup. + + Args: + db: Async database session + tenant_id: UUID of the tenant + agent_id: UUID of the agent to assign the task to + base_url: The base URL for Poste.io + admin_email: Email address for the admin account + admin_password: Password for admin (auto-generated if None) + + Returns: + The created Task object with type="PLAYWRIGHT" + """ + payload = build_poste_initial_setup_step( + base_url=base_url, + admin_email=admin_email, + admin_password=admin_password, + ) + + task = Task( + tenant_id=tenant_id, + agent_id=agent_id, + type="PLAYWRIGHT", + payload=payload, + status=TaskStatus.PENDING.value, + ) + + db.add(task) + await db.commit() + await db.refresh(task) + + return task diff --git a/app/routes/playbooks.py b/app/routes/playbooks.py index b051726..44f8aea 100644 --- a/app/routes/playbooks.py +++ b/app/routes/playbooks.py @@ -12,17 +12,21 @@ from app.db import AsyncSessionDep from app.models.agent import Agent from app.models.task import Task from app.models.tenant import Tenant -from app.playbooks.chatwoot import create_chatwoot_setup_task +from app.playbooks.chatwoot import ( + create_chatwoot_initial_setup_task, + create_chatwoot_setup_task, +) from app.playbooks.nextcloud import ( create_nextcloud_initial_setup_task, create_nextcloud_set_domain_task, ) +from app.playbooks.poste import create_poste_initial_setup_task from app.schemas.task import TaskResponse router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"]) # Health check timeout for Nextcloud availability -NEXTCLOUD_HEALTH_CHECK_TIMEOUT = 10.0 +HEALTH_CHECK_TIMEOUT = 10.0 class ChatwootSetupRequest(BaseModel): @@ -78,6 +82,68 @@ class NextcloudInitialSetupRequest(BaseModel): return v + + +class PosteInitialSetupRequest(BaseModel): + """Request body for Poste.io initial setup via Playwright.""" + + admin_email: str = Field( + ..., + min_length=5, + description="Email address for the Poste.io admin account (e.g., admin@example.com)", + ) + admin_password: str | None = Field( + None, + min_length=8, + description="Password for admin account (auto-generated if not provided)", + ) + + @field_validator("admin_email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate that admin_email is a valid email address.""" + email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + if not re.match(email_pattern, v): + raise ValueError("admin_email must be a valid email address") + return v + + +class ChatwootInitialSetupRequest(BaseModel): + """Request body for Chatwoot initial setup via Playwright.""" + + admin_name: str = Field( + ..., + min_length=2, + max_length=100, + description="Full name for the Chatwoot admin account", + ) + company_name: str = Field( + ..., + min_length=2, + max_length=100, + description="Company/organization name", + ) + admin_email: str = Field( + ..., + min_length=5, + description="Email address for the Chatwoot admin account", + ) + admin_password: str | None = Field( + None, + min_length=8, + description="Password for admin account (auto-generated if not provided)", + ) + + @field_validator("admin_email") + @classmethod + def validate_email(cls, v: str) -> str: + """Validate that admin_email is a valid email address.""" + email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + if not re.match(email_pattern, v): + raise ValueError("admin_email must be a valid email address") + return v + + # --- Helper functions --- @@ -123,7 +189,7 @@ async def check_nextcloud_availability(base_url: str) -> bool: try: async with httpx.AsyncClient( - timeout=NEXTCLOUD_HEALTH_CHECK_TIMEOUT, + timeout=HEALTH_CHECK_TIMEOUT, follow_redirects=True, ) as client: response = await client.get(status_url) @@ -146,6 +212,58 @@ async def check_nextcloud_availability(base_url: str) -> bool: return False + + +async def check_poste_availability(base_url: str) -> bool: + """ + Check if Poste.io is available at the given URL. + + Performs a health check against the base URL to verify the service is up. + + Args: + base_url: The base URL for Poste.io (e.g., "https://mail.example.com") + + Returns: + True if Poste.io responds, False otherwise + """ + try: + async with httpx.AsyncClient( + timeout=HEALTH_CHECK_TIMEOUT, + follow_redirects=True, + ) as client: + response = await client.get(base_url.rstrip("/")) + # Poste.io returns 200 on setup page or login page + return response.status_code == 200 + + except (httpx.RequestError, httpx.TimeoutException): + return False + + +async def check_chatwoot_availability(base_url: str) -> bool: + """ + Check if Chatwoot is available at the given URL. + + Performs a health check against the base URL to verify the service is up. + + Args: + base_url: The base URL for Chatwoot (e.g., "https://chatwoot.example.com") + + Returns: + True if Chatwoot responds, False otherwise + """ + try: + async with httpx.AsyncClient( + timeout=HEALTH_CHECK_TIMEOUT, + follow_redirects=True, + ) as client: + response = await client.get(base_url.rstrip("/")) + # Chatwoot returns 200 on setup page or login page + return response.status_code == 200 + + except (httpx.RequestError, httpx.TimeoutException): + return False + + # --- Route handlers --- @@ -345,3 +463,188 @@ async def nextcloud_initial_setup( ) return task + + +@router.post( + "/poste/setup", + response_model=TaskResponse, + status_code=status.HTTP_201_CREATED, +) +async def poste_initial_setup( + tenant_id: uuid.UUID, + request: PosteInitialSetupRequest, + db: AsyncSessionDep, +) -> Task: + """ + Perform initial Poste.io setup via Playwright browser automation. + + Creates a PLAYWRIGHT task that navigates to the Poste.io setup wizard, + configures the mailserver hostname, and creates the admin account. + This should only be called once on a fresh Poste.io installation. + + **Prerequisites:** + - Tenant must have a domain configured + - Poste.io must be deployed and accessible at `https://mail.{domain}` + - An online agent must be registered for the tenant + + **Health Check:** + Before creating the task, the endpoint checks if Poste.io is available. + If not reachable, returns 409. + + ## Request Body + - **admin_email**: Email address for the admin account (e.g., admin@example.com) + - **admin_password**: Password for admin (auto-generated if not provided) + + ## Response + Returns the created Task with type="PLAYWRIGHT" and status="pending". + + The task result will contain the generated/provided credentials. + + ## Errors + - **404**: Tenant not found + - **400**: Tenant has no domain configured + - **404**: No online agent found for tenant + - **409**: Poste.io not available (health check failed) + - **422**: Invalid email format + """ + # Validate tenant exists + tenant = await get_tenant_by_id(db, tenant_id) + if tenant is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Tenant {tenant_id} not found", + ) + + # Validate tenant has domain configured + if not tenant.domain: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Tenant {tenant_id} has no domain configured", + ) + + # Auto-resolve agent (first online agent for tenant) + agent = await get_online_agent_for_tenant(db, tenant_id) + if agent is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No online agent found for tenant {tenant_id}", + ) + + # Construct Poste.io base URL from tenant domain + base_url = f"https://mail.{tenant.domain}" + + # Health check: verify Poste.io is accessible + is_available = await check_poste_availability(base_url) + if not is_available: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Poste.io not available at {base_url}. " + "Ensure Poste.io is deployed and accessible before running setup.", + ) + + # Create the PLAYWRIGHT task + task = await create_poste_initial_setup_task( + db=db, + tenant_id=tenant_id, + agent_id=agent.id, + base_url=base_url, + admin_email=request.admin_email, + admin_password=request.admin_password, + ) + + return task + + +@router.post( + "/chatwoot/initial-setup", + response_model=TaskResponse, + status_code=status.HTTP_201_CREATED, +) +async def chatwoot_initial_setup( + tenant_id: uuid.UUID, + request: ChatwootInitialSetupRequest, + db: AsyncSessionDep, +) -> Task: + """ + Perform initial Chatwoot setup via Playwright browser automation. + + Creates a PLAYWRIGHT task that navigates to the Chatwoot setup wizard, + creates the super admin account, unchecks newsletter subscription, + and completes the setup. This should only be called once on a fresh + Chatwoot installation. + + **Prerequisites:** + - Tenant must have a domain configured + - Chatwoot must be deployed and accessible at `https://chatwoot.{domain}` + - An online agent must be registered for the tenant + + **Health Check:** + Before creating the task, the endpoint checks if Chatwoot is available. + If not reachable, returns 409. + + ## Request Body + - **admin_name**: Full name for the admin account + - **company_name**: Company/organization name + - **admin_email**: Email address for the admin account + - **admin_password**: Password for admin (auto-generated if not provided) + + ## Response + Returns the created Task with type="PLAYWRIGHT" and status="pending". + + The task result will contain the generated/provided credentials. + + ## Errors + - **404**: Tenant not found + - **400**: Tenant has no domain configured + - **404**: No online agent found for tenant + - **409**: Chatwoot not available (health check failed) + - **422**: Invalid email format + """ + # Validate tenant exists + tenant = await get_tenant_by_id(db, tenant_id) + if tenant is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Tenant {tenant_id} not found", + ) + + # Validate tenant has domain configured + if not tenant.domain: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Tenant {tenant_id} has no domain configured", + ) + + # Auto-resolve agent (first online agent for tenant) + agent = await get_online_agent_for_tenant(db, tenant_id) + if agent is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No online agent found for tenant {tenant_id}", + ) + + # Construct Chatwoot base URL from tenant domain + base_url = f"https://chatwoot.{tenant.domain}" + + # Health check: verify Chatwoot is accessible + is_available = await check_chatwoot_availability(base_url) + if not is_available: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Chatwoot not available at {base_url}. " + "Ensure Chatwoot is deployed and accessible before running setup.", + ) + + # Create the PLAYWRIGHT task + task = await create_chatwoot_initial_setup_task( + db=db, + tenant_id=tenant_id, + agent_id=agent.id, + base_url=base_url, + admin_name=request.admin_name, + company_name=request.company_name, + admin_email=request.admin_email, + admin_password=request.admin_password, + ) + + return task