mail-muncher

Correo estrictamente de solo lectura para agentes: reglas de filtro ordenadas archivan mensajes coincidentes de cualquier buzón IMAP o la API de Gmail al disco como .eml más markdown, servidos de vuelta a través de MCP.

Documentación

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

mail-muncher

CI Go Reference Go version Release License: MIT

Dale a un programa su propio buzón de solo lectura, filtrado exactamente al correo que pidió, entregado como archivos en disco.

mail-muncher extrae mensajes de un proveedor de correo, evalúa cada uno contra reglas ordenadas y escribe las coincidencias en un directorio — fiel al byte .eml, y opcionalmente una representación en markdown con los encabezados como frontmatter YAML, el cuerpo como texto y los adjuntos extraídos junto a él. Una regla puede tomar su entrada de filtro de un archivo de texto plano que algún otro programa posee, que mail-muncher vuelve a leer al inicio de cada ciclo. Ese otro programa cambia una línea en ese archivo, y el siguiente ciclo entrega correo diferente — sin editar configuración, sin reiniciar, sin redesplegar.

Lee desde cualquier buzón IMAP — Gmail, Fastmail, iCloud, Proton Bridge, una cuenta de trabajo, tu propio servidor — o desde la API de Gmail con un alcance OAuth de solo lectura. Se ejecuta una sola vez para cron, o como un demonio de sondeo, o como un servidor MCP stdio que un agente puede consultar directamente. Cada modo emite el mismo manifiesto legible por máquina de lo que hizo, y ningún modo escribe jamás en tu buzón.

Dos formas de conectar un buzón

Elige una antes de instalar nada. Ambas son compatibles, y todo lo posterior — reglas, formatos, nombres de archivo, la estructura del archivo, las herramientas MCP — es idéntico en ambos casos.

provider: imapprovider: gmail
Tiempo de configuración~2 min~10 min en la consola de Google Cloud
Qué registrasnadatu propio proyecto de Google Cloud y cliente OAuth de aplicación de escritorio
Credencialuna contraseña de aplicación desde la página de configuración de tu proveedorun token OAuth, alcance gmail.readonly
Qué tan amplia es esa credencialuna credencial de correo completa. Una contraseña de aplicación puede enviar y eliminarsolo lectura, y nada más
Quién hace cumplir la solo lecturael propio código de mail-muncherGoogle
Caducidadningunacada 7 días en una pantalla de consentimiento en modo Prueba; mail-muncher auth debe reejecutarse semanalmente
Dónde vive el secretodonde tu gestor de contraseñas ya lo guarda: password_cmd se ejecuta y su salida estándar es la contraseña. Deliberadamente no hay clave passwordtoken.json, modo 0600, escrito por mail-muncher auth
Qué buzoneslas carpetas que enumeras en mailboxes:; [INBOX] por defectotoda la cuenta de Gmail, menos Spam y Papelera a menos que las pidas
Funciona conGmail, Fastmail, iCloud, Proton Bridge, cuentas de trabajo, autoalojadosolo Gmail
Pasos adicionalesninguno. No hay comando auth en esta rutamail-muncher auth, después de docs/gmail-setup.md

Los ~2 min / ~10 min / 7 días anteriores son los mismos números que mail-muncher init y la guía de ejecución sin configurar imprimen, porque son los números que deciden esto.

La garantía de solo lectura es real en ambas rutas, pero no es la misma garantía, y aplanar las dos sería deshonesto.

  • Gmail: impuesta por Google. El único alcance solicitado es gmail.readonly. El token que regresa es incapaz de enviar, eliminar, etiquetar o modificar — no porque mail-muncher se niegue, sino porque Google rechazará la llamada. Un error en este programa no puede alcanzar tu buzón.
  • IMAP: impuesta por mail-muncher. IMAP no tiene una credencial de solo lectura que pedir. Una contraseña de aplicación es una credencial de correo completa; el protocolo permitirá felizmente que su titular elimine una carpeta. Lo que mail-muncher hace en su lugar es negarse: cada carpeta se abre con EXAMINE y nunca SELECT, cada cuerpo se obtiene con BODY.PEEK[] y nunca BODY[] (para que el correo nunca se marque como leído), y no hay ninguna ruta de código en el proveedor que emita STORE, APPEND o EXPUNGE. Se usan ambos cinturones porque un servidor no está obligado a proteger a un cliente de sí mismo. Esa es una garantía fuerte y auditable — es solo la garantía de este programa, no la de tu proveedor de correo.

Si no tienes una razón específica para querer la API de Gmail, comienza con IMAP. Funciona también con una cuenta de Gmail, y es la ruta que toma la guía de inicio rápido.

El problema

Un proceso automatizado necesita algo de correo. Un rastreador de búsqueda de empleo quiere respuestas de empresas a las que aplicaste. Un bot de soporte quiere mensajes del dominio de un proveedor. Un agente de investigación quiere cada boletín de tres editores, como texto que pueda leer realmente.

Las respuestas habituales son todas malas. Entrega al proceso las credenciales de tu bandeja de entrada y puede leer (y enviar, y eliminar) todo. Dale una integración de API de correo y ahora mantienes un flujo OAuth, un cursor de sincronización, análisis MIME y una historia de deduplicación dentro de cada proceso que quiera correo. O codifica el filtro en un archivo de configuración, y cada cambio en lo que quiere es una edición de configuración y un redespliegue.

mail-muncher divide eso por la mitad. Posee las credenciales, la sincronización incremental, el análisis y la deduplicación. El programa consumidor posee un archivo de texto que enumera lo que quiere y un directorio del que lee resultados — y, si prefiere preguntar en lugar de observar, un puñado de herramientas MCP sobre ese mismo directorio.

El flujo de trabajo del agente

Hay dos formas compatibles, y se componen. Elige según si tu agente se ejecuta en un bucle propio o espera a que le pregunten.

  • Entrega de archivos — mail-muncher se ejecuta en un horario y escribe archivos; el agente lee el directorio. Nada llama a nada. Esta es la forma a continuación.
  • Llamada de herramienta — el agente habla con mail-muncher mcp a través de MCP y hace preguntas directamente: ¿a qué estoy suscrito, qué llegó, qué dice este hilo, busca ahora? Ver Forma 2: llamada de herramienta.

Ambas leen el mismo archivo, y ejecutar ambas a la vez es normal: un demonio llena el directorio mientras el servidor MCP responde preguntas sobre él.

Forma 1: entrega de archivos

El bucle está completamente desacoplado: mail-muncher nunca llama al agente, y el agente nunca necesita llamar a mail-muncher. Comparten dos rutas en disco.

1. El agente declara lo que quiere. Añade a un archivo que posee:

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 se suscribe a esa declaración. Una regla, apuntada al archivo:

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

3. Cada ciclo vuelve a leer el archivo. Ejecútalo desde cron, o deja el demonio corriendo:

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

4. El correo coincidente aterriza en dest como archivos que el agente lee.

~/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

El .md es la representación consumible — analiza el frontmatter, alimenta el cuerpo a un modelo, abre los adjuntos desde el directorio hermano:

---
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á en cada mensaje y nunca está vacío, así que agrupar un directorio en conversaciones es un sort en un campo — sin cadenas de referencia que reensamblar.

5. Opcionalmente, toma el manifiesto en lugar de recorrer el árbol. --json escribe un registro legible por máquina del ciclo a la salida estándar, un objeto por cuenta, mientras que cada línea de registro va a la salida de error:

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

Contrato completo: docs/manifest.md.

Tres propiedades hacen que esto sea seguro para poner en un bucle autónomo:

  • Solo lectura por construcción. Nada en mail-muncher escribe en un buzón. En Gmail eso es la imposición de Google del alcance gmail.readonly; en IMAP es EXAMINE y BODY.PEEK[] y ninguna ruta de escritura en absoluto. De cualquier manera, lo que consuma la salida — y cualquier error que tenga — no puede enviar, eliminar, o modificar correo. Ver la comparación anterior para saber cuál de esas dos garantías estás obteniendo.
  • Entrega idempotente. El nombre de archivo de un mensaje incrusta un resumen de account + ":" + message id, así que su ruta de destino es una función pura de su identidad. Un archivo que ya está allí significa "un ciclo anterior almacenó esto", y el sumidero no escribe nada. Reejecuta, reproduce después de perder estado, falla a mitad de ciclo, o superpone dos invocaciones de cron: el árbol converge, y nada se procesa dos veces.
  • Enrutamiento determinista. Las reglas están ordenadas y la primera coincidencia gana, así que cada mensaje es escrito por exactamente una regla. Dale a cada consumidor su propia regla y su propio dest, y cada uno obtiene un buzón privado en el que nada más escribe.

La entrega son archivos en disco, y nada aquí escucha en una red. El contrato es el directorio, con el manifiesto como un registro opcional y legible por máquina de lo que cambió.

Forma 2: llamada de herramienta

