Bot de automatización de email single-user, construido sobre la Gmail API. Lee los correos no leídos del inbox, los clasifica con un LLM y ejecuta acciones (etiquetar, archivar, responder, reenviar, crear borradores…) según reglas declarativas por cuenta. Además envía recordatorios diarios de reuniones leyendo Google Calendar.
La entrada de correo llega vía Cloudflare Email Routing → Gmail inbox. Gmail es la fuente de verdad (no hay base de datos de correos): el bot lee y escribe sobre el mismo buzón con labels.
Origen: el repo nace como separación de
pacto-mundial-botpara tener una versión Gmail-native sin acoplamiento a Microsoft Graph / Outlook.
- Arquitectura
- Pipeline de procesamiento de email
- Categorías y enrutado del inbox
- Pre-filtros
- Acciones disponibles
- Clasificador (LLM)
- Plantillas de respuesta y firma
- Recordatorios de Google Calendar
- Idempotencia y prevención de bucles
- Notificaciones (Telegram) e Interactive Brokers
- Métricas (Supabase)
- Panel de administración
- Configuración
- OAuth2 y scopes
- Archivo local y limpieza de adjuntos
- Comandos
- Despliegue
- Estructura del proyecto
Cloudflare Email Routing ─► Gmail inbox
│
(cada poll, ~10 min) ▼
Bot de polling ──► Gmail API
1. lee no leídos (is:unread in:inbox)
2. idempotencia (PROCESSED_TAGS)
3. pre-filtros (por remitente/asunto)
4. clasifica con LLM (OpenAI/Groq)
5. ejecuta acción (tag/move/reply/forward/…)
6. registra métrica (Supabase)
(cada día 09:16) ▼
Scheduler de recordatorios ──► Google Calendar API
reuniones de hoy con 1-2 invitados → email a cada asistente
Todo corre en un único proceso (--server): un servidor FastAPI (admin UI + health) con dos
daemon threads en background — el bot de polling y el scheduler de recordatorios.
Por cada email no leído del inbox (gmail_inbox_bot/bot.py::_process_email):
- Idempotencia — si el email ya tiene algún tag de
PROCESSED_TAGS, se salta. - Pre-filtros — reglas rápidas por remitente/asunto que cortocircuitan la clasificación
(
mail_processing.py::apply_pre_filters). Si alguna coincide, se ejecuta su acción y se termina. - Detección de reenvíos — si el remitente coincide con
forwarded_from, se intenta extraer el remitente original del cuerpo para responder a la persona correcta (o forzar borrador con aviso si no se puede extraer). - Clasificación LLM — devuelve
categoria,idiomayrazon_clasificacion. Si falla → tagERROR IA(queda sin leer en el inbox). - Notificación — si la categoría está en
NOTIFY_CATEGORIES, avisa por Telegram (actualmente desactivado, ver más abajo). - Acción — según
routing[categoria]se ejecuta el handler correspondiente (actions.py::execute). - Métrica — registro fire-and-forget en Supabase (categoría, acción, modelo, tokens, coste…).
Los fallos de un email se aíslan: se etiqueta ERROR IA y se continúa con el siguiente.
Premisa: lo que requiere acción del usuario se queda sin leer en el inbox con una etiqueta del bot; el resto se mueve fuera del inbox a su carpeta (label).
| Categoría | Destino | ¿En INBOX? | ¿Unread? | Motivo |
|---|---|---|---|---|
personal |
tag REVISAR IA |
Sí | Sí | Requiere acción/respuesta |
finanzas |
tag REVISAR IA |
Sí | Sí | Verificación/acción financiera |
otros |
tag REVISAR IA |
Sí | Sí | Fallback seguro — revisión manual |
compras |
carpeta Compras |
No | No | Informativo |
notificaciones |
carpeta Notificaciones |
No | No | Alertas de apps |
automatico |
carpeta Automatico |
No | No | Out-of-office, noreply |
newsletters |
carpeta Newsletters |
No | Sí | Se conserva sin leer para lectura eventual |
spam |
papelera | No | — | Basura (recuperable 30 días) |
| error clasificador | tag ERROR IA |
Sí | Sí | Fallo técnico pre-clasificación |
| error config/acción | tag PENDIENTE GESTIONAR |
Sí | Sí | Sin template/routing/acción desconocida |
Nunca se borra permanentemente nada:
deletemueve a papelera (recuperable 30 días).
Se evalúan antes de clasificar, en orden, y la primera coincidencia gana. Útiles para silenciar o
enrutar remitentes conocidos sin gastar una llamada al LLM. Criterios de match:
sender_contains, sender_not_contains, subject_contains, subject_not_contains (string o lista).
Acciones de pre-filtro: silent, tag, tag_and_move, delete, ib_trade.
pre_filters:
- name: GitHub notifications (archivar)
match:
sender_contains: notifications@github.com
action: tag_and_move
tag: Notificaciones
folder: NotificacionesDefinidas en routing[categoria].action (actions.py):
| Acción | Efecto |
|---|---|
tag |
Añade una etiqueta; respeta is_read (puede dejarlo sin leer en el inbox) |
move |
Marca leído + mueve a carpeta (quita INBOX) |
silent |
Marca leído, sin más |
delete |
Mueve a papelera |
reply |
Responde con una plantilla fija (por categoría e idioma) |
reply_with_attachment |
Responde con plantilla + adjuntos |
dynamic_reply |
Genera la respuesta con el LLM (response_prompt_file) |
forward |
Reenvía a un destinatario fijo (destination) |
tag_and_move |
Etiqueta y mueve |
reply_and_move |
Responde y mueve |
Todos los salientes (reply, dynamic_reply, reply_with_attachment, forward) incluyen la firma
HTML (ver abajo). Los borradores añaden un banner con el motivo de clasificación de la IA (solo en
borrador, nunca en emails enviados).
classifier.py usa neutral-llm-gateway==0.16.0 con salida json_object. El bot conserva su API
síncrona mediante llm_gateway_client.py; por debajo, el gateway usa los adapters async oficiales
de Groq y OpenAI. Las credenciales las lee la aplicación y las entrega explícitamente a las
factorías del paquete; el gateway no lee el entorno. El prompt vive en
gmail_inbox_bot/prompts/clasificador_inbox.txt (referenciado por classifier.prompt_file).
- Modelo por defecto:
openai/gpt-oss-120bvía Groq (GROQ_API_KEY). - Fallback automático: si Groq falla (quota/caída/rate-limit) reintenta con
gpt-5.6-lunavía OpenAI (OPENAI_API_KEY). - Credenciales parciales: los modelos cuyo proveedor no está configurado se eliminan del plan; con solo OpenAI, la petición empieza directamente en Luna, y con solo Groq no intenta Luna.
- Salida inválida: JSON ilegible también activa el fallback y queda contabilizado como intento.
- Razonamiento: Luna usa
maxcuando es el modelo primario efectivo. Si la llamada empieza en Groq, no se fuerza esfuerzo para no encarecer el camino normal; el paquete no permite aplicarmaxsolo al modelo de fallback, por lo que Luna hereda el esfuerzo vacío en esa degradación puntual. - Costes:
llm_costs.pyconserva el formato legado de métricas (split entrada/salida), pero modelos, proveedores y tarifas proceden del catálogo versionado del gateway. - Override por cuenta:
classifier.modelen el YAML.
El prompt tiene dos bloques: reglas generales (definiciones de categoría) y reglas aprendidas de producción (refinamientos por dominio/remitente a partir de errores reales). Para mejorar la clasificación se añaden reglas concretas a este último bloque (preferir reglas por remitente/dominio sobre contenido del body).
- Plantillas por categoría/idioma en
templatesdel YAML (esp/pt), con soporte de variantes por fecha (valid_from/valid_until+default). - Firma (
templates/signature.html): footer HTML de marketing de aiship.co, siempre en inglés, añadido a todos los salientes. Personalizable por cuenta consignature_file:o desactivable consignature_file: "".- Excepción: los recordatorios de Calendar no llevan este footer (ver abajo).
Un scheduler interno (calendar_reminders.py, segundo daemon thread) revisa cada mañana el
calendario de cada cuenta y envía a los asistentes un recordatorio de las reuniones del día.
- A quién: solo reuniones con 1 o 2 invitados además del titular (1:1 y tríos). Se excluyen eventos all-day, cancelados, los que el titular rechazó, los invitados que rechazaron, los recursos /salas y los eventos sin invitados humanos.
- Cuándo: a la hora
send_timede cada mailbox (por defecto 09:16 Europe/Madrid). La hora "rota" (no en punto) es deliberada: busca que el mensaje parezca escrito a mano en un momento cualquiera, no un cron disparando a las 9:00. - Qué envía: email en prosa natural, firmado con
sender_name, sin footer de marketing. El saludo usa el nombre real del invitado y nunca muestra su email. HTML con autoescape (los campos de Calendar se escapan). - Idempotencia: estado JSON en
logs/calendar_reminders_state.json(volumen docker). Dedupe global entre cuentas poriCalUID+ invitado → cada persona recibe un único recordatorio por reunión. Si un envío falla, ese día no se marca completado y se reintenta en el siguiente tick. - Credenciales: reutiliza el OAuth de Gmail (mismo refresh token, ampliado con
calendar.readonly). - Limitación: solo se lee el calendario
primaryde cada cuenta; las reuniones en calendarios secundarios (compartidos/de equipo) no generan recordatorio.
Configuración opt-in por mailbox:
calendar_reminders:
enabled: true
send_time: "09:16"
timezone: Europe/Madrid
max_attendees: 2 # invitados además del titular
sender_name: Miguel # nombre con el que se firmaEjecución manual / pruebas:
uv run python -m gmail_inbox_bot.calendar_reminders --once --dry-run # lista sin enviar ni guardar estado
uv run python -m gmail_inbox_bot.calendar_reminders --once # envía una vez ahoraDos mecanismos evitan reprocesar o entrar en bucle:
- Acciones que quitan
INBOX(move,tag_and_move…) → el queryis:unread in:inboxno vuelve a encontrar el email. already_processed()→ si el email tiene un tag dePROCESSED_TAGS(RESPONDIDO IA,REVISAR IA,ERROR IA,PENDIENTE GESTIONAR…), se salta.
Para categorías que se quedan en el inbox (personal, finanzas, otros): el email queda sin leer
con REVISAR IA; en el siguiente poll already_processed() lo detecta y lo salta. El usuario lo ve;
el bot no lo reprocesa.
- Telegram (
telegram.py,notifications.py): infraestructura para avisar de emails importantes. Actualmente desactivado (NOTIFY_CATEGORIESvacío) — se reactiva añadiendo categorías al frozenset. RequiereTELEGRAM_TOKENyTELEGRAM_CHAT_ID. - Interactive Brokers (
ib_trades.py): el pre-filtroib_tradeparsea el asunto de los emails de ejecución de IB (SOLD 1,511 VEEA @ 0.5722 (Uxxx)) y envía una notificación de trade por Telegram.
metrics.py hace un upsert fire-and-forget a la tabla email_metrics por cada email procesado
(categoría, acción, modelo, tokens, coste USD, remitente, asunto…). Cualquier error se loguea pero
nunca propaga al bot. Requiere SUPABASE_URL y SUPABASE_SECRET_KEY.
Servido por FastAPI cuando se arranca con --server:
| Ruta | Descripción |
|---|---|
/health |
Healthcheck |
/admin/dashboard |
Dashboard de métricas |
/admin/logs |
Visor de logs (protegido con LOGS_VIEWER_PASSWORD) |
/admin/api/metrics |
API JSON de métricas (consumida por el dashboard) |
Cada YAML del directorio config/ es una cuenta que el bot monitoriza. Campos principales:
name: jesus82c
email: jesus82c@gmail.com
refresh_token_env: GOOGLE_REFRESH_TOKEN_JESUS82C # variable .env con el refresh token
# send_as: alias@midominio.com # opcional
query: is:unread in:inbox
max_emails_per_poll: 50
poll_interval_seconds: 600
classifier:
prompt_file: gmail_inbox_bot/prompts/clasificador_inbox.txt
# model: gpt-5.6-luna # override opcional
calendar_reminders: # opt-in (ver sección)
enabled: true
send_time: "09:16"
timezone: Europe/Madrid
max_attendees: 2
sender_name: Miguel
pre_filters: [ ... ] # ver sección
routing: { categoria: { action: ..., ... } } # ver secciones
templates: { categoria: { esp: "...", pt: "..." } } # respuestas fijas| Variable | Uso |
|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
Cliente OAuth (compartido) |
GOOGLE_REFRESH_TOKEN_<CUENTA> |
Refresh token por cuenta (referenciado en el YAML) |
OPENAI_API_KEY |
LLM (clasificación, dynamic_reply, fallback) |
GROQ_API_KEY |
LLM por defecto (gpt-oss-120b) |
TELEGRAM_TOKEN / TELEGRAM_CHAT_ID |
Notificaciones (opcional) |
SUPABASE_URL / SUPABASE_SECRET_KEY |
Métricas (opcional) |
LOGS_VIEWER_PASSWORD |
Password del visor de logs |
SENTRY_DSN |
Observabilidad (opcional) |
LOG_LEVEL / ENVIRONMENT |
Runtime |
DISABLE_BOT |
Si truthy, solo admin UI (sin polling ni scheduler) |
DRY_RUN |
Si truthy, los background threads operan en seco |
- App OAuth External + In production (proyecto GCP). Tokens permanentes (no expiran).
- Scopes:
gmail.modify+calendar.readonly(+documents,presentations,drive.file). - Google deprecó el flujo OOB → se usa localhost redirect:
redirect_uri = http://localhost; el navegador redirige ahttp://localhost/?code=XXXX(no carga, se copia elcode=). - Añadir/renovar una cuenta:
uv run python scripts/get_refresh_token.py→ pega el refresh token en.envcon el nombre que referencia el YAML. - La Google Calendar API debe estar habilitada en el proyecto GCP para los recordatorios.
El exportador one-off scripts/download_attachments.py sirve para archivar una cuenta Gmail antes de
limpiarla y liberar espacio. Es una operación solo de lectura sobre Gmail: descarga cada mensaje
seleccionado como .eml, extrae sus adjuntos, PDF e imágenes inline, calcula hashes SHA-256 y genera
índices CSV. No mueve ni borra mensajes.
Los ficheros quedan físicamente en una carpeta plana para revisión manual:
attachments_dump/
<mailbox>/
attachments/ # todos los ficheros extraídos, visibles directamente
messages/ # respaldo .eml completo por mensaje
messages.csv # una fila por mensaje
index.csv # una fila por fichero; ruta_local apunta al archivo real
.state.sqlite3 # estado reanudable, no editar a mano
El directorio contiene correo personal y está excluido de Git mediante .gitignore. El CSV es solo
un índice: los binarios deben abrirse desde <mailbox>/attachments/.
# Piloto inicial: 10 mensajes de una sola cuenta, sin escrituras en Gmail
uv run python scripts/download_attachments.py \
--output-dir attachments_dump \
--mailbox jesus82c \
--query 'has:attachment' \
--max-messages 10 \
--workers 1
# Segunda cuenta: repetir primero el piloto y una muestra de 50 mensajes nuevos
uv run python scripts/download_attachments.py \
--output-dir attachments_dump \
--mailbox miguelgutierrezbarquin \
--query 'has:attachment' \
--max-messages 10 \
--workers 1
uv run python scripts/download_attachments.py \
--output-dir attachments_dump \
--mailbox miguelgutierrezbarquin \
--query 'has:attachment' \
--max-messages 50 \
--workers 1
# Fase de alto ahorro ya ejecutada: mensajes mayores de 1 MB
uv run python scripts/download_attachments.py \
--output-dir attachments_dump \
--mailbox jesus82c \
--query 'has:attachment larger:1M' \
--workers 1
# Ampliación posterior, solo tras revisar esta fase: mensajes mayores de 700 KB
uv run python scripts/download_attachments.py \
--output-dir attachments_dump \
--mailbox jesus82c \
--query 'has:attachment larger:700K' \
--workers 1
# Fase de alto ahorro de la segunda cuenta
uv run python scripts/download_attachments.py \
--output-dir attachments_dump \
--mailbox miguelgutierrezbarquin \
--query 'has:attachment larger:1M' \
--workers 1El piloto de la primera cuenta está archivado en attachments_dump/jesus82c/ y el de Miguel en
attachments_dump/miguelgutierrezbarquin/. --max-messages cuenta solo mensajes nuevos; relanzar
el comando no redescarga los que ya tienen estado completed. Para una iteración futura que también
busque emails con imágenes inline no indexadas por Gmail, usar --all-messages tras validar cuotas,
espacio local y cobertura de esta iteración.
La muestra ampliada de jesus82c dejó 60 mensajes completados y 65 ficheros (54 adjuntos, 3 PDF y
8 imágenes inline), con todos los hashes verificados. Después se archivaron 1.252 mensajes nuevos
de más de 1 MB: 1.312 mensajes y 3.440 ficheros en total. De esos mensajes, 1.167 se movieron a
papelera tras la revisión manual; la ejecución quedó auditada en su carpeta de revisión.
En miguelgutierrezbarquin se completaron el piloto de 10, la muestra de 50 y la fase de alto
ahorro. La consulta has:attachment larger:1M devolvió 216 mensajes (215 nuevos en el barrido),
que junto con la muestra dejan 275 mensajes archivados y 758 ficheros extraídos: 220 PDF, 472
imágenes inline y 66 adjuntos, con 0 hashes inválidos. Tras proteger 101 mensajes de 71 hilos,
se movieron 174 mensajes de 115 hilos a papelera, con 0 errores. La auditoría está en
attachments_dump/trash_results_miguelgutierrezbarquin.csv y en la carpeta de revisión. La copia
visible para revisión está en
C:\Users\USER\Desktop\revisar_miguelgutierrezbarquin, ordenada por extensión, e incluye sus
index.csv y messages.csv.
El archivo local de jesus82c ocupa aproximadamente 4 GB (.eml + ficheros extraídos). En Miguel,
la copia de revisión ocupa aproximadamente 916 MB de ficheros extraídos. Los archivos locales son
la copia de seguridad; mover mensajes a papelera mantiene la recuperación de Gmail durante su
periodo de retención.
El exportador aplica un máximo de 3 solicitudes por segundo, reintenta errores transitorios de cuota
(429/403 de rate limit/5xx) y respeta Retry-After. Antes de empezar exige 100 MiB libres (se puede
ajustar con --min-free-bytes). La fase actual sigue siendo secuencial (--workers 1) para que el
estado sea fácil de auditar.
scripts/migrate_archive_layout.py solo se necesita para convertir un archivo antiguo a la carpeta
plana attachments/; no llama a Gmail.
Revisa los binarios directamente en <mailbox>/attachments/. La columna conservar puede llevar
x para dejar constancia de los hilos protegidos; borrar solo debe llevar x cuando quieras
seleccionar un mensaje para papelera. Un mensaje no puede tener ambas marcas. El comando siguiente es
siempre dry-run: valida el EML, todos los hashes y el estado
SQLite, pero no crea ningún cliente Gmail ni hace escrituras.
uv run python scripts/trash_marked.py --messages attachments_dump/messages.csvPara una tanda aprobada, añade --execute desde una terminal interactiva y escribe exactamente
TRASH N (donde N es el número de filas marcadas). Solo usa messages.trash, nunca borrado
permanente, y deja la auditoría en un archivo separado por cuenta:
uv run python scripts/trash_marked.py \
--messages attachments_dump/messages.csv \
--results attachments_dump/trash_results_<cuenta>.csv \
--executeuv sync # instalar dependencias
uv run python -m gmail_inbox_bot # bot de polling (loop)
uv run python -m gmail_inbox_bot --once # un solo ciclo de poll
uv run python -m gmail_inbox_bot --dry-run # sin ejecutar acciones
uv run python scripts/download_invoice_emails.py # facturas PDF del mes anterior al escritorio (cron dia 1, 09:30)
uv run python -m gmail_inbox_bot --server # FastAPI + bot + scheduler en background
uv run python -m gmail_inbox_bot.calendar_reminders --once --dry-run # recordatorios (prueba)
uv run pytest # tests
uv run ruff check . && uv run ruff format . # lint + formatoPre-push checklist (lo mismo que valida la GitHub Action):
uv run ruff check . && uv run ruff format --check . && uv run pytest- VPS:
158.69.215.223(usuarioubuntu), ruta/home/ubuntu/services/gmail-inbox-bot. - Autodeploy: push a
main→ GitHub Action (deploy-vps.yml) →git pull+docker compose up -d --build. - Docker: imagen
python:3.13-slim, entrypointpython -m gmail_inbox_bot --server. Puerto 8007 → 8000. Volúmenes:./logsy./config(el estado de recordatorios persiste enlogs/). - Endpoints prod:
https://email.pymechat.com/health,/admin/dashboard,/admin/logs.
gmail_inbox_bot/
__main__.py # entrypoint CLI (--once/--dry-run/--server/--port)
app.py # FastAPI + daemon threads (polling + reminders)
bot.py # loop de polling y pipeline _process_email
config.py # carga de .env y YAMLs de mailbox
gmail_client.py # cliente Gmail API (leer, labels, responder, enviar)
calendar_client.py # cliente Google Calendar API (eventos del día)
calendar_reminders.py# filtrado, render, estado/idempotencia, scheduler, CLI
classifier.py # clasificación y respuesta dinámica (contrato neutral)
llm_gateway_client.py# borde sync sobre el gateway async
actions.py # router de acciones (tag/move/reply/forward/…)
mail_processing.py # pre-filtros, detección de reenvíos, strip_html
notifications.py # avisos de email importante (Telegram)
ib_trades.py # parser de trades de Interactive Brokers
metrics.py # métricas a Supabase (fire-and-forget)
llm_costs.py # adapta uso/coste del gateway a métricas legadas
telegram.py # envío de mensajes a Telegram
admin_dashboard.py # UI de métricas (/admin/dashboard)
admin_logs.py # visor de logs (/admin/logs)
attachment_archive.py# parseo MIME y escritura segura de adjuntos
attachment_manifest.py # SQLite + índices CSV del archivo local
prompts/ # prompt del clasificador
config/ # un YAML por cuenta
templates/ # signature.html, calendar_reminder.html
scripts/ # OAuth, exportador de adjuntos y trash_marked.py (dry-run seguro)
tests/ # pytest
docs/ # documentación y specs
En producción (VPS, autodeploy desde main): cliente Gmail funcional (lectura, clasificación,
respuestas/reenvíos/labels/borradores), recordatorios diarios de Google Calendar, panel de admin y
métricas en Supabase.
Install the versioned hook once in each clone:
git config --local core.hooksPath .githooks
bash scripts/ci-local.shGitHub Actions invokes the same script. The hook rejects an uncommitted tree, a push of a commit other than the checked-out HEAD, failed checks, and edits made during validation. Deleting a ref does not run checks. The script selects Python 3.13 and the locked development dependencies, then runs Ruff lint, the format check and pytest.