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)
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.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