- 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
777 lines
29 KiB
Python
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),
|
|
}
|