mail-muncher mcp es un servidor MCP stdio sobre el correo ya archivado. El agente pregunta; nada está programado.

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

Cinco herramientas:

HerramientaQué responde
list_rules¿Qué estoy recopilando, y a qué remitentes estoy suscrito ahora mismo? Cada from_domains_file se vuelve a leer en cada llamada.
list_messages¿Qué ha llegado? Filtra por regla, cuenta, hilo o fecha; opcionalmente agrupado en conversaciones.
search_messages¿Dónde está el mensaje que menciona X? Búsqueda de subcadena en asunto, remitente, destinatarios, etiquetas, nombres de adjuntos y cuerpo.
read_messageUn mensaje completo — metadatos, cuerpo, nombres y tamaños de adjuntos — y opcionalmente todo su hilo en orden.
syncBusca correo nuevo una vez, devolviendo el mismo manifiesto que run --json escribe.

Es de solo lectura sobre el correo: ninguna herramienta envía, elimina o modifica nada, y sync — la única herramienta que cambia algo en absoluto — solo puede añadir archivos. El acceso al sistema de archivos está restringido a las raíces de regla dest configuradas, así que la configuración, cualquier credencial almacenada y el directorio de estado son inalcanzables e innombrables.

Un servidor mcp sin configurar se inicia de todos modos, y eso es deliberado. Si un cliente lanza mail-muncher mcp antes de que haya una configuración, el servidor no sale — completa el apretón de manos, registra los mismos cinco nombres de herramientas, y responde cada llamada con la guía de configuración como un error de herramienta, para que el agente tenga algo que transmitir en lugar de "el servidor falló al iniciar". Si estás conectando esto para un operador, ese es un comportamiento esperado y no un error que reportar. La guía también va a la salida de error al inicio, donde los clientes registran el registro del servidor.

Referencia completa, conexión del cliente y cada argumento y campo de retorno: docs/mcp.md.

list_rules es la que cierra el bucle. El agente escribe un dominio en su propio archivo, luego pregunta a list_rules y ve su propia suscripción reflejada de vuelta — la misma lista contra la que el siguiente ciclo coincidirá.

Alternativas

Lee esto antes de adoptar. Varias herramientas hacen bien la forma de buscar-filtrar-entregar, y algunas de ellas encajan mejor que esta.

HerramientaÚsala en su lugar cuando
getmail6Quieres un buscador maduro y ampliamente empaquetado. Hace IMAP y OAuth2 de Gmail, entrega a Maildir/MDA, y filtra a través de programas externos. Si un humano (o mutt, o notmuch) es el consumidor, esta es la herramienta más fuerte.
fdmQuieres destinos Maildir por regla con una configuración compacta y bien probada — exactamente la forma de esta herramienta, menos la fuente de filtro externa. El acceso a Gmail es IMAP con contraseña de aplicación.
lieerQuieres todo tu buzón de Gmail sincronizado bidireccionalmente en un Maildir local para notmuch, no un subconjunto filtrado extraído de él.
gmail-archiveEra casi exactamente esto — consulta de Gmail a Maildir, incremental — y sería la respuesta obvia si aún se mantuviera. No se ha mantenido desde 2018.
gmail-exporterQuieres una exportación única, basada en etiquetas, en forma de hoja de cálculo en lugar de sincronización incremental.
mbsync / offlineimapQuieres replicación completa del buzón y filtrarás localmente después.

Lo que ninguna de ellas hace, y para lo que existe esta herramienta: tomar la entrada de filtros de un archivo que otro programa posee y releerlo en cada ciclo, y emitir una representación construida para que un programa la consuma, en lugar de para que un cliente de correo la muestre. Si no necesitas ambas cosas, una de las herramientas anteriores te servirá mejor y tiene años más de recorrido.

Instalación

No se requiere cadena de herramientas de Go para las dos primeras opciones.

Homebrew

brew install craigjmidwinter/tap/mail-muncher

Eso añade el tap craigjmidwinter/homebrew-tap e instala un binario precompilado. brew upgrade mail-muncher rastrea nuevas versiones.

Descargar un binario

Cada release incluye archivos para macOS y Linux tanto en amd64 como en arm64, además de un checksums.txt y una firma sobre él.

# 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

Si el binario entonces se niega a ejecutarse por completo —

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

— tienes un archivo para la arquitectura incorrecta. Ese mensaje proviene del kernel y no dice nada sobre mail-muncher, por lo que vale la pena conocer su forma. Compara uname -m con el _amd64 / _arm64 en el nombre de archivo que descargaste; la línea ARCH= anterior calcula la correcta por ti, así que esto solo afecta si estableces el nombre manualmente.

¿Sin root? /usr/local/bin lo necesita; ~/.local/bin no. Omite el sudo e instala allí en su lugar — nada sobre mail-muncher requiere una ubicación a nivel de sistema:

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

Si mail-muncher entonces es "command not found", ~/.local/bin no está en tu PATH; agrégalo en tu perfil de shell.

Omitir el sudo sin cambiar el destino falla con un Permission denied from install en sí — en macOS nombrando un archivo temporal en lugar de mail-muncher, lo cual es confuso la primera vez que lo ves:

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

Cualquiera de los mensajes significa lo mismo: elige la ruta ~/.local/bin anterior, o vuelve a poner el sudo.

En macOS, un binario que descargaste tú mismo está en cuarentena por Gatekeeper. Límpialo con xattr -d com.apple.quarantine /usr/local/bin/mail-muncher, o usa la instalación de Homebrew anterior, que hace esto por ti.

Verificar lo que descargaste

Esta herramienta lee tu correo. Comprueba que el archivo es el que el flujo de trabajo de release construyó. Primero la suma de verificación:

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

Luego la firma sobre checksums.txt. Los releases se firman sin clave con cosign — no hay clave pública que obtener ni clave privada que alguien deba proteger. El certificado de firma se emite a la identidad OIDC de GitHub del propio flujo de trabajo de release y se registra en el registro público de transparencia de Rekor, así que lo que estás comprobando es "esto fue construido por release.yml en este repositorio, desde una etiqueta":

cosign no está instalado por defecto en ninguna plataforma y no está en los repositorios habituales de las distribuciones, así que cosign: command not found aquí significa "aún no instalado", no "verificación fallida". Consíguelo primero — brew install cosign, o go install github.com/sigstore/cosign/v2/cmd/cosign@latest, o un binario de release de los documentos de instalación.

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 el archivo de suma de verificación es auténtico; el paso sha256sum luego vincula tu archivo a él. cosign 3 imprime un aviso de obsolescencia para --certificate y --signature — la verificación aún se ejecuta, y estos archivos separados son lo que cosign 2 también entiende.

go install

El camino correcto si ya tienes Go 1.25 o más reciente:

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

Ten en cuenta que las compilaciones go install informan dev para --version, porque la versión se sella en tiempo de enlace y la herramienta go no lo hace. Los binarios de release y make build informan la etiqueta real. Si reportas un error desde una compilación go install, di qué commit instalaste.

Compilar desde el código fuente

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

make snapshot compila el conjunto completo de archivos de release localmente (requiere goreleaser) si quieres comprobar qué contendría un release.

Las configuraciones de ejemplo referenciadas abajo viven en examples/ — imap.yml, minimal.yml y job-search.yml. También están incluidas dentro de cada archivo de release, así que una descarga de binario también las tiene. No las necesitas para empezar, sin embargo: mail-muncher init escribe una configuración desde cero.

Imagen de contenedor

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

linux/amd64 y linux/arm64, construidos desde los mismos binarios que llevan los archivos de release. El comando predeterminado de la imagen es mcp, porque servir el archivo a través de stdio es el modo que un contenedor se adapta: un cliente lo inicia, habla con él y lo detiene. run y daemon también funcionan — anula el comando — pero en un host esos son una línea de cron y una unidad de launchd/systemd, que encajan mejor.

Dos montajes, y ambos importan:

# -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

Ese export se empareja con password_cmd: printenv IMAP_PASSWORD en la configuración — consulta la nota abajo sobre por qué tu gestor de contraseñas del host no es accesible desde dentro del contenedor.

Cada ruta dentro de config.yml tiene que ser una ruta que el contenedor pueda ver. Un dest: de ~/Mail/receipts se resuelve contra el directorio de inicio del contenedor, no el tuyo, así que el correo aterriza en una capa que desaparece cuando el contenedor sale. Apunta dest: al directorio montado — /home/muncher/archive/receipts para el montaje anterior — o archivarás en el vacío y el manifiesto te dirá alegremente que funcionó.

password_cmd se ejecuta dentro del contenedor, bajo /bin/sh, lo que significa que tu gestor de contraseñas del host no está allí. pass show mail/fastmail no puede funcionar. Usa el material secreto que el contenedor sí tiene:

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

Este es el único lugar donde la ruta del contenedor es genuinamente peor que una instalación en el host: mueve la credencial fuera de tu gestor de contraseñas y dentro del entorno del contenedor. Si ese intercambio no vale la pena para ti, instala el binario — password_cmd está diseñado para el caso del host, y este es el compromiso, no la intención.

