Pular para o conteúdo
Voltar ao blog

MailVault 2.4.0: uma versão de estabilidade

7 min de leitura

A maior parte do trabalho que entrou no MailVault 2.4.0 é invisível. A interface está quase idêntica. A lista de recursos é modesta — um atraso de envio configurável, uma cópia na pasta Enviados, alguns ajustes nas configurações. Mas, por baixo do capô, esta versão é sobretudo sobre corrigir o tipo de bug que corrói discretamente a confiança em um aplicativo de e-mail: o e-mail errado aparecendo quando você clica, mensagens antigas vazando entre contas, um auxiliar em segundo plano que não conseguia falar consigo mesmo, e uma infraestrutura de lançamento capaz de publicar um binário quebrado.

Esta é uma versão de estabilidade. Veja o que estava quebrado e o que fizemos a respeito.

O bug que nos fez escrever este post

Em contas do Outlook e do Microsoft 365, clicar em um e-mail podia mostrar o conteúdo de um e-mail completamente diferente. O assunto na lista e o assunto no topo do visualizador batiam — mas o corpo, o remetente e os cabeçalhos pertenciam a outra mensagem. Relatado poucas vezes, impossível de reproduzir sob demanda, aterrorizante na primeira vez que você vê.

A causa raiz foi um acidente do jeito como ligamos dois protocolos de e-mail diferentes. O IMAP clássico dá a cada mensagem um UID estável, que nunca muda durante a vida de uma caixa postal. A API do Microsoft Graph não — ela tem IDs de mensagem opacos, em texto. Para manter o resto da base de código do MailVault uniforme, a nossa camada de transporte do Graph sintetizava UIDs posicionais na hora: indexar a pasta e distribuir 1, 2, 3 e assim por diante.

Isso funciona até a ordem mudar. Chega uma mensagem nova, uma conversa muda de pasta, a ordenação no servidor desloca por um milissegundo — e o UID posicional que apontava para o e-mail A passa a apontar para o e-mail B. A lista de cabeçalhos ficava certa porque a buscávamos na hora. Mas o cache de corpos, indexado por esse mesmo UID que escorregava, passava a entregar o conteúdo errado.

A correção é arquitetural: embutimos o ID real da mensagem do Graph diretamente em cada cabeçalho de e-mail e usamos isso como identificador canônico em todo o cache de corpos, no carregador de conteúdo e nos caminhos de ação. O UID sintético continua existindo por compatibilidade com as interfaces em formato IMAP dentro do aplicativo, mas ele não é mais a fonte da verdade sobre "qual mensagem é esta, de verdade".

Vazamento entre contas

Uma classe relacionada de bug de "e-mail errado": trocar da conta A para a conta B, abrir uma pasta em B que estava vazia e ver por um instante e-mails da conta A ainda na lista.

Era um problema de invalidação de cache. A troca de conta limpava parte do estado de e-mail, mas não todo. A lista ordenada de e-mails, o cache de corpos em memória e o cache de conversas eram atualizados em ritmos ligeiramente diferentes. Se a pasta de destino tinha e-mails novos, os dados frescos chegavam rápido o bastante para mascarar as entradas velhas. Se a pasta de destino estava vazia, a lista velha era o que você via até o resultado "vazio" finalmente se propagar.

A correção foi sem glamour: todo ponto que limpa o estado dos e-mails ao trocar de conta ou de caixa postal agora limpa tudo, de forma atômica, antes de os dados da nova conta começarem a carregar. Chega de caches limpos pela metade, chega de corrida entre "chegou uma pasta vazia" e "os e-mails da conta anterior foram descartados".

O daemon em sandbox que não achava o próprio socket

O MailVault tem um auxiliar em segundo plano — um pequeno daemon sidecar que mantém suas mensagens sincronizadas mesmo com a janela principal fechada. No macOS, tanto o aplicativo quanto o daemon rodam dentro do App Sandbox, o que significa que cada um enxerga uma visão diferente do sistema de arquivos. Eles precisam de um ponto de encontro compartilhado para conversar por um socket de domínio Unix.

O bug: o aplicativo resolvia o caminho do socket de um jeito, o daemon resolvia de outro e, nas versões em sandbox, esses dois caminhos apontavam para diretórios de contêiner diferentes. O daemon iniciava, criava seu socket e ficava esperando. O aplicativo tentava conectar, não encontrava e concluía que o daemon não estava rodando.

Agora usamos dirs::home_dir() dos dois lados para resolver ~/.mailvault/mv.sock. Sob o sandbox, isso se expande para o mesmo caminho relativo ao contêiner nos dois processos, então o cliente sempre encontra o servidor que acabou de iniciá-lo.

As permissões do auxiliar passaram por uma limpeza parecida. O binário do daemon herdava o app-sandbox da configuração de assinatura do aplicativo, o que faz binários avulsos sem um Info.plist próprio quebrarem. O application-groups também estava definido, sobra de um plano anterior de usar IPC por App Group — desnecessário agora que os dois processos compartilham o mesmo diretório de contêiner. Os dois foram removidos. O auxiliar inicia sem problemas em todas as versões do macOS contra as quais testamos, incluindo builds sandboxados recém-instalados.

Os entitlements que pareciam certos, mas não eram

Esse é o tipo de bug mais preocupante: tudo parece certo, e caladamente não funciona.

