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:
parent
5c554d6758
commit
884321c43b
13 changed files with 580 additions and 10 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
56
deploy/vm112-wizard/MAIL-BUNDLE-DEPLOY.md
Normal file
56
deploy/vm112-wizard/MAIL-BUNDLE-DEPLOY.md
Normal 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` |
|
||||||
62
deploy/vm112-wizard/deploy-mail-bundle-vm112.py
Normal file
62
deploy/vm112-wizard/deploy-mail-bundle-vm112.py
Normal 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")
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
94
projects/finance/deploy/vm123-finance-stack/create-foss-mail-business.sh
Executable file
94
projects/finance/deploy/vm123-finance-stack/create-foss-mail-business.sh
Executable 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}"
|
||||||
75
projects/wizard/backend/app/routers/internal_provision.py
Normal file
75
projects/wizard/backend/app/routers/internal_provision.py
Normal 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"}}
|
||||||
146
projects/wizard/backend/app/services/mail_bundle.py
Normal file
146
projects/wizard/backend/app/services/mail_bundle.py
Normal 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),
|
||||||
|
}
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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*
|
||||||
|
|
@ -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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue