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
|
||||
|
||||
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