obsidian-vault/ligbox-ops-platform/deploy/vm112-wizard/backend/routers/onboarding.py
Ligbox Obsidian Vault 4248e3694c docs(vm112/vm122): espelho fixes wizard /admin + Desk VM001 + KB 2026-06-19
- deploy/vm112-wizard: main-wizard DomainAdmin route, clientSettings, FinishToolbar
- deploy/vm122-desk: card Ligbox Datacenter Node VM001, audit sync
- Spec 025: secao Passo Concluido CTAs
- KB: Portal de gerenciamento reabria wizard Concluido
2026-06-19 21:52:09 +00:00

777 lines
29 KiB
Python

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from pydantic import BaseModel, EmailStr, Field
from app.config import cloudflare_token_path, settings
from app.deps import bind_onboarding_session, get_session_from_request
from app.services import activity_log, carbonio, dns_verify, domain_registry, notifications, onboard_handoff
from app.services import infrastructure, ops_webhook, support_tickets
from app.services.cloudflare import CloudflareDNS, CloudflareError, is_cloudflare_sandbox, mail_dns_records, wizard_nameservers
from app.services.domain_format import cloudflare_error_http_detail, invalid_domain_http_detail, normalize_domain, validate_primary_domain
from app.services.mail_aliases import sanitize_mail_aliases
router = APIRouter(
prefix="/onboarding",
tags=["onboarding"],
dependencies=[Depends(bind_onboarding_session)],
)
def _cloudflare_token_missing_error() -> str:
return (
"Token Cloudflare não configurado. Na VM112: "
f"{cloudflare_token_path()} (ver secrets/README.txt)"
)
class DomainRequest(BaseModel):
domain: str = Field(..., min_length=3, max_length=253)
class ValidateDomainRequest(BaseModel):
domain: str = Field(..., min_length=3, max_length=253)
mail_aliases: list[str] = Field(default_factory=list, max_length=5)
class CreateAccountRequest(BaseModel):
domain: str
local_part: str = "admin"
password: str | None = Field(None, min_length=8)
use_server_password: bool = False
display_name: str | None = None
notify_email: EmailStr | None = None
send_welcome: bool = True
dns_mode: str | None = None
mail_aliases: list[str] = Field(default_factory=list, max_length=5)
class CloudflareApplyRequest(BaseModel):
domain: str
zone_id: str | None = None
use_cloudflare: bool = True
mail_aliases: list[str] = Field(default_factory=list, max_length=5)
class SupportTicketRequest(BaseModel):
domain: str = Field(..., min_length=3, max_length=253)
account_email: str | None = None
client_note: str | None = Field(default=None, max_length=2000)
def _client_settings(domain: str, email: str) -> dict:
mail = f"mail.{domain}"
return {
"imap": {"host": mail, "port": 993, "security": "SSL/TLS"},
"smtp": {"host": mail, "port": 465, "security": "SSL/TLS"},
"pop3": {"host": mail, "port": 995, "security": "SSL/TLS"},
"webmail": f"https://{mail}/",
"email": email,
"smtp_note": "Use a porta 465 com SSL/TLS para envio. Não use a porta 587.",
}
@router.get("/health")
def health():
return {"status": "ok", "service": "ibytera-mail-portal"}
@router.post("/session/reset")
def reset_session(
request: Request,
session_id: str = Depends(bind_onboarding_session),
domain: str | None = None,
):
"""Limpa logs da sessão actual e, opcionalmente, histórico unificado do domínio."""
get_session_from_request(request)
cleared_sessions: list[str] = []
if session_id:
activity_log.clear(session_id)
cleared_sessions.append(session_id)
domain_q = (domain or "").strip().lower()
if domain_q and len(domain_q) >= 3:
cleared_sessions.extend(activity_log.clear_for_domain(domain_q))
cleared_sessions = sorted(set(cleared_sessions))
activity_log.info("Sessão reiniciada — cache de logs limpo", source="portal")
return {
"ok": True,
"cleared_sessions": cleared_sessions,
"message": "Cache da sessão limpo. Recarregue a página.",
}
@router.post("/support/ticket")
def open_support_ticket(
body: SupportTicketRequest,
background_tasks: BackgroundTasks,
session_id: str = Depends(bind_onboarding_session),
):
"""Abre chamado de suporte (webmail/infra pendente) com link da sessão completa."""
domain = body.domain.lower().strip()
aliases = domain_registry.get_domain_record(domain)
mail_aliases = (aliases or {}).get("mail_aliases") or []
infra_status = infrastructure.get_status(domain, mail_aliases)
layman = support_tickets.build_layman_explanation(domain, body.account_email, infra_status)
ticket, created = support_tickets.find_or_create_ticket(
domain=domain,
session_id=session_id or "",
account_email=body.account_email,
infra_status=infra_status,
client_note=body.client_note,
)
pending = [s.get("label", s.get("id")) for s in infra_status.get("steps", []) if not s.get("ok")]
try:
notifications.notify_support_ticket_opened(
ticket_id=ticket["ticket_id"],
domain=domain,
account_email=body.account_email,
support_url=ticket["support_url"],
pending_summary="; ".join(pending) or "A verificar",
session_id=session_id or "",
client_note=body.client_note,
)
except Exception as exc:
activity_log.warn(f"Notificação de chamado falhou (ticket criado): {exc}", source="portal")
if created:
background_tasks.add_task(
ops_webhook.emit_event,
"onboarding.escalated",
domain=domain,
session_id=session_id or "",
data={
"reason": "client_support_request",
"wizard_ticket_id": ticket["ticket_id"],
"client_note": body.client_note,
"source": "wizard_support_modal",
},
)
if created:
activity_log.info(
f"Cliente abriu chamado {ticket['ticket_id']} — suporte notificado",
source="portal",
)
else:
activity_log.info(
f"Cliente reutilizou chamado {ticket['ticket_id']} — sem duplicar",
source="portal",
)
msg = (
"Chamado registrado. Nossa equipe (assistente + suporte humano) recebeu o histórico desta sessão."
if created
else f"Já existe um chamado aberto ({ticket['ticket_id']}). Acompanhe o andamento abaixo."
)
return {
"ticket_id": ticket["ticket_id"],
"support_url": ticket["support_url"],
"tracking_url": ticket.get("tracking_url") or ticket["support_url"],
"created": created,
"layman": layman,
"message": msg,
}
@router.get("/support/ticket/by-session")
def get_open_ticket_for_session(session_id: str = Depends(bind_onboarding_session)):
"""Chamado OB- aberto para banner no wizard."""
ticket = support_tickets.find_open_ticket_for_session(session_id or "")
if not ticket:
return {"ticket": None}
return {
"ticket": {
"ticket_id": ticket.get("ticket_id"),
"tracking_url": ticket.get("tracking_url") or ticket.get("support_url"),
"status": ticket.get("status"),
}
}
@router.get("/support/ticket/{ticket_id}")
def get_support_ticket(ticket_id: str):
ticket = support_tickets.get_ticket(ticket_id)
if not ticket:
raise HTTPException(404, "Chamado não encontrado.")
return ticket
class SupportTicketNoteRequest(BaseModel):
session_id: str = Field(..., min_length=8)
message: str = Field(..., min_length=1, max_length=2000)
@router.get("/support/ticket/{ticket_id}/public")
def get_support_ticket_public(
ticket_id: str,
session_id: str = Query(..., min_length=8),
):
"""Portal cliente Spec 026 — requer par ticket + session."""
from app.services import assist_session, infrastructure
from app.services.domain_registry import get_domain_record
ticket = support_tickets.get_ticket(ticket_id.strip().upper())
if not ticket:
raise HTTPException(404, detail={"message": "Chamado não encontrado."})
if (ticket.get("session_id") or "").strip() != session_id.strip():
raise HTTPException(403, detail={"message": "Sessão não corresponde a este chamado."})
domain = ticket.get("domain") or ""
rec = get_domain_record(domain) if domain else None
aliases = (rec or {}).get("mail_aliases") or []
infra_status = infrastructure.get_status(domain, aliases) if domain else {}
ticket["infra_status"] = infra_status
assist_state = assist_session.public_state(session_id.strip())
return support_tickets.build_public_view(ticket, infra_status=infra_status, assist_state=assist_state)
@router.post("/support/ticket/{ticket_id}/note")
def add_support_ticket_note(
ticket_id: str,
body: SupportTicketNoteRequest,
session_id: str = Depends(bind_onboarding_session),
):
sid = (body.session_id or session_id or "").strip()
updated = support_tickets.add_client_note(ticket_id.strip().upper(), sid, body.message)
if not updated:
raise HTTPException(404, detail={"message": "Chamado não encontrado ou sessão inválida."})
from app.services import assist_session, infrastructure
from app.services.domain_registry import get_domain_record
domain = updated.get("domain") or ""
rec = get_domain_record(domain) if domain else None
aliases = (rec or {}).get("mail_aliases") or []
infra_status = infrastructure.get_status(domain, aliases) if domain else {}
assist_state = assist_session.public_state(sid)
return support_tickets.build_public_view(updated, infra_status=infra_status, assist_state=assist_state)
@router.get("/activity-log")
def get_activity_log(
session_id: str = Depends(bind_onboarding_session),
domain: str | None = None,
):
domain_q = (domain or "").strip().lower()
if domain_q and len(domain_q) >= 3:
entries, sessions_merged = activity_log.get_entries_for_domain(domain_q)
return {
"session_id": session_id or None,
"domain": domain_q,
"sessions_merged": sessions_merged,
"entries": entries,
}
if not session_id:
return {"entries": [], "session_id": None}
return {
"session_id": session_id,
"entries": activity_log.get_entries(session_id),
"sessions_merged": [session_id],
}
@router.get("/infrastructure/status/{domain}")
def infrastructure_status(domain: str):
return infrastructure.get_status(domain.lower().strip())
@router.post("/infrastructure/provision")
def infrastructure_provision(
body: DomainRequest,
request: Request,
step: str | None = Query(None),
):
get_session_from_request(request)
domain = body.domain.lower().strip()
activity_log.info(f"Provisionar infra: {domain}", source="traefik")
try:
return infrastructure.provision(domain, step_id=step)
except Exception as e:
activity_log.error(str(e), source="traefik")
raise HTTPException(400, str(e)) from e
@router.post("/validate-domain")
def validate_domain(body: ValidateDomainRequest, request: Request):
get_session_from_request(request)
domain = normalize_domain(body.domain)
try:
validate_primary_domain(domain)
except ValueError as e:
raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
aliases = sanitize_mail_aliases(body.mail_aliases, domain)
if body.mail_aliases:
domain_registry.save_mail_aliases(domain, aliases)
activity_log.info(
f"Alias registados: {', '.join(aliases) if aliases else 'nenhum'}",
source="portal",
)
activity_log.info(f"Validar domínio: {domain}", source="portal")
exists = carbonio.domain_exists(domain)
if exists:
activity_log.ok(f"Carbonio: domínio {domain} já existe", source="vm112")
else:
activity_log.info(
f"Carbonio: domínio {domain} ainda não existe (normal — será criado ao «Criar conta agora»)",
source="vm112",
)
cf_auto = False
if settings.cloudflare_api_token:
if is_cloudflare_sandbox():
activity_log.info(
"Cloudflare sandbox: token de teste activo (sem chamadas à API real)",
source="cloudflare",
)
cf_auto = True
else:
try:
cf = CloudflareDNS()
activity_log.info("Verificar zona Cloudflare…", source="cloudflare")
cf_auto = cf.get_zone_by_name(domain) is not None
activity_log.ok(
f"Cloudflare: zona {'encontrada' if cf_auto else 'ainda não na conta Ligbox'}",
source="cloudflare",
)
except CloudflareError as e:
activity_log.warn(f"Cloudflare: {e}", source="cloudflare")
else:
activity_log.warn("Token Cloudflare não configurado", source="cloudflare")
return {
"domain": domain,
"mail_aliases": aliases,
"exists_in_carbonio": exists,
"carbonio_will_create_domain": not exists,
"carbonio_provisioning": True,
"cloudflare_auto_dns": cf_auto,
"message": (
f"O domínio {'já existe' if exists else 'será criado'} no Carbonio ao criar a conta. "
f"{'Cloudflare: DNS automático OK.' if cf_auto else 'Cloudflare: configure DNS no passo seguinte.'}"
),
}
def _cloudflare_zone_status(domain: str) -> dict:
domain = domain.lower().strip()
if not settings.cloudflare_api_token:
return {
"domain": domain,
"cloudflare_configured": False,
"zone_in_account": False,
"can_apply_mail_records": False,
"zone_status": None,
"nameservers": [],
"managed_zones": [],
"portal_onboarding_required": True,
"message": "API Cloudflare não configurada no servidor.",
}
cf = CloudflareDNS()
cf.verify_token()
managed = cf.list_zone_names()
zone = cf.get_zone_by_name(domain)
in_account = zone is not None
zone_status = zone.get("status") if zone else None
nameservers = wizard_nameservers(zone) if zone else []
return {
"domain": domain,
"cloudflare_configured": True,
"zone_in_account": in_account,
"can_apply_mail_records": in_account,
"zone_id": zone["id"] if zone else None,
"zone_status": zone_status,
"nameservers": nameservers,
"managed_zones": managed,
"portal_onboarding_required": not in_account,
"message": (
f"Zona {domain} pronta na Cloudflare Ligbox ({zone_status})."
if in_account
else f"Adicione {domain} na Cloudflare Ligbox e altere os NS no registrador."
),
}
@router.get("/dns/cloudflare/status/{domain}")
def cloudflare_zone_status(domain: str):
try:
activity_log.info(f"Estado Cloudflare: {domain}", source="cloudflare")
return _cloudflare_zone_status(domain.lower().strip())
except CloudflareError as e:
activity_log.error(str(e), source="cloudflare")
raise HTTPException(502, f"Erro Cloudflare: {e}") from e
def _portal_onboarding_payload(domain: str, zone_created: bool = False) -> dict:
status = _cloudflare_zone_status(domain)
nameservers = status.get("nameservers") or []
in_account = status.get("zone_in_account")
if in_account:
return {
"domain": domain,
"ready": True,
"zone_created": zone_created,
"steps": [
{
"order": 1,
"title": "Domínio na Cloudflare Ligbox",
"done": True,
"detail": (
f"Zona criada automaticamente. Estado: {status.get('zone_status', 'pending')}."
if zone_created
else f"Zona já existia ({status.get('zone_status', 'active')})."
),
},
{
"order": 2,
"title": "Alterar nameservers no registrador",
"done": status.get("zone_status") == "active",
"detail": "Aponte o domínio para os nameservers Cloudflare listados abaixo.",
},
{
"order": 3,
"title": "Registos de email",
"done": False,
"detail": "Use «Criar apontamentos de email» para MX, A, SPF e DMARC.",
},
],
"nameservers": nameservers,
"status": status,
}
return {
"domain": domain,
"ready": False,
"zone_created": False,
"steps": [
{
"order": 1,
"title": "Criar zona na Cloudflare",
"done": False,
"detail": "Não foi possível criar a zona. Verifique o API token.",
},
],
"nameservers": [],
"status": status,
}
@router.post("/dns/cloudflare/provision-zone")
def provision_cloudflare_zone(body: DomainRequest, request: Request):
get_session_from_request(request)
domain = normalize_domain(body.domain)
try:
validate_primary_domain(domain)
except ValueError as e:
raise HTTPException(status_code=400, detail=invalid_domain_http_detail()) from e
if not settings.cloudflare_api_token:
raise HTTPException(400, _cloudflare_token_missing_error())
cf = CloudflareDNS()
try:
activity_log.info(f"Criar zona Cloudflare: {domain}", source="cloudflare")
cf.verify_token()
result = cf.ensure_zone(domain)
zone = result["zone"]
created = result["created"]
sandbox = is_cloudflare_sandbox()
if sandbox:
activity_log.info(
"Cloudflare sandbox: zona simulada (token de teste — não cria zona real)",
source="cloudflare",
)
activity_log.ok(
f"Zona {domain} {'criada' if created else 'já existia'} (id {zone.get('id', '?')})",
source="cloudflare",
)
payload = _portal_onboarding_payload(domain, zone_created=created)
payload["zone_id"] = zone.get("id")
payload["sandbox"] = sandbox
payload["message"] = (
f"Domínio {domain} {'criado' if created else 'já existia'} na Cloudflare Ligbox. "
+ (
"Ambiente de teste: apontamentos simulados automaticamente."
if sandbox
else "Altere os nameservers no registrador."
)
)
if nameservers := wizard_nameservers(zone):
payload["nameservers"] = nameservers
payload["status"]["nameservers"] = nameservers
if sandbox:
zone_id = zone.get("id")
applied = []
for rec in mail_dns_records(domain, settings.mail_public_ip, []):
upsert = cf.upsert_record(
zone_id,
rec["type"],
rec["name"],
rec["content"],
priority=rec.get("priority"),
proxied=rec.get("proxied", False),
zone_name=domain,
)
applied.append({"type": rec["type"], "name": rec["name"], "id": upsert.get("id")})
verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
payload["applied"] = applied
payload["verification"] = verification
for step in payload.get("steps") or []:
if step.get("order") == 3:
step["done"] = True
step["detail"] = "Apontamentos simulados (sandbox)."
activity_log.ok("Cloudflare sandbox: apontamentos de email simulados", source="cloudflare")
return payload
except CloudflareError as e:
activity_log.error(f"Provision zona falhou: {e}", source="cloudflare")
raise HTTPException(
status_code=502,
detail=cloudflare_error_http_detail(e, domain),
) from e
@router.get("/dns/portal-onboarding/{domain}")
def portal_dns_onboarding(domain: str):
domain = domain.lower().strip()
try:
return _portal_onboarding_payload(domain, zone_created=False)
except CloudflareError as e:
activity_log.error(str(e), source="cloudflare")
raise HTTPException(502, f"Erro Cloudflare: {e}") from e
@router.get("/dns/instructions/{domain}")
def dns_instructions(domain: str):
domain = domain.lower().strip()
mail_host = f"mail.{domain}"
ip = settings.mail_public_ip
activity_log.info(f"Instruções DNS para {domain}", source="portal")
cf_status = None
try:
cf_status = _cloudflare_zone_status(domain)
except CloudflareError:
pass
return {
"domain": domain,
"mail_public_ip": ip,
"cloudflare": cf_status,
"dns_paths": {
"portal": "Trazer DNS para o portal (Cloudflare conta Ligbox)",
"external": "Manter DNS no provedor atual (apontamentos manuais)",
},
"records": [
{"type": "A", "name": mail_host, "value": ip, "note": "DNS only (sem proxy Cloudflare)"},
{"type": "MX", "name": domain, "value": mail_host, "priority": 10},
{"type": "TXT", "name": domain, "value": f"v=spf1 mx a:{mail_host} ip4:{ip} -all"},
{
"type": "TXT",
"name": f"_dmarc.{domain}",
"value": f"v=DMARC1; p=quarantine; rua=mailto:postmaster@{domain}",
},
{
"type": "TXT",
"name": f"default._domainkey.{domain}",
"value": "(DKIM — Carbonio Admin após criar domínio)",
},
],
"cloudflare_hint": "Escolha no passo DNS: trazer para o portal ou manter no provedor atual.",
}
@router.get("/dns/verify/{domain}")
def verify_dns(domain: str):
domain = domain.lower().strip()
activity_log.info(f"Verificar DNS público: {domain}", source="portal")
result = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
if result.get("ready"):
activity_log.ok("DNS público: MX e A mail OK", source="portal")
else:
activity_log.warn("DNS público: ainda incompleto (MX ou A mail)", source="portal")
return result
@router.post("/dns/cloudflare/apply")
def apply_cloudflare_dns(body: CloudflareApplyRequest, request: Request):
get_session_from_request(request)
domain = body.domain.lower().strip()
if not settings.cloudflare_api_token:
raise HTTPException(400, _cloudflare_token_missing_error())
cf = CloudflareDNS()
try:
activity_log.info(f"Aplicar registos mail na Cloudflare: {domain}", source="cloudflare")
cf.verify_token()
except CloudflareError as e:
activity_log.error(str(e), source="cloudflare")
raise HTTPException(401, f"Token Cloudflare inválido: {e}") from e
zone_id = body.zone_id
if not zone_id:
zone = cf.get_zone_by_name(domain)
if not zone:
activity_log.error(f"Zona {domain} não está na conta Ligbox", source="cloudflare")
raise HTTPException(
status_code=422,
detail={
"code": "zone_not_in_account",
"domain": domain,
"message": f"Zona {domain} ainda não está na Cloudflare Ligbox.",
"use_portal_onboarding": True,
},
)
zone_id = zone["id"]
rec = domain_registry.get_domain_record(domain)
aliases = sanitize_mail_aliases(body.mail_aliases, domain) if body.mail_aliases else []
if not aliases and rec:
aliases = list(rec.get("mail_aliases") or [])
applied = []
for rec in mail_dns_records(domain, settings.mail_public_ip, aliases):
result = cf.upsert_record(
zone_id,
rec["type"],
rec["name"],
rec["content"],
priority=rec.get("priority"),
proxied=rec.get("proxied", False),
zone_name=domain,
)
applied.append({"type": rec["type"], "name": rec["name"], "id": result.get("id")})
activity_log.ok(f"DNS {rec['type']} {rec['name']}{rec['content']}", source="cloudflare")
verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
return {"domain": domain, "zone_id": zone_id, "applied": applied, "verification": verification}
@router.get("/session/password-status")
def session_password_status(request: Request):
"""Spec 016b — indica se a senha do cadastro está no vault servidor (sem expor valor)."""
sid = get_session_from_request(request)
if not sid:
return {"ready": False, "source": None}
ready = onboard_handoff.session_has_password(sid)
return {"ready": ready, "source": "portal_handoff" if ready else None}
@router.delete("/session/password")
def clear_session_password(request: Request):
"""Remove senha do vault (ex.: utilizador escolhe senha diferente no wizard)."""
sid = get_session_from_request(request)
if sid:
onboard_handoff.clear_session_password(sid)
return {"ok": True}
@router.post("/account/create")
def create_account(body: CreateAccountRequest, request: Request):
sid = get_session_from_request(request)
domain = body.domain.lower().strip()
email = f"{body.local_part}@{domain}"
webmail = f"https://mail.{domain}/"
dns_mode = body.dns_mode or "não indicado"
aliases = sanitize_mail_aliases(body.mail_aliases, domain)
if aliases:
domain_registry.save_mail_aliases(domain, aliases)
if body.use_server_password:
password = onboard_handoff.pop_password_for_session(sid) if sid else None
if not password:
raise HTTPException(
400,
"Senha do cadastro expirou ou não está disponível. Defina a senha manualmente no passo Conta admin.",
)
else:
password = (body.password or "").strip()
if len(password) < 8:
raise HTTPException(400, "A senha precisa de pelo menos 8 caracteres.")
if sid:
onboard_handoff.clear_session_password(sid)
activity_log.info(f"Iniciar criação de conta: {email}", source="portal")
try:
out, account_reused = carbonio.ensure_onboarding_account(
email, password, body.display_name
)
if account_reused:
activity_log.info(
f"Conta reutilizada (retry wizard): {email} — senha actualizada",
source="vm112",
)
else:
activity_log.ok(f"zmprov ca concluído: {out or 'OK'}", source="vm112")
except carbonio.CarbonioError as e:
activity_log.error(f"Criação falhou: {e}", source="vm112")
raise HTTPException(400, str(e)) from e
verified = carbonio.account_exists(email)
if verified:
activity_log.ok(f"Verificação: conta {email} existe no Carbonio", source="vm112")
domain_registry.register_portal_admin(domain, email, mail_aliases=aliases)
activity_log.ok(f"Painel admin: {email} autorizado para {domain}", source="portal")
if aliases:
carbonio.add_mail_alias_hostnames(domain, aliases)
activity_log.ok(f"Alias Carbonio: {len(aliases)} hostname(s)", source="vm112")
else:
activity_log.error(
f"ALERTA: zmprov ca executou mas conta {email} NÃO encontrada (ga)",
source="vm112",
)
verification = None
try:
verification = dns_verify.verify_mail_dns(domain, settings.mail_public_ip)
except Exception as e:
activity_log.warn(f"Verificação DNS: {e}", source="portal")
notifications_sent = {
"admin": notifications.notify_admin_onboarding(
domain,
email,
dns_mode,
verified,
verification.get("ready") if verification else None,
),
"client": False,
"welcome": False,
}
if body.notify_email:
notifications_sent["client"] = notifications.notify_client_contact(
str(body.notify_email),
domain,
email,
webmail,
verified,
)
if body.send_welcome and verified:
notifications_sent["welcome"] = notifications.send_welcome_to_mailbox(
email,
domain,
webmail,
body.display_name,
)
activity_log.ok("Processo de onboarding finalizado", source="portal")
infra_status = None
try:
infra_status = infrastructure.provision(domain, mail_aliases=aliases)
activity_log.ok("Provisionamento infra (Traefik/cert/SNI) concluído", source="traefik")
except Exception as e:
activity_log.warn(f"Infra automática: {e} — use a coluna Infraestrutura", source="traefik")
return {
"email": email,
"message": out,
"account_reused": account_reused,
"webmail": webmail,
"account_verified": verified,
"notifications_sent": notifications_sent,
"client_settings": _client_settings(domain, email),
"dns_verification": verification,
"needs_review": not verified,
"mail_aliases": aliases,
"infrastructure": infra_status.get("status") if infra_status else infrastructure.get_status(domain, aliases),
}