From 884321c43b342f67b9873d0aac8e4ef9afe2f3d3 Mon Sep 17 00:00:00 2001 From: Ligbox Spec Hub Date: Wed, 1 Jul 2026 16:54:40 +0000 Subject: [PATCH] Spec 043/035: FOSS ligbox-mail-business + mail-bundle VM112 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- _sidebar.md | 1 + deploy/vm112-wizard/MAIL-BUNDLE-DEPLOY.md | 56 +++++++ .../vm112-wizard/deploy-mail-bundle-vm112.py | 62 ++++++++ docs/vms/VM112.md | 23 ++- .../create-foss-mail-business.sh | 94 +++++++++++ .../backend/app/routers/internal_provision.py | 75 +++++++++ .../backend/app/services/mail_bundle.py | 146 ++++++++++++++++++ .../foss-products.md | 15 +- .../tasks.md | 3 +- .../contracts/activation-field-mapping.md | 3 +- .../contracts/mail-bundle-api.md | 101 ++++++++++++ specs/043-desk-client-activation-sync/spec.md | 3 +- .../043-desk-client-activation-sync/tasks.md | 8 + 13 files changed, 580 insertions(+), 10 deletions(-) create mode 100644 deploy/vm112-wizard/MAIL-BUNDLE-DEPLOY.md create mode 100644 deploy/vm112-wizard/deploy-mail-bundle-vm112.py create mode 100755 projects/finance/deploy/vm123-finance-stack/create-foss-mail-business.sh create mode 100644 projects/wizard/backend/app/routers/internal_provision.py create mode 100644 projects/wizard/backend/app/services/mail_bundle.py create mode 100644 specs/043-desk-client-activation-sync/contracts/mail-bundle-api.md diff --git a/_sidebar.md b/_sidebar.md index 52e9473..ef5ab38 100644 --- a/_sidebar.md +++ b/_sidebar.md @@ -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) diff --git a/deploy/vm112-wizard/MAIL-BUNDLE-DEPLOY.md b/deploy/vm112-wizard/MAIL-BUNDLE-DEPLOY.md new file mode 100644 index 0000000..12eca35 --- /dev/null +++ b/deploy/vm112-wizard/MAIL-BUNDLE-DEPLOY.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` | diff --git a/deploy/vm112-wizard/deploy-mail-bundle-vm112.py b/deploy/vm112-wizard/deploy-mail-bundle-vm112.py new file mode 100644 index 0000000..5eb112e --- /dev/null +++ b/deploy/vm112-wizard/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") diff --git a/docs/vms/VM112.md b/docs/vms/VM112.md index 0522a41..7e3fdb7 100644 --- a/docs/vms/VM112.md +++ b/docs/vms/VM112.md @@ -18,7 +18,7 @@ ``` 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 docs/EMAIL_LIGBOX_VM112.md 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). +### 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 diff --git a/projects/finance/deploy/vm123-finance-stack/create-foss-mail-business.sh b/projects/finance/deploy/vm123-finance-stack/create-foss-mail-business.sh new file mode 100755 index 0000000..b996e08 --- /dev/null +++ b/projects/finance/deploy/vm123-finance-stack/create-foss-mail-business.sh @@ -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}" diff --git a/projects/wizard/backend/app/routers/internal_provision.py b/projects/wizard/backend/app/routers/internal_provision.py new file mode 100644 index 0000000..97918da --- /dev/null +++ b/projects/wizard/backend/app/routers/internal_provision.py @@ -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"}} diff --git a/projects/wizard/backend/app/services/mail_bundle.py b/projects/wizard/backend/app/services/mail_bundle.py new file mode 100644 index 0000000..f64100e --- /dev/null +++ b/projects/wizard/backend/app/services/mail_bundle.py @@ -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), + } diff --git a/specs/035-ligbox-mail-bundles-foss-openpanel/foss-products.md b/specs/035-ligbox-mail-bundles-foss-openpanel/foss-products.md index bcbc47b..30084a0 100644 --- a/specs/035-ligbox-mail-bundles-foss-openpanel/foss-products.md +++ b/specs/035-ligbox-mail-bundles-foss-openpanel/foss-products.md @@ -136,9 +136,12 @@ bash /opt/ligbox-ops-platform/projects/finance/deploy/vm123-finance-stack/test-f ## IDs reais (preencher pós-criação) -| Produto | FOSS product_id | OP plan_id | -|---------|-----------------|------------| -| Starter | _TBD_ | _TBD_ | -| Business | _TBD_ | _TBD_ | -| Enterprise | _TBD_ | _TBD_ | -| Custom | _TBD_ | _TBD_ | +| 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` diff --git a/specs/035-ligbox-mail-bundles-foss-openpanel/tasks.md b/specs/035-ligbox-mail-bundles-foss-openpanel/tasks.md index 054b351..b99d86f 100644 --- a/specs/035-ligbox-mail-bundles-foss-openpanel/tasks.md +++ b/specs/035-ligbox-mail-bundles-foss-openpanel/tasks.md @@ -16,7 +16,8 @@ ## Fase 1 — Provisionamento - [ ] 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 - [ ] Bridge: metadata JSON no user OpenPanel hub - [ ] Email template FOSS boas-vindas (links OP + Domain Admin) diff --git a/specs/043-desk-client-activation-sync/contracts/activation-field-mapping.md b/specs/043-desk-client-activation-sync/contracts/activation-field-mapping.md index a711465..50513f3 100644 --- a/specs/043-desk-client-activation-sync/contracts/activation-field-mapping.md +++ b/specs/043-desk-client-activation-sync/contracts/activation-field-mapping.md @@ -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 | --- diff --git a/specs/043-desk-client-activation-sync/contracts/mail-bundle-api.md b/specs/043-desk-client-activation-sync/contracts/mail-bundle-api.md new file mode 100644 index 0000000..b6dcd2e --- /dev/null +++ b/specs/043-desk-client-activation-sync/contracts/mail-bundle-api.md @@ -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* diff --git a/specs/043-desk-client-activation-sync/spec.md b/specs/043-desk-client-activation-sync/spec.md index 7e11acc..d891998 100644 --- a/specs/043-desk-client-activation-sync/spec.md +++ b/specs/043-desk-client-activation-sync/spec.md @@ -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) | --- diff --git a/specs/043-desk-client-activation-sync/tasks.md b/specs/043-desk-client-activation-sync/tasks.md index b274b46..28ac07e 100644 --- a/specs/043-desk-client-activation-sync/tasks.md +++ b/specs/043-desk-client-activation-sync/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)