Commit inicial

This commit is contained in:
lastdragon
2026-09-10 09:24:22 -06:00
parent 202164a0da
commit 6e11482d36
14 changed files with 1634 additions and 1 deletions
+254
View File
@@ -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.
+254 -1
View File
@@ -1,3 +1,256 @@
# imap_migrate # imap_migrate
Migrar buzones de correo usando protocolo IMAP 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.
+159
View File
@@ -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,
)
+17
View File
@@ -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
+298
View File
@@ -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<flags>[^)]*)\)\s+(?:"(?P<delim>[^"]*)"|NIL)\s+(?P<name>.+)$')
_FETCH_META_RE = re.compile(
r"UID (?P<uid>\d+)"
r"|RFC822\.SIZE (?P<size>\d+)"
r'|INTERNALDATE "(?P<idate>[^"]*)"'
r"|FLAGS \((?P<flags>[^)]*)\)"
)
_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)
+52
View File
@@ -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())
+27
View File
@@ -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
View File
View File
+263
View File
@@ -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)
+156
View File
@@ -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)
+7
View File
@@ -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.
+87
View File
@@ -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())
+60
View File
@@ -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)