commit 08559481922a71024d13eba2381a72f4f3e5cede Author: George Date: Mon Jun 1 16:31:24 2026 -0500 primer commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb61760 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.env +venv/ +.DS_Store +.vscode/ +.idea/ +logs/ +adjuntos/ diff --git a/config.json b/config.json new file mode 100644 index 0000000..ecdefa9 --- /dev/null +++ b/config.json @@ -0,0 +1,24 @@ +{ + "hana": { + "server": "192.168.9.13", + "port": 30015, + "user": "SYSTEM", + "password": "" + }, + "outlook": { + "client_id": "671f6154-715f-4e52-aef9-94ad108dc1d4", + "client_secret": "oG58Q~yufghPKmZuh0oVqX6rcLedLP1HTwIC-bq5", + "tenant_id": "5ac66bf8-fbbd-4615-80c0-8e3d521dc5e6", + "download_folder": "./adjuntos", + "verify_ssl": false, + "ca_bundle": null + }, + "client": { + "email": "contabilidad1@aberio.com.pe", + "ruta_descarga": "./adjuntos", + "ruta_final": "./resultado", + "base_datos": "SBO_ABERIO_PRODUCCION", + "usuario": "manager", + "password": "s1st3m4s" + } +} \ No newline at end of file diff --git a/config_loader.py b/config_loader.py new file mode 100644 index 0000000..b9641bb --- /dev/null +++ b/config_loader.py @@ -0,0 +1,22 @@ +import json +from pathlib import Path +from config_model import Config, HanaConfig, OutlookConfig, ClientConfig + +######################################################## +CONFIG_PATH = Path(__file__).resolve().parent / "config.json" + +with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + +# Instantiate dataclasses +hana = HanaConfig(**data.get('hana', {})) +outlook = OutlookConfig(**data.get('outlook', {})) +client = ClientConfig(**data.get('client', {})) + +CONFIG = Config( + hana=hana, + outlook=outlook, + client=client +) + +######################################################## \ No newline at end of file diff --git a/config_model.py b/config_model.py new file mode 100644 index 0000000..a103786 --- /dev/null +++ b/config_model.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from typing import Optional + +@dataclass +class HanaConfig: + server: str + port: int + user: str + password: str + +@dataclass +class OutlookConfig: + client_id: str + client_secret: str + tenant_id: str + download_folder: str + verify_ssl: bool + ca_bundle: Optional[str] + +@dataclass +class ClientConfig: + email: str + ruta_descarga: str + ruta_final: str + base_datos: str + usuario: str + password: str + +@dataclass +class Config: + hana: HanaConfig + outlook: OutlookConfig + client: ClientConfig \ No newline at end of file diff --git a/core.py b/core.py new file mode 100644 index 0000000..0f085e9 --- /dev/null +++ b/core.py @@ -0,0 +1,3 @@ + +def procesar_correo(): + pass \ No newline at end of file diff --git a/database_utilities.py b/database_utilities.py new file mode 100644 index 0000000..14180e7 --- /dev/null +++ b/database_utilities.py @@ -0,0 +1,111 @@ +import sqlite3 + +DB_NAME = "emails.db" + +######################################################## +def init_db(): + """ + Inicializa la base de datos SQLite y crea la tabla de correos si no existe. + """ + conn = sqlite3.connect(DB_NAME) + cursor = conn.cursor() + + # Crear tabla con id como llave primaria para evitar duplicados + cursor.execute(''' + CREATE TABLE IF NOT EXISTS emails ( + id TEXT PRIMARY KEY, + fecha TEXT, + sender TEXT, + subject TEXT, + has_attach INTEGER, + fecha_descarga TEXT + ) + ''') + + conn.commit() + conn.close() + +######################################################## +def save_emails(emails_list): + """ + Guarda una lista de correos en la base de datos. + Retorna la cantidad de registros nuevos insertados. + """ + if not emails_list: + return 0 + + conn = sqlite3.connect(DB_NAME) + cursor = conn.cursor() + + from datetime import datetime + fecha_ahora = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Mapear lista de diccionarios a tuplas para executemany + # has_attach se guarda como 1 (True) o 0 (False) + data = [ + ( + e['id'], + e['fecha'], + e['sender'], + e['subject'], + 1 if e['has_attach'] else 0, + fecha_ahora + ) + for e in emails_list + ] + + # INSERT OR IGNORE evita errores si el ID ya existe + cursor.executemany(''' + INSERT OR IGNORE INTO emails (id, fecha, sender, subject, has_attach, fecha_descarga) + VALUES (?, ?, ?, ?, ?, ?) + ''', data) + + conn.commit() + rows_affected = cursor.rowcount + conn.close() + return rows_affected + +######################################################## +def get_existing_ids(ids_list): + """ + Recibe una lista de IDs y devuelve una lista con aquellos que ya existen en la BD. + """ + if not ids_list: + return [] + + conn = sqlite3.connect(DB_NAME) + cursor = conn.cursor() + + existing_ids = [] + # Procesar por lotes para evitar límites de parámetros en SQLite + for i in range(0, len(ids_list), 900): + chunk = ids_list[i:i + 900] + placeholders = ','.join(['?'] * len(chunk)) + query = f"SELECT id FROM emails WHERE id IN ({placeholders})" + cursor.execute(query, chunk) + existing_ids.extend([row[0] for row in cursor.fetchall()]) + + conn.close() + return existing_ids + +######################################################## +def delete_email_by_id(email_id): + """ + Elimina un correo de la base de datos por su ID. + Retorna True si se eliminó el registro, False si no se encontró. + """ + if not email_id: + return False + + conn = sqlite3.connect(DB_NAME) + cursor = conn.cursor() + + cursor.execute("DELETE FROM emails WHERE id = ?", (email_id,)) + + conn.commit() + rows_affected = cursor.rowcount + conn.close() + + return rows_affected > 0 + +######################################################## \ No newline at end of file diff --git a/email_utilities.py b/email_utilities.py new file mode 100644 index 0000000..96a64e2 --- /dev/null +++ b/email_utilities.py @@ -0,0 +1,78 @@ +import os +import zipfile +import database_utilities + +######################################################## +def quitar_correos_ya_registrados_en_base_de_datos(emails): + """ + Verifica cuáles correos son nuevos (no están en la BD), los guarda + y retorna la lista filtrada de los que tienen adjuntos. + """ + if not emails: + return [] + + # 1. Verificar cuáles ya existen en la BD + ids = [e['id'] for e in emails] + existentes = database_utilities.get_existing_ids(ids) + + # Solo nos quedamos con los que NO están en la BD + correos_no_registrados = [e for e in emails if e['id'] not in existentes] + print(f"Correos nuevos detectados: {len(correos_no_registrados)}") + + if correos_no_registrados: + print("Guardando nuevos correos en la base de datos...") + database_utilities.save_emails(correos_no_registrados) + + return correos_no_registrados + +######################################################## +def descomprimir_adjuntos(emails, ruta): + """ + Esta funcion recibe la lista de emails y por cada no recorre sus adjuntos + si encuentra un adjunto .zip lo descomprime en la ruta especificada + y actualiza la lista de archivos agregar estos nuevos documentos al lsitado de adjuntos del email + """ + archivos_finales = [] + for archivo in archivos: + ruta_archivo = os.path.join(ruta, archivo) + if archivo.lower().endswith('.zip') and os.path.exists(ruta_archivo): + try: + with zipfile.ZipFile(ruta_archivo, 'r') as zip_ref: + nombres_extraidos = zip_ref.namelist() + zip_ref.extractall(ruta) + archivos_finales.extend(nombres_extraidos) + except Exception as e: + print(f" ⚠️ Error descomprimiendo {archivo}: {e}") + archivos_finales.append(archivo) + else: + archivos_finales.append(archivo) + return archivos_finales + +######################################################## +def filtrar_archivos_validos(archivos): + + """ + Retorna una lista solo con archivos .pdf y .xml (ignora mayúsculas/minúsculas). + """ + extensiones_validas = ('.pdf', '.xml') + return [a for a in archivos if a.lower().endswith(extensiones_validas)] + +######################################################## +def procesar_adjuntos_email(e, archivos, ruta_descarga): + """ + Descomprime y filtra los archivos de un correo. + Retorna la lista de archivos válidos si existen, de lo contrario lista vacía. + """ + # 1. Descomprimir archivos ZIP si existen + archivos_procesados = descomprimir_adjuntos(archivos, ruta_descarga) + + # 2. Filtrar solo PDF y XML + archivos_validos = filtrar_archivos_validos(archivos_procesados) + + if archivos_validos: + e['archivos'] = archivos_validos + return archivos_validos + return [] + +######################################################## + diff --git a/emails.db b/emails.db new file mode 100644 index 0000000..3d4dd08 Binary files /dev/null and b/emails.db differ diff --git a/logger_utilities.py b/logger_utilities.py new file mode 100644 index 0000000..6d05b9e --- /dev/null +++ b/logger_utilities.py @@ -0,0 +1,70 @@ +import sys +import os +from datetime import datetime, timedelta + +######################################################## +class MultiStream: + """ + Clase para redirigir la salida a múltiples flujos (consola y archivo). + """ + def __init__(self, *streams): + self.streams = streams + + def write(self, data): + for stream in self.streams: + stream.write(data) + stream.flush() + + def flush(self): + for stream in self.streams: + stream.flush() + +######################################################## +def setup_logger(): + """ + Configura la redirección de stdout y stderr a un archivo de log y a la consola. + Retorna la ruta del archivo de log creado. + """ + log_dir = "logs" + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + # Nombre del archivo: YYYY-MM-DD_HH-MM-SS.log + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + log_filename = f"{timestamp}.log" + log_path = os.path.join(log_dir, log_filename) + + # Abrir archivo en modo append con encoding utf-8 para soportar iconos + log_file = open(log_path, "a", encoding="utf-8") + + # Redirigir stdout y stderr + sys.stdout = MultiStream(sys.stdout, log_file) + sys.stderr = MultiStream(sys.stderr, log_file) + + return log_path + +######################################################## +def limpiar_logs_antiguos(dias=7): + """ + Elimina archivos de log con más de 'n' días de antigüedad. + """ + log_dir = "logs" + if not os.path.exists(log_dir): + return + + limite = datetime.now() - timedelta(days=dias) + count = 0 + + for archivo in os.listdir(log_dir): + ruta_archivo = os.path.join(log_dir, archivo) + if os.path.isfile(ruta_archivo) and archivo.endswith(".log"): + fecha_archivo = datetime.fromtimestamp(os.path.getmtime(ruta_archivo)) + if fecha_archivo < limite: + try: + os.remove(ruta_archivo) + count += 1 + except Exception as e: + print(f"⚠️ Error al eliminar log {archivo}: {e}") + + if count > 0: + print(f"🧹 Se eliminaron {count} archivos de log antiguos (más de {dias} días).") diff --git a/main.py b/main.py new file mode 100644 index 0000000..cda4404 --- /dev/null +++ b/main.py @@ -0,0 +1,127 @@ + +import sys +import json +import os +import email_utilities +import outlook_utilities +import database_utilities +import logger_utilities +from datetime import datetime, timedelta +from config_loader import CONFIG + +######################################################## +def obtener_token(): + try: + print("Intentando obtener token de acceso...") + outlook_token = outlook_utilities.get_access_token() + print("✅ Token obtenido exitosamente.") + print(f"Token prefix: {outlook_token[:25]}...") + return outlook_token + except Exception as e: + print(f"❌ Error: {e}") + sys.exit(1) + +######################################################## +def descargar_correos(outlook_token, fecha_inicio, fecha_fin): + + try: + print("\n" + "="*60) + print(f"[MAIL] {CONFIG.client.email}") + + emails = outlook_utilities.get_emails(CONFIG.client.email, outlook_token, fecha_inicio, fecha_fin) + print(f"Se encontraron {len(emails)} correos.") + + return emails + + except Exception as e: + print(f"❌ Error: {e}") + sys.exit(1) + + return [] + +######################################################## +def descargar_adjuntos_de_correos(outlook_token, emails, ruta_descarga): + try: + + for email in emails: + # Guardar lista original para el log + adjuntos = outlook_utilities.download_attachments( + CONFIG.client.email, + email['id'], + outlook_token, + ruta_descarga) + if adjuntos: + email['adjuntos'] = adjuntos + + return emails + + except Exception as ex: + print(f" ❌ Error procesando {email['id'][:10]}: {ex}") + + return [] + +######################################################## +def main(): + + # Inicializar Base de Datos + database_utilities.init_db() + + # Calcular fechas + hoy = datetime.now() + ayer = hoy - timedelta(days=25) + + fecha_inicio = ayer.strftime("%Y-%m-%d") + fecha_fin = hoy.strftime("%Y-%m-%d") + + print(f"Rango de búsqueda: {fecha_inicio} al {fecha_fin}") + + outlook_token = obtener_token() + # se descarga todos los correos en el rango de fecha ingresado + # tengan o no adjuntos + emails = descargar_correos(outlook_token, fecha_inicio, fecha_fin) + + # se verifica si estos correos ya estan en la base de datos + # si ya esta registrado, se quita del listado + # si no esta se inserta en la base de datos + + emails = email_utilities.quitar_correos_ya_registrados_en_base_de_datos(emails) + + # descargar todos los adjuntos de cada mail + + + + + # si el correo tiene archivos zip, se descomprimen y se agregan a + # la lista de archivos adjuntos + + + + # se evalua los adjuntos del correo: + # 1. se quita de la lista el correo si no tiene adjunto + # 2. se quita de la lista el correo si no tiene + + for e in emails: + print(f"\n📬 [{e['fecha']}] {e['subject'][:80]}...") + archivos_adjuntos = descargar_adjunto_de_correo(outlook_token, e['id'], CONFIG.client.ruta_descarga) + +######################################################## +if __name__ == "__main__": + + # Configurar Logger (Redirige stdout a archivo y consola) + log_path = logger_utilities.setup_logger() + logger_utilities.limpiar_logs_antiguos(dias=30) + + inicio_ejecucion = datetime.now() + print("="*60) + print(f"🚀 INICIO DE EJECUCIÓN: {inicio_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}") + print(f"📄 Log guardado en: {log_path}") + print("="*60) + + main() + + fin_ejecucion = datetime.now() + duracion = fin_ejecucion - inicio_ejecucion + print("\n" + "="*60) + print(f"🏁 FIN DE EJECUCIÓN: {fin_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}") + print(f"⏱️ Duración total: {str(duracion).split('.')[0]}") # Sin microsegundos + print("="*60) diff --git a/outlook_utilities.py b/outlook_utilities.py new file mode 100644 index 0000000..d2f90a4 --- /dev/null +++ b/outlook_utilities.py @@ -0,0 +1,140 @@ +import msal +import requests +import warnings +import base64 +import os +from datetime import datetime, timedelta +from config_loader import CONFIG + +warnings.filterwarnings("ignore") + +######################################################## +def get_access_token(): + """ + Obtiene un token de acceso de Microsoft Graph usando las credenciales + configuradas en config.json (Client ID, Client Secret y Tenant ID). + """ + if not CONFIG or not CONFIG.outlook: + raise ValueError("La configuración de Outlook no está cargada correctamente.") + + authority = f"https://login.microsoftonline.com/{CONFIG.outlook.tenant_id}" + + app = msal.ConfidentialClientApplication( + CONFIG.outlook.client_id, + authority=authority, + client_credential=CONFIG.outlook.client_secret, + verify=CONFIG.outlook.verify_ssl + ) + + scopes = ["https://graph.microsoft.com/.default"] + result = app.acquire_token_for_client(scopes=scopes) + + if "access_token" in result: + return result["access_token"] + else: + error = result.get("error") + error_description = result.get("error_description") + correlation_id = result.get("correlation_id") + raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})") + +######################################################## +def get_emails(user_email, token, fecha_inicio, fecha_fin): + """ + Obtiene los correos de una cuenta específica en un rango de fechas. + Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject y has_attach. + """ + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + dt_inicio_utc = datetime.strptime(fecha_inicio, "%Y-%m-%d") + timedelta(hours=5) + dt_fin_utc = datetime.strptime(fecha_fin, "%Y-%m-%d") + timedelta(days=1, hours=5) + + filter_query = ( + f"receivedDateTime ge {dt_inicio_utc.strftime('%Y-%m-%dT%H:%M:%SZ')} and " + f"receivedDateTime lt {dt_fin_utc.strftime('%Y-%m-%dT%H:%M:%SZ')}" + ) + + params = { + "$filter": filter_query, + "$select": "id,receivedDateTime,hasAttachments,subject,from", + "$top": 50 + } + + url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages" + all_mails = [] + + while url: + response = requests.get( + url, + headers=headers, + params=params, + verify=CONFIG.outlook.verify_ssl + ) + + if response.status_code != 200: + raise Exception(f"Error Graph API ({user_email}): {response.status_code} - {response.text}") + + data = response.json() + messages = data.get("value", []) + + for msg in messages: + utc_str = msg.get("receivedDateTime") + dt_utc = datetime.fromisoformat(utc_str.replace("Z", "+00:00")) + dt_local = dt_utc - timedelta(hours=5) + + from_obj = msg.get("from", {}) + sender = from_obj.get("emailAddress", {}).get("address", "Desconocido") + + all_mails.append({ + "id": msg.get("id"), + "fecha": dt_local.strftime("%Y-%m-%d %H:%M:%S"), + "sender": sender, + "subject": msg.get("subject", ""), + "has_attach": msg.get("hasAttachments") + }) + + url = data.get("@odata.nextLink") + params = None + + return all_mails + +######################################################## +def download_attachments(user_email, message_id, token, download_path): + """ + Descarga los adjuntos de un mensaje específico y los guarda en la ruta indicada. + Retorna una lista con los nombres de los archivos descargados. + """ + if not os.path.exists(download_path): + os.makedirs(download_path, exist_ok=True) + + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages/{message_id}/attachments" + + response = requests.get(url, headers=headers, verify=CONFIG.outlook.verify_ssl) + + if response.status_code != 200: + raise Exception(f"Error al obtener adjuntos ({message_id}): {response.status_code} - {response.text}") + + attachments = response.json().get("value", []) + downloaded_files = [] + + for attachment in attachments: + if attachment.get("@odata.type") == "#microsoft.graph.fileAttachment": + file_name = attachment.get("name") + content_bytes = attachment.get("contentBytes") + + if content_bytes: + file_path = os.path.join(download_path, file_name) + with open(file_path, "wb") as f: + f.write(base64.b64decode(content_bytes)) + downloaded_files.append(file_name) + + return downloaded_files + +########################################################