feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user