Añadir herramienta Export Outlook Mailbox

This commit is contained in:
2026-02-11 09:51:34 +01:00
parent e733509327
commit a057953f4c
2 changed files with 1017 additions and 0 deletions

View File

@@ -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 = """
<html>
<head><title>Authentication completed</title></head>
<body style="font-family: Arial;">
<h2>&#10003; Authentication completed</h2>
<p>You can close this window and return to the application.</p>
</body>
</html>
"""
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"""
<html>
<head><title>Authentication Error</title></head>
<body style="font-family: Arial;">
<h2>Error: {params['error'][0]}</h2>
<p>{error_desc}</p>
</body>
</html>
"""
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()

View File

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