#!/usr/bin/env python3 # -*- coding: utf-8 -*- import tkinter as tk from tkinter import ttk, filedialog, messagebox, scrolledtext import requests from datetime import datetime import threading import os import base64 import email from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.base import MIMEBase from email import encoders import mailbox import time import webbrowser from urllib.parse import urlencode, parse_qs from http.server import HTTPServer, BaseHTTPRequestHandler import socket import json CLIENT_ID = "your_client_id" TENANT_ID = "your_tenant_id" CLIENT_SECRET = "your_client_secret" class OutlookBackup: def __init__(self, root): self.root = root self.root.title("Mail Backup - Microsoft Graph API") self.root.geometry("1000x850") # Variables self.access_token = None self.refresh_token = None self.user_email = None self.output_folder = None self.is_running = False self.auth_code = None # Configuración API self.auth_method = tk.StringVar(value="interactive") self.CLIENT_ID = CLIENT_ID self.TENANT_ID = TENANT_ID self.CLIENT_SECRET = CLIENT_SECRET # Estadísticas self.total_emails = 0 self.downloaded_emails = 0 self.failed_emails = 0 self.setup_ui() def setup_ui(self): # Frame principal main_frame = ttk.Frame(self.root, padding="10") main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) # 0. Selección de método de autenticación ttk.Label(main_frame, text="0. Authentication method:", font=('Arial', 10, 'bold')).grid( row=0, column=0, sticky=tk.W, pady=(0,5)) auth_frame = ttk.LabelFrame(main_frame, text="Choose method", padding="10") auth_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0,15)) # Left side: Radio buttons rb_frame = ttk.Frame(auth_frame) rb_frame.grid(row=0, column=0, sticky=tk.NW, padx=(0, 20)) ttk.Radiobutton(rb_frame, text="Interactive Login (OAuth2)", variable=self.auth_method, value="interactive", command=self._update_auth_ui).grid(row=0, column=0, sticky=tk.W, pady=5) ttk.Radiobutton(rb_frame, text="Service Account (Client Credentials)", variable=self.auth_method, value="service", command=self._update_auth_ui).grid(row=1, column=0, sticky=tk.W, pady=5) # Right side: Service credentials frame (visible only if service account is selected) self.service_frame = ttk.LabelFrame(auth_frame, text="Application Credentials", padding="10") self.service_frame.grid(row=0, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) ttk.Label(self.service_frame, text="Client ID:").grid(row=0, column=0, sticky=tk.W, padx=(0,10)) self.client_id_entry = ttk.Entry(self.service_frame, width=50) self.client_id_entry.grid(row=0, column=1, sticky=(tk.W, tk.E)) self.client_id_entry.insert(0, CLIENT_ID) ttk.Label(self.service_frame, text="Tenant ID:").grid(row=1, column=0, sticky=tk.W, padx=(0,10), pady=(5,0)) self.tenant_id_entry = ttk.Entry(self.service_frame, width=50) self.tenant_id_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), pady=(5,0)) self.tenant_id_entry.insert(0, TENANT_ID) ttk.Label(self.service_frame, text="Client Secret:").grid(row=2, column=0, sticky=tk.W, padx=(0,10), pady=(5,0)) self.client_secret_entry = ttk.Entry(self.service_frame, width=50, show="*") self.client_secret_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), pady=(5,0)) self.client_secret_entry.insert(0, CLIENT_SECRET) # 1. Configuración de cuenta ttk.Label(main_frame, text="1. Account Configuration:", font=('Arial', 10, 'bold')).grid( row=2, column=0, sticky=tk.W, pady=(15,5)) account_frame = ttk.Frame(main_frame) account_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0,15)) ttk.Label(account_frame, text="Email address:").grid(row=0, column=0, sticky=tk.W, padx=(0,10)) self.email_entry = ttk.Entry(account_frame, width=40) self.email_entry.grid(row=0, column=1, sticky=tk.W) self.email_entry.insert(0, "email@example.com") ttk.Button(account_frame, text="Connect", command=self.connect_account).grid( row=0, column=2, padx=(10,0)) self.connection_label = ttk.Label(account_frame, text="Not connected", foreground="red") self.connection_label.grid(row=0, column=3, padx=(10,0)) # 2. Opciones de exportación ttk.Label(main_frame, text="2. Export Options:", font=('Arial', 10, 'bold')).grid( row=4, column=0, sticky=tk.W, pady=(15,5)) options_frame = ttk.LabelFrame(main_frame, text="Export Options", padding="10") options_frame.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0,15)) # Format block (Left) format_block = ttk.Frame(options_frame) format_block.grid(row=0, column=0, sticky=tk.NW, padx=(0, 20)) self.export_format = tk.StringVar(value="mbox") ttk.Radiobutton(format_block, text="MBOX (single file, compatible with Thunderbird/Outlook)", variable=self.export_format, value="mbox").grid(row=0, column=0, sticky=tk.W, pady=2) ttk.Radiobutton(format_block, text="EML (individual files per email)", variable=self.export_format, value="eml").grid(row=1, column=0, sticky=tk.W, pady=2) ttk.Radiobutton(format_block, text="Both formats", variable=self.export_format, value="both").grid(row=2, column=0, sticky=tk.W, pady=2) # Additional options block (Right) misc_block = ttk.Frame(options_frame) misc_block.grid(row=0, column=1, sticky=tk.NW, padx=(10, 0)) self.include_attachments = tk.BooleanVar(value=True) ttk.Checkbutton(misc_block, text="Include attachments", variable=self.include_attachments).grid(row=0, column=0, sticky=tk.W, pady=2) self.include_folders = tk.BooleanVar(value=True) ttk.Checkbutton(misc_block, text="Keep folder structure", variable=self.include_folders).grid(row=1, column=0, sticky=tk.W, pady=2) # 3. Carpeta de destino ttk.Label(main_frame, text="3. Destination folder:", font=('Arial', 10, 'bold')).grid( row=6, column=0, sticky=tk.W, pady=(15,5)) folder_frame = ttk.Frame(main_frame) folder_frame.grid(row=7, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0,15)) self.folder_label = ttk.Label(folder_frame, text="No folder selected", foreground="gray") self.folder_label.pack(side=tk.LEFT, padx=(0,10)) ttk.Button(folder_frame, text="Select folder...", command=self.select_output_folder).pack(side=tk.LEFT) # 4. Botón de inicio button_frame = ttk.Frame(main_frame) button_frame.grid(row=8, column=0, columnspan=3, pady=(10,15)) self.start_button = ttk.Button(button_frame, text="START BACKUP", command=self.start_backup, state="disabled") self.start_button.pack(side=tk.LEFT, padx=5) self.stop_button = ttk.Button(button_frame, text="STOP", command=self.stop_backup, state="disabled") self.stop_button.pack(side=tk.LEFT, padx=5) # 5. Progreso ttk.Label(main_frame, text="Progress:", font=('Arial', 10, 'bold')).grid( row=9, column=0, sticky=tk.W, pady=(0,5)) self.progress = ttk.Progressbar(main_frame, mode='determinate', length=400) self.progress.grid(row=10, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0,5)) self.progress_label = ttk.Label(main_frame, text="0 / 0 emails downloaded") self.progress_label.grid(row=11, column=0, columnspan=3, sticky=tk.W, pady=(0,15)) # 6. Log ttk.Label(main_frame, text="Log:", font=('Arial', 10, 'bold')).grid( row=12, column=0, sticky=tk.W, pady=(0,5)) self.log_text = scrolledtext.ScrolledText(main_frame, height=10, width=100, state="disabled") self.log_text.grid(row=13, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0,10)) # Configurar expansión self.root.columnconfigure(0, weight=1) self.root.rowconfigure(0, weight=1) main_frame.columnconfigure(0, weight=1) main_frame.columnconfigure(1, weight=1) # Mostrar/ocultar frame de credenciales self._update_auth_ui() def _update_auth_ui(self): """Muestra u oculta el frame de credenciales según el método seleccionado""" if self.auth_method.get() == "service": self.service_frame.grid() else: self.service_frame.grid_remove() def log_message(self, message): """Añade un mensaje al log""" self.log_text.config(state="normal") self.log_text.insert(tk.END, f"{datetime.now().strftime('%H:%M:%S')} - {message}\n") self.log_text.see(tk.END) self.log_text.config(state="disabled") self.root.update() def get_access_token(self): """Obtiene el access token según el método de autenticación seleccionado""" if self.auth_method.get() == "interactive": return self._get_token_interactive() else: return self._get_token_client_credentials() def _get_token_client_credentials(self): """Obtiene el access token usando Client Credentials""" client_id = self.client_id_entry.get().strip() tenant_id = self.tenant_id_entry.get().strip() client_secret = self.client_secret_entry.get().strip() if not all([client_id, tenant_id, client_secret]): raise ValueError("Client ID, Tenant ID and Client Secret are required") url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" data = { 'client_id': client_id, 'client_secret': client_secret, 'scope': 'https://graph.microsoft.com/.default', 'grant_type': 'client_credentials' } response = requests.post(url, data=data) response.raise_for_status() return response.json()['access_token'] def _get_token_interactive(self): """Obtiene el access token usando Authorization Code Flow""" # Pedir Client ID y Tenant ID dialog = tk.Toplevel(self.root) dialog.title("Application Credentials") dialog.geometry("500x220") dialog.transient(self.root) dialog.grab_set() dialog.columnconfigure(1, weight=1) ttk.Label(dialog, text="For interactive login you need:", font=('Arial', 10, 'bold')).grid(row=0, column=0, columnspan=2, pady=(15, 5)) ttk.Label(dialog, text="Enter your registered application data:").grid(row=1, column=0, columnspan=2, pady=(0, 10)) ttk.Label(dialog, text="Client ID:").grid(row=2, column=0, sticky=tk.E, padx=(20, 10), pady=5) client_id_entry = ttk.Entry(dialog, width=50) client_id_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=(0, 20), pady=5) ttk.Label(dialog, text="Tenant ID:").grid(row=3, column=0, sticky=tk.E, padx=(20, 10), pady=5) tenant_id_entry = ttk.Entry(dialog, width=50) tenant_id_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), padx=(0, 20), pady=5) ttk.Label(dialog, text="Redirect URI:").grid(row=4, column=0, sticky=tk.E, padx=(20, 10), pady=5) redirect_uri_entry = ttk.Entry(dialog, width=50) redirect_uri_entry.insert(0, "http://localhost:8000") redirect_uri_entry.grid(row=4, column=1, sticky=(tk.W, tk.E), padx=(0, 20), pady=5) result = {"ok": False} def on_ok(): result["client_id"] = client_id_entry.get().strip() result["tenant_id"] = tenant_id_entry.get().strip() result["redirect_uri"] = redirect_uri_entry.get().strip() if all([result["client_id"], result["tenant_id"], result["redirect_uri"]]): result["ok"] = True dialog.destroy() else: messagebox.showwarning("Error", "All fields are required", parent=dialog) ttk.Button(dialog, text="Continue", command=on_ok).grid(row=5, column=0, columnspan=2, pady=15) self.root.wait_window(dialog) if not result["ok"]: raise ValueError("Interactive login was cancelled") client_id = result["client_id"] tenant_id = result["tenant_id"] redirect_uri = result["redirect_uri"] self.log_message("Starting interactive login in browser...") # Generar auth URL auth_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/authorize" params = { 'client_id': client_id, 'redirect_uri': redirect_uri, 'scope': 'Mail.Read Mail.ReadWrite offline_access', 'response_type': 'code', 'prompt': 'login' } full_auth_url = auth_url + '?' + urlencode(params) # Abrir navegador webbrowser.open(full_auth_url) # Iniciar servidor local para capturar el código self.log_message("Waiting for browser response...") auth_code = self._start_callback_server(redirect_uri) if not auth_code: raise ValueError("No authorization code received") # Intercambiar código por token token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" token_data = { 'client_id': client_id, 'code': auth_code, 'redirect_uri': redirect_uri, 'scope': 'Mail.Read Mail.ReadWrite offline_access', 'grant_type': 'authorization_code' } response = requests.post(token_url, data=token_data) response.raise_for_status() response_json = response.json() self.refresh_token = response_json.get('refresh_token') return response_json['access_token'] def _start_callback_server(self, redirect_uri): """Inicia un servidor local para capturar el código de autorización""" port = int(redirect_uri.split(':')[-1]) auth_code_holder = {"code": None} class CallbackHandler(BaseHTTPRequestHandler): def do_GET(handler_self): # Parsear la query string query = handler_self.path.split('?')[1] if '?' in handler_self.path else "" params = parse_qs(query) if 'code' in params: auth_code_holder["code"] = params['code'][0] handler_self.send_response(200) handler_self.send_header('Content-type', 'text/html; charset=utf-8') handler_self.end_headers() html = """
You can close this window and return to the application.
""" handler_self.wfile.write(html.encode()) elif 'error' in params: handler_self.send_response(400) handler_self.send_header('Content-type', 'text/html; charset=utf-8') handler_self.end_headers() error_desc = params.get('error_description', [''])[0] html = f"""{error_desc}
""" handler_self.wfile.write(html.encode()) def log_message(handler_self, format, *args): pass # Suprimir logs del servidor try: server = HTTPServer(('localhost', port), CallbackHandler) server.timeout = 120 # Esperar máximo 2 minutos while auth_code_holder["code"] is None: server.handle_request() if not server.timeout: break return auth_code_holder["code"] except Exception as e: self.log_message(f"Error in callback server: {str(e)}") return None finally: try: server.server_close() except: pass def _make_api_request(self, url, method='GET', **kwargs): """Realiza una petición a la API con renovación automática de token""" max_retries = 2 for attempt in range(max_retries): headers = kwargs.get('headers', {}) headers['Authorization'] = f'Bearer {self.access_token}' kwargs['headers'] = headers if method == 'GET': response = requests.get(url, **kwargs) else: response = requests.post(url, **kwargs) # Si es 401, renovar token y reintentar if response.status_code == 401 and attempt < max_retries - 1: self.log_message("⚠ Token expired, renewing...") if self.auth_method.get() == "interactive" and self.refresh_token: self.access_token = self._refresh_access_token() else: self.access_token = self.get_access_token() continue response.raise_for_status() return response return response def _refresh_access_token(self): """Refresca el access token usando el refresh token (para flujo interactivo)""" # Mostrar diálogo para obtener credenciales nuevamente ya que no tenemos el refresh token almacenado # En producción, estos datos deberían almacenarse de forma segura dialog = tk.Toplevel(self.root) dialog.title("Application Credentials") dialog.geometry("550x200") dialog.transient(self.root) dialog.grab_set() dialog.columnconfigure(1, weight=1) ttk.Label(dialog, text="Credentials are needed to renew the token:").grid(row=0, column=0, columnspan=2, pady=10) ttk.Label(dialog, text="Client ID:").grid(row=1, column=0, sticky=tk.E, padx=(20, 10), pady=5) client_id_entry = ttk.Entry(dialog, width=50) client_id_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=(0, 20), pady=5) ttk.Label(dialog, text="Tenant ID:").grid(row=2, column=0, sticky=tk.E, padx=(20, 10), pady=5) tenant_id_entry = ttk.Entry(dialog, width=50) tenant_id_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=(0, 20), pady=5) result = {"ok": False} def on_ok(): result["client_id"] = client_id_entry.get().strip() result["tenant_id"] = tenant_id_entry.get().strip() result["redirect_uri"] = "http://localhost:8000" if result["client_id"] and result["tenant_id"]: result["ok"] = True dialog.destroy() else: messagebox.showwarning("Error", "All fields are required", parent=dialog) ttk.Button(dialog, text="Renew", command=on_ok).grid(row=3, column=0, columnspan=2, pady=15) self.root.wait_window(dialog) if not result["ok"]: raise ValueError("Token renewal was cancelled") token_url = f"https://login.microsoftonline.com/{result['tenant_id']}/oauth2/v2.0/token" token_data = { 'client_id': result["client_id"], 'refresh_token': self.refresh_token, 'grant_type': 'refresh_token', 'scope': 'Mail.Read Mail.ReadWrite offline_access' } response = requests.post(token_url, data=token_data) response.raise_for_status() response_json = response.json() if 'refresh_token' in response_json: self.refresh_token = response_json['refresh_token'] return response_json['access_token'] def connect_account(self): """Conecta con la cuenta de Outlook""" self.user_email = self.email_entry.get().strip() if not self.user_email: messagebox.showwarning("Warning", "Please enter an email address") return try: self.log_message("Obtaining access token...") self.access_token = self.get_access_token() self.log_message("✓ Token obtained successfully") # Verificar acceso a la cuenta headers = {'Authorization': f'Bearer {self.access_token}'} url = f"https://graph.microsoft.com/v1.0/users/{self.user_email}/mailFolders" response = self._make_api_request(url) self.connection_label.config(text="✓ Connected", foreground="green") self.log_message(f"✓ Connected to {self.user_email}") # Contar correos totales self.count_total_emails() except Exception as e: messagebox.showerror("Error", f"Error connecting:\n{str(e)}") self.log_message(f"❌ Error: {str(e)}") self.connection_label.config(text="✗ Error", foreground="red") def count_total_emails(self): """Cuenta el total de correos en la cuenta""" try: headers = {'Authorization': f'Bearer {self.access_token}'} url = f"https://graph.microsoft.com/v1.0/users/{self.user_email}/messages/$count" response = self._make_api_request(url) self.total_emails = int(response.text) self.log_message(f"Total emails found: {self.total_emails}") self.progress_label.config(text=f"0 / {self.total_emails} emails downloaded") if self.output_folder: self.start_button.config(state="normal") except Exception as e: self.log_message(f"⚠ Could not count emails: {str(e)}") def select_output_folder(self): """Selecciona la carpeta de destino""" folder = filedialog.askdirectory(title="Select destination folder") if folder: self.output_folder = folder self.folder_label.config(text=folder, foreground="black") self.log_message(f"Destination folder: {folder}") if self.access_token: self.start_button.config(state="normal") def start_backup(self): """Inicia el proceso de backup""" if not self.access_token or not self.output_folder: messagebox.showerror("Error", "You must connect the account and select a destination folder") return # Confirmar if not messagebox.askyesno("Confirm", f"Start backup of {self.total_emails} emails?\n\n" f"This may take several minutes."): return # Deshabilitar controles self.start_button.config(state="disabled") self.stop_button.config(state="normal") self.is_running = True # Resetear contadores self.downloaded_emails = 0 self.failed_emails = 0 # Iniciar en thread separado thread = threading.Thread(target=self._do_backup) thread.daemon = True thread.start() def stop_backup(self): """Detiene el proceso de backup""" self.is_running = False self.log_message("⚠ Stopping backup...") self.stop_button.config(state="disabled") def _do_backup(self): """Realiza el backup en un thread separado""" try: self.log_message("="*60) self.log_message("STARTING BACKUP") self.log_message(f"Account: {self.user_email}") self.log_message(f"Format: {self.export_format.get()}") self.log_message(f"Attachments: {'Yes' if self.include_attachments.get() else 'No'}") self.log_message("="*60) # Crear estructura de carpetas timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") embkuptxt = str(self.user_email).replace("@","_").replace(".","_") backup_folder = os.path.join(self.output_folder, f"outlook_backup_{embkuptxt}_{timestamp}") os.makedirs(backup_folder, exist_ok=True) # No inicializar MBOX aquí, se creará uno por carpeta formato = self.export_format.get() self.log_message(f"Selected format: '{formato}'") # Obtener todas las carpetas de correo folders = self._get_all_folders() self.log_message(f"Folders found: {len(folders)}") # Descargar correos de cada carpeta for folder in folders: if not self.is_running: break folder_name = folder['displayName'] folder_id = folder['id'] self.log_message(f"\n📁 Processing folder: {folder_name}") # Crear subcarpeta si es necesario if self.export_format.get() in ["eml", "both"] and self.include_folders.get(): folder_path = os.path.join(backup_folder, self._sanitize_filename(folder_name)) os.makedirs(folder_path, exist_ok=True) else: folder_path = backup_folder # Descargar correos de cada carpeta self._download_folder_emails(folder_id, folder_name, folder_path, backup_folder) # Resumen final self.log_message("="*60) self.log_message("BACKUP COMPLETED") self.log_message(f"✓ Emails downloaded: {self.downloaded_emails}") self.log_message(f"❌ Errors: {self.failed_emails}") self.log_message(f"📁 Location: {backup_folder}") self.log_message("="*60) self.root.after(0, lambda: messagebox.showinfo("Backup completed", f"Backup finished:\n\n" f"✓ Downloaded: {self.downloaded_emails}\n" f"❌ Errors: {self.failed_emails}\n\n" f"Location: {backup_folder}")) except Exception as e: self.log_message(f"❌ Critical error: {str(e)}") self.root.after(0, lambda: messagebox.showerror("Error", f"Error during backup:\n{str(e)}")) finally: self.is_running = False self.start_button.config(state="normal") self.stop_button.config(state="disabled") def _get_all_folders(self): """Obtiene todas las carpetas de correo recursivamente""" folders = [] headers = {'Authorization': f'Bearer {self.access_token}'} def get_folders_recursive(parent_id=None): if parent_id: url = f"https://graph.microsoft.com/v1.0/users/{self.user_email}/mailFolders/{parent_id}/childFolders" else: url = f"https://graph.microsoft.com/v1.0/users/{self.user_email}/mailFolders" response = self._make_api_request(url) folder_list = response.json().get('value', []) for folder in folder_list: folders.append(folder) # Recursivamente obtener subcarpetas get_folders_recursive(folder['id']) get_folders_recursive() return folders def _download_folder_emails(self, folder_id, folder_name, folder_path, backup_folder): """Descarga todos los correos de una carpeta""" headers = {'Authorization': f'Bearer {self.access_token}'} url = f"https://graph.microsoft.com/v1.0/users/{self.user_email}/mailFolders/{folder_id}/messages" # Crear MBOX específico para esta carpeta si es necesario folder_mbox = None if self.export_format.get() in ["mbox", "both"]: mbox_filename = f"{self._sanitize_filename(folder_name)}.mbox" mbox_path = os.path.join(folder_path if self.include_folders.get() else backup_folder, mbox_filename) folder_mbox = mailbox.mbox(mbox_path) folder_mbox.lock() # AÑADIR ESTA LÍNEA self.log_message(f" Creating MBOX: {mbox_filename}") while url and self.is_running: try: response = self._make_api_request(url) data = response.json() messages = data.get('value', []) for message in messages: if not self.is_running: break try: self._save_email(message, folder_name, folder_path, folder_mbox) self.downloaded_emails += 1 # Actualizar progreso if self.total_emails > 0: progress = (self.downloaded_emails / self.total_emails) * 100 self.progress['value'] = progress self.progress_label.config( text=f"{self.downloaded_emails} / {self.total_emails} emails downloaded") if self.downloaded_emails % 50 == 0: self.log_message(f" ✓ {self.downloaded_emails} emails processed...") except Exception as e: self.failed_emails += 1 self.log_message(f" ❌ Error in email: {str(e)}") # Siguiente página url = data.get('@odata.nextLink') # Pequeña pausa para no saturar la API time.sleep(0.1) except Exception as e: self.log_message(f"❌ Error downloading folder {folder_name}: {str(e)}") break # Cerrar MBOX de la carpeta if folder_mbox is not None: folder_mbox.flush() folder_mbox.unlock() folder_mbox.close() def _save_email(self, message, folder_name, folder_path, mbox): """Guarda un correo en el formato seleccionado""" # Crear mensaje MIME msg = self._create_mime_message(message) # Guardar en MBOX if mbox is not None and self.export_format.get() in ["mbox", "both"]: try: # Convertir a formato mailbox.mboxMessage mbox_msg = mailbox.mboxMessage(msg) mbox.add(mbox_msg) # Log cada 20 correos para verificar # if self.downloaded_emails % 20 == 0: # self.log_message(f" 📧 MBOX: {len(mbox)} correos guardados") # mbox.flush() except Exception as e: self.log_message(f" ❌ Error saving to MBOX: {str(e)}") # Guardar como EML if self.export_format.get() in ["eml", "both"]: # Usar el ID del mensaje como nombre de archivo message_id = message.get('id', '') filename = f"{message_id}.eml" filepath = os.path.join(folder_path, filename) with open(filepath, 'wb') as f: f.write(msg.as_bytes()) def _create_mime_message(self, message): """Crea un mensaje MIME a partir de un mensaje de Graph API""" msg = MIMEMultipart() # Headers básicos msg['Subject'] = message.get('subject', 'No subject') msg['From'] = message.get('from', {}).get('emailAddress', {}).get('address', '') to_recipients = message.get('toRecipients', []) if to_recipients: msg['To'] = ', '.join([r.get('emailAddress', {}).get('address', '') for r in to_recipients]) msg['Date'] = message.get('receivedDateTime', '') # Cuerpo del mensaje body = message.get('body', {}) content = body.get('content', '') content_type = body.get('contentType', 'text') if content_type.lower() == 'html': msg.attach(MIMEText(content, 'html', 'utf-8')) else: msg.attach(MIMEText(content, 'plain', 'utf-8')) # Adjuntos if self.include_attachments.get() and message.get('hasAttachments'): self._add_attachments(msg, message['id']) return msg def _add_attachments(self, msg, message_id): """Añade los adjuntos a un mensaje MIME""" try: headers = {'Authorization': f'Bearer {self.access_token}'} url = f"https://graph.microsoft.com/v1.0/users/{self.user_email}/messages/{message_id}/attachments" response = self._make_api_request(url) attachments = response.json().get('value', []) for attachment in attachments: if attachment.get('@odata.type') == '#microsoft.graph.fileAttachment': content_bytes = base64.b64decode(attachment.get('contentBytes', '')) part = MIMEBase('application', 'octet-stream') part.set_payload(content_bytes) encoders.encode_base64(part) part.add_header('Content-Disposition', f'attachment; filename="{attachment.get("name", "attachment")}"') msg.attach(part) except Exception as e: self.log_message(f" ⚠ Error downloading attachments: {str(e)}") def _sanitize_filename(self, filename): """Limpia un nombre de archivo de caracteres no válidos""" invalid_chars = '<>:"/\\|?*' for char in invalid_chars: filename = filename.replace(char, '_') return filename[:200] # Limitar longitud def main(): root = tk.Tk() app = OutlookBackup(root) root.mainloop() if __name__ == "__main__": main()