Commit inicial
This commit is contained in:
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
+263
@@ -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
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user