feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,8 +34,18 @@ from .upsert_xlsx_sheet import upsert_xlsx_sheet
|
||||
from .duckdb_query_readonly import duckdb_query_readonly
|
||||
from .duckdb_execute import duckdb_execute
|
||||
from .duckdb_upsert import duckdb_upsert
|
||||
from .imap_connect import imap_connect
|
||||
from .imap_list_mailboxes import imap_list_mailboxes
|
||||
from .imap_search import imap_search
|
||||
from .imap_fetch_message import imap_fetch_message
|
||||
from .gsc_auth import gsc_auth
|
||||
|
||||
__all__ = [
|
||||
"imap_connect",
|
||||
"imap_list_mailboxes",
|
||||
"imap_search",
|
||||
"imap_fetch_message",
|
||||
"gsc_auth",
|
||||
"write_xlsx_sheets",
|
||||
"upsert_xlsx_sheet",
|
||||
"duckdb_query_readonly",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: gsc_auth
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def gsc_auth(credentials_path: str = \"\", subject: str = \"\") -> object"
|
||||
description: "Autentica contra la Google Search Console API v1 (searchconsole/webmasters) con una cuenta de servicio JSON. Lee el JSON de credentials_path o, si esta vacio, de la env var GSC_SA_JSON; lanza ValueError claro si falta. Usa service_account.Credentials.from_service_account_file con scope https://www.googleapis.com/auth/webmasters.readonly (solo lectura). subject opcional aplica with_subject(subject) para domain-wide delegation (normalmente vacio en GSC). Construye y retorna el objeto service de googleapiclient.discovery.build('searchconsole','v1', cache_discovery=False) listo para consumir por pull_gsc_search_analytics. Requiere google-api-python-client y google-auth."
|
||||
tags: [seo, gsc, infra, google, search-console, auth, service-account]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [os, google.oauth2.service_account, googleapiclient.discovery]
|
||||
params:
|
||||
- name: credentials_path
|
||||
desc: "ruta al JSON de la service account de GCP. Si esta vacio, se lee de la env var GSC_SA_JSON. Si tampoco existe, se lanza ValueError. El JSON es un secreto: resolver desde pass o una ruta fuera del repo, nunca commitear."
|
||||
- name: subject
|
||||
desc: "opcional. Email para domain-wide delegation (impersonation) via with_subject. Normalmente vacio en Search Console, donde la SA se anade directamente como usuario en GSC sin requerir delegation."
|
||||
output: "object. El service de googleapiclient (googleapiclient.discovery.Resource) para la API 'searchconsole' v1, autenticado con scope webmasters.readonly y cache_discovery=False. Se pasa a funciones consumidoras como pull_gsc_search_analytics."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_build_se_llama_con_searchconsole_v1_y_cache_off"
|
||||
- "test_credentials_se_cargan_con_scope_readonly"
|
||||
- "test_fallback_a_env_var_gsc_sa_json"
|
||||
- "test_subject_aplica_with_subject"
|
||||
- "test_error_cuando_falta_credential"
|
||||
test_file_path: "python/functions/infra/gsc_auth_test.py"
|
||||
file_path: "python/functions/infra/gsc_auth.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import gsc_auth
|
||||
|
||||
# Opcion A: ruta explicita al JSON de la service account
|
||||
service = gsc_auth(credentials_path="/home/enmanuel/secrets/gsc-sa.json")
|
||||
|
||||
# Opcion B: leer la ruta de la env var GSC_SA_JSON
|
||||
# export GSC_SA_JSON=/home/enmanuel/secrets/gsc-sa.json
|
||||
service = gsc_auth()
|
||||
|
||||
# Verificar la autenticacion listando los sitios verificados:
|
||||
sites = service.sites().list().execute()
|
||||
print([s["siteUrl"] for s in sites.get("siteEntry", [])])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Antes de `pull_gsc_search_analytics` (o cualquier llamada a la Search Console
|
||||
API): la usas para obtener el objeto `service` autenticado una sola vez y
|
||||
reutilizarlo en las consultas posteriores (Search Analytics, sitemaps, sites).
|
||||
Es el punto de entrada del capability group `seo`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: lee un JSON del disco y construye un cliente HTTP de Google. No
|
||||
es determinista ni componible en el nucleo puro.
|
||||
- **Dar de alta la SA en Search Console**: el email de la service account debe
|
||||
anadirse manualmente como usuario en Search Console > Settings > Users and
|
||||
permissions (rol Restricted/Full). Sin esto la auth funciona pero las
|
||||
consultas devuelven 403 / sitios vacios.
|
||||
- **Habilitar la API**: la "Search Console API" debe estar habilitada en el
|
||||
proyecto GCP de la service account (consola de APIs & Services). Si no, el
|
||||
primer `.execute()` falla con un error de API deshabilitada.
|
||||
- **El JSON de la SA es un secreto**: no commitear nunca. Guardarlo en `pass`
|
||||
o en una ruta fuera del repo y pasar la ruta por `credentials_path` o la env
|
||||
var `GSC_SA_JSON`.
|
||||
- **`subject` casi siempre vacio**: domain-wide delegation solo aplica si
|
||||
impersonas a un usuario de un dominio Workspace; en GSC lo normal es anadir
|
||||
la SA directamente como usuario y dejar `subject=""`.
|
||||
- **Dependencias**: requiere `google-api-python-client` y `google-auth` en el
|
||||
venv. Ya estan en `python/pyproject.toml`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
(sin cambios — v1.0.0 inicial)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Autenticacion contra la Google Search Console API con una cuenta de servicio."""
|
||||
|
||||
import os
|
||||
|
||||
# Scope de solo lectura: suficiente para Search Analytics y listar sitios.
|
||||
_SCOPE_READONLY = "https://www.googleapis.com/auth/webmasters.readonly"
|
||||
|
||||
|
||||
def gsc_auth(credentials_path: str = "", subject: str = "") -> object:
|
||||
"""Autentica contra la Google Search Console API v1 con una service account.
|
||||
|
||||
Construye unas credenciales a partir del JSON de una cuenta de servicio y
|
||||
devuelve el objeto ``service`` de ``googleapiclient`` listo para consumir
|
||||
(lo usa, por ejemplo, ``pull_gsc_search_analytics``).
|
||||
|
||||
Args:
|
||||
credentials_path: ruta al JSON de la service account. Si esta vacio,
|
||||
se lee de la variable de entorno ``GSC_SA_JSON``. Si tampoco existe,
|
||||
se lanza ``ValueError`` indicando que falta el credential.
|
||||
subject: email para domain-wide delegation (impersonation). Normalmente
|
||||
vacio en Search Console (la SA se anade directamente como usuario en
|
||||
GSC). Si se pasa, se aplica ``.with_subject(subject)``.
|
||||
|
||||
Returns:
|
||||
El objeto ``service`` (``googleapiclient.discovery.Resource``) para la
|
||||
API ``searchconsole`` v1, autenticado con scope
|
||||
``webmasters.readonly``.
|
||||
|
||||
Raises:
|
||||
ValueError: si no se proporciona ``credentials_path`` ni la env var
|
||||
``GSC_SA_JSON``.
|
||||
"""
|
||||
# Imports diferidos: mantienen la importacion del modulo barata y la
|
||||
# dependencia externa aislada al momento de uso real.
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
path = credentials_path or os.environ.get("GSC_SA_JSON", "")
|
||||
if not path:
|
||||
raise ValueError(
|
||||
"gsc_auth: falta el credential de la service account. "
|
||||
"Pasa credentials_path o define la env var GSC_SA_JSON con la "
|
||||
"ruta al JSON de la cuenta de servicio."
|
||||
)
|
||||
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
path, scopes=[_SCOPE_READONLY]
|
||||
)
|
||||
if subject:
|
||||
creds = creds.with_subject(subject)
|
||||
|
||||
return build(
|
||||
"searchconsole",
|
||||
"v1",
|
||||
credentials=creds,
|
||||
cache_discovery=False,
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests para gsc_auth (sin credenciales reales ni red)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
|
||||
from functions.infra.gsc_auth import gsc_auth # noqa: E402
|
||||
|
||||
_SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"
|
||||
|
||||
|
||||
def _patches():
|
||||
"""Devuelve los context managers para mockear creds y build.
|
||||
|
||||
La funcion importa de forma diferida desde
|
||||
``google.oauth2.service_account`` y ``googleapiclient.discovery``,
|
||||
por eso se parchea en el modulo de origen.
|
||||
"""
|
||||
creds_patch = mock.patch(
|
||||
"google.oauth2.service_account.Credentials.from_service_account_file"
|
||||
)
|
||||
build_patch = mock.patch("googleapiclient.discovery.build")
|
||||
return creds_patch, build_patch
|
||||
|
||||
|
||||
def test_build_se_llama_con_searchconsole_v1_y_cache_off():
|
||||
creds_patch, build_patch = _patches()
|
||||
with creds_patch as m_creds, build_patch as m_build:
|
||||
fake_creds = mock.Mock(name="creds")
|
||||
m_creds.return_value = fake_creds
|
||||
fake_service = mock.Mock(name="service")
|
||||
m_build.return_value = fake_service
|
||||
|
||||
result = gsc_auth(credentials_path="/tmp/sa.json")
|
||||
|
||||
assert result is fake_service
|
||||
m_build.assert_called_once_with(
|
||||
"searchconsole",
|
||||
"v1",
|
||||
credentials=fake_creds,
|
||||
cache_discovery=False,
|
||||
)
|
||||
|
||||
|
||||
def test_credentials_se_cargan_con_scope_readonly():
|
||||
creds_patch, build_patch = _patches()
|
||||
with creds_patch as m_creds, build_patch as m_build:
|
||||
m_creds.return_value = mock.Mock()
|
||||
m_build.return_value = mock.Mock()
|
||||
|
||||
gsc_auth(credentials_path="/tmp/sa.json")
|
||||
|
||||
m_creds.assert_called_once_with("/tmp/sa.json", scopes=[_SCOPE])
|
||||
|
||||
|
||||
def test_fallback_a_env_var_gsc_sa_json():
|
||||
creds_patch, build_patch = _patches()
|
||||
with creds_patch as m_creds, build_patch as m_build, mock.patch.dict(
|
||||
os.environ, {"GSC_SA_JSON": "/env/sa.json"}, clear=False
|
||||
):
|
||||
m_creds.return_value = mock.Mock()
|
||||
m_build.return_value = mock.Mock()
|
||||
|
||||
gsc_auth(credentials_path="")
|
||||
|
||||
m_creds.assert_called_once_with("/env/sa.json", scopes=[_SCOPE])
|
||||
|
||||
|
||||
def test_subject_aplica_with_subject():
|
||||
creds_patch, build_patch = _patches()
|
||||
with creds_patch as m_creds, build_patch as m_build:
|
||||
base_creds = mock.Mock(name="base_creds")
|
||||
delegated = mock.Mock(name="delegated_creds")
|
||||
base_creds.with_subject.return_value = delegated
|
||||
m_creds.return_value = base_creds
|
||||
m_build.return_value = mock.Mock()
|
||||
|
||||
gsc_auth(credentials_path="/tmp/sa.json", subject="user@dominio.com")
|
||||
|
||||
base_creds.with_subject.assert_called_once_with("user@dominio.com")
|
||||
_, kwargs = m_build.call_args
|
||||
assert kwargs["credentials"] is delegated
|
||||
|
||||
|
||||
def test_error_cuando_falta_credential():
|
||||
creds_patch, build_patch = _patches()
|
||||
with creds_patch, build_patch, mock.patch.dict(
|
||||
os.environ, {}, clear=True
|
||||
):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
gsc_auth(credentials_path="")
|
||||
assert "GSC_SA_JSON" in str(exc.value)
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: gsc_list_sites
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def gsc_list_sites(credentials_path: str = \"\") -> list"
|
||||
description: "Lista las propiedades de Google Search Console a las que tiene acceso una cuenta de servicio. Verificacion de acceso del capability group seo: tras dar de alta la service account y anadir su email como usuario en Search Console, esta funcion confirma que el acceso funciona y muestra que site_url usar y con que nivel de permiso. Autentica con gsc_auth del registry (que resuelve credentials_path o, si esta vacio, la env var GSC_SA_JSON) y llama a service.sites().list().execute(). Aplana la respuesta {siteEntry: [...]} a una lista de dicts [{site_url, permission_level}, ...]. Devuelve lista vacia (sin lanzar) si la cuenta no tiene propiedades accesibles. Requiere google-api-python-client y google-auth."
|
||||
tags: [seo, gsc, search-console, infra]
|
||||
uses_functions: [gsc_auth_py_infra]
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [infra.gsc_auth]
|
||||
params:
|
||||
- name: credentials_path
|
||||
desc: "ruta al JSON de la service account de GCP. Si esta vacio, se delega en gsc_auth, que lo lee de la env var GSC_SA_JSON. El JSON es un secreto: resolver desde pass o una ruta fuera del repo, nunca commitear."
|
||||
output: "list. Lista de dicts [{\"site_url\": <siteUrl>, \"permission_level\": <permissionLevel>}, ...] con una entrada por propiedad de Search Console accesible. site_url de dominio sale como 'sc-domain:ejemplo.com' y el de prefijo como URL completa 'https://ejemplo.com/'. permission_level es uno de siteOwner / siteFullUser / siteRestrictedUser / siteUnverifiedUser. Lista vacia si la SA no tiene propiedades accesibles (no lanza)."
|
||||
tested: true
|
||||
tests:
|
||||
- "test_aplana_siteurl_y_permissionlevel"
|
||||
- "test_site_entry_ausente_devuelve_lista_vacia"
|
||||
test_file_path: "python/functions/infra/gsc_list_sites_test.py"
|
||||
file_path: "python/functions/infra/gsc_list_sites.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra import gsc_list_sites
|
||||
|
||||
# Opcion A: ruta explicita al JSON de la service account
|
||||
sites = gsc_list_sites(credentials_path="/home/enmanuel/secrets/gsc-sa.json")
|
||||
|
||||
# Opcion B: leer la ruta de la env var GSC_SA_JSON
|
||||
# export GSC_SA_JSON=/home/enmanuel/secrets/gsc-sa.json
|
||||
sites = gsc_list_sites()
|
||||
|
||||
for s in sites:
|
||||
print(s["site_url"], "->", s["permission_level"])
|
||||
# sc-domain:ejemplo.com -> siteOwner
|
||||
# El site_url que imprime es exactamente el valor que va en GSC_SITE_URL
|
||||
# al configurar el pipeline de ingesta.
|
||||
```
|
||||
|
||||
Lanzable directo (lee `GSC_SA_JSON` del entorno):
|
||||
|
||||
```bash
|
||||
export GSC_SA_JSON=/home/enmanuel/secrets/gsc-sa.json
|
||||
./fn run gsc_list_sites
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Primer paso tras dar acceso a la service account en Search Console (anadir su
|
||||
email en Settings > Users and permissions): la usas para verificar que el
|
||||
acceso funciona y descubrir el `site_url` exacto antes de configurar el
|
||||
pipeline de ingesta. El valor de `site_url` que devuelve es el que pones en
|
||||
`GSC_SITE_URL` / pasas a `pull_gsc_search_analytics`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: autentica contra Google y hace una llamada HTTP a la Search
|
||||
Console API. No es determinista ni componible en el nucleo puro.
|
||||
- **Lista vacia = falta acceso**: si devuelve `[]`, el email de la service
|
||||
account no esta anadido como usuario en Search Console (Settings > Users and
|
||||
permissions) o la propiedad no existe. La auth puede funcionar igualmente; lo
|
||||
que falta es el alta de la SA como usuario de la propiedad.
|
||||
- **Formato del site_url**: la propiedad de dominio sale como
|
||||
`sc-domain:ejemplo.com` y la de prefijo como la URL completa
|
||||
`https://ejemplo.com/`. Copia el valor tal cual al configurar el pipeline.
|
||||
- **API habilitada**: la "Search Console API" debe estar habilitada en el
|
||||
proyecto GCP de la service account, o el `.execute()` falla.
|
||||
- **El JSON de la SA es un secreto**: nunca commitear. Guardar en `pass` o en
|
||||
una ruta fuera del repo y pasar la ruta por `credentials_path` o la env var
|
||||
`GSC_SA_JSON`.
|
||||
- **Dependencias**: requiere `google-api-python-client` y `google-auth` en el
|
||||
venv (ya en `python/pyproject.toml`).
|
||||
|
||||
## Capability growth log
|
||||
|
||||
(sin cambios — v1.0.0 inicial)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Lista las propiedades de Google Search Console accesibles por una cuenta de servicio.
|
||||
|
||||
Verificacion de acceso del capability group ``seo``: tras dar de alta la
|
||||
service account y anadir su email como usuario en Search Console, esta funcion
|
||||
confirma que el acceso funciona y muestra que ``site_url`` usar (y con que
|
||||
nivel de permiso).
|
||||
"""
|
||||
|
||||
|
||||
def gsc_list_sites(credentials_path: str = "") -> list:
|
||||
"""Lista las propiedades de Search Console accesibles por la service account.
|
||||
|
||||
Autentica con ``gsc_auth`` del registry (que ya resuelve ``credentials_path``
|
||||
o, si esta vacio, la env var ``GSC_SA_JSON``) y llama a
|
||||
``service.sites().list().execute()``. Aplana la respuesta a una lista de
|
||||
dicts con ``site_url`` y ``permission_level``.
|
||||
|
||||
Es la forma de comprobar, antes de ingerir nada, que (a) la service account
|
||||
autentica y (b) tiene acceso a la propiedad esperada y con que nivel de
|
||||
permiso.
|
||||
|
||||
Args:
|
||||
credentials_path: ruta al JSON de la service account. Si esta vacio, se
|
||||
delega en ``gsc_auth``, que lo lee de la env var ``GSC_SA_JSON``.
|
||||
|
||||
Returns:
|
||||
Lista de dicts ``[{"site_url": <siteUrl>, "permission_level":
|
||||
<permissionLevel>}, ...]``. Lista vacia si la cuenta no tiene
|
||||
propiedades accesibles (no lanza en ese caso).
|
||||
|
||||
Raises:
|
||||
ValueError: si no se proporciona ``credentials_path`` ni la env var
|
||||
``GSC_SA_JSON`` (propagado desde ``gsc_auth``).
|
||||
"""
|
||||
# Import diferido del registry: mantiene barata la importacion del modulo y
|
||||
# permite mockear el simbolo en tests sin tocar la red.
|
||||
from infra import gsc_auth
|
||||
|
||||
service = gsc_auth(credentials_path)
|
||||
response = service.sites().list().execute()
|
||||
return [
|
||||
{
|
||||
"site_url": entry.get("siteUrl"),
|
||||
"permission_level": entry.get("permissionLevel"),
|
||||
}
|
||||
for entry in response.get("siteEntry", [])
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Tests para gsc_list_sites (sin red ni credenciales — gsc_auth mockeado)."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from infra.gsc_list_sites import gsc_list_sites
|
||||
|
||||
|
||||
def _service_returning(response: dict) -> MagicMock:
|
||||
"""Construye un service mock cuyo .sites().list().execute() retorna response."""
|
||||
service = MagicMock()
|
||||
service.sites.return_value.list.return_value.execute.return_value = response
|
||||
return service
|
||||
|
||||
|
||||
def test_aplana_siteurl_y_permissionlevel():
|
||||
fake = {
|
||||
"siteEntry": [
|
||||
{"siteUrl": "sc-domain:ejemplo.com", "permissionLevel": "siteOwner"},
|
||||
{"siteUrl": "https://www.ejemplo.com/", "permissionLevel": "siteFullUser"},
|
||||
]
|
||||
}
|
||||
service = _service_returning(fake)
|
||||
# La funcion hace `from infra import gsc_auth` en runtime, asi que el
|
||||
# simbolo a parchear es infra.gsc_auth (donde se resuelve el lookup).
|
||||
with patch("infra.gsc_auth", return_value=service) as mock_auth:
|
||||
result = gsc_list_sites()
|
||||
|
||||
mock_auth.assert_called_once_with("")
|
||||
assert result == [
|
||||
{"site_url": "sc-domain:ejemplo.com", "permission_level": "siteOwner"},
|
||||
{"site_url": "https://www.ejemplo.com/", "permission_level": "siteFullUser"},
|
||||
]
|
||||
|
||||
|
||||
def test_site_entry_ausente_devuelve_lista_vacia():
|
||||
service = _service_returning({}) # sin clave siteEntry
|
||||
with patch("infra.gsc_auth", return_value=service):
|
||||
result = gsc_list_sites()
|
||||
|
||||
assert result == []
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: imap_connect
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def imap_connect(host: str, port: int = 993, user: str = '', password: str = '', mailbox: str = 'INBOX', use_ssl: bool = True, timeout_s: float = 30.0) -> dict"
|
||||
description: "Abre y autentica una conexion IMAP (IMAP4_SSL por defecto, IMAP4 en claro si use_ssl=False) con usuario + app-password (NO OAuth), hace login y select(mailbox), y devuelve el objeto imaplib vivo dentro del dict de estado para componer el resto de operaciones del grupo email/imap. Defaults Gmail: host imap.gmail.com, port 993. Devuelve {status:'ok', conn, mailbox, num_messages} o {status:'error', error}. Nunca lanza. Las credenciales las pasa la capa app (via pass/vault), no se resuelven aqui."
|
||||
tags: [email, imap, infra, mailbox, network]
|
||||
params:
|
||||
- name: host
|
||||
desc: "Servidor IMAP, ej. imap.gmail.com. Vacio devuelve status error."
|
||||
- name: port
|
||||
desc: "Puerto IMAP. Default 993 (IMAPS sobre SSL). 143 para STARTTLS/plano."
|
||||
- name: user
|
||||
desc: "Direccion de correo / usuario de la cuenta."
|
||||
- name: password
|
||||
desc: "App-password (16 chars en Gmail, requiere 2FA) o contrasena del proveedor. NO OAuth."
|
||||
- name: mailbox
|
||||
desc: "Buzon a seleccionar tras autenticar. Default 'INBOX'."
|
||||
- name: use_ssl
|
||||
desc: "True (default) usa IMAP4_SSL cifrado. False usa IMAP4 en claro (solo redes de confianza/test)."
|
||||
- name: timeout_s
|
||||
desc: "Timeout del socket en segundos para conectar/operar. Default 30.0."
|
||||
output: "dict de estado. En exito {status:'ok', conn: <imaplib.IMAP4_SSL vivo autenticado con mailbox seleccionado>, mailbox: str, num_messages: int (mensajes en el buzon, de la respuesta de SELECT)}. En fallo (host vacio, auth invalida, red caida, buzon inexistente) {status:'error', error: str} y SIN clave conn."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/imap_connect.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra import imap_connect
|
||||
|
||||
# App-password de Gmail (16 chars, requiere 2FA). Pasalo desde pass/vault.
|
||||
res = imap_connect(
|
||||
host="imap.gmail.com",
|
||||
port=993,
|
||||
user="gutierenmanuel15@gmail.com",
|
||||
password="abcd efgh ijkl mnop", # app-password Gmail
|
||||
mailbox="INBOX",
|
||||
)
|
||||
print(res["status"]) # "ok"
|
||||
print(res["num_messages"]) # p.ej. 1423
|
||||
conn = res["conn"] # objeto vivo: pasalo a imap_search / imap_fetch_message
|
||||
|
||||
# ... operar ...
|
||||
conn.logout() # cierra siempre al terminar
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala como PRIMER paso de cualquier flujo de lectura de correo por IMAP: antes
|
||||
de listar carpetas (`imap_list_mailboxes`), buscar (`imap_search`) o leer un
|
||||
mensaje (`imap_fetch_message`). Es la fabrica del objeto `conn` que el resto
|
||||
del grupo consume. Para Gmail usa los defaults (`imap.gmail.com:993`); para
|
||||
otros proveedores cambia `host`/`port` y pasa user+pass.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion impura: hace red. No determinista (latencia, disponibilidad del
|
||||
servidor). Nunca lanza: comprueba `status` antes de tocar `conn`.
|
||||
- El objeto `conn` VIVO viaja dentro del dict a proposito: este grupo se
|
||||
compone en heredocs Python, no por `fn run` (un proceso `fn run` no puede
|
||||
devolver un socket abierto entre invocaciones). Mantén `conn` en memoria del
|
||||
mismo proceso mientras lo uses.
|
||||
- Cierra SIEMPRE con `conn.logout()` al terminar (o en un `finally`). Una
|
||||
conexion sin cerrar deja sesiones colgando en el servidor; Gmail limita el
|
||||
numero de conexiones IMAP simultaneas por cuenta.
|
||||
- Auth = app-password, NO OAuth. En Gmail debes tener 2FA activado y generar
|
||||
una app-password; la contrasena normal de la cuenta NO funciona por IMAP.
|
||||
- Si `select(mailbox)` falla (buzon inexistente, mayusculas mal en nombres
|
||||
tipo `[Gmail]/Sent Mail`), se hace `logout` y se devuelve `status:'error'`.
|
||||
- `use_ssl=False` envia credenciales en claro: usalo solo contra servidores de
|
||||
test en redes de confianza.
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Abre y autentica una conexion IMAP (SSL por defecto) y selecciona un buzon.
|
||||
|
||||
Funcion IMPURA: hace I/O de red. Construye un `imaplib.IMAP4_SSL(host, port)`
|
||||
(o `imaplib.IMAP4(host, port)` si `use_ssl=False`), hace `login(user, password)`
|
||||
y `select(mailbox)`, y devuelve el objeto de conexion VIVO dentro del dict de
|
||||
resultado para que las demas funciones del grupo (`imap_list_mailboxes`,
|
||||
`imap_search`, `imap_fetch_message`) operen sobre el.
|
||||
|
||||
Es la primera pieza de un sistema propio (sin browser/CDP) de lectura de correo
|
||||
multi-proveedor. La autenticacion es usuario + app-password (16 caracteres en
|
||||
Gmail, o user+pass del proveedor): NO usa OAuth. Las credenciales NO se
|
||||
resuelven aqui — las pasa la capa de aplicacion (via `pass`/vault).
|
||||
|
||||
NUNCA lanza: devuelve un dict con `status` ("ok"/"error"). En error el campo
|
||||
`conn` no esta presente; el caller debe comprobar `status` antes de usar `conn`.
|
||||
"""
|
||||
|
||||
import imaplib
|
||||
|
||||
|
||||
def imap_connect(
|
||||
host: str,
|
||||
port: int = 993,
|
||||
user: str = "",
|
||||
password: str = "",
|
||||
mailbox: str = "INBOX",
|
||||
use_ssl: bool = True,
|
||||
timeout_s: float = 30.0,
|
||||
) -> dict:
|
||||
"""Conecta, autentica y selecciona un buzon IMAP.
|
||||
|
||||
Abre el socket IMAP (SSL por defecto), hace `login` con usuario +
|
||||
app-password y `select(mailbox)`. El objeto `imaplib.IMAP4[_SSL]` vivo se
|
||||
devuelve dentro del dict para componer el resto de operaciones del grupo.
|
||||
|
||||
Args:
|
||||
host: servidor IMAP (ej. ``"imap.gmail.com"``). Vacio -> status error.
|
||||
port: puerto IMAP. Default 993 (IMAPS). Para STARTTLS/plano suele ser 143.
|
||||
user: direccion de correo / usuario de la cuenta.
|
||||
password: app-password (16 chars en Gmail) o contrasena del proveedor.
|
||||
NO OAuth. Requiere 2FA activado para emitir app-passwords en Gmail.
|
||||
mailbox: buzon a seleccionar tras autenticar. Default ``"INBOX"``.
|
||||
use_ssl: True usa ``IMAP4_SSL`` (cifrado de extremo a extremo desde el
|
||||
saludo). False usa ``IMAP4`` en claro (solo redes de confianza/test).
|
||||
timeout_s: timeout del socket en segundos para conectar y operar.
|
||||
|
||||
Returns:
|
||||
Dict de estado. En exito::
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
"conn": <imaplib.IMAP4_SSL vivo, autenticado y con mailbox seleccionado>,
|
||||
"mailbox": <mailbox>,
|
||||
"num_messages": <int>, # mensajes en el buzon (respuesta de SELECT)
|
||||
}
|
||||
|
||||
En fallo (host vacio, auth invalida, red, buzon inexistente)::
|
||||
|
||||
{"status": "error", "error": <str>}
|
||||
"""
|
||||
if not host or not host.strip():
|
||||
return {"status": "error", "error": "imap_connect: host vacio"}
|
||||
|
||||
host = host.strip()
|
||||
conn = None
|
||||
try:
|
||||
if use_ssl:
|
||||
conn = imaplib.IMAP4_SSL(host, int(port), timeout=float(timeout_s))
|
||||
else:
|
||||
conn = imaplib.IMAP4(host, int(port), timeout=float(timeout_s))
|
||||
|
||||
# login lanza imaplib.IMAP4.error si las credenciales son invalidas.
|
||||
conn.login(user, password)
|
||||
|
||||
typ, data = conn.select(mailbox)
|
||||
if typ != "OK":
|
||||
# data suele traer el motivo (buzon inexistente, etc.).
|
||||
reason = _first_str(data)
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_connect: SELECT {mailbox!r} fallo: {reason}",
|
||||
}
|
||||
|
||||
num_messages = _parse_int(data)
|
||||
return {
|
||||
"status": "ok",
|
||||
"conn": conn,
|
||||
"mailbox": mailbox,
|
||||
"num_messages": num_messages,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001 — contrato: nunca lanzar.
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "error", "error": f"imap_connect: {exc}"}
|
||||
|
||||
|
||||
def _first_str(data) -> str:
|
||||
"""Devuelve el primer elemento de una respuesta imaplib como str legible."""
|
||||
if not data:
|
||||
return ""
|
||||
item = data[0]
|
||||
if isinstance(item, bytes):
|
||||
return item.decode("utf-8", errors="replace")
|
||||
return str(item)
|
||||
|
||||
|
||||
def _parse_int(data) -> int:
|
||||
"""Parsea el numero de mensajes de la respuesta de SELECT (lista de bytes)."""
|
||||
try:
|
||||
return int(_first_str(data))
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: imap_delete_message
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def imap_delete_message(conn, uid: int, expunge: bool = True) -> dict"
|
||||
description: "Marca un mensaje IMAP (por UID) como borrado anadiendo la bandera del sistema \\Deleted en el mailbox seleccionado de una conexion imaplib.IMAP4_SSL ya autenticada, y opcionalmente ejecuta EXPUNGE para materializar el borrado. Ejecuta conn.uid('STORE', str(uid), '+FLAGS', '(\\Deleted)'); si expunge=True ademas conn.expunge() (que elimina TODOS los mensajes marcados \\Deleted del mailbox, no solo este). Opera siempre por UID (estable dentro del mailbox), nunca por numero de secuencia. No abre la conexion ni resuelve credenciales: el caller pasa conn ya conectado, autenticado y con conn.select() hecho. Nunca lanza: devuelve {status:'ok', uid, expunged} o {status:'error', error}. GOTCHA Gmail: marcar \\Deleted NO borra, solo quita la etiqueta de la carpeta actual; para borrar de verdad en Gmail hay que MOVER a '[Gmail]/Trash' (ver imap_move_message). Parte del grupo email/imap. Solo stdlib (imaplib)."
|
||||
tags: [email, imap, mail, delete, expunge, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [imaplib]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "objeto imaplib.IMAP4_SSL (o IMAP4) YA conectado, autenticado y con un mailbox seleccionado (conn.select('INBOX')). Normalmente lo produce imap_connect. La funcion no lo abre ni lo cierra."
|
||||
- name: uid
|
||||
desc: "UID del mensaje dentro del mailbox seleccionado. Estable mientras no cambie la UIDVALIDITY (a diferencia del numero de secuencia, que se desplaza al borrar mensajes)."
|
||||
- name: expunge
|
||||
desc: "True (default) ejecuta EXPUNGE tras marcar \\Deleted, eliminando del mailbox TODOS los mensajes marcados \\Deleted (no solo este). False solo marca \\Deleted sin expurgar: el borrado se materializa en un EXPUNGE posterior o al cerrar el mailbox."
|
||||
output: "dict. En exito: {status:'ok', uid:int, expunged:bool} reflejando el UID y si se ejecuto EXPUNGE. En error (sin lanzar): {status:'error', error:str}, incluyendo el caso en que STORE responde un typ distinto de OK."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/imap_delete_message.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.imap_connect import imap_connect
|
||||
from infra.imap_delete_message import imap_delete_message
|
||||
|
||||
# conn ya conectado, autenticado y con INBOX seleccionado (lo produce imap_connect).
|
||||
conn = imap_connect(...)["conn"] # firma exacta la define imap_connect
|
||||
|
||||
# Borrado IMAP estandar: marca \\Deleted y expurga el mailbox.
|
||||
print(imap_delete_message(conn, 12345))
|
||||
# {'status': 'ok', 'uid': 12345, 'expunged': True}
|
||||
|
||||
# Marcar \\Deleted sin expurgar todavia (borrado diferido).
|
||||
print(imap_delete_message(conn, 12346, expunge=False))
|
||||
# {'status': 'ok', 'uid': 12346, 'expunged': False}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieres eliminar un mensaje en un servidor IMAP estandar (Dovecot, Courier,
|
||||
proveedores no-Gmail) donde marcar `\\Deleted` + EXPUNGE si borra el mensaje del
|
||||
mailbox. Para borrado en lote, llama con `expunge=False` por cada mensaje y haz un
|
||||
unico EXPUNGE al final (o un solo `imap_delete_message(..., expunge=True)` en el
|
||||
ultimo). **En Gmail no la uses para "borrar de verdad"**: alli marcar `\\Deleted`
|
||||
solo quita la etiqueta de la carpeta; usa `imap_move_message(conn, uid,
|
||||
"[Gmail]/Trash")` en su lugar.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura y destructiva**: con `expunge=True` el mensaje desaparece del mailbox de
|
||||
forma permanente (salvo politicas de retencion del servidor).
|
||||
- **Gmail \\Deleted vs Trash**: en Gmail marcar `\\Deleted` NO borra el mensaje,
|
||||
solo le quita la etiqueta de la carpeta actual (el correo sigue en "All Mail").
|
||||
Para borrar de verdad en Gmail hay que MOVER a `[Gmail]/Trash` con
|
||||
`imap_move_message`. Esta funcion es el borrado estandar de servidores no-Gmail.
|
||||
- **EXPUNGE afecta a todo el mailbox**: `conn.expunge()` elimina TODOS los mensajes
|
||||
marcados `\\Deleted` del mailbox seleccionado, no solo el `uid` indicado. Si otros
|
||||
mensajes quedaron marcados antes, tambien se borran. Usa `expunge=False` para
|
||||
marcar varios y expurgar una sola vez de forma controlada.
|
||||
- **UID estable, no secuencia**: se usa siempre `conn.uid("STORE", ...)`. El UID es
|
||||
estable dentro del mailbox mientras la UIDVALIDITY no cambie; el numero de
|
||||
secuencia se desplaza al expurgar y por eso nunca se usa.
|
||||
- **Nunca lanza**: cualquier fallo (conexion caida, mailbox no seleccionado,
|
||||
respuesta no-OK) vuelve como `{status:'error', error:str}`.
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Marca un mensaje IMAP como borrado (\\Deleted) por UID y opcionalmente expurga.
|
||||
|
||||
Funcion IMPURA: anade la bandera del sistema `\\Deleted` al mensaje identificado
|
||||
por su UID en el mailbox seleccionado de una conexion `imaplib.IMAP4_SSL` ya
|
||||
autenticada y, si `expunge=True`, ejecuta EXPUNGE para materializar el borrado
|
||||
de todos los mensajes marcados \\Deleted del mailbox.
|
||||
|
||||
OJO Gmail: marcar \\Deleted NO borra el mensaje, solo le quita la etiqueta de la
|
||||
carpeta actual; para un borrado real en Gmail hay que MOVER a "[Gmail]/Trash"
|
||||
(ver imap_move_message). Esta funcion es el borrado IMAP estandar, util en
|
||||
servidores no-Gmail (Dovecot, etc.) donde \\Deleted + EXPUNGE si borra.
|
||||
|
||||
Nunca lanza: devuelve un dict con `status` ("ok"/"error"). No abre la conexion ni
|
||||
resuelve credenciales: el caller pasa `conn` ya conectado, autenticado y con
|
||||
`conn.select("<mailbox>")` hecho.
|
||||
"""
|
||||
|
||||
|
||||
def imap_delete_message(conn, uid: int, expunge: bool = True) -> dict:
|
||||
"""Marca como borrado el mensaje `uid` y opcionalmente expurga el mailbox.
|
||||
|
||||
Ejecuta ``conn.uid("STORE", str(uid), "+FLAGS", "(\\Deleted)")`` y, si
|
||||
``expunge=True``, ``conn.expunge()``. La operacion es por UID, no por numero
|
||||
de secuencia.
|
||||
|
||||
Args:
|
||||
conn: objeto ``imaplib.IMAP4_SSL`` (o ``IMAP4``) YA conectado,
|
||||
autenticado y con un mailbox seleccionado (``conn.select(...)``).
|
||||
uid: UID del mensaje dentro del mailbox seleccionado. Estable mientras no
|
||||
cambie la UIDVALIDITY del mailbox.
|
||||
expunge: ``True`` (default) ejecuta EXPUNGE tras marcar \\Deleted, lo que
|
||||
elimina del mailbox TODOS los mensajes marcados \\Deleted (no solo
|
||||
este). ``False`` deja el mensaje solo marcado \\Deleted, sin expurgar:
|
||||
el borrado se materializa en un EXPUNGE posterior o al cerrar el
|
||||
mailbox.
|
||||
|
||||
Returns:
|
||||
dict. En exito: ``{"status": "ok", "uid": uid, "expunged": expunge}``. En
|
||||
fallo (sin lanzar): ``{"status": "error", "error": str}``. Tambien error
|
||||
si el STORE responde algo distinto de ``OK``.
|
||||
"""
|
||||
try:
|
||||
typ, data = conn.uid("STORE", str(uid), "+FLAGS", "(\\Deleted)")
|
||||
if typ != "OK":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_delete_message: STORE \\Deleted devolvio {typ!r}: {data!r}",
|
||||
}
|
||||
if expunge:
|
||||
conn.expunge()
|
||||
return {"status": "ok", "uid": uid, "expunged": expunge}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("imap_delete_message: importable. Uso real requiere un conn IMAP autenticado.")
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: imap_fetch_message
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def imap_fetch_message(conn, uid: int, mark_seen: bool = False) -> dict"
|
||||
description: "Descarga y parsea un mensaje IMAP por UID a un dict estructurado. Sobre una conexion imaplib viva (de imap_connect) ejecuta conn.uid('FETCH', uid, '(BODY.PEEK[])') si mark_seen=False (NO marca leido) o '(RFC822)' si True (marca \\Seen), parsea con email.message_from_bytes y extrae from/to/cc/subject/date/message_id (cabeceras RFC 2047 decodificadas a Unicode con decode_header), body_text (text/plain) y body_html (text/html) respetando el charset de cada parte, y attachments como lista de {filename, content_type, size_bytes} SIN bajar el binario completo. Maneja multipart y mensajes simples. Devuelve {status:'ok', message:{...}} o {status:'error', error}. Nunca lanza."
|
||||
tags: [email, imap, infra, parse, network]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "Objeto imaplib.IMAP4[_SSL] vivo y autenticado, producido por imap_connect. None devuelve status error."
|
||||
- name: uid
|
||||
desc: "UID del mensaje (de imap_search). NO numero de secuencia. No-entero devuelve status error."
|
||||
- name: mark_seen
|
||||
desc: "False (default) usa BODY.PEEK[] y NO marca el mensaje como leido. True usa RFC822 y lo marca \\Seen."
|
||||
output: "dict de estado. En exito {status:'ok', message:{uid:int, from:str, to:str, cc:str, subject:str, date:str, message_id:str, body_text:str (text/plain concatenado), body_html:str (text/html concatenado), attachments:[{filename:str, content_type:str, size_bytes:int}]}}: cabeceras decodificadas de RFC 2047 a Unicode; cuerpos decodificados respetando el charset declarado. En fallo (conn None, uid no-entero o inexistente, FETCH no OK) {status:'error', error: str}."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/imap_fetch_message.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra import imap_connect, imap_search, imap_fetch_message
|
||||
|
||||
c = imap_connect("imap.gmail.com", 993, "gutierenmanuel15@gmail.com", "abcd efgh ijkl mnop")
|
||||
conn = c["conn"]
|
||||
|
||||
# Localiza no leidos y lee el primero SIN marcarlo como leido (PEEK)
|
||||
found = imap_search(conn, criteria="UNSEEN")
|
||||
if found["uids"]:
|
||||
res = imap_fetch_message(conn, found["uids"][0], mark_seen=False)
|
||||
m = res["message"]
|
||||
print(m["from"]) # 'Soporte <soporte@banco.es>'
|
||||
print(m["subject"]) # 'Tu factura de junio' (acentos ya decodificados)
|
||||
print(m["date"])
|
||||
print(m["body_text"][:200])
|
||||
for att in m["attachments"]:
|
||||
print(att["filename"], att["content_type"], att["size_bytes"])
|
||||
|
||||
conn.logout()
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala como ultimo paso del flujo de lectura (connect -> search -> fetch) cuando
|
||||
ya tienes un UID y quieres el contenido del mensaje normalizado: remitente,
|
||||
asunto, fecha, cuerpo en texto/HTML y la lista de adjuntos con sus metadatos.
|
||||
Deja `mark_seen=False` para previsualizar sin alterar el estado leido/no-leido
|
||||
del buzon (util en monitores que no deben "tocar" la bandeja del usuario).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion impura: hace red sobre el `conn` vivo. Nunca lanza: comprueba `status`.
|
||||
El `conn` lo provee `imap_connect`; este grupo se compone en un mismo proceso
|
||||
Python (heredoc), no por `fn run`.
|
||||
- Espera UID (de `imap_search`), NO numero de secuencia. Pasar un seq devuelve
|
||||
el mensaje equivocado o ninguno.
|
||||
- `mark_seen=False` usa `BODY.PEEK[]` y NO marca leido; `mark_seen=True` usa
|
||||
`RFC822` y SI marca `\Seen`. Elige segun si quieres que el usuario vea el
|
||||
correo como ya leido.
|
||||
- `attachments` lista metadatos (`filename`, `content_type`, `size_bytes`) pero
|
||||
NO incluye el binario para no inflar el resultado; `size_bytes` se mide
|
||||
decodificando el payload de esa parte. Para bajar un adjunto, haz un FETCH
|
||||
parcial aparte por su seccion.
|
||||
- Charsets: cada parte de texto se decodifica con el charset declarado, con
|
||||
fallback a utf-8 y latin-1; las cabeceras (`Subject`, `From`, ...) se
|
||||
decodifican de RFC 2047 (`=?UTF-8?B?...?=`). Mensajes mal etiquetados pueden
|
||||
mostrar caracteres de reemplazo en vez de fallar.
|
||||
- Mensajes muy grandes (adjuntos pesados) descargan el RFC822 completo: ten en
|
||||
cuenta el ancho de banda y la memoria.
|
||||
- Cierra con `conn.logout()` al terminar (responsabilidad del caller).
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Descarga y parsea un mensaje IMAP por UID a un dict estructurado.
|
||||
|
||||
Funcion IMPURA: hace I/O de red sobre una conexion `imaplib` viva (la produce
|
||||
`imap_connect`). Ejecuta `conn.uid("FETCH", uid, "(BODY.PEEK[])")` (que NO marca
|
||||
el mensaje como leido) o `"(RFC822)"` (que SI lo marca) segun `mark_seen`,
|
||||
parsea los bytes con `email.message_from_bytes` y extrae las cabeceras y el
|
||||
cuerpo a un dict.
|
||||
|
||||
Las cabeceras codificadas (RFC 2047, ej. `=?UTF-8?B?...?=`) se decodifican a
|
||||
Unicode con `email.header.decode_header`. Los cuerpos de texto se decodifican
|
||||
respetando el charset declarado en cada parte (con fallback a utf-8/latin-1).
|
||||
Los adjuntos se listan con metadatos (nombre, tipo, tamano) SIN incluir el
|
||||
binario completo en el resultado.
|
||||
|
||||
NUNCA lanza: devuelve un dict con `status` ("ok"/"error").
|
||||
"""
|
||||
|
||||
import email
|
||||
from email.header import decode_header
|
||||
from email.utils import parseaddr, getaddresses
|
||||
|
||||
|
||||
def imap_fetch_message(conn, uid: int, mark_seen: bool = False) -> dict:
|
||||
"""Descarga el mensaje de UID `uid` y lo devuelve parseado.
|
||||
|
||||
Args:
|
||||
conn: objeto `imaplib.IMAP4[_SSL]` vivo y autenticado (de `imap_connect`).
|
||||
uid: UID del mensaje (de `imap_search`). Numero de secuencia NO valido.
|
||||
mark_seen: False (default) usa `BODY.PEEK[]` y NO marca leido; True usa
|
||||
`RFC822` y marca el mensaje como `\\Seen`.
|
||||
|
||||
Returns:
|
||||
Dict de estado. En exito::
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
"message": {
|
||||
"uid": <int>,
|
||||
"from": <str>, "to": <str>, "cc": <str>,
|
||||
"subject": <str>, "date": <str>, "message_id": <str>,
|
||||
"body_text": <str>, # text/plain concatenado
|
||||
"body_html": <str>, # text/html concatenado
|
||||
"attachments": [
|
||||
{"filename": <str>, "content_type": <str>, "size_bytes": <int>},
|
||||
...
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
En fallo (conn invalido, UID inexistente, FETCH no OK)::
|
||||
|
||||
{"status": "error", "error": <str>}
|
||||
"""
|
||||
if conn is None:
|
||||
return {"status": "error", "error": "imap_fetch_message: conn es None"}
|
||||
try:
|
||||
uid_int = int(uid)
|
||||
except (ValueError, TypeError):
|
||||
return {"status": "error", "error": f"imap_fetch_message: uid invalido: {uid!r}"}
|
||||
|
||||
fetch_spec = "(RFC822)" if mark_seen else "(BODY.PEEK[])"
|
||||
try:
|
||||
typ, data = conn.uid("FETCH", str(uid_int), fetch_spec)
|
||||
if typ != "OK":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_fetch_message: FETCH uid {uid_int} devolvio {typ}",
|
||||
}
|
||||
|
||||
raw = _extract_rfc822(data)
|
||||
if raw is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_fetch_message: UID {uid_int} sin contenido (inexistente?)",
|
||||
}
|
||||
|
||||
msg = email.message_from_bytes(raw)
|
||||
parsed = _parse_message(msg, uid_int)
|
||||
return {"status": "ok", "message": parsed}
|
||||
except Exception as exc: # noqa: BLE001 — contrato: nunca lanzar.
|
||||
return {"status": "error", "error": f"imap_fetch_message: {exc}"}
|
||||
|
||||
|
||||
def _extract_rfc822(data):
|
||||
"""Extrae los bytes RFC822 de la respuesta de FETCH.
|
||||
|
||||
imaplib devuelve algo como ``[(b'1 (BODY[] {N}', b'<bytes>'), b')']``.
|
||||
Buscamos la primera tupla cuyo segundo elemento sean los bytes del mensaje.
|
||||
"""
|
||||
if not data:
|
||||
return None
|
||||
for item in data:
|
||||
if isinstance(item, tuple) and len(item) >= 2:
|
||||
payload = item[1]
|
||||
if isinstance(payload, (bytes, bytearray)):
|
||||
return bytes(payload)
|
||||
return None
|
||||
|
||||
|
||||
def _parse_message(msg, uid_int: int) -> dict:
|
||||
"""Convierte un email.message.Message en el dict del contrato."""
|
||||
body_text_parts: list[str] = []
|
||||
body_html_parts: list[str] = []
|
||||
attachments: list[dict] = []
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.is_multipart():
|
||||
continue
|
||||
_consume_part(part, body_text_parts, body_html_parts, attachments)
|
||||
else:
|
||||
_consume_part(msg, body_text_parts, body_html_parts, attachments)
|
||||
|
||||
return {
|
||||
"uid": uid_int,
|
||||
"from": _decode_header(msg.get("From", "")),
|
||||
"to": _decode_addr_list(msg.get_all("To", [])),
|
||||
"cc": _decode_addr_list(msg.get_all("Cc", [])),
|
||||
"subject": _decode_header(msg.get("Subject", "")),
|
||||
"date": _decode_header(msg.get("Date", "")),
|
||||
"message_id": (msg.get("Message-ID", "") or "").strip(),
|
||||
"body_text": "\n".join(p for p in body_text_parts if p),
|
||||
"body_html": "\n".join(p for p in body_html_parts if p),
|
||||
"attachments": attachments,
|
||||
}
|
||||
|
||||
|
||||
def _consume_part(part, body_text_parts, body_html_parts, attachments) -> None:
|
||||
"""Clasifica una parte: adjunto, text/plain o text/html."""
|
||||
content_type = part.get_content_type()
|
||||
disposition = (part.get("Content-Disposition") or "").lower()
|
||||
filename = part.get_filename()
|
||||
if filename:
|
||||
filename = _decode_header(filename)
|
||||
|
||||
is_attachment = "attachment" in disposition or (
|
||||
filename and content_type not in ("text/plain", "text/html")
|
||||
)
|
||||
|
||||
if is_attachment:
|
||||
payload = part.get_payload(decode=True) or b""
|
||||
attachments.append(
|
||||
{
|
||||
"filename": filename or "",
|
||||
"content_type": content_type,
|
||||
"size_bytes": len(payload),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if content_type == "text/plain":
|
||||
body_text_parts.append(_decode_body(part))
|
||||
elif content_type == "text/html":
|
||||
body_html_parts.append(_decode_body(part))
|
||||
# Otros tipos inline sin filename (ej. multipart/alternative wrappers) se ignoran.
|
||||
|
||||
|
||||
def _decode_body(part) -> str:
|
||||
"""Decodifica el payload de una parte de texto respetando su charset."""
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload is None:
|
||||
return ""
|
||||
charset = part.get_content_charset()
|
||||
candidates = []
|
||||
if charset:
|
||||
candidates.append(charset)
|
||||
candidates += ["utf-8", "latin-1"]
|
||||
for enc in candidates:
|
||||
try:
|
||||
return payload.decode(enc)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
continue
|
||||
# Ultimo recurso: nunca falla.
|
||||
return payload.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _decode_header(value: str) -> str:
|
||||
"""Decodifica una cabecera RFC 2047 (=?charset?enc?...?=) a Unicode."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("latin-1", errors="replace")
|
||||
parts = []
|
||||
try:
|
||||
for chunk, enc in decode_header(value):
|
||||
if isinstance(chunk, bytes):
|
||||
if enc:
|
||||
try:
|
||||
parts.append(chunk.decode(enc, errors="replace"))
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
parts.append(chunk.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
# Sin charset declarado: ASCII con fallback latin-1.
|
||||
parts.append(chunk.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
parts.append(chunk)
|
||||
except Exception: # noqa: BLE001 — cabecera mal formada: best-effort.
|
||||
return str(value)
|
||||
return "".join(parts).strip()
|
||||
|
||||
|
||||
def _decode_addr_list(values) -> str:
|
||||
"""Decodifica una lista de cabeceras de direcciones a una cadena unica.
|
||||
|
||||
Une multiples cabeceras (To/Cc pueden repetirse) y decodifica el nombre
|
||||
de cada direccion (RFC 2047) preservando la parte addr-spec.
|
||||
"""
|
||||
if not values:
|
||||
return ""
|
||||
addrs = getaddresses(values)
|
||||
out = []
|
||||
for name, addr in addrs:
|
||||
name = _decode_header(name) if name else ""
|
||||
if name and addr:
|
||||
out.append(f"{name} <{addr}>")
|
||||
elif addr:
|
||||
out.append(addr)
|
||||
elif name:
|
||||
out.append(name)
|
||||
return ", ".join(out)
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: imap_list_mailboxes
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def imap_list_mailboxes(conn) -> dict"
|
||||
description: "Lista los buzones/carpetas de una cuenta IMAP ya conectada. Ejecuta conn.list() sobre una conexion imaplib viva (de imap_connect), parsea cada linea de respuesta para extraer el nombre de carpeta y lo decodifica de modified-UTF-7 (RFC 3501) a Unicode (codec imap4-utf-7 si esta disponible, conversion manual si no, ASCII best-effort como fallback). Las carpetas anidadas de Gmail salen con prefijo ([Gmail]/Sent Mail, [Gmail]/Spam). Devuelve {status:'ok', mailboxes:[...]} o {status:'error', error}. Nunca lanza."
|
||||
tags: [email, imap, infra, mailbox, network]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "Objeto imaplib.IMAP4[_SSL] vivo y autenticado, producido por imap_connect. None devuelve status error."
|
||||
output: "dict de estado. En exito {status:'ok', mailboxes: list[str]} con los nombres de carpeta decodificados a Unicode (ej. ['INBOX', '[Gmail]/Sent Mail', '[Gmail]/Spam']). En fallo (conn None, respuesta LIST no OK) {status:'error', error: str}."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/imap_list_mailboxes.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra import imap_connect, imap_list_mailboxes
|
||||
|
||||
c = imap_connect("imap.gmail.com", 993, "gutierenmanuel15@gmail.com", "abcd efgh ijkl mnop")
|
||||
conn = c["conn"]
|
||||
|
||||
res = imap_list_mailboxes(conn)
|
||||
print(res["status"]) # "ok"
|
||||
for mb in res["mailboxes"]:
|
||||
print(mb) # INBOX, [Gmail]/Sent Mail, [Gmail]/Spam, [Gmail]/Trash, ...
|
||||
|
||||
conn.logout()
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala despues de `imap_connect` cuando necesites descubrir que carpetas tiene
|
||||
la cuenta antes de buscar o leer (ej. saber el nombre exacto del buzon de
|
||||
enviados o spam para pasarlo a `imap_search`/`imap_connect`). Imprescindible
|
||||
con Gmail, donde los nombres llevan el prefijo `[Gmail]/` y pueden estar
|
||||
localizados segun el idioma de la cuenta.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion impura: hace red sobre el `conn` vivo. Nunca lanza: comprueba
|
||||
`status`. Requiere un `conn` valido de `imap_connect` (no resuelve
|
||||
credenciales ni reconecta).
|
||||
- El objeto `conn` es el mismo objeto vivo del grupo: se opera en el mismo
|
||||
proceso Python (heredoc), no por `fn run`.
|
||||
- Nombres de carpeta no-ASCII vienen en modified-UTF-7 (RFC 3501): `&...-`
|
||||
delimita la secuencia y usa `,` en vez de `/` en el base64. Se decodifican a
|
||||
Unicode; si el codec/conversion falla, la entrada se conserva en ASCII
|
||||
best-effort (puede mostrar mojibake) en vez de perderse o lanzar.
|
||||
- Los nombres de carpeta de Gmail dependen del idioma de la cuenta y del
|
||||
prefijo `[Gmail]/`. No los hardcodees: descubrelos con esta funcion.
|
||||
- Cierra la conexion con `conn.logout()` al terminar (responsabilidad del
|
||||
caller, no de esta funcion).
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Lista los buzones/carpetas de una cuenta IMAP ya conectada.
|
||||
|
||||
Funcion IMPURA: hace I/O de red sobre una conexion `imaplib` viva (la produce
|
||||
`imap_connect`). Ejecuta `conn.list()`, parsea cada linea de respuesta para
|
||||
extraer el nombre de la carpeta y lo decodifica de modified-UTF-7 (la
|
||||
codificacion que IMAP usa en los nombres de buzon, RFC 3501 5.1.3) a Unicode.
|
||||
|
||||
NUNCA lanza: devuelve un dict con `status` ("ok"/"error").
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Cada linea de LIST tiene forma: (\HasNoChildren) "/" "INBOX"
|
||||
# o sin comillas: (\HasNoChildren) "/" INBOX
|
||||
# Capturamos delimitador y nombre (con o sin comillas).
|
||||
_LIST_RE = re.compile(rb'^\((?P<flags>[^)]*)\)\s+(?P<delim>"[^"]*"|NIL)\s+(?P<name>.*)$')
|
||||
|
||||
|
||||
def imap_list_mailboxes(conn) -> dict:
|
||||
"""Lista los nombres de los buzones de la cuenta.
|
||||
|
||||
Ejecuta `conn.list()` y parsea cada entrada, decodificando el nombre de
|
||||
modified-UTF-7 a Unicode. Las carpetas anidadas de Gmail aparecen con su
|
||||
prefijo (`[Gmail]/Sent Mail`, `[Gmail]/Spam`, ...).
|
||||
|
||||
Args:
|
||||
conn: objeto `imaplib.IMAP4[_SSL]` vivo y autenticado (de `imap_connect`).
|
||||
|
||||
Returns:
|
||||
Dict de estado. En exito::
|
||||
|
||||
{"status": "ok", "mailboxes": ["INBOX", "[Gmail]/Sent Mail", ...]}
|
||||
|
||||
En fallo (conn invalido, respuesta no OK)::
|
||||
|
||||
{"status": "error", "error": <str>}
|
||||
"""
|
||||
if conn is None:
|
||||
return {"status": "error", "error": "imap_list_mailboxes: conn es None"}
|
||||
|
||||
try:
|
||||
typ, data = conn.list()
|
||||
if typ != "OK":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_list_mailboxes: LIST devolvio {typ}",
|
||||
}
|
||||
|
||||
mailboxes: list[str] = []
|
||||
for line in data:
|
||||
name = _extract_name(line)
|
||||
if name:
|
||||
mailboxes.append(_decode_mailbox(name))
|
||||
return {"status": "ok", "mailboxes": mailboxes}
|
||||
except Exception as exc: # noqa: BLE001 — contrato: nunca lanzar.
|
||||
return {"status": "error", "error": f"imap_list_mailboxes: {exc}"}
|
||||
|
||||
|
||||
def _extract_name(line) -> bytes:
|
||||
"""Extrae el nombre crudo (bytes, modified-UTF-7) de una linea de LIST."""
|
||||
if line is None:
|
||||
return b""
|
||||
# Algunos servidores devuelven tuplas (para literales); normalizamos a bytes.
|
||||
if isinstance(line, tuple):
|
||||
line = b"".join(p if isinstance(p, bytes) else str(p).encode() for p in line)
|
||||
if isinstance(line, str):
|
||||
line = line.encode("utf-8", errors="replace")
|
||||
|
||||
m = _LIST_RE.match(line.strip())
|
||||
if not m:
|
||||
return b""
|
||||
name = m.group("name").strip()
|
||||
# Quita comillas externas si las hay.
|
||||
if len(name) >= 2 and name[:1] == b'"' and name[-1:] == b'"':
|
||||
name = name[1:-1]
|
||||
return name
|
||||
|
||||
|
||||
def _decode_mailbox(raw: bytes) -> str:
|
||||
"""Decodifica un nombre de buzon de modified-UTF-7 (IMAP) a Unicode.
|
||||
|
||||
IMAP codifica caracteres no-ASCII de los nombres de carpeta en una variante
|
||||
de UTF-7 (RFC 3501): `&` introduce la secuencia y `-` la cierra, con `,` en
|
||||
lugar de `/` en el base64. Probamos el codec `imap4-utf-7` si el interprete
|
||||
lo registra; si no, hacemos la conversion manual; si todo falla, devolvemos
|
||||
el nombre como ASCII best-effort para no perder la entrada.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
# 1) Codec nativo si esta disponible (algunos builds lo registran).
|
||||
try:
|
||||
return raw.decode("imap4-utf-7")
|
||||
except (LookupError, UnicodeDecodeError, Exception): # noqa: BLE001
|
||||
pass
|
||||
# 2) Conversion manual modified-UTF-7 -> Unicode.
|
||||
try:
|
||||
return _decode_imap_utf7(raw)
|
||||
except Exception: # noqa: BLE001
|
||||
# 3) Fallback crudo: no perdemos la carpeta aunque tenga mojibake.
|
||||
return raw.decode("ascii", errors="replace")
|
||||
|
||||
|
||||
def _decode_imap_utf7(raw: bytes) -> str:
|
||||
"""Implementacion manual de modified-UTF-7 -> str (sin dependencias)."""
|
||||
import base64
|
||||
|
||||
s = raw.decode("ascii", errors="replace")
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
n = len(s)
|
||||
while i < n:
|
||||
ch = s[i]
|
||||
if ch == "&":
|
||||
j = s.find("-", i + 1)
|
||||
if j == -1:
|
||||
# & sin cierre: literal mal formado, lo dejamos como esta.
|
||||
out.append(s[i:])
|
||||
break
|
||||
chunk = s[i + 1 : j]
|
||||
if chunk == "":
|
||||
# "&-" representa un literal "&".
|
||||
out.append("&")
|
||||
else:
|
||||
# En modified-UTF-7 el base64 usa ',' en lugar de '/'.
|
||||
b64 = chunk.replace(",", "/")
|
||||
pad = "=" * ((4 - len(b64) % 4) % 4)
|
||||
decoded = base64.b64decode(b64 + pad)
|
||||
out.append(decoded.decode("utf-16-be"))
|
||||
i = j + 1
|
||||
else:
|
||||
out.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: imap_mark_seen
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def imap_mark_seen(conn, uid: int, seen: bool = True) -> dict"
|
||||
description: "Marca o desmarca un mensaje IMAP como leido (bandera del sistema \\Seen) operando por UID sobre una conexion imaplib.IMAP4_SSL ya autenticada y con un mailbox seleccionado. Ejecuta conn.uid('STORE', str(uid), '+FLAGS' si seen else '-FLAGS', '(\\Seen)'): seen=True anade \\Seen (leido), seen=False la quita (no leido). Opera siempre por UID (estable dentro del mailbox mientras no cambie la UIDVALIDITY), nunca por numero de secuencia. No abre la conexion ni resuelve credenciales: el caller pasa conn ya conectado, autenticado y con conn.select() hecho. Nunca lanza: devuelve {status:'ok', uid, seen} o {status:'error', error}; tambien error si STORE responde algo distinto de OK. Parte del grupo email/imap (mutacion de estado de correo por IMAP, tecnologia propia, sin browser). Solo stdlib (imaplib)."
|
||||
tags: [email, imap, mail, flags, seen, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [imaplib]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "objeto imaplib.IMAP4_SSL (o IMAP4) YA conectado, autenticado y con un mailbox seleccionado (conn.select('INBOX')). Normalmente lo produce imap_connect. La funcion no lo abre ni lo cierra."
|
||||
- name: uid
|
||||
desc: "UID del mensaje dentro del mailbox seleccionado. Es estable mientras no cambie la UIDVALIDITY del mailbox (a diferencia del numero de secuencia, que se desplaza al borrar mensajes). Se obtiene de una busqueda/fetch por UID previa."
|
||||
- name: seen
|
||||
desc: "True (default) para marcar como leido: STORE +FLAGS (\\Seen). False para marcar como no leido: STORE -FLAGS (\\Seen)."
|
||||
output: "dict. En exito: {status:'ok', uid:int, seen:bool} reflejando el UID y el estado solicitado. En error (sin lanzar): {status:'error', error:str}, incluyendo el caso en que el servidor responde un typ distinto de OK al comando STORE."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/imap_mark_seen.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.imap_connect import imap_connect
|
||||
from infra.imap_mark_seen import imap_mark_seen
|
||||
|
||||
# conn ya conectado, autenticado y con INBOX seleccionado (lo produce imap_connect).
|
||||
conn = imap_connect(...)["conn"] # firma exacta la define imap_connect
|
||||
|
||||
# Marcar el mensaje UID 12345 como leido.
|
||||
print(imap_mark_seen(conn, 12345))
|
||||
# {'status': 'ok', 'uid': 12345, 'seen': True}
|
||||
|
||||
# Volver a marcarlo como NO leido.
|
||||
print(imap_mark_seen(conn, 12345, seen=False))
|
||||
# {'status': 'ok', 'uid': 12345, 'seen': False}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando ya tienes el UID de un mensaje (por una busqueda/fetch previa) y quieres
|
||||
cambiar su estado leido/no-leido sin descargar ni reenviar nada: marcar como
|
||||
visto tras procesarlo automaticamente, o re-marcar como no leido para que el
|
||||
usuario lo vea pendiente. Es la primitiva de mutacion mas barata del grupo IMAP
|
||||
(un solo comando STORE). Compone bien tras `imap_search` + `imap_fetch_message`:
|
||||
lees, decides, y marcas el estado con esta funcion.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: cambia estado en el servidor de correo de forma persistente.
|
||||
- **UID, no secuencia**: la funcion usa `conn.uid("STORE", ...)`. El UID es
|
||||
estable dentro del mailbox mientras la UIDVALIDITY no cambie; el numero de
|
||||
secuencia NO lo es (se desplaza al borrar mensajes), por eso nunca se usa.
|
||||
- **Mailbox seleccionado**: el UID solo tiene sentido en el mailbox que el caller
|
||||
selecciono con `conn.select(...)`. El mismo numero de UID en otro mailbox
|
||||
apunta a otro mensaje (o a ninguno).
|
||||
- **\\Seen es del sistema**: `(\\Seen)` es una bandera estandar IMAP. Marcarla no
|
||||
mueve ni borra el mensaje, solo cambia su estado de lectura. En Gmail esto
|
||||
equivale a marcar el hilo como leido/no leido.
|
||||
- **Nunca lanza**: cualquier fallo (conexion caida, mailbox no seleccionado,
|
||||
respuesta no-OK del servidor) vuelve como `{status:'error', error:str}`.
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Marca o desmarca un mensaje IMAP como leido (\\Seen) operando por UID.
|
||||
|
||||
Funcion IMPURA: emite un comando UID STORE contra un servidor IMAP a traves de
|
||||
una conexion `imaplib.IMAP4_SSL` ya autenticada y con un mailbox seleccionado.
|
||||
Anade (+FLAGS) o quita (-FLAGS) la bandera del sistema `\\Seen` sobre el mensaje
|
||||
identificado por su UID, que es estable dentro del mailbox seleccionado (a
|
||||
diferencia del numero de secuencia, que cambia cuando se borran mensajes).
|
||||
|
||||
Nunca lanza: devuelve un dict con `status` ("ok"/"error"), siguiendo el estilo
|
||||
del contrato compartido del grupo email/imap del registry. No resuelve
|
||||
credenciales ni abre la conexion: el caller pasa `conn` ya conectado, autenticado
|
||||
y con `conn.select("<mailbox>")` hecho.
|
||||
"""
|
||||
|
||||
|
||||
def imap_mark_seen(conn, uid: int, seen: bool = True) -> dict:
|
||||
"""Marca/desmarca como leido el mensaje `uid` del mailbox seleccionado.
|
||||
|
||||
Ejecuta ``conn.uid("STORE", str(uid), "+FLAGS" | "-FLAGS", "(\\Seen)")``.
|
||||
Con ``seen=True`` anade la bandera ``\\Seen`` (mensaje leido); con
|
||||
``seen=False`` la quita (mensaje no leido). La operacion es por UID, no por
|
||||
numero de secuencia.
|
||||
|
||||
Args:
|
||||
conn: objeto ``imaplib.IMAP4_SSL`` (o ``IMAP4``) YA conectado,
|
||||
autenticado y con un mailbox seleccionado (``conn.select(...)``).
|
||||
uid: UID del mensaje dentro del mailbox seleccionado. Estable mientras
|
||||
no cambie la UIDVALIDITY del mailbox.
|
||||
seen: ``True`` para marcar como leido (``+FLAGS``), ``False`` para
|
||||
marcar como no leido (``-FLAGS``). Default ``True``.
|
||||
|
||||
Returns:
|
||||
dict. En exito: ``{"status": "ok", "uid": uid, "seen": seen}``. En
|
||||
fallo (sin lanzar): ``{"status": "error", "error": str}``. Tambien se
|
||||
devuelve error si el servidor responde algo distinto de ``OK`` al
|
||||
comando STORE.
|
||||
"""
|
||||
try:
|
||||
op = "+FLAGS" if seen else "-FLAGS"
|
||||
typ, data = conn.uid("STORE", str(uid), op, "(\\Seen)")
|
||||
if typ != "OK":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_mark_seen: STORE devolvio {typ!r}: {data!r}",
|
||||
}
|
||||
return {"status": "ok", "uid": uid, "seen": seen}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Smoke manual (requiere un `conn` real de imap_connect). Se documenta el uso
|
||||
# en el .md; aqui solo dejamos constancia del patron, tolerando ausencia de conn.
|
||||
print("imap_mark_seen: importable. Uso real requiere un conn IMAP autenticado.")
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: imap_move_message
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def imap_move_message(conn, uid: int, dest_mailbox: str) -> dict"
|
||||
description: "Mueve un mensaje IMAP (por UID) del mailbox seleccionado a dest_mailbox sobre una conexion imaplib.IMAP4_SSL ya autenticada. Intenta primero conn.uid('MOVE', str(uid), dest_mailbox) (RFC 6851, atomico, soportado por Gmail) detectando la capability en conn.capabilities; si el servidor NO anuncia MOVE o el comando falla, usa el fallback clasico equivalente: UID COPY a dest + UID STORE +FLAGS (\\Deleted) en origen + EXPUNGE. Devuelve el camino usado en method ('move' o 'copy_delete'). Opera siempre por UID (estable dentro del mailbox), nunca por numero de secuencia. No abre la conexion ni resuelve credenciales: el caller pasa conn ya conectado, autenticado y con conn.select() del mailbox origen hecho. Nunca lanza: devuelve {status:'ok', uid, dest, method} o {status:'error', error}. En Gmail los nombres de carpeta llevan el prefijo '[Gmail]/' y mover a '[Gmail]/Trash' = papelera. Parte del grupo email/imap. Solo stdlib (imaplib)."
|
||||
tags: [email, imap, mail, move, mailbox, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [imaplib]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "objeto imaplib.IMAP4_SSL (o IMAP4) YA conectado, autenticado y con el mailbox ORIGEN seleccionado (conn.select('INBOX')). Normalmente lo produce imap_connect. La funcion no lo abre ni lo cierra. Lee conn.capabilities para decidir MOVE vs fallback."
|
||||
- name: uid
|
||||
desc: "UID del mensaje en el mailbox origen. Operacion siempre por UID (estable mientras no cambie la UIDVALIDITY), nunca por numero de secuencia."
|
||||
- name: dest_mailbox
|
||||
desc: "nombre del mailbox destino. En Gmail lleva el prefijo '[Gmail]/' (ej. '[Gmail]/Trash' = papelera, '[Gmail]/Spam'); una carpeta de usuario es solo su nombre. El destino debe existir en el servidor."
|
||||
output: "dict. En exito: {status:'ok', uid:int, dest:str, method:'move'|'copy_delete'} donde method indica si se uso UID MOVE atomico o el fallback COPY+STORE \\Deleted+EXPUNGE. En error (sin lanzar): {status:'error', error:str}, p.ej. si COPY o STORE devuelven un typ distinto de OK."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/imap_move_message.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from infra.imap_connect import imap_connect
|
||||
from infra.imap_move_message import imap_move_message
|
||||
|
||||
# conn ya conectado, autenticado y con INBOX seleccionado (lo produce imap_connect).
|
||||
conn = imap_connect(...)["conn"] # firma exacta la define imap_connect
|
||||
|
||||
# Mover el mensaje UID 12345 a la papelera de Gmail (nota el prefijo [Gmail]/).
|
||||
print(imap_move_message(conn, 12345, "[Gmail]/Trash"))
|
||||
# {'status': 'ok', 'uid': 12345, 'dest': '[Gmail]/Trash', 'method': 'move'}
|
||||
|
||||
# En un servidor sin MOVE el resultado seria identico salvo method:
|
||||
# {'status': 'ok', 'uid': 12345, 'dest': 'Archive', 'method': 'copy_delete'}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieres reubicar un mensaje entre carpetas sin descargarlo ni reenviarlo:
|
||||
archivar, clasificar en una etiqueta/carpeta, o enviarlo a la papelera. Es la via
|
||||
correcta para "borrar de verdad" en Gmail (mover a `[Gmail]/Trash`), donde marcar
|
||||
`\\Deleted` solo quita la etiqueta de la carpeta actual. La funcion abstrae la
|
||||
diferencia entre servidores con y sin soporte MOVE, asi que el caller no necesita
|
||||
saber si el backend implementa RFC 6851. Compone tras `imap_search`/`imap_fetch_message`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: cambia estado en el servidor de forma persistente y puede borrar el
|
||||
mensaje del origen (en el fallback, tras EXPUNGE).
|
||||
- **Gmail \\Deleted vs Trash**: en Gmail, marcar `\\Deleted` NO borra el mensaje,
|
||||
solo le quita la etiqueta de la carpeta actual. Para borrar de verdad hay que
|
||||
mover a `[Gmail]/Trash`. Por eso esta funcion es la herramienta de borrado real
|
||||
en Gmail (con `dest_mailbox="[Gmail]/Trash"`).
|
||||
- **Prefijo [Gmail]/**: las carpetas del sistema de Gmail llevan el prefijo
|
||||
`[Gmail]/` (`[Gmail]/Trash`, `[Gmail]/Spam`, `[Gmail]/Drafts`, `[Gmail]/Sent Mail`,
|
||||
`[Gmail]/All Mail`). Las carpetas/etiquetas de usuario van por su nombre directo.
|
||||
- **MOVE vs COPY+EXPUNGE**: MOVE (RFC 6851) es atomico; el fallback COPY+STORE
|
||||
\\Deleted+EXPUNGE no lo es — si falla entre pasos, el mensaje puede quedar
|
||||
duplicado (en origen y destino) o marcado \\Deleted sin expurgar. El EXPUNGE del
|
||||
fallback materializa los borrados pendientes del mailbox origen completo.
|
||||
- **UID estable, no secuencia**: se usa siempre `conn.uid(...)`. El UID es estable
|
||||
dentro del mailbox mientras no cambie la UIDVALIDITY; el numero de secuencia se
|
||||
desplaza al borrar mensajes y por eso nunca se usa.
|
||||
- **Nunca lanza**: cualquier fallo (destino inexistente, conexion caida, respuesta
|
||||
no-OK) vuelve como `{status:'error', error:str}`.
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Mueve un mensaje IMAP a otro mailbox por UID, con fallback COPY+EXPUNGE.
|
||||
|
||||
Funcion IMPURA: traslada un mensaje (identificado por su UID) del mailbox
|
||||
seleccionado a `dest_mailbox` sobre una conexion `imaplib.IMAP4_SSL` ya
|
||||
autenticada. Intenta primero el comando UID MOVE (RFC 6851, soportado por Gmail
|
||||
y la mayoria de servidores modernos): es atomico y eficiente. Si el servidor NO
|
||||
anuncia la capacidad MOVE, cae a la secuencia clasica equivalente: UID COPY al
|
||||
destino, STORE +FLAGS (\\Deleted) en el origen y EXPUNGE para materializar el
|
||||
borrado del origen.
|
||||
|
||||
Nunca lanza: devuelve un dict con `status` ("ok"/"error") y `method` para indicar
|
||||
que camino se uso. No abre la conexion ni resuelve credenciales: el caller pasa
|
||||
`conn` ya conectado, autenticado y con `conn.select("<mailbox origen>")` hecho.
|
||||
"""
|
||||
|
||||
|
||||
def _server_supports_move(conn) -> bool:
|
||||
"""Devuelve True si la conexion anuncia la capability MOVE (RFC 6851).
|
||||
|
||||
Inspecciona ``conn.capabilities`` (tupla de capacidades que imaplib cachea
|
||||
tras el login). La comparacion es case-insensitive porque distintos servidores
|
||||
devuelven "MOVE" en mayusculas/minusculas. Cualquier problema accediendo a las
|
||||
capacidades se trata como "no soportado" para forzar el fallback seguro.
|
||||
"""
|
||||
try:
|
||||
caps = getattr(conn, "capabilities", ()) or ()
|
||||
return any(str(c).upper() == "MOVE" for c in caps)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def imap_move_message(conn, uid: int, dest_mailbox: str) -> dict:
|
||||
"""Mueve el mensaje `uid` del mailbox seleccionado a `dest_mailbox`.
|
||||
|
||||
Camino preferido (``method="move"``): ``conn.uid("MOVE", str(uid),
|
||||
dest_mailbox)``, atomico. Si el servidor no soporta MOVE (no esta en
|
||||
``conn.capabilities``) o el comando MOVE falla, se usa el fallback
|
||||
(``method="copy_delete"``): ``UID COPY`` al destino, ``UID STORE +FLAGS
|
||||
(\\Deleted)`` en el origen y ``EXPUNGE``.
|
||||
|
||||
Args:
|
||||
conn: objeto ``imaplib.IMAP4_SSL`` (o ``IMAP4``) YA conectado,
|
||||
autenticado y con el mailbox ORIGEN seleccionado (``conn.select(...)``).
|
||||
uid: UID del mensaje en el mailbox origen. Operacion siempre por UID, no
|
||||
por numero de secuencia.
|
||||
dest_mailbox: nombre del mailbox destino. En Gmail los nombres llevan el
|
||||
prefijo ``[Gmail]/`` (ej. ``"[Gmail]/Trash"`` para la papelera,
|
||||
``"[Gmail]/Spam"``). Una carpeta de usuario es simplemente su nombre.
|
||||
|
||||
Returns:
|
||||
dict. En exito: ``{"status": "ok", "uid": uid, "dest": dest_mailbox,
|
||||
"method": "move" | "copy_delete"}``. En fallo (sin lanzar):
|
||||
``{"status": "error", "error": str}``.
|
||||
"""
|
||||
try:
|
||||
# Camino 1: UID MOVE (atomico) si el servidor lo anuncia.
|
||||
if _server_supports_move(conn):
|
||||
try:
|
||||
typ, data = conn.uid("MOVE", str(uid), dest_mailbox)
|
||||
if typ == "OK":
|
||||
return {
|
||||
"status": "ok",
|
||||
"uid": uid,
|
||||
"dest": dest_mailbox,
|
||||
"method": "move",
|
||||
}
|
||||
# MOVE anunciado pero rechazado: caemos al fallback.
|
||||
except Exception: # noqa: BLE001
|
||||
# MOVE no soportado en la practica pese a la capability: fallback.
|
||||
pass
|
||||
|
||||
# Camino 2 (fallback): COPY al destino + marcar \\Deleted en origen + EXPUNGE.
|
||||
typ, data = conn.uid("COPY", str(uid), dest_mailbox)
|
||||
if typ != "OK":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_move_message: COPY a {dest_mailbox!r} devolvio {typ!r}: {data!r}",
|
||||
}
|
||||
|
||||
typ, data = conn.uid("STORE", str(uid), "+FLAGS", "(\\Deleted)")
|
||||
if typ != "OK":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_move_message: STORE \\Deleted devolvio {typ!r}: {data!r}",
|
||||
}
|
||||
|
||||
conn.expunge()
|
||||
return {
|
||||
"status": "ok",
|
||||
"uid": uid,
|
||||
"dest": dest_mailbox,
|
||||
"method": "copy_delete",
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("imap_move_message: importable. Uso real requiere un conn IMAP autenticado.")
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: imap_save_draft
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def imap_save_draft(conn, raw_rfc822: bytes, mailbox: str = '[Gmail]/Drafts', flags: str = '\\Draft') -> dict"
|
||||
description: "Guarda un borrador en un mailbox via IMAP APPEND sobre una conexion imaplib.IMAP4_SSL ya autenticada. Ejecuta conn.append(mailbox, flags, imaplib.Time2Internaldate(time.time()), raw_rfc822): raw_rfc822 son los bytes MIME ya serializados de un email completo (cabeceras + cuerpo) que el caller arma con email.message.EmailMessage().as_bytes() (stdlib) o con las funciones email_build_*_py_infra del registry + serializacion. A diferencia de las demas operaciones del grupo, APPEND NO requiere un mailbox seleccionado: el destino es el argumento mailbox (default '[Gmail]/Drafts', con su prefijo [Gmail]/). flags default '\\Draft' para que el cliente lo trate como borrador. Valida que raw_rfc822 sean bytes. No abre la conexion ni resuelve credenciales. Nunca lanza: devuelve {status:'ok', mailbox} o {status:'error', error}; tambien error si APPEND responde un typ distinto de OK. Parte del grupo email/imap. Solo stdlib (imaplib, time, email.message para construir el MIME)."
|
||||
tags: [email, imap, mail, draft, append, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: [imaplib, time]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "objeto imaplib.IMAP4_SSL (o IMAP4) YA conectado y autenticado. Normalmente lo produce imap_connect. APPEND no requiere mailbox seleccionado: el destino se pasa en el argumento mailbox. La funcion no abre ni cierra conn."
|
||||
- name: raw_rfc822
|
||||
desc: "bytes MIME ya serializados de un email completo (cabeceras From/To/Subject + cuerpo). El caller los construye con email.message.EmailMessage(...).as_bytes() (stdlib) o con email_build_*_py_infra del registry + serializacion. Debe ser bytes; un str devuelve {status:'error'}."
|
||||
- name: mailbox
|
||||
desc: "mailbox destino del borrador. Default '[Gmail]/Drafts' (carpeta de borradores de Gmail, con prefijo [Gmail]/). En otros servidores suele ser 'Drafts'. Debe existir en el servidor."
|
||||
- name: flags
|
||||
desc: "banderas IMAP a poner al mensaje, como string separado por espacios. Default '\\Draft' para marcarlo como borrador. Combinable, p.ej. '\\Draft \\Seen'."
|
||||
output: "dict. En exito: {status:'ok', mailbox:str} reflejando el mailbox donde se guardo el borrador. En error (sin lanzar): {status:'error', error:str}, p.ej. si raw_rfc822 no son bytes o si APPEND responde un typ distinto de OK."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/imap_save_draft.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, "python/functions")
|
||||
from email.message import EmailMessage
|
||||
from infra.imap_connect import imap_connect
|
||||
from infra.imap_save_draft import imap_save_draft
|
||||
|
||||
# conn ya conectado y autenticado (lo produce imap_connect). APPEND no necesita select().
|
||||
conn = imap_connect(...)["conn"] # firma exacta la define imap_connect
|
||||
|
||||
# Armar un borrador minimo con stdlib y serializarlo a bytes MIME.
|
||||
msg = EmailMessage()
|
||||
msg["From"] = "yo@example.com"
|
||||
msg["To"] = "destinatario@example.com"
|
||||
msg["Subject"] = "Propuesta (borrador)"
|
||||
msg.set_content("Hola, este es un borrador guardado por IMAP APPEND.")
|
||||
raw = msg.as_bytes()
|
||||
|
||||
# Guardarlo en la carpeta de borradores de Gmail (nota el prefijo [Gmail]/).
|
||||
print(imap_save_draft(conn, raw, mailbox="[Gmail]/Drafts"))
|
||||
# {'status': 'ok', 'mailbox': '[Gmail]/Drafts'}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando quieres dejar un correo a medio escribir guardado en el servidor (no
|
||||
enviarlo) para retomarlo desde cualquier cliente: el clasico "guardar borrador".
|
||||
Util para flujos donde un agente prepara una respuesta y la deja en Drafts para
|
||||
que el humano la revise y envie. Tambien sirve para archivar copias arbitrarias de
|
||||
mensajes en un mailbox (cambiando `flags`). El envio real es otra cosa: para
|
||||
enviar usa `smtp_send_py_infra`. Compone con `email_build_*_py_infra` (que
|
||||
producen el EmailMessage) + serializacion a bytes.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: escribe un mensaje nuevo en el servidor (consume cuota de la cuenta).
|
||||
- **raw_rfc822 son BYTES, no str**: el mensaje MIME debe estar ya serializado a
|
||||
`bytes` (`EmailMessage().as_bytes()`). Pasar un `str` devuelve `{status:'error'}`.
|
||||
La funcion no construye el MIME: solo lo deposita.
|
||||
- **APPEND no usa mailbox seleccionado**: a diferencia de STORE/COPY/EXPUNGE, el
|
||||
destino de APPEND es el argumento `mailbox`, no el mailbox que el caller
|
||||
selecciono. No hace falta `conn.select(...)` previo.
|
||||
- **Prefijo [Gmail]/ y existencia**: en Gmail la carpeta de borradores es
|
||||
`[Gmail]/Drafts` (con prefijo). En otros servidores suele ser `Drafts`. El
|
||||
mailbox destino debe existir; si no, APPEND falla y se devuelve error.
|
||||
- **No hay UID estable garantizado en la respuesta**: APPEND crea un mensaje nuevo;
|
||||
algunos servidores devuelven su UID (APPENDUID) y otros no. Esta funcion no lo
|
||||
parsea — devuelve solo `{status, mailbox}`. Si necesitas el UID del borrador,
|
||||
busca despues con `imap_search`.
|
||||
- **flags con backslash**: las banderas del sistema llevan barra invertida
|
||||
(`\\Draft`, `\\Seen`). En el string Python recuerda escaparla (`"\\Draft"`).
|
||||
- **Nunca lanza**: cualquier fallo (mailbox inexistente, conexion caida, bytes
|
||||
invalidos, respuesta no-OK) vuelve como `{status:'error', error:str}`.
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Guarda un borrador (mensaje RFC822 ya serializado) en un mailbox via IMAP APPEND.
|
||||
|
||||
Funcion IMPURA: usa el comando IMAP APPEND para anadir un mensaje completo (bytes
|
||||
MIME ya serializados) a un mailbox de una conexion `imaplib.IMAP4_SSL` ya
|
||||
autenticada, marcandolo con las banderas indicadas (por defecto `\\Draft`). No
|
||||
construye el MIME ni resuelve credenciales: el caller arma los bytes con stdlib
|
||||
(`email.message.EmailMessage().as_bytes()`) o con las funciones email_build_*
|
||||
del registry + serializacion, y pasa una conexion ya lista.
|
||||
|
||||
A diferencia de las otras operaciones del grupo, APPEND no necesita un mailbox
|
||||
seleccionado: el destino se indica como argumento. La marca de tiempo interna del
|
||||
mensaje se fija con `imaplib.Time2Internaldate(time.time())`.
|
||||
|
||||
Nunca lanza: devuelve un dict con `status` ("ok"/"error").
|
||||
"""
|
||||
|
||||
import imaplib
|
||||
import time
|
||||
|
||||
|
||||
def imap_save_draft(
|
||||
conn,
|
||||
raw_rfc822: bytes,
|
||||
mailbox: str = "[Gmail]/Drafts",
|
||||
flags: str = "\\Draft",
|
||||
) -> dict:
|
||||
"""Guarda `raw_rfc822` como borrador en `mailbox` via IMAP APPEND.
|
||||
|
||||
Ejecuta ``conn.append(mailbox, flags, imaplib.Time2Internaldate(time.time()),
|
||||
raw_rfc822)``. Los bytes deben ser un mensaje RFC822/MIME completo (cabeceras
|
||||
+ cuerpo) ya serializado por el caller.
|
||||
|
||||
Args:
|
||||
conn: objeto ``imaplib.IMAP4_SSL`` (o ``IMAP4``) YA conectado y
|
||||
autenticado. APPEND no requiere mailbox seleccionado (el destino es
|
||||
``mailbox``).
|
||||
raw_rfc822: bytes MIME ya serializados del email. El caller los construye
|
||||
con ``email.message.EmailMessage(...).as_bytes()`` (stdlib) o con las
|
||||
funciones ``email_build_*_py_infra`` del registry + serializacion.
|
||||
Deben ser ``bytes``, no ``str``.
|
||||
mailbox: mailbox destino del borrador. Default ``"[Gmail]/Drafts"`` (la
|
||||
carpeta de borradores de Gmail, con su prefijo ``[Gmail]/``). En otros
|
||||
servidores suele ser ``"Drafts"``.
|
||||
flags: banderas IMAP a poner al mensaje, como string entre las que separa
|
||||
espacios. Default ``"\\Draft"`` para que el cliente lo trate como
|
||||
borrador. Se puede combinar, p.ej. ``"\\Draft \\Seen"``.
|
||||
|
||||
Returns:
|
||||
dict. En exito: ``{"status": "ok", "mailbox": mailbox}``. En fallo (sin
|
||||
lanzar): ``{"status": "error", "error": str}``. Tambien error si APPEND
|
||||
responde algo distinto de ``OK`` o si ``raw_rfc822`` no son bytes.
|
||||
"""
|
||||
try:
|
||||
if not isinstance(raw_rfc822, (bytes, bytearray)):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"imap_save_draft: raw_rfc822 debe ser bytes MIME ya "
|
||||
"serializados (usa EmailMessage().as_bytes()), no "
|
||||
f"{type(raw_rfc822).__name__}"
|
||||
),
|
||||
}
|
||||
date_time = imaplib.Time2Internaldate(time.time())
|
||||
typ, data = conn.append(mailbox, flags, date_time, bytes(raw_rfc822))
|
||||
if typ != "OK":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_save_draft: APPEND a {mailbox!r} devolvio {typ!r}: {data!r}",
|
||||
}
|
||||
return {"status": "ok", "mailbox": mailbox}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("imap_save_draft: importable. Uso real requiere un conn IMAP autenticado.")
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: imap_search
|
||||
kind: function
|
||||
lang: py
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def imap_search(conn, criteria: str = 'UNSEEN', mailbox: str = '') -> dict"
|
||||
description: "Busca mensajes en un buzon IMAP por criterio y devuelve sus UIDs. Sobre una conexion imaplib viva (de imap_connect), opcionalmente hace select(mailbox) y luego conn.uid('SEARCH', None, criteria). Usa SIEMPRE UIDs (estables mientras no cambie UIDVALIDITY), no numeros de secuencia (que se renumeran al borrar). criteria es una expresion IMAP cruda RFC 3501 (UNSEEN, ALL, FROM x, SUBJECT y, SINCE 01-Jan-2026, combinaciones). Devuelve {status:'ok', uids:[int], count} o {status:'error', error}. Nunca lanza."
|
||||
tags: [email, imap, infra, search, network]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "Objeto imaplib.IMAP4[_SSL] vivo y autenticado, producido por imap_connect. None devuelve status error."
|
||||
- name: criteria
|
||||
desc: "Expresion de busqueda IMAP cruda (RFC 3501 SEARCH). Ej: 'UNSEEN', 'ALL', 'FROM foo@bar.com', 'SUBJECT factura', 'SINCE 01-Jan-2026', 'UNSEEN SINCE 01-Jun-2026'. Vacio devuelve status error. Default 'UNSEEN'."
|
||||
- name: mailbox
|
||||
desc: "Si no esta vacio, se hace select(mailbox) antes de buscar (ej. '[Gmail]/Sent Mail'). Vacio (default) usa el buzon ya seleccionado."
|
||||
output: "dict de estado. En exito {status:'ok', uids: list[int], count: int}: uids son UIDs (no numeros de secuencia), ordenados como los devuelve el servidor; lista vacia si nada casa (sigue siendo status ok). En fallo (conn None, criteria vacio o mal formado, buzon inexistente) {status:'error', error: str}."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_py_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/infra/imap_search.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join("python", "functions"))
|
||||
from infra import imap_connect, imap_search, imap_fetch_message
|
||||
|
||||
c = imap_connect("imap.gmail.com", 993, "gutierenmanuel15@gmail.com", "abcd efgh ijkl mnop")
|
||||
conn = c["conn"]
|
||||
|
||||
# No leidos del INBOX (buzon ya seleccionado por imap_connect)
|
||||
res = imap_search(conn, criteria="UNSEEN")
|
||||
print(res["status"], res["count"]) # "ok" 7
|
||||
print(res["uids"]) # [1422, 1425, 1431, ...]
|
||||
|
||||
# Buscar en otra carpeta sin reconectar
|
||||
sent = imap_search(conn, criteria="SINCE 01-Jun-2026", mailbox="[Gmail]/Sent Mail")
|
||||
|
||||
# Fetch del primer UID encontrado
|
||||
if res["uids"]:
|
||||
msg = imap_fetch_message(conn, res["uids"][0])
|
||||
print(msg["message"]["subject"])
|
||||
|
||||
conn.logout()
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala tras `imap_connect` cuando necesites localizar mensajes por criterio
|
||||
(no leidos, de un remitente, por asunto, por fecha) antes de leerlos con
|
||||
`imap_fetch_message`. Es el paso intermedio del flujo lectura: connect -> search
|
||||
-> fetch. Para barrer una carpeta distinta del INBOX pasa `mailbox` y evita
|
||||
una reconexion.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Funcion impura: hace red sobre el `conn` vivo. Nunca lanza: comprueba `status`.
|
||||
El `conn` lo provee `imap_connect` (no resuelve credenciales).
|
||||
- Devuelve UIDs, NO numeros de secuencia. Importante: guarda y reutiliza los
|
||||
UIDs; los seq cambian cuando se borran mensajes, los UIDs no (salvo cambio de
|
||||
UIDVALIDITY del buzon, raro). `imap_fetch_message` tambien espera UID.
|
||||
- El `criteria` es sintaxis IMAP cruda, sin validar: un criterio mal formado
|
||||
hace que el servidor responda no-OK y devuelve `status:'error'`. Las fechas
|
||||
van en formato IMAP `DD-Mon-YYYY` (ej. `01-Jan-2026`), no ISO.
|
||||
- `SEARCH` por defecto opera sobre US-ASCII; para acentos en `SUBJECT`/`FROM`
|
||||
algunos servidores requieren `CHARSET UTF-8` (este wrapper pasa `None` como
|
||||
charset, que cubre el caso comun). Si necesitas charset, busca por cabeceras
|
||||
ASCII o filtra el resultado en cliente.
|
||||
- Una busqueda sin coincidencias devuelve `status:'ok'` con `uids:[]` y
|
||||
`count:0` — distingue "sin resultados" mirando `count`, no `status`.
|
||||
- Cierra con `conn.logout()` al terminar (responsabilidad del caller).
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Busca mensajes en un buzon IMAP por criterio y devuelve sus UIDs.
|
||||
|
||||
Funcion IMPURA: hace I/O de red sobre una conexion `imaplib` viva (la produce
|
||||
`imap_connect`). Opcionalmente cambia de buzon con `select(mailbox)` y luego
|
||||
ejecuta `conn.uid("SEARCH", None, criteria)`.
|
||||
|
||||
Usa SIEMPRE UIDs (Unique IDentifiers), no numeros de secuencia: los UID son
|
||||
estables dentro de un buzon mientras no cambie el UIDVALIDITY, mientras que los
|
||||
numeros de secuencia se renumeran cuando se borran mensajes. Asi un UID
|
||||
guardado sigue apuntando al mismo mensaje en una sesion posterior.
|
||||
|
||||
NUNCA lanza: devuelve un dict con `status` ("ok"/"error").
|
||||
"""
|
||||
|
||||
|
||||
def imap_search(conn, criteria: str = "UNSEEN", mailbox: str = "") -> dict:
|
||||
"""Busca mensajes y devuelve la lista de UIDs que casan el criterio.
|
||||
|
||||
Si `mailbox` no esta vacio, hace `conn.select(mailbox)` antes de buscar.
|
||||
Luego ejecuta `conn.uid("SEARCH", None, criteria)` y parsea la respuesta a
|
||||
una lista de enteros (UIDs).
|
||||
|
||||
Args:
|
||||
conn: objeto `imaplib.IMAP4[_SSL]` vivo y autenticado (de `imap_connect`).
|
||||
criteria: expresion de busqueda IMAP cruda (RFC 3501 SEARCH). Ejemplos:
|
||||
``"UNSEEN"`` (no leidos), ``"ALL"`` (todos),
|
||||
``"FROM foo@bar.com"``, ``"SUBJECT factura"``,
|
||||
``"SINCE 01-Jan-2026"``, ``"UNSEEN SINCE 01-Jun-2026"``,
|
||||
``'HEADER Message-ID "<id@host>"'``.
|
||||
mailbox: si no esta vacio, se selecciona ese buzon antes de buscar
|
||||
(ej. ``"[Gmail]/Sent Mail"``). Vacio usa el buzon ya seleccionado.
|
||||
|
||||
Returns:
|
||||
Dict de estado. En exito::
|
||||
|
||||
{"status": "ok", "uids": [123, 456, ...], "count": <int>}
|
||||
|
||||
En fallo (conn invalido, criterio mal formado, buzon inexistente)::
|
||||
|
||||
{"status": "error", "error": <str>}
|
||||
"""
|
||||
if conn is None:
|
||||
return {"status": "error", "error": "imap_search: conn es None"}
|
||||
if not criteria or not str(criteria).strip():
|
||||
return {"status": "error", "error": "imap_search: criteria vacio"}
|
||||
|
||||
criteria = str(criteria).strip()
|
||||
try:
|
||||
if mailbox:
|
||||
typ, data = conn.select(mailbox)
|
||||
if typ != "OK":
|
||||
reason = _first_str(data)
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_search: SELECT {mailbox!r} fallo: {reason}",
|
||||
}
|
||||
|
||||
typ, data = conn.uid("SEARCH", None, criteria)
|
||||
if typ != "OK":
|
||||
reason = _first_str(data)
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"imap_search: SEARCH {criteria!r} devolvio {typ}: {reason}",
|
||||
}
|
||||
|
||||
uids = _parse_uids(data)
|
||||
return {"status": "ok", "uids": uids, "count": len(uids)}
|
||||
except Exception as exc: # noqa: BLE001 — contrato: nunca lanzar.
|
||||
return {"status": "error", "error": f"imap_search: {exc}"}
|
||||
|
||||
|
||||
def _first_str(data) -> str:
|
||||
"""Devuelve el primer elemento de una respuesta imaplib como str legible."""
|
||||
if not data:
|
||||
return ""
|
||||
item = data[0]
|
||||
if isinstance(item, bytes):
|
||||
return item.decode("utf-8", errors="replace")
|
||||
return str(item)
|
||||
|
||||
|
||||
def _parse_uids(data) -> list:
|
||||
"""Parsea la respuesta de SEARCH (lista con un bytes de UIDs separados por espacio)."""
|
||||
uids: list[int] = []
|
||||
if not data:
|
||||
return uids
|
||||
for chunk in data:
|
||||
if chunk is None:
|
||||
continue
|
||||
if isinstance(chunk, bytes):
|
||||
text = chunk.decode("ascii", errors="replace")
|
||||
else:
|
||||
text = str(chunk)
|
||||
for token in text.split():
|
||||
try:
|
||||
uids.append(int(token))
|
||||
except ValueError:
|
||||
# Token no numerico (raro): lo ignoramos.
|
||||
continue
|
||||
return uids
|
||||
Reference in New Issue
Block a user