From afb9adb4443b10509af45e2051bab0d24eb45a47 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Thu, 27 Aug 2026 07:44:45 +0200 Subject: [PATCH] KI-AGENT: Gmail-Download mit Debugausgaben erweitern --- .../bedienung/gmail-anhaenge-herunterladen.md | 9 +- scripts/download-gmail-attachments.py | 82 +++++++++++++++++-- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/docs/bedienung/gmail-anhaenge-herunterladen.md b/docs/bedienung/gmail-anhaenge-herunterladen.md index a229799..96ac756 100644 --- a/docs/bedienung/gmail-anhaenge-herunterladen.md +++ b/docs/bedienung/gmail-anhaenge-herunterladen.md @@ -17,7 +17,8 @@ GMAIL_APP_PASSWORD="dein-app-passwort" \ python3 scripts/download-gmail-attachments.py \ --email name@gmail.com \ --output gmail-anhaenge \ - --group-by-message + --group-by-message \ + --debug ``` Nur Anhänge ab einem bestimmten Datum herunterladen: @@ -48,12 +49,14 @@ python3 scripts/download-gmail-attachments.py \ --list-mailboxes ``` -Wenn `--password` nicht gesetzt ist und `GMAIL_APP_PASSWORD` fehlt, fragt das Skript das Passwort interaktiv ab. +Wenn `--password` nicht gesetzt ist und `GMAIL_APP_PASSWORD` fehlt, fragt das Skript das Passwort interaktiv ab. Mit `--debug` zeigt das Skript unter anderem den Aufbau der TLS-Verbindung, den erfolgreichen Login, das ausgewählte Postfach, die Suche und die Verarbeitung jeder Mail an. Das Passwort selbst wird nie ausgegeben. ## Häufige Optionen -- `--mailbox "[Gmail]/All Mail"` durchsucht standardmäßig alle Mails. +- Ohne `--mailbox` erkennt das Skript das sprachabhängige Gmail-Postfach für alle Nachrichten automatisch. +- `--mailbox "Postfachname"` überschreibt die automatische Auswahl. - `--list-mailboxes` zeigt alle verfügbaren Gmail-IMAP-Postfächer an. +- `--debug` aktiviert detaillierte Statusausgaben inklusive Login-Erfolg. - `--group-by-message` legt pro Mail einen Unterordner an. - `--overwrite` überschreibt vorhandene Dateien. - `--since YYYY-MM-DD` und `--before YYYY-MM-DD` grenzen den Zeitraum ein. diff --git a/scripts/download-gmail-attachments.py b/scripts/download-gmail-attachments.py index 01ede0f..4aed41c 100755 --- a/scripts/download-gmail-attachments.py +++ b/scripts/download-gmail-attachments.py @@ -68,6 +68,30 @@ def quote_imap_string(value: str) -> str: return f'"{escaped}"' +def debug(args: argparse.Namespace, message: str) -> None: + if args.debug: + print(f"KI-AGENT: DEBUG: {message}") + + +def mailbox_name_from_list_entry(entry: bytes) -> str | None: + """Liest den Postfachnamen aus einer IMAP-LIST-Antwort.""" + decoded = entry.decode("utf-8", errors="replace") + match = re.search(r'(?:"((?:[^"\\]|\\.)*)"|([^ ]+))$', decoded) + if not match: + return None + if match.group(1) is not None: + return re.sub(r"\\([\\\"])", r"\1", match.group(1)) + return match.group(2) + + +def find_all_mailbox(mailbox_data: list[bytes]) -> str | None: + for entry in mailbox_data: + flags = entry.split(b")", 1)[0].lower() + if b"\\all" in flags: + return mailbox_name_from_list_entry(entry) + return None + + def message_folder(message: Message, message_id: bytes) -> str: date_header = decode_mime_header(message.get("Date")) subject = safe_filename(decode_mime_header(message.get("Subject"), "ohne-betreff"), "ohne-betreff") @@ -120,42 +144,72 @@ def iter_attachment_parts(message: Message): def download_attachments(args: argparse.Namespace) -> DownloadStats: password = args.password or os.environ.get("GMAIL_APP_PASSWORD") if not password: + debug(args, "Kein Passwortparameter gefunden; frage das App-Passwort interaktiv ab.") password = getpass.getpass("Gmail App-Passwort: ") + else: + source = "--password" if args.password else "GMAIL_APP_PASSWORD" + debug(args, f"App-Passwort aus {source} geladen (Inhalt wird nicht ausgegeben).") stats = DownloadStats() + debug(args, f"Verbinde verschlüsselt mit {GMAIL_IMAP_HOST}:{GMAIL_IMAP_PORT}.") with imaplib.IMAP4_SSL(GMAIL_IMAP_HOST, GMAIL_IMAP_PORT) as client: + debug(args, "TLS-Verbindung zum Gmail-IMAP-Server steht.") + debug(args, f"Melde Benutzer {args.email} an.") client.login(args.email, password) + debug(args, "Login erfolgreich.") - if args.list_mailboxes: + mailbox_data: list[bytes] = [] + if args.list_mailboxes or args.mailbox is None: + debug(args, "Rufe die verfügbaren IMAP-Postfächer ab.") status, mailbox_data = client.list() if status != "OK": raise RuntimeError("IMAP-Postfächer konnten nicht gelesen werden.") + mailbox_data = mailbox_data or [] + debug(args, f"{len(mailbox_data)} Postfächer gefunden.") + + if args.list_mailboxes: for mailbox in mailbox_data: print(f"KI-AGENT: {mailbox.decode('utf-8', errors='replace')}") return stats + mailbox = args.mailbox or find_all_mailbox(mailbox_data) + if not mailbox: + raise RuntimeError( + "Das Gmail-Postfach für alle Nachrichten konnte nicht automatisch gefunden werden. " + "Nutze --list-mailboxes und gib den Namen anschließend mit --mailbox an." + ) + if args.mailbox: + debug(args, f'Verwende das angegebene Postfach "{mailbox}".') + else: + debug(args, f'Postfach für alle Nachrichten automatisch erkannt: "{mailbox}".') + output_dir = args.output.expanduser().resolve() + debug(args, f"Lege den Zielordner an, falls nötig: {output_dir}") output_dir.mkdir(parents=True, exist_ok=True) - # imaplib quotiert Argumente nicht automatisch. Gmail-Postfächer wie - # "[Gmail]/All Mail" würden deshalb wegen des Leerzeichens fehlschlagen. - status, select_data = client.select(quote_imap_string(args.mailbox), readonly=True) + debug(args, f'Öffne das Postfach "{mailbox}" schreibgeschützt.') + status, select_data = client.select(quote_imap_string(mailbox), readonly=True) if status != "OK": details = b" ".join(select_data or []).decode("utf-8", errors="replace") raise RuntimeError( - f'IMAP-Postfach "{args.mailbox}" konnte nicht geöffnet werden. ' + f'IMAP-Postfach "{mailbox}" konnte nicht geöffnet werden. ' f"Nutze --list-mailboxes, um verfügbare Postfächer anzuzeigen. {details}".strip() ) + debug(args, "Postfach erfolgreich geöffnet.") - status, search_data = client.search(None, build_search_query(args)) + search_query = build_search_query(args) + debug(args, f"Starte IMAP-Suche: {search_query}") + status, search_data = client.search(None, search_query) if status != "OK": raise RuntimeError("IMAP-Suche ist fehlgeschlagen.") message_ids = search_data[0].split() stats = DownloadStats(messages_seen=len(message_ids)) + debug(args, f"Suche erfolgreich: {len(message_ids)} Mails werden verarbeitet.") for position, message_id in enumerate(message_ids, start=1): + debug(args, f"Lese Mail {position}/{len(message_ids)} mit IMAP-ID {message_id.decode(errors='replace')}.") status, fetch_data = client.fetch(message_id, "(RFC822)") if status != "OK" or not fetch_data: print(f"KI-AGENT: Mail {message_id!r} konnte nicht gelesen werden.", file=sys.stderr) @@ -166,10 +220,14 @@ def download_attachments(args: argparse.Namespace) -> DownloadStats: continue message = email.message_from_bytes(raw_message) + debug(args, f'Mail gelesen: "{decode_mime_header(message.get("Subject"), "ohne Betreff")}".') target_dir = output_dir / message_folder(message, message_id) if args.group_by_message else output_dir target_dir.mkdir(parents=True, exist_ok=True) + attachment_count = 0 for part, filename in iter_attachment_parts(message): + attachment_count += 1 + debug(args, f'Verarbeite Anhang "{filename}" aus Mail {position}.') payload = part.get_payload(decode=True) if payload is None: stats = DownloadStats( @@ -188,9 +246,13 @@ def download_attachments(args: argparse.Namespace) -> DownloadStats: ) print(f"KI-AGENT: Gespeichert: {target}") + if attachment_count == 0: + debug(args, f"Mail {position} enthält keinen Anhang.") + if args.progress and position % args.progress == 0: print(f"KI-AGENT: {position}/{len(message_ids)} Mails verarbeitet.") + debug(args, "IMAP-Verbindung ordnungsgemäß geschlossen.") return stats @@ -211,8 +273,7 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument( "--mailbox", - default="[Gmail]/All Mail", - help='IMAP-Postfach/Label. Standard: "[Gmail]/All Mail"', + help="IMAP-Postfach/Label. Standard: automatisch das mit \\All markierte Gmail-Postfach.", ) parser.add_argument( "--list-mailboxes", @@ -232,6 +293,11 @@ def parse_args() -> argparse.Namespace: help="Anhänge je Mail in einen eigenen Unterordner speichern.", ) parser.add_argument("--overwrite", action="store_true", help="Bestehende Dateien überschreiben.") + parser.add_argument( + "--debug", + action="store_true", + help="Detaillierte Statusausgaben ausgeben, ohne das Passwort anzuzeigen.", + ) parser.add_argument( "--progress", type=int,