Os arquivos de permissões do macOS deixam você referenciar o prefixo da equipe de desenvolvimento como $(AppIdentifierPrefix). O Xcode expande essa variável antes de passar o arquivo para o codesign. O nosso processo de release não é o Xcode — ele chama o codesign diretamente — e acontece que o codesign não expande essa variável. A string literal $(AppIdentifierPrefix) estava sendo embutida no binário assinado.

O sintoma: o compartilhamento de chaveiro entre o aplicativo e o daemon estava quebrado, em silêncio. O aplicativo conseguia ler as credenciais do chaveiro, mas o daemon não as enxergava — porque o identificador do grupo de acesso que ele pedia era a string literal $(AppIdentifierPrefix).com.mailvault..., e não o identificador real, com o prefixo da equipe. O daemon acabava pedindo credenciais que não tinha e desistia.

A correção é uma etapa de expansão com sed que reescreve o arquivo de permissões substituindo o prefixo real da equipe antes da chamada ao codesign. Nós a acrescentamos ao lado da nossa verificação de consistência de versão que já existia, para que uma classe parecida de bug do tipo "configuração que parece certa à primeira vista" seja pega na CI.

Publicação controlada do snap

Por falar em CI: até a 2.4.0, enviar uma tag de versão publicava automaticamente o snap resultante no canal estável. Se algo quebrasse depois do teste inicial — e às vezes quebrava, porque o confinamento do Snap se comporta diferente de uma build de desenvolvimento de maneiras que só aparecem depois de instalar — a versão quebrada já estava no ar quando descobríamos.

O processo de release agora para na etapa "snap enviado". Um fluxo separado, o Promote Snaps, move um build do canal edge para o stable, e só depois de instalarmos a partir do canal edge em uma máquina limpa e conferirmos o básico: adicionar conta, carregar e-mails, fazer backup. É um clique a mais em um lançamento bem-sucedido e uma reversão bem mais tranquila em um lançamento que dá errado.

As correções mais discretas

Um punhado de correções menores cabe no mesmo tema de "impedir que o aplicativo se contradiga".

Invalidação de cache de módulo. Vários caches em nível de módulo, inicializados de forma preguiçosa — coisas como buscas memorizadas de pastas e de descritores —, não eram limpos quando os dados por baixo deles mudavam. Agora são, e o cache de pastas unificadas tem um teto de tamanho, para não crescer sem limite.

Gravações redundantes de descritor. A ativação de conta gravava o descritor da conta no disco mais de uma vez por ativação, às vezes correndo consigo mesma em trocas rápidas. As gravações duplicadas acabaram.

Recursão infinita na ativação de conta. Um caminho raro no activateAccount — a restauração do descritor como alternativa depois de uma falha de cache — podia chamar a si mesmo se o descritor restaurado disparasse outra restauração. Isso era contido por uma proteção contra reentrância; uma correção mais limpa elimina a recursão de vez.

Arquitetura do store. Nós tínhamos um conjunto de invólucros de "store de domínio" entre os componentes e o store principal do Zustand, que duplicavam o estado em cópias locais. A ideia era oferecer uma API mais limpa, mas na prática eles criavam duas fontes de verdade por pedaço de estado e bugs ocasionais de ordem de atualização. Agora eles são apenas reexportações finas de seletores — a mesma ergonomia, uma fonte de verdade só.

Arquitetura do auxiliar em segundo plano. O código Rust que cuidava do auxiliar em segundo plano fazia dois trabalhos em um módulo só: decidir como iniciar o daemon (qual binário, com quais argumentos, sob qual sandbox) e cuidar do transporte de RPC depois que ele estava rodando. Separamos isso em helper.rs, para a estratégia de inicialização, e um módulo de transporte à parte. Depurar uma falha na inicialização não exige mais vasculhar o código de requisição/resposta, e vice-versa. O texto visível para quem usa foi reescrito ao mesmo tempo — "serviço do sistema" virou "auxiliar em segundo plano", que é mais honesto sobre o que aquilo de fato é.

Por que esta versão demorou

Versões de estabilidade são estranhamente difíceis de planejar. Cada correção é pequena, mas a área que elas cobrem é enorme — o transporte do Graph, a troca de contas, o IPC em sandbox, a assinatura de código e a infraestrutura de lançamento foram todos mexidos. A maioria desses bugs era rara demais para reproduzirmos sob demanda; tivemos que raciocinar a partir de rastros, relatos de usuários e leitura do código até enxergar como o estado ruim surgia, então escrever a correção e esperar para confirmar que os relatos pararam.

O retorno é o tipo de mudança que você só nota quando, de repente, deixa de ver um bug que te pegava uma vez por semana. Se você foi atingido por qualquer um dos comportamentos acima — conteúdo de e-mail errado no Outlook, e-mails velhos ao trocar de conta, um auxiliar em segundo plano que não ficava conectado — a 2.4.0 é a versão que faz isso sumir.

Os próximos da lista: terminar a refatoração em cinco fases, fechar os itens que faltam na lista de acompanhamento da v2.4 (detecção de caixa postal única no IMAP para a Hostinger, casos extremos de troca de pasta, o balão de rascunho na composição) e continuar levando a reescrita do classificador a um bom estado. O trabalho de recursos continua — mas só em cima de uma base em que confiamos.