Pular para conteúdo

0023 — E-mail de usuário guarda a maiúscula, e quem digita em minúsculas não entra nem recupera a senha

Status CORRIGIDO
Branch fix/0023-email-com-maiuscula
Aberto em 2026-10-09
Severidade ALTA — em produção, com cliente
Apps afetados api
Revisão do diagnóstico Aprovado na rodada 2
Revisão da correção —

Status: RASCUNHO → DIAGNOSTICADO → EM CORREÇÃO → CORRIGIDO

O fix atravessa dois portões, cada um revisado por um subagente de contexto limpo, em até 3 rodadas. Máquina do plugin superpowers, régua em .claude/rubrics/. Qualquer Critical reprova; Important em aberto reprova salvo decisão registrada do usuário.

  1. O diagnóstico (seções 1 a 4) é revisado na seção 5 (reprodução × causa raiz).
  2. A correção (o diff e os testes) é revisada na seção 11 (correção × regressão).

1. Sintoma

Relato do usuário (2026-10-09), em produção: pessoas cadastradas com o e-mail começando em maiúscula — Bruno@solarimoveis.adm.br — não conseguem recuperar a senha digitando o e-mail em minúsculas, que é como digitam. A tela de Esqueci minha senha responde que enviou, e o e-mail nunca chega. O login tem o mesmo defeito: em minúsculas, credenciais inválidas (401).

Em produção (srv01, leitura do banco em 2026-10-09), das 7 contas de usuário, 2 ativas têm maiúscula no e-mail, as duas da garantidora TXC Garantia Ltda:

  • Bruno@solarimoveis.adm.br — ADMIN_TENANT, e-mail ainda não verificado;
  • Adm.cisperimoveis@gmail.com — AGENCY_USER, criada em 2026-10-08.

E há uma terceira conta com o mesmo e-mail em minúsculas: adm.cisperimoveis@gmail.com, VIEWER, criada e removida em 2026-10-09 — o par que a seção 4 explica.

Os logs da API das 24h anteriores não têm 500 nem exceção: o defeito não aparece como erro, só como "não acho você".

2. Reprodução

Reproduzida no container do dev-remote, com um teste descartável sobre a suíte (SQLite em memória) — não versionado:

  1. Criar um usuário com POST /api/v1/users, email = "Bruno@Solar.com.br". A API responde 201 e grava Bruno@solar.com.br.
  2. POST /api/v1/auth/login com Bruno@solar.com.br → 200.
  3. POST /api/v1/auth/login com bruno@solar.com.br → 401.
  4. POST /api/v1/auth/forgot-password com Bruno@solar.com.br → 202, um e-mail enviado.
  5. POST /api/v1/auth/forgot-password com bruno@solar.com.br → 202, nenhum e-mail enviado.

  6. Ambiente: produção (relato e dados) e dev-remote (reprodução); qualquer ambiente.

  7. Superfície: independe de desktop ou mobile — o defeito é da API. Os campos de e-mail do web (login, cadastro, esqueci a senha, convites, usuários) já são type="email"; a maiúscula entra porque a pessoa a digita, não por o teclado capitalizar.
  8. Dados/pré-condições: uma conta cujo e-mail tenha qualquer maiúscula antes do @.

3. Esperado × Atual

  • Esperado: o e-mail identifica a pessoa sem depender de caixa. Bruno@… e bruno@… são a mesma conta: entra, recupera a senha, e não pode ser cadastrado de novo com outra caixa.
  • Atual: a conta só é achada se o e-mail vier com a mesma caixa com que foi gravado, na parte antes do @. Login falha com 401; a recuperação responde 202 sem enviar nada; e o mesmo endereço com outra caixa é aceito como conta nova.

4. Causa raiz

Onde. Em duas pontas que só juntas produzem o defeito:

  1. A entrada não normaliza a caixa da parte local. Todo campo de e-mail que identifica usuário passa pelo tipo AppEmail (apps/api/app/schemas/common.py:11-27): login, cadastro, verificação, reenvio de código, esqueci a senha (apps/api/app/schemas/auth.py:18, :53, :77, :88, :109), cadastro de usuário (apps/api/app/schemas/user.py:32), convites de membro e de imobiliária (apps/api/app/schemas/onboarding.py:163, :186) e convite de garantidora (apps/api/app/schemas/tenant_invite.py:25). O validador devolve validate_email(...).normalized (apps/api/app/schemas/common.py:22), e a biblioteca email_validator põe em minúsculas só o domínio — a parte antes do @ é preservada, porque o padrão do e-mail a trata como sensível a caixa. Medido no container: validate_email("Bruno@Solar.com.br").normalized == "Bruno@solar.com.br". Fora de produção, o atalho dos domínios .test (common.py:14-18) faz o mesmo: baixa só o domínio.
  2. As buscas comparam por igualdade exata. User.email == email em apps/api/app/repositories/user_repo.py:32 (get_by_email, o do login), :43 (get_by_email_incluindo_excluidos) e :58 (email_em_uso, a checagem de duplicidade); e TenantInvite.email == email em apps/api/app/repositories/tenant_invite_repo.py:35.

