|
|
|
|
@@ -62,6 +62,36 @@ def unique_path(path: Path, overwrite: bool) -> Path:
|
|
|
|
|
counter += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def quote_imap_string(value: str) -> str:
|
|
|
|
|
"""Maskiert einen Text als quotierten IMAP-String."""
|
|
|
|
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
|
|
|
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")
|
|
|
|
|
@@ -114,40 +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)
|
|
|
|
|
|
|
|
|
|
status, select_data = client.select(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)
|
|
|
|
|
@@ -158,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(
|
|
|
|
|
@@ -180,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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -203,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",
|
|
|
|
|
@@ -224,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,
|
|
|
|
|
|