Añadir herramienta Export Outlook Mailbox
This commit is contained in:
826
microsoft/ExportOutlookMailbox/ExportOutlookMailbox.pyw
Normal file
826
microsoft/ExportOutlookMailbox/ExportOutlookMailbox.pyw
Normal 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>✓ 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()
|
||||
191
microsoft/ExportOutlookMailbox/README.md
Normal file
191
microsoft/ExportOutlookMailbox/README.md
Normal 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
|
||||
Reference in New Issue
Block a user