mail-muncher

E-mail estritamente somente leitura para agentes: regras de filtro ordenadas arquivam mensagens correspondentes de qualquer caixa de correio IMAP ou da API do Gmail para o disco como .eml mais markdown, servidas de volta via MCP.

Documentação

The mail-muncher archive beast biting an envelope
mail-muncher

mail-muncher

CI Go Reference Go version Release License: MIT

Dê a um programa sua própria caixa de correio somente leitura, filtrada exatamente para o e-mail que ele pediu, entregue como arquivos no disco.

mail-muncher puxa mensagens de um provedor de e-mail, avalia cada uma contra regras ordenadas e grava as correspondências em um diretório — fiel ao byte .eml, e opcionalmente uma renderização em markdown com os cabeçalhos como frontmatter YAML, o corpo como texto e anexos extraídos ao lado. Uma regra pode obter sua entrada de filtro de um arquivo de texto simples que algum outro programa possui, que o mail-muncher relê no início de cada ciclo. Esse outro programa muda uma linha nesse arquivo, e o próximo ciclo entrega e-mails diferentes — sem editar configuração, sem reiniciar, sem reimplantar.

Ele lê de qualquer caixa de correio IMAP — Gmail, Fastmail, iCloud, Proton Bridge, uma conta de trabalho, seu próprio servidor — ou da API do Gmail com um escopo OAuth somente leitura. Ele executa uma única vez para cron, ou como um daemon de polling, ou como um servidor MCP stdio que um agente pode consultar diretamente. Cada modo emite o mesmo manifesto legível por máquina do que fez, e nenhum modo jamais grava na sua caixa de correio.

Duas maneiras de conectar uma caixa de correio

Escolha uma antes de instalar qualquer coisa. Ambas são suportadas, e tudo a jusante — regras, formatos, nomes de arquivo, o layout do arquivo, as ferramentas MCP — é idêntico de qualquer forma.

provider: imapprovider: gmail
Tempo de configuração~2 min~10 min no Google Cloud Console
O que você registranadaseu próprio projeto Google Cloud e cliente OAuth de aplicativo de desktop
Credencialuma senha de aplicativo da página de configurações do seu provedorum token OAuth, escopo gmail.readonly
Quão ampla é essa credencialuma credencial de e-mail completa. Uma senha de aplicativo pode enviar e excluirsomente leitura, e nada mais
Quem impõe somente leiturao próprio código do mail-muncherGoogle
Expiraçãonenhumaa cada 7 dias em uma tela de consentimento em modo Teste; mail-muncher auth precisa ser reexecutado semanalmente
Onde o segredo viveonde seu gerenciador de senhas já o mantém: password_cmd é executado e seu stdout é a senha. Deliberadamente não há chave passwordtoken.json, modo 0600, escrito por mail-muncher auth
Quais caixas de correioas pastas que você lista em mailboxes:; [INBOX] por padrãotoda a conta Gmail, menos Spam e Lixeira, a menos que você as peça
Funciona comGmail, Fastmail, iCloud, Proton Bridge, contas de trabalho, auto-hospedadoSomente Gmail
Etapas extrasnenhuma. Não há comando auth neste caminhomail-muncher auth, após docs/gmail-setup.md

Os números ~2 min / ~10 min / 7 dias acima são os mesmos que mail-muncher init e as orientações de execução não configurada imprimem, porque são os números que decidem isso.

A garantia de somente leitura é real em ambos os caminhos, mas não é a mesma garantia, e achatar os dois seria desonesto.

  • Gmail: imposto pelo Google. O único escopo solicitado é gmail.readonly. O token que volta é incapaz de enviar, excluir, rotular ou modificar — não porque o mail-muncher se recusa, mas porque o Google recusará a chamada. Um bug neste programa não pode alcançar sua caixa de correio.
  • IMAP: imposto pelo mail-muncher. IMAP não tem credencial somente leitura para pedir. Uma senha de aplicativo é uma credencial de e-mail completa; o protocolo permitirá de bom grado que seu titular exclua uma pasta. O que o mail-muncher faz em vez disso é recusar: cada pasta é aberta com EXAMINE e nunca SELECT, cada corpo é buscado com BODY.PEEK[] e nunca BODY[] (então o e-mail nunca é marcado como lido), e não há caminho de código em nenhum lugar do provedor que emita STORE, APPEND ou EXPUNGE. Ambos os cintos são usados porque um servidor não é obrigado a proteger um cliente de si mesmo. Essa é uma garantia forte e auditável — é apenas a garantia deste programa, não a do seu provedor de e-mail.

Se você não tem nenhuma razão específica para querer a API do Gmail, comece com IMAP. Funciona também em uma conta Gmail, e é o caminho que o quickstart segue.

O problema

Um processo automatizado precisa de algum e-mail. Um rastreador de busca de emprego quer respostas de empresas para as quais você se candidatou. Um bot de suporte quer mensagens do domínio de um fornecedor. Um agente de pesquisa quer cada newsletter de três editoras, como texto que ele possa realmente ler.

As respostas usuais são todas ruins. Entregue ao processo suas credenciais de caixa de entrada e ele pode ler (e enviar, e excluir) tudo. Dê a ele uma integração de API de e-mail e você agora mantém um fluxo OAuth, um cursor de sincronização, análise MIME e uma história de deduplicação dentro de cada processo que quer e-mail. Ou codifique o filtro em um arquivo de configuração, e cada mudança no que ele quer é uma edição de configuração e um reimplantar.

mail-muncher divide isso ao meio. Ele possui as credenciais, a sincronização incremental, a análise e a deduplicação. O programa consumidor possui um arquivo de texto listando o que quer e um diretório do qual lê resultados — e, se preferir perguntar em vez de observar, um punhado de ferramentas MCP sobre esse mesmo diretório.

O fluxo de trabalho do agente

Existem duas formas suportadas, e elas se compõem. Escolha com base em se seu agente executa em um loop próprio ou espera ser perguntado.

  • Entrega de arquivo — mail-muncher executa em um agendamento e grava arquivos; o agente lê o diretório. Nada chama nada. Esta é a forma abaixo.
  • Chamada de ferramenta — o agente fala com mail-muncher mcp via MCP e faz perguntas diretamente: do que estou assinado, o que chegou, o que este tópico diz, busque agora. Veja Forma 2: chamada de ferramenta.

Ambos leem o mesmo arquivo, e executar ambos ao mesmo tempo é normal: um daemon preenche o diretório enquanto o servidor MCP responde perguntas sobre ele.

Forma 1: entrega de arquivo

O loop é totalmente desacoplado: mail-muncher nunca chama o agente, e o agente nunca precisa chamar o mail-muncher. Eles compartilham dois caminhos no disco.

1. O agente declara o que quer. Acrescente a um arquivo que ele possui:

mkdir -p ~/.local/share/agent
cat >> ~/.local/share/agent/domains.txt <<'EOF'
# domains this agent is currently interested in
acme.com
globex.io
EOF

2. mail-muncher assina essa declaração. Uma regra, apontada para o arquivo:

rules:
  - name: agent-inbox
    match:
      from_domains_file: ~/.local/share/agent/domains.txt
    dest: ~/mail/agent-inbox
    formats: [eml, markdown]

3. Cada ciclo relê o arquivo. Execute-o a partir do cron, ou deixe o daemon rodando:

mail-muncher run                 # one cycle — the cron entrypoint
mail-muncher daemon --interval 5m  # poll forever

4. E-mails correspondentes caem em dest como arquivos que o agente lê.

~/mail/agent-inbox/
└── 2026/
    └── 07/
        ├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.eml
        ├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.md
        └── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.attachments/
            └── offer.pdf

O .md é a renderização consumível — analise o frontmatter, alimente o corpo a um modelo, abra os anexos do diretório irmão:

---
subject: 'Re: Your application for Senior Engineer'
from: Jane Doe <jane@acme.com>
from_address: jane@acme.com
from_addresses: [jane@acme.com]
to: [me@example.com]
to_addresses: [me@example.com]
date: 2026-07-28T09:15:00Z
message_id: <abc123@acme.com>
thread_id: 18fe9c0d1a2b3c4d
thread_id_source: provider
in_reply_to: <application-000@example.com>
account: personal
rule: job-search
labels: [INBOX]
attachments: [offer.pdf]
---

Hi there,

Thanks for applying.

## Attachments

- [offer.pdf](1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.attachments/offer.pdf)

thread_id está em cada mensagem e nunca está vazio, então agrupar um diretório em conversas é um sort em um campo — sem cadeias de referência para remontar.

5. Opcionalmente, pegue o manifesto em vez de percorrer a árvore. --json grava um registro legível por máquina do ciclo no stdout, um objeto por conta, enquanto cada linha de log vai para o stderr:

mail-muncher run --json 2>/dev/null | jq -r '.stored[].path'

Contrato completo: docs/manifest.md.

Três propriedades tornam isso seguro para colocar em um loop autônomo:

  • Somente leitura por construção. Nada no mail-muncher grava em uma caixa de correio. No Gmail, isso é a imposição do Google do escopo gmail.readonly; no IMAP, é EXAMINE e BODY.PEEK[] e nenhum caminho de gravação. De qualquer forma, o que consome a saída — e qualquer bug que tenha — não pode enviar, excluir, ou modificar e-mail. Veja a comparação acima para saber qual dessas duas garantias você está obtendo.
  • Entrega idempotente. O nome do arquivo de uma mensagem incorpora um digest de account + ":" + message id, então seu caminho de destino é uma função pura de sua identidade. Um arquivo que já está lá significa "um ciclo anterior armazenou isso", e o sink não grava nada. Reexecute, reproduza após perder estado, falhe no meio do ciclo ou sobreponha duas invocações de cron: a árvore converge, e nada é processado duas vezes.
  • Roteamento determinístico. As regras são ordenadas e a primeira correspondência vence, então cada mensagem é gravada por exatamente uma regra. Dê a cada consumidor sua própria regra e seu próprio dest, e cada um obtém uma caixa de correio privada na qual nada mais grava.

A entrega são arquivos no disco, e nada aqui escuta em uma rede. O contrato é o diretório, com o manifesto como um registro opcional e legível por máquina do que mudou.

Forma 2: chamada de ferramenta

mail-muncher mcp é um servidor MCP stdio sobre o e-mail já arquivado. O agente pergunta; nada é agendado.

{
  "mcpServers": {
    "mail-muncher": {
      "command": "/usr/local/bin/mail-muncher",
      "args": ["mcp", "--config", "/Users/you/.config/mail-muncher/config.yml"]
    }
  }
}