Não é regressão: o validador é o mesmo desde o primeiro commit da API (767a8db, ajustado em 21ec96a), e nenhuma busca por e-mail jamais ignorou a caixa.

O mecanismo. O e-mail é gravado com a caixa que a pessoa digitou antes do @. Depois, a busca compara o que chega — também com a caixa digitada, porque passa pelo mesmo validador — contra o gravado, byte a byte:

  • login (apps/api/app/services/auth_service.py:45): get_by_email com bruno@… não acha Bruno@…, e o service responde 401 como credencial inválida;
  • esqueci a senha (auth_service.py:128): não acha, e sai em silêncio — é a RN2 da spec 0001, que responde 202 igual para e-mail inexistente para não revelar quem tem conta. Por isso a pessoa vê "enviamos o link" e nada chega;
  • verificação de e-mail e reenvio de código (apps/api/app/services/onboarding_service.py:105 e :130): o mesmo get_by_email, a mesma falha;
  • duplicidade, nas cinco chamadas de email_em_uso — cadastro público (onboarding_service.py:70), convite de membro da equipe (onboarding_service.py:249), convite de usuário de imobiliária no caminho sem reconvite (onboarding_service.py:506), criação de usuário pela tela Equipe (apps/api/app/services/user_service.py:52) e convite de garantidora (apps/api/app/services/tenant_invite_service.py:62): adm.cisperimoveis@… não colide com Adm.cisperimoveis@…, e a conta nova é criada.

É o que permitiu o par da seção 1. A auditoria de produção mostra o convite da conta ativa (AGENCY_USER_INVITED, 2026-10-08) e a remoção da duplicada (USER_DELETED, 2026-10-09), as duas por uma usuária da garantidora. A criação da duplicada aparece como ASSIGN_USER com at_creation: true e papel VIEWER, no mesmo instante em que a conta nasceu (2026-10-09 12:39:23) — que é o evento que POST /users grava ao criar com papel (user_service.py:102-117); o convite de membro só o grava no aceite, e a conta nunca foi ativada. Foi, portanto, a tela Equipe, e a checagem que deixou passar é a de user_service.py:52 — a mesma comparação exata das outras quatro; - reconvite de imobiliária (onboarding_service.py:485, get_by_email_incluindo_excluidos) e convite pendente de garantidora (apps/api/app/services/tenant_invite_service.py:71, get_pending_by_email): não acham o registro anterior se a caixa mudou, e o tratam como inexistente.

O alcance.

  • Os pontos acima são todos os que buscam usuário ou convite por e-mail — onze chamadas: get_by_email em auth_service.py:45 e :128 e em onboarding_service.py:105 e :130; get_by_email_incluindo_excluidos em onboarding_service.py:485; email_em_uso nas cinco da duplicidade; e get_pending_by_email em tenant_invite_service.py:71. Procurado por User.email ==, TenantInvite.email ==, get_by_email, email_em_uso, get_pending_by_email e email.lower() em apps/api/app inteiro.
  • Já ignoram a caixa, e não são defeito: a busca da lista de equipe (user_repo.py:216, func.lower) e a reserva do convite de garantidora no cadastro (tenant_invite_service.py:169, invite.email.lower() != user.email.lower()).
  • Comparam exato, sem efeito hoje: o seed do admin (apps/api/app/db/init_db.py:21, por SEED_ADMIN_EMAIL, minúsculo em produção) e o seed do e2e (apps/api/app/db/seed_e2e.py:137, e-mails do próprio seed, todos minúsculos).
  • O dado já gravado. Corrigir só a entrada não basta: as contas existentes com maiúscula continuariam inalcançáveis por quem digita em minúsculas. Em produção são as duas da seção 1. E a coluna é unique (apps/api/app/models/user.py:37) incluindo removidas, então baixar a caixa de Adm.cisperimoveis@… colide com a removida adm.cisperimoveis@…. Convites de garantidora em produção: nenhum com maiúscula.
  • O mesmo AppEmail valida e-mails de contato que não identificam ninguém — do inquilino (apps/api/app/schemas/renter.py:51, :72), da garantidora (apps/api/app/schemas/tenant.py:42, :47, :57, :69), da imobiliária (apps/api/app/schemas/partner_agency.py:40, :45, :67, :79) e do onboarding (onboarding.py:118, :122, :132, :144). Os de onboarding.py:48, :63 e :73 são de identidade, mas são cópias sem uso — o router de autenticação importa os de schemas/auth.py; o tipo compartilhado os cobre igual. Ali a caixa não quebra nada, porque ninguém busca por eles; normalizar o tipo compartilhado os põe em minúsculas também, o que é inofensivo.
  • O e-mail do admin semeado (apps/api/app/db/init_db.py:30) vem de SEED_ADMIN_EMAIL sem passar pelo validador; em produção ele é minúsculo.
  • Web: sem defeito próprio — envia o que a pessoa digita, e é a API que decide como gravar e comparar.

