From 8009b9d16f1fa4354edacc7aed9fef6577595202 Mon Sep 17 00:00:00 2001 From: allebonvi Date: Tue, 21 Apr 2026 19:17:04 +0200 Subject: [PATCH] Improve export diagnostics and splash --- MailExporter.spec | 14 ++ mail_exporter.py | 387 +++++++++++++++++++++++++++++++++++++++++++--- splash.png | Bin 0 -> 13102 bytes 3 files changed, 381 insertions(+), 20 deletions(-) create mode 100644 splash.png diff --git a/MailExporter.spec b/MailExporter.spec index 70d2754..8ca1b16 100644 --- a/MailExporter.spec +++ b/MailExporter.spec @@ -17,6 +17,7 @@ a = Analysis( hiddenimports=[ "pythoncom", "pywintypes", + "win32timezone", "win32com", "win32com.client", ], @@ -58,8 +59,20 @@ a = Analysis( pyz = PYZ(a.pure) +splash = Splash( + str(project_dir / "splash.png"), + binaries=a.binaries, + datas=a.datas, + text_pos=(220, 300), + text_size=14, + text_color="black", + text_default="Avvio MailExporter...", + always_on_top=True, +) + exe = EXE( pyz, + splash, a.scripts, [], exclude_binaries=True, @@ -78,6 +91,7 @@ exe = EXE( coll = COLLECT( exe, + splash.binaries, a.binaries, a.datas, strip=False, diff --git a/mail_exporter.py b/mail_exporter.py index 5f1ad90..c8687c5 100644 --- a/mail_exporter.py +++ b/mail_exporter.py @@ -18,6 +18,7 @@ from tkinter import ttk try: import pythoncom + import win32timezone # required by pywin32 when Outlook returns timezone-aware COM dates import win32com.client except ImportError: pythoncom = None @@ -30,6 +31,7 @@ MAIL_CLASS = 43 # OlObjectClass.olMail MAX_LOG_LINES = 3000 PREFS_APP_DIR = "OutlookExporter" PREFS_FILE_NAME = "prefs.json" +LOG_FILE_NAME = "mail_exporter.log" OUTLOOK_NOT_RUNNING_MESSAGE = ( "Outlook non risulta aperto.\n\n" "Apri Microsoft Outlook con il profilo email da esportare, attendi che abbia completato il caricamento, " @@ -61,9 +63,21 @@ ALLOWED_ATTACHMENT_EXTENSIONS = { ".potx", ".potm", ".rtf", + ".fodp", + ".fods", + ".fodt", + ".odb", + ".odf", + ".odg", ".odt", ".ods", ".odp", + ".otg", + ".oth", + ".otp", + ".ots", + ".ott", + ".sxc", ".jpg", ".jpeg", ".png", @@ -73,6 +87,24 @@ ALLOWED_ATTACHMENT_EXTENSIONS = { ".tiff", ".webp", } +IMAGE_ATTACHMENT_EXTENSIONS = { + ".jpg", + ".jpeg", + ".png", + ".gif", + ".bmp", + ".tif", + ".tiff", + ".webp", +} +MAPI_ATTACHMENT_HIDDEN = "http://schemas.microsoft.com/mapi/proptag/0x7FFE000B" +MAPI_ATTACH_CONTENT_ID = "http://schemas.microsoft.com/mapi/proptag/0x3712001F" +MAPI_ATTACH_CONTENT_LOCATION = "http://schemas.microsoft.com/mapi/proptag/0x3713001F" +MAPI_ATTACH_CONTENT_DISPOSITION = "http://schemas.microsoft.com/mapi/proptag/0x3716001F" +INLINE_ATTACHMENT_NAME_RE = re.compile( + r"^(image\d{3,}|att\d{5}|oledata|logo|spacer|facebook|linkedin|twitter|instagram|youtube)\b", + re.IGNORECASE, +) @dataclass @@ -111,6 +143,10 @@ class PreferenceStore: base_dir = Path(appdata) if appdata else Path.home() / ".config" return base_dir / PREFS_APP_DIR / PREFS_FILE_NAME + @classmethod + def app_data_dir(cls) -> Path: + return cls._prefs_path().parent + def _load_or_create(self) -> dict: if self.path.exists(): try: @@ -279,9 +315,10 @@ class NameCodec: return f"mail_{stamp}_{seq:05d}" @classmethod - def mail_folder_name(cls, base_mail_name: str, subject: str) -> str: + def mail_folder_name(cls, base_mail_name: str, subject: str, attachment_count: int = 0) -> str: safe_subject = cls.sanitize(subject or "senza oggetto", max_len=50) - return f"{base_mail_name}__{safe_subject}" + attachment_marker = f"CON_ALLEGATI_{attachment_count:02d}" if attachment_count else "SOLO_MAIL" + return f"{base_mail_name}__{attachment_marker}__{safe_subject}" @classmethod def attachment_name(cls, base_mail_name: str, original_filename: str, att_index: int) -> str: @@ -318,6 +355,8 @@ class Exporter: self.global_seq = 0 self.exported_mail_count = 0 self.exported_attachment_count = 0 + self.datetime_diagnostic_count = 0 + self.datetime_diagnostic_limit = 20 def cancel(self) -> None: self.cancel_requested = True @@ -406,12 +445,16 @@ class Exporter: return local_mail_count, local_attachment_count def _export_mail(self, item: object, folder_out_path: Path) -> Tuple[int, int]: - received_dt = self._convert_received_time(self._safe_get(item, "ReceivedTime")) + received_dt = self._get_best_mail_datetime(item) subject = self._safe_get(item, "Subject") or "" body = self._safe_get(item, "Body") or "" + html_body = self._safe_get(item, "HTMLBody") or "" + exportable_attachments = self._collect_exportable_attachments(item, html_body) base_name = NameCodec.email_base_name(received_dt, self.global_seq) - mail_dir = self._unique_path(folder_out_path / NameCodec.mail_folder_name(base_name, subject)) + mail_dir = self._unique_path( + folder_out_path / NameCodec.mail_folder_name(base_name, subject, len(exportable_attachments)) + ) mail_dir.mkdir(parents=True, exist_ok=True) txt_path = mail_dir / f"{base_name}.txt" @@ -426,24 +469,11 @@ class Exporter: self.app.log(f"Mail salvata: {txt_path.name}") attachment_count = 0 - attachments = self._safe_get(item, "Attachments") - if attachments is not None: - try: - attachment_total = attachments.Count - except Exception as exc: - self.app.log(f" Errore leggendo il numero di allegati: {exc}") - attachment_total = 0 - + if exportable_attachments: attachments_dir = mail_dir / "allegati" - for att_index in range(1, attachment_total + 1): + for att_index, attachment, original_filename in exportable_attachments: self.check_cancel() try: - attachment = attachments.Item(att_index) - original_filename = self._safe_get(attachment, "FileName") or f"attachment_{att_index}" - if not NameCodec.is_allowed_attachment(original_filename): - self.app.log(f" Allegato ignorato: {original_filename}") - continue - export_name = NameCodec.attachment_name(base_name, original_filename, att_index) attachments_dir.mkdir(parents=True, exist_ok=True) export_path = self._unique_path(attachments_dir / export_name) @@ -455,6 +485,173 @@ class Exporter: return 1, attachment_count + def _collect_exportable_attachments(self, item: object, html_body: str) -> List[Tuple[int, object, str]]: + exportable = [] + attachments = self._safe_get(item, "Attachments") + if attachments is None: + return exportable + + try: + attachment_total = attachments.Count + except Exception as exc: + self.app.log(f" Errore leggendo il numero di allegati: {exc}") + return exportable + + for att_index in range(1, attachment_total + 1): + self.check_cancel() + try: + attachment = attachments.Item(att_index) + original_filename = self._safe_get(attachment, "FileName") or f"attachment_{att_index}" + inline_score, inline_reasons = self._inline_signature_score( + attachment, original_filename, html_body + ) + if inline_score >= 3: + self.app.log( + f" Allegato inline/firma ignorato: {original_filename} | " + f"score={inline_score} | motivi={', '.join(inline_reasons)}" + ) + elif NameCodec.is_allowed_attachment(original_filename): + exportable.append((att_index, attachment, original_filename)) + else: + self.app.log(f" Allegato ignorato: {original_filename}") + except Exception as exc: + self.app.log(f" Errore leggendo allegato {att_index}: {exc}") + + return exportable + + def _inline_signature_score(self, attachment: object, filename: str, html_body: str) -> Tuple[int, List[str]]: + ext = NameCodec.sanitize_extension(os.path.splitext(filename or "")[1]) + if ext not in IMAGE_ATTACHMENT_EXTENSIONS: + return 0, [] + + score = 0 + reasons = [] + html_lower = (html_body or "").lower() + filename_lower = os.path.basename(filename or "").lower() + + hidden = self._get_attachment_mapi_property(attachment, MAPI_ATTACHMENT_HIDDEN) + if hidden is True: + score += 4 + reasons.append("hidden") + + content_id = self._get_attachment_mapi_property(attachment, MAPI_ATTACH_CONTENT_ID) + if self._has_value(content_id): + cid = str(content_id).strip() + if self._html_references_cid(html_lower, cid): + score += 3 + reasons.append("cid nel corpo HTML") + else: + score += 1 + reasons.append("cid presente") + + content_location = self._get_attachment_mapi_property(attachment, MAPI_ATTACH_CONTENT_LOCATION) + if self._has_value(content_location): + location = str(content_location).strip() + if location.lower() in html_lower: + score += 2 + reasons.append("content-location nel corpo HTML") + else: + score += 1 + reasons.append("content-location presente") + + disposition = self._get_attachment_mapi_property(attachment, MAPI_ATTACH_CONTENT_DISPOSITION) + if self._has_value(disposition) and "inline" in str(disposition).lower(): + score += 3 + reasons.append("disposition inline") + + if filename_lower and filename_lower in html_lower: + score += 2 + reasons.append("nome file nel corpo HTML") + + stem = os.path.splitext(filename_lower)[0] + if INLINE_ATTACHMENT_NAME_RE.match(stem or "") is not None: + score += 2 + reasons.append("nome tipico inline/firma") + + return score, reasons + + @staticmethod + def _html_references_cid(html_lower: str, content_id: str) -> bool: + if not content_id: + return False + cid = content_id.strip().strip("<>").lower() + if not cid: + return False + return f"cid:{cid}" in html_lower or cid in html_lower + + def _get_attachment_mapi_property(self, attachment: object, schema: str): + try: + accessor = attachment.PropertyAccessor + return accessor.GetProperty(schema) + except Exception: + return None + + @staticmethod + def _has_value(value: object) -> bool: + if value is None: + return False + try: + return bool(str(value).strip()) + except Exception: + return True + + def _get_best_mail_datetime(self, item: object) -> Optional[datetime]: + for attr in ("ReceivedTime", "SentOn", "CreationTime", "LastModificationTime"): + value = self._safe_get(item, attr) + converted = self._convert_outlook_datetime(value) + if converted is not None: + if attr != "ReceivedTime": + self.app.log(f" Data ReceivedTime non disponibile, uso {attr}.") + return converted + self._log_datetime_diagnostics(item) + return None + + def _log_datetime_diagnostics(self, item: object) -> None: + if self.datetime_diagnostic_count >= self.datetime_diagnostic_limit: + if self.datetime_diagnostic_count == self.datetime_diagnostic_limit: + self.app.log( + " Diagnostica date: limite raggiunto, ulteriori mail senza data non verranno dettagliate." + ) + self.datetime_diagnostic_count += 1 + return + + self.datetime_diagnostic_count += 1 + subject = self._safe_get(item, "Subject") or "(senza oggetto)" + self.app.log(f" Diagnostica date mail #{self.global_seq}: {subject}") + for label, value in self._mail_identity_details(item): + self.app.log(f" {label}: {value}") + for attr in ("ReceivedTime", "SentOn", "CreationTime", "LastModificationTime"): + ok, value_or_error = self._safe_get_with_error(item, attr) + if not ok: + self.app.log(f" {attr}: ERRORE accesso: {value_or_error}") + continue + + value = value_or_error + self.app.log( + f" {attr}: tipo={type(value).__name__}; repr={repr(value)}; str={self._safe_str(value)}" + ) + + def _mail_identity_details(self, item: object) -> List[Tuple[str, str]]: + parent = self._safe_get(item, "Parent") + folder_name = self._safe_get(parent, "Name") if parent is not None else None + store_id = self._safe_get(parent, "StoreID") if parent is not None else None + + details = [ + ("EntryID", self._safe_str(self._safe_get(item, "EntryID"))), + ("ConversationID", self._safe_str(self._safe_get(item, "ConversationID"))), + ("InternetMessageID", self._safe_str(self._get_mail_mapi_property(item, "http://schemas.microsoft.com/mapi/proptag/0x1035001F"))), + ("Cartella Outlook", self._safe_str(folder_name)), + ("StoreID", self._safe_str(store_id)), + ] + return details + + def _get_mail_mapi_property(self, item: object, schema: str): + try: + accessor = item.PropertyAccessor + return accessor.GetProperty(schema) + except Exception: + return None + @staticmethod def _unique_path(path: Path) -> Path: if not path.exists(): @@ -478,7 +675,24 @@ class Exporter: return None @staticmethod - def _convert_received_time(value) -> Optional[datetime]: + def _safe_get_with_error(obj: object, attr: str) -> Tuple[bool, object]: + try: + return True, getattr(obj, attr) + except Exception as exc: + return False, exc + + @staticmethod + def _safe_str(value: object, max_len: int = 300) -> str: + try: + text = str(value) + except Exception as exc: + text = f"" + if len(text) > max_len: + return text[:max_len] + "..." + return text + + @staticmethod + def _convert_outlook_datetime(value) -> Optional[datetime]: if value is None: return None if isinstance(value, datetime): @@ -492,6 +706,20 @@ class Exporter: value.minute, value.second, ) + except Exception: + pass + + for converter in (getattr(value, "Format", None), getattr(value, "strftime", None)): + if converter is None: + continue + try: + text = str(converter("%Y-%m-%d %H:%M:%S")) + return datetime.strptime(text, "%Y-%m-%d %H:%M:%S") + except Exception: + continue + + try: + return datetime.fromisoformat(str(value)) except Exception: return None @@ -517,9 +745,12 @@ class OutlookExporterApp(ctk.CTk): self.connected = False self.tree_folder_map = {} self.tree_path_map = {} + self.empty_folder_alert_shown = set() self.selected_folder_obj = None default_output_dir = Path.home() / "Desktop" / "outlook_export" self.prefs = PreferenceStore(default_output_dir) + self.log_file_path = PreferenceStore.app_data_dir() / LOG_FILE_NAME + self._init_file_log() self.selected_folder_display = ctk.StringVar(value="Nessuna cartella selezionata") self.output_dir_var = ctk.StringVar(value=self.prefs.get_output_dir()) self.status_var = ctk.StringVar(value="Pronto") @@ -529,6 +760,7 @@ class OutlookExporterApp(ctk.CTk): self._build_ui() self.hide_startup_splash() self.deiconify() + self.close_pyinstaller_splash() self.after(100, self.process_ui_queue) self.protocol("WM_DELETE_WINDOW", self.on_close) @@ -574,6 +806,15 @@ class OutlookExporterApp(ctk.CTk): pass self.splash = None + @staticmethod + def close_pyinstaller_splash() -> None: + try: + import pyi_splash + + pyi_splash.close() + except Exception: + pass + def _build_ui(self) -> None: self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(1, weight=1) @@ -741,6 +982,7 @@ class OutlookExporterApp(ctk.CTk): self.tree.delete(*self.tree.get_children()) self.tree_folder_map.clear() self.tree_path_map.clear() + self.empty_folder_alert_shown.clear() self.selected_folder_obj = None self.selected_folder_display.set("Nessuna cartella selezionata") self.btn_export.configure(state="disabled") @@ -796,6 +1038,89 @@ class OutlookExporterApp(ctk.CTk): self.selected_folder_display.set(f"Selezionata: {folder_path}") self.btn_export.configure(state="normal") + self.maybe_show_empty_folder_server_alert(node_id, folder_obj) + + def maybe_show_empty_folder_server_alert(self, node_id: str, folder_obj: object) -> None: + if node_id in self.empty_folder_alert_shown: + return + + mail_count = OutlookService.count_mail_items(folder_obj) + if mail_count != 0: + return + + self.empty_folder_alert_shown.add(node_id) + self.show_empty_folder_server_alert() + + def show_empty_folder_server_alert(self) -> None: + dialog = ctk.CTkToplevel(self) + dialog.title("Cartella Outlook vuota") + dialog.geometry("720x430") + dialog.minsize(640, 380) + dialog.transient(self) + dialog.grab_set() + + dialog.grid_columnconfigure(0, weight=0) + dialog.grid_columnconfigure(1, weight=1) + dialog.grid_rowconfigure(1, weight=1) + + alert = ctk.CTkLabel( + dialog, + text="!", + width=72, + height=72, + corner_radius=36, + fg_color="#b45309", + text_color="white", + font=ctk.CTkFont(size=42, weight="bold"), + ) + alert.grid(row=0, column=0, rowspan=2, sticky="n", padx=(24, 16), pady=(24, 12)) + + ctk.CTkLabel( + dialog, + text="La cartella selezionata risulta vuota", + font=ctk.CTkFont(size=22, weight="bold"), + anchor="w", + ).grid(row=0, column=1, sticky="ew", padx=(0, 24), pady=(26, 8)) + + message = ( + "La cartella selezionata risulta vuota, ma potrebbero esserci email sul server di Outlook " + "che non hai sincronizzato.\n\n" + "Per sincronizzare vai su:\n\n" + "File -> Impostazioni account -> Impostazioni di sincronizzazione e Account -> Tutto\n\n" + "Poi attendi la sincronizzazione completa e riprova l'esportazione.\n\n" + "Nota: se Outlook mostra un link tipo 'ci sono altri elementi sul server', il programma puo " + "vedere solo le email gia sincronizzate localmente." + ) + ctk.CTkLabel(dialog, text=message, justify="left", anchor="nw", wraplength=560).grid( + row=1, column=1, sticky="nsew", padx=(0, 24), pady=(0, 16) + ) + + buttons = ctk.CTkFrame(dialog, fg_color="transparent") + buttons.grid(row=2, column=0, columnspan=2, sticky="ew", padx=24, pady=(0, 24)) + buttons.grid_columnconfigure(0, weight=1) + buttons.grid_columnconfigure(1, weight=0) + buttons.grid_columnconfigure(2, weight=0) + + def open_outlook() -> None: + try: + os.startfile("outlook") + except Exception as exc: + messagebox.showerror(APP_TITLE, f"Impossibile aprire Outlook.\n\n{exc}") + + ctk.CTkButton(buttons, text="Apri Outlook", width=140, command=open_outlook).grid( + row=0, column=1, sticky="e", padx=(0, 12) + ) + ctk.CTkButton( + buttons, + text="Chiudi", + width=120, + command=dialog.destroy, + fg_color="#5f6368", + hover_color="#4b4f52", + ).grid(row=0, column=2, sticky="e") + + dialog.protocol("WM_DELETE_WINDOW", dialog.destroy) + dialog.after(100, dialog.focus_force) def expand_all(self) -> None: def _expand(node_id: str) -> None: @@ -1085,9 +1410,23 @@ class OutlookExporterApp(ctk.CTk): self._write_log(message) + def _init_file_log(self) -> None: + try: + self.log_file_path.parent.mkdir(parents=True, exist_ok=True) + header = ( + f"MailExporter log avviato: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + f"File log: {self.log_file_path}\n" + "=" * 80 + + "\n" + ) + self.log_file_path.write_text(header, encoding="utf-8", errors="replace") + except Exception: + pass + def _write_log(self, message: str) -> None: timestamp = datetime.now().strftime("%H:%M:%S") line = f"[{timestamp}] {message}\n" + self._append_file_log(line) self.log_box.configure(state="normal") self.log_box.insert("end", line) current_text = self.log_box.get("1.0", "end") @@ -1100,6 +1439,14 @@ class OutlookExporterApp(ctk.CTk): self.log_box.configure(state="disabled") self.pump_ui() + def _append_file_log(self, line: str) -> None: + try: + self.log_file_path.parent.mkdir(parents=True, exist_ok=True) + with self.log_file_path.open("a", encoding="utf-8", errors="replace") as fh: + fh.write(line) + except Exception: + pass + def set_status(self, message: str) -> None: if threading.get_ident() != self.main_thread_id: self.ui_queue.put(("status", message)) diff --git a/splash.png b/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..65ef5cc272bd60970b73923c17cb65207c889280 GIT binary patch literal 13102 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 literal 0 HcmV?d00001