Files
usefulscripts/microsoft/ExportOutlookMailbox/ExportOutlookMailbox.pyw

826 lines
29 KiB
Python

#!/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()