feat(browser): auto-commit con 178 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:22:23 +02:00
parent 7d100e7f3e
commit 763e06c127
178 changed files with 19917 additions and 317 deletions
+137
View File
@@ -0,0 +1,137 @@
# Email — Gestionar cuentas de correo por IMAP + SMTP (tecnología propia)
Tag: `email`. Grupo de funciones Python (solo stdlib: `imaplib`, `smtplib`, `email`) para
**leer, hacer CRUD y enviar correo hablando los protocolos directamente** — sin browser CDP
y sin el MCP Gmail de claude.ai. Es la base de un sistema multi-proveedor de gestión de
cuentas: una conexión IMAP por buzón + SMTP para envío, con las credenciales resueltas desde
`pass`/vault por la capa de aplicación.
Filtro MCP: `mcp__registry__fn_search query="" tag="email"`.
## Cuándo usar este grupo (y cuándo NO)
| Caso | Vía |
|---|---|
| Leer/buscar/clasificar/mover/borrar/enviar correo de forma programática y fiable, multi-cuenta | **Este grupo** (IMAP+SMTP directo). |
| Leer correo *interactivo* del usuario en su sesión (códigos de verificación al instante en su Gmail logueado) | Browser MCP sobre Gmail web (perfil 9222). Ver memoria `correos-por-browser-no-mcp-gmail`. |
| — | El MCP Gmail de `claude.ai` queda descartado en ambos casos (indexa con latencia). |
IMAP directo **no** sustituye al browser para el flujo interactivo del usuario; lo complementa
para automatización fiable con credenciales propias.
## Autenticación
Usuario + **app-password** (NO OAuth). Gmail exige 2FA activado y un App Password de 16 chars
(`myaccount.google.com/apppasswords`). Otros proveedores con IMAP/SMTP clásico (Dovecot,
dominio propio) aceptan user+pass directo. La credencial se guarda en `pass`
(`email/<cuenta>-apppass`) y la resuelve la capa app, **nunca** se hardcodea ni se pasa a
estas funciones desde el código del registry.
**Outlook/Hotmail/Office365 NO entran por aquí**: Microsoft desactivó basic auth para
IMAP/SMTP; requieren OAuth2 (pista aparte, no cubierta por este grupo hoy).
## Servidores comunes
| Proveedor | IMAP | SMTP |
|---|---|---|
| Gmail | `imap.gmail.com:993` (SSL) | `smtp.gmail.com:465` (SSL) o `587` (STARTTLS) |
| Dominio propio (Dovecot+Postfix) | `mail.<dominio>:993` | `mail.<dominio>:465`/`587` |
## Funciones del grupo
Núcleo IMAP — el primer argumento `conn` de toda operación es el objeto `imaplib.IMAP4_SSL`
vivo que produce `imap_connect`. Todas operan por **UID** (estable), nunca por número de
secuencia, y devuelven `dict {"status": "ok"|"error", ...}` sin lanzar.
| ID | Firma corta | Qué hace |
|---|---|---|
| [imap_connect_py_infra](../../python/functions/infra/imap_connect.md) | `imap_connect(host, port=993, user, password, mailbox='INBOX', use_ssl=True, timeout_s=30) -> dict` | Abre IMAP4_SSL, login + select(mailbox), devuelve el `conn` vivo + `num_messages`. Impura. |
| [imap_list_mailboxes_py_infra](../../python/functions/infra/imap_list_mailboxes.md) | `imap_list_mailboxes(conn) -> dict` | Lista carpetas decodificando modified-UTF-7 (Gmail: `[Gmail]/Sent Mail`, etc.). Impura. |
| [imap_search_py_infra](../../python/functions/infra/imap_search.md) | `imap_search(conn, criteria='UNSEEN', mailbox='') -> dict` | Busca por criterio IMAP crudo (UNSEEN, FROM, SINCE…) y devuelve UIDs. Impura. |
| [imap_fetch_message_py_infra](../../python/functions/infra/imap_fetch_message.md) | `imap_fetch_message(conn, uid, mark_seen=False) -> dict` | Baja y parsea un mensaje (from/to/cc/subject/date/body_text/body_html/attachments). `BODY.PEEK` no marca leído. Impura. |
| [imap_mark_seen_py_infra](../../python/functions/infra/imap_mark_seen.md) | `imap_mark_seen(conn, uid, seen=True) -> dict` | Añade/quita la bandera `\Seen`. Impura. |
| [imap_move_message_py_infra](../../python/functions/infra/imap_move_message.md) | `imap_move_message(conn, uid, dest_mailbox) -> dict` | Mueve por UID (UID MOVE RFC 6851, fallback COPY+EXPUNGE). Impura. |
| [imap_delete_message_py_infra](../../python/functions/infra/imap_delete_message.md) | `imap_delete_message(conn, uid, expunge=True) -> dict` | Marca `\Deleted` y opcionalmente EXPUNGE. Impura. |
| [imap_save_draft_py_infra](../../python/functions/infra/imap_save_draft.md) | `imap_save_draft(conn, raw_rfc822, mailbox='[Gmail]/Drafts', flags='\Draft') -> dict` | Guarda un borrador (bytes MIME) vía APPEND. Impura. |
Construir + enviar (SMTP):
| ID | Firma corta | Qué hace |
|---|---|---|
| [email_build_html_py_infra](../../python/functions/infra/email_build_html.md) | `email_build_html(from_addr, to, subject, body_html) -> EmailMessagePy` | Construye un mensaje HTML inmutable. Pura. |
| [smtp_send_py_infra](../../python/functions/infra/smtp_send.md) | `smtp_send(cfg, from_addr, to, subject, body_html='', body_text='', cc, bcc, attachments, headers) -> None` | Conecta SMTP, arma MIME y envía en un paso (TLS/STARTTLS/claro). Impura. |
## Ejemplo canónico end-to-end
Conectar a Gmail con app-password resuelto desde `pass`, listar no leídos, leer el primero,
marcarlo leído, y enviar una respuesta. Las funciones se componen en un heredoc Python que
**importa** del registry (no reescribe protocolo):
```python
import sys, os, subprocess
sys.path.insert(0, os.path.join("python", "functions"))
from infra.imap_connect import imap_connect
from infra.imap_search import imap_search
from infra.imap_fetch_message import imap_fetch_message
from infra.imap_mark_seen import imap_mark_seen
from infra.smtp_send import smtp_send, SMTPConfigPy
EMAIL = "gutierenmanuel15@gmail.com"
# Credencial desde pass (o usar pass_get_secret del registry). NUNCA hardcodear.
PW = subprocess.run(["pass", "show", "email/gmail-enmanuel-apppass"],
capture_output=True, text=True).stdout.splitlines()[0]
# 1. Conectar (IMAP) — el conn vivo viaja dentro del dict
c = imap_connect(host="imap.gmail.com", port=993, user=EMAIL, password=PW, mailbox="INBOX")
assert c["status"] == "ok", c
conn = c["conn"]
# 2. Buscar no leídos y leer el primero (PEEK: no marca leído)
s = imap_search(conn, criteria="UNSEEN")
print("no leídos:", s["count"])
if s["uids"]:
uid = s["uids"][0]
m = imap_fetch_message(conn, uid)["message"]
print(m["from"], "", m["subject"])
imap_mark_seen(conn, uid) # marcar leído
# 3. Enviar (SMTP) — mismo app-password
smtp_send(
SMTPConfigPy(host="smtp.gmail.com", port=465, username=EMAIL, password=PW, tls_mode="tls"),
from_addr=EMAIL, to=["dest@example.com"],
subject="Probando IMAP+SMTP propios", body_text="Enviado sin browser, protocolo directo.",
)
conn.logout() # cerrar siempre
```
## Fronteras
- **No gestiona la cuenta multi-proveedor**: estas son primitivas de protocolo. El registro
de N cuentas (host/port/auth_type por buzón) y la resolución de credenciales desde `pass`
son responsabilidad de una **app** (p. ej. `apps/mail_manager`), no de este grupo.
- **No hace OAuth**: solo user+app-password. Outlook/Office365 (basic auth muerto) quedan fuera
hasta que exista una función `*_oauth_token` dedicada.
- **No reemplaza al browser para el flujo interactivo del usuario** (ver tabla arriba).
- **`imap_save_draft` no construye el MIME**: recibe bytes RFC822 ya serializados; el caller
los arma con `email.message.EmailMessage().as_bytes()` (stdlib) o con `email_build_*` +
serialización.
## Gotchas
- **`conn` es un objeto vivo dentro del dict**: estas funciones se componen en heredocs/apps
Python, NO por `fn run` (que no puede serializar el socket). Cerrar siempre con `conn.logout()`.
- **UID, no número de secuencia**: los seq se renumeran al borrar; los UID son estables
mientras no cambie `UIDVALIDITY` del buzón.
- **Gmail `\Deleted` ≠ borrar**: marcar `\Deleted` solo quita la etiqueta de la carpeta actual.
Para borrar de verdad hay que **mover a `[Gmail]/Trash`** con `imap_move_message`.
- **Nombres de carpeta Gmail** llevan prefijo `[Gmail]/` (`[Gmail]/Sent Mail`, `[Gmail]/Drafts`,
`[Gmail]/Trash`, `[Gmail]/Spam`).
- **App-password requiere 2FA** activado en la cuenta Google; sin 2FA no se puede generar.
- **Charsets**: `imap_fetch_message` decodifica RFC 2047 en cabeceras y respeta el charset de
cada parte del cuerpo; aun así correos malformados pueden traer texto degradado.
## Prerequisitos
- `python/.venv` (solo stdlib, sin dependencias nuevas).
- App-password de cada cuenta guardado en `pass` (`email/<cuenta>-apppass`).
- 2FA activado en las cuentas Google.