La imagen también respalda el listado del MCP Registry; server.json es esa entrada, y su name tiene que coincidir con la etiqueta io.modelcontextprotocol.server.name integrada en la imagen.

Publicar esa entrada es automático. Etiquetar un release compila y envía la imagen, y luego un segundo trabajo reescribe version y la etiqueta de imagen en server.json desde la etiqueta de git y publica en el registro, autenticándose con la identidad OIDC del propio flujo de trabajo en lugar de un token almacenado.

Así que el version confirmado en server.json es el del último release, y se retrasa por una etiqueta a propósito. La etiqueta es la fuente de verdad; el archivo es una plantilla que CI sella. Aumentarlo manualmente no logra nada.

Como una habilidad de Claude Code

El repositorio incluye un paquete de habilidad y plugin bajo skills/, que instala mail-muncher como algo que un agente puede configurar y manejar por ti — escribiendo la configuración, ejecutando auth, y conectando el servidor MCP a tu cliente. Si así es como quieres adoptarlo, comienza allí en lugar del inicio rápido abajo.

La habilidad comienza con provider: imap y maneja mail-muncher init, así que toma la misma ruta de dos minutos que este README en lugar de enviarte a la Google Cloud Console.

Windows

No hay compilación para Windows, y ninguna de las opciones anteriores lo soluciona silenciosamente. Homebrew no se ejecuta en Windows. Los archivos de release son darwin y linux solamente, y el fragmento de descarga anterior es un script de shell POSIX construido sobre uname, que PowerShell y cmd no pueden ejecutar en absoluto.

go install es el único camino que produce algo, y ese es el problema que vale la pena declarar claramente. Go compila de forma cruzada este módulo limpiamente — sin cgo, sin etiquetas de compilación de plataforma fuera de un archivo de prueba — así que obtienes un mail-muncher.exe que se inicia, y mail-muncher init que escribe una configuración sin quejarse. Se detiene en el primer run. El proveedor IMAP, el camino de ~2 min con el que este README comienza, ejecuta imap.password_cmd entregándolo a /bin/sh -c (internal/provider/imap/password.go), y una máquina Windows estándar no tiene /bin/sh. init es lo suficientemente cuidadoso como para no sembrar una configuración de Windows con una herramienta secreta de macOS o Linux, pero el comando que sí siembra aún va a un shell que no está allí, así que el fallo llega tarde y culpa a lo incorrecto.

El proveedor de Gmail no tiene tal dependencia — su flujo de OAuth ya elige rundll32 en Windows — así que puede funcionar de extremo a extremo. No está probado allí y no tiene soporte.

Lo que sí funciona en Windows: la imagen de contenedor bajo Docker Desktop, o cualquiera de las rutas de instalación dentro de WSL2, donde un binario de Linux y /bin/sh ambos existen.

Actualización

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

Para un binario descargado, repite los pasos de descarga anteriores — el paso install sobrescribe en el lugar. Nada más tiene que cambiar: el esquema de configuración y el diseño en disco son estables dentro de 0.x, y los cursores de sincronización en state_dir son leídos por cualquier versión más reciente. CHANGELOG.md registra cualquier cosa que haría que eso no fuera cierto, y no hay nada allí todavía.

Comprueba en qué versión aterrizaste con mail-muncher --version. Una compilación sin make informa dev — ese es el problema del sello de versión go install, no una instalación rota.

Desinstalación

Eliminar el binario deja todo lo demás atrás, así que esto está en el orden que elimina el material más sensible primero. Nada aquí se hace por ti:

# 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

Tu correo archivado está deliberadamente fuera de esa lista. Vive en lo que dest tus reglas nombraron — ~/Mail/mail-muncher si tomaste el valor predeterminado init — y esos son archivos ordinarios que sobreviven a la herramienta, que es el punto completo del formato. grep -n 'dest:' ~/.config/mail-muncher/config.yml antes del paso 3 si quieres las rutas, y elimínalos tú mismo si quieres que el correo desaparezca.

La cask de Homebrew no lleva ninguna estrofa zap, así que brew uninstall elimina el binario y nada bajo tu directorio de inicio. Eso es a propósito — el correo que esta herramienta ya ha escrito es tuyo, y un desinstalador es un mal lugar para descubrir lo contrario.

Inicio rápido

Alrededor de cinco minutos, sin navegador, sin clonar. Este es el camino IMAP; para la API de Gmail en su lugar, lee Inicio rápido: Gmail abajo antes de empezar, porque cuesta unos diez minutos en la Google Cloud Console y el token expira semanalmente.

0. Comprueba la instalación. Justo después de instalar, antes de que haya cualquier configuración:

mail-muncher run

Eso es una prueba de humo genuinamente útil en lugar de un error. Sale con 1 y te dice exactamente dónde buscó, qué ejecutar a continuación, y qué cuesta cada proveedor:

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/

Cada comando que necesita una configuración dice esto, así que una instalación rota y una sin configurar nunca se ven iguales.

1. Obtén una contraseña de aplicación. Desde la página de configuración de tu propio proveedor de correo — Gmail, Fastmail, iCloud, Proton, tu cuenta de trabajo. Está limitada a este único uso y puedes revocarla sin tocar nada más. Ponla donde ya guardas secretos:

# 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 almacena esto. Ejecuta un comando que tú nombras y lee la contraseña de la salida estándar de ese comando, así que el secreto permanece en tu gestor de contraseñas. Deliberadamente no hay ninguna clave password: en el esquema de configuración.

2. Escribe una configuración.

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/

No hay paso de edición. init pregunta por todo lo que el camino IMAP necesita y escribe una configuración que se valida al primer intento. El comando de contraseña se ofrece con el predeterminado de tu plataforma ya rellenado — Keychain en macOS, secret-tool en Linux, pass en otros lugares — así que presionar Enter a través de él es una respuesta real, no un marcador de posición.

Cada pregunta tiene una bandera, para responder de antemano o scriptear todo el proceso:

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

--yes toma el valor predeterminado para todo lo que tiene uno honesto, por eso todavía requiere --provider, --host y --username. Esos tres no tienen un valor predeterminado que valga la pena adivinar, y lo dice en lugar de escribir un marcador de posición:

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

Agrega --account NAME, --dest DIR y --password-cmd CMD para responder al resto. Una configuración existente nunca se sobrescribe sin --force.

~/.config/mail-muncher/config.yml es la ruta predeterminada; --config la anula en todas partes, incluso para init.

3. Verifica que el comando de contraseña imprima la contraseña y nada más.

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

| cat -A hace visible un mensaje extraviado, una advertencia o una línea en blanco al final. Cualquier cosa adicional en stdout se convierte en parte de la contraseña y el inicio de sesión falla: esta es la razón más común por la que una primera ejecución no puede autenticarse.

Esto es lo que init escribió, como referencia; está comentado en todo el documento, y examples/imap.yml es una versión trabajada más completa:

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. Verifica la configuración.

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

Una cuenta IMAP se valida limpiamente: no hay archivo de credenciales que encontrar, no hay token que haya que escribir todavía, nada en el disco en absoluto. OK sin advertencias es el resultado esperado. validate analiza la configuración, compila el árbol de coincidencias de cada regla y verifica los archivos a los que hace referencia. Los archivos faltantes que pertenecen a otro programa — un from_domains_file, o en la ruta de Gmail las credenciales OAuth y el token — son advertencias, no errores:

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. Mira lo que haría una ejecución real.

mail-muncher run --dry-run

Una ejecución de prueba se conecta, obtiene y evalúa exactamente como lo hace una ejecución real, y informa la ruta a la que se escribiría cada coincidencia. No escribe archivos y no guarda el estado de sincronización, así que puedes ejecutarla tantas veces como quieras. Aquí es también donde un host, nombre de usuario o password_cmd incorrectos salen a la superficie, nombrados exactamente:

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. Ejecútalo.

mail-muncher run

La configuración que escribió init lleva una regla inicial que coincide con todo lo más nuevo que 72h, así que esta primera ejecución está garantizada para almacenar algo — una ejecución que no almacena nada es indistinguible de una instalación rota. Luego ejecútalo de nuevo: todo lo que ya está en el disco se reporta como skipped, y el cursor incremental significa que la segunda ejecución apenas habla con el servidor.

Una vez que eso funcione, reduce la regla inicial a lo que realmente quieres (docs/filters.md), luego ponla en un horario (ver Scheduling). docs/configuration.md tiene todas las claves.

Inicio rápido: Gmail

