Release 1.0.4

This commit is contained in:
2026-06-12 11:45:50 +02:00
parent 2760dcf8a5
commit d64f197f1e

View File

@@ -2,6 +2,7 @@ import json
import os import os
import queue import queue
import re import re
import subprocess
import sys import sys
import tempfile import tempfile
import threading import threading
@@ -28,7 +29,7 @@ except ImportError:
win32com = None win32com = None
APP_TITLE = "Outlook email exporter ver. 1.0" APP_TITLE = "Outlook email exporter ver. 1.0.4"
APP_GEOMETRY = "1220x760+0+0" APP_GEOMETRY = "1220x760+0+0"
MAIL_CLASS = 43 # OlObjectClass.olMail MAIL_CLASS = 43 # OlObjectClass.olMail
MAX_LOG_LINES = 3000 MAX_LOG_LINES = 3000
@@ -44,11 +45,17 @@ OUTLOOK_NOT_RUNNING_MESSAGE = (
"Open Microsoft Outlook with the email profile you want to export, wait for it to finish loading, " "Open Microsoft Outlook with the email profile you want to export, wait for it to finish loading, "
"then minimize it and come back here to press 'Connect Outlook' again." "then minimize it and come back here to press 'Connect Outlook' again."
) )
NEW_OUTLOOK_UNSUPPORTED_MESSAGE = (
"New Outlook for Windows appears to be running, but this version of Outlook Email Exporter "
"requires Classic Outlook for Windows.\n\n"
"New Outlook does not expose the classic local COM/MAPI automation interface used by this tool. "
"Please open Classic Outlook, wait for it to finish loading, and then press 'Connect Outlook' again."
)
HELP_WINDOW_TITLE = "Help" HELP_WINDOW_TITLE = "Help"
HELP_TEXT = """Outlook Email Exporter 1.0 HELP_TEXT = """Outlook Email Exporter 1.0.4
Overview Overview
- Connect to a running Outlook session. - Connect to a running Classic Outlook session.
- Select an Outlook folder from the tree. - Select an Outlook folder from the tree.
- Choose an output folder. - Choose an output folder.
- Use immediate export to export the selected folder and all subfolders. - Use immediate export to export the selected folder and all subfolders.
@@ -58,8 +65,8 @@ Preview and filters
- Sender and Subject filters narrow the visible preview. - Sender and Subject filters narrow the visible preview.
- Period filters the preview by recent time ranges. - Period filters the preview by recent time ranges.
- New shows only emails not yet exported. - New shows only emails not yet exported.
- With attachments shows only emails with Outlook attachments. - Office/PDF/images shows only emails with office, PDF, or image attachments.
- Selected limits export to the rows currently selected in the preview. - Selected rows only limits export to the rows currently selected in the preview.
Export modes Export modes
- Create zip builds a ZIP archive. - Create zip builds a ZIP archive.
@@ -67,9 +74,17 @@ Export modes
- Export preview exports only the emails currently visible in the filtered preview. - Export preview exports only the emails currently visible in the filtered preview.
Notes Notes
- Outlook must already be installed and configured on this PC. - Classic Outlook for Windows must already be installed and configured on this PC.
- New Outlook for Windows is not supported by this version.
- Large folders can take a few minutes to load or export. - Large folders can take a few minutes to load or export.
- The program uses an incremental manifest to avoid exporting the same email twice. - The program uses an incremental manifest to avoid exporting the same email twice.
Version history
- 1.0.4: Outlook tree loading now runs in background workers, so progress overlays stay responsive while connecting, reloading folders, or opening slow tree nodes.
- 1.0.3: Added clearer progress overlay text and improved feedback while connecting to Outlook and loading the folder tree.
- 1.0.2: Improved preview selection filtering so selected rows remain stable while live filters refresh the preview.
- 1.0.1: Added Classic Outlook compatibility messaging and a warning when New Outlook for Windows is detected.
- 1.0.0: First packaged release with incremental export, preview filters, ZIP/file-system output, manifest checks, and log window.
""" """
ALLOWED_ATTACHMENT_EXTENSIONS = { ALLOWED_ATTACHMENT_EXTENSIONS = {
".pdf", ".pdf",
@@ -133,6 +148,49 @@ IMAGE_ATTACHMENT_EXTENSIONS = {
".tiff", ".tiff",
".webp", ".webp",
} }
PREVIEW_DOCUMENT_ATTACHMENT_EXTENSIONS = {
".pdf",
".doc",
".docx",
".docm",
".dot",
".dotx",
".dotm",
".xls",
".xlsx",
".xlsm",
".xlsb",
".xlt",
".xltx",
".xltm",
".csv",
".ppt",
".pptx",
".pptm",
".pps",
".ppsx",
".ppsm",
".pot",
".potx",
".potm",
".rtf",
".fodp",
".fods",
".fodt",
".odb",
".odf",
".odg",
".odt",
".ods",
".odp",
".otg",
".oth",
".otp",
".ots",
".ott",
".sxc",
}
PREVIEW_FILTER_ATTACHMENT_EXTENSIONS = PREVIEW_DOCUMENT_ATTACHMENT_EXTENSIONS | IMAGE_ATTACHMENT_EXTENSIONS
MAPI_ATTACHMENT_HIDDEN = "http://schemas.microsoft.com/mapi/proptag/0x7FFE000B" MAPI_ATTACHMENT_HIDDEN = "http://schemas.microsoft.com/mapi/proptag/0x7FFE000B"
MAPI_ATTACH_CONTENT_ID = ( MAPI_ATTACH_CONTENT_ID = (
"http://schemas.microsoft.com/mapi/proptag/0x3712001F", "http://schemas.microsoft.com/mapi/proptag/0x3712001F",
@@ -422,6 +480,8 @@ class PreferenceStore:
class OutlookService: class OutlookService:
NEW_OUTLOOK_PROCESS_NAMES = {"olk.exe"}
def __init__(self) -> None: def __init__(self) -> None:
self.outlook = None self.outlook = None
self.namespace = None self.namespace = None
@@ -430,7 +490,7 @@ class OutlookService:
def connect(self) -> None: def connect(self) -> None:
if win32com is None or pythoncom is None: if win32com is None or pythoncom is None:
raise RuntimeError( raise RuntimeError(
"pywin32 non è installato. Esegui: pip install pywin32" "pywin32 is not installed. Run: pip install pywin32"
) )
pythoncom.CoInitialize() pythoncom.CoInitialize()
@@ -438,6 +498,8 @@ class OutlookService:
try: try:
self.outlook = win32com.client.GetActiveObject("Outlook.Application") self.outlook = win32com.client.GetActiveObject("Outlook.Application")
except Exception as exc: except Exception as exc:
if self.is_new_outlook_running():
raise RuntimeError(NEW_OUTLOOK_UNSUPPORTED_MESSAGE) from exc
raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from exc raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from exc
self.namespace = self.outlook.GetNamespace("MAPI") self.namespace = self.outlook.GetNamespace("MAPI")
@@ -457,6 +519,25 @@ class OutlookService:
pythoncom.CoUninitialize() pythoncom.CoUninitialize()
raise raise
@classmethod
def is_new_outlook_running(cls) -> bool:
if sys.platform != "win32":
return False
try:
result = subprocess.run(
["tasklist", "/fo", "csv", "/nh"],
capture_output=True,
text=True,
timeout=4,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
return False
if result.returncode != 0:
return False
process_text = (result.stdout or "").lower()
return any(f'"{name}"' in process_text or name in process_text for name in cls.NEW_OUTLOOK_PROCESS_NAMES)
def disconnect(self) -> None: def disconnect(self) -> None:
try: try:
self.root_folders = [] self.root_folders = []
@@ -549,10 +630,12 @@ class OutlookService:
@staticmethod @staticmethod
def get_namespace_for_worker() -> object: def get_namespace_for_worker() -> object:
if win32com is None: if win32com is None:
raise RuntimeError("pywin32 non disponibile.") raise RuntimeError("pywin32 is not available.")
try: try:
outlook = win32com.client.GetActiveObject("Outlook.Application") outlook = win32com.client.GetActiveObject("Outlook.Application")
except Exception as exc: except Exception as exc:
if OutlookService.is_new_outlook_running():
raise RuntimeError(NEW_OUTLOOK_UNSUPPORTED_MESSAGE) from exc
raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from exc raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from exc
return outlook.GetNamespace("MAPI") return outlook.GetNamespace("MAPI")
@@ -652,6 +735,11 @@ class NameCodec:
ext = cls.sanitize_extension(os.path.splitext(filename or "")[1]) ext = cls.sanitize_extension(os.path.splitext(filename or "")[1])
return ext in ALLOWED_ATTACHMENT_EXTENSIONS return ext in ALLOWED_ATTACHMENT_EXTENSIONS
@classmethod
def is_preview_filter_attachment(cls, filename: str) -> bool:
ext = cls.sanitize_extension(os.path.splitext(filename or "")[1])
return ext in PREVIEW_FILTER_ATTACHMENT_EXTENSIONS
class Exporter: class Exporter:
MANIFEST_FLUSH_INTERVAL_SECONDS = 20.0 MANIFEST_FLUSH_INTERVAL_SECONDS = 20.0
@@ -1292,6 +1380,7 @@ class OutlookExporterApp(ctk.CTk):
self.outlook_service = OutlookService() self.outlook_service = OutlookService()
self.exporter = Exporter(self) self.exporter = Exporter(self)
self.export_thread = None self.export_thread = None
self.tree_load_thread = None
self.main_thread_id = threading.get_ident() self.main_thread_id = threading.get_ident()
self.ui_queue = queue.Queue() self.ui_queue = queue.Queue()
self.connected = False self.connected = False
@@ -1305,6 +1394,7 @@ class OutlookExporterApp(ctk.CTk):
self.loaded_email_headers: List[EmailHeader] = [] self.loaded_email_headers: List[EmailHeader] = []
self.filtered_email_headers: List[EmailHeader] = [] self.filtered_email_headers: List[EmailHeader] = []
self.filter_tree_key_map = {} self.filter_tree_key_map = {}
self.selected_preview_keys: set[str] = set()
self.filter_tree = None self.filter_tree = None
self.filter_loaded_folder_text = "" self.filter_loaded_folder_text = ""
self.filter_live_after_id = None self.filter_live_after_id = None
@@ -1411,7 +1501,7 @@ class OutlookExporterApp(ctk.CTk):
self.btn_connect = ctk.CTkButton(top, text="Connect Outlook", command=self.connect_outlook) self.btn_connect = ctk.CTkButton(top, text="Connect Outlook", command=self.connect_outlook)
self.btn_connect.grid(row=0, column=0, padx=6, pady=6) self.btn_connect.grid(row=0, column=0, padx=6, pady=6)
self.btn_refresh = ctk.CTkButton(top, text="Reload folders", command=self.reload_tree, state="disabled") self.btn_refresh = ctk.CTkButton(top, text="Reload folders", command=self.reload_tree_with_overlay, state="disabled")
self.btn_refresh.grid(row=0, column=1, padx=6, pady=6) self.btn_refresh.grid(row=0, column=1, padx=6, pady=6)
self.btn_expand = ctk.CTkButton(top, text="Expand tree", command=self.expand_all, state="disabled") self.btn_expand = ctk.CTkButton(top, text="Expand tree", command=self.expand_all, state="disabled")
@@ -1571,7 +1661,7 @@ class OutlookExporterApp(ctk.CTk):
self.chk_filter_export_new.grid(row=0, column=0, sticky="w", padx=(0, 12)) self.chk_filter_export_new.grid(row=0, column=0, sticky="w", padx=(0, 12))
self.chk_filter_export_with_attachments = ctk.CTkCheckBox( self.chk_filter_export_with_attachments = ctk.CTkCheckBox(
filter_options, filter_options,
text="With attachments", text="Office/PDF/images",
variable=self.filter_export_with_attachments_var, variable=self.filter_export_with_attachments_var,
command=self.schedule_live_filter, command=self.schedule_live_filter,
state="disabled", state="disabled",
@@ -1579,7 +1669,7 @@ class OutlookExporterApp(ctk.CTk):
self.chk_filter_export_with_attachments.grid(row=0, column=1, sticky="w", padx=(0, 12)) self.chk_filter_export_with_attachments.grid(row=0, column=1, sticky="w", padx=(0, 12))
self.chk_filter_export_selected = ctk.CTkCheckBox( self.chk_filter_export_selected = ctk.CTkCheckBox(
filter_options, filter_options,
text="Selected", text="Selected rows only",
variable=self.filter_export_selected_var, variable=self.filter_export_selected_var,
command=self.schedule_live_filter, command=self.schedule_live_filter,
state="disabled", state="disabled",
@@ -1691,12 +1781,15 @@ class OutlookExporterApp(ctk.CTk):
ctk.CTkLabel( ctk.CTkLabel(
self.busy_overlay, self.busy_overlay,
textvariable=self.busy_title_var, textvariable=self.busy_title_var,
font=ctk.CTkFont(size=18, weight="bold"), font=ctk.CTkFont(size=13, weight="bold"),
wraplength=460,
).grid(row=0, column=0, sticky="ew", padx=18, pady=(18, 8)) ).grid(row=0, column=0, sticky="ew", padx=18, pady=(18, 8))
ctk.CTkLabel( ctk.CTkLabel(
self.busy_overlay, self.busy_overlay,
textvariable=self.busy_detail_var, textvariable=self.busy_detail_var,
justify="center", justify="center",
font=ctk.CTkFont(size=12),
wraplength=460,
).grid(row=1, column=0, sticky="ew", padx=18, pady=(0, 10)) ).grid(row=1, column=0, sticky="ew", padx=18, pady=(0, 10))
self.busy_progress = ctk.CTkProgressBar(self.busy_overlay, mode="indeterminate") self.busy_progress = ctk.CTkProgressBar(self.busy_overlay, mode="indeterminate")
self.busy_progress.grid(row=2, column=0, sticky="ew", padx=18, pady=(0, 18)) self.busy_progress.grid(row=2, column=0, sticky="ew", padx=18, pady=(0, 18))
@@ -1725,31 +1818,91 @@ class OutlookExporterApp(ctk.CTk):
(self.chk_zip, "Create a ZIP archive as export output."), (self.chk_zip, "Create a ZIP archive as export output."),
(self.chk_hierarchy, "Create folders and files directly on the file system."), (self.chk_hierarchy, "Create folders and files directly on the file system."),
(self.chk_filter_export_new, "Show only emails that are not yet exported."), (self.chk_filter_export_new, "Show only emails that are not yet exported."),
(self.chk_filter_export_with_attachments, "Show only emails that have Outlook attachments."), (self.chk_filter_export_with_attachments, "Show only emails that have office, PDF, or image attachments."),
(self.chk_filter_export_selected, "Limit the preview and export to the rows currently selected."), (self.chk_filter_export_selected, "Show and export only the rows currently selected in the preview."),
] ]
self.tooltips = [ToolTip(widget, text) for widget, text in tooltip_specs] self.tooltips = [ToolTip(widget, text) for widget, text in tooltip_specs]
def connect_outlook(self) -> None: def connect_outlook(self) -> None:
try: if self.tree_load_thread is not None and self.tree_load_thread.is_alive():
return
self.set_status("Connecting to Outlook...") self.set_status("Connecting to Outlook...")
self.log("Connecting to Outlook...") self.log("Connecting to Outlook...")
self.outlook_service.connect() self.set_busy_overlay(
self.connected = True True,
self.log("Connected to Outlook.") "Please wait...",
self.reload_tree() "Connecting to Classic Outlook and loading the folder tree...",
self.btn_refresh.configure(state="normal") )
self.btn_expand.configure(state="normal") self._set_controls_enabled(False)
self.set_status("Connected to Outlook") self.tree_load_thread = threading.Thread(target=self._run_root_tree_load_worker, args=(True,), daemon=True)
self.tree_load_thread.start()
def reload_tree_with_overlay(self) -> None:
if not self.connected:
return
if self.tree_load_thread is not None and self.tree_load_thread.is_alive():
return
self.set_status("Reloading Outlook folder tree...")
self.log("Reloading Outlook folder tree...")
self.set_busy_overlay(
True,
"Please wait...",
"Loading Outlook folder tree...",
)
self._set_controls_enabled(False)
self.tree_load_thread = threading.Thread(target=self._run_root_tree_load_worker, args=(False,), daemon=True)
self.tree_load_thread.start()
def _run_root_tree_load_worker(self, is_connect: bool) -> None:
if pythoncom is not None:
pythoncom.CoInitialize()
try:
if win32com is None or pythoncom is None:
raise RuntimeError("pywin32 is not installed. Run: pip install pywin32")
try:
outlook = win32com.client.GetActiveObject("Outlook.Application")
except Exception as exc: except Exception as exc:
self.connected = False if OutlookService.is_new_outlook_running():
self.log(f"Outlook connection error: {exc}") raise RuntimeError(NEW_OUTLOOK_UNSUPPORTED_MESSAGE) from exc
self.set_status("Connection error") raise RuntimeError(OUTLOOK_NOT_RUNNING_MESSAGE) from exc
messagebox.showerror(APP_TITLE, f"Unable to connect to Outlook.\n\n{exc}") namespace = outlook.GetNamespace("MAPI")
roots = []
for i in range(1, namespace.Folders.Count + 1):
store = namespace.Folders.Item(i)
store_name = str(store.Name)
display_name = NameCodec.store_display_name(store_name)
roots.append(
{
"display_name": display_name,
"path": display_name,
"folder_ref": self._marshal_folder_for_worker(store),
}
)
self.ui_queue.put(("root_tree_loaded", is_connect, roots))
except Exception as exc:
self.ui_queue.put(("root_tree_error", is_connect, str(exc), traceback.format_exc()))
finally:
if pythoncom is not None:
try:
pythoncom.CoUninitialize()
except Exception:
pass
def reload_tree(self) -> None: def reload_tree(self) -> None:
if not self.connected: if not self.connected:
return return
self._clear_tree_preview_state()
for root_node in self.outlook_service.list_root_folders():
node_text = NameCodec.tree_label(root_node.display_name or root_node.path, root_node.mail_count)
node_id = self.tree.insert("", "end", text=node_text, open=False, tags=("root",))
self.tree_folder_map[node_id] = root_node.folder_obj
self.tree_path_map[node_id] = root_node.path
self._insert_dummy(node_id)
self.log("Folder tree reloaded.")
def _clear_tree_preview_state(self) -> None:
if self.filter_live_after_id is not None: if self.filter_live_after_id is not None:
try: try:
self.after_cancel(self.filter_live_after_id) self.after_cancel(self.filter_live_after_id)
@@ -1764,6 +1917,7 @@ class OutlookExporterApp(ctk.CTk):
self.loaded_email_headers = [] self.loaded_email_headers = []
self.filtered_email_headers = [] self.filtered_email_headers = []
self.filter_tree_key_map = {} self.filter_tree_key_map = {}
self.selected_preview_keys.clear()
self.filter_loaded_folder_text = "" self.filter_loaded_folder_text = ""
self.filter_loaded_folder_var.set("No folder loaded") self.filter_loaded_folder_var.set("No folder loaded")
self.filter_status_var.set("No emails loaded") self.filter_status_var.set("No emails loaded")
@@ -1772,27 +1926,83 @@ class OutlookExporterApp(ctk.CTk):
self.refresh_filter_tree() self.refresh_filter_tree()
self._update_filter_controls_state() self._update_filter_controls_state()
for root_node in self.outlook_service.list_root_folders():
node_text = NameCodec.tree_label(root_node.display_name or root_node.path, root_node.mail_count)
node_id = self.tree.insert("", "end", text=node_text, open=False, tags=("root",))
self.tree_folder_map[node_id] = root_node.folder_obj
self.tree_path_map[node_id] = root_node.path
self._insert_dummy(node_id)
self.log("Folder tree reloaded.")
def _insert_dummy(self, node_id: str) -> None: def _insert_dummy(self, node_id: str) -> None:
self.tree.insert(node_id, "end", text="__dummy__") self.tree.insert(node_id, "end", text="__dummy__")
def _insert_loaded_tree_roots(self, roots: List[dict]) -> None:
self._clear_tree_preview_state()
for root in roots:
folder_obj = self._resolve_worker_folder(root["folder_ref"])
node_text = NameCodec.tree_label(root["display_name"] or root["path"], None)
node_id = self.tree.insert("", "end", text=node_text, open=False, tags=("root",))
self.tree_folder_map[node_id] = folder_obj
self.tree_path_map[node_id] = root["path"]
self._insert_dummy(node_id)
def on_tree_open(self, _event=None) -> None: def on_tree_open(self, _event=None) -> None:
selected = self.tree.focus() selected = self.tree.focus()
if not selected: if not selected:
return return
children = self.tree.get_children(selected) children = self.tree.get_children(selected)
if len(children) == 1 and self.tree.item(children[0], "text") == "__dummy__": if len(children) == 1 and self.tree.item(children[0], "text") == "__dummy__":
self.tree.delete(children[0]) if self.tree_load_thread is not None and self.tree_load_thread.is_alive():
folder_obj = self.tree_folder_map.get(selected) return
current_path = self.tree_path_map.get(selected, self.tree.item(selected, "text")) current_path = self.tree_path_map.get(selected, self.tree.item(selected, "text"))
self.set_status("Loading Outlook subfolders...")
self.set_busy_overlay(
True,
"Please wait...",
f"Loading subfolders for:\n{current_path}",
)
self._set_controls_enabled(False)
folder_ref = self._marshal_folder_for_worker(self.tree_folder_map.get(selected))
self.tree_load_thread = threading.Thread(
target=self._run_tree_node_load_worker,
args=(selected, folder_ref, current_path),
daemon=True,
)
self.tree_load_thread.start()
def _run_tree_node_load_worker(self, node_id: str, folder_ref: object, current_path: str) -> None:
if pythoncom is not None:
pythoncom.CoInitialize()
try:
folder_obj = self._resolve_worker_folder(folder_ref)
child_nodes = self.outlook_service.list_children(folder_obj, current_path)
children = []
for node in child_nodes:
has_children = False
try:
has_children = bool(node.folder_obj.Folders.Count > 0)
except Exception:
has_children = False
children.append(
{
"display_name": node.display_name,
"path": node.path,
"mail_count": node.mail_count,
"has_children": has_children,
"folder_ref": self._marshal_folder_for_worker(node.folder_obj),
}
)
self.ui_queue.put(("tree_node_loaded", node_id, children))
except Exception as exc:
self.ui_queue.put(("tree_node_error", node_id, str(exc), traceback.format_exc()))
finally:
if pythoncom is not None:
try:
pythoncom.CoUninitialize()
except Exception:
pass
def _load_tree_node_children(self, node_id: str) -> None:
children = self.tree.get_children(node_id)
if not (len(children) == 1 and self.tree.item(children[0], "text") == "__dummy__"):
return
self.tree.delete(children[0])
folder_obj = self.tree_folder_map.get(node_id)
current_path = self.tree_path_map.get(node_id, self.tree.item(node_id, "text"))
try: try:
child_nodes = self.outlook_service.list_children(folder_obj, current_path) child_nodes = self.outlook_service.list_children(folder_obj, current_path)
for idx, node in enumerate(child_nodes, start=1): for idx, node in enumerate(child_nodes, start=1):
@@ -1806,7 +2016,7 @@ class OutlookExporterApp(ctk.CTk):
row_tag = "branch_even" if idx % 2 == 0 else "branch_odd" row_tag = "branch_even" if idx % 2 == 0 else "branch_odd"
else: else:
row_tag = "even" if idx % 2 == 0 else "odd" row_tag = "even" if idx % 2 == 0 else "odd"
child_id = self.tree.insert(selected, "end", text=node_text, open=False, tags=(row_tag,)) child_id = self.tree.insert(node_id, "end", text=node_text, open=False, tags=(row_tag,))
self.tree_folder_map[child_id] = node.folder_obj self.tree_folder_map[child_id] = node.folder_obj
self.tree_path_map[child_id] = node.path self.tree_path_map[child_id] = node.path
try: try:
@@ -1817,6 +2027,23 @@ class OutlookExporterApp(ctk.CTk):
except Exception as exc: except Exception as exc:
self.log(f"Error loading subfolders: {exc}") self.log(f"Error loading subfolders: {exc}")
def _insert_loaded_tree_children(self, node_id: str, children: List[dict]) -> None:
current_children = self.tree.get_children(node_id)
if len(current_children) == 1 and self.tree.item(current_children[0], "text") == "__dummy__":
self.tree.delete(current_children[0])
for idx, node in enumerate(children, start=1):
folder_obj = self._resolve_worker_folder(node["folder_ref"])
node_text = NameCodec.tree_label(node["display_name"] or node["path"], node["mail_count"])
if node["has_children"]:
row_tag = "branch_even" if idx % 2 == 0 else "branch_odd"
else:
row_tag = "even" if idx % 2 == 0 else "odd"
child_id = self.tree.insert(node_id, "end", text=node_text, open=False, tags=(row_tag,))
self.tree_folder_map[child_id] = folder_obj
self.tree_path_map[child_id] = node["path"]
if node["has_children"]:
self._insert_dummy(child_id)
def on_tree_select(self, _event=None) -> None: def on_tree_select(self, _event=None) -> None:
selected = self.tree.selection() selected = self.tree.selection()
if not selected: if not selected:
@@ -1837,6 +2064,7 @@ class OutlookExporterApp(ctk.CTk):
self.loaded_email_headers = [] self.loaded_email_headers = []
self.filtered_email_headers = [] self.filtered_email_headers = []
self.filter_tree_key_map = {} self.filter_tree_key_map = {}
self.selected_preview_keys.clear()
self.filter_loaded_folder_text = "" self.filter_loaded_folder_text = ""
self.filter_loaded_folder_var.set(f"Selected folder: {folder_path}") self.filter_loaded_folder_var.set(f"Selected folder: {folder_path}")
self.filter_status_var.set("No emails loaded for the selected folder") self.filter_status_var.set("No emails loaded for the selected folder")
@@ -1945,7 +2173,7 @@ class OutlookExporterApp(ctk.CTk):
def _expand(node_id: str) -> None: def _expand(node_id: str) -> None:
self.tree.item(node_id, open=True) self.tree.item(node_id, open=True)
self.tree.focus(node_id) self.tree.focus(node_id)
self.on_tree_open() self._load_tree_node_children(node_id)
for child in self.tree.get_children(node_id): for child in self.tree.get_children(node_id):
if self.tree.item(child, "text") != "__dummy__": if self.tree.item(child, "text") != "__dummy__":
_expand(child) _expand(child)
@@ -1971,8 +2199,8 @@ class OutlookExporterApp(ctk.CTk):
folder_text = self.selected_folder_display.get().replace("Selected: ", "") folder_text = self.selected_folder_display.get().replace("Selected: ", "")
self.set_busy_overlay( self.set_busy_overlay(
True, True,
"Please wait... loading many emails may take a few minutes", "Please wait...",
f"Loading email headers for:\n{folder_text}", f"Loading email headers for:\n{folder_text}\nLarge folders may take a few minutes.",
) )
self._set_controls_enabled(False) self._set_controls_enabled(False)
self.filter_status_var.set("Loading in progress...") self.filter_status_var.set("Loading in progress...")
@@ -2187,8 +2415,8 @@ class OutlookExporterApp(ctk.CTk):
self.btn_cancel.configure(state="normal") self.btn_cancel.configure(state="normal")
self.set_busy_overlay( self.set_busy_overlay(
True, True,
"Please wait... exporting many emails may take a few minutes", "Please wait...",
"Export in progress...", "Export in progress. Exporting many emails may take several minutes.",
) )
def _restore_export_controls(self) -> None: def _restore_export_controls(self) -> None:
@@ -2326,7 +2554,10 @@ class OutlookExporterApp(ctk.CTk):
if received_dt is None: if received_dt is None:
received_dt = Exporter._convert_outlook_datetime(getattr(item, "SentOn", None)) received_dt = Exporter._convert_outlook_datetime(getattr(item, "SentOn", None))
received_text = received_dt.strftime("%Y-%m-%d %H:%M:%S") if received_dt else "" received_text = received_dt.strftime("%Y-%m-%d %H:%M:%S") if received_dt else ""
attachment_count, attachment_types = self._collect_preview_attachment_info_minimal(item) html_body = ""
if self._has_outlook_attachments(item):
html_body = getattr(item, "HTMLBody", "") or ""
attachment_count, attachment_types = self._collect_preview_attachment_info(item, html_body)
key = self._build_manifest_key_for_item(item, Exporter._safe_str(subject_raw), received_text) key = self._build_manifest_key_for_item(item, Exporter._safe_str(subject_raw), received_text)
exported = bool(manifest and manifest.has_record(key) and manifest.record_file_exists(key)) exported = bool(manifest and manifest.has_record(key) and manifest.record_file_exists(key))
@@ -2391,7 +2622,7 @@ class OutlookExporterApp(ctk.CTk):
inline_score, _ = self.exporter._signature_image_score(attachment, original_filename, html_body) inline_score, _ = self.exporter._signature_image_score(attachment, original_filename, html_body)
if inline_score >= 3: if inline_score >= 3:
continue continue
if not NameCodec.is_allowed_attachment(original_filename): if not NameCodec.is_preview_filter_attachment(original_filename):
continue continue
ext = NameCodec.sanitize_extension(os.path.splitext(original_filename or "")[1]).lstrip(".") ext = NameCodec.sanitize_extension(os.path.splitext(original_filename or "")[1]).lstrip(".")
if ext: if ext:
@@ -2421,6 +2652,16 @@ class OutlookExporterApp(ctk.CTk):
return 0, "" return 0, ""
return attachment_total, "" return attachment_total, ""
@staticmethod
def _has_outlook_attachments(item: object) -> bool:
attachments = getattr(item, "Attachments", None)
if attachments is None:
return False
try:
return int(attachments.Count or 0) > 0
except Exception:
return False
def _build_manifest_key_for_item(self, item: object, subject: str, received_text: str) -> str: def _build_manifest_key_for_item(self, item: object, subject: str, received_text: str) -> str:
entry_id = Exporter._safe_full_str(getattr(item, "EntryID", None)) entry_id = Exporter._safe_full_str(getattr(item, "EntryID", None))
parent = getattr(item, "Parent", None) parent = getattr(item, "Parent", None)
@@ -2540,6 +2781,52 @@ class OutlookExporterApp(ctk.CTk):
self._set_controls_enabled(True) self._set_controls_enabled(True)
self._flush_file_log() self._flush_file_log()
messagebox.showerror(APP_TITLE, f"Error while loading emails.\n\n{message}") messagebox.showerror(APP_TITLE, f"Error while loading emails.\n\n{message}")
elif event_type == "root_tree_loaded":
_, is_connect, roots = event
self.tree_load_thread = None
self.connected = True
self._insert_loaded_tree_roots(roots)
if is_connect:
self.log("Connected to Outlook.")
self.set_status("Connected to Outlook")
else:
self.log("Folder tree reloaded.")
self.set_status("Outlook folder tree loaded")
self.set_busy_overlay(False)
self._set_controls_enabled(True)
elif event_type == "root_tree_error":
_, is_connect, message, details = event
self.tree_load_thread = None
if is_connect:
self.connected = False
self.set_status("Connection error")
self.log(f"Outlook connection error: {message}")
title = "Unable to connect to Outlook."
else:
self.set_status("Folder tree loading error")
self.log(f"Folder tree loading error: {message}")
title = "Unable to load the Outlook folder tree."
self.log(details)
self.set_busy_overlay(False)
self._set_controls_enabled(True)
messagebox.showerror(APP_TITLE, f"{title}\n\n{message}")
elif event_type == "tree_node_loaded":
_, node_id, children = event
self.tree_load_thread = None
self._insert_loaded_tree_children(node_id, children)
self.set_status(f"Outlook subfolders loaded | folders: {len(children)}")
self.log(f"Outlook subfolders loaded: {len(children)}")
self.set_busy_overlay(False)
self._set_controls_enabled(True)
elif event_type == "tree_node_error":
_, _node_id, message, details = event
self.tree_load_thread = None
self.set_status("Error while loading Outlook subfolders")
self.log(f"Outlook subfolder loading error: {message}")
self.log(details)
self.set_busy_overlay(False)
self._set_controls_enabled(True)
messagebox.showerror(APP_TITLE, f"Unable to load Outlook subfolders.\n\n{message}")
elif event_type == "error": elif event_type == "error":
_, message, details = event _, message, details = event
self.set_status("Error during export") self.set_status("Error during export")
@@ -2674,13 +2961,7 @@ class OutlookExporterApp(ctk.CTk):
only_new = bool(self.filter_export_new_var.get()) only_new = bool(self.filter_export_new_var.get())
only_with_attachments = bool(self.filter_export_with_attachments_var.get()) only_with_attachments = bool(self.filter_export_with_attachments_var.get())
only_selected = bool(self.filter_export_selected_var.get()) only_selected = bool(self.filter_export_selected_var.get())
selected_keys: set[str] = set() selected_keys = set(self.selected_preview_keys) if only_selected else set()
if only_selected and self.filter_tree is not None:
selected_keys = {
self.filter_tree_key_map[item_id]
for item_id in self.filter_tree.selection()
if item_id in self.filter_tree_key_map
}
filtered: List[EmailHeader] = [] filtered: List[EmailHeader] = []
for header in self.loaded_email_headers: for header in self.loaded_email_headers:
@@ -2694,7 +2975,7 @@ class OutlookExporterApp(ctk.CTk):
continue continue
if only_with_attachments and header.attachment_count <= 0: if only_with_attachments and header.attachment_count <= 0:
continue continue
if only_selected and header.key not in selected_keys: if only_selected and (not selected_keys or header.key not in selected_keys):
continue continue
filtered.append(header) filtered.append(header)
@@ -2714,12 +2995,18 @@ class OutlookExporterApp(ctk.CTk):
self.filter_export_new_var.set(0) self.filter_export_new_var.set(0)
self.filter_export_with_attachments_var.set(0) self.filter_export_with_attachments_var.set(0)
self.filter_export_selected_var.set(0) self.filter_export_selected_var.set(0)
self.selected_preview_keys.clear()
self.apply_email_filters() self.apply_email_filters()
def _on_live_filter_input(self, _event=None) -> None: def _on_live_filter_input(self, _event=None) -> None:
self.schedule_live_filter() self.schedule_live_filter()
def _on_filter_tree_select(self, _event=None) -> None: def _on_filter_tree_select(self, _event=None) -> None:
if self.filter_tree is not None:
for item_id in self.filter_tree.selection():
key = self.filter_tree_key_map.get(item_id)
if key:
self.selected_preview_keys.add(key)
self._update_filter_controls_state() self._update_filter_controls_state()
if self.filter_export_selected_var.get(): if self.filter_export_selected_var.get():
self.schedule_live_filter() self.schedule_live_filter()
@@ -2768,6 +3055,7 @@ class OutlookExporterApp(ctk.CTk):
return return
self.filter_tree.delete(*self.filter_tree.get_children()) self.filter_tree.delete(*self.filter_tree.get_children())
self.filter_tree_key_map = {} self.filter_tree_key_map = {}
visible_selected_items = []
for idx, header in enumerate(self.filtered_email_headers, start=1): for idx, header in enumerate(self.filtered_email_headers, start=1):
state = "Exported" if header.exported else "New" state = "Exported" if header.exported else "New"
item_id = f"mail_{idx}" item_id = f"mail_{idx}"
@@ -2787,6 +3075,13 @@ class OutlookExporterApp(ctk.CTk):
), ),
) )
self.filter_tree_key_map[item_id] = header.key self.filter_tree_key_map[item_id] = header.key
if header.key in self.selected_preview_keys:
visible_selected_items.append(item_id)
if visible_selected_items:
try:
self.filter_tree.selection_set(visible_selected_items)
except Exception:
pass
if autosize: if autosize:
self._autosize_preview_columns() self._autosize_preview_columns()
exported = sum(1 for item in self.filtered_email_headers if item.exported) exported = sum(1 for item in self.filtered_email_headers if item.exported)