Spec 043/035: FOSS ligbox-mail-business + mail-bundle VM112

Cria produto FOSS (product_id=3), endpoint mail-bundle no wizard VM112 com
contrato documentado, e runbook de deploy para fechar o ciclo de activação.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ligbox Spec Hub 2026-07-01 16:54:40 +00:00
parent 5c554d6758
commit 884321c43b
13 changed files with 580 additions and 10 deletions

View file

@ -211,3 +211,4 @@
- **contracts/** - **contracts/**
- [activation-field-mapping.md](specs/043-desk-client-activation-sync/contracts/activation-field-mapping.md) — **fonte única** - [activation-field-mapping.md](specs/043-desk-client-activation-sync/contracts/activation-field-mapping.md) — **fonte única**
- [desk-activate-account-api.md](specs/043-desk-client-activation-sync/contracts/desk-activate-account-api.md) - [desk-activate-account-api.md](specs/043-desk-client-activation-sync/contracts/desk-activate-account-api.md)
- [mail-bundle-api.md](specs/043-desk-client-activation-sync/contracts/mail-bundle-api.md)

View file

@ -0,0 +1,56 @@
# Deploy — mail-bundle endpoint VM112 (Spec 035/043)
**Roger · 2026-07-01**
## Pré-requisitos
- `OPS_INTERNAL_TOKEN` no `/opt/ligbox-deploy/env/.env` ou `.env` wizard (igual VM122 Desk)
- Clone `ligbox-ops-platform` na VM112 ou copiar ficheiros via `scp`
## Passos (executar NA VM112)
```bash
cd /opt/ligbox-spec-hub/repos/ligbox-ops-platform
git pull origin main
python3 deploy/vm112-wizard/deploy-mail-bundle-vm112.py
systemctl restart ligbox-wizard
systemctl status ligbox-wizard --no-pager
```
## Teste local
```bash
source /opt/ligbox-deploy/env/.env 2>/dev/null || true
curl -s -X POST http://127.0.0.1:8090/api/internal/provision/mail-bundle \
-H "X-Ops-Internal-Token: ${OPS_INTERNAL_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"domain": "empresa-teste.ligbox.com.br",
"admin_email": "admin@empresa-teste.ligbox.com.br",
"admin_name": "Teste Bundle",
"seats": 25,
"mail_gb_per_seat": 30,
"files_gb_per_seat": 200
}' | python3 -m json.tool
```
## Teste via Desk (após deploy VM122)
Activar conta no Desk → passo `wizard` deve retornar `provisioned: true` (não `skipped`).
## Rollback
```bash
git checkout HEAD~1 -- projects/wizard/backend/app/services/mail_bundle.py
# ou remover router de main.py manualmente
systemctl restart ligbox-wizard
```
## Ficheiros instalados
| Destino VM112 | Origem repo |
|---------------|-------------|
| `/opt/ligbox-wizard/backend/app/services/mail_bundle.py` | `projects/wizard/.../mail_bundle.py` |
| `/opt/ligbox-wizard/backend/app/routers/internal_provision.py` | `projects/wizard/.../internal_provision.py` |
| `main.py` (patch router) | `deploy-mail-bundle-vm112.py` |

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Deploy mail-bundle endpoint na VM112 — executar NO host (Spec 035/043).
Uso (a partir do clone ligbox-ops-platform na VM112 ou CT130 com scp):
REPO=/opt/ligbox-spec-hub/repos/ligbox-ops-platform
python3 $REPO/deploy/vm112-wizard/deploy-mail-bundle-vm112.py
systemctl restart ligbox-wizard
"""
from __future__ import annotations
import ast
import shutil
import sys
from pathlib import Path
REPO = Path(sys.argv[1] if len(sys.argv) > 1 else "/opt/ligbox-spec-hub/repos/ligbox-ops-platform")
WIZARD = Path("/opt/ligbox-wizard")
SRC_SVC = REPO / "projects/wizard/backend/app/services/mail_bundle.py"
SRC_ROUTER = REPO / "projects/wizard/backend/app/routers/internal_provision.py"
DST_SVC = WIZARD / "backend/app/services/mail_bundle.py"
DST_ROUTER = WIZARD / "backend/app/routers/internal_provision.py"
MAIN = WIZARD / "backend/app/main.py"
if not WIZARD.is_dir():
print("ERRO: /opt/ligbox-wizard não encontrado — executar na VM112")
sys.exit(1)
if not SRC_SVC.is_file():
print(f"ERRO: fonte não encontrada: {SRC_SVC}")
sys.exit(1)
shutil.copy2(SRC_SVC, DST_SVC)
shutil.copy2(SRC_ROUTER, DST_ROUTER)
print("copied mail_bundle.py + internal_provision.py")
text = MAIN.read_text(encoding="utf-8")
if "internal_provision" not in text:
old = " telemetry,\n)"
new = " telemetry,\n internal_provision,\n)"
if old in text:
text = text.replace(old, new)
else:
text = text.replace(
"from app.routers import",
"from app.routers import internal_provision,",
1,
)
if "internal_provision.router" not in text:
anchor = 'app.include_router(onboarding.router, prefix="/api")'
if anchor in text:
text = text.replace(
anchor,
anchor + '\napp.include_router(internal_provision.router, prefix="/api")',
)
else:
text = text.rstrip() + '\napp.include_router(internal_provision.router, prefix="/api")\n'
MAIN.write_text(text, encoding="utf-8")
ast.parse(text)
print("main.py patched")
print("OK — reiniciar: systemctl restart ligbox-wizard")

View file

@ -18,7 +18,7 @@
``` ```
deploy/vm112-spec022/ # Carbonio account scripts deploy/vm112-spec022/ # Carbonio account scripts
deploy/vm112-wizard/ # Patches wizard (Spec 037 V4 DNS Viewer) deploy/vm112-wizard/ # Patches wizard (Spec 037 V4 DNS Viewer) · **mail-bundle Spec 043**
deploy/vm112-wizard-security/ # CSP, webhooks, audit deploy/vm112-wizard-security/ # CSP, webhooks, audit
docs/EMAIL_LIGBOX_VM112.md docs/EMAIL_LIGBOX_VM112.md
specs/001-webhook-vm112-integration/ specs/001-webhook-vm112-integration/
@ -49,6 +49,27 @@ deploy/vm112-wizard/frontend-dns-viewer-v4.patch.py
Env obrigatório: `DESK_API_URL`, `OPS_INTERNAL_TOKEN` (igual Desk VM122). Env obrigatório: `DESK_API_URL`, `OPS_INTERNAL_TOKEN` (igual Desk VM122).
### Mail-bundle Spec 043 ✅ (deploy 2026-07-01)
Endpoint interno para Desk activar domínio Carbonio + conta admin após FOSS order:
```
POST /api/internal/provision/mail-bundle
GET /api/internal/provision/mail-bundle/{domain}
```
Runbook:
```
deploy/vm112-wizard/MAIL-BUNDLE-DEPLOY.md
deploy/vm112-wizard/deploy-mail-bundle-vm112.py
specs/043-desk-client-activation-sync/contracts/mail-bundle-api.md
```
Auth: header `X-Ops-Internal-Token` (= `OPS_INTERNAL_TOKEN` Desk VM122, em `/opt/ligbox-deploy/env/.env`).
**Teste validado:** domínio `bundle-novo-spec043.ligbox.com.br` → HTTP 200 `provisioned: true`.
## Integração ## Integração
- **→ VM122:** webhooks `onboarding.*` · Assist/takeover API - **→ VM122:** webhooks `onboarding.*` · Assist/takeover API

View file

@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Spec 035/043 — Cria plano + produto FOSS ligbox-mail-business (VM123)
set -euo pipefail
FOSS_URL="${FOSS_URL:-https://financeiro.ligbox.com.br}"
ADMIN_EMAIL="${FOSS_ADMIN_EMAIL:-admin@ligbox.com.br}"
ADMIN_PASS="${FOSS_ADMIN_PASS:-LbFossAdmin805353}"
PLAN_NAME="${PLAN_NAME:-ligbox-mail-business}"
PRODUCT_SLUG="${PRODUCT_SLUG:-ligbox-mail-business}"
PRODUCT_TITLE="${PRODUCT_TITLE:-Ligbox Mail Business}"
PRICE="${PRICE:-549.00}"
COOKIE_JAR="$(mktemp)"
trap 'rm -f "$COOKIE_JAR"' EXIT
echo "=== FOSS: criar ${PRODUCT_SLUG} ==="
curl -sk -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST "${FOSS_URL}/api/guest/staff/login" \
-d "email=${ADMIN_EMAIL}&password=${ADMIN_PASS}" | grep -q '"role":"admin"' || { echo "Login falhou"; exit 1; }
curl -sk -c "$COOKIE_JAR" -b "$COOKIE_JAR" "${FOSS_URL}/admin" >/dev/null
CSRF=$(awk '$6=="csrf_token" {print $7}' "$COOKIE_JAR" | tail -1)
# Hosting plan OpenPanel
PLANS=$(curl -sk -b "$COOKIE_JAR" "${FOSS_URL}/api/admin/servicehosting/hp_get_list?CSRFToken=${CSRF}")
if ! echo "$PLANS" | grep -q "${PLAN_NAME}"; then
curl -sk -b "$COOKIE_JAR" -X POST "${FOSS_URL}/api/admin/servicehosting/hp_create" \
-d "CSRFToken=${CSRF}&name=${PLAN_NAME}" >/dev/null
echo "Plano hosting ${PLAN_NAME} criado"
else
echo "Plano hosting ${PLAN_NAME} já existe"
fi
HP_ID=$(echo "$PLANS" | python3 -c "
import sys,json,re
d=json.load(sys.stdin)
items=d.get('result',{}).get('list',[]) if isinstance(d.get('result'),dict) else d.get('list',[])
name='${PLAN_NAME}'
for i in items:
if i.get('name')==name:
print(i.get('id')); break
" 2>/dev/null || true)
if [[ -z "$HP_ID" ]]; then
PLANS2=$(curl -sk -b "$COOKIE_JAR" "${FOSS_URL}/api/admin/servicehosting/hp_get_list?CSRFToken=${CSRF}")
HP_ID=$(echo "$PLANS2" | python3 -c "
import sys,json
d=json.load(sys.stdin)
items=d.get('result',{}).get('list',[]) if isinstance(d.get('result'),dict) else d.get('list',[])
for i in items:
if i.get('name')=='${PLAN_NAME}':
print(i.get('id')); break
")
fi
echo "hp_id=${HP_ID}"
SERVER_ID=$(curl -sk -b "$COOKIE_JAR" "${FOSS_URL}/api/admin/servicehosting/server_get_list?CSRFToken=${CSRF}" | python3 -c "
import sys,json
d=json.load(sys.stdin)
items=d.get('result',{}).get('list',[]) if isinstance(d.get('result'),dict) else d.get('list',[])
print(items[0]['id'] if items else '1')
")
echo "server_id=${SERVER_ID}"
LIST=$(curl -sk -b "$COOKIE_JAR" -X POST "${FOSS_URL}/api/admin/product/get_list" \
-d "CSRFToken=${CSRF}&per_page=100")
if echo "$LIST" | grep -q "${PRODUCT_SLUG}"; then
PID=$(echo "$LIST" | python3 -c "
import sys,json
d=json.load(sys.stdin)
items=d.get('result',{}).get('list',[]) if isinstance(d.get('result'),dict) else d.get('list',[])
for i in items:
if i.get('slug')=='${PRODUCT_SLUG}':
print(i.get('id')); break
")
echo "Produto ${PRODUCT_SLUG} já existe id=${PID}"
exit 0
fi
PID=$(curl -sk -b "$COOKIE_JAR" -X POST "${FOSS_URL}/api/admin/product/prepare" \
-d "CSRFToken=${CSRF}&title=${PRODUCT_TITLE// /+}&type=hosting" | python3 -c "import sys,json; print(json.load(sys.stdin)['result'])")
curl -sk -b "$COOKIE_JAR" -X POST "${FOSS_URL}/api/admin/product/update_config" \
--data-urlencode "CSRFToken=${CSRF}" --data-urlencode "id=${PID}" \
--data-urlencode "config[server_id]=${SERVER_ID}" \
--data-urlencode "config[hosting_plan_id]=${HP_ID}" \
--data-urlencode "config[reseller]=0" \
--data-urlencode "config[allow_domain_own]=1" \
--data-urlencode "config[allow_domain_register]=0" \
--data-urlencode "config[allow_domain_transfer]=0" \
--data-urlencode "config[allow_subdomain]=0" >/dev/null
curl -sk -b "$COOKIE_JAR" -X POST "${FOSS_URL}/api/admin/product/update" \
-d "CSRFToken=${CSRF}&id=${PID}&status=enabled&slug=${PRODUCT_SLUG}" >/dev/null
echo "Produto criado: id=${PID} slug=${PRODUCT_SLUG} plan=${PLAN_NAME} price=R\$${PRICE}"
echo "Actualizar foss-products.md com product_id=${PID}"

View file

@ -0,0 +1,75 @@
"""Rotas internas VM112 — Desk/FOSS (Spec 035/043). Auth: X-Ops-Internal-Token."""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from app.services import activity_log, mail_bundle
from app.services.carbonio import CarbonioError
from app.services.domain_format import invalid_domain_http_detail, normalize_domain, validate_primary_domain
router = APIRouter(prefix="/internal/provision", tags=["internal-provision"])
OPS_INTERNAL_TOKEN = os.getenv("OPS_INTERNAL_TOKEN", "")
def _require_internal(x_ops_internal_token: str | None = Header(default=None, alias="X-Ops-Internal-Token")):
if not OPS_INTERNAL_TOKEN:
raise HTTPException(503, "OPS_INTERNAL_TOKEN não configurado no wizard")
if x_ops_internal_token != OPS_INTERNAL_TOKEN:
raise HTTPException(401, "token interno inválido")
return True
class MailBundleRequest(BaseModel):
domain: str = Field(..., min_length=3, max_length=253)
admin_email: str = Field(..., min_length=5)
admin_name: str | None = Field(None, max_length=120)
seats: int = Field(25, ge=1, le=500)
mail_gb_per_seat: int = Field(30, ge=1, le=500)
files_gb_per_seat: int = Field(200, ge=1, le=2000)
foss_order_id: str | int | None = None
@router.post("/mail-bundle", dependencies=[Depends(_require_internal)])
def post_mail_bundle(body: MailBundleRequest):
"""
Provisiona domínio + conta gerente Carbonio com quotas do bundle mail.
Chamado pelo Desk (Spec 043) após FOSS/OpenPanel/Odoo.
"""
domain = normalize_domain(body.domain)
try:
validate_primary_domain(domain)
except ValueError as e:
raise HTTPException(400, detail=invalid_domain_http_detail()) from e
try:
result = mail_bundle.provision_mail_bundle(
domain=domain,
admin_email=body.admin_email.strip().lower(),
admin_name=body.admin_name,
seats=body.seats,
mail_gb_per_seat=body.mail_gb_per_seat,
files_gb_per_seat=body.files_gb_per_seat,
foss_order_id=body.foss_order_id,
)
safe = {k: v for k, v in result.items() if k not in ("password",)}
return {"ok": True, **safe}
except ValueError as e:
raise HTTPException(400, str(e)) from e
except CarbonioError as e:
activity_log.error(f"mail-bundle falhou: {e}", source="internal")
raise HTTPException(502, str(e)) from e
@router.get("/mail-bundle/{domain}", dependencies=[Depends(_require_internal)])
def get_mail_bundle(domain: str):
"""Estado do bundle (sem senhas)."""
domain = normalize_domain(domain)
data = mail_bundle.load_bundle(domain)
if not data:
raise HTTPException(404, "bundle não provisionado")
return {"ok": True, **{k: v for k, v in data.items() if k != "password"}}

View file

@ -0,0 +1,146 @@
"""
Spec 035/043 Provisionamento mail bundle pós-activação Desk/FOSS.
Garante domínio Carbonio, conta gerente (admin@domínio), quotas e metadata no registry.
Deploy: /opt/ligbox-wizard/backend/app/services/mail_bundle.py
"""
from __future__ import annotations
import json
import secrets
import string
from datetime import datetime, timezone
from typing import Any
from app.services import activity_log, carbonio, domain_registry
from app.services.carbonio import CarbonioError
_BUNDLE_VAULT = __import__("pathlib").Path("/var/lib/ligbox-wizard/mail_bundles")
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _generate_password(length: int = 16) -> str:
alphabet = string.ascii_letters + string.digits + "!@#$%&*"
while True:
pwd = "".join(secrets.choice(alphabet) for _ in range(length))
if any(c.islower() for c in pwd) and any(c.isupper() for c in pwd) and any(c.isdigit() for c in pwd):
return pwd
def _bundle_path(domain: str) -> __import__("pathlib").Path:
safe = domain.lower().strip().replace("/", "_")
return _BUNDLE_VAULT / f"{safe}.json"
def load_bundle(domain: str) -> dict[str, Any] | None:
p = _bundle_path(domain)
if not p.is_file():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
def save_bundle(domain: str, data: dict[str, Any]) -> None:
_BUNDLE_VAULT.mkdir(parents=True, exist_ok=True)
_bundle_path(domain).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def provision_mail_bundle(
*,
domain: str,
admin_email: str,
admin_name: str | None = None,
seats: int = 25,
mail_gb_per_seat: int = 30,
files_gb_per_seat: int = 200,
foss_order_id: str | int | None = None,
admin_password: str | None = None,
) -> dict[str, Any]:
"""
Idempotente reutiliza domínio/conta existentes; actualiza quotas e metadata.
"""
domain = domain.lower().strip().rstrip(".")
email = admin_email.lower().strip()
if not email.endswith(f"@{domain}"):
raise ValueError(f"admin_email deve ser @{domain}")
existing = load_bundle(domain)
if existing and existing.get("provisioned"):
return {
**existing,
"already_provisioned": True,
"mail_host": existing.get("mail_host") or f"mail.{domain}",
}
quota_mb = max(1, int(mail_gb_per_seat)) * 1024
password = admin_password or _generate_password()
display = admin_name or email.split("@")[0]
activity_log.info(f"mail-bundle: domínio {domain} seats={seats}", source="internal")
try:
if not carbonio.domain_exists(domain):
try:
carbonio.create_domain(domain)
except CarbonioError as exc:
if "DOMAIN_EXISTS" not in str(exc):
raise
carbonio.set_domain_public_hostname(domain)
else:
carbonio.set_domain_public_hostname(domain)
_msg, reused_account = carbonio.ensure_onboarding_account(
email, password, display_name=display
)
if hasattr(carbonio, "set_mail_quota"):
carbonio.set_mail_quota(email, quota_mb)
except CarbonioError as exc:
activity_log.error(f"mail-bundle Carbonio: {exc}", source="internal")
raise
mail_host = f"mail.{domain}"
bundle = {
"domain": domain,
"admin_email": email,
"admin_name": display,
"seats": int(seats),
"mail_gb_per_seat": int(mail_gb_per_seat),
"files_gb_per_seat": int(files_gb_per_seat),
"foss_order_id": foss_order_id,
"mail_host": mail_host,
"webmail_url": f"https://{mail_host}/",
"files_url": f"https://files.{domain}/",
"provisioned": True,
"provisioned_at": _now(),
"account_reused": reused_account,
}
save_bundle(domain, bundle)
rec = domain_registry.get_domain_record(domain) or {}
rec.update(
{
"domain": domain,
"mail_bundle": {
"seats": bundle["seats"],
"mail_gb_per_seat": bundle["mail_gb_per_seat"],
"files_gb_per_seat": bundle["files_gb_per_seat"],
"foss_order_id": foss_order_id,
"provisioned_at": bundle["provisioned_at"],
},
"manager_email": email,
"manager_name": display,
}
)
domain_registry.save_domain_record(domain, rec)
activity_log.ok(f"mail-bundle OK {domain}{mail_host}", source="internal")
return {
**bundle,
"already_provisioned": False,
"password_generated": not bool(admin_password),
}

View file

@ -136,9 +136,12 @@ bash /opt/ligbox-ops-platform/projects/finance/deploy/vm123-finance-stack/test-f
## IDs reais (preencher pós-criação) ## IDs reais (preencher pós-criação)
| Produto | FOSS product_id | OP plan_id | | Produto | FOSS product_id | OP plan (hp) | Slug |
|---------|-----------------|------------| |---------|-----------------|--------------|------|
| Starter | _TBD_ | _TBD_ | | Starter | _TBD_ | _TBD_ | ligbox-mail-starter |
| Business | _TBD_ | _TBD_ | | **Business** | **3** | **2** | **ligbox-mail-business** |
| Enterprise | _TBD_ | _TBD_ | | Enterprise | _TBD_ | _TBD_ | ligbox-mail-enterprise |
| Custom | _TBD_ | _TBD_ | | Custom | _TBD_ | _TBD_ | ligbox-mail-custom |
| Site CMS | 2 | 1 | ligbox-site-cms-hosting |
**Criado:** 2026-07-01 · VM123 · `deploy/vm123-finance-stack/create-foss-mail-business.sh`

View file

@ -16,7 +16,8 @@
## Fase 1 — Provisionamento ## Fase 1 — Provisionamento
- [ ] Tabela `bundle_entitlements` no wizard VM112 - [ ] Tabela `bundle_entitlements` no wizard VM112
- [ ] Endpoint `POST /api/internal/provision/mail-bundle` - [ ] Endpoint `POST /api/internal/provision/mail-bundle`**código pronto** · [mail-bundle-api.md](../043-desk-client-activation-sync/contracts/mail-bundle-api.md) · deploy: [MAIL-BUNDLE-DEPLOY.md](../../deploy/vm112-wizard/MAIL-BUNDLE-DEPLOY.md)
- [x] Produto FOSS `ligbox-mail-business` (id=3) — ver [foss-products.md](./foss-products.md)
- [ ] Webhook Desk `foss/order-activated` → wizard - [ ] Webhook Desk `foss/order-activated` → wizard
- [ ] Bridge: metadata JSON no user OpenPanel hub - [ ] Bridge: metadata JSON no user OpenPanel hub
- [ ] Email template FOSS boas-vindas (links OP + Domain Admin) - [ ] Email template FOSS boas-vindas (links OP + Domain Admin)

View file

@ -19,7 +19,7 @@
| G | Desk → Odoo | `res.partner` create/update | Odoo DB `ligbox` | | G | Desk → Odoo | `res.partner` create/update | Odoo DB `ligbox` |
| H | Desk → Wizard | `POST /api/internal/provision/mail-bundle` | Carbonio VM112 | | H | Desk → Wizard | `POST /api/internal/provision/mail-bundle` | Carbonio VM112 |
**Specs de origem:** [023](../../023-billing-recurrence-desk-visibility/spec.md) · [024 PROVISIONING_CLIENT_CARD](../../024-openpanel-fossbilling/PROVISIONING_CLIENT_CARD.md) · [035](../../035-ligbox-mail-bundles-foss-openpanel/spec.md) · [028 bridge](../../028-openpanel-ce-ligbox-reengineering/contracts/foss-bridge-api.md) **Specs de origem:** [023](../../023-billing-recurrence-desk-visibility/spec.md) · [024 PROVISIONING_CLIENT_CARD](../../024-openpanel-fossbilling/PROVISIONING_CLIENT_CARD.md) · [035](../../035-ligbox-mail-bundles-foss-openpanel/spec.md) · [028 bridge](../../028-openpanel-ce-ligbox-reengineering/contracts/foss-bridge-api.md) · **[mail-bundle-api](./mail-bundle-api.md)**
--- ---
@ -329,6 +329,7 @@ POST http://10.10.10.112/api/internal/provision/mail-bundle
| Versão | Data | Autor | Alteração | | Versão | Data | Autor | Alteração |
|--------|------|-------|-----------| |--------|------|-------|-----------|
| 1.0 | 2026-07-01 | Roger / Cursor | Documento inicial Spec 043 | | 1.0 | 2026-07-01 | Roger / Cursor | Documento inicial Spec 043 |
| 1.1 | 2026-07-01 | Roger / Cursor | FOSS product_id=3 · mail-bundle-api |
--- ---

View file

@ -0,0 +1,101 @@
# Contrato — API Wizard `POST /api/internal/provision/mail-bundle`
**Spec:** [043](../spec.md) · [035](../../035-ligbox-mail-bundles-foss-openpanel/spec.md)
**VM:** 112 (`10.10.10.112:8090`)
**Status:** ✅ Implementado no repo · deploy VM112 pendente
**Auth:** `X-Ops-Internal-Token` (mesmo valor Desk `OPS_INTERNAL_TOKEN`)
---
## POST `/api/internal/provision/mail-bundle`
Provisiona domínio Carbonio + conta gerente com quotas do bundle mail.
### Request
```json
{
"domain": "empresa.com.br",
"admin_email": "admin@empresa.com.br",
"admin_name": "João Silva",
"seats": 25,
"mail_gb_per_seat": 30,
"files_gb_per_seat": 200,
"foss_order_id": 55
}
```
| Campo | Obrigatório | Descrição |
|-------|-------------|-----------|
| `domain` | sim | Domínio cliente |
| `admin_email` | sim | Conta gerente (`admin@{domain}`) |
| `admin_name` | não | Display name Carbonio |
| `seats` | não (default 25) | Licenças mail bundle |
| `mail_gb_per_seat` | não (default 30) | Quota GB/caixa gerente |
| `files_gb_per_seat` | não (default 200) | Nextcloud (fase 034) |
| `foss_order_id` | não | Correlação FOSS order |
### Response 200
```json
{
"ok": true,
"domain": "empresa.com.br",
"admin_email": "admin@empresa.com.br",
"mail_host": "mail.empresa.com.br",
"webmail_url": "https://mail.empresa.com.br/",
"files_url": "https://files.empresa.com.br/",
"seats": 25,
"provisioned": true,
"already_provisioned": false,
"account_reused": false
}
```
### Erros
| HTTP | Condição |
|------|----------|
| 401 | Token interno inválido |
| 400 | Domínio/email inválido |
| 502 | Carbonio/zmprov falhou |
| 503 | `OPS_INTERNAL_TOKEN` ausente no wizard |
---
## GET `/api/internal/provision/mail-bundle/{domain}`
Estado do bundle (sem senhas).
---
## Código
| Artefacto | Path repo |
|-----------|-----------|
| Serviço | `projects/wizard/backend/app/services/mail_bundle.py` |
| Router | `projects/wizard/backend/app/routers/internal_provision.py` |
| Deploy VM112 | `deploy/vm112-wizard/deploy-mail-bundle-vm112.py` |
| Cliente Desk | `projects/ops-desk/api/app/vm123/wizard_client.py` |
---
## Deploy VM112
```bash
# Na VM112 (SSH)
cd /opt/ligbox-spec-hub/repos/ligbox-ops-platform # ou clone Forgejo
git pull
python3 deploy/vm112-wizard/deploy-mail-bundle-vm112.py
systemctl restart ligbox-wizard
# Teste
curl -s -X POST http://127.0.0.1:8090/api/internal/provision/mail-bundle \
-H "X-Ops-Internal-Token: $OPS_INTERNAL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"domain":"teste.ligbox.com.br","admin_email":"admin@teste.ligbox.com.br","seats":25}'
```
---
*Roger · 2026-07-01 · Spec 043 fecho ciclo mail-bundle*

View file

@ -72,7 +72,8 @@ O Desk (VM122) é o **orquestrador**: recebe `company.validated`, persiste `bill
| Artefacto | Path | | Artefacto | Path |
|-----------|------| |-----------|------|
| **Mapa de campos** | [contracts/activation-field-mapping.md](./contracts/activation-field-mapping.md) | | **Mapa de campos** | [contracts/activation-field-mapping.md](./contracts/activation-field-mapping.md) |
| **API Desk (planeada)** | [contracts/desk-activate-account-api.md](./contracts/desk-activate-account-api.md) | | **API Desk (activate)** | [contracts/desk-activate-account-api.md](./contracts/desk-activate-account-api.md) |
| **API Wizard mail-bundle** | [contracts/mail-bundle-api.md](./contracts/mail-bundle-api.md) |
| **Tasks implementação** | [tasks.md](./tasks.md) | | **Tasks implementação** | [tasks.md](./tasks.md) |
--- ---

View file

@ -31,9 +31,17 @@
- [x] Deep-link Odoo na ficha cliente - [x] Deep-link Odoo na ficha cliente
## Fase 5 — Wizard mail-bundle ## Fase 5 — Wizard mail-bundle
- [x] Contrato `contracts/mail-bundle-api.md`
- [x] Código `mail_bundle.py` + `internal_provision.py` (repo)
- [x] `POST /billing/accounts/{id}/activate` com `provision_mail` - [x] `POST /billing/accounts/{id}/activate` com `provision_mail`
- [x] Webhook `POST /billing/webhook/foss/order-activated` - [x] Webhook `POST /billing/webhook/foss/order-activated`
- [x] `wizard_client.provision_mail_bundle()` → VM112 - [x] `wizard_client.provision_mail_bundle()` → VM112
- [x] **Deploy VM112** — 2026-07-01 · `OPS_INTERNAL_TOKEN` + endpoint activo
## FOSS — produto ligbox-mail-business (VM123)
- [x] Plano hosting hp_id=2
- [x] Produto FOSS product_id=3 slug `ligbox-mail-business`
- [x] Script `deploy/vm123-finance-stack/create-foss-mail-business.sh`
## Fase 6 — Testes ## Fase 6 — Testes
- [x] Unit: `test_activation_mapper_043.py` (3) - [x] Unit: `test_activation_mapper_043.py` (3)