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:
Ligbox Obsidian Vault 2026-07-01 16:54:41 +00:00
parent 91c9072942
commit e5564c15b9
13 changed files with 867 additions and 2 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

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

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

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

View file

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

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)