openapi: 3.1.0

info:
  title: CDW Mail — API
  version: 0.1.0
  summary: API da plataforma de e-mail da CDW Tech.
  description: |
    Liga o webmail e o painel ao motor (Postfix, Dovecot, Rspamd).

    ## A fronteira entre clientes

    A hierarquia é `revenda → domínio → caixa`, e **o escopo de quem pede
    limita o que ele enxerga em TODA consulta**, sem exceção. Não existe
    rota que devolva dado fora do escopo do solicitante.

    Quatro níveis, do mais amplo ao mais restrito:

    | Nível | Enxerga |
    |---|---|
    | `super_admin` | tudo |
    | `revenda` | os domínios da própria revenda, e as caixas deles |
    | `admin_dominio` | o próprio domínio e suas caixas e aliases |
    | `usuario` | a própria caixa |

    Toda operação declara o nível mínimo em `x-acesso`. Isso é contrato, não
    documentação: a implementação deriva a verificação daqui, e há teste de
    isolamento para cada rota.

    **Recurso fora do escopo responde `404`, nunca `403`.** Um `403` confirma
    que o recurso existe, e isso já é vazamento: permite a uma revenda
    descobrir os domínios de outra por tentativa. Do lado de fora, o que não
    é seu não existe.

    ## Identificadores

    As rotas usam `uuid`, nunca o id sequencial do banco. Com id sequencial,
    `/dominios/7` convida a enumerar a base inteira.

    ## Paginação

    Duas estratégias, por motivos diferentes:

    - **Listas administrativas** (revendas, domínios, caixas): `pagina` e
      `por_pagina`. São listas pequenas e estáveis.
    - **Mensagens**: cursor opaco. Caixa recebe mensagem durante a
      navegação, e paginar por deslocamento faria o usuário pular ou repetir
      mensagens a cada página nova.

    ## Erros

    `application/problem+json`, conforme a RFC 9457. Todo erro traz `type`,
    `title`, `status`, `detail` e, quando houver, `erros` por campo.

    ## Datas

    Sempre `date-time` em UTC, com fuso explícito. A conversão para o fuso
    do usuário é da interface.

  contact:
    name: CDW Tech
    email: contato@cdwtech.com.br
  license:
    name: Proprietária — CDW Tech
    url: https://cdwtech.com.br/licenca

servers:
  - url: https://{host}/api/v1
    description: Instalação da plataforma
    variables:
      host:
        default: webmail.cdwmail.com.br

tags:
  - name: autenticacao
    description: Login, sessão, 2FA e senhas de aplicativo.
  - name: revendas
    description: Parceiros. Só o super admin administra.
  - name: dominios
    description: Domínios hospedados, cotas e verificação de DNS.
  - name: caixas
    description: Caixas postais e senhas.
  - name: aliases
    description: Encaminhamentos e listas simples.
  - name: entregas
    description: Fila, entregas, rejeições, devoluções e quarentena.
  - name: migracao
    description: Trazer um cliente de outro painel. Só o super admin.
  - name: auditoria
    description: Registro de quem fez o quê.
  - name: correio
    description: Leitura e envio de e-mail pelo webmail.
  - name: sistema
    description: Saúde e versão.

security:
  - tokenDeSessao: []