Toma esta ruta si específicamente quieres la API de Gmail y una garantía de solo lectura impuesta por Google en lugar de por este programa. Conoce los dos costos antes de comenzar, porque ambos son estructurales y ninguno desaparece:

  • Alrededor de diez minutos en Google Cloud Console, por adelantado. gmail.readonly es un alcance restringido de Google, así que mail-muncher no incluye ningún cliente OAuth y nunca lo hará — registras tu propio proyecto y cliente de aplicación de escritorio y descargas su JSON.
  • El token expira cada 7 días. Google aplica eso a cada pantalla de consentimiento que todavía esté en modo de Prueba, que será la tuya. mail-muncher auth tiene que ejecutarse de nuevo semanalmente. No hay ninguna configuración que lo elimine; docs/gmail-setup.md explica por qué y cuáles son los costos de las alternativas.

Si ninguno vale la pena para ti, la ruta IMAP anterior funciona con una cuenta de 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 una URL de consentimiento (e intenta abrir un navegador), escucha en un puerto de bucle local para la redirección y escribe el token en el token_file de la cuenta con modo 0600. Es un comando solo de Gmail — en una cuenta IMAP se niega, porque no hay nada que autorizar. Los pasos 4 a 6 del inicio rápido de IMAP anterior se aplican sin cambios; validate reportará dos advertencias hasta que existan los archivos de credenciales y token.

Archivos de filtro gestionados externamente

Esta es la característica alrededor de la cual se construye la herramienta, así que vale la pena ser precisos sobre la semántica.

from_domains_file nombra un archivo que mail-muncher no posee, no crea y nunca escribe:

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
  • Se lee una vez por ciclo, en el primer uso. No una vez por proceso, y no una vez por mensaje. run lo vuelve a leer; cada tick del daemon lo vuelve a leer. Un archivo referenciado por varias reglas se lee una vez y se comparte.
  • Que falte o no se pueda leer nunca es fatal. El predicado simplemente no coincide con nada y se registra una advertencia para ese archivo en ese ciclo. El programa propietario puede no haberlo creado todavía, y mail-muncher no debe fallar por eso.
  • Análisis liberal. Una entrada por línea; # comienza un comentario; las líneas en blanco se omiten; el espacio en blanco circundante se recorta; un @ inicial y un . final se eliminan; todo se convierte a minúsculas; los duplicados se colapsan. Una entrada sin punto se conserva y se registra como sospechosa en lugar de descartarse, porque el archivo pertenece a otra persona y adivinar mal no debería descartar silenciosamente una entrada.
  • Igualdad o subdominio. acme.com coincide con acme.com y careers.acme.com, pero no con notacme.com.

Las mismas reglas de coincidencia se aplican al predicado from_domains: en línea; la única diferencia es quién posee la lista.

Cuando una lista de dominios no puede expresarlo

Algunos remitentes no se pueden enumerar de antemano. El correo de una empresa podría llegar desde wagepoint.teamtailor.com, mail.wagepoint.com y notifications@wagepoint-hr.example — una lista de dominios solo puede nombrar hosts que ya conoces. from_regex_file es la misma idea para patrones:

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

El ciclo de vida es idéntico — se lee una vez por ciclo, que falte nunca es fatal, on_degraded_filter gobierna el cursor. Dos diferencias deliberadas con respecto al formato de dominio:

  • Nada se convierte a minúsculas, porque una expresión regular distingue entre mayúsculas y minúsculas por construcción. Escribe (?i) cuando quieras lo contrario.
  • # solo comienza un comentario al inicio de una línea. Truncar un patrón en un # a mitad de línea cambiaría silenciosamente lo que coincide.

Los modos de fallo son opuestos, y por eso las protecciones difieren. Un error tipográfico en una lista de dominios coincide con nada — el costo es el silencio. Un error tipográfico en una lista de patrones puede coincidir con todo: .* o una línea en blanco suelta reclama todo el buzón. Así que un patrón vacío, o cualquier patrón que coincida con la cadena vacía, se rechaza directamente; un patrón que no se compila se rechaza por sí mismo mientras el resto del archivo permanece en vigor; y el recuento de patrones cargados se registra cada ciclo, para que un archivo que pasó de doce patrones a un comodín sea un número en la salida de tu ejecución en lugar de un descubrimiento a través de un disco lleno.

Configuración

Referencia completa: docs/configuration.md. Archivos ejecutables: examples/imap.yml, examples/minimal.yml, examples/job-search.yml.

El bloque de cuenta es la única parte que difiere según el proveedor. 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]
ClaveTipoPredeterminadoDescripción
state_dirruta~/.local/state/mail-muncherCursores de sincronización (un archivo JSON por cuenta), el bloqueo de ciclo, el bloqueo de instancia y el directorio de cuarentena.
on_message_failurequarantine, abortquarantineQué hacer con un mensaje que no se puede analizar o en el que un sumidero falló. Ver abajo.
on_degraded_filterhold, fail, proceedholdQué hacer cuando el from_domains_file o from_regex_file de una regla no se puede leer. Ver abajo.
quarantine_dirruta<state_dir>/quarantineDónde se estacionan los mensajes en cuarentena.
accountslista—Buzones de los que extraer. Se requiere al menos uno.
accounts[].namecadena—Requerido, único. Nombra el archivo de estado y es a lo que se refiere rules[].account.
accounts[].providerimap, gmail—Requerido; no hay predeterminado. Qué backend obtiene. Ver Dos formas de conectar un buzón.
accounts[].imapmapeo—Requerido — y solo permitido — cuando el proveedor es imap.
accounts[].imap.hostcadena—Requerido. imap.fastmail.com, imap.gmail.com, 127.0.0.1 para el Proton Bridge.
accounts[].imap.portentero993993 es TLS implícito (IMAPS) y se combina con el predeterminado de tls: true.
accounts[].imap.usernamecadena—Requerido. Generalmente la dirección completa; algunos proveedores quieren la parte local simple.
accounts[].imap.password_cmdcomando de shell—Requerido. Se ejecuta bajo /bin/sh -c; su stdout es la contraseña. Deliberadamente no hay una clave password — el secreto permanece en tu gestor de contraseñas.
accounts[].imap.mailboxeslista de cadenas[INBOX]Carpetas a obtener, cada una con su propio cursor. Un nombre también sirve como valor del predicado label. Una carpeta que el servidor no tiene es un error, no una carpeta vacía.
accounts[].imap.tlsbooleanotrueTLS implícito al conectar. false envía la contraseña y cada cuerpo en claro; validate advierte. Legítimo solo en bucle local o detrás de un stunnel.
accounts[].imap.initial_lookbackduración Go720hQué tan atrás llega la primera sincronización de cada buzón, y de nuevo después de cualquier cambio de UIDVALIDITY. Debe ser positivo.
accounts[].gmailmapeo—Requerido — y solo permitido — cuando el proveedor es gmail.
accounts[].gmail.credentials_fileruta—Requerido. El JSON de cliente OAuth descargado de Google Cloud.
accounts[].gmail.token_fileruta—Requerido. Dónde auth almacena en caché el token OAuth, modo 0600.
accounts[].gmail.querycadenaningunoExpresión de búsqueda de Gmail opcional. Una optimización de costos solo para el escaneo inicial — ver abajo.
accounts[].gmail.initial_lookbackduración Go720hQué tan atrás llega el escaneo inicial. Debe ser positivo. Ver Backfill.
accounts[].gmail.include_spam_trashbooleanofalseObtener mensajes en Spam y Papelera. Se respeta de manera idéntica en ambas rutas de sincronización de Gmail. validate advierte cuando es verdadero. Ver Spam y Papelera.
ruleslista—Evaluada en orden contra cada mensaje; la primera coincidencia gana.
rules[].namecadena—Requerido, único. Aparece en registros y en el frontmatter de markdown.
rules[].accountcadenatodas las cuentasRestringe la regla a una cuenta.
rules[].matchnodo de coincidencia—Requerido. Ver Filtros.
rules[].destruta—Requerido. Directorio de destino; se crea bajo demanda.
rules[].formatslista de eml, markdown[eml]Representaciones a escribir.

Notas que muerden a la gente:

  • Las claves desconocidas son un error grave. Un error tipográfico hace fallar la carga en lugar de ser ignorado, por lo que validate detecta initial_lookbak antes de que se ejecute una corrida.
  • ~ y $VAR se expanden en cada campo con valor de ruta, incluidos los valores de from_domains_file y from_regex_file dentro de un árbol de coincidencias. Las formas ~user no son compatibles. Una variable no definida se expande a la cadena vacía, como en un shell.
  • gmail.query no filtra lo que se conserva y se aplica a menos de lo que crees. Se envía a Gmail en el primer escaneo de una cuenta y en ningún otro lugar — no en ciclos incrementales, ni en un escaneo de recuperación después de que expire el cursor de historial. Nunca se vuelve a aplicar localmente. Tus reglas son la única autoridad sobre lo que se almacena. Mantén la consulta amplia u omítela.
  • Spam y Papelera no se obtienen por defecto (Gmail). Ambas rutas de sincronización de Gmail coinciden en esto: los escaneos completos pasan includeSpamTrash=false, y la ruta incremental descarta los mensajes etiquetados como SPAM o TRASH antes de que lleguen a la canalización. Establece gmail.include_spam_trash: true para obtenerlos de todos modos — consulta Spam y Papelera. En IMAP no hay clave equivalente: obtienes exactamente las carpetas que enumeras en mailboxes:, por lo que simplemente no listar la carpeta de basura es todo el mecanismo.
  • Los bloques gmail: y imap: son mutuamente excluyentes. Establecer el que no coincide con provider: es un error grave en lugar de un bloque ignorado silenciosamente, por lo que un bloque imap: bajo una cuenta de Gmail no puede hacerte creer que estás obteniendo a través de IMAP cuando no es así.