5. Revisão do diagnóstico

Preenchido pelo laço do /fix new, rubrica .claude/rubrics/diagnostico.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 0 2 1 Reprovado — o alcance listava duas das cinco checagens de duplicidade e se dizia completo; a origem da conta duplicada de produção era atribuída ao cadastro público, que não cria VIEWER. Corrigidos lendo o código e a auditoria de produção
2 0 0 2 Aprovado — os dois Minor corrigidos: a origem da duplicada confirmada pela auditoria (ASSIGN_USER com at_creation, logo POST /users), e a classificação dos e-mails de onboarding.py

Resultado: aprovado na rodada 2.

Pendências aceitas: nenhuma.

6. Correção

A correção vive inteira na API, nas duas pontas da causa: a entrada passa a gravar a forma canônica, e o dado já gravado é trazido para ela.

apps/api

  • Onde: schema e migration.
  • O quê:
  • _validate_app_email (apps/api/app/schemas/common.py) devolve o e-mail sem espaço nas pontas e todo em minúsculas, no caminho do validate_email e no atalho dos domínios .test. Como login, recuperação, verificação, reenvio, cadastro e convites passam todos pelo mesmo tipo, o que chega para comparar e o que se grava passam a ter a mesma forma. Os repositórios continuam comparando com ==, e é intencional: a forma canônica é o invariante; espalhar func.lower pelas onze chamadas seria consertar a comparação e deixar o dado divergindo.
  • Migration q1e3m5a7i9l2_email_sem_caixa, só de dado, em três passos:

    1. aborta, sem alterar nada, se duas contas não removidas só diferirem na caixa — qual fica com o endereço é decisão de gente;
    2. libera o endereço da conta removida que colide com outra, acrescentando #removido-<id> (que não é e-mail válido, então nunca mais colide nem entra). Em produção é a adm.cisperimoveis@gmail.com, conforme decidido pelo usuário;
    3. baixa a caixa (e tira espaço das pontas) em users.email e tenant_invites.email.

    O downgrade não devolve a caixa antiga, e diz por quê: o schema não muda, o código anterior funciona com e-mail minúsculo, e devolver o endereço à removida recriaria a colisão. Voltar o código é o caminho de volta.

apps/web

Sem impacto. O campo já é type="email" e envia o que a pessoa digita; quem decide a forma gravada e comparada é a API.

apps/e2e

Sem impacto. O fluxo inteiro (cadastro → login → recuperação) é exercitado pela API em apps/api/tests/test_email_sem_caixa.py, sem depender de tela; não há comportamento de layout a medir.

7. Risco / impacto colateral

  • E-mails de contato (inquilino, garantidora, imobiliária) passam a ser gravados em minúsculas quando criados ou editados, porque usam o mesmo tipo. Ninguém busca por eles; os já gravados não são tocados pela migration.
  • A parte local do e-mail é, pelo padrão, sensível a caixa. Nenhum provedor real a trata assim, e o produto já não deixava dois cadastros diferirem só nela por intenção — a duplicata de produção foi acidente. Aceito.
  • A migration aborta o deploy se encontrar duas contas vivas que só diferem na caixa. Conferido em produção (leitura): não há; o único par tem uma das contas removida.
  • Não toca dinheiro.

