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.
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.
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-yamlsecrets/accounts.yml é obrigatório, local e ignorado pelo Git. Tokens Gmail
ficam em secrets/gmail_tokens/. Não publique nem copie esses arquivos.
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.comO 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]"O estado de autenticação de cada conta fica no banco e aparece em:
docker compose exec email-helper-app agent accounts listreauth_required— conta Gmail com token expirado ou revogado. Resolve comagent gmail authno host, pela receita acima.error— falha de conexão IMAP (senha trocada, app password revogada, host ou porta errados emsecrets/accounts.yml). Não há fluxo interativo: corrija o YAML e rodeagent 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.
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=6API 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=6LLM_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.
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 --bootstrapPara 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:cssBase 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 buildsync 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.
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-cardUm 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.
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-NNNNNNNa 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:
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;- o mesmo sinal vai ao prompt de triagem, para atacar a causa e não o sintoma;
responder: falseno 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_respostaA busca usa duas estratégias complementares, sem outro serviço:
- full-text multilíngue com coluna
tsvectorgerada, pesos maiores para assunto e remetente, e índice GIN; - fuzzy match com
pg_trgme í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 documentoA 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.
# 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-NNNNNNO 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.
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.
Com o Docker Desktop iniciado, execute na raiz do projeto:
sh scripts/install-macos-scheduler.shO 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 --uninstallCrie ~/.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 allCrie ~/.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.targetAtive 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.serviceEm 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.
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.
- Gmail: remove somente
INBOX. - IMAP: prioriza
SPECIAL-USE \\Archive, reconheceArchive,ArchiveseArquivados, e criaArchivesomente quando necessário.
É 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.
- O pipeline automático nunca apaga, expurga ou move para Trash/Spam.
deletemove 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
revisare nunca pré-seleção de limpeza.
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