From 6e11482d36065779b518b5bdf6714bf99fea9458 Mon Sep 17 00:00:00 2001 From: lastdragon Date: Thu, 10 Sep 2026 09:24:22 -0600 Subject: [PATCH] Commit inicial --- Leame.txt | 254 ++++++++++++++++++++++++++++++++++++ README.md | 255 +++++++++++++++++++++++++++++++++++- config.py | 159 +++++++++++++++++++++++ fingerprint.py | 17 +++ imap_client.py | 298 +++++++++++++++++++++++++++++++++++++++++++ imap_migrate.py | 52 ++++++++ logger.py | 27 ++++ logs/.gitkeep | 0 migration-state.json | 0 migrator.py | 263 ++++++++++++++++++++++++++++++++++++++ progress.py | 156 ++++++++++++++++++++++ requirements.txt | 7 + state.py | 87 +++++++++++++ utf7.py | 60 +++++++++ 14 files changed, 1634 insertions(+), 1 deletion(-) create mode 100644 Leame.txt create mode 100644 config.py create mode 100644 fingerprint.py create mode 100644 imap_client.py create mode 100644 imap_migrate.py create mode 100644 logger.py create mode 100644 logs/.gitkeep create mode 100644 migration-state.json create mode 100644 migrator.py create mode 100644 progress.py create mode 100644 requirements.txt create mode 100644 state.py create mode 100644 utf7.py diff --git a/Leame.txt b/Leame.txt new file mode 100644 index 0000000..6a3cb14 --- /dev/null +++ b/Leame.txt @@ -0,0 +1,254 @@ +==================================================================== + Migrador y respaldador de cuentas IMAP +==================================================================== + +Este programa copia o migra el contenido completo de una cuenta de +correo IMAP (carpetas, mensajes, adjuntos, flags y fechas) desde un +servidor origen (A) hacia un servidor destino (B). + +Puede usarse tanto para migrar una cuenta a un servidor nuevo como +para mantener un respaldo periódico de una cuenta en otro servidor. + +El programa NUNCA borra, mueve ni modifica mensajes en el servidor +origen. + +-------------------------------------------------------------------- +1. Requisitos +-------------------------------------------------------------------- + +- Python 3.10 o superior. +- No se requieren dependencias externas (solo librería estándar). + +-------------------------------------------------------------------- +2. Creación del entorno virtual +-------------------------------------------------------------------- + +Linux / macOS: + + python3 -m venv python-env + source python-env/bin/activate + +Windows: + + python -m venv python-env + python-env\Scripts\activate + +-------------------------------------------------------------------- +3. Instalación de dependencias +-------------------------------------------------------------------- + + pip install --upgrade pip + pip install -r requirements.txt + +(requirements.txt no declara dependencias externas: todo el programa +usa la librería estándar de Python.) + +-------------------------------------------------------------------- +4. Ejecución +-------------------------------------------------------------------- + +Ejemplo básico (las contraseñas se piden de forma interactiva si se +omiten): + + python3 imap_migrate.py \ + --aserver mail.oldserver.com \ + --auser usuario@dominio.com \ + --bserver mail.newserver.com \ + --buser usuario@dominio.com + +Ejemplo completo, con contraseñas por parámetro: + + python3 imap_migrate.py \ + --aserver imap.servidor-a.com --aport 993 \ + --auser usuario@dominio.com --apassword 'passwordA' \ + --bserver imap.servidor-b.com --bport 993 \ + --buser usuario@dominio.com --bpassword 'passwordB' + +Ejemplo usando una IP directa pero validando el certificado TLS +contra el nombre de host real (útil cuando el DNS no resuelve o se +quiere evitar la resolución DNS): + + python3 imap_migrate.py \ + --aserver mail.oldserver.com --aip 203.0.113.10 \ + --auser usuario@dominio.com \ + --bserver mail.newserver.com \ + --buser usuario@dominio.com + +Ejemplo con servidor destino de certificado autofirmado (ver sección +6 para la advertencia de seguridad): + + python3 imap_migrate.py \ + --aserver mail.oldserver.com --auser usuario@dominio.com \ + --bserver mail.interno.local --buser usuario@dominio.com \ + --b-no-verify-tls + +-------------------------------------------------------------------- +5. Parámetros disponibles +-------------------------------------------------------------------- + +Servidor origen (A): + --aserver Host del servidor IMAP origen (obligatorio) + --aport Puerto (por defecto 993) + --auser Usuario/cuenta en el servidor origen (obligatorio) + --apassword Password (si se omite, se solicita interactivamente + o se toma de la variable de entorno IMAP_A_PASSWORD) + --adomain Dominio lógico de la cuenta (informativo / uso + futuro para migración de múltiples cuentas) + --aip IP a la que conectar, usando --aserver como + nombre de host para la validación TLS + --a-no-verify-tls Deshabilita la verificación de certificado TLS + SOLO para el servidor origen (inseguro) + --a-no-ssl Conecta sin SSL/TLS al servidor origen (inseguro) + +Servidor destino (B): + --bserver, --bport, --buser, --bpassword, --bdomain, --bip, + --b-no-verify-tls, --b-no-ssl + Mismo significado que los anteriores, aplicados al servidor + destino. La contraseña también puede tomarse de la variable + de entorno IMAP_B_PASSWORD. + +Generales: + --no-verify-tls Deshabilita la verificación de certificado TLS + en AMBOS servidores (inseguro). Muestra una + advertencia de seguridad en consola y en el log. + --timeout Timeout de conexión en segundos (por defecto 30) + --retries Número de reintentos ante errores de red/IMAP + (por defecto 3) + --state-file Archivo de estado de la migración (por defecto + migration-state.json) + --log-dir Directorio de logs (por defecto logs/) + --header-batch-size Cantidad de mensajes cuyos encabezados se + analizan por cada lote de red (por defecto 500) + --verbose Muestra en consola el mismo detalle que se + registra en el archivo de log + -h, --help Muestra la ayuda completa + +-------------------------------------------------------------------- +6. Certificados autofirmados / servidores internos +-------------------------------------------------------------------- + +Por defecto el programa valida los certificados TLS de ambos +servidores. Si alguno usa un certificado autofirmado (típico en +servidores internos), se puede deshabilitar la validación con: + + --no-verify-tls (para ambos servidores) + --a-no-verify-tls (solo para el servidor origen) + --b-no-verify-tls (solo para el servidor destino) + +Al usar cualquiera de estas opciones se imprime y registra una +advertencia de seguridad explícita. Úsese solo con servidores de +confianza. + +-------------------------------------------------------------------- +7. Cómo reanudar / re-ejecutar una migración incremental +-------------------------------------------------------------------- + +El programa mantiene un archivo de estado (por defecto +"migration-state.json" en el directorio de trabajo) con, por cada +carpeta: + + - el UID de origen hasta el cual ya se analizó de forma segura; + - la huella (fingerprint, basada en Message-ID o en tamaño+fecha) + de cada mensaje ya copiado; + - el UIDVALIDITY visto la última vez. + +Para reanudar una migración interrumpida (por CTRL+C, pérdida de +conexión, reinicio del servidor, etc.) o para hacer una nueva pasada +incremental (por ejemplo, para respaldar los mensajes nuevos desde la +última ejecución), simplemente se vuelve a ejecutar el mismo comando +apuntando al mismo --state-file: + + python3 imap_migrate.py --aserver ... --auser ... --bserver ... --buser ... + +El programa detectará automáticamente qué mensajes ya fueron copiados +y solo transferirá los mensajes nuevos o los que quedaron pendientes +por un error anterior. Si un mensaje falló en una ejecución anterior, +se reintenta en la siguiente ejecución sin volver a analizar toda la +carpeta desde cero. + +Si el UIDVALIDITY de una carpeta cambia en el servidor origen (esto +puede pasar tras una reconstrucción del buzón), el programa vuelve a +escanear esa carpeta completa, pero sigue evitando duplicados gracias +a que la huella de cada mensaje se basa principalmente en su +Message-ID. + +-------------------------------------------------------------------- +8. Cómo detener correctamente una migración +-------------------------------------------------------------------- + +Presionar CTRL+C durante la ejecución hace que el programa: + + 1. termine de forma segura la operación IMAP en curso; + 2. guarde el estado de la migración; + 3. cierre las conexiones con ambos servidores; + 4. muestre las estadísticas parciales acumuladas; + 5. finalice. + +La siguiente ejecución continuará desde el punto en que se quedó. + +-------------------------------------------------------------------- +9. Dónde están los logs +-------------------------------------------------------------------- + +Los logs se escriben en el directorio indicado por --log-dir (por +defecto "logs/"), con un archivo por día: + + logs/migration-2026-09-09.log + +El log incluye inicio de ejecución, conexiones, reconexiones, +creación de carpetas, mensajes copiados/omitidos, errores, reintentos +y el resumen final. Nunca se registran contraseñas. + +-------------------------------------------------------------------- +10. Dónde está el archivo de estado +-------------------------------------------------------------------- + +Por defecto: "migration-state.json" en el directorio desde el que se +ejecuta el programa (configurable con --state-file). Nunca contiene +contraseñas. + +-------------------------------------------------------------------- +11. Códigos de salida +-------------------------------------------------------------------- + + 0 = migración terminada correctamente + 1 = migración terminada con errores (ver el resumen final y el log) + 2 = error de parámetros + 3 = error de conexión con alguno de los servidores + +-------------------------------------------------------------------- +12. Ejecutar las pruebas +-------------------------------------------------------------------- + +Las pruebas usan únicamente "unittest" (librería estándar) y mocks; +no requieren servidores IMAP reales. + + python3 -m unittest discover -s tests -t . + +-------------------------------------------------------------------- +13. Estructura del proyecto +-------------------------------------------------------------------- + + imap_migrate.py Punto de entrada de línea de comandos + config.py Parseo de argumentos y configuración + imap_client.py Cliente IMAP (conexión, carpetas, fetch, append) + migrator.py Orquestador de la migración (análisis + copia) + state.py Persistencia del estado de migración + fingerprint.py Cálculo de huellas para detectar duplicados + progress.py Progreso en consola, ETA, resumen final + logger.py Configuración de logging + utf7.py Codificación UTF-7 modificada para nombres de carpeta + requirements.txt + Leame.txt + logs/ Logs de ejecución + tests/ Pruebas unitarias + +-------------------------------------------------------------------- +14. Migración de múltiples cuentas (extensión futura) +-------------------------------------------------------------------- + +Actualmente el programa migra una cuenta por ejecución. El diseño +(Migrator recibe un objeto de configuración por cuenta) permite +extenderlo fácilmente para leer un archivo "usuarios.csv" o +"usuarios.json" con varias cuentas y ejecutar la migración de cada +una en un bucle, sin cambios estructurales. diff --git a/README.md b/README.md index a41a2d7..047d2e6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,256 @@ # imap_migrate -Migrar buzones de correo usando protocolo IMAP \ No newline at end of file +Migrar buzones de correo usando protocolo IMAP==================================================================== + Migrador y respaldador de cuentas IMAP +==================================================================== + +Este programa copia o migra el contenido completo de una cuenta de +correo IMAP (carpetas, mensajes, adjuntos, flags y fechas) desde un +servidor origen (A) hacia un servidor destino (B). + +Puede usarse tanto para migrar una cuenta a un servidor nuevo como +para mantener un respaldo periódico de una cuenta en otro servidor. + +El programa NUNCA borra, mueve ni modifica mensajes en el servidor +origen. + +-------------------------------------------------------------------- +1. Requisitos +-------------------------------------------------------------------- + +- Python 3.10 o superior. +- No se requieren dependencias externas (solo librería estándar). + +-------------------------------------------------------------------- +2. Creación del entorno virtual +-------------------------------------------------------------------- + +Linux / macOS: + + python3 -m venv python-env + source python-env/bin/activate + +Windows: + + python -m venv python-env + python-env\Scripts\activate + +-------------------------------------------------------------------- +3. Instalación de dependencias +-------------------------------------------------------------------- + + pip install --upgrade pip + pip install -r requirements.txt + +(requirements.txt no declara dependencias externas: todo el programa +usa la librería estándar de Python.) + +-------------------------------------------------------------------- +4. Ejecución +-------------------------------------------------------------------- + +Ejemplo básico (las contraseñas se piden de forma interactiva si se +omiten): + + python3 imap_migrate.py \ + --aserver mail.oldserver.com \ + --auser usuario@dominio.com \ + --bserver mail.newserver.com \ + --buser usuario@dominio.com + +Ejemplo completo, con contraseñas por parámetro: + + python3 imap_migrate.py \ + --aserver imap.servidor-a.com --aport 993 \ + --auser usuario@dominio.com --apassword 'passwordA' \ + --bserver imap.servidor-b.com --bport 993 \ + --buser usuario@dominio.com --bpassword 'passwordB' + +Ejemplo usando una IP directa pero validando el certificado TLS +contra el nombre de host real (útil cuando el DNS no resuelve o se +quiere evitar la resolución DNS): + + python3 imap_migrate.py \ + --aserver mail.oldserver.com --aip 203.0.113.10 \ + --auser usuario@dominio.com \ + --bserver mail.newserver.com \ + --buser usuario@dominio.com + +Ejemplo con servidor destino de certificado autofirmado (ver sección +6 para la advertencia de seguridad): + + python3 imap_migrate.py \ + --aserver mail.oldserver.com --auser usuario@dominio.com \ + --bserver mail.interno.local --buser usuario@dominio.com \ + --b-no-verify-tls + +-------------------------------------------------------------------- +5. Parámetros disponibles +-------------------------------------------------------------------- + +Servidor origen (A): + --aserver Host del servidor IMAP origen (obligatorio) + --aport Puerto (por defecto 993) + --auser Usuario/cuenta en el servidor origen (obligatorio) + --apassword Password (si se omite, se solicita interactivamente + o se toma de la variable de entorno IMAP_A_PASSWORD) + --adomain Dominio lógico de la cuenta (informativo / uso + futuro para migración de múltiples cuentas) + --aip IP a la que conectar, usando --aserver como + nombre de host para la validación TLS + --a-no-verify-tls Deshabilita la verificación de certificado TLS + SOLO para el servidor origen (inseguro) + --a-no-ssl Conecta sin SSL/TLS al servidor origen (inseguro) + +Servidor destino (B): + --bserver, --bport, --buser, --bpassword, --bdomain, --bip, + --b-no-verify-tls, --b-no-ssl + Mismo significado que los anteriores, aplicados al servidor + destino. La contraseña también puede tomarse de la variable + de entorno IMAP_B_PASSWORD. + +Generales: + --no-verify-tls Deshabilita la verificación de certificado TLS + en AMBOS servidores (inseguro). Muestra una + advertencia de seguridad en consola y en el log. + --timeout Timeout de conexión en segundos (por defecto 30) + --retries Número de reintentos ante errores de red/IMAP + (por defecto 3) + --state-file Archivo de estado de la migración (por defecto + migration-state.json) + --log-dir Directorio de logs (por defecto logs/) + --header-batch-size Cantidad de mensajes cuyos encabezados se + analizan por cada lote de red (por defecto 500) + --verbose Muestra en consola el mismo detalle que se + registra en el archivo de log + -h, --help Muestra la ayuda completa + +-------------------------------------------------------------------- +6. Certificados autofirmados / servidores internos +-------------------------------------------------------------------- + +Por defecto el programa valida los certificados TLS de ambos +servidores. Si alguno usa un certificado autofirmado (típico en +servidores internos), se puede deshabilitar la validación con: + + --no-verify-tls (para ambos servidores) + --a-no-verify-tls (solo para el servidor origen) + --b-no-verify-tls (solo para el servidor destino) + +Al usar cualquiera de estas opciones se imprime y registra una +advertencia de seguridad explícita. Úsese solo con servidores de +confianza. + +-------------------------------------------------------------------- +7. Cómo reanudar / re-ejecutar una migración incremental +-------------------------------------------------------------------- + +El programa mantiene un archivo de estado (por defecto +"migration-state.json" en el directorio de trabajo) con, por cada +carpeta: + + - el UID de origen hasta el cual ya se analizó de forma segura; + - la huella (fingerprint, basada en Message-ID o en tamaño+fecha) + de cada mensaje ya copiado; + - el UIDVALIDITY visto la última vez. + +Para reanudar una migración interrumpida (por CTRL+C, pérdida de +conexión, reinicio del servidor, etc.) o para hacer una nueva pasada +incremental (por ejemplo, para respaldar los mensajes nuevos desde la +última ejecución), simplemente se vuelve a ejecutar el mismo comando +apuntando al mismo --state-file: + + python3 imap_migrate.py --aserver ... --auser ... --bserver ... --buser ... + +El programa detectará automáticamente qué mensajes ya fueron copiados +y solo transferirá los mensajes nuevos o los que quedaron pendientes +por un error anterior. Si un mensaje falló en una ejecución anterior, +se reintenta en la siguiente ejecución sin volver a analizar toda la +carpeta desde cero. + +Si el UIDVALIDITY de una carpeta cambia en el servidor origen (esto +puede pasar tras una reconstrucción del buzón), el programa vuelve a +escanear esa carpeta completa, pero sigue evitando duplicados gracias +a que la huella de cada mensaje se basa principalmente en su +Message-ID. + +-------------------------------------------------------------------- +8. Cómo detener correctamente una migración +-------------------------------------------------------------------- + +Presionar CTRL+C durante la ejecución hace que el programa: + + 1. termine de forma segura la operación IMAP en curso; + 2. guarde el estado de la migración; + 3. cierre las conexiones con ambos servidores; + 4. muestre las estadísticas parciales acumuladas; + 5. finalice. + +La siguiente ejecución continuará desde el punto en que se quedó. + +-------------------------------------------------------------------- +9. Dónde están los logs +-------------------------------------------------------------------- + +Los logs se escriben en el directorio indicado por --log-dir (por +defecto "logs/"), con un archivo por día: + + logs/migration-2026-09-09.log + +El log incluye inicio de ejecución, conexiones, reconexiones, +creación de carpetas, mensajes copiados/omitidos, errores, reintentos +y el resumen final. Nunca se registran contraseñas. + +-------------------------------------------------------------------- +10. Dónde está el archivo de estado +-------------------------------------------------------------------- + +Por defecto: "migration-state.json" en el directorio desde el que se +ejecuta el programa (configurable con --state-file). Nunca contiene +contraseñas. + +-------------------------------------------------------------------- +11. Códigos de salida +-------------------------------------------------------------------- + + 0 = migración terminada correctamente + 1 = migración terminada con errores (ver el resumen final y el log) + 2 = error de parámetros + 3 = error de conexión con alguno de los servidores + +-------------------------------------------------------------------- +12. Ejecutar las pruebas +-------------------------------------------------------------------- + +Las pruebas usan únicamente "unittest" (librería estándar) y mocks; +no requieren servidores IMAP reales. + + python3 -m unittest discover -s tests -t . + +-------------------------------------------------------------------- +13. Estructura del proyecto +-------------------------------------------------------------------- + + imap_migrate.py Punto de entrada de línea de comandos + config.py Parseo de argumentos y configuración + imap_client.py Cliente IMAP (conexión, carpetas, fetch, append) + migrator.py Orquestador de la migración (análisis + copia) + state.py Persistencia del estado de migración + fingerprint.py Cálculo de huellas para detectar duplicados + progress.py Progreso en consola, ETA, resumen final + logger.py Configuración de logging + utf7.py Codificación UTF-7 modificada para nombres de carpeta + requirements.txt + Leame.txt + logs/ Logs de ejecución + tests/ Pruebas unitarias + +-------------------------------------------------------------------- +14. Migración de múltiples cuentas (extensión futura) +-------------------------------------------------------------------- + +Actualmente el programa migra una cuenta por ejecución. El diseño +(Migrator recibe un objeto de configuración por cuenta) permite +extenderlo fácilmente para leer un archivo "usuarios.csv" o +"usuarios.json" con varias cuentas y ejecutar la migración de cada +una en un bucle, sin cambios estructurales. diff --git a/config.py b/config.py new file mode 100644 index 0000000..a694662 --- /dev/null +++ b/config.py @@ -0,0 +1,159 @@ +"""Parseo de argumentos de línea de comandos y configuración de la aplicación.""" + +from __future__ import annotations + +import argparse +import getpass +import os +import sys +from dataclasses import dataclass + +DEFAULT_PORT = 993 +DEFAULT_TIMEOUT = 30 +DEFAULT_RETRIES = 3 +DEFAULT_STATE_FILE = "migration-state.json" +DEFAULT_LOG_DIR = "logs" +DEFAULT_HEADER_BATCH_SIZE = 500 + +EXIT_PARAM_ERROR = 2 + + +@dataclass +class ServerConfig: + label: str + host: str + port: int + user: str + password: str + domain: str | None = None + ip: str | None = None + verify_tls: bool = True + use_ssl: bool = True + + @property + def connect_host(self) -> str: + return self.ip or self.host + + @property + def tls_hostname(self) -> str: + return self.host + + +@dataclass +class AppConfig: + server_a: ServerConfig + server_b: ServerConfig + timeout: int + retries: int + state_file: str + log_dir: str + header_batch_size: int + verbose: bool + + +def _resolve_password(cli_value: str | None, env_var: str, prompt_label: str) -> str: + if cli_value: + return cli_value + env_value = os.environ.get(env_var) + if env_value: + return env_value + try: + return getpass.getpass(f"{prompt_label}: ") + except (EOFError, KeyboardInterrupt): + print("\nNo se proporcionó contraseña.", file=sys.stderr) + sys.exit(EXIT_PARAM_ERROR) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="imap_migrate.py", + description="Migra o respalda el contenido completo de una cuenta IMAP entre dos servidores.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Ejemplo: + python3 imap_migrate.py \\ + --aserver imap.servidor-a.com --aport 993 \\ + --auser usuario@dominio.com --apassword 'passwordA' \\ + --bserver imap.servidor-b.com --bport 993 \\ + --buser usuario@dominio.com --bpassword 'passwordB' + +Si no se proporcionan las contraseñas, se solicitarán de forma interactiva +(o pueden definirse en las variables de entorno IMAP_A_PASSWORD / IMAP_B_PASSWORD). + +Ejecutar el programa varias veces sobre el mismo --state-file continúa una +migración incremental sin volver a copiar mensajes ya migrados. +""", + ) + + group_a = parser.add_argument_group("Servidor origen (A)") + group_a.add_argument("--aserver", required=True, help="Host del servidor IMAP origen") + group_a.add_argument("--aport", type=int, default=DEFAULT_PORT, help=f"Puerto del servidor origen (por defecto {DEFAULT_PORT})") + group_a.add_argument("--auser", required=True, help="Usuario/cuenta en el servidor origen") + group_a.add_argument("--apassword", default=None, help="Password del servidor origen (si se omite, se solicita de forma interactiva)") + group_a.add_argument("--adomain", default=None, help="Dominio lógico de la cuenta origen (informativo / uso futuro multi-cuenta)") + group_a.add_argument("--aip", default=None, help="IP a la que conectar en el servidor origen, usando --aserver como hostname TLS") + group_a.add_argument("--a-no-verify-tls", action="store_true", help="Deshabilita verificación de certificado TLS en el servidor origen (inseguro)") + group_a.add_argument("--a-no-ssl", action="store_true", help="Conecta sin SSL/TLS al servidor origen (inseguro, no recomendado)") + + group_b = parser.add_argument_group("Servidor destino (B)") + group_b.add_argument("--bserver", required=True, help="Host del servidor IMAP destino") + group_b.add_argument("--bport", type=int, default=DEFAULT_PORT, help=f"Puerto del servidor destino (por defecto {DEFAULT_PORT})") + group_b.add_argument("--buser", required=True, help="Usuario/cuenta en el servidor destino") + group_b.add_argument("--bpassword", default=None, help="Password del servidor destino (si se omite, se solicita de forma interactiva)") + group_b.add_argument("--bdomain", default=None, help="Dominio lógico de la cuenta destino (informativo / uso futuro multi-cuenta)") + group_b.add_argument("--bip", default=None, help="IP a la que conectar en el servidor destino, usando --bserver como hostname TLS") + group_b.add_argument("--b-no-verify-tls", action="store_true", help="Deshabilita verificación de certificado TLS en el servidor destino (inseguro)") + group_b.add_argument("--b-no-ssl", action="store_true", help="Conecta sin SSL/TLS al servidor destino (inseguro, no recomendado)") + + parser.add_argument("--no-verify-tls", action="store_true", help="Deshabilita verificación de certificado TLS en AMBOS servidores (inseguro)") + + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help=f"Timeout de conexión en segundos (por defecto {DEFAULT_TIMEOUT})") + parser.add_argument("--retries", type=int, default=DEFAULT_RETRIES, help=f"Número de reintentos ante fallos de conexión (por defecto {DEFAULT_RETRIES})") + parser.add_argument("--state-file", default=DEFAULT_STATE_FILE, help=f"Archivo de estado de la migración (por defecto {DEFAULT_STATE_FILE})") + parser.add_argument("--log-dir", default=DEFAULT_LOG_DIR, help=f"Directorio de logs (por defecto {DEFAULT_LOG_DIR})") + parser.add_argument("--header-batch-size", type=int, default=DEFAULT_HEADER_BATCH_SIZE, help="Cantidad de mensajes a analizar por lote (por defecto %(default)s)") + parser.add_argument("--verbose", action="store_true", help="Muestra en consola los mismos detalles que se registran en el log") + + return parser + + +def parse_args(argv: list[str] | None = None) -> AppConfig: + parser = build_parser() + args = parser.parse_args(argv) + + a_password = _resolve_password(args.apassword, "IMAP_A_PASSWORD", "Password servidor A") + b_password = _resolve_password(args.bpassword, "IMAP_B_PASSWORD", "Password servidor B") + + server_a = ServerConfig( + label="A", + host=args.aserver, + port=args.aport, + user=args.auser, + password=a_password, + domain=args.adomain, + ip=args.aip, + verify_tls=not (args.no_verify_tls or args.a_no_verify_tls), + use_ssl=not args.a_no_ssl, + ) + server_b = ServerConfig( + label="B", + host=args.bserver, + port=args.bport, + user=args.buser, + password=b_password, + domain=args.bdomain, + ip=args.bip, + verify_tls=not (args.no_verify_tls or args.b_no_verify_tls), + use_ssl=not args.b_no_ssl, + ) + + return AppConfig( + server_a=server_a, + server_b=server_b, + timeout=args.timeout, + retries=args.retries, + state_file=args.state_file, + log_dir=args.log_dir, + header_batch_size=args.header_batch_size, + verbose=args.verbose, + ) diff --git a/fingerprint.py b/fingerprint.py new file mode 100644 index 0000000..5c698c4 --- /dev/null +++ b/fingerprint.py @@ -0,0 +1,17 @@ +"""Cálculo de huellas (fingerprints) para detectar mensajes ya migrados. + +Los UID de IMAP no son estables entre servidores, así que la identidad de un +mensaje se basa preferentemente en su ``Message-ID``. Cuando el mensaje no +tiene ``Message-ID`` (raro, pero ocurre con correo mal formado), se recurre a +una combinación de tamaño y fecha interna. +""" + +import hashlib + + +def compute_fingerprint(message_id: str | None, size: int, internaldate_raw: str, extra: str | None = None) -> str: + if message_id: + return "mid:" + message_id.strip() + base = f"{size}:{internaldate_raw}:{extra or ''}" + digest = hashlib.sha256(base.encode("utf-8", "replace")).hexdigest() + return "nomid:" + digest diff --git a/imap_client.py b/imap_client.py new file mode 100644 index 0000000..f4ff41c --- /dev/null +++ b/imap_client.py @@ -0,0 +1,298 @@ +"""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) diff --git a/imap_migrate.py b/imap_migrate.py new file mode 100644 index 0000000..c69d99a --- /dev/null +++ b/imap_migrate.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Migrador y respaldador de cuentas IMAP. + +Uso: + python3 imap_migrate.py --aserver ... --auser ... --bserver ... --buser ... + +Ejecutar --help para ver todos los parámetros disponibles. + +Códigos de salida: + 0 = migración terminada correctamente + 1 = migración terminada con errores + 2 = error de parámetros + 3 = error de conexión +""" + +from __future__ import annotations + +import signal +import sys + +from config import parse_args +from logger import setup_logging +from migrator import Migrator + +EXIT_OK = 0 +EXIT_WITH_ERRORS = 1 +EXIT_PARAM_ERROR = 2 +EXIT_CONNECTION_ERROR = 3 + + +def main(argv: list[str] | None = None) -> int: + config = parse_args(argv) + logger = setup_logging(config.log_dir, config.verbose) + migrator = Migrator(config, logger) + + def handle_sigint(signum, frame): + logger.warning("CTRL+C recibido: terminando la operación actual y guardando el estado...") + print("\nCTRL+C recibido, deteniendo de forma segura (esto puede tardar unos segundos)...") + migrator.request_stop() + + signal.signal(signal.SIGINT, handle_sigint) + + try: + return migrator.run() + except Exception as exc: # salvaguarda ante fallos verdaderamente inesperados + logger.exception(f"Error inesperado: {exc}") + print(f"Error inesperado: {exc}", file=sys.stderr) + return EXIT_WITH_ERRORS + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/logger.py b/logger.py new file mode 100644 index 0000000..7a14958 --- /dev/null +++ b/logger.py @@ -0,0 +1,27 @@ +"""Configuración de logging de la aplicación.""" + +import logging +import os +from datetime import datetime + + +def setup_logging(log_dir: str, verbose: bool = False) -> logging.Logger: + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, f"migration-{datetime.now():%Y-%m-%d}.log") + + logger = logging.getLogger("imap_migrator") + logger.setLevel(logging.DEBUG) + logger.handlers.clear() + logger.propagate = False + + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + logger.addHandler(file_handler) + + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.DEBUG if verbose else logging.WARNING) + console_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + logger.addHandler(console_handler) + + return logger diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migration-state.json b/migration-state.json new file mode 100644 index 0000000..e69de29 diff --git a/migrator.py b/migrator.py new file mode 100644 index 0000000..04f4d36 --- /dev/null +++ b/migrator.py @@ -0,0 +1,263 @@ +"""Orquestador principal de la migración/respaldo IMAP. + +Estrategia en dos fases por ejecución: + +1. **Análisis**: para cada carpeta del servidor A se reproduce su + estructura en B, se determina desde qué UID hay que buscar (usando el + ``migration-state.json`` de una ejecución anterior) y se leen solo los + encabezados (``Message-ID``, tamaño, fecha, flags) de los mensajes + nuevos. Con esto se calcula qué mensajes realmente faltan por copiar, + sin tener que descargar ni comparar el cuerpo completo de todos los + mensajes en cada ejecución. +2. **Copia**: se transfieren uno a uno, mensaje por mensaje, solo los + mensajes pendientes detectados en la fase anterior. + +El estado (``last_scanned_a_uid`` por carpeta) solo avanza hasta el punto +donde todos los mensajes de esa carpeta quedaron resueltos (copiados o ya +existentes). Si un mensaje falla o la ejecución se interrumpe, el estado no +avanza más allá de ese punto, así que la siguiente ejecución lo vuelve a +intentar sin perder mensajes. +""" + +from __future__ import annotations + +import dataclasses + +from fingerprint import compute_fingerprint +from imap_client import ImapClient +from progress import ProgressTracker, format_bytes +from state import MigrationState + + +def translate_folder_path(name: str, src_delim: str, dst_delim: str) -> str: + if not src_delim or src_delim == dst_delim: + return name + parts = [p for p in name.split(src_delim) if p != ""] + return dst_delim.join(parts) + + +def _is_noselect(flags: str) -> bool: + lowered = flags.lower() + return "\\noselect" in lowered or "\\nonexistent" in lowered + + +@dataclasses.dataclass +class PendingItem: + folder: str + dst_folder: str + uid: int + size: int + fingerprint: str + + +class Migrator: + def __init__(self, config, logger, progress: ProgressTracker | None = None): + self.config = config + self.logger = logger + self.client_a = ImapClient(config.server_a, config.timeout, logger, config.retries) + self.client_b = ImapClient(config.server_b, config.timeout, logger, config.retries) + self.state = MigrationState(config.state_file, config.server_a.user) + self.progress = progress or ProgressTracker() + self._stop_requested = False + + def request_stop(self) -> None: + self._stop_requested = True + + def run(self) -> int: + try: + print("Conectando al servidor origen...") + self.logger.info("Conectando al servidor origen...") + self.client_a.connect() + print("OK\n") + + print("Conectando al servidor destino...") + self.logger.info("Conectando al servidor destino...") + self.client_b.connect() + print("OK\n") + except Exception as exc: + self.logger.error(f"Error de conexión: {exc}") + print(f"Error de conexión: {exc}") + return 3 + + exit_code = 0 + try: + print("Analizando carpetas...") + self.logger.info("Analizando carpetas...") + folders_a = self.client_a.list_folders() + selectable = [f for f in folders_a if not _is_noselect(f.flags)] + + b_delim = self._mirror_folder_structure(selectable) + + pending, folder_scan_info, total_exists, total_skipped = self._scan_folders(selectable, b_delim) + + total_new_messages = len(pending) + total_new_bytes = sum(item.size for item in pending) + + print(f"\nCarpetas encontradas: {len(selectable)}") + print(f"Mensajes en servidor origen: {total_exists}") + print(f"Mensajes nuevos a copiar: {total_new_messages}") + print(f"Tamaño estimado a transferir: {format_bytes(total_new_bytes)}\n") + self.logger.info(f"Carpetas encontradas: {len(selectable)}") + self.logger.info(f"Mensajes en servidor origen: {total_exists}") + self.logger.info(f"Mensajes nuevos a copiar: {total_new_messages}, existentes: {total_skipped}") + + self.progress.set_total_folders(len(selectable)) + self.progress.set_total_messages(total_new_messages) + self.progress.set_total_bytes(total_new_bytes) + self.progress.messages_skipped = total_skipped + self.progress.folders_done = len(selectable) + self.progress.start() + + if not self._stop_requested: + print("Migrando...") + self._copy_pending(pending, folder_scan_info) + + self.progress.stop() + exit_code = 0 if self.progress.messages_errors == 0 else 1 + except Exception as exc: + self.logger.exception(f"Error inesperado durante la migración: {exc}") + exit_code = 1 + finally: + self.state.save() + self.client_a.close() + self.client_b.close() + self.progress.print_summary(self.config.server_a.host, self.config.server_b.host, self.logger) + + return exit_code + + # -- fase de estructura de carpetas ----------------------------------- + + def _mirror_folder_structure(self, selectable) -> str: + folders_b = self.client_b.list_folders() + b_delim = folders_b[0].delimiter if folders_b else "/" + created: set[str] = set() + for folder in selectable: + dst_path = translate_folder_path(folder.name, folder.delimiter, b_delim) + self._ensure_folder_path(dst_path, b_delim, created) + return b_delim + + def _ensure_folder_path(self, dst_path: str, b_delim: str, created: set[str]) -> None: + parts = [p for p in dst_path.split(b_delim) if p != ""] + prefix_parts: list[str] = [] + for part in parts: + prefix_parts.append(part) + path = b_delim.join(prefix_parts) + if path in created: + continue + try: + self.client_b.create_folder(path) + self.logger.info(f"Carpeta asegurada en destino: {path}") + except Exception as exc: + self.logger.warning(f"No se pudo crear/verificar la carpeta {path!r} en destino: {exc}") + created.add(path) + + # -- fase de análisis -------------------------------------------------- + + def _scan_folders(self, selectable, b_delim: str): + pending: list[PendingItem] = [] + folder_scan_info: dict[str, dict] = {} + total_exists = 0 + total_skipped = 0 + + for folder in selectable: + if self._stop_requested: + break + dst_folder = translate_folder_path(folder.name, folder.delimiter, b_delim) + try: + exists = self.client_a.select(folder.name, readonly=True) + total_exists += exists + uidvalidity = self.client_a.get_uidvalidity(folder.name) + except Exception as exc: + self.logger.error(f"No se pudo analizar la carpeta {folder.name!r}: {exc}") + continue + + prev_uidvalidity = self.state.get_uidvalidity(folder.name) + if prev_uidvalidity is not None and prev_uidvalidity != uidvalidity: + self.logger.warning( + f"UIDVALIDITY cambió en {folder.name!r} ({prev_uidvalidity} -> {uidvalidity}); " + "se reescaneará la carpeta completa (los mensajes ya migrados se detectan por Message-ID)." + ) + self.state.set_uidvalidity(folder.name, uidvalidity) + + last_scanned = self.state.get_last_scanned_uid(folder.name) + since_uid = last_scanned + 1 if last_scanned else None + + try: + uids = self.client_a.search_uids(since_uid=since_uid) + except Exception as exc: + self.logger.error(f"No se pudo buscar mensajes en {folder.name!r}: {exc}") + continue + + max_uid = last_scanned + if uids: + try: + headers = self.client_a.fetch_headers(uids, self.config.header_batch_size) + except Exception as exc: + self.logger.error(f"No se pudieron leer encabezados de {folder.name!r}: {exc}") + folder_scan_info[folder.name] = {"max_uid": max_uid} + continue + + for uid in sorted(headers.keys()): + info = headers[uid] + max_uid = max(max_uid, uid) + fp = compute_fingerprint(info.get("message_id"), info.get("size", 0), info.get("internaldate_raw", "")) + if self.state.has_fingerprint(folder.name, fp): + total_skipped += 1 + continue + pending.append(PendingItem(folder.name, dst_folder, uid, info.get("size", 0), fp)) + + folder_scan_info[folder.name] = {"max_uid": max_uid} + + return pending, folder_scan_info, total_exists, total_skipped + + # -- fase de copia ------------------------------------------------------- + + def _copy_pending(self, pending: list[PendingItem], folder_scan_info: dict[str, dict]) -> None: + succeeded: dict[str, set[int]] = {folder: set() for folder in folder_scan_info} + processed_since_save = 0 + current_folder = None + + for item in pending: + if self._stop_requested: + self.logger.warning("Detención solicitada; guardando estado y saliendo de forma segura.") + break + + if item.folder != current_folder: + current_folder = item.folder + self.progress.set_current_folder(item.folder) + + try: + self.client_a.select(item.folder, readonly=True) + content, flags_raw, internaldate_raw = self.client_a.fetch_full(item.uid) + flags = " ".join(f for f in flags_raw.split() if f.lower() != "\\recent") + b_uid = self.client_b.append(item.dst_folder, content, flags, internaldate_raw) + self.state.mark_migrated(item.folder, item.fingerprint, item.uid, b_uid, item.size) + succeeded.setdefault(item.folder, set()).add(item.uid) + self.progress.message_copied(item.size) + self.logger.debug(f"Copiado UID {item.uid} ({item.folder} -> {item.dst_folder})") + except Exception as exc: + self.progress.message_error() + self.logger.error(f"Error copiando UID {item.uid} en {item.folder!r}: {exc}") + + processed_since_save += 1 + if processed_since_save >= 50: + self._finalize_scan_state(pending, folder_scan_info, succeeded) + self.state.save() + processed_since_save = 0 + + self._finalize_scan_state(pending, folder_scan_info, succeeded) + self.state.save() + + def _finalize_scan_state(self, pending: list[PendingItem], folder_scan_info: dict[str, dict], succeeded: dict[str, set[int]]) -> None: + pending_uids_by_folder: dict[str, list[int]] = {} + for item in pending: + pending_uids_by_folder.setdefault(item.folder, []).append(item.uid) + + for folder, info in folder_scan_info.items(): + all_pending = pending_uids_by_folder.get(folder, []) + ok_uids = succeeded.get(folder, set()) + unresolved = [u for u in all_pending if u not in ok_uids] + final_uid = (min(unresolved) - 1) if unresolved else info["max_uid"] + prev = self.state.get_last_scanned_uid(folder) + if final_uid > prev: + self.state.set_last_scanned_uid(folder, final_uid) diff --git a/progress.py b/progress.py new file mode 100644 index 0000000..7343b53 --- /dev/null +++ b/progress.py @@ -0,0 +1,156 @@ +"""Seguimiento y presentación del progreso de la migración en consola.""" + +from __future__ import annotations + +import sys +import time + + +def format_bytes(n: float) -> str: + units = ["B", "KB", "MB", "GB", "TB"] + size = float(max(n, 0)) + for unit in units[:-1]: + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} {units[-1]}" + + +def format_duration(seconds: float) -> str: + seconds = int(max(0, seconds)) + h, rem = divmod(seconds, 3600) + m, s = divmod(rem, 60) + return f"{h:02d}:{m:02d}:{s:02d}" + + +def compute_eta(elapsed: float, done: int, total: int) -> float: + """Tiempo estimado restante en segundos, dado lo hecho hasta ahora.""" + if done <= 0 or total <= 0 or elapsed <= 0: + return 0.0 + rate = done / elapsed + if rate <= 0: + return 0.0 + remaining = max(total - done, 0) + return remaining / rate + + +class ProgressTracker: + def __init__(self, stream=None): + self.stream = stream or sys.stdout + self.total_folders = 0 + self.folders_done = 0 + self.current_folder = "" + self.total_messages = 0 + self.messages_processed = 0 + self.messages_copied = 0 + self.messages_skipped = 0 + self.messages_errors = 0 + self.total_bytes = 0 + self.bytes_transferred = 0 + self.start_time = None + self._last_render = 0.0 + + def set_total_folders(self, n: int) -> None: + self.total_folders = n + + def set_total_messages(self, n: int) -> None: + self.total_messages = n + + def set_total_bytes(self, n: int) -> None: + self.total_bytes = n + + def set_current_folder(self, name: str) -> None: + self.current_folder = name + + def folder_done(self) -> None: + self.folders_done += 1 + + def start(self) -> None: + self.start_time = time.monotonic() + + def message_copied(self, size: int) -> None: + self.messages_processed += 1 + self.messages_copied += 1 + self.bytes_transferred += size + self._maybe_render() + + def message_error(self) -> None: + self.messages_processed += 1 + self.messages_errors += 1 + self._maybe_render() + + def elapsed(self) -> float: + if self.start_time is None: + return 0.0 + return time.monotonic() - self.start_time + + def speed_bps(self) -> float: + e = self.elapsed() + return self.bytes_transferred / e if e > 0 else 0.0 + + def eta_seconds(self) -> float: + return compute_eta(self.elapsed(), self.messages_processed, self.total_messages) + + def _maybe_render(self, force: bool = False) -> None: + now = time.monotonic() + if not force and (now - self._last_render) < 0.5: + return + self._last_render = now + self.render() + + def render(self) -> None: + pct = (self.messages_processed / self.total_messages * 100) if self.total_messages else 100.0 + pct = min(pct, 100.0) + bar_width = 24 + filled = int(bar_width * pct / 100) + bar = "█" * filled + "░" * (bar_width - filled) + folder = (self.current_folder or "")[:20] + line = ( + f"\rCarpeta: {folder:<20} [{bar}] {pct:5.1f}% " + f"Msjs: {self.messages_processed}/{self.total_messages} " + f"Copiados: {self.messages_copied} Omitidos: {self.messages_skipped} " + f"Errores: {self.messages_errors} " + f"{format_bytes(self.bytes_transferred)}/{format_bytes(self.total_bytes)} " + f"{format_bytes(self.speed_bps())}/s " + f"T: {format_duration(self.elapsed())} ETA: {format_duration(self.eta_seconds())}" + ) + self.stream.write(line) + self.stream.flush() + + def stop(self) -> None: + self.render() + self.stream.write("\n") + self.stream.flush() + + def print_summary(self, host_a: str, host_b: str, logger) -> None: + lines = [ + "=" * 40, + "MIGRACIÓN TERMINADA", + "=" * 40, + "", + "Servidor origen:", + host_a, + "", + "Servidor destino:", + host_b, + "", + f"Carpetas procesadas: {self.folders_done}/{self.total_folders}", + "", + f"Mensajes nuevos detectados: {self.total_messages}", + f"Mensajes copiados: {self.messages_copied}", + f"Mensajes existentes: {self.messages_skipped}", + f"Mensajes con error: {self.messages_errors}", + "", + "Datos transferidos:", + format_bytes(self.bytes_transferred), + "", + "Tiempo total:", + format_duration(self.elapsed()), + "", + "Velocidad promedio:", + f"{format_bytes(self.speed_bps())}/s", + "=" * 40, + ] + text = "\n".join(lines) + print(text) + logger.info("Resumen final:\n" + text) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..902979d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +# Este proyecto usa únicamente la librería estándar de Python (3.10+). +# No se requieren dependencias externas para ejecutar imap_migrate.py. +# +# Módulos de la librería estándar utilizados: imaplib, ssl, socket, argparse, +# getpass, logging, json, hashlib, dataclasses, signal, tempfile, threading. +# +# Las pruebas (carpeta tests/) usan unittest, también de la librería estándar. diff --git a/state.py b/state.py new file mode 100644 index 0000000..1ad8363 --- /dev/null +++ b/state.py @@ -0,0 +1,87 @@ +"""Persistencia del estado de migración (migration-state.json). + +El estado permite reanudar una migración interrumpida y evitar volver a +copiar mensajes ya migrados. Nunca se almacenan contraseñas aquí. +""" + +import json +import os +import tempfile +import threading +from datetime import datetime, timezone + +STATE_VERSION = 1 + + +class MigrationState: + def __init__(self, path: str, account: str): + self.path = path + self.account = account + self._lock = threading.Lock() + self._data = self._load() + + def _load(self) -> dict: + if os.path.exists(self.path): + with open(self.path, "r", encoding="utf-8") as f: + data = json.load(f) + data.setdefault("folders", {}) + return data + return { + "version": STATE_VERSION, + "account": self.account, + "created_at": datetime.now(timezone.utc).isoformat(), + "folders": {}, + } + + def save(self) -> None: + with self._lock: + self._data["updated_at"] = datetime.now(timezone.utc).isoformat() + target_dir = os.path.dirname(os.path.abspath(self.path)) or "." + os.makedirs(target_dir, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=".migration-state-", dir=target_dir) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(self._data, f, indent=2, ensure_ascii=False) + os.replace(tmp_path, self.path) + finally: + if os.path.exists(tmp_path): + try: + os.remove(tmp_path) + except OSError: + pass + + def _folder(self, folder: str) -> dict: + return self._data["folders"].setdefault( + folder, + {"fingerprints": {}, "last_scanned_a_uid": 0, "a_uidvalidity": None}, + ) + + def get_uidvalidity(self, folder: str): + return self._folder(folder).get("a_uidvalidity") + + def set_uidvalidity(self, folder: str, value) -> None: + f = self._folder(folder) + if f.get("a_uidvalidity") != value: + f["a_uidvalidity"] = value + f["last_scanned_a_uid"] = 0 + + def get_last_scanned_uid(self, folder: str) -> int: + return self._folder(folder).get("last_scanned_a_uid", 0) + + def set_last_scanned_uid(self, folder: str, uid: int) -> None: + f = self._folder(folder) + f["last_scanned_a_uid"] = max(f.get("last_scanned_a_uid", 0), uid) + + def has_fingerprint(self, folder: str, fingerprint: str) -> bool: + return fingerprint in self._folder(folder)["fingerprints"] + + def mark_migrated(self, folder: str, fingerprint: str, a_uid: int, b_uid, size: int) -> None: + self._folder(folder)["fingerprints"][fingerprint] = { + "a_uid": a_uid, + "b_uid": b_uid, + "size": size, + "migrated_at": datetime.now(timezone.utc).isoformat(), + } + + def folders_seen(self) -> list[str]: + return list(self._data["folders"].keys()) diff --git a/utf7.py b/utf7.py new file mode 100644 index 0000000..f896999 --- /dev/null +++ b/utf7.py @@ -0,0 +1,60 @@ +"""Codec para UTF-7 modificado (RFC 3501), usado por IMAP para nombres de carpetas.""" + +import base64 + +_B64_CHARS = "-,ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+" + + +def encode(name: str) -> bytes: + """Codifica un nombre de carpeta Unicode a UTF-7 modificado (IMAP).""" + res = [] + buffer = [] + + def flush(): + if not buffer: + return + raw = "".join(buffer).encode("utf-16-be") + b64 = base64.b64encode(raw).decode("ascii").rstrip("=") + b64 = b64.replace("/", ",") + res.append("&" + b64 + "-") + buffer.clear() + + for ch in name: + code = ord(ch) + if ch == "&": + flush() + res.append("&-") + elif 0x20 <= code <= 0x7E: + flush() + res.append(ch) + else: + buffer.append(ch) + flush() + return "".join(res).encode("ascii") + + +def decode(data: bytes) -> str: + """Decodifica un nombre de carpeta IMAP en UTF-7 modificado a texto Unicode.""" + s = data.decode("ascii") + res = [] + i = 0 + n = len(s) + while i < n: + ch = s[i] + if ch == "&": + end = s.find("-", i + 1) + if end == -1: + end = n + segment = s[i + 1:end] + if segment == "": + res.append("&") + else: + segment = segment.replace(",", "/") + padding = "=" * (-len(segment) % 4) + raw = base64.b64decode(segment + padding) + res.append(raw.decode("utf-16-be")) + i = end + 1 + else: + res.append(ch) + i += 1 + return "".join(res)