Políticas para las dos cosas que pueden salir mal

Ambas claves se encuentran en el nivel superior, junto a state_dir. Los valores predeterminados son las opciones seguras; solo los cambias si has decidido qué fallo prefieres.

on_message_failure — un mensaje que no se puede analizar, o donde cada renderizado que su regla solicitó falló al escribirse.

ValorComportamiento
quarantine (predeterminado)Escribe los bytes sin procesar en <quarantine_dir>/<account>/<id>.eml con un archivo secundario .json que nombra el fallo, luego deja que el cursor avance más allá del mensaje. Nada se pierde, y un mensaje venenoso no puede atascar la canalización. Se cuenta como quarantined en el resumen y el manifiesto; la corrida aún sale con 0.
abortDevuelve el fallo, por lo que el cursor no avanza y el mensaje se vuelve a obtener en el siguiente ciclo. La compensación es explícita: un mensaje permanentemente inanalizable atasca la cuenta hasta que un humano lo resuelva.

Una escritura de cuarentena que falla en sí misma recurre a la semántica de abort para ese mensaje — negarse a avanzar es recuperable, perder el mensaje no lo es.

on_degraded_filter — el from_domains_file o from_regex_file de una regla falta, no se puede leer o se trunca a mitad de camino. Tal archivo no coincide con nada, por lo que sin una política cada mensaje en ese ciclo se evaluaría contra una lista vacía, se encontraría que no coincide y se consumiría.

ValorComportamiento
hold (predeterminado)Ejecuta el ciclo y almacena todo lo que coincidió, registra la degradación a nivel de error, pero no guarda el cursor avanzado — para que el mismo correo se reevalúe una vez que el archivo regrese. El manifiesto informa degraded y state_held. Salida 0.
failTermina el ciclo antes de que se obtenga cualquier cosa. Nada almacenado, nada avanzado, salida no cero.
proceedTrata una lista ilegible como una vacía y avanza de todos modos. El comportamiento anterior, y la única opción que acepta la pérdida silenciosa de correo deseado — validate advierte sobre ello.

Los archivos ya almacenados bajo hold permanecen almacenados: los sumideros son idempotentes, por lo que la re-ejecución los omite.

Filtros

Referencia completa y recetario: docs/filters.md.

Un valor match: es un mapeo con exactamente una clave — un combinador o un predicado. Dos claves en un mapeo es un error de compilación que te dice que las combines con all: o any:. Las expresiones regulares y duraciones se compilan cuando se carga la configuración, por lo que un patrón malo es un fallo de validate, no una sorpresa a las 3 a.m.

Combinadores

ClaveValorCoincide cuando
alllista de nodoscada hijo coincide (se requiere al menos un hijo)
anylista de nodosal menos un hijo coincide (se requiere al menos un hijo)
notun solo nodoel hijo no coincide
match:
  all:
    - any:
        - from_domains: [acme.com]
        - from_domains_file: ~/.local/share/agent/domains.txt
    - not:
        subject_regex: "(?i)^\\[newsletter\\]"

Predicados

ClaveValorCoincide cuando
from_domainslista de dominiosel dominio de cualquier dirección From es igual o es un subdominio de un dominio listado
from_domains_filerutalo mismo, con la lista leída de un archivo de propiedad externa cada ciclo
from_regexpatrón RE2el patrón coincide con cualquier especificación de dirección From (sin nombre para mostrar)
from_regex_filerutalo mismo, con los patrones leídos de un archivo de propiedad externa cada ciclo
to_regexpatrón RE2el patrón coincide con cualquier especificación de dirección To o Cc
subject_regexpatrón RE2el patrón coincide con el Subject decodificado
header{name: X-Foo, regex: ...}el patrón coincide con cualquier valor de ese encabezado
has_attachmenttrue / falseel mensaje tiene (o no tiene) un adjunto real
labelnombre de etiquetael mensaje lleva esa etiqueta del proveedor, comparada exactamente. En Gmail es una etiqueta de Gmail; en IMAP es el nombre del buzón del que provino el mensaje
older_thanduración Goel mensaje Date está más en el pasado que la duración
newer_thanduración Goel mensaje Date es más reciente que la duración

Un ejemplo trabajado de cada uno:

# 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

Detalles que vale la pena conocer:

  • from_regex y to_regex prueban la dirección desnuda (jane@acme.com), nunca el nombre para mostrar. Usa header: {name: From, regex: ...} para probar el encabezado sin procesar incluyendo el nombre para mostrar.
  • has_attachment cuenta las partes marcadas como Content-Disposition: attachment. Las imágenes en línea referenciadas por cid: no son adjuntos.
  • label distingue entre mayúsculas y minúsculas y es exacto — label: inbox no coincide con INBOX. En una cuenta IMAP, los valores son los nombres de buzones que listaste bajo imap.mailboxes, por lo que un mensaje solo puede llevar el del que se obtuvo.
  • older_than / newer_than comparan contra el encabezado Date del mensaje, recurriendo a la fecha interna del proveedor cuando el encabezado falta o no se puede analizar. Un mensaje sin fecha utilizable no coincide con ninguno.
  • Los patrones son Go RE2: sin referencias hacia atrás y sin mirar alrededor. Prefija con (?i) para insensibilidad a mayúsculas. En YAML, prefiere comillas dobles y escapa las barras invertidas ("\\."), o usa comillas simples donde no se necesite escape.
  • Usa true / false para has_attachment. YAML 1.2 trata yes y no como cadenas, y mail-muncher los rechaza.

Spam y Papelera (Gmail)

Spam y Papelera no se obtienen por defecto. Nada en esas carpetas llega a tus reglas, y nada aterriza en el disco. El spam es la fuente más probable de texto hostil y escrito por atacantes en una canalización que termina en la ventana de contexto de un modelo, por lo que el valor predeterminado es dejarlo donde Gmail lo puso.

Toda esta sección trata sobre el proveedor de Gmail. IMAP no tiene clave equivalente porque no la necesita: una cuenta IMAP obtiene exactamente las carpetas nombradas en imap.mailboxes, por lo que la basura llega solo si la pides por nombre.

Si lo quieres de todos modos — un mensaje legítimo mal clasificado como spam, o un archivo genuinamente completo — establece la clave por cuenta:

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

Los dos ajustes hacen trabajos diferentes, y puede que quieras ambos:

Decide
gmail.include_spam_trashsi esos mensajes se obtienen en absoluto
Una regla sobre las etiquetas SPAM / TRASHqué les sucede una vez obtenidos

gmail.query no puede hacer ninguno de los dos trabajos. Se envía solo en el primer escaneo, por lo que -in:spam allí no hace nada para cualquier ciclo posterior. Con include_spam_trash: true establecido, discrimina con una regla — el motor de filtros es lo único que ve cada mensaje obtenido:

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

Las etiquetas del sistema de Gmail son exactas y en mayúsculas. Si quieres Spam y Papelera fuera de cada regla, pon el not: en cada una — no hay exclusión global, por diseño: las reglas son la autoridad única sobre lo que se almacena.

Diseño en disco

Los archivos que escribe mail-muncher son su API pública. Esta sección es el recorrido; docs/output-format.md es el contrato — cada clave de frontmatter, por qué el frontmatter necesita un analizador YAML real, y las reglas para enumerar un árbol de entrega de manera segura. Léelo antes de escribir un consumidor, y consulta examples/read_delivered.py para uno corto y correcto.

Cada sumidero archiva un mensaje bajo el dest de la regla por la fecha del mensaje, en 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

El nombre base es compartido por cada formato, por lo que los renderizados de un mensaje se ordenan juntos:

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

El fragmento de digestión es de 16 caracteres hexadecimales — 64 bits. Dos mensajes que colisionen en él no es alcanzable a ningún volumen que produzca un buzón, y los lectores del archivo lo analizan de vuelta como un id de mensaje, así que trata el ancho como parte del diseño.

  • La marca de tiempo ordena un directorio cronológicamente.
  • La digestión es la clave de idempotencia. Depende solo del nombre de la cuenta y del id de mensaje del proveedor, por lo que la ruta es una función pura de la identidad del mensaje.
  • El slug es el asunto en minúsculas, con cada carácter fuera de [a-z0-9] colapsado a un solo -, recortado y truncado a 40 caracteres.

