Skip to content

About

Sistema de triagem vibecoding (Fable + Obus) para múltiplas caixas de email com relatórios matinais pelo WhatsApp (Evolution API) usando ML clássica e LLM local

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

email-helper

Assistente para várias contas Gmail e IMAP. Sincroniza mensagens, faz uma leitura estruturada por LLM, oferece busca local rápida e monta uma fila conservadora de limpeza. A IA nunca apaga e-mail; Lixeira e Arquivo exigem ação explícita do usuário.

Roadmap e decisões de produto: docs/REVISAO_E_ROADMAP_FOCO.md.

Stack atual

Python 3.12 · FastAPI · PostgreSQL 16 · SQLAlchemy/Alembic · Gmail API · IMAP · Typer/Rich · adapter HTTP de LLM · Langfuse opt-in.

Não há Celery, Valkey, workers, LangGraph, LangChain, sklearn, treinamento noturno ou Label Studio. O runtime normal tem apenas email-helper-app e email-helper-db; uma falha em uma conta não interrompe as demais.

Configuração

cp .env.example .env
cp secrets/accounts.example.yml secrets/accounts.yml
docker compose up -d --build
docker compose exec email-helper-app alembic upgrade head
docker compose exec email-helper-app agent accounts import-yaml

secrets/accounts.yml é obrigatório, local e ignorado pelo Git. Tokens Gmail ficam em secrets/gmail_tokens/. Não publique nem copie esses arquivos.

Comandos que rodam no HOST, não no container

Dois comandos precisam do navegador ou do teclado da máquina e falham dentro do container, onde não há nem um nem outro:

comando por que no host
agent gmail auth EMAIL o fluxo OAuth abre navegador e escuta em localhost
agent tui console interativo

No host o pacote roda pela venv local (Python 3.12; o Python do sistema é 3.14 e não serve), e as variáveis precisam apontar para as portas e caminhos publicados — no container os caminhos são absolutos (/secrets), no host são relativos:

DATABASE_URL=postgresql+psycopg://emailagent:emailagent@localhost:5433/emailagent \
GMAIL_OAUTH_CLIENT_SECRET_FILE=secrets/gmail_client_secret.json \
GMAIL_TOKEN_STORAGE_PATH=secrets/gmail_tokens \
ACCOUNTS_FILE=secrets/accounts.yml \
.venv/bin/agent gmail auth conta@gmail.com

O e-mail é argumento obrigatório: o token é salvo por conta, em secrets/gmail_tokens/<email>.json.

Se a venv não existir:

uv venv --python 3.12 .venv && uv pip install -p .venv/bin/python -e ".[dev]"

Quando reautenticar

O estado de autenticação de cada conta fica no banco e aparece em:

docker compose exec email-helper-app agent accounts list
  • reauth_required — conta Gmail com token expirado ou revogado. Resolve com agent gmail auth no host, pela receita acima.
  • error — falha de conexão IMAP (senha trocada, app password revogada, host ou porta errados em secrets/accounts.yml). Não há fluxo interativo: corrija o YAML e rode agent accounts import-yaml.
  • ok — nada a fazer.

Uma conta em reauth_required/error é ignorada nas operações dela, mas não interrompe as outras — o sync de todas as contas segue, e a falha fica no log.

Provider de LLM

O restante da aplicação não conhece SDKs de providers. Todas as chamadas passam por intelligence/llm_client.py e retornam o mesmo contrato estruturado.

Ollama local:

LLM_PROVIDER=ollama
LLM_BASE_URL=http://host.docker.internal:11434
LLM_API_TOKEN=
LLM_MODEL=gemma4:e2b-mlx
LLM_MAX_CONCURRENCY=6

API compatível com OpenAI:

LLM_PROVIDER=openai_compatible
LLM_BASE_URL=https://api.openai.com
LLM_API_TOKEN=troque-aqui
LLM_MODEL=gpt-5-mini
LLM_MAX_CONCURRENCY=6