Cinco ferramentas:

FerramentaO que ela responde
list_rulesO que estou coletando, e de quais remetentes estou assinado agora? Cada from_domains_file é relido em cada chamada.
list_messagesO que chegou? Filtre por regra, conta, tópico ou data; opcionalmente agrupado em conversas.
search_messagesOnde está a mensagem que menciona X? Pesquisa de substring sobre assunto, remetente, destinatários, rótulos, nomes de anexos e corpo.
read_messageUma mensagem completa — metadados, corpo, nomes e tamanhos de anexos — e opcionalmente todo o tópico em ordem.
syncBusque novos e-mails uma vez, retornando o mesmo manifesto que run --json grava.

É somente leitura sobre e-mail: nenhuma ferramenta envia, exclui ou modifica nada, e sync — a única ferramenta que muda qualquer coisa — só pode adicionar arquivos. O acesso ao sistema de arquivos é restrito às raízes dest da regra configurada, então a configuração, qualquer credencial armazenada e o diretório de estado são inacessíveis e sem nome.

Um servidor mcp não configurado inicia mesmo assim, e isso é deliberado. Se um cliente lança mail-muncher mcp antes de haver uma configuração, o servidor não sai — ele completa o handshake, registra os mesmos cinco nomes de ferramentas e responde a cada chamada com a orientação de configuração como um erro de ferramenta, então o agente tem algo para repassar em vez de "o servidor falhou ao iniciar". Se você está conectando isso para um operador, esse é o comportamento esperado e não um bug para relatar. A orientação também vai para o stderr na inicialização, onde os clientes exibem o log do servidor.

Referência completa, conexão do cliente e cada argumento e campo de retorno: docs/mcp.md.

list_rules é o que fecha o loop. O agente grava um domínio em seu próprio arquivo, então pergunta a list_rules e vê sua própria assinatura refletida de volta — a mesma lista que o próximo ciclo corresponderá.

Alternativas

Leia isso antes de adotar. Várias ferramentas fazem a forma buscar-filtrar-entregar bem, e algumas delas são um ajuste melhor do que esta.

FerramentaUse-a em vez disso quando
getmail6Você quer um buscador maduro e amplamente empacotado. Ele faz IMAP e OAuth2 do Gmail, entrega para Maildir/MDAs e filtra através de programas externos. Se um humano (ou mutt, ou notmuch) é o consumidor, esta é a ferramenta mais forte.
fdmVocê quer destinos Maildir por regra com uma configuração compacta e bem testada — exatamente a forma desta ferramenta, menos a fonte de filtro externa. O acesso ao Gmail é IMAP com senha de aplicativo.
lieerVocê quer toda a sua caixa de correio Gmail sincronizada bidirecionalmente em um Maildir local para notmuch, não um subconjunto filtrado extraído dela.
gmail-archiveEra quase exatamente isso — consulta Gmail para Maildir, incremental — e seria a resposta óbvia se ainda fosse mantido. Não é desde 2018.
gmail-exporterVocê quer uma exportação única, baseada em rótulos, em formato de planilha em vez de sincronização incremental.
mbsync / offlineimapVocê quer replicação completa da caixa de correio e filtrará localmente depois.

O que nenhuma dessas ferramentas faz, e para o que esta ferramenta existe: receber entrada de filtro de um arquivo que outro programa possui e relê-la a cada ciclo, e emitir uma renderização construída para um programa consumir, em vez de para um cliente de e-mail exibir. Se você não precisa de ambas as coisas, uma das ferramentas acima atenderá melhor e tem anos a mais de uso.

Instalação

Nenhuma toolchain Go é necessária para as duas primeiras opções.

Homebrew

brew install craigjmidwinter/tap/mail-muncher

Isso adiciona o tap craigjmidwinter/homebrew-tap e instala um binário pré-compilado. brew upgrade mail-muncher rastreia novos lançamentos.

Baixar um binário

Cada lançamento inclui arquivos para macOS e Linux em amd64 e arm64, além de um checksums.txt e uma assinatura sobre ele.

# Latest release, without the leading v. Set this by hand to pin a version.
VERSION=$(curl -fsSL https://api.github.com/repos/craigjmidwinter/mail-muncher/releases/latest \
  | sed -n 's/.*"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p')

OS=$(uname -s | tr '[:upper:]' '[:lower:]')     # darwin | linux
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')

curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/mail-muncher_${VERSION}_${OS}_${ARCH}.tar.gz"
tar xzf "mail-muncher_${VERSION}_${OS}_${ARCH}.tar.gz" mail-muncher
sudo install -m 0755 mail-muncher /usr/local/bin/mail-muncher

Se o binário então se recusar a executar completamente —

bash: mail-muncher: cannot execute binary file: Exec format error

— você tem um arquivo para a arquitetura errada. Essa mensagem vem do kernel e não diz nada sobre o mail-muncher, então vale a pena conhecer o formato dela. Compare uname -m com o _amd64 / _arm64 no nome do arquivo que você baixou; a linha ARCH= acima calcula a correta para você, então isso só morde se você definir o nome manualmente.

Sem root? /usr/local/bin precisa disso; ~/.local/bin não. Solte o sudo e instale lá em vez disso — nada sobre o mail-muncher quer um local em todo o sistema:

install -d ~/.local/bin
install -m 0755 mail-muncher ~/.local/bin/mail-muncher

Se mail-muncher for então "command not found", ~/.local/bin não está no seu PATH; adicione-o no seu perfil de shell.

Pular o sudo sem mudar o destino falha com um Permission denied from install em si — no macOS nomeando um arquivo temporário em vez de mail-muncher, o que é confuso na primeira vez que você vê:

install: /usr/local/bin/INS@LPh1Hz: Permission denied     # macOS
install: cannot create regular file '/usr/local/bin/mail-muncher': Permission denied   # GNU

Qualquer mensagem significa a mesma coisa: escolha a rota ~/.local/bin acima, ou coloque o sudo de volta.

No macOS, um binário que você baixou é colocado em quarentena pelo Gatekeeper. Limpe com xattr -d com.apple.quarantine /usr/local/bin/mail-muncher, ou use a instalação via Homebrew acima, que faz isso por você.

Verificar o que você baixou

Esta ferramenta lê seu e-mail. Verifique se o arquivo é o que o fluxo de lançamento construiu. Primeiro o checksum:

curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt"

# Linux
sha256sum --check --ignore-missing checksums.txt
# macOS
shasum -a 256 --check --ignore-missing checksums.txt

Depois a assinatura sobre checksums.txt. Lançamentos são assinados sem chave com cosign — não há chave pública para buscar e nenhuma chave privada que alguém precise proteger. O certificado de assinatura é emitido para a identidade OIDC do próprio fluxo de lançamento do GitHub e registrado no log público de transparência Rekor, então o que você está verificando é "isso foi construído por release.yml neste repositório, a partir de uma tag":

cosign não é instalado por padrão em nenhuma plataforma e não está nos repositórios usuais de distribuição, então cosign: command not found aqui significa "ainda não instalado", não "verificação falhou". Obtenha-o primeiro — brew install cosign, ou go install github.com/sigstore/cosign/v2/cmd/cosign@latest, ou um binário de lançamento dos documentos de instalação.

curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt.sig"
curl -fsSLO "https://github.com/craigjmidwinter/mail-muncher/releases/download/v${VERSION}/checksums.txt.pem"

cosign verify-blob \
  --certificate checksums.txt.pem \
  --signature checksums.txt.sig \
  --certificate-identity-regexp '^https://github\.com/craigjmidwinter/mail-muncher/\.github/workflows/release\.yml@refs/tags/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  checksums.txt

Verified OK significa que o arquivo de checksum é autêntico; o passo sha256sum então amarra seu arquivo a ele. cosign 3 imprime um aviso de depreciação para --certificate e --signature — a verificação ainda roda, e esses arquivos destacados são o que cosign 2 também entende.

go install

O caminho certo se você já tem Go 1.25 ou mais novo:

go install github.com/craigjmidwinter/mail-muncher/cmd/mail-muncher@latest

Note que builds go install reportam dev para --version, porque a versão é carimbada no momento da linkagem e a ferramenta go não faz isso. Binários lançados e make build reportam a tag real. Se você registrar um bug a partir de um build go install, diga qual commit você instalou.

Compilar a partir do código-fonte

git clone https://github.com/craigjmidwinter/mail-muncher
cd mail-muncher
make build          # -> ./mail-muncher, version stamped from git describe

make snapshot compila o conjunto completo de arquivos de lançamento localmente (requer goreleaser) se você quiser verificar o que um lançamento conteria.

Os exemplos de configuração referenciados abaixo vivem em examples/ — imap.yml, minimal.yml e job-search.yml. Eles também estão incluídos em cada arquivo de lançamento, então um download de binário também os tem. Você não precisa deles para começar, no entanto: mail-muncher init escreve uma configuração do zero.

Imagem de contêiner

docker pull ghcr.io/craigjmidwinter/mail-muncher:latest

linux/amd64 e linux/arm64, construídos a partir dos mesmos binários que os arquivos de lançamento carregam. O comando padrão da imagem é mcp, porque servir o arquivo via stdio é o modo que um contêiner se adequa: um cliente o inicia, fala com ele e o para. run e daemon também funcionam — sobrescreva o comando — mas em um host esses são uma linha de cron e uma unidade launchd/systemd, que se encaixam melhor.

Duas montagens, e ambas importam:

# -e IMAP_PASSWORD forwards the variable, it does not invent it: export it
# first, from wherever you actually keep the secret.
export IMAP_PASSWORD="$(security find-generic-password -s mail-muncher -w)"

docker run -i --rm \
  -e IMAP_PASSWORD \
  -v ~/.config/mail-muncher:/home/muncher/.config/mail-muncher:ro \
  -v ~/.local/share/mail-muncher:/home/muncher/archive \
  ghcr.io/craigjmidwinter/mail-muncher:latest mcp

Esse export combina com password_cmd: printenv IMAP_PASSWORD na configuração — veja a nota abaixo sobre por que seu gerenciador de senhas do host não é alcançável de dentro do contêiner.

Todo caminho dentro de config.yml tem que ser um caminho que o contêiner possa ver. Um dest: de ~/Mail/receipts resolve contra o diretório home do contêiner, não o seu, então o e-mail cai em uma camada que desaparece quando o contêiner sai. Aponte dest: para o diretório montado — /home/muncher/archive/receipts para a montagem acima — ou você arquivará no vazio e o manifesto dirá alegremente que funcionou.