8. Critérios de aceite

  • [x] CA1. Seguindo os passos da seção 2, o comportamento agora é o esperado: a conta criada como Bruno@Solar.com.br fica gravada bruno@solar.com.br, entra digitando o e-mail em qualquer caixa e com espaço nas pontas, e a recuperação de senha em minúsculas envia o link.
  • [x] CA2. Criar usuário com o mesmo endereço em outra caixa responde 409 EMAIL_TAKEN.
  • [x] CA3. A migration baixa a caixa de users e tenant_invites, libera o endereço da conta removida que colide sem apagá-la, e aborta sem alterar nada quando duas contas vivas colidem.
  • [x] CA4. A migration roda no PostgreSQL sem erro — exercitada no banco do dev-remote dentro de uma transação desfeita (seção 12).

9. Plano

  • [x] Escrever o teste que reproduz o bug e vê-lo falhar — test_email_sem_caixa.py, 6 vermelhos antes da correção
  • [x] Corrigir a causa raiz — _validate_app_email e a migration
  • [x] Red-green verificado (seção 11)
  • [x] make preflight limpo
  • [x] pytest inteiro — 1237 verdes
  • [x] Migration exercitada no PostgreSQL (transação desfeita; sem rebuild — ver seção 12)
  • [x] Web: sem mudança — nada a validar no browser

10. Decisões em aberto

Nenhuma. A colisão de produção foi decidida no /fix new: liberar o e-mail da conta removida, sem apagá-la.

11. Revisão da correção

Preenchido pelo laço do /fix implement, rubrica .claude/rubrics/correcao.md. Registrar todas as rodadas.

Rodada Critical Important Minor Veredicto
1 0 0 3 Aprovado — os três Minor: o atalho .test sem teste (coberto agora, com red-green), o sufixo podendo estourar a coluna de 255 (documentado na migration; o caso de produção é curto) e o seed do admin fora do invariante (pendência aceita)

Resultado: aprovado na rodada 1.

O teste sobreviveria à reversão? Não — feito de verdade, uma parte da correção por vez, com pytest tests/test_email_sem_caixa.py no container:

  • antes da correção: 6 vermelhos — gravado Bruno@solar.com.br, login 401 nas três grafias (a com espaço dava 422), recuperação sem e-mail enviado, duplicata aceita;
  • validador revertido (git show HEAD:…/common.py): os mesmos 6 falham; os 2 da migration seguem verdes, como deviam;
  • sem a recusa de contas vivas na migration: test_migration_recusa_… falha (UNIQUE constraint failed: users.email);
  • sem liberar a removida: test_migration_baixa_a_caixa_… falha com o mesmo UNIQUE constraint failed;
  • sem o passo de tenant_invites: test_migration_baixa_a_caixa_… falha na asserção do convite;
  • restaurado: 8 verdes.

  • o atalho .test, acrescentado depois da revisão: com ele quebrado (devolvendo a parte local capitalizada), test_o_atalho_dos_dominios_test_… falha nos dois casos; restaurado, 10 verdes.

Defeitos aceitos:

  • O seed do admin (apps/api/app/db/init_db.py:21 e :30) grava SEED_ADMIN_EMAIL sem passar pelo validador. Com o valor em maiúsculas, o seed não acharia o admin já migrado e criaria outro. Os .env de produção e do dev-remote estão em minúsculas; fica fora deste fix.

12. Registro

  • A migration foi exercitada no PostgreSQL do dev-remote dentro de uma transação desfeita — o par de produção reproduzido (viva Adm.<x>@gmail.com e removida adm.<x>@gmail.com) saiu como adm.<x>@gmail.com e adm.<x>@gmail.com#removido-<uuid>; duas vivas colidindo abortaram com a mensagem; nada sobrou no banco. Não houve rebuild do dev-remote: ele está servindo a spec 0055, com o banco migrado nela, e reconstruir a partir desta branch deixaria o banco numa revisão que esta branch não conhece.
  • O id da revisão mudou no caminho. Nasceu o3v5y7a9c1e3, que é o id da migration da spec 0055 (o3v5y7a9c1e3_acerto_com_imobiliaria.py, ainda não mesclada). Virou q1e3m5a7i9l2. As duas saem de n2u4x6z8b0d2: quem for mesclado por segundo precisa encadear a sua down_revision na do primeiro, ou o alembic upgrade head acusa duas heads.
  • Produção conferida por leitura em 2026-10-09: head n2u4x6z8b0d2, um único par colidindo (adm.cisperimoveis@gmail.com, uma viva e uma removida), duas contas com maiúscula, nenhum convite com maiúscula.