Dos advertencias sobre el slug, ambas deliberadas:

  • Es solo ASCII. Un asunto escrito completamente en un script no latino, o completamente en emoji, se convierte en slug a no-subject. Los nombres de archivo no ASCII estarían sujetos a la normalización Unicode del sistema de archivos (HFS+ almacena NFD), lo que puede hacer que el nombre escrito difiera del nombre que el siguiente ciclo verifica — y esa verificación de existencia es toda la historia de idempotencia. La digestión aún mantiene separados tales mensajes.
  • Es cosmético. Solo la digestión lleva identidad. Dos mensajes con el mismo asunto nunca colisionan.

Cómo se escriben los archivos

Un archivo de mensaje se escribe en un archivo temporal en su directorio de destino, se sincroniza con fsync, y luego se enlaza duramente en su lugar con link(2). Tres consecuencias que vale la pena aprovechar:

  • Un archivo parcial nunca se publica. El archivo temporal está completo antes de que el nombre exista.
  • Un archivo existente nunca se sobrescribe. link(2) falla con EEXIST en lugar de sobrescribir, a diferencia de rename(2). Ese fallo es la verificación de idempotencia — el kernel decide si el nombre está libre en el instante en que se reclama, por lo que no hay ventana en la que otro escritor pueda colar un archivo y hacer que se reemplace silenciosamente. "Ya está allí" se informa como skipped.
  • Los enlaces simbólicos se rechazan, no se siguen. Un enlace simbólico en la ruta final de un mensaje, o que represente el directorio <YYYY> o <MM> debajo de dest, es un error por el que el mensaje se cuenta y se registra. Nada en el diseño es legítimamente un enlace, por lo que uno significa que algo más los está colocando allí. El propio dest: de la regla está exento — apuntarlo a otro volumen es ordinario.

En un sistema de archivos sin enlaces duros (FAT, algunos montajes de red), el respaldo es una escritura O_CREAT|O_EXCL en su lugar: aún atómicamente sin sobrescribir y aún a prueba de enlaces simbólicos, a costa de la garantía de no archivo parcial.

Los adjuntos son la única excepción: se escriben con un archivo temporal y rename(2), porque sus nombres no son el marcador de idempotencia — el .md encima de ellos decide eso. Los directorios se crean con permisos 0700 y los archivos con 0600. El correo archivado es correspondencia privada y archivos adjuntos decodificados, por lo que recibe el mismo tratamiento que los cursores de sincronización y el token OAuth: nada aquí es legible por otros usuarios locales. Una herramienta que ejecutes como tú mismo no se ve afectada.

El archivo .eml

model.Message.Raw, byte por byte, exactamente como lo entregó el proveedor. Nada se vuelve a codificar, reempaquetar ni normalizar, por lo que puede pasar por cualquier herramienta de correo y seguir verificándose contra las firmas DKIM. Esta es la copia de fidelidad.

El archivo .md

Frontmatter YAML, luego el cuerpo, y luego enlaces a cualquier archivo adjunto.

  • Selección del cuerpo: la parte text/plain si existe; de lo contrario, la parte text/html convertida a markdown; de lo contrario, el *(no body)* literal. Los finales de línea se normalizan a LF, se elimina el espacio en blanco final de cada línea y se recortan las líneas en blanco iniciales y finales.
  • El frontmatter siempre lleva 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 y attachments se omiten cuando están vacíos. Analiza las direcciones de los campos *_address / *_addresses, nunca de from/to/cc — esos son cadenas de visualización, y un nombre para mostrar elegido por el remitente que contenga <, > o , los hace ambiguos. Los campos legibles por máquina llevan addr-specs simples y no pueden ser falsificados de esa manera. Se produce con un codificador YAML, no con formato de cadenas, por lo que un asunto lleno de comillas y dos puntos no puede romper el análisis — lo que también significa que necesitas un analizador YAML real para leerlo. Un asunto con emoji llega entre comillas dobles con un escape \U0001F389, y un asunto que contiene un salto de línea llega como un escalar de bloque |-. Un divisor key: value se equivoca con ambos. Consulta docs/output-format.md.
  • El hilo tiene tres campos, no cuatro. thread_id es la clave de unión y nunca está vacío: el ID de conversación del propio proveedor cuando existe, de lo contrario uno sintetizado a partir de la cadena References del mensaje. Agrupa un directorio por él sin casos especiales. thread_id_source indica cuánto confiar en esa agrupación — provider, references, in_reply_to o self — porque la reconstrucción es de mejor esfuerzo y un cliente de correo que rompe la cadena divide un hilo. in_reply_to nombra al padre. La cadena References completa se omite deliberadamente: es ilimitada, y el .eml junto al archivo la tiene textualmente.
  • Los archivos adjuntos se escriben en <basename>.attachments/ junto al .md, con nombres de archivo saneados (sin componentes de directorio, sin traversal de rutas), una extensión .md o .eml neutralizada a .md.attachment / .eml.attachment, y las colisiones se deduplican como name-2.pdf, name-3.pdf. Se escriben antes del .md, por lo que el documento nunca enlaza a un archivo que no está allí. Su contenido lo elige el remitente — consulta la regla de enumeración a continuación.
  • Las imágenes cid: en línea no se resuelven. Un cuerpo HTML que incrusta imágenes por ID de contenido se renderiza como ![alt](cid:...) — un enlace sin resolver, no una ruta al directorio de adjuntos. Si necesitas los bytes de la imagen, están en el .eml. Esta es una limitación conocida, no un error.
  • El .md no es un formato de fidelidad. Cualquier cosa que importe byte a byte debe leerse del .eml.

Enumerando un árbol de entrega