password_cmd roda dentro do contêiner, sob /bin/sh, o que significa que seu gerenciador de senhas do host não está lá. pass show mail/fastmail não pode funcionar. Use o material secreto que o contêiner tem:

password_cmd: printenv IMAP_PASSWORD          # -e IMAP_PASSWORD
password_cmd: cat /run/secrets/imap-password  # docker secret or a mounted file

Este é o único lugar onde o caminho do contêiner é genuinamente pior que uma instalação no host: ele move a credencial para fora do seu gerenciador de senhas e para dentro do ambiente do contêiner. Se essa troca não vale a pena para você, instale o binário — password_cmd é projetado para o caso do host, e este é o compromisso, não a intenção.

A imagem também é o que sustenta a listagem do MCP Registry; server.json é essa entrada, e seu name tem que corresponder ao rótulo io.modelcontextprotocol.server.name embutido na imagem.

Publicar essa entrada é automático. Marcar um lançamento compila e envia a imagem, e então um segundo trabalho reescreve version e a tag da imagem em server.json a partir da tag git e publica no registro, autenticando com a identidade OIDC do próprio fluxo de trabalho em vez de um token armazenado.

Então o version commitado em server.json é do último lançamento, e fica atrasado por uma tag de propósito. A tag é a fonte da verdade; o arquivo é um modelo que o CI carimba. Aumentá-lo manualmente não alcança nada.

Como uma skill do Claude Code

O repositório inclui um pacote de skill e plugin sob skills/, que instala o mail-muncher como algo que um agente pode configurar e dirigir para você — escrevendo a configuração, rodando auth, e conectando o servidor MCP ao seu cliente. Se é assim que você quer adotá-lo, comece lá em vez do quickstart abaixo.

A skill lidera com provider: imap e dirige mail-muncher init, então ela segue a mesma rota de dois minutos que este README faz em vez de enviar você para o Google Cloud Console.

Windows

Não há build para Windows, e nenhuma das opções acima contorna isso silenciosamente. Homebrew não roda no Windows. Os arquivos de lançamento são darwin e linux apenas, e o trecho de download acima é um script de shell POSIX construído sobre uname, que PowerShell e cmd não conseguem rodar de jeito nenhum.

go install é o único caminho que produz algo, e esse é o problema que vale a pena declarar claramente. Go cross-compila este módulo limpo — sem cgo, sem tags de build de plataforma fora de um arquivo de teste — então você obtém um mail-muncher.exe que inicia, e mail-muncher init que escreve uma configuração sem reclamar. Ele para no primeiro run. O provedor IMAP, o caminho de ~2 min que este README lidera, executa imap.password_cmd entregando-o a /bin/sh -c (internal/provider/imap/password.go), e uma máquina Windows padrão não tem /bin/sh. init é cuidadoso o suficiente para não semear uma configuração Windows com uma ferramenta de segredo macOS ou Linux, mas o comando que ele semeia ainda vai para um shell que não está lá, então a falha chega tarde e culpa a coisa errada.

O provedor Gmail não tem tal dependência — seu fluxo OAuth já escolhe rundll32 no Windows — então pode funcionar de ponta a ponta. É não testado lá e não suportado.

O que funciona no Windows: a imagem de contêiner sob Docker Desktop, ou qualquer caminho de instalação dentro do WSL2, onde um binário Linux e /bin/sh ambos existem.

Atualização

brew upgrade mail-muncher            # Homebrew
go install github.com/craigjmidwinter/mail-muncher/cmd/mail-muncher@latest

Para um binário baixado, repita os passos de download acima — o passo install sobrescreve no lugar. Nada mais precisa mudar: o esquema de configuração e o layout em disco são estáveis dentro de 0.x, e cursores de sincronização em state_dir são lidos por qualquer versão mais nova. CHANGELOG.md registra qualquer coisa que tornaria isso falso, e não há nada lá ainda.

Verifique no que você caiu com mail-muncher --version. Um build sem make reporta dev — esse é o problema do carimbo de versão go install, não uma instalação quebrada.

Desinstalação

Remover o binário deixa todo o resto para trás, então isso está na ordem que remove o material mais sensível primeiro. Nada aqui é feito por você:

# 1. Stop it, if you scheduled it.
launchctl unload ~/Library/LaunchAgents/com.craigjmidwinter.mail-muncher.plist
rm ~/Library/LaunchAgents/com.craigjmidwinter.mail-muncher.plist
rm -f ~/Library/Logs/mail-muncher.out.log ~/Library/Logs/mail-muncher.err.log
# or, if you used cron: crontab -e and delete the line.

# 2. The credential. This is the part nothing else will clean up.
security delete-generic-password -s mail-muncher      # macOS Keychain, IMAP
# Gmail instead: revoke the app at https://myaccount.google.com/permissions

# 3. Config, credentials and the OAuth token.
rm -rf ~/.config/mail-muncher

# 4. Sync cursors, both lockfiles, and the quarantine directory.
rm -rf ~/.local/state/mail-muncher

# 5. The binary.
brew uninstall mail-muncher          # Homebrew
rm -f /usr/local/bin/mail-muncher    # downloaded binary
rm -f "$(go env GOPATH)/bin/mail-muncher"   # go install

Seu e-mail arquivado está deliberadamente fora dessa lista. Ele vive em qualquer dest que suas regras nomearam — ~/Mail/mail-muncher se você pegou o padrão init — e esses são arquivos comuns que sobrevivem à ferramenta, que é o ponto inteiro do formato. grep -n 'dest:' ~/.config/mail-muncher/config.yml antes do passo 3 se você quiser os caminhos, e delete-os você mesmo se quiser o e-mail sumido.

O cask Homebrew não carrega nenhuma estrofe zap, então brew uninstall remove o binário e nada sob seu diretório home. Isso é de propósito — e-mail que esta ferramenta já escreveu é seu, e um desinstalador é um lugar ruim para descobrir o contrário.

Quickstart

Cerca de cinco minutos, sem navegador, sem clone. Este é o caminho IMAP; para a API Gmail em vez disso, leia Quickstart: Gmail abaixo antes de começar, porque custa cerca de dez minutos no Google Cloud Console e o token expira semanalmente.

0. Verifique a instalação. Logo após instalar, antes de haver qualquer configuração:

mail-muncher run

Isso é um teste de fumaça genuinamente útil em vez de um erro. Ele sai com 1 e diz exatamente onde procurou, o que rodar em seguida, e o que cada provedor custa:

mail-muncher is not configured.
  missing config file: /Users/you/.config/mail-muncher/config.yml
  next command:        mail-muncher init
  then:                mail-muncher validate && mail-muncher run --dry-run

init asks which provider to use. Both are supported; the costs differ.
  provider: imap   ~2 min. Gmail, Fastmail, Proton Bridge, work accounts,
    self-hosted. Needs an app password, which is a broader credential than a
    read-only OAuth token; mail-muncher only ever issues BODY.PEEK.
  provider: gmail  ~10 min in the Google Cloud Console: gmail.readonly is a
    Google restricted scope, so mail-muncher ships no OAuth client and you
    register your own. Google enforces read-only, but on a Testing-mode
    consent screen the refresh token expires every 7 days, so
    "mail-muncher auth" must be re-run weekly. Read docs/gmail-setup.md.
Docs: https://craigjmidwinter.github.io/mail-muncher/

Todo comando que precisa de uma configuração diz isso, então uma instalação quebrada e uma não configurada nunca parecem iguais.

1. Obtenha uma senha de app. Da própria página de configurações do seu provedor de e-mail — Gmail, Fastmail, iCloud, Proton, sua conta de trabalho. Ela é escopada para este uso único e você pode revogá-la sem tocar em mais nada. Coloque-a onde quer que você já guarde segredos:

# macOS Keychain
security add-generic-password -s mail-muncher -a "$USER" -w

# or pass, 1Password, secret-tool, gpg — anything that prints it on stdout

mail-muncher nunca armazena isso. Ele roda um comando que você nomeia e lê a senha da saída stdout desse comando, então o segredo fica no seu gerenciador de senhas. Não há deliberadamente nenhuma chave password: no esquema de configuração.

2. Escreva uma configuração.

mail-muncher init --provider imap
Account name [personal]:
Write matched mail to [~/Mail/mail-muncher]:
IMAP host: imap.fastmail.com
IMAP username: you@fastmail.com
Password command [security find-generic-password -s mail-muncher -w]:
Wrote /Users/you/.config/mail-muncher/config.yml

Next, for provider imap:
  1. Run that password_cmd in a shell and check it prints the password and
     nothing else, for example:
       security find-generic-password -s mail-muncher -w | cat -A
     Anything else on stdout - a prompt, a warning, a trailing blank line -
     becomes part of the password and the login fails. If it is not there
     yet, create an app password with your mail provider first and store it
     where password_cmd can read it.
  2. mail-muncher validate
  3. mail-muncher run --dry-run     then     mail-muncher run
Matched mail lands in ~/Mail/mail-muncher
Docs: https://craigjmidwinter.github.io/mail-muncher/

Não há passo de edição. init pergunta tudo o que o caminho IMAP precisa e escreve uma configuração que valida na primeira tentativa. O comando de senha é oferecido com o padrão da sua plataforma já preenchido — Keychain no macOS, secret-tool no Linux, pass em outros lugares — então pressionar Enter através dele é uma resposta real, não um placeholder.

Toda pergunta tem uma flag, para responder antecipadamente ou scriptar a coisa toda:

mail-muncher init --provider imap --yes \
  --host imap.fastmail.com --username you@fastmail.com

--yes assume o padrão para tudo que tem um padrão honesto, por isso ainda exige --provider, --host e --username. Esses três não têm padrão que valha a pena adivinhar, e ele diz isso em vez de escrever um espaço reservado:

error: --host and --username required with --yes --provider imap; host and
username have no honest default to take. Run `mail-muncher init --provider
imap` without --yes to be prompted instead

Adicione --account NAME, --dest DIR e --password-cmd CMD para responder ao restante. Uma configuração existente nunca é sobrescrita sem --force.

~/.config/mail-muncher/config.yml é o caminho padrão; --config o substitui em todos os lugares, inclusive para init.

3. Verifique se o comando de senha imprime a senha, e nada mais.

security find-generic-password -s mail-muncher -w | cat -A

| cat -A torna visível um prompt perdido, aviso ou linha em branco no final. Qualquer coisa extra no stdout vira parte da senha e o login falha — essa é a razão mais comum para uma primeira execução não autenticar.