paths:

  # =========================================================================
  # AUTENTICAÇÃO
  # =========================================================================

  /auth/login:
    post:
      tags: [autenticacao]
      operationId: criar_auth_login
      summary: Autentica e abre uma sessão
      description: |
        O login é o endereço completo (`maria@teste1.local`). Duas caixas
        `maria` em domínios diferentes são pessoas diferentes.

        Quando a conta tem 2FA ativo, responde `202` com um `desafio` de uso
        único, e a sessão só nasce depois de `/auth/2fa/verificar`.

        Credencial inválida e conta inexistente devolvem a MESMA resposta e
        levam o MESMO tempo. Diferenciar as duas transforma o login num
        verificador de quais caixas existem.

        **O refresh também vem num cookie `HttpOnly`** (`cdw_refresh`, com
        `Secure` e `SameSite=Strict`, restrito a `/api/v1/auth`). O navegador
        deve usar o cookie e IGNORAR o campo `refresh` do corpo: guardar o
        valor em qualquer lugar que o JavaScript leia permitiria que um XSS
        exfiltrasse a sessão e a usasse de outra máquina, por dias.

        O campo continua no corpo para integrações e scripts, que não têm
        como usar cookie.
      x-acesso: publico
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, senha]
              properties:
                email:    { type: string, format: email, example: maria@teste1.local }
                senha:    { type: string, format: password, writeOnly: true }
                lembrar:  { type: boolean, default: false, description: Estende a validade do refresh. }
      responses:
        '200':
          description: Sessão aberta.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sessao' }
        '202':
          description: Credencial correta, mas a conta exige o segundo fator.
          content:
            application/json:
              schema:
                type: object
                required: [desafio, metodos]
                properties:
                  desafio:
                    type: string
                    description: Uso único, validade curta.
                  metodos:
                    type: array
                    items: { type: string, enum: [totp, codigo_recuperacao] }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '429': { $ref: '#/components/responses/ExcessoDeTentativas' }

  /auth/2fa/verificar:
    post:
      tags: [autenticacao]
      operationId: criar_auth_2fa_verificar
      summary: Conclui o login apresentando o segundo fator
      x-acesso: publico
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [desafio, codigo]
              properties:
                desafio: { type: string }
                codigo:  { type: string, description: Código TOTP ou de recuperação. }
      responses:
        '200':
          description: Sessão aberta.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sessao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '429': { $ref: '#/components/responses/ExcessoDeTentativas' }

  /auth/refresh:
    post:
      tags: [autenticacao]
      operationId: criar_auth_refresh
      summary: Renova a sessão
      description: |
        O refresh é rotativo: cada uso invalida o anterior. Se um refresh já
        usado reaparecer, é sinal de token roubado — a família inteira de
        sessões daquele usuário é encerrada, e fica registrado na auditoria.

        A renovação reconfere no banco se a caixa, o domínio e a revenda
        continuam ativos. Suspender uma revenda derruba o acesso dela.

        **O refresh vem do cookie `cdw_refresh` quando ele existe**, e o corpo
        é a reserva para clientes que não usam cookie. O cookie GANHA do corpo
        de propósito: se o corpo vencesse, um XSS poderia mandar um refresh
        antigo que tivesse capturado, e o reuso derrubaria a sessão legítima.

        A resposta grava o cookie novo — a rotação vale para ele também. Uma
        renovação que falha apaga o cookie, para o navegador ir direto ao login
        em vez de tentar de novo a cada carregamento.
      x-acesso: publico
      security: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              required: [refresh]
              properties:
                refresh: { type: string }
      responses:
        '200':
          description: Sessão renovada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sessao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

  /auth/contas:
    get:
      tags: [autenticacao]
      operationId: obter_auth_contas
      summary: Lista as contas abertas NESTE navegador
      description: |
        Quais contas este navegador tem logadas, e em que vaga cada uma está.

        Existe porque o índice de vagas é um cookie HttpOnly — o JavaScript
        não pode lê-lo, e é esse o ponto. Uma aba recém-aberta precisa saber
        qual vaga retomar; sem esta rota, abrir o webmail numa segunda aba
        cairia no login mesmo havendo sessão.

        **Não exige autenticação**, e é seguro: a resposta é montada a partir
        do cookie que este mesmo navegador enviou, e não contém credencial
        nenhuma. Saber que a vaga 1 é de alguém não abre a caixa de ninguém.
      x-acesso: publico
      security: []
      responses:
        '200':
          description: As contas abertas neste navegador.
          content:
            application/json:
              schema:
                type: object
                properties:
                  itens:
                    type: array
                    items:
                      type: object
                      properties:
                        vaga:  { type: integer, minimum: 0, maximum: 4 }
                        caixa: { type: string, format: email }
                  teto:
                    type: integer
                    description: Quantas contas cabem neste navegador.

  /auth/u/{vaga}/refresh:
    post:
      tags: [autenticacao]
      operationId: criar_auth_vaga_refresh
      summary: Renova a sessão de uma vaga do navegador
      description: |
        Mesma renovação de `/auth/refresh`, para UMA conta do navegador.

        Cada conta aberta ocupa uma VAGA, e a vaga vira o caminho do cookie
        (`Path=/api/v1/auth/u/0`). Um pedido a esta rota só carrega o cookie
        daquela vaga: a aba de uma conta não alcança o cookie de outra, e a
        separação passa a ser do NAVEGADOR em vez do nosso código.

        Isso existe porque antes havia um cookie só por origem: entrar com
        uma segunda conta no mesmo endereço sobrescrevia o cookie da
        primeira, e a primeira aba passava a renovar como a segunda pessoa.

        `/auth/refresh`, sem vaga, continua existindo para as sessões criadas
        antes disto — ela migra a sessão para uma vaga e sai de cena.
      x-acesso: publico
      security: []
      parameters:
        - name: vaga
          in: path
          required: true
          description: Índice da conta no navegador, de 0 a 4.
          schema: { type: integer, minimum: 0, maximum: 4 }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              required: [refresh]
              properties:
                refresh: { type: string }
      responses:
        '200':
          description: Sessão renovada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sessao' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /auth/logout:
    post:
      tags: [autenticacao]
      operationId: criar_auth_logout
      summary: Encerra a sessão atual
      x-acesso: usuario
      responses:
        '204': { description: Encerrada. }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

  /chaves-api:
    get:
      tags: [integracao]
      operationId: obter_chaves_api
      summary: Lista as chaves de API
      description: |
        Nunca devolve a chave — só o PREFIXO, que é a parte pública.

        A chave inteira existe uma vez só, no instante em que é criada: o
        banco guarda o hash. É esse contrato que faz um vazamento do banco
        não virar acesso.
      x-acesso: super_admin
      responses:
        '200':
          description: As chaves, ativas primeiro.
          content:
            application/json:
              schema:
                type: object
                properties:
                  itens:
                    type: array
                    items: { $ref: '#/components/schemas/ChaveApi' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
    post:
      tags: [integracao]
      operationId: criar_chave_api
      summary: Cria uma chave de API para uma revenda
      description: |
        **Só o super admin cria.** Uma revenda não cria as próprias: quem
        decide que uma integração existe é quem responde pela plataforma.

        A resposta traz a chave inteira, **uma única vez**. Guarde na hora —
        ela não é recuperável. Quem perder, revoga e cria outra.

        O `nome` é obrigatório porque três chaves sem nome viram três
        enigmas na hora de revogar uma delas.
      x-acesso: super_admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [revenda_uuid, nome]
              properties:
                revenda_uuid: { type: string, format: uuid }
                nome:
                  type: string
                  maxLength: 80
                  description: Para que serve. "ERP financeiro", "portal do cliente".
      responses:
        '201':
          description: Criada. É a ÚNICA vez que a chave aparece.
          content:
            application/json:
              schema:
                type: object
                properties:
                  uuid:    { type: string, format: uuid }
                  nome:    { type: string }
                  prefixo: { type: string }
                  chave:   { type: string, description: A chave inteira. Não será mostrada de novo. }
                  aviso:   { type: string }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /chaves-api/{uuid}:
    delete:
      tags: [integracao]
      operationId: remover_chave_api
      summary: Revoga uma chave
      description: |
        A chave para de valer na requisição seguinte. A linha fica, com quem
        revogou e quando — revogação some do histórico é revogação que
        ninguém consegue auditar.
      x-acesso: super_admin
      parameters:
        - name: uuid
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Revogada. }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /bloqueios:
    get:
      tags: [autenticacao]
      operationId: obter_bloqueios
      summary: Quem o limitador de login barrou
      description: |
        Quem está barrado agora, e o histórico.

        O limitador vive em MEMÓRIA e sempre viveu — isso bastava para
        proteger, mas não para operar: quando o cliente ligava dizendo "não
        consigo entrar", não havia onde olhar.

        **São dois limites, com alcances diferentes.** Por CONTA: 5 senhas
        erradas na mesma caixa em 15 minutos. Por IP: 30 senhas erradas do
        mesmo IP em 15 minutos — é o que pega quem tenta uma senha em muitas
        caixas, que o limite por conta nunca veria. Só ERRO consome cota.

        Enquanto o bloqueio vale, **a senha certa também é barrada** — a
        pessoa lembra a senha, digita certo e continua de fora.

        `?ativos=1` traz só quem está barrado neste instante, que é como a
        tela abre; sem o filtro vem o histórico. `?alvo=` busca por trecho.

        **Bloqueio de IP só aparece para o super admin**: um IP não pertence
        a domínio nenhum, e mostrá-lo a uma revenda contaria que outro
        cliente está sob ataque.
      x-acesso: admin_dominio
      parameters:
        - name: ativos
          in: query
          schema: { type: string, enum: ["1"] }
        - name: alvo
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Os bloqueios que este solicitante pode ver.
          content:
            application/json:
              schema:
                type: object
                properties:
                  itens:
                    type: array
                    items: { $ref: '#/components/schemas/BloqueioLogin' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }

  /bloqueios/{uuid}/liberar:
    post:
      tags: [autenticacao]
      operationId: criar_bloqueio_liberar
      summary: Destrava um bloqueio antes da hora
      description: |
        Limpa a contagem em memória — que é o que destrava de verdade — e
        marca a linha com quem liberou.

        A ordem importa: a memória primeiro. Fosse ao contrário e a limpeza
        falhasse, a tela diria "liberado" com a pessoa ainda barrada.

        Responde **404** para bloqueio fora do escopo de quem pede: 403
        confirmaria que ele existe.
      x-acesso: admin_dominio
      parameters:
        - name: uuid
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Destravado. }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /auth/sessoes:
    get:
      tags: [autenticacao]
      operationId: obter_auth_sessoes
      summary: Lista as sessões abertas da própria conta
      x-acesso: usuario
      responses:
        '200':
          description: Sessões da conta.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SessaoAberta' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

  /auth/sessoes/{uuid}:
    delete:
      tags: [autenticacao]
      operationId: remover_auth_sessoes_por_uuid
      summary: Encerra uma sessão específica
      description: Só as próprias sessões. Sessão de outra conta responde `404`.
      x-acesso: usuario
      parameters:
        - $ref: '#/components/parameters/Uuid'
      responses:
        '204': { description: Encerrada. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /auth/2fa:
    post:
      tags: [autenticacao]
      operationId: criar_auth_2fa
      summary: Inicia a ativação do segundo fator
      description: |
        Devolve o segredo e o `otpauth://` para o aplicativo. **O 2FA só
        passa a valer depois de confirmado** com um código válido — senão um
        erro de leitura do QR tranca o usuário para fora da própria conta.
      x-acesso: usuario
      responses:
        '200':
          description: Segredo gerado, aguardando confirmação.
          content:
            application/json:
              schema:
                type: object
                properties:
                  segredo: { type: string }
                  uri:     { type: string, example: 'otpauth://totp/CDW%20Mail:maria@teste1.local?secret=...' }
        '409':
          $ref: '#/components/responses/Conflito'
    delete:
      tags: [autenticacao]
      operationId: remover_auth_2fa
      summary: Desativa o segundo fator
      description: Exige a senha atual, para que uma sessão roubada não desligue a proteção.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [senha]
              properties:
                senha: { type: string, format: password, writeOnly: true }
      responses:
        '204': { description: Desativado. }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

  /auth/2fa/confirmar:
    post:
      tags: [autenticacao]
      operationId: criar_auth_2fa_confirmar
      summary: Confirma a ativação do segundo fator
      description: Devolve os códigos de recuperação. **Eles são mostrados uma única vez.**
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [codigo]
              properties:
                codigo: { type: string }
      responses:
        '200':
          description: Ativado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  codigos_recuperacao:
                    type: array
                    items: { type: string }
        '400': { $ref: '#/components/responses/DadosInvalidos' }

  /auth/senhas-app:
    get:
      tags: [autenticacao]
      operationId: obter_auth_senhas_app
      summary: Lista as senhas de aplicativo da conta
      description: |
        Senha de aplicativo existe porque cliente de e-mail não sabe fazer
        2FA: o IMAP e o SMTP não têm segundo fator. Cada dispositivo ganha a
        sua, e revogar uma não derruba as outras.
      x-acesso: usuario
      responses:
        '200':
          description: Senhas cadastradas (sem o segredo).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SenhaApp' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

    post:
      tags: [autenticacao]
      operationId: criar_auth_senhas_app
      summary: Cria uma senha de aplicativo
      description: A senha em si aparece **uma única vez**, nesta resposta.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [descricao]
              properties:
                descricao: { type: string, example: iPhone da Maria }
      responses:
        '201':
          description: Criada.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/SenhaApp'
                  - type: object
                    properties:
                      senha:
                        type: string
                        description: Mostrada só agora; não é recuperável depois.
        '401': { $ref: '#/components/responses/NaoAutenticado' }

  /auth/senhas-app/{uuid}:
    delete:
      tags: [autenticacao]
      operationId: remover_auth_senhas_app_por_uuid
      summary: Revoga uma senha de aplicativo
      x-acesso: usuario
      parameters:
        - $ref: '#/components/parameters/Uuid'
      responses:
        '204': { description: Revogada. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  # =========================================================================
  # ADMINISTRAÇÃO — revendas
  # =========================================================================

  /revendas:
    get:
      tags: [revendas]
      operationId: obter_revendas
      summary: Lista revendas
      x-acesso: super_admin
      parameters:
        - $ref: '#/components/parameters/Pagina'
        - $ref: '#/components/parameters/PorPagina'
        - $ref: '#/components/parameters/Busca'
        - $ref: '#/components/parameters/FiltroStatus'
      responses:
        '200':
          description: Página de revendas.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Pagina'
                  - type: object
                    properties:
                      itens:
                        type: array
                        items: { $ref: '#/components/schemas/Revenda' }
        '403': { $ref: '#/components/responses/SemPermissao' }
    post:
      tags: [revendas]
      operationId: criar_revendas
      summary: Cria uma revenda
      x-acesso: super_admin
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RevendaEntrada' }
      responses:
        '201':
          description: Criada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Revenda' }
        '409': { $ref: '#/components/responses/Conflito' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /revendas/{uuid}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    get:
      tags: [revendas]
      operationId: obter_revendas_por_uuid
      summary: Detalha uma revenda
      description: |
        Uma revenda pode ler a si mesma. Qualquer outra responde `404` — não
        `403`, que confirmaria a existência.
      x-acesso: revenda
      responses:
        '200':
          description: A revenda.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Revenda' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    patch:
      tags: [revendas]
      operationId: alterar_revendas_por_uuid
      summary: Altera uma revenda
      description: Alterar limites e status é privativo do super admin.
      x-acesso: super_admin
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RevendaEntrada' }
      responses:
        '200':
          description: Alterada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Revenda' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    delete:
      tags: [revendas]
      operationId: remover_revendas_por_uuid
      summary: Remove uma revenda
      description: |
        Recusado enquanto houver domínio ativo. Apagar uma revenda não pode
        evaporar as caixas dos clientes dela: os domínios têm que ser
        transferidos ou removidos antes, de propósito.
      x-acesso: super_admin
      responses:
        '204': { description: Removida. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '409': { $ref: '#/components/responses/Conflito' }

  # =========================================================================
  # ADMINISTRAÇÃO — domínios
  # =========================================================================

  /dominios:
    get:
      tags: [dominios]
      operationId: obter_dominios
      summary: Lista domínios
      description: |
        A revenda vê os seus. O admin de domínio vê o seu, e só ele. O
        parâmetro `revenda` só é aceito do super admin; de qualquer outro
        nível é ignorado, porque o escopo já está fixado pela sessão.
      x-acesso: admin_dominio
      parameters:
        - $ref: '#/components/parameters/Pagina'
        - $ref: '#/components/parameters/PorPagina'
        - $ref: '#/components/parameters/Busca'
        - $ref: '#/components/parameters/FiltroStatus'
        - name: revenda
          in: query
          schema: { type: string, format: uuid }
          description: Só o super admin pode filtrar por revenda.
      responses:
        '200':
          description: Página de domínios.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Pagina'
                  - type: object
                    properties:
                      itens:
                        type: array
                        items: { $ref: '#/components/schemas/Dominio' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }

    post:
      tags: [dominios]
      operationId: criar_dominios
      summary: Cria um domínio
      description: |
        Recusado se estourar o limite de domínios da revenda.

        `revenda_uuid` é obrigatório para o super admin e, para uma revenda,
        só é aceito se for a dela — apontar outra responde `404`. Ignorar em
        silêncio seria pior: o pedido diria "crie na revenda X", a resposta
        viria `201`, e o domínio teria nascido em Y.

        A chave DKIM é gerada pelo Rspamd a partir dos domínios ativos do
        banco. Até isso acontecer o domínio existe mas ainda não assina, e o
        verificador de DNS diz exatamente isso ao cliente.
      x-acesso: revenda
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DominioEntrada' }
      responses:
        '201':
          description: Criado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Dominio' }
        '409': { $ref: '#/components/responses/Conflito' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /dominios/pendencias-ssl:
    get:
      tags: [dominios]
      operationId: obter_dominios_pendencias_ssl
      summary: Domínios com DNS apontando para cá e sem certificado emitido
      description: |
        O aviso que ninguém tinha que procurar.

        A conferência de DNS é sob demanda — só roda quando alguém clica
        "conferir DNS" naquele domínio. Para o estado **"o DNS já aponta
        para cá e falta emitir o certificado"** isso não serve: era preciso
        suspeitar antes de olhar. O `apollonodontologia.com.br` passou três
        dias assim, em 23–26/10/2026, e quem percebeu foi o cliente ao ver
        erro de SSL no navegador.

        A varredura é barata porque faz as duas perguntas NA ORDEM CERTA:
        primeiro pergunta ao nginx local qual certificado ele entrega (um
        aperto de mão TLS na mesma máquina, em todos os domínios), e só nos
        que não têm certificado consulta o DNS (caro, de rede). Em regime
        esse segundo grupo é vazio ou tem um domínio.

        O resultado é reaproveitado por 5 minutos, e a chave do cache inclui
        o escopo de quem perguntou — sem isso uma revenda veria a pendência
        de domínio alheio.
      x-acesso: admin_dominio
      responses:
        '200':
          description: Os domínios que precisam de certificado agora.
          content:
            application/json:
              schema:
                type: object
                properties:
                  itens:
                    type: array
                    items:
                      type: object
                      properties:
                        uuid:    { type: string, format: uuid }
                        dominio: { type: string }
                        motivo:  { type: string }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }

  /dominios/{uuid}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    get:
      tags: [dominios]
      operationId: obter_dominios_por_uuid
      summary: Detalha um domínio
      x-acesso: admin_dominio
      responses:
        '200':
          description: O domínio.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Dominio' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    patch:
      tags: [dominios]
      operationId: alterar_dominios_por_uuid
      summary: Altera um domínio
      description: |
        O nome é imutável. Renomear domínio de e-mail não é edição: é
        migração, e reescrever o endereço de todas as caixas em silêncio
        seria pior que recusar.
      x-acesso: revenda
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DominioEntrada' }
      responses:
        '200':
          description: Alterado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Dominio' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }
    delete:
      tags: [dominios]
      operationId: remover_dominios_por_uuid
      summary: Remove um domínio
      description: |
        Exige `?confirmar=nome.do.dominio`.

        Recusado com `409` enquanto houver caixa OU alias no domínio. Não há
        remoção em cascata: os aliases são `ON DELETE CASCADE` no banco, então
        sem esta recusa eles sumiriam junto, em silêncio, e a falta só
        apareceria quando alguém reclamasse que o encaminhamento parou.

        A chave DKIM do domínio NÃO é apagada. O TXT publicado no DNS do
        cliente continua apontando para ela, e gerar outra depois faria toda
        mensagem do domínio falhar no destino sem erro nenhum deste lado.
      x-acesso: revenda
      parameters:
        - name: confirmar
          in: query
          required: true
          schema: { type: string }
          description: O nome do domínio, digitado por extenso.
      responses:
        '204': { description: Removido. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '409': { $ref: '#/components/responses/Conflito' }

  /dominios/{uuid}/dns:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    get:
      tags: [dominios]
      operationId: obter_dominios_por_uuid_dns
      summary: Verifica o DNS do domínio
      description: |
        Confere MX, SPF, DKIM, DMARC, PTR e os nomes próprios do cliente
        (`mail.` e `webmail.`) contra o que a plataforma espera, e diz o que
        está faltando ou divergente.

        O MX aceita DUAS formas certas: o nome da plataforma
        (`mail.cdwmail.com.br`, para cliente novo) ou o nome próprio do cliente
        (`mail.dominiodele.com.br`, para cliente migrado). A segunda tem uma
        condição a mais — o nome precisa resolver para este servidor. MX
        apontando para `mail.dominiodele.com.br` enquanto esse nome ainda
        resolve para o servidor antigo é o pior estado possível: parece
        configurado e entrega no lugar errado.

        É a rota que mais reduz suporte: quase todo problema de entrega de
        cliente novo é DNS publicado errado, e sem isto o diagnóstico vira
        troca de e-mails. Para o DKIM, compara o TXT publicado com a chave
        privada que assina de verdade — divergência aí faz a mensagem sair,
        chegar, e falhar no destino sem erro nenhum do lado de cá.

        A consulta é feita na hora, com resultado em cache curto.
      x-acesso: admin_dominio
      parameters:
        - name: sem_cache
          in: query
          schema: { type: boolean, default: false }
      responses:
        '200':
          description: Diagnóstico do DNS.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiagnosticoDns' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /dominios/{uuid}/antispam:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    get:
      tags: [dominios]
      operationId: obter_dominios_por_uuid_antispam
      summary: Limite de spam e listas de remetente do domínio
      description: |
        O limite a partir do qual a mensagem vai para a quarentena ou é
        recusada, e os remetentes sempre liberados ou sempre recusados.

        `quarentena` e `recusa` em `null` querem dizer **usa o padrão da
        plataforma** — que vem junto, em `quarentena_padrao` e
        `recusa_padrao`. Null não é zero: zero na quarentena mandaria toda
        mensagem do domínio para a pasta Junk.
      x-acesso: revenda
      responses:
        '200':
          description: A configuração do domínio.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AntispamDominio' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    put:
      tags: [dominios]
      operationId: definir_dominios_por_uuid_antispam
      summary: Define o limite de spam do domínio
      description: |
        Mandar `null` num campo devolve aquele limite ao padrão da
        plataforma.

        **Subir o limite é uma troca, não uma melhora:** menos e-mail
        legítimo na quarentena, mais spam na caixa de entrada. E a `recusa` é
        irreversível — acima dela o remetente leva erro no SMTP e não tem
        para onde recorrer. Na faixa de quarentena o erro se desfaz, porque a
        mensagem está na pasta.

        **O efeito não é instantâneo:** vale em até cinco minutos, quando o
        Rspamd regenera a configuração a partir do banco.
      x-acesso: revenda
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                quarentena:
                  type: [number, 'null']
                  minimum: 1
                  maximum: 30
                  description: 'A partir de quantos pontos vai para a pasta Junk. Padrão 6.'
                recusa:
                  type: [number, 'null']
                  minimum: 5
                  maximum: 50
                  description: 'A partir de quantos pontos é recusada no SMTP. Padrão 15.'
      responses:
        '204': { description: Definido. }
        '422': { $ref: '#/components/responses/DadosInvalidos' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /dominios/{uuid}/antispam/listas:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    post:
      tags: [dominios]
      operationId: criar_dominios_por_uuid_antispam_listas
      summary: Põe um remetente na lista branca ou preta do domínio
      description: |
        `branca` libera: a mensagem continua sendo examinada e pontuada — o
        que aparece na quarentena —, mas não é mais quarentenada, recusada
        nem adiada. `preta` recusa no SMTP, qualquer que seja a pontuação.

        Aceita um endereço (`fulano@empresa.com.br`) ou um domínio inteiro
        (`@empresa.com.br`).

        **A liberação vale só neste domínio.** A lista de um cliente não
        alcança os outros — é por isso que ela é feita com `settings` do
        Rspamd, que casa remetente e destinatário juntos, e não com
        `multimap`, que casaria o remetente para todo mundo.

        **Cuidado com o que a liberação significa:** liberar um endereço
        libera **quem o forjar**, e forjar remetente é trivial num domínio
        sem DKIM e DMARC. A plataforma ainda não condiciona a liberação a
        SPF ou DKIM conferirem — ver a migração 33, que explica por quê.
      x-acesso: revenda
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tipo, remetente]
              properties:
                tipo:      { type: string, enum: [branca, preta] }
                remetente: { type: string, example: 'contato@fornecedor.com.br' }
                nota:      { type: string, description: 'Por que foi liberado. Para quem olhar daqui a um ano.' }
      responses:
        '201':
          description: Incluído.
          content:
            application/json:
              schema:
                type: object
                properties: { uuid: { type: string, format: uuid } }
        '422': { $ref: '#/components/responses/DadosInvalidos' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '409': { $ref: '#/components/responses/Conflito' }

  /dominios/{uuid}/antispam/listas/{entrada}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
      - name: entrada
        in: path
        required: true
        schema: { type: string, format: uuid }
        description: A entrada da lista a remover.
    delete:
      tags: [dominios]
      operationId: remover_dominios_por_uuid_antispam_listas_por_entrada
      summary: Tira um remetente da lista
      description: |
        O domínio vai na URL além da entrada: assim apagar a entrada de um
        domínio passando o uuid de outro não funciona, mesmo entre domínios
        da mesma revenda.
      x-acesso: revenda
      responses:
        '204': { description: Removido. }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /dominios/{uuid}/dkim:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    get:
      tags: [dominios]
      operationId: obter_dominios_por_uuid_dkim
      summary: Mostra o registro DKIM a publicar
      description: |
        O TXT pronto para colar no DNS do cliente. É o que transforma
        "configure o DKIM" em "copie isto e cole lá".
      x-acesso: admin_dominio
      responses:
        '200':
          description: Seletor e registro TXT.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RegistroDkim' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  # REMOVIDAS: POST /dominios/{uuid}/dkim e POST /dominios/{uuid}/dkim/ativar.
  #
  # O desenho delas estava certo — chave nova nasce com SELETOR novo, as duas
  # convivem, e a troca só vale depois do DNS publicado e conferido. O que não
  # está certo é QUEM faria isso: gerar chave exige escrever no volume das
  # chaves PRIVADAS de todos os domínios, e dá-lo à API significaria que o
  # processo exposto à internet passa a guardar a chave DKIM de cada cliente.
  #
  # Rotação continua sendo `make dkim`, que já é idempotente e recusa
  # sobrescrever chave existente (regra 6). Quando a fase 6 desenhar o
  # instalador, é o momento de decidir se vale um serviço próprio para isso.

  # =========================================================================
  # ADMINISTRAÇÃO — caixas e aliases
  # =========================================================================

  /caixas:
    get:
      tags: [caixas]
      operationId: obter_caixas
      summary: Lista caixas
      description: |
        O usuário comum enxerga uma caixa só: a dele. Não é caso especial na
        implementação — é o mesmo filtro de escopo, no nível mais restrito.
      x-acesso: usuario
      parameters:
        - $ref: '#/components/parameters/Pagina'
        - $ref: '#/components/parameters/PorPagina'
        - $ref: '#/components/parameters/Busca'
        - name: dominio
          in: query
          schema: { type: string, format: uuid }
        - name: ativo
          in: query
          schema: { type: boolean }
      responses:
        '200':
          description: Página de caixas.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Pagina'
                  - type: object
                    properties:
                      itens:
                        type: array
                        items: { $ref: '#/components/schemas/Caixa' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

    post:
      tags: [caixas]
      operationId: criar_caixas
      summary: Cria uma caixa
      description: |
        Recusado se estourar o limite de caixas da revenda ou do domínio.

        Recusado também (`409`) quando o endereço já existiu e o maildir da
        caixa anterior continua no servidor. Criar assim entregaria à pessoa
        nova a caixa inteira de quem saiu. A trava só sai quando um
        administrador resolve o órfão (`make maildirs-orfaos`).

        A senha é guardada como hash `{ARGON2ID}` e nunca volta em resposta
        nenhuma. Não existe rota que leia senha.
      x-acesso: admin_dominio
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CaixaEntrada' }
      responses:
        '201':
          description: Criada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Caixa' }
        '409': { $ref: '#/components/responses/Conflito' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /caixas/{uuid}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    get:
      tags: [caixas]
      operationId: obter_caixas_por_uuid
      summary: Detalha uma caixa
      x-acesso: usuario
      responses:
        '200':
          description: A caixa.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Caixa' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    patch:
      tags: [caixas]
      operationId: alterar_caixas_por_uuid
      summary: Altera uma caixa
      description: |
        O usuário comum altera só o próprio nome de exibição. Cota, estado e
        o papel de admin do domínio são do admin do domínio para cima —
        senão o usuário aumentaria a própria cota ou se promoveria.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CaixaEntrada' }
      responses:
        '200':
          description: Alterada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Caixa' }
        '403': { $ref: '#/components/responses/SemPermissao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    delete:
      tags: [caixas]
      operationId: remover_caixas_por_uuid
      summary: Remove uma caixa
      description: |
        Exige `?confirmar=endereco@completo`. Um usuário não apaga a própria
        caixa.

        As MENSAGENS não são apagadas: o maildir continua no servidor, porque
        a API não tem o volume de e-mail montado — ela é o processo exposto à
        internet. A remoção registra esse maildir como órfão, e a criação do
        MESMO endereço passa a ser recusada com `409` até que um administrador
        resolva no servidor (`make maildirs-orfaos`).

        Sem essa trava, recriar `pessoa@cliente.com` para quem substituiu a
        anterior entregaria a caixa inteira de quem saiu — que é o caso mais
        comum que existe.
      x-acesso: admin_dominio
      parameters:
        - name: confirmar
          in: query
          required: true
          schema: { type: string }
          description: O endereço completo, digitado por extenso.
      responses:
        '204': { description: Removida. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '409': { $ref: '#/components/responses/Conflito' }

  /caixas/{uuid}/entrar:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    post:
      tags: [caixas]
      operationId: criar_caixas_por_uuid_entrar
      summary: Abre uma sessão de suporte na caixa de outra pessoa
      description: |
        Entra no webmail do cliente sem a senha dele, como faz o DirectAdmin.
        A senha não é necessária nem é tocada: o usuário mestre do Dovecot já
        existe, e é o mesmo que a migração usa.

        **A sessão criada é diferente de uma sessão de login, e as diferenças
        são todas travas:**

        - **Nível `usuario`, sempre**, qualquer que seja o nível da caixa.
          Entrou para usar a caixa, não para administrar a plataforma. Sem
          isto, entrar na caixa de um administrador daria o poder dele.
        - **Não tem token de renovação e não grava cookie.** Não pode ser
          esticada — não por regra, mas porque a peça não existe. É também o
          que mantém intacta a sessão de quem entrou: com cookie, entrar na
          caixa do cliente derrubaria a própria sessão.
        - **Dura 30 minutos**, fixo e não configurável.
        - **Não encadeia:** a sessão resultante é `usuario`, e esta rota
          exige `revenda`.

        **Não se entra na caixa de alguém de nível igual ou maior**, nem
        entre iguais. Quem precisa da caixa de um par pede a senha — aí
        existe uma pessoa decidindo, não um clique.

        **Fica registrado antes de existir o token.** Se a auditoria falhar,
        ninguém entra. O registro é gravado no domínio da caixa ALVO, então
        o administrador daquele domínio o vê no painel dele — e toda ação
        feita dentro da sessão carrega `personificada_por` nos detalhes.
      x-acesso: revenda
      responses:
        '200':
          description: |
            Sessão aberta. O token vem só no corpo, nunca em cookie.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Sessao' }
        '403':
          $ref: '#/components/responses/SemPermissao'
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /caixas/{uuid}/senha:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    put:
      tags: [caixas]
      operationId: definir_caixas_por_uuid_senha
      summary: Troca a senha de uma caixa
      description: |
        O próprio usuário troca apresentando a senha atual. O admin do
        domínio troca sem ela — mas nesse caso **todas as sessões e senhas
        de aplicativo daquela caixa são revogadas**, porque a troca
        administrativa costuma ser resposta a comprometimento.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nova_senha]
              properties:
                senha_atual:
                  type: string
                  format: password
                  writeOnly: true
                  description: Obrigatória quando o próprio usuário troca.
                nova_senha:
                  type: string
                  format: password
                  writeOnly: true
                  minLength: 12
      responses:
        '204': { description: Trocada. }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /aliases:
    get:
      tags: [aliases]
      operationId: obter_aliases
      summary: Lista aliases
      x-acesso: admin_dominio
      parameters:
        - $ref: '#/components/parameters/Pagina'
        - $ref: '#/components/parameters/PorPagina'
        - $ref: '#/components/parameters/Busca'
        - name: dominio
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Página de aliases.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Pagina'
                  - type: object
                    properties:
                      itens:
                        type: array
                        items: { $ref: '#/components/schemas/Alias' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }

    post:
      tags: [aliases]
      operationId: criar_aliases
      summary: Cria um alias
      description: |
        A origem tem que pertencer ao domínio informado. Vários destinos para
        a mesma origem formam uma lista simples.

        Catch-all (`@dominio`) é aceito, com aviso: ele faz o servidor
        aceitar mensagem para qualquer endereço do domínio, inclusive
        inexistente, o que alimenta devolução em massa se o domínio virar
        alvo. As caixas reais ficam protegidas do curinga automaticamente.
      x-acesso: admin_dominio
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AliasEntrada' }
      responses:
        '201':
          description: Criado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Alias' }
        '409': { $ref: '#/components/responses/Conflito' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /aliases/{uuid}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    get:
      tags: [aliases]
      operationId: obter_aliases_por_uuid
      summary: Detalha um alias
      x-acesso: admin_dominio
      responses:
        '200':
          description: O alias.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Alias' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    patch:
      tags: [aliases]
      operationId: alterar_aliases_por_uuid
      summary: Altera um alias
      x-acesso: admin_dominio
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AliasEntrada' }
      responses:
        '200':
          description: Alterado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Alias' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    delete:
      tags: [aliases]
      operationId: remover_aliases_por_uuid
      summary: Remove um alias
      x-acesso: admin_dominio
      responses:
        '204': { description: Removido. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  # =========================================================================
  # ENTREGAS, FILA E QUARENTENA
  # =========================================================================

  /entregas:
    get:
      tags: [entregas]
      operationId: obter_entregas
      summary: Histórico de entregas, rejeições e devoluções
      description: |
        Responde a pergunta que mais chega no suporte: "mandei e não chegou,
        o que houve?". Escopado como todo o resto — a revenda vê os eventos
        dos domínios dela, o usuário vê os da própria caixa.
      x-acesso: usuario
      parameters:
        - $ref: '#/components/parameters/Pagina'
        - $ref: '#/components/parameters/PorPagina'
        - name: dominio
          in: query
          schema: { type: string, format: uuid }
        - name: tipo
          in: query
          schema:
            type: string
            enum: [recebido, entregue, adiado, rejeitado, bounce, quarentena, descartado]
        - name: endereco
          in: query
          schema: { type: string }
          description: Remetente ou destinatário, busca por trecho.
        - name: de
          in: query
          schema: { type: string }
          description: |
            Só o REMETENTE, busca por trecho. Separado de `endereco` porque a
            direção é a pergunta: "o que eu mandei chegou?" olha o que sai,
            "não me chegou nada dele" olha o que entra.
        - name: para
          in: query
          schema: { type: string }
          description: Só o DESTINATÁRIO, busca por trecho. Ver `de`.
        - name: desde
          in: query
          schema: { type: string, format: date-time }
        - name: ate
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: Página de eventos.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Pagina'
                  - type: object
                    properties:
                      itens:
                        type: array
                        items: { $ref: '#/components/schemas/EventoEntrega' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

  /fila:
    get:
      tags: [entregas]
      operationId: obter_fila
      summary: Mensagens paradas na fila do Postfix
      description: |
        O que ainda não saiu, e por quê. Diferente de `/entregas`, que é
        histórico: aqui é o presente.
      x-acesso: revenda
      parameters:
        - $ref: '#/components/parameters/Pagina'
        - $ref: '#/components/parameters/PorPagina'
      responses:
        '200':
          description: Itens na fila.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Pagina'
                  - type: object
                    properties:
                      itens:
                        type: array
                        items: { $ref: '#/components/schemas/ItemFila' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }

  # REMOVIDAS: DELETE /fila/{queue_id} e POST /fila/reprocessar.
  #
  # As duas exigem `postsuper`, e rodar postsuper a partir da API exigiria
  # dar a ela o socket do Docker — que é root na máquina. Essa troca já foi
  # recusada quando a visão de fila foi desenhada (ver CLAUDE.md, decisões da
  # etapa G): a API é o processo exposto à internet, e uma visão de fila não
  # vale esse preço.
  #
  # Ficaram declaradas aqui por engano e nunca foram implementadas — quem as
  # chamasse recebia o 404 genérico do roteador, idêntico ao de "não existe
  # essa mensagem". Operar a fila continua sendo trabalho de quem tem acesso
  # ao servidor.

  /quarentena:
    get:
      tags: [entregas]
      operationId: obter_quarentena
      summary: Lista mensagens em quarentena
      description: |
        A quarentena da plataforma é a pasta Junk da caixa — não há
        armazenamento separado. Assim o falso positivo também é recuperável
        de dentro do Thunderbird ou do Outlook, e não só por esta API.

        O usuário comum vê a própria caixa; revenda e admin de domínio veem
        as caixas do próprio escopo, pela mesma regra de fronteira das rotas
        administrativas.
      x-acesso: usuario
      parameters:
        - $ref: '#/components/parameters/Pagina'
        - $ref: '#/components/parameters/PorPagina'
        - name: dominio
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Página de mensagens retidas.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Pagina'
                  - type: object
                    properties:
                      itens:
                        type: array
                        items: { $ref: '#/components/schemas/MensagemQuarentena' }
                      sem_nota:
                        type: integer
                        description: |
                          Quantas das `total` NUNCA foram avaliadas pelo nosso
                          filtro — as que vieram de migração e aparecem com
                          `pontuacao: null`.

                          Vem do servidor e não é contado na tela de
                          propósito: contar na tela alcança só a PÁGINA. Com
                          13.126 em quarentena e 50 por página, o aviso dizia
                          "47 vieram de migração" quando eram 13.053 — número
                          certo sobre a página, e errado como resposta à
                          pergunta que o leitor faz.
        '401': { $ref: '#/components/responses/NaoAutenticado' }

  /quarentena/{uuid}/liberar:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    post:
      tags: [entregas]
      operationId: criar_quarentena_por_uuid_liberar
      summary: Entrega uma mensagem retida
      description: |
        Move a mensagem da quarentena (pasta Junk) para a caixa de entrada e
        ensina ao antispam que aquilo era legítimo.

        Fica registrado na auditoria quem liberou — é por aqui que entra
        phishing quando alguém libera sem olhar.

        O aprendizado é **por caixa**, nunca global: cada caixa treina o
        próprio classificador e não alcança ninguém. Com classificador global,
        qualquer caixa seria um canal para envenenar o filtro de todos os
        clientes. O Bayes só passa a influenciar a pontuação depois de 200
        lições da mesma caixa; abaixo disso a lição fica guardada e não faz
        efeito nenhum.

        Antispam fora do ar não faz esta rota falhar: a mensagem é liberada
        e a lição se perde, registrada na auditoria.
      x-acesso: usuario
      responses:
        '204': { description: Entregue. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /quarentena/{uuid}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    delete:
      tags: [entregas]
      operationId: remover_quarentena_por_uuid
      summary: Descarta uma mensagem retida
      x-acesso: usuario
      responses:
        '204': { description: Descartada. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  # =========================================================================
  # AUDITORIA
  # =========================================================================

  /auditoria:
    get:
      tags: [auditoria]
      operationId: obter_auditoria
      summary: Consulta o registro de ações
      description: |
        Somente leitura, para todo mundo. A auditoria não tem rota de
        escrita nem de remoção em nível nenhum: nem o super admin apaga o
        próprio rastro pela API.
      x-acesso: admin_dominio
      parameters:
        - $ref: '#/components/parameters/Pagina'
        - $ref: '#/components/parameters/PorPagina'
        - name: ator
          in: query
          schema: { type: string }
        - name: acao
          in: query
          schema: { type: string, example: dominio.criar }
        - name: resultado
          in: query
          schema: { type: string, enum: [sucesso, falha, negado] }
        - name: desde
          in: query
          schema: { type: string, format: date-time }
        - name: ate
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: Página de registros.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Pagina'
                  - type: object
                    properties:
                      itens:
                        type: array
                        items: { $ref: '#/components/schemas/RegistroAuditoria' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }

  # =========================================================================
  # CORREIO — o que o webmail consome
  # =========================================================================

  # =========================================================================
  # PREFERÊNCIAS DA PRÓPRIA CAIXA
  #
  # Não há uuid nestas rotas de propósito: o escopo é a SESSÃO. Cada um mexe
  # na sua, e não existe caminho para alcançar a preferência alheia.
  # =========================================================================

  /conta/assinatura:
    get:
      tags: [conta]
      operationId: obter_conta_assinatura
      summary: Assinatura de quem escreve
      description: |
        Texto acrescentado ao compor. Preferência de quem ESCREVE — não tem
        relação com Sieve, que age na entrega.
      x-acesso: usuario
      responses:
        '200':
          description: A assinatura.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Assinatura' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
    put:
      tags: [conta]
      operationId: alterar_conta_assinatura
      summary: Salva a assinatura
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Assinatura' }
      responses:
        '200':
          description: Salva.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Assinatura' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /conta/resposta-automatica:
    get:
      tags: [conta]
      operationId: obter_conta_resposta_automatica
      summary: Resposta automática
      description: |
        Caixa que nunca configurou devolve o padrão, **não 404** — não ter
        configurado é o estado normal, não um erro.
      x-acesso: usuario
      responses:
        '200':
          description: A configuração.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaAutomatica' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
    put:
      tags: [conta]
      operationId: alterar_conta_resposta_automatica
      summary: Salva a resposta automática
      description: |
        Gera o script Sieve e o publica no Dovecot por ManageSieve.

        O PERÍODO é decidido pelo servidor, não pelo Sieve: fora dele o
        comando `vacation` simplesmente não entra no script. É o que evita o
        clássico "voltei das férias há três semanas e ainda respondo que
        estou fora".
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RespostaAutomatica' }
      responses:
        '200':
          description: Salva e publicada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RespostaAutomatica' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /conta/filtros:
    get:
      tags: [conta]
      operationId: obter_conta_filtros
      summary: Filtros de entrega
      description: |
        Na ordem em que são avaliados. A ordem é regra de negócio: uma regra
        com `parar` impede as seguintes.
      x-acesso: usuario
      responses:
        '200':
          description: As regras.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Filtro' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
    put:
      tags: [conta]
      operationId: alterar_conta_filtros
      summary: Substitui a lista inteira de filtros
      description: |
        Substitui tudo, e não edita uma a uma, porque a ORDEM importa: mandar
        várias chamadas de reordenação deixaria a lista inconsistente se uma
        falhasse no meio. Aqui é transação — ou a lista nova vale inteira, ou
        a antiga continua.

        Depois de gravar, o Sieve é gerado e publicado. Um script que o
        Dovecot recusa volta como `422` com a mensagem do compilador: regra
        mal montada é erro de quem escreveu, não falha do servidor.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              maxItems: 100
              items: { $ref: '#/components/schemas/Filtro' }
      responses:
        '200':
          description: Salvos e publicados.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Filtro' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /maildirs-orfaos:
    get:
      tags: [caixas]
      operationId: obter_maildirs_orfaos
      summary: Caixas removidas cujo maildir continua em disco
      description: |
        Quando uma caixa é apagada, o maildir CONTINUA em disco e o endereço
        fica travado até alguém decidir o que fazer com ele. É proteção: sem
        ela, uma pessoa nova criada com o mesmo endereço herdaria a
        correspondência da anterior.

        Resolver isso só existia no terminal — quem apagasse uma caixa pelo
        painel recebia "resolva no servidor com `make maildirs-orfaos`", uma
        instrução que um revendedor não tem como cumprir.
      x-acesso: super_admin
      responses:
        '200':
          description: Os pendentes, do mais antigo para o mais novo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  itens:
                    type: array
                    items: { $ref: '#/components/schemas/MaildirOrfao' }

  /maildirs-orfaos/{uuid}/liberar:
    post:
      tags: [caixas]
      operationId: criar_maildirs_orfaos_liberar
      summary: Destrava o endereço, MANTENDO o maildir em disco
      description: |
        Libera o endereço para ser criado de novo. **Nada é apagado**: o
        maildir continua exatamente onde está, e quem herdar o endereço
        herda a caixa antiga.

        Apagar o e-mail em definitivo (`make maildirs-purgar`) continua só no
        terminal, de propósito: não tem desfazer, e a diferença entre as duas
        operações é fácil demais de errar num clique.
      x-acesso: super_admin
      parameters:
        - name: uuid
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '204': { description: Liberado. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /migracao/servidores:
    get:
      tags: [migracao]
      operationId: obter_migracao_servidores
      summary: Servidores de origem cadastrados
      description: |
        Coordenadas de um WHM/cPanel de origem: host, porta e usuário.

        **A CHAVE SSH NÃO FICA AQUI.** A API é o processo exposto à internet;
        guardar nela uma credencial root para o servidor do cliente é o mesmo
        raciocínio que fez a fila não ser lida do `postqueue` — só que o
        estrago sairia da nossa plataforma e alcançaria outra empresa.

        A chave vive no host e é montada apenas no processo que migra.
      x-acesso: super_admin
      responses:
        '200':
          description: Os servidores.
          content:
            application/json:
              schema:
                type: object
                properties:
                  itens:
                    type: array
                    items: { $ref: '#/components/schemas/ServidorOrigem' }
        '403': { $ref: '#/components/responses/SemPermissao' }
    post:
      tags: [migracao]
      operationId: criar_migracao_servidores
      summary: Cadastra um servidor de origem
      x-acesso: super_admin
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ServidorOrigemEntrada' }
      responses:
        '201':
          description: Cadastrado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ServidorOrigem' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /migracao/servidores/{uuid}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    delete:
      tags: [migracao]
      operationId: remover_migracao_servidores_por_uuid
      summary: Remove um servidor de origem
      x-acesso: super_admin
      responses:
        '204': { description: Removido. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /migracao/trabalhos:
    get:
      tags: [migracao]
      operationId: obter_migracao_trabalhos
      summary: Trabalhos de migração
      x-acesso: super_admin
      responses:
        '200':
          description: Os trabalhos, do mais recente ao mais antigo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  itens:
                    type: array
                    items: { $ref: '#/components/schemas/TrabalhoMigracao' }
        '403': { $ref: '#/components/responses/SemPermissao' }
    post:
      tags: [migracao]
      operationId: criar_migracao_trabalhos
      summary: Enfileira um trabalho
      description: |
        A API **enfileira**; quem executa é um processo separado, que tem a
        chave SSH e não escuta em porta nenhuma.

        **`dominios`** lista o servidor INTEIRO: cada domínio com e-mail, a
        conta do cPanel a que pertence, quantas caixas tem, se já existe
        aqui, e **quantas mensagens dele já foram trazidas** — é o que
        distingue "nunca migrei" de "migrei há dois dias e faltam as novas". É o primeiro passo — ele não pede `conta`, porque existe
        justamente para descobri-la.

        Sem `dominio`, `conferir` descobre quais domínios a conta tem.

        **`conferir`** só lê. **`contas`** cria o domínio, as caixas com o hash
        e a cota da origem, e os encaminhamentos — sem mover mensagem nenhuma.

        `contas` **nunca sobrescreve**: caixa que já existe aqui é pulada e
        relatada. E é retomável — rodar de novo cria só o que falta.

        **`mensagens`** copia o conteúdo das caixas, com as datas de
        recebimento e as marcas de lida e respondida originais. Ele **não
        altera nada na origem**: lê os arquivos do Maildir com `rsync`
        puxando, sem usar o Dovecot de lá.

        `mensagens` também não duplica. **Cada mensagem trazida vira uma
        linha** em `cdw.migracao_mensagens`, identificada pelo nome do
        arquivo no Maildir de origem — sem o sufixo das marcas, que muda
        quando alguém lê ou responde.

        É isso que permite **remigrar**. Um domínio grande leva dois dias, e
        nesses dois dias o cliente continua recebendo; rodar de novo no dia
        da virada de MX traz só o que chegou depois. Também é o que faz uma
        cópia interrompida no meio ser retomável em vez de recomeçada.

        O registro é da CAIXA, não da pasta: se alguém moveu a mensagem de
        pasta na origem entre duas execuções, ela não vem de novo — viria
        como segunda cópia, em outro lugar da mesma caixa. A divergência
        aparece em `movidas_na_origem`, no relatório.

        Comece por `caixa`, uma só. Conferir contagem e datas em uma caixa
        custa minutos; descobrir um erro depois de horas de cópia custa a
        migração inteira.
      x-acesso: super_admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [servidor_uuid]
              properties:
                servidor_uuid: { type: string, format: uuid }
                conta:         { type: string, description: 'A conta do cPanel — o nome, ou o caminho /home/NOME. Dispensável no estágio `dominios`.' }
                dominio:       { type: string, description: 'Vazio = listar os domínios da conta.' }
                estagio:       { type: string, enum: [dominios, conferir, completo, contas, mensagens, contatos, verificar, liberar, testar, imap, indexar], default: conferir }
                caixa:
                  type: string
                  description: |
                    Só no estágio `mensagens`: limita a UMA caixa (a parte
                    antes do @, ou o endereço completo). Vazio = o domínio
                    inteiro.
                revenda_uuid:
                  type: string
                  format: uuid
                  description: |
                    De quem é o cliente. **Obrigatório no estágio `contas`** — o
                    cPanel não sabe isso, e é a única coisa que a tela precisa
                    perguntar para criar o domínio sozinha.
                origem_usuario:
                  type: string
                  description: |
                    Só nos estágios `testar` e `imap`: o login na origem IMAP.
                origem_senha:
                  type: string
                  format: password
                  description: |
                    Só nos estágios `testar` e `imap`: a senha da caixa NA ORIGEM.
                    Ela é **selada** com a chave pública da migração antes de tocar
                    o banco — a API sabe fechar e não sabe abrir; quem abre é o
                    trabalhador, que não escuta porta nenhuma. Nunca aparece em
                    relatório, progresso ou auditoria.
                    O destino **não pede senha**: ele é o nosso próprio servidor, e
                    o trabalhador entra com a chave-mestra do Dovecot.
      responses:
        '202':
          description: Enfileirado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrabalhoMigracao' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
        '409': { $ref: '#/components/responses/Conflito' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /migracao/trabalhos/{uuid}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    delete:
      tags: [migracao]
      operationId: remover_migracao_trabalhos_por_uuid
      summary: Tira um trabalho da fila
      description: Recusado com `409` quando ele já está rodando.
      x-acesso: super_admin
      responses:
        '204': { description: Removido. }
        '409': { $ref: '#/components/responses/Conflito' }

  /contatos:
    get:
      tags: [contatos]
      operationId: obter_contatos
      summary: A agenda da caixa
      description: |
        A agenda é de cada CAIXA, não do domínio: contato é dado pessoal.

        A ordem é por CONVERSA, não alfabética — quem se fala mais e mais
        recentemente vem antes. No autocompletar é a diferença entre a
        primeira sugestão servir ou não.
      x-acesso: usuario
      parameters:
        - $ref: '#/components/parameters/Busca'
      responses:
        '200':
          description: Os contatos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  itens:
                    type: array
                    items: { $ref: '#/components/schemas/Contato' }
    post:
      tags: [contatos]
      operationId: criar_contatos
      summary: Salva um contato à mão
      description: |
        Salvar um contato que a captura automática já trouxe NÃO é erro: é a
        pessoa adotando aquele contato. Ele deixa de ser `automatico`, e a
        partir daí a captura não mexe mais no nome dele.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContatoEntrada' }
      responses:
        '201':
          description: Salvo.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contato' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /contatos/{uuid}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    patch:
      tags: [contatos]
      operationId: alterar_contatos_por_uuid
      summary: Altera um contato
      description: >-
        Editar promove o contato a "meu" — a captura automática não volta a
        mexer no nome dele.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContatoEntrada' }
      responses:
        '200':
          description: Alterado.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contato' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    delete:
      tags: [contatos]
      operationId: remover_contatos_por_uuid
      summary: Apaga um contato
      description: |
        Sem confirmação digitada, ao contrário de caixa e domínio: apagar um
        contato não apaga e-mail nenhum, e ele volta sozinho na próxima troca
        de mensagens. O gesto é barato e reversível.
      x-acesso: usuario
      responses:
        '204': { description: Apagado. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /correio/pastas:
    get:
      tags: [correio]
      operationId: obter_correio_pastas
      summary: Lista as pastas da caixa
      description: Traz o uso de cota junto, para a interface não precisar de segunda chamada.
      x-acesso: usuario
      responses:
        '200':
          description: Pastas e cota.
          content:
            application/json:
              schema:
                type: object
                properties:
                  pastas:
                    type: array
                    items: { $ref: '#/components/schemas/Pasta' }
                  cota: { $ref: '#/components/schemas/Cota' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
    post:
      tags: [correio]
      operationId: criar_correio_pastas
      summary: Cria uma pasta
      description: |
        O nome não pode conter `/`, `\\` nem aspas, e não pode começar com
        ponto: a barra é o separador de hierarquia do namespace, e no formato
        Maildir++ um nome iniciado por ponto vira arquivo oculto e some da
        lista.

        A pasta é criada **e assinada**. Cliente de e-mail que lista só as
        assinadas não mostraria a recém-criada, e o usuário concluiria que ela
        não foi criada.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nome]
              properties:
                nome: { type: string, maxLength: 100 }
      responses:
        '201':
          description: Criada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  nome: { type: string }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /correio/pastas/{nome}:
    parameters:
      - name: nome
        in: path
        required: true
        description: Nome da pasta, escapado para a URL.
        schema: { type: string }
    patch:
      tags: [correio]
      operationId: alterar_correio_pastas_por_nome
      summary: Renomeia uma pasta
      description: |
        **Pasta do sistema não se renomeia** (`INBOX`, `Sent`, `Drafts`,
        `Trash`, `Junk`, `Archive`). Elas têm SPECIAL-USE, e o webmail, o
        Sieve e os clientes de e-mail as procuram pelo PAPEL. Renomear
        `Sent` quebraria o "guardar em enviados" de todo programa que a pessoa
        usa — em silêncio, e dias depois.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nome]
              properties:
                nome: { type: string, maxLength: 100 }
      responses:
        '200':
          description: Renomeada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  nome: { type: string }
        '409':
          $ref: '#/components/responses/Conflito'
        '422': { $ref: '#/components/responses/DadosInvalidos' }
    delete:
      tags: [correio]
      operationId: remover_correio_pastas_por_nome
      summary: Apaga uma pasta
      description: |
        Apagar a pasta apaga **as mensagens dentro dela**. Sem
        `confirmado=true`, uma pasta com mensagens responde `409` — é o que
        permite à interface perguntar antes, sabendo quantas são.

        Pasta do sistema não se apaga: sem a Lixeira, o botão de excluir para
        de funcionar.
      x-acesso: usuario
      parameters:
        - name: confirmado
          in: query
          schema: { type: boolean, default: false }
      responses:
        '204': { description: Apagada. }
        '409': { $ref: '#/components/responses/Conflito' }

  /correio/mensagens:
    get:
      tags: [correio]
      operationId: obter_correio_mensagens
      summary: Lista mensagens de uma pasta
      description: |
        Lê do índice em `mensagens_idx`, **não do IMAP**. Foi a decisão que
        viabilizou o webmail: falar IMAP a cada clique foi o gargalo que
        inviabilizou o Roundcube.

        Paginação por cursor opaco, e não por deslocamento: a caixa recebe
        mensagem enquanto o usuário navega, e paginar por número faria
        mensagens pularem ou repetirem a cada página.
      x-acesso: usuario
      parameters:
        - name: pasta
          in: query
          required: true
          schema: { type: string, default: INBOX }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limite'
        - name: nao_lidas
          in: query
          schema: { type: boolean }
        - name: marcadas
          in: query
          schema: { type: boolean }
        - name: com_anexo
          in: query
          schema: { type: boolean }
        - name: agrupar_conversa
          in: query
          schema: { type: boolean, default: true }
      responses:
        '200':
          description: Página de mensagens.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginaCursor'
                  - type: object
                    properties:
                      itens:
                        type: array
                        items: { $ref: '#/components/schemas/MensagemResumo' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

    post:
      tags: [correio]
      operationId: criar_correio_mensagens
      summary: Envia uma mensagem
      description: |
        Entrega ao Postfix pela porta de submissão, autenticando como o
        usuário da sessão. A API não é um caminho paralelo de envio: passa
        pelo mesmo limite de envio, mesma assinatura DKIM e mesmo antivírus
        que um cliente de e-mail comum.

        Não se pode enviar com remetente que não seja a própria caixa, ou um
        alias que aponte para ela.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MensagemEnvio' }
      responses:
        '202':
          description: Aceita para entrega.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id: { type: string }
                  queue_id:   { type: string }
        '413':
          description: Mensagem maior que o limite.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problema' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }
        '429': { $ref: '#/components/responses/ExcessoDeTentativas' }

  /correio/mensagens/busca:
    get:
      tags: [correio]
      operationId: obter_correio_mensagens_busca
      summary: Busca mensagens
      description: |
        Busca por palavra no assunto e no remetente, e por trecho de
        endereço. Sempre dentro da própria caixa — o índice tem o
        `usuario_id` embutido, então a fronteira é do banco, não da consulta.
      x-acesso: usuario
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2 }
        - name: pasta
          in: query
          schema: { type: string, description: Ausente busca em todas. }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limite'
        - name: de
          in: query
          schema: { type: string }
        - name: para
          in: query
          schema: { type: string }
        - name: desde
          in: query
          schema: { type: string, format: date }
        - name: ate
          in: query
          schema: { type: string, format: date }
      responses:
        '200':
          description: Resultados.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginaCursor'
                  - type: object
                    properties:
                      itens:
                        type: array
                        items: { $ref: '#/components/schemas/MensagemResumo' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

  /correio/mensagens/marcar:
    post:
      tags: [correio]
      operationId: criar_correio_mensagens_marcar
      summary: Marca VÁRIAS mensagens de uma vez
      description: |
        Lida/não lida, estrela e marcadores, para até 500 mensagens.

        Existe porque o `PATCH /mensagens/{uuid}` abre **uma sessão IMAP por
        chamada**: marcar cinquenta mensagens como lidas pelo caminho
        individual são cinquenta sessões, cinquenta idas de rede e cinquenta
        gravações — para uma operação que a pessoa percebe como única. Aqui
        é uma sessão só, agrupando por pasta de origem.

        **Os marcadores aqui são ACRESCENTAR e TIRAR**, e não a lista
        completa como no PATCH individual. A diferença é deliberada: em lote
        "a lista completa" não existe, porque as mensagens selecionadas têm
        marcadores diferentes entre si. Quem seleciona vinte e põe
        "urgente" quer acrescentar urgente, não substituir tudo por urgente.

        Um pedido que não pede nada responde **422**, e não 200: um 200 ali
        faria a interface confirmar sucesso de uma operação que não
        aconteceu.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mensagens]
              properties:
                mensagens:
                  type: array
                  maxItems: 500
                  items: { type: string, format: uuid }
                lida:    { type: boolean }
                marcada: { type: boolean }
                acrescentar_marcadores:
                  type: array
                  items: { type: string }
                tirar_marcadores:
                  type: array
                  items: { type: string }
      responses:
        '200':
          description: |
            Quantas mudaram, e quais falharam. Falha parcial NÃO é erro: numa
            operação em lote, uma mensagem que sumiu no meio do caminho não
            pode desfazer as outras quarenta e nove.
          content:
            application/json:
              schema:
                type: object
                properties:
                  alteradas: { type: integer }
                  falhas:
                    type: array
                    items:
                      type: object
                      properties:
                        uuid:   { type: string }
                        motivo: { type: string }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /correio/mensagens/{uuid}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    get:
      tags: [correio]
      operationId: obter_correio_mensagens_por_uuid
      summary: Lê uma mensagem
      description: |
        O corpo vem do IMAP, sob demanda — o índice guarda só o que a lista
        precisa. O HTML volta já higienizado: sem script, sem conteúdo
        externo, com os endereços de imagem trocados por um proxy que só
        carrega quando o usuário pedir. Mensagem de e-mail é conteúdo
        hostil por padrão.
      x-acesso: usuario
      parameters:
        - name: marcar_lida
          in: query
          schema: { type: boolean, default: true }
        - name: carregar_imagens
          in: query
          schema: { type: boolean, default: false }
      responses:
        '200':
          description: A mensagem.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Mensagem' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    patch:
      tags: [correio]
      operationId: alterar_correio_mensagens_por_uuid
      summary: Altera marcadores de uma mensagem
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                lida:       { type: boolean }
                marcada:    { type: boolean }
                marcadores: { type: array, items: { type: string } }
      responses:
        '200':
          description: Alterada.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MensagemResumo' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }
    delete:
      tags: [correio]
      operationId: remover_correio_mensagens_por_uuid
      summary: Apaga uma mensagem
      description: Move para a lixeira. Já estando nela, apaga de vez.
      x-acesso: usuario
      parameters:
        - name: definitivo
          in: query
          schema: { type: boolean, default: false }
      responses:
        '204': { description: Apagada. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /correio/mensagens/{uuid}/conversa:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    get:
      tags: [correio]
      operationId: obter_correio_mensagens_por_uuid_conversa
      summary: A conversa inteira a que a mensagem pertence
      description: |
        Todas as mensagens do mesmo `thread_id`, **em todas as pastas**, da mais
        antiga para a mais nova. É o que permite ver a própria resposta ao abrir
        a mensagem recebida, em vez de ter que ir até "Enviados" procurar.

        A conversa é derivada de `References`, `In-Reply-To` e `Message-ID` no
        momento da indexação — não de assunto igual, que juntaria mensagens sem
        relação nenhuma.

        Rascunhos podem não aparecer: enquanto não são enviados eles ainda não
        têm os cabeçalhos que ligam à conversa.

        Uma mensagem sem `thread_id` (remetente que não mandou `Message-ID`)
        devolve a si mesma, nunca lista vazia.
      x-acesso: usuario
      responses:
        '200':
          description: A conversa, da mais antiga para a mais nova.
          content:
            application/json:
              schema:
                type: object
                properties:
                  thread_id: { type: string }
                  itens:
                    type: array
                    items: { $ref: '#/components/schemas/MensagemResumo' }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /correio/mensagens/{uuid}/responder:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    post:
      tags: [correio]
      operationId: criar_correio_mensagens_por_uuid_responder
      summary: Responde uma mensagem
      description: Mantém `In-Reply-To` e `References`, que é o que preserva a conversa no cliente de quem recebe.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/MensagemEnvio'
                - type: object
                  properties:
                    a_todos: { type: boolean, default: false }
      responses:
        '202':
          description: Aceita para entrega.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id: { type: string }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /correio/mensagens/{uuid}/encaminhar:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    post:
      tags: [correio]
      operationId: criar_correio_mensagens_por_uuid_encaminhar
      summary: Encaminha uma mensagem
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/MensagemEnvio'
                - type: object
                  properties:
                    manter_anexos: { type: boolean, default: true }
      responses:
        '202':
          description: Aceita para entrega.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id: { type: string }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /correio/mensagens/mover:
    post:
      tags: [correio]
      operationId: criar_correio_mensagens_mover
      summary: Move mensagens de pasta
      description: Em lote, porque a interface seleciona várias. Uma chamada por mensagem seria dezenas de idas ao servidor.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mensagens, pasta_destino]
              properties:
                mensagens:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { type: string, format: uuid }
                pasta_destino: { type: string }
      responses:
        '200':
          description: Resultado por mensagem.
          content:
            application/json:
              schema:
                type: object
                properties:
                  movidas:  { type: integer }
                  falhas:
                    type: array
                    items:
                      type: object
                      properties:
                        uuid:   { type: string, format: uuid }
                        motivo: { type: string }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

  /correio/mensagens/{uuid}/anexos/{parte}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
      - name: parte
        in: path
        required: true
        schema: { type: string }
        description: Identificador da parte MIME, como veio na mensagem.
    get:
      tags: [correio]
      operationId: obter_correio_mensagens_por_uuid_anexos_por_parte
      summary: Baixa um anexo
      description: |
        Sempre com `Content-Disposition: attachment` e
        `Content-Type: application/octet-stream`, mesmo quando o tipo
        declarado é outro. Servir um anexo com o tipo que ele afirma ter
        permite que um HTML hostil execute no domínio do webmail.
      x-acesso: usuario
      responses:
        '200':
          description: O anexo.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /correio/arquivos:
    get:
      tags: [correio]
      operationId: listar_correio_arquivos
      summary: Lista os arquivos grandes guardados
      description: |
        O que esta caixa tem guardado, com o estado de cada link. Quem
        administra vê os das caixas no seu escopo.

        O campo `estado` é calculado no servidor — `ativo`, `vencido`,
        `revogado` ou `apagado` — de propósito: três telas decidindo
        "vencido" por conta própria divergem.
      x-acesso: usuario
      responses:
        '200':
          description: Os arquivos.
          content:
            application/json:
              schema:
                type: object
                properties:
                  itens:
                    type: array
                    items:
                      type: object
                      properties:
                        uuid:      { type: string, format: uuid }
                        caixa:     { type: string, format: email }
                        nome:      { type: string }
                        bytes:     { type: integer, format: int64 }
                        criado_em: { type: string, format: date-time }
                        expira_em: { type: string, format: date-time }
                        estado:    { type: string, enum: [ativo, vencido, revogado, apagado] }
                        baixas:    { type: integer }
    post:
      tags: [correio]
      operationId: criar_correio_arquivos
      summary: Sobe um arquivo grande e devolve o link de download
      description: |
        Para o arquivo que NÃO cabe como anexo. O teto do anexo é 25 MB: o
        Postfix aceita 50 MB por mensagem, mas Gmail e Outlook recusam acima
        de 25–35 MB, e o base64 que leva o anexo no JSON engorda tudo em 33%.

        **O corpo é `multipart/form-data`, não JSON**, e é a única rota desta
        API que não é JSON. Em base64, 180 MB de arquivo virariam 240 MB de
        texto na memória do servidor; em multipart o corpo é lido em fluxo e
        vai direto para o disco.

        O link devolvido aponta para **outro domínio**, nunca o do webmail —
        arquivo escolhido por terceiro servido na mesma origem da sessão faz
        o CSP, o cookie e o isolamento entre clientes deixarem de valer. O
        download é público: quem recebe a mensagem não é usuário desta
        plataforma, e exigir login transformaria um anexo em cadastro. O que
        protege é um token de 32 bytes aleatórios, guardado como hash, com
        prazo de validade.

        **409** quando a caixa estoura o teto de volume guardado, ou quando a
        instalação não tem o domínio de download configurado.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [arquivo]
              properties:
                arquivo: { type: string, format: binary }
      responses:
        '201':
          description: O arquivo subiu; use `url` no corpo da mensagem.
          content:
            application/json:
              schema:
                type: object
                properties:
                  uuid:      { type: string, format: uuid }
                  nome:      { type: string }
                  bytes:     { type: integer, format: int64 }
                  expira_em: { type: string, format: date-time }
                  url:       { type: string, description: O endereço de download, pronto para colar. }
        '409': { $ref: '#/components/responses/Conflito' }
        '422': { $ref: '#/components/responses/DadosInvalidos' }

  /correio/arquivos/{uuid}:
    parameters:
      - $ref: '#/components/parameters/Uuid'
    delete:
      tags: [correio]
      operationId: remover_correio_arquivos_por_uuid
      summary: Revoga o link de um arquivo grande
      description: |
        Mata o link **imediatamente**, sem esperar o prazo. O download passa a
        responder `410` na próxima tentativa, porque ele confere a revogação
        antes de abrir o arquivo.

        O arquivo sai do disco na próxima passagem da varredura, mas o
        ACESSO morre na hora — esperar a varredura para cortar o acesso daria
        quinze minutos a quem estivesse abusando.

        **Por que esta rota é obrigatória e não conveniência:** uma caixa
        comprometida vira hospedagem de malware com o domínio do cliente
        dando credibilidade ao arquivo. Sem ela, a única saída seria SQL à
        mão — numa urgência, o mesmo que não ter saída.

        Responde `404` para arquivo de outra caixa fora do seu escopo: o que
        não é seu não existe.
      x-acesso: usuario
      responses:
        '204': { description: Revogado. }
        '404': { $ref: '#/components/responses/NaoEncontrado' }

  /correio/rascunhos:
    post:
      tags: [correio]
      operationId: criar_correio_rascunhos
      summary: Salva um rascunho
      description: Chamado automaticamente enquanto o usuário escreve; devolve o mesmo `uuid` a cada gravação.
      x-acesso: usuario
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/MensagemEnvio'
                - type: object
                  properties:
                    uuid: { type: string, format: uuid }
      responses:
        '200':
          description: Rascunho salvo.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MensagemResumo' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }

  # =========================================================================
  # SISTEMA
  # =========================================================================

  /saude:
    get:
      tags: [sistema]
      operationId: obter_saude
      summary: Estado dos componentes
      x-acesso: publico
      security: []
      responses:
        '200':
          description: Tudo respondendo.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Saude' }
        '503':
          description: Algum componente fora do ar.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Saude' }
        '400': { $ref: '#/components/responses/DadosInvalidos' }

  /versao:
    get:
      tags: [sistema]
      operationId: obter_versao
      summary: Versão instalada e estado da licença
      x-acesso: super_admin
      responses:
        '200':
          description: Versão e licença.
          content:
            application/json:
              schema:
                type: object
                properties:
                  versao:    { type: string }
                  licenca:   { $ref: '#/components/schemas/Licenca' }
        '401': { $ref: '#/components/responses/NaoAutenticado' }
        '403': { $ref: '#/components/responses/SemPermissao' }

# ===========================================================================
components:
# ===========================================================================

  securitySchemes:
    tokenDeSessao:
      type: http
      scheme: bearer
      description: |
        Token **opaco**, conferido no banco a cada requisição — não é JWT.

        A escolha é deliberada. Com JWT, suspender uma revenda deixaria as
        sessões dela funcionando até o token expirar, e a fronteira entre
        clientes não pode ter janela. Aqui, suspender apaga o acesso na
        requisição seguinte. O custo é uma consulta por requisição, indexada,
        contra um processo que já vai ao banco de qualquer forma.

  parameters:
    Uuid:
      name: uuid
      in: path
      required: true
      schema: { type: string, format: uuid }
    Pagina:
      name: pagina
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    PorPagina:
      name: por_pagina
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Busca:
      name: q
      in: query
      schema: { type: string }
      description: Busca por trecho, nos campos de texto do recurso.
    FiltroStatus:
      name: status
      in: query
      schema: { type: string, enum: [ativo, suspenso, cancelado, removido] }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
      description: Cursor opaco, vindo de `proximo_cursor`. Não interpretar o conteúdo.
    Limite:
      name: limite
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }

  responses:
    NaoAutenticado:
      description: Sem token válido.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problema' }
    SemPermissao:
      description: |
        Autenticado, mas o nível não alcança a operação.

        Usado só quando o recurso está DENTRO do escopo e a ação é que não é
        permitida — por exemplo, um usuário tentando mudar a própria cota.
        Recurso de outro cliente responde `404`, para não confirmar que existe.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problema' }
    NaoEncontrado:
      description: |
        Não existe, ou está fora do escopo de quem pediu — a resposta é a
        mesma nos dois casos, de propósito.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problema' }
    Conflito:
      description: A operação contraria o estado atual (duplicidade, limite estourado, dependência).
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problema' }
    DadosInvalidos:
      description: Corpo mal formado ou campo inválido.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problema' }
    ExcessoDeTentativas:
      description: Limite de tentativas atingido.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problema' }

  schemas:

    Problema:
      type: object
      description: RFC 9457.
      properties:
        type:   { type: string, format: uri, example: 'https://cdwtech.com.br/erros/limite-excedido' }
        title:  { type: string, example: Limite de caixas da revenda atingido }
        status: { type: integer, example: 409 }
        detail: { type: string }
        instance: { type: string }
        erros:
          type: object
          description: Mensagens por campo, quando o erro é de validação.
          additionalProperties:
            type: array
            items: { type: string }

    Pagina:
      type: object
      properties:
        pagina:      { type: integer }
        por_pagina:  { type: integer }
        total:       { type: integer }
        total_paginas: { type: integer }

    PaginaCursor:
      type: object
      properties:
        proximo_cursor:
          type: [string, 'null']
          description: Ausente ou nulo quando acabou.
        tem_mais: { type: boolean }

    Nivel:
      type: string
      enum: [super_admin, revenda, admin_dominio, usuario]
      description: |
        Define o escopo: o que a sessão enxerga em TODA consulta. Está na
        coluna `usuarios.nivel` — a mesma lista que o `x-acesso` de cada
        operação declara. Duas fontes para a mesma verdade divergem.

    Sessao:
      type: object
      properties:
        token:      { type: string }
        refresh:    { type: string }
        expira_em:  { type: string, format: date-time }
        nivel:      { $ref: '#/components/schemas/Nivel' }
        caixa:      { type: string, format: email }
        nome:       { type: string }
        vaga:
          type: integer
          minimum: 0
          maximum: 4
          description: |
            Qual VAGA do navegador esta conta ocupa. A vaga vira o caminho do
            cookie de renovação, e é o que permite duas contas abertas em duas
            abas sem uma sobrescrever a credencial da outra.

            A aba guarda este número e renova em `/auth/u/{vaga}/refresh`.

            **Ausente** nas sessões criadas antes das vagas (26/10/2026) e nas
            de personificação, que não gravam cookie nenhum. Quem não recebe
            continua no caminho antigo, `/auth/refresh`.
        escopo:
          type: object
          description: O que esta sessão alcança. A interface usa para decidir o que mostrar; o servidor não confia nisto.
          properties:
            revenda_uuid: { type: [string, 'null'], format: uuid }
            dominio_uuid: { type: [string, 'null'], format: uuid }
        personificada_por:
          type: [string, 'null']
          format: email
          description: |
            Quando presente, esta sessão foi aberta por um administrador NA
            CAIXA DE OUTRA PESSOA, e este é o e-mail dele.

            A interface **precisa** mostrar isso de forma impossível de não
            ver: quem está na tela não é o dono da caixa, e tudo que for
            feito ali sai no nome do dono. Ausente em sessão normal.

    SessaoAberta:
      type: object
      properties:
        uuid:        { type: string, format: uuid }
        criado_em:   { type: string, format: date-time }
        ultimo_uso:  { type: string, format: date-time }
        ip:          { type: string }
        user_agent:  { type: string }
        atual:       { type: boolean }

    SenhaApp:
      type: object
      properties:
        uuid:       { type: string, format: uuid }
        descricao:  { type: string }
        criado_em:  { type: string, format: date-time }
        ultimo_uso: { type: [string, 'null'], format: date-time }

    Revenda:
      type: object
      properties:
        uuid:            { type: string, format: uuid }
        nome:            { type: string }
        slug:            { type: string }
        documento:       { type: [string, 'null'] }
        email_contato:   { type: string, format: email }
        telefone:        { type: [string, 'null'] }
        limite_dominios: { type: integer, description: '0 = ilimitado.' }
        limite_caixas:   { type: integer, description: '0 = ilimitado.' }
        status:          { type: string, enum: [ativo, suspenso, cancelado] }
        uso:
          type: object
          properties:
            dominios: { type: integer }
            caixas:   { type: integer }
        criado_em:   { type: string, format: date-time }
        alterado_em: { type: string, format: date-time }

    RevendaEntrada:
      type: object
      properties:
        nome:            { type: string, minLength: 1 }
        slug:            { type: string, pattern: '^[a-z0-9]([a-z0-9-]*[a-z0-9])?$' }
        documento:       { type: string }
        email_contato:   { type: string, format: email }
        telefone:        { type: string }
        limite_dominios: { type: integer, minimum: 0 }
        limite_caixas:   { type: integer, minimum: 0 }
        status:          { type: string, enum: [ativo, suspenso, cancelado] }

    Dominio:
      type: object
      properties:
        uuid:         { type: string, format: uuid }
        nome:         { type: string, example: cliente.com.br }
        revenda_uuid: { type: string, format: uuid }
        revenda_nome: { type: string }
        cota_bytes:              { type: integer, format: int64, description: 'Teto do domínio. 0 = ilimitado.' }
        cota_padrao_caixa_bytes: { type: integer, format: int64 }
        max_caixas:   { type: integer }
        dkim_seletor: { type: string }
        status:       { type: string, enum: [ativo, suspenso, removido] }
        uso:
          type: object
          properties:
            caixas:      { type: integer }
            bytes_usados: { type: integer, format: int64 }
        dns_ok:
          type: [boolean, 'null']
          description: Resultado da última verificação de DNS; nulo se nunca verificado.
        criado_em:   { type: string, format: date-time }
        alterado_em: { type: string, format: date-time }

    DominioEntrada:
      type: object
      properties:
        nome:
          type: string
          description: Só na criação. O nome é imutável depois.
        revenda_uuid:            { type: string, format: uuid }
        cota_bytes:              { type: integer, format: int64, minimum: 0 }
        cota_padrao_caixa_bytes: { type: integer, format: int64, minimum: 0 }
        max_caixas:              { type: integer, minimum: 0 }
        status:                  { type: string, enum: [ativo, suspenso, removido] }

    DiagnosticoDns:
      type: object
      properties:
        dominio:       { type: string }
        verificado_em: { type: string, format: date-time }
        tudo_certo:    { type: boolean }
        mx:    { $ref: '#/components/schemas/ConferenciaDns' }
        spf:   { $ref: '#/components/schemas/ConferenciaDns' }
        dkim:  { $ref: '#/components/schemas/ConferenciaDns' }
        dmarc: { $ref: '#/components/schemas/ConferenciaDns' }
        ptr:
          allOf:
            - $ref: '#/components/schemas/ConferenciaDns'
            - description: |
                Do IP do servidor, não do domínio do cliente. É do provedor
                do IP, e costuma ser o item mais demorado de resolver.
        host_mail:
          allOf:
            - $ref: '#/components/schemas/ConferenciaDns'
            - description: |
                O registro A de `mail.dominiodocliente.com.br`.

                OPCIONAL por natureza: o domínio funciona inteiro pelos nomes
                da plataforma. Serve para o Outlook já configurado e a senha
                salva no navegador continuarem valendo depois da migração,
                sem ninguém reconfigurar nada.

                Deixa de ser opcional quando o MX aponta para ele — aí o MX
                depende deste registro para resolver.
        host_webmail:
          allOf:
            - $ref: '#/components/schemas/ConferenciaDns'
            - description: |
                O registro A de `webmail.dominiodocliente.com.br`. Opcional,
                e nunca obrigatório: ninguém entrega e-mail por ele.
        certificado:
          allOf:
            - $ref: '#/components/schemas/ConferenciaDns'
            - description: |
                **Não é DNS**, e está aqui assim mesmo: esta é a tela onde se
                olha para saber se o domínio está pronto.

                Acusa o estado que ninguém via — **o DNS já aponta para cá e
                o certificado ainda não foi emitido**. Quem abrir o webmail
                do cliente nesse estado vê erro de segurança. O
                `apollonodontologia.com.br` passou três dias assim, em
                23–26/10/2026, e quem percebeu foi o cliente.

                A conferência pergunta ao nginx, por SNI, QUAL certificado
                ele entrega para `mail.` e `webmail.` do cliente — o mesmo
                que o navegador faz. Não se lê o disco: a API não monta
                `/etc/letsencrypt` (lá estão as chaves privadas de todos os
                clientes), e um certificado que existe em arquivo mas não
                está sendo servido apareceria como correto numa checagem de
                arquivo.

                Fica **opcional** enquanto `host_mail` e `host_webmail` não
                apontarem para cá: emitir antes disso falharia e gastaria
                cota do Let's Encrypt.

    ChaveApi:
      type: object
      properties:
        uuid:    { type: string, format: uuid }
        nome:    { type: string }
        prefixo:
          type: string
          description: |
            A parte pública da chave, que identifica qual é sem revelar o
            segredo. É o que permite saber qual revogar.
        revenda:      { type: string }
        revenda_uuid: { type: string, format: uuid }
        criada_por:   { type: string, format: email }
        criado_em:    { type: string, format: date-time }
        usada_em:
          type: string
          format: date-time
          description: |
            Última vez que a chave autenticou. Responde "esta chave ainda
            serve para alguma coisa?" antes de revogar — e denuncia uma
            chave esquecida viva há meses.
        ativa:       { type: boolean }
        revogada_em: { type: string, format: date-time }

    BloqueioLogin:
      type: object
      properties:
        uuid:  { type: string, format: uuid }
        tipo:
          type: string
          enum: [conta, ip]
          description: |
            `conta` é quem tentou muitas senhas numa caixa; `ip` é quem
            tentou uma senha em muitas caixas. Confundir os dois leva o
            suporte a destravar a coisa errada — liberar a conta não adianta
            quando quem está barrado é o IP.
        alvo:       { type: string, description: O endereço de e-mail ou o IP. }
        dominio:    { type: string, description: Vazio em bloqueio de IP. }
        ip:         { type: string }
        tentativas: { type: integer }
        criado_em:  { type: string, format: date-time }
        expira_em:  { type: string, format: date-time }
        ativo:      { type: boolean, description: Está barrado NESTE instante. }
        liberado_em:  { type: string, format: date-time }
        liberado_por: { type: string, format: email }

    ConferenciaDns:
      type: object
      properties:
        situacao:
          type: string
          enum: [ok, ausente, divergente, erro]
        encontrado:
          type: [string, 'null']
          description: O que está publicado hoje.
        esperado:
          type: [string, 'null']
          description: O que a plataforma precisa que esteja publicado.
        explicacao:
          type: string
          description: Em português, para o cliente entender sem saber o que é DNS.
        opcional:
          type: boolean
          description: |
            Quando verdadeiro, a AUSÊNCIA deste registro não é problema e a
            tela não deve pintá-lo de vermelho.

            Não vale para `divergente`: registro que existe e aponta para
            outro servidor é pior que registro que não existe — é Outlook
            configurado lendo caixa velha.

    RegistroDkim:
      type: object
      properties:
        seletor:  { type: string, example: cdw }
        nome:     { type: string, example: cdw._domainkey }
        tipo:     { type: string, example: TXT }
        valor:    { type: string, description: Valor pronto para colar no painel de DNS. }
        ativo:    { type: boolean }
        publicado_e_conferido:
          type: boolean
          description: O TXT publicado corresponde à chave privada que assina.

    Caixa:
      type: object
      properties:
        uuid:             { type: string, format: uuid }
        email:            { type: string, format: email }
        nome_exibicao:    { type: [string, 'null'] }
        dominio_uuid:     { type: string, format: uuid }
        dominio:          { type: string }
        cota_bytes:
          type: [integer, 'null']
          format: int64
          description: 'Nulo herda a cota padrão do domínio; 0 é ilimitada.'
        cota_efetiva_bytes: { type: integer, format: int64 }
        cota_usada_bytes:   { type: integer, format: int64 }
        ativo:            { type: boolean }
        nivel:            { $ref: '#/components/schemas/Nivel' }
        dois_fatores:     { type: boolean }
        ultimo_login_em:  { type: [string, 'null'], format: date-time }
        criado_em:        { type: string, format: date-time }
        alterado_em:      { type: string, format: date-time }

    CaixaEntrada:
      type: object
      properties:
        email:         { type: string, format: email, description: Só na criação. }
        nome_exibicao: { type: string }
        senha:         { type: string, format: password, writeOnly: true, minLength: 12 }
        cota_bytes:
          type: [integer, 'null']
          format: int64
          minimum: 0
          description: |
            Três estados, e são diferentes entre si:

            * **ausente** — não mexe na cota;
            * **`null`** — volta a HERDAR a cota padrão do domínio;
            * **número** — cota própria da caixa; `0` é ilimitada.

            Sem o `null`, uma caixa que ganhou cota própria nunca mais voltava
            a acompanhar o padrão do domínio.
        ativo:         { type: boolean }
        nivel:
          $ref: '#/components/schemas/Nivel'
          description: |
            Só quem está ACIMA do nível concedido pode concedê-lo. Sem isso um
            admin de domínio se promoveria a revenda pela própria API.

    Alias:
      type: object
      properties:
        uuid:         { type: string, format: uuid }
        origem:       { type: string, example: vendas@cliente.com.br }
        destino:      { type: string, format: email }
        dominio_uuid: { type: string, format: uuid }
        descricao:    { type: [string, 'null'] }
        ativo:        { type: boolean }
        catch_all:    { type: boolean, description: Verdadeiro quando a origem é `@dominio`. }
        criado_em:    { type: string, format: date-time }

    AliasEntrada:
      type: object
      properties:
        origem:       { type: string }
        destino:      { type: string, format: email }
        dominio_uuid: { type: string, format: uuid }
        descricao:    { type: string }
        ativo:        { type: boolean }

    EventoEntrega:
      type: object
      properties:
        uuid:         { type: string, format: uuid }
        tipo:         { type: string, enum: [recebido, entregue, adiado, rejeitado, bounce, quarentena, descartado] }
        remetente:    { type: [string, 'null'] }
        destinatario: { type: [string, 'null'] }
        dominio:      { type: [string, 'null'] }
        codigo:       { type: [string, 'null'], example: 5.1.1 }
        codigo_smtp:  { type: [integer, 'null'], example: 550 }
        resposta:     { type: [string, 'null'], description: 'A resposta do servidor remoto, literal.' }
        explicacao:   { type: string, description: 'O mesmo, em português, para quem não lê SMTP.' }
        acao:
          type: string
          description: |
            O que FAZER a respeito, em português, ou vazio quando não há nada a
            fazer. É a frase que evita o chamado — e, no caso `adiado`, a que
            evita o erro caro: reenviar cria duas cópias quando o servidor do
            destino volta.
            Nenhum código SMTP aparece aqui nem em `explicacao`: se quem lê
            precisa pesquisar o que viu, a frase falhou.
        relay:        { type: [string, 'null'] }
        ocorrido_em:  { type: string, format: date-time }

    ItemFila:
      type: object
      properties:
        queue_id:     { type: string }
        remetente:    { type: string }
        destinatarios: { type: array, items: { type: string } }
        tamanho_bytes: { type: integer }
        na_fila_desde: { type: string, format: date-time }
        ultimo_erro:   { type: [string, 'null'] }

    AntispamDominio:
      type: object
      properties:
        dominio: { type: string }
        quarentena:
          type: [number, 'null']
          description: '`null` = usa o padrão da plataforma. NÃO é zero.'
        recusa:
          type: [number, 'null']
          description: '`null` = usa o padrão da plataforma.'
        quarentena_padrao: { type: number, example: 6 }
        recusa_padrao:     { type: number, example: 15 }
        listas:
          type: array
          items:
            type: object
            properties:
              uuid:       { type: string, format: uuid }
              tipo:       { type: string, enum: [branca, preta] }
              remetente:  { type: string }
              nota:       { type: string }
              criado_por: { type: string }
              criado_em:  { type: string, format: date-time }

    MensagemQuarentena:
      type: object
      properties:
        uuid:         { type: string, format: uuid }
        remetente:    { type: string }
        destinatario: { type: string }
        assunto:      { type: string }
        pontuacao:
          type: [number, 'null']
          description: |
            A nota do antispam. **`null` quer dizer NÃO AVALIADA**, e isso é
            diferente de zero.

            Mensagem trazida por migração entra na caixa por IMAP, sem passar
            por Postfix nem Rspamd — então ela nunca recebeu nota nenhuma. A
            API devolvia `0` nesses casos, o que a tela mostrava como "0.0":
            exatamente o oposto da verdade, porque 0 significa "o filtro olhou
            e achou limpa" e a mensagem está na quarentena.

            Quem desenha tem que separar os dois estados. Sem nota não é nota
            baixa.
        motivos:      { type: array, items: { type: string }, description: 'Regras que pesaram na decisão, como o antispam as nomeia. Para o painel técnico.' }
        motivos_explicados:
          type: array
          items: { type: string }
          description: |
            As mesmas regras em frases, para quem RECEBE o e-mail — "o dono do
            domínio diz que esta mensagem não saiu de um servidor dele".
            Só os símbolos conhecidos viram frase: o antispam tem centenas e
            ganha novos a cada versão, e inventar significado para um símbolo
            desconhecido seria pior que calar. Pode vir mais curta que `motivos`.
        recebida_em:  { type: string, format: date-time }

    RegistroAuditoria:
      type: object
      properties:
        uuid:        { type: string, format: uuid }
        ator:        { type: string, format: email }
        ator_nivel:  { $ref: '#/components/schemas/Nivel' }
        acao:        { type: string, example: caixa.remover }
        objeto_tipo: { type: [string, 'null'] }
        objeto_id:   { type: [string, 'null'] }
        resultado:   { type: string, enum: [sucesso, falha, negado] }
        ip:          { type: [string, 'null'] }
        detalhes:    { type: object, additionalProperties: true }
        criado_em:   { type: string, format: date-time }

    Assinatura:
      type: object
      properties:
        texto: { type: string, maxLength: 4000 }

    RespostaAutomatica:
      type: object
      properties:
        ativa:    { type: boolean }
        assunto:  { type: string }
        mensagem: { type: string, maxLength: 4000 }
        inicio:   { type: string, description: 'AAAA-MM-DD, ou vazio.' }
        fim:      { type: string, description: 'AAAA-MM-DD, ou vazio.' }
        intervalo_dias:
          type: integer
          minimum: 1
          maximum: 60
          default: 7
          description: |
            De quantos em quantos dias repetir para o MESMO remetente.
            Responder a cada mensagem transforma uma conversa em dois robôs.

    Filtro:
      type: object
      required: [campo, operador, valor, acao]
      properties:
        uuid:     { type: string, format: uuid, readOnly: true }
        nome:     { type: string }
        ativo:    { type: boolean, default: true }
        ordem:    { type: integer, readOnly: true }
        campo:    { type: string, enum: [de, para, assunto, corpo] }
        operador: { type: string, enum: [contem, igual, comeca, termina] }
        valor:    { type: string, maxLength: 500 }
        acao:
          type: string
          enum: [mover, marcar_lida, marcar_estrela, apagar, encaminhar]
        destino:
          type: string
          description: Pasta para `mover`, endereço para `encaminhar`.
        parar:
          type: boolean
          default: false
          description: Interrompe a avaliação das regras seguintes.

    Pasta:
      type: object
      properties:
        nome:        { type: string, example: INBOX }
        rotulo:      { type: string, example: Caixa de entrada }
        uso_especial: { type: [string, 'null'], example: '\Sent' }
        total:       { type: integer }
        nao_lidas:   { type: integer }

    Cota:
      type: object
      properties:
        usado_bytes:  { type: integer, format: int64 }
        limite_bytes: { type: integer, format: int64, description: '0 = ilimitada.' }
        percentual:   { type: number }

    MensagemResumo:
      type: object
      description: O que a lista de mensagens precisa, e nada além. Vem de `mensagens_idx`.
      properties:
        uuid:           { type: string, format: uuid }
        pasta:          { type: string }
        assunto:        { type: [string, 'null'] }
        remetente:      { type: string }
        remetente_nome: { type: [string, 'null'] }
        destinatarios:  { type: array, items: { type: string } }
        preview:        { type: [string, 'null'] }
        data:           { type: string, format: date-time }
        tamanho_bytes:  { type: integer }
        tem_anexo:      { type: boolean }
        lida:           { type: boolean }
        respondida:     { type: boolean }
        marcada:        { type: boolean }
        rascunho:       { type: boolean }
        marcadores:     { type: array, items: { type: string } }
        thread_id:      { type: [string, 'null'] }
        na_conversa:    { type: integer, description: Quantas mensagens a conversa tem. }

    Mensagem:
      allOf:
        - $ref: '#/components/schemas/MensagemResumo'
        - type: object
          properties:
            corpo_texto: { type: [string, 'null'] }
            corpo_html:
              type: [string, 'null']
              description: Já higienizado; sem script e sem conteúdo externo.
            imagens_bloqueadas:
              type: integer
              description: Quantas imagens externas foram retidas.
            anexos:
              type: array
              items: { $ref: '#/components/schemas/Anexo' }
            cabecalhos:
              type: object
              additionalProperties: { type: string }
            autenticacao:
              type: object
              description: Como a mensagem se saiu nos exames — é o que permite mostrar ao usuário se o remetente é quem diz ser.
              properties:
                spf:   { type: string, enum: [pass, fail, softfail, neutral, none, temperror, permerror] }
                dkim:  { type: string, enum: [pass, fail, none] }
                dmarc: { type: string, enum: [pass, fail, none] }
                pontuacao_spam: { type: number }

    Anexo:
      type: object
      properties:
        parte:         { type: string }
        nome:          { type: string }
        tipo_declarado: { type: string, example: application/pdf }
        tamanho_bytes: { type: integer }
        embutido:      { type: boolean }

    MensagemEnvio:
      type: object
      required: [para, assunto]
      properties:
        de:
          type: string
          format: email
          description: Ausente usa a própria caixa. Só é aceito endereço da própria caixa ou alias que aponte para ela.
        para:      { type: array, items: { type: string, format: email }, minItems: 1 }
        cc:        { type: array, items: { type: string, format: email } }
        cco:       { type: array, items: { type: string, format: email } }
        responder_para: { type: string, format: email }
        assunto:   { type: string }
        corpo_texto: { type: string }
        corpo_html:  { type: string }
        anexos:
          type: array
          items:
            type: object
            required: [nome, conteudo]
            properties:
              nome:     { type: string }
              tipo:     { type: string }
              conteudo: { type: string, format: byte, description: Base64. }
        prioridade: { type: string, enum: [baixa, normal, alta], default: normal }
        origem:
          type: string
          format: uuid
          description: |
            A mensagem sendo respondida. Só faz sentido em `/correio/rascunhos`
            — nas rotas de responder e encaminhar a origem já está no caminho.

            Sem ela o rascunho de uma resposta não carrega `In-Reply-To` e fica
            FORA da conversa: aparece solto na pasta Rascunhos em vez de junto
            da mensagem que está sendo respondida.
        rascunho_id:
          type: string
          description: |
            Identificador da JANELA de escrita, gerado pelo cliente e igual em
            todos os salvamentos automáticos da mesma redação.

            APPEND não substitui: sem isto cada salvamento deixava um rascunho
            novo, e enviar não apagava nenhum — a mensagem ficava em Enviados
            E em Rascunhos, duas vezes na mesma conversa. O servidor usa este
            valor para achar e apagar o rascunho anterior, num cabeçalho
            `X-CDW-Rascunho` que NÃO vai na mensagem enviada.

    ServidorOrigem:
      type: object
      properties:
        uuid:        { type: string, format: uuid }
        nome:        { type: string }
        host:        { type: string }
        porta:       { type: integer }
        usuario:     { type: string }
        tipo:
          type: string
          enum: [whm, imap]
          description: |
            `whm` entra por SSH com a nossa chave e traz tudo — hash de senha,
            cota, encaminhador, resposta automática e agenda. `imap` entra com a
            senha de UMA caixa e traz só as mensagens; a caixa precisa já existir
            aqui, e o cliente troca de senha.
        observacoes: { type: string }
        migracoes:   { type: integer, description: 'Quantos trabalhos já usaram este servidor.' }
        criado_em:   { type: [string, 'null'], format: date-time }

    ServidorOrigemEntrada:
      type: object
      required: [nome, host]
      properties:
        nome:        { type: string }
        tipo:        { type: string, enum: [whm, imap], default: whm }
        host:        { type: string, description: 'Endereço ou IP do WHM, ou do servidor IMAP de origem.' }
        porta:       { type: integer, default: 22 }
        usuario:     { type: string, default: root }
        observacoes: { type: string }

    MaildirOrfao:
      type: object
      properties:
        uuid:         { type: string, format: uuid }
        email:        { type: string }
        dominio:      { type: string }
        caminho:      { type: string, description: 'Onde o maildir está, em disco.' }
        removido_por: { type: string }
        criado_em:    { type: string, format: date-time }

    TrabalhoMigracao:
      type: object
      properties:
        uuid:     { type: string, format: uuid }
        servidor: { type: string }
        host:     { type: string }
        conta:    { type: string }
        dominio:  { type: string }
        estagio:  { type: string, enum: [dominios, conferir, completo, contas, mensagens, contatos, verificar, liberar, testar, imap, indexar] }
        estado:
          type: string
          enum: ['na fila', rodando, concluido, parcial, falhou, cancelado]
          description: |
            `parcial` é o estado do meio, e existe por um erro real: uma
            migração terminou com 94% das mensagens de uma caixa faltando —
            ela encheu — e o painel escreveu **concluído**, porque as falhas
            estavam dentro do relatório e não no erro de execução. Quem
            olhasse viraria o MX em cima de uma caixa incompleta.

            `falhou` seria igualmente mentira: 22 das 23 caixas vieram
            inteiras. Terminar não é o mesmo que dar certo.
        erro:     { type: string }
        pedido_por: { type: string }
        relatorio:
          type: [object, 'null']
          description: |
            O resultado do estágio. Para `conferir`, é um resumo DERIVADO —
            contagens e consequências, nunca os hashes de senha das caixas do
            cliente, que não podem ir para uma coluna que a tela lê.
        progresso:
          type: [object, 'null']
          description: |
            Onde o trabalho está AGORA — só enquanto `estado` é `rodando`;
            nulo antes de começar e depois de terminar.

            Existe porque trazer centenas de gigabytes leva horas, e
            "indo bem", "muito lento" e "travado" davam exatamente a mesma
            tela. É gravado de tempos em tempos, nunca a cada mensagem.
          properties:
            fase:          { type: string, enum: ['lendo a origem', 'copiando arquivos', importando] }
            caixa:         { type: string }
            caixas_feitas: { type: integer }
            caixas_total:  { type: integer }
            pasta:         { type: string }
            pasta_feitas:  { type: integer }
            pasta_total:   { type: integer }
            caixa_feitas:  { type: integer, description: 'Mensagens já passadas NESTA caixa, somando as pastas.' }
            caixa_total:   { type: integer, description: 'Total de mensagens da caixa. É a unidade que mede trabalho de verdade — pasta não serve, porque uma tem 4.400 mensagens e outra tem zero.' }
            importadas:    { type: integer }
            puladas:       { type: integer }
            bytes:         { type: integer, format: int64 }
            em:            { type: string, format: date-time }
        criado_em:   { type: [string, 'null'], format: date-time }
        terminou_em: { type: [string, 'null'], format: date-time }

    Contato:
      type: object
      properties:
        uuid:        { type: string, format: uuid }
        email:       { type: string, format: email }
        nome:        { type: string }
        telefone:    { type: string }
        empresa:     { type: string }
        observacoes: { type: string }
        automatico:
          type: boolean
          description: |
            `true` = entrou sozinho ao você enviar uma mensagem para este
            endereço. `false` = você salvou ou editou à mão.

            A tela pode separar "meus contatos" de "pessoas com quem você já
            falou", e a captura automática nunca sobrescreve o que foi
            digitado à mão.
        interacoes:  { type: integer, description: Quantas mensagens você já enviou a este endereço. }
        ultima_interacao_em: { type: [string, 'null'], format: date-time }
        criado_em:   { type: [string, 'null'], format: date-time }

    ContatoEntrada:
      type: object
      properties:
        email:       { type: string, format: email, description: Obrigatório na criação. }
        nome:        { type: string }
        telefone:    { type: string }
        empresa:     { type: string }
        observacoes: { type: string }

    Saude:
      type: object
      properties:
        estado: { type: string, enum: [ok, degradado, fora] }
        componentes:
          type: object
          additionalProperties:
            type: object
            properties:
              estado:   { type: string, enum: [ok, fora] }
              detalhe:  { type: [string, 'null'] }
              latencia_ms: { type: [number, 'null'] }

    Licenca:
      type: object
      properties:
        cliente:         { type: string }
        valida_ate:      { type: string, format: date-time }
        limite_dominios: { type: integer }
        limite_caixas:   { type: integer }
        situacao:        { type: string, enum: [valida, vencida, invalida, ausente] }
        aviso:
          type: [string, 'null']
          description: |
            Licença vencida degrada com aviso e NUNCA apaga dado do cliente.
