From a057953f4c5f9a9402aed8e035baaa10f6fea3a7 Mon Sep 17 00:00:00 2001 From: KEVIN PUERTAS RUIZ Date: Wed, 11 Feb 2026 09:51:34 +0100 Subject: [PATCH] =?UTF-8?q?A=C3=B1adir=20herramienta=20Export=20Outlook=20?= =?UTF-8?q?Mailbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExportOutlookMailbox.pyw | 826 ++++++++++++++++++ microsoft/ExportOutlookMailbox/README.md | 191 ++++ 2 files changed, 1017 insertions(+) create mode 100644 microsoft/ExportOutlookMailbox/ExportOutlookMailbox.pyw create mode 100644 microsoft/ExportOutlookMailbox/README.md diff --git a/microsoft/ExportOutlookMailbox/ExportOutlookMailbox.pyw b/microsoft/ExportOutlookMailbox/ExportOutlookMailbox.pyw new file mode 100644 index 0000000..452ede8 --- /dev/null +++ b/microsoft/ExportOutlookMailbox/ExportOutlookMailbox.pyw @@ -0,0 +1,826 @@ +#!/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 = """ + + Authentication completed + +

✓ Authentication completed

+

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""" + + Authentication Error + +

Error: {params['error'][0]}

+

{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() \ No newline at end of file diff --git a/microsoft/ExportOutlookMailbox/README.md b/microsoft/ExportOutlookMailbox/README.md new file mode 100644 index 0000000..63a28a8 --- /dev/null +++ b/microsoft/ExportOutlookMailbox/README.md @@ -0,0 +1,191 @@ +# Outlook Email Backup Tool + +A Python GUI application that exports emails from Microsoft Outlook/Exchange accounts to local files using the Microsoft Graph API. + +## Features + +- **Dual Authentication Methods:** + - Interactive OAuth2 login (browser-based) + - Service account credentials (Client Credentials flow) + +- **Multiple Export Formats:** + - MBOX (single file, compatible with Thunderbird/Outlook) + - EML (individual files per email) + - Both formats simultaneously + +- **Flexible Options:** + - Include/exclude attachments + - Preserve folder structure + + +## Requirements + +### System Requirements +- Python 3.7 or higher +- Windows, macOS, or Linux +- Microsoft Azure Account (for application registration) + +## Installation + +1. Clone or download the project +2. Install Python dependencies: +```bash +pip install requests +``` + +3. Register an Azure Application + +## Azure Application Setup + +You need to register an application in Azure AD to use this tool. Follow these steps: + +### 1. Register Application in Azure Portal + +1. Go to [Azure Portal](https://portal.azure.com) +2. Navigate to "Azure Active Directory" → "App registrations" +3. Click "New registration" +4. Fill in the details: + - **Name:** e.g., "Outlook Backup Tool" + - **Supported account types:** "Accounts in this organizational directory only" (single-tenant) + - **Redirect URI:** + - For interactive login: `http://localhost:8000` + - For service account: Leave empty +5. Click "Register" + +### 2. Configure API Permissions + +1. In the app settings, go to "API permissions" +2. Click "Add a permission" +3. Select "Microsoft Graph" +4. Choose **Delegated permissions** (for interactive login) OR **Application permissions** (for service account): + - Search and select: + - `Mail.Read` + - `Mail.ReadWrite` + - `offline_access` (only for interactive login) +5. Click "Add permissions" + +### 3. For Service Account Method Only + +1. Go to "Certificates & secrets" +2. Click "New client secret" +3. Set expiration and create +4. **Copy the secret value immediately** (you won't see it again) + +### 4. Get Your Credentials + +From the app overview page, copy: +- **Application (client) ID** - Use as "Client ID" +- **Directory (tenant) ID** - Use as "Tenant ID" + +## Usage + +### Running the Application + +```bash +python ExportOutlookMailbox.pyw +``` + +Or on Windows, you can double-click the `.pyw` file. + +### Authentication Methods + +#### Method 1: Interactive OAuth2 Login (Recommended for Users) + +1. Select "Interactive login (OAuth2)" radio button +2. Click "Connect" +3. A dialog will ask for: + - **Client ID** (from Azure app registration) + - **Tenant ID** (from Azure app registration) + - **Redirect URI** (default: `http://localhost:8000`) +4. Your default browser will open - sign in with your email +5. After authentication, you'll be redirected back to the app +6. The tool will be ready to backup emails + +#### Method 2: Service Account (Client Credentials Flow) + +1. Select "Service account (Client Credentials)" radio button +2. Credential fields will appear +3. Fill in: + - **Client ID** - From Azure app registration + - **Tenant ID** - From Azure app registration + - **Client Secret** - Created in "Certificates & secrets" +4. Enter the email address of the mailbox to backup +5. Click "Connect" + +### Backup Process + +1. **Select authentication method** and provide credentials +2. **Enter email address** to backup (for service account method) +3. **Choose export format:** + - MBOX - Recommended for single archive file + - EML - Individual files per email (better for editing) + - Both - Export in both formats +4. **Select options:** + - ☑ Include attachments (uncheck to skip attachments) + - ☑ Preserve folder structure (mirror Outlook folder hierarchy) +5. **Select output folder** - Where to save the backup +6. **Click "START BACKUP"** - Start the backup process +7. **Monitor progress** - Watch the log and progress bar +8. **Click "STOP BACKUP"** to cancel if needed + +### Output Structure + +``` +outlook_backup_email@example_com_20260211_143022/ +├── Inbox/ +│ ├── message1.eml +│ ├── message2.eml +│ └── Inbox.mbox +├── Sent Items/ +│ ├── message1.eml +│ ├── message2.eml +│ └── Sent Items.mbox +├── Archive/ +│ └── ... +``` + +## Export Formats Explained + +### MBOX Format +- **Single file** containing all emails from a folder +- **Compatible with:** Thunderbird, Outlook, Apple Mail, many email clients + +### EML Format +- **Individual files** for each email (one .eml file per message) +- **Compatible with:** All email clients + +## Troubleshooting + +### Error: "Authentication failed" + +- **Interactive login:** + - Verify Client ID and Tenant ID are correct + - Check that permissions are granted in Azure AD + - Ensure redirect URI matches exactly in Azure (including http vs https) + +- **Service account:** + - Verify Client Secret hasn't expired + - Confirm application has required permissions + - Check email address format + +### Error: "Folder access denied" + +- The mailbox account may have restricted shared mailbox permissions +- For shared mailboxes with service account, ensure proper delegation + +### Port 8000 already in use + +- Change redirect URI in Azure app settings +- Update the redirect URI in the tool +- Use a different port (e.g., `http://localhost:8001`) + + +## API Limits + +Microsoft Graph API has rate limits: +- 4 requests per second (for multi-tenant apps) +- The tool includes delays to stay within limits + +If you hit rate limits: +- The tool will retry automatically +- Try with "Stop" and continue later