Aqui está o que init escreveu, para referência; está comentado por completo, e examples/imap.yml é uma versão mais completa e trabalhada:

accounts:
  - name: personal
    provider: imap
    imap:
      host: imap.fastmail.com
      username: you@fastmail.com
      password_cmd: security find-generic-password -s mail-muncher -w
      mailboxes: [INBOX]

4. Verifique a configuração.

mail-muncher validate
config: /Users/you/.config/mail-muncher/config.yml
1 account(s), 1 rule(s), state_dir /Users/you/.local/state/mail-muncher
OK

Uma conta IMAP valida de forma limpa: nenhum arquivo de credenciais para encontrar, nenhum token para ter escrito ainda, nada no disco. OK sem avisos é o resultado esperado. validate analisa a configuração, compila a árvore de correspondência de cada regra e verifica os arquivos que ela referencia. Arquivos ausentes que pertencem a outro programa — um from_domains_file, ou no caminho Gmail as credenciais OAuth e o token — são avisos, não erros:

warning: rules[0].match.any[0].from_domains_file: file does not exist yet: /Users/you/.local/share/jobsearch/domains.txt (it is maintained by another program; the rule matches nothing until it appears)
OK with 1 warning

5. Veja o que uma execução real faria.

mail-muncher run --dry-run

Uma execução de teste conecta, busca e avalia exatamente como uma execução real, e relata o caminho para onde cada correspondência seria gravada. Ela não grava arquivos e não salva o estado de sincronização, então você pode executá-la quantas vezes quiser. É aqui também que um host, nome de usuário ou password_cmd errado aparece, nomeado exatamente:

error: account "personal": imap: password_cmd "security find-generic-password -s mail-muncher -w" failed: exit status 44: security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.

6. Execute.

mail-muncher run

A configuração que init escreveu carrega uma regra inicial que corresponde a tudo mais novo que 72h, então esta primeira execução tem garantia de armazenar algo — uma execução que não armazena nada é indistinguível de uma instalação quebrada. Depois execute novamente: tudo que já está no disco é relatado como skipped, e o cursor incremental faz com que a segunda execução mal fale com o servidor.

Quando isso funcionar, restrinja a regra inicial para o que você realmente quer (docs/filters.md), depois coloque-a em um agendamento (veja Agendamento). docs/configuration.md tem todas as chaves.

Início rápido: Gmail

Siga este caminho se você quer especificamente a API Gmail e uma garantia de somente leitura imposta pelo Google em vez deste programa. Conheça os dois custos antes de começar, porque ambos são estruturais e nenhum desaparece:

  • Cerca de dez minutos no Google Cloud Console, logo de início. gmail.readonly é um escopo restrito do Google, então o mail-muncher não inclui cliente OAuth e nunca incluirá — você registra seu próprio projeto e cliente Desktop e baixa o JSON dele.
  • O token expira a cada 7 dias. O Google aplica isso a toda tela de consentimento ainda em modo Teste, que será o seu caso. mail-muncher auth precisa ser executado novamente toda semana. Não há configuração que remova isso; docs/gmail-setup.md explica o porquê e quanto custam as alternativas.

Se nenhum dos dois valer a pena para você, o caminho IMAP acima funciona em uma conta Gmail.

mail-muncher init --provider gmail        # prints the cost warning, then writes the config
# → follow docs/gmail-setup.md: project, Gmail API, consent screen,
#   Desktop app OAuth client, save its JSON as
#   ~/.config/mail-muncher/credentials.json
mail-muncher auth --account personal      # browser consent; writes token.json 0600
mail-muncher validate
mail-muncher run --dry-run
mail-muncher run

auth imprime uma URL de consentimento (e tenta abrir um navegador), escuta em uma porta de loopback pelo redirecionamento e grava o token no token_file da conta com modo 0600. É um comando exclusivo do Gmail — em uma conta IMAP ele recusa, porque não há nada para autorizar. Os passos 4 a 6 do início rápido IMAP acima então se aplicam sem alterações; validate relatará dois avisos até que os arquivos de credenciais e token existam.

Arquivos de filtro gerenciados externamente

Este é o recurso em torno do qual a ferramenta é construída, então vale a pena ser preciso sobre a semântica.

from_domains_file nomeia um arquivo que o mail-muncher não possui, não cria e nunca grava:

match:
  from_domains_file: ~/.local/share/jobsearch/domains.txt
# ~/.local/share/jobsearch/domains.txt
# written by the job-search tracker

acme.com
globex.io          # inline comments are fine
@initech.com       # a leading @ is stripped
MAIL.Umbrella.COM  # case is irrelevant
  • Lido uma vez por ciclo, no primeiro uso. Não uma vez por processo, e não uma vez por mensagem. run o relê; cada tick do daemon o relê. Um arquivo referenciado por várias regras é lido uma vez e compartilhado.
  • Ausente ou ilegível nunca é fatal. O predicado simplesmente não corresponde a nada e um aviso é registrado para aquele arquivo naquele ciclo. O programa dono pode ainda não tê-lo criado, e o mail-muncher não deve falhar por causa disso.
  • Análise liberal. Uma entrada por linha; # inicia um comentário; linhas em branco são ignoradas; espaços ao redor são removidos; um @ inicial e um . final são removidos; tudo é convertido para minúsculas; duplicatas colapsam. Uma entrada sem ponto é mantida e registrada como suspeita em vez de descartada, porque o arquivo pertence a outra pessoa e adivinhar errado não deve descartar silenciosamente uma entrada.
  • Igualdade ou subdomínio. acme.com corresponde a acme.com e careers.acme.com, mas não a notacme.com.

As mesmas regras de correspondência se aplicam ao predicado inline from_domains:; a única diferença é quem é dono da lista.

Quando uma lista de domínios não consegue expressar

Alguns remetentes não podem ser enumerados antecipadamente. O e-mail de uma empresa pode chegar de wagepoint.teamtailor.com, mail.wagepoint.com e notifications@wagepoint-hr.example — uma lista de domínios só pode nomear hosts que você já conhece. from_regex_file é a mesma ideia para padrões:

match:
  from_regex_file: ~/.local/share/jobsearch/companies.txt
# one RE2 pattern per line, unanchored
wagepoint
(?i)^careers@acme\.io$
teamtailor\.com$

O ciclo de vida é idêntico — lido uma vez por ciclo, ausência nunca é fatal, on_degraded_filter governa o cursor. Duas diferenças deliberadas em relação ao formato de domínio:

  • Nada é convertido para minúsculas, porque uma regex é sensível a maiúsculas por construção. Escreva (?i) quando quiser o contrário.
  • # só inicia um comentário no início de uma linha. Truncar um padrão em um # no meio da linha mudaria silenciosamente o que ele corresponde.

Os modos de falha são opostos, e é por isso que as proteções diferem. Um erro de digitação em uma lista de domínios corresponde a nada — o custo é o silêncio. Um erro de digitação em uma lista de padrões pode corresponder a tudo: .* ou uma linha em branco perdida reivindica a caixa de entrada inteira. Então um padrão vazio, ou qualquer padrão que corresponda à string vazia, é recusado de imediato; um padrão que falha ao compilar é recusado sozinho enquanto o restante do arquivo permanece em vigor; e a contagem de padrões carregados é registrada a cada ciclo, então um arquivo que caiu de doze padrões para um pega-tudo é um número na saída da sua execução em vez de uma descoberta por meio de um disco cheio.

Configuração

Referência completa: docs/configuration.md. Arquivos executáveis: examples/imap.yml, examples/minimal.yml, examples/job-search.yml.

O bloco de conta é a única parte que difere por provedor. IMAP:

accounts:
  - name: personal
    provider: imap
    imap:
      host: imap.fastmail.com
      port: 993                  # default
      tls: true                  # default
      username: you@fastmail.com
      password_cmd: pass show mail/fastmail   # stdout is the password
      mailboxes: [INBOX, Archive]             # default [INBOX]
      initial_lookback: 720h                  # default

Gmail:

state_dir: ~/.local/state/mail-muncher

on_message_failure: quarantine   # or: abort
on_degraded_filter: hold         # or: fail, proceed

accounts:
  - name: personal
    provider: gmail
    gmail:
      credentials_file: ~/.config/mail-muncher/credentials.json
      token_file: ~/.config/mail-muncher/token.json
      query: "-in:chats"
      initial_lookback: 2160h

rules:
  - name: job-search
    account: personal
    match:
      any:
        - from_domains_file: ~/.local/share/jobsearch/domains.txt
        - subject_regex: "(?i)your application"
    dest: ~/Mail/job-search
    formats: [eml, markdown]
ChaveTipoPadrãoDescrição
state_dircaminho~/.local/state/mail-muncherCursors de sincronização (um arquivo JSON por conta), o bloqueio de ciclo, o bloqueio de instância e o diretório de quarentena.
on_message_failurequarantine, abortquarantineO que fazer com uma mensagem que não pode ser analisada ou que um sink falhou. Veja abaixo.
on_degraded_filterhold, fail, proceedholdO que fazer quando o from_domains_file ou from_regex_file de uma regra não pode ser lido. Veja abaixo.
quarantine_dircaminho<state_dir>/quarantineOnde mensagens em quarentena são estacionadas.
accountslista—Caixas de correio para buscar. Pelo menos uma é obrigatória.
accounts[].namestring—Obrigatório, único. Nomeia o arquivo de estado e é ao que rules[].account se refere.
accounts[].providerimap, gmail—Obrigatório; não há padrão. Qual backend busca. Veja Duas maneiras de conectar uma caixa de correio.
accounts[].imapmapeamento—Obrigatório — e somente permitido — quando o provedor é imap.
accounts[].imap.hoststring—Obrigatório. imap.fastmail.com, imap.gmail.com, 127.0.0.1 para a Proton Bridge.
accounts[].imap.portinteiro993993 é TLS implícito (IMAPS) e combina com o padrão tls: true.
accounts[].imap.usernamestring—Obrigatório. Geralmente o endereço completo; alguns provedores querem a parte local simples.
accounts[].imap.password_cmdcomando shell—Obrigatório. Executado sob /bin/sh -c; o stdout dele é a senha. Deliberadamente não há chave password — o segredo permanece no seu gerenciador de senhas.
accounts[].imap.mailboxeslista de strings[INBOX]Pastas para buscar, cada uma com seu próprio cursor. Um nome também serve como valor do predicado label. Uma pasta que o servidor não tem é um erro, não uma pasta vazia.
accounts[].imap.tlsbooleanotrueTLS implícito na conexão. false envia a senha e cada corpo em texto claro; validate avisa. Legítimo apenas em loopback ou atrás de um stunnel.
accounts[].imap.initial_lookbackduração Go720hAté onde uma primeira sincronização de cada caixa de correio alcança, e novamente após qualquer mudança de UIDVALIDITY. Deve ser positivo.
accounts[].gmailmapeamento—Obrigatório — e somente permitido — quando o provedor é gmail.
accounts[].gmail.credentials_filecaminho—Obrigatório. O JSON de cliente OAuth baixado do Google Cloud.
accounts[].gmail.token_filecaminho—Obrigatório. Onde auth armazena em cache o token OAuth, modo 0600.
accounts[].gmail.querystringnenhumExpressão de busca Gmail opcional. Uma otimização de custo apenas para a varredura inicial — veja abaixo.
accounts[].gmail.initial_lookbackduração Go720hAté onde a varredura inicial alcança. Deve ser positivo. Veja Backfill.
accounts[].gmail.include_spam_trashbooleanofalseBuscar mensagens em Spam e Lixeira. Honrado de forma idêntica por ambos os caminhos de sincronização Gmail. validate avisa quando verdadeiro. Veja Spam e Lixeira.
ruleslista—Avaliada em ordem contra cada mensagem; a primeira correspondência vence.
rules[].namestring—Obrigatório, único. Aparece em logs e no frontmatter markdown.
rules[].accountstringtodas as contasRestringe a regra a uma conta.
rules[].matchnó de correspondência—Obrigatório. Veja Filtros.
rules[].destcaminho—Obrigatório. Diretório de destino; criado sob demanda.
rules[].formatslista de eml, markdown[eml]Renderizações para gravar.