El conjunto autoritativo de mensajes entregados es exactamente <dest>/<YYYY>/<MM>/*.md (o *.eml) — dos niveles de profundidad, nunca un glob recursivo:

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

Un glob recursivo desciende a <basename>.attachments/, donde los archivos provienen de quien envió el correo. Un atacante que pueda hacer que una regla coincida puede adjuntar un archivo con frontmatter falsificado y hacer que un consumidor descuidado lo lea como un mensaje entregado con un from:, subject: y cuerpo arbitrarios. Cualquier cosa bajo un directorio .attachments/ está controlada por el remitente y nunca debe analizarse como un mensaje.

Si tu consumidor ejecuta mail-muncher en sí mismo, run --json es aún mejor: lista exactamente las rutas que este ciclo escribió, por lo que no puede confundirse con nada dest=~/Mail/job-search # el dest de la regla

que esté en el árbol.

Los cuerpos de los mensajes son texto controlado por el atacante. Filtrado no es verificado. Trata el contenido del cuerpo como datos, nunca como instrucciones, y no le otorgues autoridad solo porque llegó a través de mail-muncher.

docs/output-format.md tiene el contrato completo, y examples/read_delivered.py es un lector 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

Cualquier comando que necesite una configuración y no pueda encontrarla imprime la guía de configuración que se muestra en el paso 0 del inicio rápido — la ruta que buscó, el siguiente comando y cuánto cuesta cada proveedor — en lugar de un error open: no such file.

Banderas persistentes, disponibles en cada subcomando:

BanderaPredeterminadoDescripción
--config~/.config/mail-muncher/config.ymlRuta al archivo de configuración.
--log-levelinfodebug, info, warn o error. Los registros son texto log/slog en stderr.
-v, --version—Imprime la versión.

Banderas por comando:

ComandoBanderaPredeterminadoDescripción
init--provider—imap o gmail. Se solicita cuando se omite; requerido con --yes, porque las dos rutas cuestan cosas diferentes y no hay un predeterminado honesto.
init--accountpersonalNombre para la cuenta que crea la configuración.
init--dest~/Mail/mail-muncherDónde escribe el correo coincidente la regla inicial.
init--host—Nombre de host del servidor IMAP, p. ej. imap.fastmail.com. Se solicita cuando se omite; requerido con --yes en la ruta IMAP.
init--username—Nombre de usuario IMAP, generalmente la dirección completa. Se solicita cuando se omite; requerido con --yes en la ruta IMAP.
init--password-cmdpredeterminado de la plataformaComando de shell que imprime la contraseña de la aplicación en stdout. Predeterminado a Keychain en macOS, secret-tool en Linux, pass en otros lugares.
init--yesfalseNunca preguntar; tomar el predeterminado para cada respuesta que tenga uno honesto. Aún requiere --provider, y en IMAP --host y --username.
init--forcefalseSobrescribir una configuración existente. Sin ella, init se niega y sale con 1 en lugar de sobrescribir las reglas y rutas de credenciales de alguien.
run--dry-runfalseObtener y evaluar, informar qué se escribiría, no escribir nada y no guardar estado.
run--jsonfalseEscribir un manifiesto legible por máquina en stdout, un objeto JSON por cuenta.
daemon--interval5mTiempo entre ciclos, mínimo 30s. Cada espera se varía hasta ±10%.
daemon--dry-runfalseComo run --dry-run, en cada tick.
daemon--jsonfalseEscribir un manifiesto en stdout después de cada ciclo: JSON delimitado por nuevas líneas, un objeto por cuenta por tick.
auth--account—Qué cuenta autenticar. Requerido cuando la configuración tiene más de una. Solo cuentas de Gmail — en una cuenta IMAP auth se niega, porque no hay nada que autorizar.

mcp no toma banderas propias; se configura completamente por --config. Ten en cuenta que init escribe en --config en lugar de leer de él, y crea el directorio (0700) y el archivo (0600) según sea necesario.

--log-level debug registra la decisión de la regla para cada mensaje — el ID del mensaje, su asunto y el nombre de la regla ganadora o no match — que es la forma más rápida de averiguar por qué una regla no se activa. El modo de depuración también registra cada salto already stored con su ruta. Todo va a stderr, por lo que nunca interfiere con --json en stdout.

Un intervalo por debajo de 30s se rechaza antes de que comience cualquier cosa:

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

Códigos de salida

CódigoSignificado
0Éxito. Un ciclo que encontró errores por mensaje (un mensaje que no se pudo analizar, una escritura de sumidero que falló) aún sale con 0 — esos se cuentan en el resumen, no se escalan.
1Error de configuración o validación: el archivo no se pudo analizar, una regla no se pudo compilar, falta una clave requerida o no hay ningún archivo de configuración. No se obtuvo nada. También es lo que init devuelve cuando ya existe una configuración y no se dio --force.
2Falla del proveedor o autenticación: un token de Gmail que nunca se escribió o que Google rechazó, un password_cmd IMAP que falló o un inicio de sesión que el servidor rechazó, el servidor inalcanzable después de reintentos.
3Otra instancia tiene el bloqueo del ciclo. El código de salida 3 es cómo una invocación cron superpuesta informa "la anterior aún está en curso".

Un mensaje en cuarentena no cambia el estado de salida: es una falla a nivel de mensaje que se manejó, se contó como quarantined y se informó en el manifiesto. Alerta sobre el contador, no sobre el código de salida.

daemon nunca sale con 2. Un ciclo fallido se registra con un contador de fallas consecutivas y se reintenta en el siguiente tick — un demonio que se detuviera en el primer token caducado necesitaría un humano en el momento exactamente equivocado. Sus estados son 0 (detenido por SIGINT o SIGTERM, después de dejar que el ciclo en curso termine y guardar el estado), 1 (error de configuración o validación, incluido un --interval por debajo del mínimo) y 3 (otra instancia ya tenía el bloqueo de instancia o ciclo al inicio). Un bloqueo mantenido por un tick posterior — un run cron superpuesto con un tick del demonio — se registra y se omite, no es fatal.

El resumen de ejecución

Cada ciclo registra e imprime una línea de resumen por cuenta:

personal: fetched=128 matched=6 stored=6 skipped=0 parse_errors=0 sink_errors=0 quarantined=0 duration=4.1s
CampoCuentaSignificado
fetchedmensajesMensajes que el proveedor entregó este ciclo.
matchedmensajesMensajes que alguna regla reclamó.
storedrenderizacionesRenderizaciones realmente escritas.
skippedrenderizacionesRenderizaciones no escritas porque el destino ya existía.
parse_errorsmensajesMensajes que no se pudieron analizar; registrados y omitidos.
sink_errorsrenderizacionesFallas de escritura; registradas y contadas, el ciclo continúa.
quarantinedmensajesMensajes colocados bajo el directorio de cuarentena porque no se pudieron entregar.
vanishedmensajesMensajes que el proveedor listó y luego descubrió que ya no existían — eliminados entre el listado y la descarga. Omitidos, y el cursor avanzó más allá de ellos. Solo se imprime cuando es distinto de cero.

La lista de campos corre contiguamente desde fetched= hasta duration=, por lo que cualquier cosa inusual sobre el ciclo se marca en la etiqueta de la cuenta en su lugar:

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

vanished=N es el único contador que se agrega condicionalmente. Aparece después de quarantined= solo en las ejecuciones donde un mensaje desapareció a mitad del ciclo, y se omite en todos los demás lugares, por lo que la lista de campos publicada no cambia para ninguna otra ejecución. Bajo --json es summary.vanished, también omitido cuando es cero — analízalo como .summary.vanished // 0 si quieres alertar sobre él. Una ejecución de cron en estado estable se ve como fetched=0. Una re-ejecución sobre la misma ventana se ve como matched=N stored=0 skipped=N — eso es la idempotencia funcionando.

El manifiesto JSON

run --json y daemon --json reemplazan esa línea con un objeto legible por máquina por cuenta — stored[], skipped[], quarantined[], los contadores, y si el cursor avanzó. stderr conserva cada línea de registro, así que run --json 2>/dev/null es JSON puro y daemon --json es un flujo NDJSON.

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

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

Estado y bloqueo

~/.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

El archivo de estado de cada cuenta es JSON. El cursor dentro de él es del proveedor, por lo que su forma difiere. Gmail:

{
  "history_id": 918273,
  "last_sync_time": "2026-07-28T09:15:00Z",
  "seen_ids": ["18f2a...", "18f2b..."]
}
  • history_id es el cursor incremental de Gmail. Cuando está establecido, un ciclo pregunta a Gmail solo qué cambió desde entonces. Gmail conserva aproximadamente una semana de historial; cuando el cursor caduca, la API responde 404, mail-muncher registra una advertencia, limpia el cursor y vuelve a un escaneo completo en el mismo ciclo.
  • last_sync_time limita el término after: de un escaneo completo. Un escaneo de recuperación alcanza 24 horas más atrás que la marca de agua almacenada, para que el correo que llegó mientras el ciclo anterior estaba listando no sea omitido. La superposición es inofensiva: todo lo que ya está en disco se omite en la escritura.
  • seen_ids es un conjunto FIFO de los últimos 2000 IDs de mensajes entregados — cinturón y tirantes junto con los nombres de archivo idempotentes de los sumideros.

IMAP rastrea cada buzón de forma independiente, como un par UIDVALIDITY/UID bajo 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"]
}

Un ciclo cuyo UIDVALIDITY almacenado aún coincide con el del servidor solicita UID FETCH 48214:* — solo lo que llegó desde entonces. Un ciclo que encuentra que cambió descarta el UID almacenado y se resincroniza desde initial_lookback, porque un cambio de UIDVALIDITY es el protocolo anunciando que cada UID que mail-muncher recuerda ahora nombra un mensaje diferente. Esa resincronización re-archiva la ventana que cubre bajo nombres de archivo nuevos, ya que la identidad de un mensaje en esta ruta es <account>:<mailbox>:<uidvalidity>:<uid>. La compensación es deliberada: el correo duplicado puede eliminarse, el correo omitido silenciosamente no puede recuperarse. Detalle completo: docs/configuration.md.

El directorio de estado se crea con 0700 y los archivos de estado con 0600: saber qué cuentas existen y qué IDs de mensajes se vieron no es información pública.

Eliminar el archivo de estado de una cuenta fuerza un re-escaneo completo limitado por initial_lookback. Eso es seguro — los sumideros omiten todo lo que ya está en disco — y es la forma soportada de recuperarse de un cursor corrupto.

Los dos bloqueos

  • El bloqueo de ciclo, <state_dir>/mail-muncher.lock, está basado en flock y se mantiene durante la duración de cada ciclo por run, por cada tick del daemon, y por la herramienta sync del MCP. Es lo que evita que una invocación de cron compita con un daemon en ejecución sobre los mismos cursores. Encontrarlo mantenido es "ahora no", no "roto": run sale con código 3, y un tick del daemon registra y omite.

  • El bloqueo de instancia, <state_dir>/instance/mail-muncher.lock, lo mantiene un daemon durante toda su vida de proceso. El bloqueo de ciclo no puede hacer este trabajo — se libera entre ticks, así que un segundo daemon que se inicie mientras el primero duerme pasaría por encima y luego sondearía para siempre junto a él, duplicando el tráfico de API contra los mismos cursores. Un segundo daemon sale con código 3 inmediatamente:

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

Ambos son liberados por el sistema operativo si el proceso se bloquea.

Cuarentena

Bajo el on_message_failure: quarantine predeterminado, un mensaje que no pudo entregarse se escribe en <quarantine_dir>/<account>/<id>.eml — los bytes crudos, verbatim — con un archivo lateral .json junto a él que nombra la regla, la etapa que falló (parse o sink), el error y la hora. El archivo lateral es autocontenido a propósito: barrer el directorio no debe requerir los registros de la ejecución.

Nada re-entrega automáticamente. Corrige la causa y alimenta el .eml de nuevo a mano. El directorio es 0700 y los archivos 0600, como el resto del directorio de estado — un mensaje en cuarentena es un correo completo sentado fuera de su árbol de destino.

Programación

cron — un ciclo cada diez minutos. run sale con código 3 si la invocación anterior aún está en curso, así que las superposiciones son 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

Usa una ruta absoluta al binario y a la configuración: el entorno de cron no es el de tu shell, y la expansión de ~ en la configuración depende de que $HOME esté establecido.

Una muestra más completa con el entorno que cron no te da está en contrib/crontab.sample.

launchd (macOS) — ejecuta el daemon bajo launchd para que sobreviva al cierre de sesión y se reinicie en caso de fallo. Un plist listo para editar vive en contrib/launchd/. Cópialo, edita las cuatro rutas absolutas dentro de la copia — launchd no expande ni ~ ni $HOME — luego cárgalo:

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 — aún no se incluye una unidad; mail-muncher daemon --interval 5m es un servicio Type=simple sencillo, o combina mail-muncher run con un temporizador.

Relleno: la primera ejecución

Una primera ejecución amplia es un modo intencionado, no un abuso. El initial_lookback predeterminado es 720h (30 días), lo que silenciosamente significa que lo que más quieres el primer día — toda la historia de lo que estás recopilando, en disco, una vez — es lo que no sucede por defecto.

Establécelo amplio para la primera ejecución. La clave existe en ambos proveedores, bajo el bloque que use tu cuenta:

Para detenerlo, launchctl unload la misma ruta. Eliminarlo para siempre significa borrar también ese archivo — ver Desinstalación.

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

Luego ejecútalo una vez, y baja el valor de nuevo después:

      initial_lookback: 720h

Bajarlo de nuevo es seguro porque initial_lookback solo limita el escaneo primera vez — de una cuenta en Gmail, de cada buzón en IMAP. Cada ciclo posterior se reanuda desde el cursor almacenado e ignora la clave por completo. Se re-activa solo al eliminar el archivo de estado de la cuenta, o, en IMAP, al cambiar el servidor UIDVALIDITY.

Qué esperar:

  • Es un gran escaneo completo, con límite de velocidad y reintentos, y puede llevar un tiempo. En Gmail, las descargas se ejecutan de cuatro en cuatro y las páginas son de 500 mensajes; ninguno es configurable. En IMAP se convierte en una búsqueda UID SINCE por buzón.

  • Mira antes de comprometerte. mail-muncher run --dry-run obtiene y evalúa exactamente como lo haría una ejecución real e informa cada ruta que escribiría, sin tocar el árbol de destino. Agrega --json para contarlos:

    mail-muncher run --dry-run --json 2>/dev/null | jq '.summary'
    
  • La ejecución posterior debería informar todo como skipped. Ese es el chequeo de que el relleno realmente aterrizó.

Las duraciones de Go no tienen unidad de día o año — multiplica horas. 18 meses es 13140h, un año es 8760h, 90 días es 2160h.

El límite de 2000 entradas del conjunto visto no limita un relleno. Ver docs/architecture.md para saber por qué: el nombre de archivo determinista más omitir-si-existe es la clave real de idempotencia, y el conjunto visto es solo cinturón y tirantes.

Estado y alcance

Pre-1.0. La versión actual es v0.4.0, y lo que cambió en cada una está en CHANGELOG.md; una compilación sin make informa su versión como dev. El esquema de configuración es lo suficientemente estable para escribir contra él, pero trátalo como sujeto a cambios hasta 1.0.

ÁreaEstado
Proveedor IMAP (password_cmd, cursores UID por buzón, EXAMINE + BODY.PEEK[])Construido
Proveedor Gmail (OAuth, escaneo completo, sincronización de historial incremental, descarga RAW)Construido
init — escribe una configuración validada para cualquiera de los proveedores, interactivo o con scriptConstruido
Guía de configuración en cada comando no configurado o medio configuradoConstruido
mcp sirviendo correctamente según el protocolo cuando no está configuradoConstruido
Carga y validación de configuraciónConstruido
Motor de filtros (todos los combinadores y predicados listados arriba)Construido
Sumideros .eml y markdownConstruido
run, daemon, bloqueo de ciclo, bloqueo de instanciaConstruido
Manifiestos de ejecución --jsonConstruido
mcp — servidor MCP stdio, cinco herramientasConstruido
Cuarentena y las dos políticas de falloConstruido

Deliberadamente fuera de alcance

  • Escribir en tu buzón. Solo lectura es una restricción de diseño, no una fase. Sin etiquetado, sin eliminación, sin envío, sin borradores, y en IMAP ni siquiera marcar un mensaje como leído.
  • Ser un cliente de correo. Sin interfaz de usuario, y sin índice. search_messages es un escaneo de subcadenas sin distinción de mayúsculas sobre los archivos almacenados, no un motor de búsqueda: sin derivación, sin clasificación, sin lenguaje de consulta. Los hilos se agrupan por un id llevado en cada mensaje, no se reensamblan en un modelo de conversación.
  • Una API de red. Nada escucha en un socket. mcp habla un protocolo stdio con un cliente que lo lanzó como subproceso — sin puerto, sin endpoint HTTP, sin superficie de red. Lo único que se vincula es el puerto de loopback auth que se abre durante unos segundos durante el redireccionamiento OAuth en la ruta de Gmail.
  • Incluir un cliente OAuth. gmail.readonly es un alcance restringido de Google, así que la ruta de Gmail siempre significará registrar el tuyo propio — que es exactamente por qué IMAP existe como la vía de baja fricción.

Limitaciones conocidas

  • Las imágenes cid: en línea se dejan como enlaces no resueltos en la salida markdown.
  • Los slugs de asunto son solo ASCII; los asuntos no latinos se archivan como no-subject.
  • Ningún predicado ve el cuerpo del mensaje; la coincidencia es en encabezados, etiquetas y fechas.

Gmail:

  • El token de Gmail expira cada 7 días en una pantalla de consentimiento en modo Testing, así que mail-muncher auth es una tarea semanal. Esto es política de Google, no un ajuste.
  • gmail.query se aplica solo al escaneo primera vez de una cuenta — no a ciclos incrementales, y no a un escaneo de recuperación después de que el cursor expire.
  • Spam y Papelera no se obtienen en absoluto a menos que gmail.include_spam_trash: true. Una vez obtenidos, una regla sobre las etiquetas SPAM y TRASH decide qué sucede con ellos.
  • La concurrencia de descarga de Gmail (4) y el tamaño de página (500) no son configurables.

IMAP:

  • Una contraseña de aplicación es una credencial completa de correo. La solo lectura es aplicada por el código de mail-muncher, no por tu proveedor — ver la comparación.
  • Un servidor que cambia UIDVALIDITY re-archiva la ventana initial_lookback bajo nombres de archivo nuevos. Deliberado: el correo duplicado es recuperable, el omitido no.
  • IMAP no tiene id de conversación del lado del servidor, así que thread_id siempre se sintetiza desde la cadena References/In-Reply-To y thread_id_source nunca es provider.
  • Un buzón que el servidor no tiene es un error, no una carpeta vacía — así que un error tipográfico en mailboxes: falla la primera ejecución en lugar de parecer correo silencioso.
  • Solo se obtienen las carpetas listadas en mailboxes:; no hay opción "todo" ni búsqueda del lado del servidor.

Documentación

Navegable en https://craigjmidwinter.github.io/mail-muncher/, o en este repositorio:

  • docs/configuration.md — cada clave de configuración para ambos proveedores, su regla de validación y su modo de fallo. La sección accounts[].imap es la referencia de IMAP; no hay una página de configuración separada porque no hay configuración más allá de una contraseña de aplicación.
  • docs/gmail-setup.md — solo la ruta de Gmail: tutorial de Google Cloud, la caducidad de siete días y cada mensaje de error de OAuth con su solución. Nada de esto aplica a una cuenta IMAP.
  • docs/filters.md — el lenguaje completo del árbol de coincidencias más un recetario de reglas reales.
  • docs/output-format.md — el contrato en disco: diseño, nombres de archivo, cada clave de frontmatter y las reglas de seguridad para enumerar un árbol de entrega. Léelo antes de escribir un consumidor.
  • docs/manifest.md — el contrato del manifiesto --json, campo por campo.
  • docs/mcp.md — el servidor MCP: conexión del cliente y los argumentos y la forma de retorno de cada herramienta.
  • docs/architecture.md — el pipeline, sus uniones y dónde encaja tu cambio.
  • CONTRIBUTING.md — compilación, pruebas y las convenciones que el código base se exige a sí mismo.

Configuraciones ejecutables: examples/imap.yml (la ruta IMAP, muy comentada), examples/minimal.yml (la configuración de Gmail más pequeña y útil), examples/job-search.yml (un archivo de filtros gestionado externamente en uso real) y examples/read_delivered.py (un consumidor correcto). Las tres configuraciones pasan mail-muncher validate --config <file>.

Agradecimientos

La marca tipográfica está compuesta en Silkscreen por Jason Kottke, utilizada bajo la SIL Open Font License 1.1. Los activos de marca, la paleta y las reglas de uso están en branding/BRAND.md.

Licencia

MIT. Consulta LICENSE.