Obsidian: Spec 043 mail-bundle + FOSS ligbox-mail-business
Sync contratos, runbooks VM112/VM123, código wizard e VM112.md com deploy validado. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
91c9072942
commit
e5564c15b9
13 changed files with 867 additions and 2 deletions
|
|
@ -211,3 +211,4 @@
|
|||
- **contracts/**
|
||||
- [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)
|
||||
- [mail-bundle-api.md](specs/043-desk-client-activation-sync/contracts/mail-bundle-api.md)
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
@ -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")
|
||||
94
ligbox-ops-platform/deploy/vm123-finance-stack/create-foss-mail-business.sh
Executable file
94
ligbox-ops-platform/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}"
|
||||
78
ligbox-ops-platform/docs/vms/VM112.md
Normal file
78
ligbox-ops-platform/docs/vms/VM112.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# VM112 — Wizard Onboard + Carbonio Mail
|
||||
|
||||
| Item | Valor |
|
||||
|------|-------|
|
||||
| **IP LAN** | `10.10.10.112` |
|
||||
| **SSH WAN** | `95.216.14.146:2512` |
|
||||
| **Hostname** | vm112-mail-ibytera |
|
||||
| **URLs** | `onboard.ligbox.com.br` · API `:8090` |
|
||||
|
||||
## Papel
|
||||
|
||||
- Wizard onboarding clientes
|
||||
- Carbonio mail tenants
|
||||
- Webhooks → VM122 Desk
|
||||
- Purge domínio / orquestração DNS (Spec 017, 026)
|
||||
|
||||
## No repo Git (CT130)
|
||||
|
||||
```
|
||||
deploy/vm112-spec022/ # Carbonio account scripts
|
||||
deploy/vm112-wizard/ # Patches wizard (Spec 037 V4 DNS Viewer) · **mail-bundle Spec 043**
|
||||
deploy/vm112-wizard-security/ # CSP, webhooks, audit
|
||||
docs/EMAIL_LIGBOX_VM112.md
|
||||
specs/001-webhook-vm112-integration/
|
||||
specs/017-vm112-domain-orchestration/
|
||||
specs/022-carbonio-account-exists-release/
|
||||
specs/025-wizard-onboarding-continuity/
|
||||
specs/026-purge-traefik-validation/
|
||||
specs/034-nextcloud-carbonio-vm112-integration/
|
||||
specs/037-dns-multi-cloudflare-orchestration/ # DNS Viewer V4 wizard
|
||||
```
|
||||
|
||||
## Deploy na VM112
|
||||
|
||||
```bash
|
||||
git clone https://git.spec.ligbox.com.br/ligbox/ligbox-ops-platform.git
|
||||
# Copiar deploy/vm112-* para paths locais — ver README em cada pasta
|
||||
```
|
||||
|
||||
### DNS Viewer V4 (Spec 037 — pendente)
|
||||
|
||||
Painel read-only no passo DNS — **sem página nova**. Runbook:
|
||||
|
||||
```
|
||||
deploy/vm112-wizard/DNS-VIEWER-V4.md
|
||||
deploy/vm112-wizard/onboarding-dns-viewer-v4.patch.py
|
||||
deploy/vm112-wizard/frontend-dns-viewer-v4.patch.py
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
- **→ VM122:** webhooks `onboarding.*` · Assist/takeover API
|
||||
- **→ CT114:** Traefik routers mail/onboard · `files.{dom}` (Spec 034)
|
||||
- **→ VM124:** IMAP/SMTP (Nextcloud Mail app) · OCS provisioning (Spec 034)
|
||||
- **← Desk:** purge, DNS revalidate, assist actions
|
||||
|
|
@ -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"}}
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
# Spec 035 — FOSSBilling Products (template)
|
||||
|
||||
**Actualizar após criação manual no Admin FOSS** com IDs reais.
|
||||
|
||||
Base URL Admin: `https://financeiro.ligbox.com.br/admin`
|
||||
|
||||
---
|
||||
|
||||
## Hosting server
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| Nome | VM123 OpenPanel |
|
||||
| Manager | OpenPanel |
|
||||
| Hostname | `10.10.10.123:18087` |
|
||||
| ID | 1 (existente) |
|
||||
|
||||
---
|
||||
|
||||
## Produtos a criar
|
||||
|
||||
### ligbox-mail-starter
|
||||
|
||||
```yaml
|
||||
title: Ligbox Mail Starter
|
||||
slug: ligbox-mail-starter
|
||||
type: hosting
|
||||
category: Ligbox Mail
|
||||
pricing:
|
||||
type: recurrent
|
||||
recurrent: monthly
|
||||
price: 249.00
|
||||
setup: 0
|
||||
plugin: OpenPanel
|
||||
plugin_config:
|
||||
plan: ligbox-mail-starter
|
||||
description: |
|
||||
Até 10 contas email · 20 GB/caixa · 100 GB Nextcloud · EasyDMARC · subdomínio
|
||||
custom_fields:
|
||||
- name: domain
|
||||
required: true
|
||||
- name: manager_email
|
||||
required: true
|
||||
```
|
||||
|
||||
### ligbox-mail-business
|
||||
|
||||
```yaml
|
||||
title: Ligbox Mail Business
|
||||
slug: ligbox-mail-business
|
||||
pricing:
|
||||
price: 549.00
|
||||
plugin_config:
|
||||
plan: ligbox-mail-business
|
||||
description: |
|
||||
Até 25 contas · 30 GB/caixa · 200 GB Nextcloud · EasyDMARC · subdomínio
|
||||
```
|
||||
|
||||
### ligbox-mail-enterprise
|
||||
|
||||
```yaml
|
||||
title: Ligbox Mail Enterprise
|
||||
slug: ligbox-mail-enterprise
|
||||
pricing:
|
||||
price: 999.00
|
||||
plugin_config:
|
||||
plan: ligbox-mail-enterprise
|
||||
description: |
|
||||
Até 50 contas · 50 GB/caixa · 300 GB Nextcloud · EasyDMARC · subdomínio
|
||||
```
|
||||
|
||||
### ligbox-mail-custom
|
||||
|
||||
```yaml
|
||||
title: Ligbox Mail Personalizado
|
||||
slug: ligbox-mail-custom
|
||||
pricing:
|
||||
type: recurrent
|
||||
price: 99.00 # base fee
|
||||
config_options:
|
||||
- id: seats
|
||||
name: Número de contas email
|
||||
type: select
|
||||
options:
|
||||
- value: 10
|
||||
price: 0
|
||||
- value: 25
|
||||
price: 50
|
||||
- value: 30
|
||||
price: 80
|
||||
- value: 40
|
||||
price: 120
|
||||
- value: 50
|
||||
price: 180
|
||||
- id: mail_gb
|
||||
name: GB email por conta
|
||||
type: select
|
||||
options:
|
||||
- { value: 20, price: 0 }
|
||||
- { value: 30, price: 15 }
|
||||
- { value: 40, price: 30 }
|
||||
- { value: 50, price: 45 }
|
||||
- id: files_gb
|
||||
name: GB Nextcloud por conta
|
||||
type: select
|
||||
options:
|
||||
- { value: 100, price: 0 }
|
||||
- { value: 200, price: 25 }
|
||||
- { value: 300, price: 50 }
|
||||
- { value: 400, price: 75 }
|
||||
- { value: 500, price: 100 }
|
||||
plugin_config:
|
||||
plan: ligbox-mail-custom
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## OpenPanel plans (OpenAdmin)
|
||||
|
||||
| plan_name | max_domains | notes |
|
||||
|-----------|-------------|-------|
|
||||
| ligbox-mail-starter | 1 | hub gerente |
|
||||
| ligbox-mail-business | 1 | hub gerente |
|
||||
| ligbox-mail-enterprise | 1 | hub gerente |
|
||||
| ligbox-mail-custom | 1 | metadata JSON quotas |
|
||||
|
||||
Script provision:
|
||||
|
||||
```bash
|
||||
ssh root@10.10.10.123
|
||||
# Após criar plans no OpenAdmin, testar:
|
||||
bash /opt/ligbox-ops-platform/projects/finance/deploy/vm123-finance-stack/test-foss-openpanel-order.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IDs reais (preencher pós-criação)
|
||||
|
||||
| Produto | FOSS product_id | OP plan (hp) | Slug |
|
||||
|---------|-----------------|--------------|------|
|
||||
| Starter | _TBD_ | _TBD_ | ligbox-mail-starter |
|
||||
| **Business** | **3** | **2** | **ligbox-mail-business** |
|
||||
| Enterprise | _TBD_ | _TBD_ | ligbox-mail-enterprise |
|
||||
| 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`
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# Spec 035 — Tasks
|
||||
|
||||
**Status:** Draft · **Início:** 2026-06-21
|
||||
|
||||
---
|
||||
|
||||
## Fase 0 — Catálogo comercial (FOSS + OpenPanel)
|
||||
|
||||
- [ ] Roger aprovar preços §1.1 — **via Admin Ligbox** (035-C), não FOSS directo
|
||||
- [ ] Criar produtos FOSS: starter, business, enterprise, custom
|
||||
- [ ] Criar planos OpenPanel espelhados (ligbox-mail-*)
|
||||
- [ ] Config options FOSS para bundle custom (seats, mail_gb, files_gb)
|
||||
- [ ] Publicar página vendas /signup com comparativo concorrência
|
||||
- [ ] Documentar em `foss-products.md` IDs reais pós-criação
|
||||
|
||||
## Fase 1 — Provisionamento
|
||||
|
||||
- [ ] Tabela `bundle_entitlements` no wizard VM112
|
||||
- [ ] 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
|
||||
- [ ] Bridge: metadata JSON no user OpenPanel hub
|
||||
- [ ] Email template FOSS boas-vindas (links OP + Domain Admin)
|
||||
- [ ] Teste E2E: order Business → admin@ + 1 conta teste
|
||||
|
||||
## Fase 2 — Ligbox Console `/admin` gerente (PRIORIDADE UX — Roger 2026-06-21)
|
||||
|
||||
Shell: [ligbox-console-shell.md](./ligbox-console-shell.md) · Detalhe: [domain-manager-console-ui.md](./domain-manager-console-ui.md)
|
||||
|
||||
- [ ] UX-0: design tokens + AppLayout + login role → `console.ligbox.com.br`
|
||||
- [ ] Traefik: `console.ligbox.com.br` VM123 + 301 onboard/admin
|
||||
- [ ] UX-A1: `/admin` Início — cards resumo (tom banco digital BR)
|
||||
- [ ] UX-A2: `/admin/contas` CRUD inline
|
||||
- [ ] UX-A3: `/admin/files` quotas + toggle Mail NC
|
||||
- [ ] UX-A4: `/admin/certificacao` + `/admin/dominio` **DNS Viewer** (037 dns-viewer.md)
|
||||
- [x] `/admin/dominio` V3 deploy VM123 2026-06-25 (`AdminDominio.jsx`, `DnsViewerPanel.jsx`)
|
||||
- [ ] `/admin/certificacao` EasyDMARC card
|
||||
- [ ] UX-A4b: `/admin/plano` boleto/PIX
|
||||
- [ ] SSO FOSS → `/admin?sso=TOKEN`
|
||||
- [ ] API Desk `/api/v1/domain-console/*`
|
||||
- [x] `GET /api/v1/domain-console/dns/viewer/{domain}` (VM122 2026-06-25)
|
||||
|
||||
## Fase 2b — OpenPanel (backend only)
|
||||
|
||||
- [ ] Bridge metadata JSON no user hub (sem UI gerente)
|
||||
|
||||
## Fase C — Admin Ligbox staff (`/comercial` + `/ops`) — depois UX-A
|
||||
|
||||
Ver: [ligbox-system-admin-ui.md](./ligbox-system-admin-ui.md) · Shell: [ligbox-console-shell.md](./ligbox-console-shell.md)
|
||||
|
||||
- [ ] UX-B: `/comercial` — fila preços, catálogo, clientes, gateway
|
||||
- [ ] UX-C: `/ops` — Spec 019 chamados no mesmo shell
|
||||
- [ ] RBAC roles comercial/ops (Spec 027)
|
||||
- [ ] Impersonate gerente → `/admin`
|
||||
|
||||
## Fase 3 — Self-service utilizador email (Fase B — depois)
|
||||
|
||||
Ver placeholder: [user-self-service-ui.md](./user-self-service-ui.md)
|
||||
|
||||
- [ ] Estudo redirects, OOO, assinaturas, calendário por user
|
||||
- [ ] Wireframe `/me` ou `mail.{dom}/settings`
|
||||
- [ ] Decidir Carbonio nativo vs SPA Ligbox
|
||||
|
||||
## Fase 3 — Nextcloud + EasyDMARC
|
||||
|
||||
- [ ] VM116 provisionada (Spec 034 Fase 0)
|
||||
- [ ] Auto-provision NC Files em cada createAccount
|
||||
- [ ] Toggle Mail Nextcloud por domínio no `/admin` (Spec 035 §2.2.1)
|
||||
- [ ] EasyDMARC register domain automático no wizard
|
||||
- [ ] Agent A3 Desk: alertas DMARC fail
|
||||
|
||||
## Fase 4 — Gateway pagamento + comercial avançado
|
||||
|
||||
- [ ] Escolher gateway: ASAAS vs Iugu (Roger)
|
||||
- [ ] Módulo FOSS: boleto bancário (PDF + linha digitável)
|
||||
- [ ] Módulo FOSS: PIX QR Code + copia-e-cola
|
||||
- [ ] Webhook pagamento confirmado → Desk → wizard provision
|
||||
- [ ] Card «Pagamento» no `/admin` — status + link boleto/PIX (embed FOSS API)
|
||||
- [ ] Upgrade/downgrade plano FOSS → update entitlements
|
||||
- [ ] Add-ons: seat extra, storage mail/files
|
||||
- [ ] Odoo subscription mirror (Spec 023 phase 2)
|
||||
|
||||
## Documentação
|
||||
|
||||
- [x] Spec 035 `spec.md`
|
||||
- [x] `tasks.md`
|
||||
- [x] `foss-products.md` (template)
|
||||
- [x] `mockups/domain-manager-sandbox.html` — UI sandbox (Mock + Live create-only)
|
||||
- [x] API Desk `domain_console_sandbox*.py` — create-only produção
|
||||
- [ ] Deploy API sandbox no VM122 + teste cenário real
|
||||
- [ ] Actualizar Spec 034 § comercial → link 035
|
||||
- [ ] Actualizar `docs/vms/README.md`
|
||||
- [ ] Publicar Spec Hub Portal
|
||||
- [x] Spec 037-DNS-VIEWER `dns-viewer.md` + §5.5 domain-manager actualizado
|
||||
- [x] Implementar `GET /api/v1/dns/viewer/{domain}` (Desk VM122 + proxy Console VM123 2026-06-25)
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
| G | Desk → Odoo | `res.partner` create/update | Odoo DB `ligbox` |
|
||||
| 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 |
|
||||
|--------|------|-------|-----------|
|
||||
| 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 |
|
||||
|-----------|------|
|
||||
| **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) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -31,9 +31,17 @@
|
|||
- [x] Deep-link Odoo na ficha cliente
|
||||
|
||||
## 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] Webhook `POST /billing/webhook/foss/order-activated`
|
||||
- [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
|
||||
- [x] Unit: `test_activation_mapper_043.py` (3)
|
||||
|
|
|
|||
Loading…
Reference in a new issue