LLM_JSON_MODE controla o response_format pedido ao provider OpenAI-compatible: json_object (padrão, OpenAI), text ou off para omitir o campo. LM Studio recusa json_object com HTTP 400 e exige json_schema ou text; use off. Não afeta Ollama, que usa format: json.

Use LLM_PROVIDER=disabled para rodar sem inferência. Nesse caso, mensagens novas falham de forma conservadora para Revisar. Providers externos recebem o trecho do e-mail usado no prompt; Ollama local não envia esse conteúdo por si. LLM_MAX_CONCURRENCY limita quantas mensagens são classificadas simultaneamente; use um valor compatível com a memória disponível para o modelo.

Cada leitura principal é salva em email_classification, incluindo resultado JSON normalizado e bruto, provider, modelo, versão do prompt, latência, tokens e erro. Isso permite auditoria e reprocessamento sem depender do Langfuse.

Web local

A fila de limpeza está disponível em http://127.0.0.1:8010. Ela oferece busca, filtros por conta/categoria, carregamento progressivo, modo Sugestões da IA e modo Todos da Inbox. Candidatos conservadores chegam pré-selecionados; o usuário pode corrigir a seleção antes de Arquivar ou mover até 200 mensagens por vez para a Lixeira recuperável.

Para iniciar com uma base vazia, importe as contas e sincronize as pastas declaradas na configuração local:

docker compose exec email-helper-app agent accounts import-yaml
docker compose exec email-helper-app agent sync all --bootstrap

Para validar a interface e a observabilidade com uma amostra pequena de uma conta, use agent sync once --account EMAIL --limit 20. O cursor só avança até as mensagens realmente coletadas, portanto o restante não é perdido.

O bootstrap usa DEFAULT_SYNC_SINCE_DAYS como janela histórica, sincroniza as contas de forma independente e classifica cada mensagem nova. Ele não arquiva nem envia nada para a Lixeira.

Tailwind é compilado em desenvolvimento e o CSS gerado é servido pelo FastAPI; Node não faz parte do runtime:

npm install
npm run build:css

Catálogo de respostas

Base para compor respostas no estilo do usuário. São duas etapas separadas de propósito: a carga é lenta e vai à rede, a montagem é barata e será reajustada várias vezes enquanto a heurística de pareamento evolui.

# 1. carga: varre as pastas de envio e persiste; não classifica, não age no provedor
docker compose exec email-helper-app agent sync sent --no-classify

# 2. medição e export
docker compose exec email-helper-app agent corpus stats
docker compose exec email-helper-app agent corpus build

sync sent ignora o cursor incremental — é justamente o last_uid que impede alcançar mensagens antigas — e no IMAP varre todas as pastas de envio (Sent, Sent Items, INBOX.Enviados, arquivos por ano), não só a primeira que discover_folders encontra. UIDs já persistidos não são rebaixados. No Gmail a busca é in:sent sem janela e o cursor _gmail_history não é tocado, então o sync incremental segue intacto. --classify existe, mas o padrão é não classificar: o catálogo não usa categoria e a triagem custa ~45s por mensagem.

corpus build monta pares (mensagem recebida → resposta escrita) com duas regras que decidem a qualidade do dataset:

  • o contexto só inclui mensagens anteriores à resposta. Incluir as posteriores colocaria a própria resposta dentro do enunciado do exemplo;
  • quando o original não está no banco — IMAP só sincroniza INBOX/spam/sent/trash, então recebidas arquivadas faltam — o trecho citado dentro da própria resposta vira a mensagem recebida.

Cada exemplo traz self_overlap: o corte por data garante que o contexto só tem mensagens anteriores, mas reenvios e templates reaproveitados fazem parte da resposta já aparecer ali por repetição legítima. Uma métrica de otimização deve excluir esses exemplos; o catálogo marca em vez de descartar.

O JSONL contém corpo de e-mail e é gravado em data/exports/, ignorado pelo Git.