Notas que pegam as pessoas:

  • Chaves desconhecidas são um erro grave. Um erro de digitação falha o carregamento em vez de ser ignorado, então validate captura initial_lookbak antes de uma execução acontecer.
  • ~ e $VAR são expandidos em todo campo com valor de caminho, incluindo valores de from_domains_file e from_regex_file dentro de uma árvore de correspondência. Formas ~user não são suportadas. Uma variável indefinida expande para a string vazia, como em um shell.
  • gmail.query não filtra o que é mantido e se aplica a menos do que você pensa. Ele é enviado ao Gmail na primeira varredura de uma conta e em nenhum outro lugar — não em ciclos incrementais, e não em uma varredura de recuperação após o cursor de histórico expirar. Ele nunca é reaplicado localmente. Suas regras são a única autoridade sobre o que é armazenado. Mantenha a consulta ampla ou omita-a.
  • Spam e Lixeira não são buscados por padrão (Gmail). Ambos os caminhos de sincronização do Gmail concordam nisso: varreduras completas passam includeSpamTrash=false, e o caminho incremental descarta mensagens rotuladas como SPAM ou TRASH antes de chegarem ao pipeline. Defina gmail.include_spam_trash: true para buscá-las mesmo assim — veja Spam e Lixeira. No IMAP não há chave equivalente: você busca exatamente as pastas que lista em mailboxes:, então simplesmente não listar a pasta de lixo é todo o mecanismo.
  • Os blocos gmail: e imap: são mutuamente exclusivos. Definir aquele que não corresponde a provider: é um erro grave em vez de um bloco silenciosamente ignorado, então um bloco imap: sob uma conta Gmail não pode deixar você acreditando que está buscando via IMAP quando não está.

Políticas para as duas coisas que podem dar errado

Ambas as chaves ficam no nível superior, ao lado de state_dir. Os padrões são as escolhas seguras; você só as altera se decidiu qual falha prefere.

on_message_failure — uma mensagem que não será analisada, ou onde cada renderização que sua regra pediu falhou ao gravar.

ValorComportamento
quarantine (padrão)Grava os bytes brutos em <quarantine_dir>/<account>/<id>.eml com um sidecar .json nomeando a falha, então deixa o cursor avançar além da mensagem. Nada é perdido, e uma mensagem venenosa não pode travar o pipeline. Contado como quarantined no resumo e manifesto; a execução ainda sai com 0.
abortRetorna a falha, então o cursor não avança e a mensagem é re-buscada no próximo ciclo. A troca é explícita: uma mensagem permanentemente não analisável trava a conta até que um humano lide com ela.

Uma gravação de quarentena que falha cai para a semântica de abort para essa mensagem — recusar avançar é recuperável, perder a mensagem não é.

on_degraded_filter — o from_domains_file ou from_regex_file de uma regra está ausente, ilegível, ou truncado no meio. Tal arquivo não corresponde a nada, então sem uma política cada mensagem naquele ciclo seria avaliada contra uma lista vazia, considerada não correspondente e consumida.

ValorComportamento
hold (padrão)Executa o ciclo e armazena tudo que correspondeu, registra a degradação no nível de erro, mas não salva o cursor avançado — então o mesmo e-mail é reavaliado quando o arquivo retorna. O manifesto relata degraded e state_held. Saída 0.
failEncerra o ciclo antes que qualquer coisa seja buscada. Nada armazenado, nada avançado, saída não zero.
proceedTrata uma lista ilegível como vazia e avança mesmo assim. O comportamento antigo, e a única opção que aceita perda silenciosa de e-mails desejados — validate avisa sobre isso.

Arquivos já armazenados sob hold permanecem armazenados: os sinks são idempotentes, então a re-execução os ignora.

Filtros

Referência completa e livro de receitas: docs/filters.md.

Um valor match: é um mapeamento com exatamente uma chave — um combinador ou um predicado. Duas chaves em um mapeamento é um erro de compilação que diz para combiná-las com all: ou any:. Regexes e durações são compilados quando a configuração carrega, então um padrão ruim é uma falha de validate, não uma surpresa às 3h da manhã.

Combinadores

ChaveValorCorresponde quando
alllista de nóstodo filho corresponde (pelo menos um filho é necessário)
anylista de nóspelo menos um filho corresponde (pelo menos um filho é necessário)
notum único nóo filho não corresponde
match:
  all:
    - any:
        - from_domains: [acme.com]
        - from_domains_file: ~/.local/share/agent/domains.txt
    - not:
        subject_regex: "(?i)^\\[newsletter\\]"

Predicados

ChaveValorCorresponde quando
from_domainslista de domínioso domínio de qualquer endereço From é igual ou um subdomínio de um domínio listado
from_domains_filecaminhoo mesmo, com a lista lida de um arquivo de propriedade externa a cada ciclo
from_regexpadrão RE2o padrão corresponde a qualquer addr-spec From (sem nome de exibição)
from_regex_filecaminhoo mesmo, com os padrões lidos de um arquivo de propriedade externa a cada ciclo
to_regexpadrão RE2o padrão corresponde a qualquer addr-spec To ou Cc
subject_regexpadrão RE2o padrão corresponde ao Subject decodificado
header{name: X-Foo, regex: ...}o padrão corresponde a qualquer valor desse cabeçalho
has_attachmenttrue / falsea mensagem tem (ou não tem) um anexo real
labelnome do rótuloa mensagem carrega esse rótulo do provedor, comparado exatamente. No Gmail, isso é um rótulo do Gmail; no IMAP, é o nome da caixa de correio de onde a mensagem veio
older_thanduração Goa mensagem Date está mais no passado do que a duração
newer_thanduração Goa mensagem Date é mais recente do que a duração

Um exemplo trabalhado de cada:

# Mail from a company or any of its subdomains.
- from_domains: [acme.com, globex.io]

# The same list, owned and updated by another program.
- from_domains_file: ~/.local/share/jobsearch/domains.txt

# A specific sender, however they capitalize it.
- from_regex: "(?i)^no-?reply@acme\\.com$"

# Patterns owned and updated by another program, for senders whose host cannot
# be enumerated in advance (wagepoint.teamtailor.com, mail.wagepoint.com).
- from_regex_file: ~/.local/share/jobsearch/companies.txt

# Anything addressed to a plus-alias you hand out to vendors.
- to_regex: "(?i)^me\\+vendors@example\\.com$"

# Application acknowledgements, case-insensitively.
- subject_regex: "(?i)(your application|application received)"

# Everything a mailing list tags for you.
- header: {name: List-Id, regex: "golang-nuts"}

# Only messages that actually carry a file.
- has_attachment: true

# On Gmail: labels exactly as shown in the UI. Nested labels use "Parent/Child";
# system labels are upper case (INBOX, SENT, UNREAD, STARRED).
# On IMAP: the mailbox the message came from, verbatim as the server names it,
# including its hierarchy separator ("Lists/golang", "Lists.golang").
- label: INBOX

# Message Date older than 90 days / newer than a day.
- older_than: 2160h
- newer_than: 24h

Detalhes que valem a pena saber:

  • from_regex e to_regex testam o endereço puro (jane@acme.com), nunca o nome de exibição. Use header: {name: From, regex: ...} para testar o cabeçalho bruto incluindo o nome de exibição.
  • has_attachment conta partes marcadas como Content-Disposition: attachment. Imagens inline referenciadas por cid: não são anexos.
  • label é sensível a maiúsculas e exato — label: inbox não corresponde a INBOX. Em uma conta IMAP, os valores são os nomes das caixas de correio que você listou sob imap.mailboxes, então uma mensagem pode carregar apenas aquela de onde foi buscada.
  • older_than / newer_than comparam com o cabeçalho Date da mensagem, caindo para a data interna do provedor quando o cabeçalho está ausente ou não analisável. Uma mensagem sem data utilizável não corresponde a nenhum.
  • Padrões são Go RE2: sem backreferences e sem lookaround. Prefixe com (?i) para insensibilidade a maiúsculas. Em YAML, prefira aspas duplas e escape barras invertidas ("\\."), ou use aspas simples onde nenhum escape é necessário.
  • Use true / false para has_attachment. YAML 1.2 trata yes e no como strings, e mail-muncher os rejeita.

Spam e Lixeira (Gmail)

Spam e Lixeira não são buscados por padrão. Nada nessas pastas alcança suas regras, e nada chega ao disco. Spam é a fonte mais provável de texto hostil e escrito por atacantes em um pipeline que termina na janela de contexto de um modelo, então o padrão é deixá-lo onde o Gmail o colocou.

Esta seção inteira é sobre o provedor Gmail. IMAP não tem chave equivalente porque não precisa: uma conta IMAP busca exatamente as pastas nomeadas em imap.mailboxes, então lixo chega apenas se você pedir por nome.

