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
mail-muncher
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: imap | provider: gmail | |
|---|---|---|
| Tiempo de configuración | ~2 min | ~10 min en la consola de Google Cloud |
| Qué registras | nada | tu propio proyecto de Google Cloud y cliente OAuth de aplicación de escritorio |
| Credencial | una contraseña de aplicación desde la página de configuración de tu proveedor | un token OAuth, alcance gmail.readonly |
| Qué tan amplia es esa credencial | una credencial de correo completa. Una contraseña de aplicación puede enviar y eliminar | solo lectura, y nada más |
| Quién hace cumplir la solo lectura | el propio código de mail-muncher | |
| Caducidad | ninguna | cada 7 días en una pantalla de consentimiento en modo Prueba; mail-muncher auth debe reejecutarse semanalmente |
| Dónde vive el secreto | donde 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 password | token.json, modo 0600, escrito por mail-muncher auth |
| Qué buzones | las carpetas que enumeras en mailboxes:; [INBOX] por defecto | toda la cuenta de Gmail, menos Spam y Papelera a menos que las pidas |
| Funciona con | Gmail, Fastmail, iCloud, Proton Bridge, cuentas de trabajo, autoalojado | solo Gmail |
| Pasos adicionales | ninguno. No hay comando auth en esta ruta | mail-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
EXAMINEy nuncaSELECT, cada cuerpo se obtiene conBODY.PEEK[]y nuncaBODY[](para que el correo nunca se marque como leído), y no hay ninguna ruta de código en el proveedor que emitaSTORE,APPENDoEXPUNGE. 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 mcpa 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 esEXAMINEyBODY.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:
| Herramienta | Qué 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_message | Un mensaje completo — metadatos, cuerpo, nombres y tamaños de adjuntos — y opcionalmente todo su hilo en orden. |
sync | Busca 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 |
|---|---|
| getmail6 | Quieres 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. |
| fdm | Quieres 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. |
| lieer | Quieres todo tu buzón de Gmail sincronizado bidireccionalmente en un Maildir local para notmuch, no un subconjunto filtrado extraído de él. |
gmail-archive | Era 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-exporter | Quieres una exportación única, basada en etiquetas, en forma de hoja de cálculo en lugar de sincronización incremental. |
mbsync / offlineimap | Quieres 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.readonlyes 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 authtiene 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.
runlo 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.comcoincide conacme.comycareers.acme.com, pero no connotacme.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]
| Clave | Tipo | Predeterminado | Descripción |
|---|---|---|---|
state_dir | ruta | ~/.local/state/mail-muncher | Cursores de sincronización (un archivo JSON por cuenta), el bloqueo de ciclo, el bloqueo de instancia y el directorio de cuarentena. |
on_message_failure | quarantine, abort | quarantine | Qué hacer con un mensaje que no se puede analizar o en el que un sumidero falló. Ver abajo. |
on_degraded_filter | hold, fail, proceed | hold | Qué hacer cuando el from_domains_file o from_regex_file de una regla no se puede leer. Ver abajo. |
quarantine_dir | ruta | <state_dir>/quarantine | Dónde se estacionan los mensajes en cuarentena. |
accounts | lista | — | Buzones de los que extraer. Se requiere al menos uno. |
accounts[].name | cadena | — | Requerido, único. Nombra el archivo de estado y es a lo que se refiere rules[].account. |
accounts[].provider | imap, gmail | — | Requerido; no hay predeterminado. Qué backend obtiene. Ver Dos formas de conectar un buzón. |
accounts[].imap | mapeo | — | Requerido — y solo permitido — cuando el proveedor es imap. |
accounts[].imap.host | cadena | — | Requerido. imap.fastmail.com, imap.gmail.com, 127.0.0.1 para el Proton Bridge. |
accounts[].imap.port | entero | 993 | 993 es TLS implícito (IMAPS) y se combina con el predeterminado de tls: true. |
accounts[].imap.username | cadena | — | Requerido. Generalmente la dirección completa; algunos proveedores quieren la parte local simple. |
accounts[].imap.password_cmd | comando 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.mailboxes | lista 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.tls | booleano | true | TLS 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_lookback | duración Go | 720h | Qué 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[].gmail | mapeo | — | Requerido — y solo permitido — cuando el proveedor es gmail. |
accounts[].gmail.credentials_file | ruta | — | Requerido. El JSON de cliente OAuth descargado de Google Cloud. |
accounts[].gmail.token_file | ruta | — | Requerido. Dónde auth almacena en caché el token OAuth, modo 0600. |
accounts[].gmail.query | cadena | ninguno | Expresión de búsqueda de Gmail opcional. Una optimización de costos solo para el escaneo inicial — ver abajo. |
accounts[].gmail.initial_lookback | duración Go | 720h | Qué tan atrás llega el escaneo inicial. Debe ser positivo. Ver Backfill. |
accounts[].gmail.include_spam_trash | booleano | false | Obtener 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. |
rules | lista | — | Evaluada en orden contra cada mensaje; la primera coincidencia gana. |
rules[].name | cadena | — | Requerido, único. Aparece en registros y en el frontmatter de markdown. |
rules[].account | cadena | todas las cuentas | Restringe la regla a una cuenta. |
rules[].match | nodo de coincidencia | — | Requerido. Ver Filtros. |
rules[].dest | ruta | — | Requerido. Directorio de destino; se crea bajo demanda. |
rules[].formats | lista 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
validatedetectainitial_lookbakantes de que se ejecute una corrida. ~y$VARse expanden en cada campo con valor de ruta, incluidos los valores defrom_domains_fileyfrom_regex_filedentro de un árbol de coincidencias. Las formas~userno son compatibles. Una variable no definida se expande a la cadena vacía, como en un shell.gmail.queryno 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 comoSPAMoTRASHantes de que lleguen a la canalización. Establecegmail.include_spam_trash: truepara obtenerlos de todos modos — consulta Spam y Papelera. En IMAP no hay clave equivalente: obtienes exactamente las carpetas que enumeras enmailboxes:, por lo que simplemente no listar la carpeta de basura es todo el mecanismo. - Los bloques
gmail:yimap:son mutuamente excluyentes. Establecer el que no coincide conprovider:es un error grave en lugar de un bloque ignorado silenciosamente, por lo que un bloqueimap: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.
| Valor | Comportamiento |
|---|---|
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. |
abort | Devuelve 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.
| Valor | Comportamiento |
|---|---|
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. |
fail | Termina el ciclo antes de que se obtenga cualquier cosa. Nada almacenado, nada avanzado, salida no cero. |
proceed | Trata 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
| Clave | Valor | Coincide cuando |
|---|---|---|
all | lista de nodos | cada hijo coincide (se requiere al menos un hijo) |
any | lista de nodos | al menos un hijo coincide (se requiere al menos un hijo) |
not | un solo nodo | el hijo no coincide |
match:
all:
- any:
- from_domains: [acme.com]
- from_domains_file: ~/.local/share/agent/domains.txt
- not:
subject_regex: "(?i)^\\[newsletter\\]"
Predicados
| Clave | Valor | Coincide cuando |
|---|---|---|
from_domains | lista de dominios | el dominio de cualquier dirección From es igual o es un subdominio de un dominio listado |
from_domains_file | ruta | lo mismo, con la lista leída de un archivo de propiedad externa cada ciclo |
from_regex | patrón RE2 | el patrón coincide con cualquier especificación de dirección From (sin nombre para mostrar) |
from_regex_file | ruta | lo mismo, con los patrones leídos de un archivo de propiedad externa cada ciclo |
to_regex | patrón RE2 | el patrón coincide con cualquier especificación de dirección To o Cc |
subject_regex | patrón RE2 | el patrón coincide con el Subject decodificado |
header | {name: X-Foo, regex: ...} | el patrón coincide con cualquier valor de ese encabezado |
has_attachment | true / false | el mensaje tiene (o no tiene) un adjunto real |
label | nombre de etiqueta | el 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_than | duración Go | el mensaje Date está más en el pasado que la duración |
newer_than | duración Go | el 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_regexyto_regexprueban la dirección desnuda (jane@acme.com), nunca el nombre para mostrar. Usaheader: {name: From, regex: ...}para probar el encabezado sin procesar incluyendo el nombre para mostrar.has_attachmentcuenta las partes marcadas comoContent-Disposition: attachment. Las imágenes en línea referenciadas porcid:no son adjuntos.labeldistingue entre mayúsculas y minúsculas y es exacto —label: inboxno coincide conINBOX. En una cuenta IMAP, los valores son los nombres de buzones que listaste bajoimap.mailboxes, por lo que un mensaje solo puede llevar el del que se obtuvo.older_than/newer_thancomparan contra el encabezadoDatedel 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/falseparahas_attachment. YAML 1.2 tratayesynocomo 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_trash | si esos mensajes se obtienen en absoluto |
Una regla sobre las etiquetas SPAM / TRASH | qué 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 conEEXISTen lugar de sobrescribir, a diferencia derename(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 comoskipped. - 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 dedest, 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 propiodest: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/plainsi existe; de lo contrario, la partetext/htmlconvertida 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,labelsyattachmentsse omiten cuando están vacíos. Analiza las direcciones de los campos*_address/*_addresses, nunca defrom/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 divisorkey: valuese equivoca con ambos. Consulta docs/output-format.md. - El hilo tiene tres campos, no cuatro.
thread_ides 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 cadenaReferencesdel mensaje. Agrupa un directorio por él sin casos especiales.thread_id_sourceindica cuánto confiar en esa agrupación —provider,references,in_reply_tooself— porque la reconstrucción es de mejor esfuerzo y un cliente de correo que rompe la cadena divide un hilo.in_reply_tonombra al padre. La cadenaReferencescompleta se omite deliberadamente: es ilimitada, y el.emljunto 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.mdo.emlneutralizada a.md.attachment/.eml.attachment, y las colisiones se deduplican comoname-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— 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
.mdno 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:
| Bandera | Predeterminado | Descripción |
|---|---|---|
--config | ~/.config/mail-muncher/config.yml | Ruta al archivo de configuración. |
--log-level | info | debug, info, warn o error. Los registros son texto log/slog en stderr. |
-v, --version | — | Imprime la versión. |
Banderas por comando:
| Comando | Bandera | Predeterminado | Descripció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 | --account | personal | Nombre para la cuenta que crea la configuración. |
init | --dest | ~/Mail/mail-muncher | Dó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-cmd | predeterminado de la plataforma | Comando 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 | --yes | false | Nunca preguntar; tomar el predeterminado para cada respuesta que tenga uno honesto. Aún requiere --provider, y en IMAP --host y --username. |
init | --force | false | Sobrescribir 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-run | false | Obtener y evaluar, informar qué se escribiría, no escribir nada y no guardar estado. |
run | --json | false | Escribir un manifiesto legible por máquina en stdout, un objeto JSON por cuenta. |
daemon | --interval | 5m | Tiempo entre ciclos, mínimo 30s. Cada espera se varía hasta ±10%. |
daemon | --dry-run | false | Como run --dry-run, en cada tick. |
daemon | --json | false | Escribir 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ódigo | Significado |
|---|---|
| 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. |
| 1 | Error 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. |
| 2 | Falla 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. |
| 3 | Otra 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
| Campo | Cuenta | Significado |
|---|---|---|
fetched | mensajes | Mensajes que el proveedor entregó este ciclo. |
matched | mensajes | Mensajes que alguna regla reclamó. |
stored | renderizaciones | Renderizaciones realmente escritas. |
skipped | renderizaciones | Renderizaciones no escritas porque el destino ya existía. |
parse_errors | mensajes | Mensajes que no se pudieron analizar; registrados y omitidos. |
sink_errors | renderizaciones | Fallas de escritura; registradas y contadas, el ciclo continúa. |
quarantined | mensajes | Mensajes colocados bajo el directorio de cuarentena porque no se pudieron entregar. |
vanished | mensajes | Mensajes 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_ides 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_timelimita el términoafter: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_idses 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 enflocky se mantiene durante la duración de cada ciclo porrun, por cada tick del daemon, y por la herramientasyncdel 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":runsale 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
SINCEpor buzón. -
Mira antes de comprometerte.
mail-muncher run --dry-runobtiene 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--jsonpara 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.
| Área | Estado |
|---|---|
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 script | Construido |
| Guía de configuración en cada comando no configurado o medio configurado | Construido |
mcp sirviendo correctamente según el protocolo cuando no está configurado | Construido |
| Carga y validación de configuración | Construido |
| Motor de filtros (todos los combinadores y predicados listados arriba) | Construido |
Sumideros .eml y markdown | Construido |
run, daemon, bloqueo de ciclo, bloqueo de instancia | Construido |
Manifiestos de ejecución --json | Construido |
mcp — servidor MCP stdio, cinco herramientas | Construido |
| Cuarentena y las dos políticas de fallo | Construido |
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_messageses 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.
mcphabla 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 loopbackauthque se abre durante unos segundos durante el redireccionamiento OAuth en la ruta de Gmail. - Incluir un cliente OAuth.
gmail.readonlyes 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 authes una tarea semanal. Esto es política de Google, no un ajuste. gmail.queryse 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 etiquetasSPAMyTRASHdecide 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_lookbackbajo 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_idsiempre se sintetiza desde la cadenaReferences/In-Reply-Toythread_id_sourcenunca esprovider. - 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[].imapes 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.