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), }