Se você quiser mesmo assim — uma mensagem legítima classificada erroneamente como spam, ou um arquivo genuinamente completo — defina a chave por conta:

accounts:
  - name: personal
    gmail:
      include_spam_trash: true   # validate warns; that is deliberate

As duas configurações fazem trabalhos diferentes, e você pode querer ambas:

Decide
gmail.include_spam_trashse essas mensagens são buscadas de forma alguma
Uma regra nos rótulos SPAM / TRASHo que acontece com elas uma vez buscadas

gmail.query não pode fazer nenhum dos dois trabalhos. Ele é enviado apenas na primeira varredura, então -in:spam lá não faz nada para qualquer ciclo posterior. Com include_spam_trash: true definido, discrimine com uma regra — o mecanismo de filtro é a única coisa que vê cada mensagem buscada:

rules:
  - name: job-search
    match:
      all:
        - from_domains_file: ~/.local/share/jobsearch/domains.txt
        - not:
            any:
              - label: SPAM
              - label: TRASH
    dest: ~/Mail/job-search

Os rótulos de sistema do Gmail são exatos e em maiúsculas. Se você quiser Spam e Lixeira fora de toda regra, coloque o not: em cada uma — não há exclusão global, por design: regras são a autoridade única sobre o que é armazenado.

Layout no disco

Os arquivos que mail-muncher grava são sua API pública. Esta seção é o tour; docs/output-format.md é o contrato — cada chave de frontmatter, por que o frontmatter precisa de um analisador YAML real, e as regras para enumerar uma árvore de entrega com segurança. Leia antes de escrever um consumidor, e veja examples/read_delivered.py para um correto e curto.

Cada sink arquiva uma mensagem sob o dest da regra pela data da mensagem, em UTC:

~/Mail/job-search/
└── 2026/
    └── 07/
        ├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.eml
        ├── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.md
        └── 1785230100-a00d5c5e383a1c08-re-your-application-for-senior-engineer.attachments/
            ├── offer.pdf
            └── R-sum-2026.docx

O nome base é compartilhado por todo formato, então as renderizações de uma mensagem se ordenam juntas:

<unix-seconds>-<sha256(account + ":" + message-id)[:16]>-<subject-slug>

O fragmento de digest é 16 caracteres hexadecimais — 64 bits. Duas mensagens colidindo nele não é alcançável em qualquer volume que uma caixa de correio produza, e leitores do arquivo o analisam de volta como um id de mensagem, então trate a largura como parte do layout.

  • O timestamp ordena um diretório cronologicamente.
  • O digest é a chave de idempotência. Ele depende apenas do nome da conta e do id de mensagem do provedor, então o caminho é uma função pura da identidade da mensagem.
  • O slug é o assunto em minúsculas, com cada caractere fora de [a-z0-9] colapsado para um único -, aparado e truncado para 40 caracteres.

Duas ressalvas sobre o slug, ambas deliberadas:

  • É apenas ASCII. Um assunto escrito inteiramente em um script não latino, ou inteiramente em emoji, vira slug no-subject. Nomes de arquivo não ASCII estariam sujeitos à normalização Unicode do sistema de arquivos (HFS+ armazena NFD), o que pode fazer o nome gravado diferir do nome que o próximo ciclo verifica — e essa verificação de existência é toda a história da idempotência. O digest ainda mantém tais mensagens separadas.
  • É cosmético. Apenas o digest carrega identidade. Duas mensagens com o mesmo assunto nunca colidem.

Como os arquivos são gravados

Um arquivo de mensagem é gravado em um arquivo temporário em seu diretório de destino, sincronizado, e então hard-linkado no lugar com link(2). Três consequências que valem a pena confiar:

  • Um arquivo parcial nunca é publicado. O arquivo temporário está completo antes do nome existir.
  • Um arquivo existente nunca é sobrescrito. link(2) falha com EEXIST em vez de sobrescrever, ao contrário de rename(2). Essa falha é a verificação de idempotência — o kernel decide se o nome está livre no instante em que é reivindicado, então não há janela em que outro gravador possa inserir um arquivo e tê-lo silenciosamente substituído. "Já está lá" é relatado como skipped.
  • Symlinks são recusados, não seguidos. Um symlink no caminho final de uma mensagem, ou representando o diretório <YYYY> ou <MM> abaixo de dest, é um erro para o qual a mensagem é contada e registrada. Nada no layout é legitimamente um link, então um significa que outra coisa os está colocando lá. O próprio dest: da regra é isento — apontá-lo para outro volume é comum.

Em um sistema de arquivos sem hard links (FAT, alguns mounts de rede), o fallback é uma gravação O_CREAT|O_EXCL no lugar: ainda atomicamente sem sobrescrita e ainda à prova de symlink, ao custo da garantia de não haver arquivo parcial.

Anexos são a única exceção: eles são gravados com um arquivo temporário e rename(2), porque seus nomes não são o marcador de idempotência — o .md acima deles decide isso. Diretórios são criados com 0700 e arquivos com 0600. Correspondência arquivada é correspondência privada e anexos decodificados, então recebe o mesmo tratamento que os cursores de sincronização e o token OAuth: nada aqui é legível por outros usuários locais. Uma ferramenta que você executa como você mesmo não é afetada.

O arquivo .eml

model.Message.Raw, byte por byte, exatamente como o provedor o entregou. Nada é recodificado, re-embrulhado ou normalizado, então ele passa por qualquer ferramenta de e-mail e ainda verifica contra assinaturas DKIM. Esta é a cópia de fidelidade.

O arquivo .md

Frontmatter YAML, depois o corpo, depois links para quaisquer anexos.

  • Seleção do corpo: a parte text/plain se houver; caso contrário, a parte text/html convertida para markdown; caso contrário, o literal *(no body)*. Finais de linha são normalizados para LF, espaços em branco finais são removidos por linha, e linhas em branco iniciais e finais são aparadas.
  • Frontmatter sempre carrega subject, from, from_address, from_addresses, to, to_addresses, date, message_id, thread_id, thread_id_source, account, rule. cc, cc_addresses, in_reply_to, labels e attachments são omitidos quando vazios. Analise endereços dos campos *_address / *_addresses, nunca de from/to/cc — esses são strings de exibição, e um nome de exibição escolhido pelo remetente contendo <, > ou , os torna ambíguos. Os campos legíveis por máquina carregam addr-specs simples e não podem ser falsificados dessa forma. É produzido com um codificador YAML, não com formatação de string, então um assunto cheio de aspas e dois-pontos não pode quebrar a análise — o que também significa que você precisa de um analisador YAML real para lê-lo. Um assunto com emoji chega entre aspas duplas com um escape \U0001F389, e um assunto contendo uma nova linha chega como um bloco escalar |-. Um divisor key: value erra em ambos. Veja docs/output-format.md.
  • Threading são três campos, não quatro. thread_id é a chave de junção e é nunca vazio: o id de conversa do próprio provedor quando houver um, caso contrário, um sintetizado a partir da cadeia References da mensagem. Agrupe um diretório por ele sem casos especiais. thread_id_source diz o quanto confiar nesse agrupamento — provider, references, in_reply_to ou self — porque a reconstrução é de melhor esforço e um mailer que quebra a cadeia divide um thread. in_reply_to nomeia o pai. A cadeia completa References é deliberadamente omitida: ela é ilimitada, e o .eml ao lado do arquivo a tem verbatim.
  • Anexos são escritos em <basename>.attachments/ ao lado do .md, com nomes de arquivo sanitizados (sem componentes de diretório, sem travessia de caminho), uma extensão .md ou .eml neutralizada para .md.attachment / .eml.attachment, e colisões de-duplicadas como name-2.pdf, name-3.pdf. Eles são escritos antes do .md, então o documento nunca linka para um arquivo que não está lá. Seus conteúdos são escolhidos pelo remetente — veja a regra de enumeração abaixo.
  • Imagens inline cid: não são resolvidas. Um corpo HTML que incorpora imagens por id de conteúdo renderiza como ![alt](cid:...) — um link não resolvido, não um caminho para o diretório de anexos. Se você precisar dos bytes da imagem, eles estão no .eml. Esta é uma limitação conhecida, não um bug.
  • O .md não é um formato de fidelidade. Qualquer coisa que importe byte-exatamente deve ser lida do .eml.

Enumerando uma árvore de entrega

O conjunto autoritativo de mensagens entregues é exatamente <dest>/<YYYY>/<MM>/*.md (ou *.eml) — dois níveis de profundidade, nunca um glob recursivo:

find "$dest" -mindepth 3 -maxdepth 3 -type f -name '*.md'   # correct
find "$dest" -name '*.md'                                    # WRONG

Um glob recursivo desce em <basename>.attachments/, onde os arquivos vieram de quem enviou o e-mail. Um atacante que consegue fazer uma regra corresponder pode anexar um arquivo contendo frontmatter forjado e ter um consumidor descuidado lendo-o como uma mensagem entregue com um from:, subject: e corpo arbitrários. Qualquer coisa sob um diretório .attachments/ é controlada pelo remetente e nunca deve ser analisada como uma mensagem.

Se o seu consumidor executa o próprio mail-muncher, run --json é ainda melhor: ele lista exatamente os caminhos que este ciclo escreveu, então não pode ser confundido por nada dest=~/Mail/job-search # o dest da regra

sentado na árvore.

Corpos de mensagens são texto controlado pelo atacante. Filtrado não é verificado. Trate o conteúdo do corpo como dados, nunca como instruções, e não conceda autoridade a ele meramente porque chegou através do mail-muncher.

docs/output-format.md tem o contrato completo, e examples/read_delivered.py é um leitor funcional.

Comandos

mail-muncher [command]

  init        Write a starter config and print the next command to run
  run         Run one fetch/filter/store cycle
  daemon      Run fetch/filter/store cycles repeatedly on an interval
  mcp         Serve the stored mail archive to agents over MCP (stdio)
  auth        Authenticate interactively against a mail provider (Gmail only)
  validate    Parse the config, resolve referenced files, and report problems
  completion  Generate the autocompletion script for the specified shell

Qualquer comando que precise de uma configuração e não consiga encontrar uma imprime a orientação de configuração mostrada em passo 0 do quickstart — o caminho que ele procurou, o próximo comando, e o que cada provedor custa — em vez de um erro open: no such file.

Flags persistentes, disponíveis em todos os subcomandos:

FlagPadrãoDescrição
--config~/.config/mail-muncher/config.ymlCaminho para o arquivo de configuração.
--log-levelinfodebug, info, warn ou error. Logs são texto log/slog no stderr.
-v, --version—Imprime a versão.

Flags por comando:

ComandoFlagPadrãoDescrição
init--provider—imap ou gmail. Solicitado quando omitido; obrigatório com --yes, porque os dois caminhos custam coisas diferentes e não há padrão honesto.
init--accountpersonalNome para a conta que a configuração cria.
init--dest~/Mail/mail-muncherOnde a regra inicial escreve o e-mail correspondido.
init--host—Hostname do servidor IMAP, ex. imap.fastmail.com. Solicitado quando omitido; obrigatório com --yes no caminho IMAP.
init--username—Nome de usuário IMAP, geralmente o endereço completo. Solicitado quando omitido; obrigatório com --yes no caminho IMAP.
init--password-cmdpadrão da plataformaComando shell que imprime a senha do app no stdout. Padrão é Keychain no macOS, secret-tool no Linux, pass em outros lugares.
init--yesfalseNunca perguntar; usar o padrão para cada resposta que tem um honesto. Ainda requer --provider, e no IMAP --host e --username.
init--forcefalseSobrescrever uma configuração existente. Sem isso, init recusa e sai com 1 em vez de sobrescrever as regras e caminhos de credenciais de alguém.
run--dry-runfalseBuscar e avaliar, relatar o que seria escrito, não escrever nada e não salvar estado.
run--jsonfalseEscrever um manifesto legível por máquina no stdout, um objeto JSON por conta.
daemon--interval5mTempo entre ciclos, mínimo 30s. Cada sleep é jittered em até ±10%.
daemon--dry-runfalseComo run --dry-run, a cada tick.
daemon--jsonfalseEscrever um manifesto no stdout após cada ciclo: JSON delimitado por nova linha, um objeto por conta por tick.
auth--account—Qual conta autenticar. Obrigatório quando a configuração tem mais de uma. Apenas contas Gmail — em uma conta IMAP auth recusa, porque não há nada para autorizar.

mcp não aceita flags próprias; é configurado inteiramente por --config. Note que init escreve em --config em vez de ler dele, e cria o diretório (0700) e o arquivo (0600) conforme necessário.

--log-level debug registra a decisão da regra para cada mensagem — o id da mensagem, seu assunto, e o nome da regra vencedora ou no match — que é a maneira mais rápida de descobrir por que uma regra não está disparando. Debug também registra cada skip already stored com seu caminho. Tudo vai para o stderr, então nunca perturba --json no stdout.

Um intervalo abaixo de 30s é rejeitado antes que qualquer coisa comece:

error: --interval 10s is below the 30s minimum

Códigos de saída

CódigoSignificado
0Sucesso. Um ciclo que encontrou erros por mensagem (uma mensagem que não analisava, uma escrita de sink que falhou) ainda sai com 0 — esses são contados no resumo, não escalados.
1Erro de configuração ou validação: o arquivo não analisava, uma regra não compilava, uma chave obrigatória está faltando, ou não há arquivo de configuração. Nada foi buscado. Também o que init retorna quando uma configuração já existe e --force não foi dado.
2Falha de provedor ou autenticação: um token Gmail que nunca foi escrito ou que o Google rejeitou, um password_cmd IMAP que falhou ou um login que o servidor recusou, o servidor inacessível após tentativas.
3Outra instância segura o lock do ciclo. Código de saída 3 é como uma invocação cron sobreposta relata "a anterior ainda está em andamento".

Uma mensagem em quarentena não muda o status de saída: é uma falha no nível da mensagem que foi tratada, contada como quarantined, e relatada no manifesto. Alerte no contador, não no código de saída.

daemon nunca sai com 2. Um ciclo com falha é registrado com uma contagem de falhas consecutivas e tentado novamente no próximo tick — um daemon que saísse no primeiro token expirado precisaria de um humano exatamente no momento errado. Seus status são 0 (parado por SIGINT ou SIGTERM, depois de deixar o ciclo em andamento terminar e salvar o estado), 1 (erro de configuração ou validação, incluindo um --interval abaixo do mínimo), e 3 (outra instância já segurava o lock de instância ou ciclo no início). Um lock segurado por um tick posterior — um run cron sobrepondo um tick do daemon — é registrado e pulado, não fatal.

O resumo da execução

Cada ciclo registra e imprime uma linha de resumo por conta:

personal: fetched=128 matched=6 stored=6 skipped=0 parse_errors=0 sink_errors=0 quarantined=0 duration=4.1s
CampoContaSignificado
fetchedmensagensMensagens que o provedor entregou neste ciclo.
matchedmensagensMensagens que alguma regra reivindicou.
storedrenderizaçõesRenderizações realmente escritas.
skippedrenderizaçõesRenderizações não escritas porque o destino já existia.
parse_errorsmensagensMensagens que não analisavam; registradas e puladas.
sink_errorsrenderizaçõesFalhas de escrita; registradas e contadas, o ciclo continua.
quarantinedmensagensMensagens estacionadas sob o diretório de quarentena porque não puderam ser entregues.
vanishedmensagensMensagens que o provedor listou e depois descobriu que não existiam mais — deletadas entre a listagem e o download. Puladas, e o cursor avançou além delas. Apenas impresso quando não é zero.

A lista de campos roda contiguamente de fetched= a duration=, então qualquer coisa incomum sobre o ciclo é marcada no rótulo da conta em vez disso:

personal (dry-run): fetched=42 ...
personal (degraded, state held): fetched=42 ...
personal (stopped): fetched=12 ...

vanished=N é o único contador anexado condicionalmente. Ele aparece depois de quarantined= apenas nas execuções onde uma mensagem desapareceu no meio do ciclo, e é omitido em todos os outros lugares, então a lista de campos publicada é inalterada para todas as outras execuções. Sob --json é summary.vanished, igualmente omitido quando zero — analise-o como .summary.vanished // 0 se quiser alertar sobre ele. Uma execução cron em estado estável se parece com fetched=0. Uma reexecução sobre a mesma janela se parece com matched=N stored=0 skipped=N — isso é a idempotência funcionando.

O manifesto JSON

run --json e daemon --json substituem essa linha por um objeto legível por máquina por conta — stored[], skipped[], quarantined[], os contadores e se o cursor avançou. O stderr mantém cada linha de log, então run --json 2>/dev/null é JSON puro e daemon --json é um fluxo NDJSON.

mail-muncher run --json 2>/dev/null | jq -r '.stored[] | "\(.rule)\t\(.path)"'

Contrato completo campo por campo: docs/manifest.md.

Estado e bloqueio

~/.local/state/mail-muncher/
├── personal.json                # one per account, mode 0600
├── mail-muncher.lock            # cycle lock, shared by run, daemon and mcp sync
├── instance/
│   └── mail-muncher.lock        # daemon lifetime lock — one daemon per state dir
└── quarantine/
    └── personal/
        ├── 18f2a1b2c3.eml       # raw bytes of an undeliverable message
        └── 18f2a1b2c3.json      # why it is here

O arquivo de estado de cada conta é JSON. O cursor dentro dele é o do provedor, então sua forma difere. Gmail:

{
  "history_id": 918273,
  "last_sync_time": "2026-07-28T09:15:00Z",
  "seen_ids": ["18f2a...", "18f2b..."]
}
  • history_id é o cursor incremental do Gmail. Quando está definido, um ciclo pergunta ao Gmail apenas o que mudou desde então. O Gmail mantém aproximadamente uma semana de histórico; quando o cursor expira, a API responde 404, o mail-muncher registra um aviso, limpa o cursor e volta a uma varredura completa no mesmo ciclo.
  • last_sync_time limita o termo after: de uma varredura completa. Uma varredura de recuperação alcança 24 horas além do watermark armazenado, para que e-mails que chegaram enquanto o ciclo anterior estava listando não sejam ignorados. A sobreposição é inofensiva: tudo o que já está no disco é pulado na gravação.
  • seen_ids é um conjunto FIFO dos últimos 2000 IDs de mensagens entregues — cinto e suspensórios junto com os nomes de arquivo idempotentes dos sinks.

O IMAP rastreia cada caixa de correio de forma independente, como um par UIDVALIDITY/UID sob extra:

{
  "last_sync_time": "2026-07-28T09:15:00Z",
  "extra": {
    "imap.INBOX.uidvalidity": "1650000000",
    "imap.INBOX.last_uid": "48213"
  },
  "seen_ids": ["personal:INBOX:1650000000:48213"]
}

Um ciclo cujo UIDVALIDITY armazenado ainda corresponde ao do servidor pede UID FETCH 48214:* — apenas o que chegou desde então. Um ciclo que descobre que ele mudou descarta o UID armazenado e ressincroniza a partir de initial_lookback, porque a mudança de UIDVALIDITY é o protocolo anunciando que cada UID que o mail-muncher lembra agora nomeia uma mensagem diferente. Essa ressincronização re-arquiva a janela que cobre sob nomes de arquivo novos, já que a identidade de uma mensagem neste caminho é <account>:<mailbox>:<uidvalidity>:<uid>. A troca é deliberada: e-mails duplicados podem ser excluídos, e-mails pulados silenciosamente não podem ser recuperados. Detalhe completo: docs/configuration.md.

O diretório de estado é criado com 0700 e os arquivos de estado com 0600: saber quais contas existem e quais IDs de mensagens foram vistos não é informação pública.

Excluir o arquivo de estado de uma conta força uma nova varredura completa limitada por initial_lookback. Isso é seguro — os sinks pulam tudo o que já está no disco — e é a forma suportada de se recuperar de um cursor corrompido.

Os dois bloqueios

  • O bloqueio de ciclo, <state_dir>/mail-muncher.lock, é baseado em flock e mantido durante a duração de cada ciclo por run, por cada tick do daemon e pela ferramenta sync do MCP. É o que impede uma invocação cron de competir com um daemon em execução nos mesmos cursores. Encontrá-lo mantido significa "agora não", não "quebrado": run sai com código 3, e um tick do daemon registra e pula.

  • O bloqueio de instância, <state_dir>/instance/mail-muncher.lock, é mantido por um daemon durante toda a vida do seu processo. O bloqueio de ciclo não pode fazer esse trabalho — ele é liberado entre ticks, então um segundo daemon iniciando enquanto o primeiro dorme passaria por ele e então faria polling para sempre ao lado dele, dobrando o tráfego de API contra os mesmos cursores. Um segundo daemon sai com código 3 imediatamente:

    another mail-muncher daemon is already running against this state directory; not starting
    

Ambos são liberados pelo SO se o processo falhar.

Quarentena

Sob o padrão on_message_failure: quarantine, uma mensagem que não pôde ser entregue é gravada em <quarantine_dir>/<account>/<id>.eml — os bytes brutos, verbatim — com um sidecar .json ao lado nomeando a regra, o estágio que falhou (parse ou sink), o erro e a hora. O sidecar é autocontido de propósito: varrer o diretório não deve exigir os logs da execução.

Nada reentrega automaticamente. Corrija a causa e alimente o .eml de volta manualmente. O diretório é 0700 e os arquivos 0600, como o resto do diretório de estado — uma mensagem em quarentena é um e-mail inteiro sentado fora da sua árvore de destino.

Agendamento

cron — um ciclo a cada dez minutos. run sai com código 3 se a invocação anterior ainda estiver em andamento, então sobreposições são inofensivas:

*/10 * * * * /usr/local/bin/mail-muncher run --config /home/you/.config/mail-muncher/config.yml >> /home/you/.local/state/mail-muncher/cron.log 2>&1

