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.
- O diagnóstico (seções 1 a 4) é revisado na seção 5 (reprodução × causa raiz).
- 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:
- Criar um usuário com
POST /api/v1/users,email = "Bruno@Solar.com.br". A API responde201e gravaBruno@solar.com.br. POST /api/v1/auth/logincomBruno@solar.com.br→200.POST /api/v1/auth/logincombruno@solar.com.br→401.POST /api/v1/auth/forgot-passwordcomBruno@solar.com.br→202, um e-mail enviado.-
POST /api/v1/auth/forgot-passwordcombruno@solar.com.br→202, nenhum e-mail enviado. -
Ambiente: produção (relato e dados) e dev-remote (reprodução); qualquer ambiente.
- 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. - 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@…ebruno@…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 com401; a recuperação responde202sem 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:
- 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 devolvevalidate_email(...).normalized(apps/api/app/schemas/common.py:22), e a bibliotecaemail_validatorpõ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. - As buscas comparam por igualdade exata.
User.email == emailemapps/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); eTenantInvite.email == emailemapps/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_emailcombruno@…não achaBruno@…, e o service responde401como credencial inválida; - esqueci a senha (
auth_service.py:128): não acha, e sai em silêncio — é a RN2 da spec 0001, que responde202igual 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:105e:130): o mesmoget_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 comAdm.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_emailemauth_service.py:45e:128e emonboarding_service.py:105e:130;get_by_email_incluindo_excluidosemonboarding_service.py:485;email_em_usonas cinco da duplicidade; eget_pending_by_emailemtenant_invite_service.py:71. Procurado porUser.email ==,TenantInvite.email ==,get_by_email,email_em_uso,get_pending_by_emaileemail.lower()emapps/api/appinteiro. - 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, porSEED_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 deAdm.cisperimoveis@…colide com a removidaadm.cisperimoveis@…. Convites de garantidora em produção: nenhum com maiúscula. - O mesmo
AppEmailvalida 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 deonboarding.py:48,:63e:73são de identidade, mas são cópias sem uso — o router de autenticação importa os deschemas/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 deSEED_ADMIN_EMAILsem 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 dovalidate_emaile 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; espalharfunc.lowerpelas onze chamadas seria consertar a comparação e deixar o dado divergindo.-
Migration
q1e3m5a7i9l2_email_sem_caixa, só de dado, em três passos:- aborta, sem alterar nada, se duas contas não removidas só diferirem na caixa — qual fica com o endereço é decisão de gente;
- 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 é aadm.cisperimoveis@gmail.com, conforme decidido pelo usuário; - baixa a caixa (e tira espaço das pontas) em
users.emailetenant_invites.email.
O
downgradenã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.brfica gravadabruno@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
usersetenant_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_emaile a migration - [x] Red-green verificado (seção 11)
- [x]
make preflightlimpo - [x]
pytestinteiro — 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, login401nas três grafias (a com espaço dava422), 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 mesmoUNIQUE 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:21e:30) gravaSEED_ADMIN_EMAILsem passar pelo validador. Com o valor em maiúsculas, o seed não acharia o admin já migrado e criaria outro. Os.envde 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.come removidaadm.<x>@gmail.com) saiu comoadm.<x>@gmail.comeadm.<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). Virouq1e3m5a7i9l2. As duas saem den2u4x6z8b0d2: quem for mesclado por segundo precisa encadear a suadown_revisionna do primeiro, ou oalembic upgrade headacusa 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.