Cartão de estilo

Destila do catálogo como você escreve, para servir de contexto fixo na composição. É trabalho offline e pesado, então roda no serviço email-helper-compose, que só existe sob o profile compose e aponta para o modelo forte do LM Studio enquanto a triagem segue no modelo pequeno do Ollama:

docker compose run --rm email-helper-compose corpus style-card

Um cartão por conta com pelo menos 30 exemplos, mais um cartão _global que agrega tudo e serve de fallback. Saem em data/style/*.md, ignorados pelo Git porque são inferências sobre correspondência privada.

O cartão tem duas camadas de propósito: os números (comprimento, % de saudação, % de fecho) são medidos em Python, porque não se pergunta ao modelo o que uma contagem resolve com exatidão; só os traços qualitativos vêm da LLM, em map-reduce sobre lotes. O Markdown é feito para ser corrigido à mão — é essa propriedade que justifica o cartão existir em vez de um prompt otimizado opaco.

Custo medido com gemma-4-26b-a4b-qat: ~90s por chamada em lote de até 20, mas com variância que estourou o timeout de 10 min em lotes de 40 — por isso o default é 25 e um lote que falha é dividido em dois e repetido. O ritmo degrada conforme a máquina entra em swap: reserve mais de uma hora para a rodada completa das contas atuais. Nenhuma escrita nos provedores.

Se o servidor local reservar contexto muito acima do necessário (o LM Studio auto-ajusta para 262k quando os prompts têm ~6k), a máquina vai a swap e cada chamada fica mais lenta que a anterior. Reduzir o context length resolve sem mexer em qualidade.

Rascunhos de resposta

Para cada conversa que espera resposta sua, um rascunho no seu estilo, gravado em human_review. Nunca escreve no provedor e nunca envia.

docker compose run --rm email-helper-draft compose      # redige (modelo médio no Ollama)
docker compose exec email-helper-app agent drafts        # fila pendente
docker compose exec email-helper-app agent drafts --show E-YYYYMMDD-NNNNNN

Na web, mensagens com sugestão oferecem Ver sugestão de resposta no menu do botão direito. A sugestão abre junto com o e-mail, logo abaixo dele — um após o outro, separados por divisor e respiro, não em painéis lado a lado — e o item do menu leva direto até lá, como âncora de página. O botão Copiar entrega texto puro com uma linha em branco entre parágrafos, pronto para colar no cliente de e-mail. Não existe criação de rascunho no provedor: copiar e colar é o fluxo.

O superseded nunca aparece na tela; a web mostra apenas o rascunho vigente.

Quando o compositor recusa redigir, a recusa volta para a triagem com a justificativa, e ela escolhe outra categoria. Só a triagem escreve categoria — o compositor apenas informa. Sem isso o chip "Aguardando minha resposta" continuaria contradizendo o segundo avaliador, que leu a conversa inteira, e cada execução gastaria uma chamada para recusar de novo a mesma mensagem.

aguardando_resposta foi separada em duas direções, porque a ação é diferente: aguardando_minha_resposta (terceiro escreveu, é a fila de rascunhos) e aguardando_resposta_de_terceiro (você escreveu e cobra retorno, é o que entra no digest). A decisão é dividida: a LLM julga se a mensagem pede resposta, o banco responde se ela já foi respondida.

Três filtros evitam redigir resposta para quem não lê resposta — mala direta, newsletter e código 2FA chegavam marcados P1 antes deles existirem:

  1. intelligence/automated.py, determinístico, antes da categoria ser atribuída: List-Unsubscribe/List-Id, Auto-Submitted/Precedence (RFC 3834), remetente que não recebe resposta, assunto de código de uso único;
  2. o mesmo sinal vai ao prompt de triagem, para atacar a causa e não o sintoma;
  3. responder: false no JSON da composição — segunda rede, custo zero em chamadas, com o modelo já vendo a conversa inteira.

Efeito medido no acervo real: de 39 pendências, 26 saíram na reclassificação e 3 foram recusadas pelo gate; sobraram 10 rascunhos.

Reprocessar nunca apaga rascunho: o antigo vira superseded. compose também aposenta rascunho cuja mensagem saiu da categoria.

Quando um sinal novo passa a ser lido do cabeçalho, o acervo fica atrás — o sync incremental salta o que já está no banco. Para preencher:

docker compose exec email-helper-app agent sync refresh-flags
docker compose exec email-helper-app agent relabel category --category aguardando_minha_resposta

PostgreSQL e busca

A busca usa duas estratégias complementares, sem outro serviço:

  • full-text multilíngue com coluna tsvector gerada, pesos maiores para assunto e remetente, e índice GIN;
  • fuzzy match com pg_trgm e índices GIN em assunto, nome e e-mail do remetente.
docker compose exec email-helper-app agent search -q "contrato renovação"
docker compose exec email-helper-app agent search -q "nome com erro de digitacao"
docker compose exec email-helper-app agent search --from fornecedor --category documento

A migration também troca índices redundantes por índices alinhados às consultas: deduplicação por conta, thread+data, Message-ID por conta, digest por data, classificação única por mensagem, fila parcial de limpeza e revisão pendente.

Busca semântica vetorial ainda não está habilitada. Ela exige pgvector, um modelo específico de embeddings e uma dimensão estável. Esses componentes não são usados atualmente; adicioná-los agora aumentaria stack e armazenamento sem produzir resultados. Full-text + trigram cobre busca por conteúdo e tolerância a typos. Embeddings entram quando houver uma consulta semântica concreta para medir recall e latência.

Operação

# sync → leitura LLM → digest
docker compose exec email-helper-app agent run
docker compose exec email-helper-app agent run --send

docker compose exec email-helper-app agent sync all
docker compose exec email-helper-app agent sync all --bootstrap
docker compose exec email-helper-app agent sync sent --no-classify
docker compose exec email-helper-app agent relabel all
docker compose exec email-helper-app agent digest
docker compose exec email-helper-app agent show E-YYYYMMDD-NNNNNN

O agendamento permanece externo ao aplicativo. Durante desenvolvimento, execute run manualmente. Em uma instalação contínua, agende agent sync all: esse comando sincroniza todas as contas ativas e classifica cada mensagem nova, mas não gera nem envia o digest. O Docker Desktop/Engine e a stack precisam estar ativos; docker compose up -d não instala o agendamento.

Agendamento a cada 5 minutos

A opção -T evita que o agendador tente abrir um terminal interativo. No macOS, o instalador abaixo resolve todos os caminhos automaticamente. Nos exemplos manuais de Linux e Windows, substitua os caminhos indicados; no Linux, descubra o executável do Docker com command -v docker.

macOS (launchd)

Com o Docker Desktop iniciado, execute na raiz do projeto:

sh scripts/install-macos-scheduler.sh

O instalador detecta automaticamente os caminhos absolutos do projeto, do Docker e do usuário. Em seguida, cria e valida ~/Library/LaunchAgents/br.com.email-helper.sync.plist, ativa o LaunchAgent e dispara o primeiro sync. A saída fica em ~/Library/Logs/email-helper-sync.log e os erros em ~/Library/Logs/email-helper-sync-error.log.

Para desinstalar:

sh scripts/install-macos-scheduler.sh --uninstall

Linux (systemd do usuário)

Crie ~/.config/systemd/user/email-helper-sync.service:

[Unit]
Description=Sincroniza e classifica e-mails do email-helper

[Service]
Type=oneshot
WorkingDirectory=/CAMINHO/ABSOLUTO/email-helper
ExecStart=/CAMINHO/DO/docker compose exec -T email-helper-app agent sync all

Crie ~/.config/systemd/user/email-helper-sync.timer:

[Unit]
Description=Executa o sync do email-helper a cada 5 minutos

[Timer]
OnBootSec=1min
OnUnitActiveSec=5min
Persistent=true

[Install]
WantedBy=timers.target

Ative e confira o timer:

systemctl --user daemon-reload
systemctl --user enable --now email-helper-sync.timer
systemctl --user list-timers email-helper-sync.timer
journalctl --user -u email-helper-sync.service

Windows (Agendador de Tarefas)

Em um PowerShell, substitua o caminho do projeto e crie a tarefa:

schtasks.exe /Create /F /TN "email-helper sync" /SC MINUTE /MO 5 /TR 'cmd.exe /d /c "cd /d C:\CAMINHO\email-helper && docker compose exec -T email-helper-app agent sync all"'
schtasks.exe /Run /TN "email-helper sync"

Para remover a tarefa:

schtasks.exe /Delete /F /TN "email-helper sync"

Nos três sistemas, valide primeiro o comando manualmente. Se a execução ocorrer enquanto o computador estiver suspenso ou o Docker estiver parado, ela falhará sem alterar mensagens no provedor e o ciclo seguinte tentará novamente.

Triagem e limpeza

Cada mensagem recebe categoria, prioridade, resumo, confiança, necessidade de ação e cleanup_action (none, archive ou trash). A triagem recebe a data atual, a data da mensagem e, quando disponível, o histórico cronológico da thread. Marketing puro, promoção expirada e spam claro podem ser sugeridos para a Lixeira; documentos/evidências e conversas resolvidas podem ser sugeridos para arquivamento. P0/P1, retorno pendente e dúvida permanecem com none.

cleanup_action vive somente no PostgreSQL. A triagem não cria label, não move mensagem e não chama a Lixeira. Os únicos labels opcionais no provedor são AI/Foco e AI/Spam Suspeito.

Na web, os modos Arquivar, Lixeira e Inbox separam os destinos. Ordenação por prioridade mostra Revisão antes de P0; revisão é uma incerteza de classificação, não uma prioridade nem um destino de limpeza. Após uma mudança de prompt, classificações existentes podem ser atualizadas de forma explícita com agent relabel all --force; sem --force, somente pendentes são processadas. O checkbox ao lado de “Caixa de entrada” seleciona as mensagens carregadas e fica parcialmente marcado quando só parte delas está selecionada. A seleção também aceita Shift-clique; nenhuma mensagem é movida sem a confirmação final.

Arquivo

  • Gmail: remove somente INBOX.
  • IMAP: prioriza SPECIAL-USE \\Archive, reconhece Archive, Archives e Arquivados, e cria Archive somente quando necessário.

Langfuse

É opt-in. Sem LANGFUSE_PUBLIC_KEY e LANGFUSE_SECRET_KEY, o cliente não é inicializado. Ao ativá-lo, prompts e respostas são enviados à instância configurada, independentemente de o provider de inferência ser local.

O projeto usa o SDK Python v4 e exige Langfuse self-hosted >= 3.63.0 para tracing. O cliente recebe public_key, secret_key e base_url explicitamente e faz flush() ao encerrar comandos curtos. Instâncias anteriores precisam ser atualizadas; não há fallback para o protocolo de ingestão antigo.

Segurança

  • O pipeline automático nunca apaga, expurga ou move para Trash/Spam.
  • delete move para a Lixeira recuperável e exige decisão humana.
  • Ações no provedor têm idempotência e log.
  • Falha ou baixa confiança vira revisão, categoria revisar e nunca pré-seleção de limpeza.

Verificação

docker compose exec email-helper-app alembic current
docker compose exec email-helper-app ruff check src tests migrations
docker compose exec email-helper-app pytest
curl -fsSL http://127.0.0.1:8010/health

About

Sistema de triagem vibecoding (Fable + Obus) para múltiplas caixas de email com relatórios matinais pelo WhatsApp (Evolution API) usando ML clássica e LLM local

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages