feat: Add Poste.io and Chatwoot initial setup Playwright endpoints
Build and Push Docker Image / test (push) Successful in 47s Details
Build and Push Docker Image / build (push) Successful in 1m10s Details

- Add POST /tenants/{tenant_id}/poste/setup endpoint for Poste.io mail
  server initial setup via Playwright browser automation
- Add POST /tenants/{tenant_id}/chatwoot/initial-setup endpoint for
  Chatwoot super admin creation via Playwright
- Add health check functions for Poste.io and Chatwoot availability
- Add request models with email validation for both services
- Passwords auto-generated if not provided, returned in task result

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matt 2025-12-09 14:51:00 +01:00
parent d85ab9c493
commit 9e8c9931b4
3 changed files with 522 additions and 3 deletions

View File

@ -100,3 +100,108 @@ async def create_chatwoot_setup_task(
await db.refresh(task) await db.refresh(task)
return 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

111
app/playbooks/poste.py Normal file
View File

@ -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

View File

@ -12,17 +12,21 @@ from app.db import AsyncSessionDep
from app.models.agent import Agent from app.models.agent import Agent
from app.models.task import Task from app.models.task import Task
from app.models.tenant import Tenant 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 ( from app.playbooks.nextcloud import (
create_nextcloud_initial_setup_task, create_nextcloud_initial_setup_task,
create_nextcloud_set_domain_task, create_nextcloud_set_domain_task,
) )
from app.playbooks.poste import create_poste_initial_setup_task
from app.schemas.task import TaskResponse from app.schemas.task import TaskResponse
router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"]) router = APIRouter(prefix="/tenants/{tenant_id}", tags=["Playbooks"])
# Health check timeout for Nextcloud availability # Health check timeout for Nextcloud availability
NEXTCLOUD_HEALTH_CHECK_TIMEOUT = 10.0 HEALTH_CHECK_TIMEOUT = 10.0
class ChatwootSetupRequest(BaseModel): class ChatwootSetupRequest(BaseModel):
@ -78,6 +82,68 @@ class NextcloudInitialSetupRequest(BaseModel):
return v 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 --- # --- Helper functions ---
@ -123,7 +189,7 @@ async def check_nextcloud_availability(base_url: str) -> bool:
try: try:
async with httpx.AsyncClient( async with httpx.AsyncClient(
timeout=NEXTCLOUD_HEALTH_CHECK_TIMEOUT, timeout=HEALTH_CHECK_TIMEOUT,
follow_redirects=True, follow_redirects=True,
) as client: ) as client:
response = await client.get(status_url) response = await client.get(status_url)
@ -146,6 +212,58 @@ async def check_nextcloud_availability(base_url: str) -> bool:
return False 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 --- # --- Route handlers ---
@ -345,3 +463,188 @@ async def nextcloud_initial_setup(
) )
return task 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