Use um caminho absoluto para o binário e para a configuração: o ambiente do cron não é o do seu shell, e a expansão de ~ na configuração depende de $HOME estar definido.

Um exemplo mais completo com o ambiente que o cron não fornece está em contrib/crontab.sample.

launchd (macOS) — execute o daemon sob launchd para que ele sobreviva ao logout e reinicie em caso de falha. Um plist pronto para editar está em contrib/launchd/. Copie-o, edite os quatro caminhos absolutos dentro da cópia — launchd não expande nem ~ nem $HOME — então carregue-o:

cp contrib/launchd/com.craigjmidwinter.mail-muncher.plist ~/Library/LaunchAgents/
# edit the four paths in the copy, then:
launchctl load ~/Library/LaunchAgents/com.craigjmidwinter.mail-muncher.plist

systemd — nenhuma unit é enviada ainda; mail-muncher daemon --interval 5m é um serviço Type=simple direto, ou emparelhe mail-muncher run com um timer.

Backfill: a primeira execução

Uma primeira execução ampla é um modo intencional, não um abuso. O padrão initial_lookback é 720h (30 dias), o que silenciosamente significa que a coisa que você mais quer no primeiro dia — todo o histórico do que você está coletando, no disco, uma vez — é a coisa que não acontece por padrão.

Defina-o amplo para a primeira execução. A chave existe em ambos os provedores, sob qualquer bloco que sua conta use:

Para interrompê-lo, launchctl unload o mesmo caminho. Removê-lo permanentemente significa excluir esse arquivo também — veja Desinstalação.

accounts:
  - name: personal
    gmail:
      credentials_file: ~/.config/mail-muncher/credentials.json
      token_file: ~/.config/mail-muncher/token.json
      initial_lookback: 13140h   # ~18 months — first run only
accounts:
  - name: personal
    provider: imap
    imap:
      host: imap.fastmail.com
      username: you@fastmail.com
      password_cmd: pass show mail/fastmail
      initial_lookback: 13140h   # ~18 months — first run only, per mailbox

Então execute uma vez e reduza o valor de volta depois:

      initial_lookback: 720h

Reduzi-lo de volta é seguro porque initial_lookback apenas limita a varredura primeira de todas — de uma conta no Gmail, de cada caixa de correio no IMAP. Cada ciclo posterior retoma do cursor armazenado e ignora a chave completamente. Ela é rearmada apenas excluindo o arquivo de estado da conta, ou, no IMAP, pelo servidor mudando o UIDVALIDITY.

O que esperar:

  • É uma grande varredura completa, com limite de taxa e tentativas, e pode levar um tempo. No Gmail, os downloads ocorrem quatro por vez e as páginas têm 500 mensagens; nenhum dos dois é configurável. No IMAP, torna-se uma busca UID SINCE por caixa de correio.

  • Olhe antes de se comprometer. mail-muncher run --dry-run busca e avalia exatamente como uma execução real e relata cada caminho que escreveria, sem tocar na árvore de destino. Adicione --json para contá-los:

    mail-muncher run --dry-run --json 2>/dev/null | jq '.summary'
    
  • A execução seguinte deve relatar tudo como skipped. Esse é o check de que o backfill realmente foi aplicado.

Durações Go não têm unidade de dia ou ano — multiplique horas. 18 meses é 13140h, um ano é 8760h, 90 dias é 2160h.

O limite de 2000 entradas do conjunto visto não limita um backfill. Veja docs/architecture.md para o porquê: o nome de arquivo determinístico mais pular-se-existe é a chave real de idempotência, e o conjunto visto é apenas cinto e suspensórios.

Status e escopo

Pré-1.0. A versão atual é v0.4.0, e o que mudou em cada uma está em CHANGELOG.md; uma build sem make relata sua versão como dev. O esquema de configuração é estável o suficiente para escrever contra ele, mas trate-o como sujeito a mudanças até 1.0.

ÁreaStatus
Provedor IMAP (password_cmd, cursores UID por caixa de correio, EXAMINE + BODY.PEEK[])Construído
Provedor Gmail (OAuth, varredura completa, sincronização incremental de histórico, download RAW)Construído
init — grava uma configuração validada para qualquer provedor, interativo ou scriptadoConstruído
Orientação de configuração em todo comando não configurado ou parcialmente configuradoConstruído
mcp servindo corretamente o protocolo quando não configuradoConstruído
Carregamento e validação de configuraçãoConstruído
Motor de filtros (todos os combinadores e predicados listados acima)Construído
Sinks .eml e markdownConstruído
run, daemon, bloqueio de ciclo, bloqueio de instânciaConstruído
Manifestos de execução --jsonConstruído
mcp — servidor MCP stdio, cinco ferramentasConstruído
Quarentena e as duas políticas de falhaConstruído

Deliberadamente fora do escopo

  • Escrever na sua caixa de correio. Somente leitura é uma restrição de design, não uma fase. Sem rotulagem, sem exclusão, sem envio, sem rascunhos e, no IMAP, nem mesmo marcar uma mensagem como lida.
  • Ser um cliente de e-mail. Sem UI e sem índice. search_messages é uma varredura de substring sem distinção de maiúsculas/minúsculas sobre os arquivos armazenados, não um mecanismo de busca: sem stemming, sem classificação, sem linguagem de consulta. Threads são agrupadas por um id carregado em cada mensagem, não remontadas em um modelo de conversa.
  • Uma API de rede. Nada escuta em um socket. mcp fala um protocolo stdio com um cliente que o lançou como subprocesso — sem porta, sem endpoint HTTP, sem superfície de rede. A única coisa que sempre faz bind é a porta de loopback auth que abre por alguns segundos durante o redirecionamento OAuth no caminho do Gmail.
  • Empacotar um cliente OAuth. gmail.readonly é um escopo restrito do Google, então o caminho do Gmail sempre significará registrar o seu próprio — que é exatamente por que o IMAP existe como o caminho de baixo atrito para entrar.

Limitações conhecidas

  • Imagens inline cid: são deixadas como links não resolvidos na saída markdown.
  • Slugs de assunto são apenas ASCII; assuntos não latinos são arquivados como no-subject.
  • Nenhum predicado vê o corpo da mensagem; a correspondência é em cabeçalhos, rótulos e datas.

Gmail:

  • O token do Gmail expira a cada 7 dias em uma tela de consentimento em modo Testing, então mail-muncher auth é uma tarefa semanal. Isso é política do Google, não uma configuração.
  • gmail.query aplica-se apenas à varredura primeira de todas de uma conta — não a ciclos incrementais e não a uma varredura de recuperação após o cursor expirar.
  • Spam e Lixeira não são buscados de forma alguma, a menos que gmail.include_spam_trash: true. Uma vez buscados, uma regra nos rótulos SPAM e TRASH decide o que acontece com eles.
  • A concorrência de download do Gmail (4) e o tamanho da página (500) não são configuráveis.

IMAP:

  • Uma senha de app é uma credencial completa de e-mail. Somente leitura é aplicada pelo código do mail-muncher, não pelo seu provedor — veja a comparação.
  • Um servidor mudando UIDVALIDITY re-arquiva a janela initial_lookback sob nomes de arquivo novos. Deliberado: e-mail duplicado é recuperável, e-mail pulado não é.
  • O IMAP não tem id de conversa no lado do servidor, então thread_id é sempre sintetizado a partir da cadeia References/In-Reply-To e thread_id_source nunca é provider.
  • Uma caixa de correio que o servidor não tem é um erro, não uma pasta vazia — então um erro de digitação em mailboxes: falha a primeira execução em vez de parecer e-mail silencioso.
  • Apenas as pastas listadas em mailboxes: são buscadas; não há opção "tudo" e nenhuma busca no lado do servidor.

Documentação

Navegável em https://craigjmidwinter.github.io/mail-muncher/, ou neste repositório:

  • docs/configuration.md — todas as chaves de configuração para ambos os provedores, suas regras de validação e seus modos de falha. A seção accounts[].imap é a referência IMAP; não há página de configuração separada porque não há configuração além de uma senha de aplicativo.
  • docs/gmail-setup.md — apenas o caminho Gmail: passo a passo do Google Cloud, a expiração de sete dias e cada mensagem de erro OAuth com sua correção. Nada disso se aplica a uma conta IMAP.
  • docs/filters.md — a linguagem completa de árvore de correspondência, além de um livro de receitas com regras reais.
  • docs/output-format.md — o contrato em disco: layout, nomes de arquivos, cada chave de frontmatter e as regras de segurança para enumerar uma árvore de entrega. Leia antes de escrever um consumidor.
  • docs/manifest.md — o contrato de manifesto --json, campo por campo.
  • docs/mcp.md — o servidor MCP: conexão do cliente e os argumentos e formatos de retorno de cada ferramenta.
  • docs/architecture.md — o pipeline, suas junções e onde sua alteração se encaixa.
  • CONTRIBUTING.md — build, testes e as convenções que o código segue.

Configs executáveis: examples/imap.yml (o caminho IMAP, bem comentado), examples/minimal.yml (a menor configuração Gmail útil), examples/job-search.yml (um arquivo de filtro gerenciado externamente em uso real) e examples/read_delivered.py (um consumidor correto). Todas as três configs passam em mail-muncher validate --config <file>.

Agradecimentos

A marca é definida em Silkscreen por Jason Kottke, usada sob a SIL Open Font License 1.1. Os ativos da marca, a paleta e as regras de uso estão em branding/BRAND.md.

Licença

MIT. Veja LICENSE.