"""Cliente IMAP de bajo nivel usado por el migrador. Se apoya únicamente en ``imaplib`` (librería estándar). Todas las operaciones que pueden fallar por problemas de red pasan por :meth:`ImapClient.with_retry`, que reintenta y reconecta automáticamente. """ from __future__ import annotations import dataclasses import imaplib import re import socket import ssl import time import utf7 class ImapClientError(Exception): pass class ImapConnectionError(ImapClientError): pass @dataclasses.dataclass class FolderInfo: name: str delimiter: str flags: str def build_ssl_context(verify: bool) -> ssl.SSLContext: ctx = ssl.create_default_context() if not verify: ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE return ctx class _IMAP4SSLCustom(imaplib.IMAP4_SSL): """IMAP4_SSL que permite conectar a una IP validando el certificado contra un hostname distinto.""" def __init__(self, connect_host, tls_hostname, port, ssl_context, timeout): self._tls_hostname = tls_hostname super().__init__(host=connect_host, port=port, ssl_context=ssl_context, timeout=timeout) def _create_socket(self, timeout): address = (self.host, self.port) if timeout is not None: sock = socket.create_connection(address, timeout) else: sock = socket.create_connection(address) return self.ssl_context.wrap_socket(sock, server_hostname=self._tls_hostname) _LIST_RE = re.compile(r'^\((?P[^)]*)\)\s+(?:"(?P[^"]*)"|NIL)\s+(?P.+)$') _FETCH_META_RE = re.compile( r"UID (?P\d+)" r"|RFC822\.SIZE (?P\d+)" r'|INTERNALDATE "(?P[^"]*)"' r"|FLAGS \((?P[^)]*)\)" ) _MESSAGE_ID_RE = re.compile(rb"^Message-ID:\s*(.+)$", re.IGNORECASE | re.MULTILINE) class ImapClient: def __init__(self, cfg, timeout: int, logger, retries: int = 3): self.cfg = cfg self.timeout = timeout self.logger = logger self.retries = max(1, retries) self.conn = None self._selected_folder = None # -- conexión ----------------------------------------------------- def connect(self) -> None: connect_host = self.cfg.connect_host tls_hostname = self.cfg.tls_hostname if self.cfg.use_ssl: ctx = build_ssl_context(self.cfg.verify_tls) if not self.cfg.verify_tls: self.logger.warning( f"[{self.cfg.label}] Verificación de certificado TLS DESHABILITADA para " f"{self.cfg.host}. Esto es inseguro y solo debe usarse con servidores de confianza." ) self.conn = _IMAP4SSLCustom(connect_host, tls_hostname, self.cfg.port, ctx, self.timeout) else: self.logger.warning( f"[{self.cfg.label}] Conexión SIN SSL/TLS a {self.cfg.host}:{self.cfg.port}. " "Las credenciales y los datos viajarán sin cifrar." ) self.conn = imaplib.IMAP4(connect_host, self.cfg.port, timeout=self.timeout) self.conn.login(self.cfg.user, self.cfg.password) self._selected_folder = None self.logger.info(f"[{self.cfg.label}] Conectado y autenticado como {self.cfg.user} en {self.cfg.host}:{self.cfg.port}") def close(self) -> None: if self.conn is not None: try: self.conn.logout() except Exception: pass self.conn = None def _reconnect(self) -> None: self.logger.info(f"[{self.cfg.label}] Reintentando conexión a {self.cfg.host}...") try: if self.conn is not None: self.conn.shutdown() if hasattr(self.conn, "shutdown") else None except Exception: pass self.conn = None self.connect() def with_retry(self, func, *args, **kwargs): last_exc = None for attempt in range(1, self.retries + 1): try: return func(*args, **kwargs) except (imaplib.IMAP4.abort, imaplib.IMAP4.error, OSError, ssl.SSLError, EOFError) as exc: last_exc = exc self.logger.warning(f"[{self.cfg.label}] Error IMAP (intento {attempt}/{self.retries}): {exc}") if attempt < self.retries: try: self._reconnect() if self._selected_folder: self.select(self._selected_folder, readonly=True) except Exception as reconnect_exc: last_exc = reconnect_exc self.logger.warning(f"[{self.cfg.label}] Fallo al reconectar: {reconnect_exc}") time.sleep(min(2 ** attempt, 30)) raise ImapConnectionError(f"[{self.cfg.label}] Fallo tras {self.retries} intento(s): {last_exc}") @staticmethod def _quote(s: str) -> str: escaped = s.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' # -- carpetas ------------------------------------------------------- def list_folders(self) -> list[FolderInfo]: def _do(): typ, data = self.conn.list() if typ != "OK": raise ImapClientError(f"LIST falló: {data}") folders = [] for line in data: if line is None: continue decoded = line.decode("utf-8", "replace") if isinstance(line, bytes) else line m = _LIST_RE.match(decoded.strip()) if not m: continue flags = m.group("flags") or "" delim = m.group("delim") or "/" raw_name = m.group("name").strip() if raw_name.startswith('"') and raw_name.endswith('"'): raw_name = raw_name[1:-1].replace('\\"', '"').replace("\\\\", "\\") try: name = utf7.decode(raw_name.encode("ascii")) except Exception: name = raw_name folders.append(FolderInfo(name=name, delimiter=delim, flags=flags)) return folders return self.with_retry(_do) def create_folder(self, name: str) -> None: def _do(): encoded = utf7.encode(name).decode("ascii") typ, data = self.conn.create(self._quote(encoded)) if typ != "OK": msg = b" ".join(x for x in data if x).decode("utf-8", "replace") if data else "" if "exist" not in msg.lower() and "duplicate" not in msg.lower(): raise ImapClientError(f"No se pudo crear la carpeta {name!r}: {msg}") return self.with_retry(_do) def select(self, name: str, readonly: bool = True) -> int: def _do(): encoded = utf7.encode(name).decode("ascii") typ, data = self.conn.select(self._quote(encoded), readonly=readonly) if typ != "OK": raise ImapClientError(f"No se pudo seleccionar la carpeta {name!r}: {data}") self._selected_folder = name return int(data[0]) if data and data[0] else 0 return self.with_retry(_do) def get_uidvalidity(self, name: str): def _do(): encoded = utf7.encode(name).decode("ascii") typ, data = self.conn.status(self._quote(encoded), "(UIDVALIDITY)") if typ != "OK" or not data or data[0] is None: return None m = re.search(rb"UIDVALIDITY (\d+)", data[0]) return int(m.group(1)) if m else None return self.with_retry(_do) # -- mensajes --------------------------------------------------------- def search_uids(self, since_uid: int | None = None) -> list[int]: def _do(): if since_uid: typ, data = self.conn.uid("search", None, "UID", f"{since_uid}:*") else: typ, data = self.conn.uid("search", None, "ALL") if typ != "OK": raise ImapClientError(f"UID SEARCH falló: {data}") if not data or not data[0]: return [] uids = [int(x) for x in data[0].split()] if since_uid: uids = [u for u in uids if u >= since_uid] return uids return self.with_retry(_do) def fetch_headers(self, uids: list[int], batch_size: int = 500) -> dict[int, dict]: results: dict[int, dict] = {} for i in range(0, len(uids), batch_size): batch = uids[i:i + batch_size] uid_set = ",".join(str(u) for u in batch) def _do(uid_set=uid_set): typ, data = self.conn.uid( "fetch", uid_set, "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])", ) if typ != "OK": raise ImapClientError(f"FETCH de encabezados falló: {data}") return data data = self.with_retry(_do) idx = 0 while idx < len(data): item = data[idx] if isinstance(item, tuple): meta_bytes, literal = item meta = meta_bytes.decode("utf-8", "replace") info: dict = {} for m in _FETCH_META_RE.finditer(meta): if m.group("uid"): info["uid"] = int(m.group("uid")) elif m.group("size"): info["size"] = int(m.group("size")) elif m.group("idate") is not None: info["internaldate_raw"] = m.group("idate") elif m.group("flags") is not None: info["flags"] = m.group("flags") mid_match = _MESSAGE_ID_RE.search(literal or b"") info["message_id"] = mid_match.group(1).decode("utf-8", "replace").strip() if mid_match else None if "uid" in info: results[info["uid"]] = info idx += 1 if idx < len(data) and isinstance(data[idx], (bytes, bytearray)): idx += 1 else: idx += 1 return results def fetch_full(self, uid: int): def _do(): typ, data = self.conn.uid("fetch", str(uid), "(FLAGS INTERNALDATE RFC822)") if typ != "OK" or not data or data[0] is None: raise ImapClientError(f"FETCH RFC822 falló para UID {uid}: {data}") meta_bytes, literal = data[0] meta = meta_bytes.decode("utf-8", "replace") flags_m = re.search(r"FLAGS \(([^)]*)\)", meta) idate_m = re.search(r'INTERNALDATE "([^"]*)"', meta) flags = flags_m.group(1) if flags_m else "" internaldate_raw = idate_m.group(1) if idate_m else None return literal, flags, internaldate_raw return self.with_retry(_do) def append(self, folder: str, message_bytes: bytes, flags: str, internaldate_raw: str | None): def _do(): encoded = utf7.encode(folder).decode("ascii") flags_str = f"({flags})" if flags else None date_arg = f'"{internaldate_raw}"' if internaldate_raw else None typ, data = self.conn.append(self._quote(encoded), flags_str, date_arg, message_bytes) if typ != "OK": raise ImapClientError(f"APPEND falló en {folder!r}: {data}") uid = None if data and data[0]: m = re.search(rb"APPENDUID \d+ (\d+)", data[0]) if m: uid = int(m.group(1)) return uid return self.with_retry(_do)