#!/usr/bin/env python3 """ mail_archive.py — инкрементальный архиватор почты в локальную файловую базу. Сохраняет письма из IMAP-ящика в структуру: /opt/hermes/email//YYYY/MM/UID/ ├── email.md # YAML-frontmatter + тело письма └── attachments/ # вложения (если есть) Формат email.md: --- id: 255 folder: Sent subject: "Re: ..." from: "Name " to: "Name " date: "2025-07-10 11:51+03:00" flags: ["Seen"] has_attachment: false message_id: <...@vinogorod.ru> in_reply_to: <...@vinogorod.ru> references: <...> <...> cc: "Name " content_type: multipart/mixed; boundary=... --- Body text here... Отслеживает last_uid для каждой папки в /opt/hermes/email/state/mail-archive-last-.json Запуск: python3 ~/bin/mail_archive.py --folder INBOX --limit 100 python3 ~/bin/mail_archive.py --folder "Отправленные" --limit 200 """ import subprocess import json import sys import argparse import re import hashlib import os import time from datetime import datetime from pathlib import Path # Конфигурация ARCHIVE_ROOT = Path("/opt/hermes/email") STATE_DIR = ARCHIVE_ROOT / "state" # Himalaya конфиг: берём из ~/.config/himalaya/config.toml (HOME пользователя). # В текущей сессии HOME=/opt/hermes/.hermes/home, поэтому явно подставляем # реальный HOME из HERMES_REAL_HOME, иначе himalaya не найдёт конфиг. def _himalaya_cmd(): cmd = ["himalaya"] real_home = os.environ.get("HERMES_REAL_HOME") or os.path.expanduser("~") if real_home != os.path.expanduser("~"): cmd = ["env", f"HOME={real_home}"] + cmd return cmd HIMALAYA_CMD = _himalaya_cmd() # Папки для полной архивации (основные папки) FOLDERS = ["INBOX", "Отправленные", "Archive", "Sent"] # Вложенные папки INBOX, которые тоже архивируем # # ⚠️ Вместо захардкоженного списка используем динамическое обнаружение # через `himalaya folder list` (см. get_inbox_subfolders()). Список ниже # оставлен как FALLBACK на случай, если himalaya запущен без конфига # (например, автономный запуск вне сессии и без HOME пользователя). INBOX_SUBFOLDERS = [ "INBOX/!Scan", "INBOX/!Битрикс", "INBOX/!ВГ Чек листы", "INBOX/!Документооборот", "INBOX/!Завки", "INBOX/!Материалы", "INBOX/!Отчеты", "INBOX/!Персонал", "INBOX/!Протоколы", "INBOX/!Реестр оплаты", "INBOX/!Торик", "INBOX/Бюджет", "INBOX/Контрагенты", "INBOX/ЛНД", "INBOX/Организация работы", "INBOX/Приемка и стройка", "INBOX/Системы", "INBOX/Эксплуатация", ] # Папки, которые НЕ архивируем (системные/мусор) EXCLUDED_FOLDERS = { "INBOX", "Drafts", "Trash", "Trash/archive", "Archive", "Archives", "RSS-каналы", "Junk", "Spam", "Sent", "Отправленные", } # Какие дополнительные заголовки вытягивать через --header EXTRA_HEADERS = [ "Message-ID", "References", "In-Reply-To", "CC", "Content-Type", ] def run_cmd(cmd, timeout=60): """Выполнить команду, вернуть stdout.""" try: result = subprocess.run( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, ) except FileNotFoundError: # himalaya не найден в PATH (автономный запуск без окружения) raise RuntimeError(f"Command not found: {cmd[0]}") if result.returncode != 0: stderr_text = result.stderr.decode("utf-8", errors="ignore") if "No such folder" in stderr_text: return "" raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{stderr_text}") return result.stdout.decode("utf-8", errors="ignore") # Таймаут для envelope list: на больших папках (10k+ писем) himalaya # перечисляет страницы медленно (IMAP через STARTTLS). 60с не хватает — # INBOX (14k) занимает >2 мин. Даём 180с (как в mail-archive.sh DRAIN_TIMEOUT). ENVELOPE_TIMEOUT = 180 def get_inbox_subfolders(): """ Динамически получить список подпапок INBOX через `himalaya folder list`. Himalaya выводит таблицу (ASCII-арт). Вытаскиваем имена папок, начинающиеся с 'INBOX/' (исключая саму INBOX и системные папки). Возвращает отсортированный список подпапок INBOX. Папки, которые исчезли на сервере, пропускаем (archive_folder обработает их как 'No such folder' → вернёт 0). Штатно используется fallback INBOX_SUBFOLDERS, если himalaya не смог выполниться (нет конфига, ошибка сети и т.п.). """ try: stdout = run_cmd( HIMALAYA_CMD + ["folder", "list"], timeout=60, ) except RuntimeError as e: print(f" [WARN] Не удалось получить список папок: {e}; " f"использую fallback ({len(INBOX_SUBFOLDERS)} папок)", file=sys.stderr) return INBOX_SUBFOLDERS folders = [] for line in stdout.splitlines(): # Таблица himalaya: "| INBOX/путь | \HasNoChildren |" line = line.strip() if not line.startswith("|"): continue parts = [p.strip() for p in line.strip("|").split("|")] if not parts: continue name = parts[0] # Пропускаем шапку-разделитель ("----------------") if not name or set(name) <= set("- "): continue if name in EXCLUDED_FOLDERS: continue if name.startswith("INBOX/"): folders.append(name) # Fallback на хардкод, если himalaya вернул пусто (нет сети/прав) if not folders: print(f" [WARN] himalaya folder list вернул 0 подпапок INBOX; " f"использую fallback ({len(INBOX_SUBFOLDERS)} папок)", file=sys.stderr) return INBOX_SUBFOLDERS return sorted(set(folders)) def get_state_file(folder): """Получить путь к файлу состояния для папки.""" safe_name = folder.replace("/", "_").replace(" ", "_") if not all(ord(c) < 128 for c in safe_name): h = hashlib.md5(folder.encode()).hexdigest()[:12] safe_name = f"folder_{h}" return STATE_DIR / f"mail-archive-last-{safe_name}.json" def load_state(folder): """Загрузить last_uid для папки.""" state_file = get_state_file(folder) if not state_file.exists(): return {"last_uid": 0} try: with state_file.open("r", encoding="utf-8") as f: return json.load(f) except Exception: return {"last_uid": 0} def save_state(folder, state): """Сохранить last_uid для папки.""" state_file = get_state_file(folder) state_file.parent.mkdir(parents=True, exist_ok=True) with state_file.open("w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) def parse_date(date_str): """Извлечь год/месяц из строки даты.""" if not date_str: return datetime.now().year, datetime.now().month try: dt = datetime.fromisoformat(date_str) return dt.year, dt.month except Exception: pass try: dt = datetime.strptime(date_str[:25], "%a, %d %b %Y %H:%M:%S") return dt.year, dt.month except Exception: pass return datetime.now().year, datetime.now().month def fmt_contact(c): """Форматировать контакт из {name, addr} в 'Name '.""" if not c: return "" name = c.get("name") or "" addr = c.get("addr") or "" if name and addr: return f"{name} <{addr}>" return addr or name def yaml_val(v): """Экранировать значение для YAML: строки в кавычки, если нужно.""" return json.dumps(v, ensure_ascii=False) def get_envelopes(folder, limit=100, last_uid=0): """ Получить список конвертов из папки. Останавливается раньше, если набрал достаточно новых писем (UID > last_uid). """ envelopes = [] page = 1 page_size = 500 # больше писем за страницу = меньше запросов while True: try: stdout = run_cmd( HIMALAYA_CMD + [ "envelope", "list", "--folder", folder, "--page", str(page), "--page-size", str(page_size), "--output", "json", ], timeout=ENVELOPE_TIMEOUT, ) except RuntimeError as e: if "No such folder" in str(e): return [] raise if not stdout.strip(): break try: batch = json.loads(stdout) except json.JSONDecodeError: break if not batch: break envelopes.extend(batch) # Считаем сколько новых писем уже набрали new_count = 0 for e in batch: uid = int(e.get("uid") or e.get("id") or 0) if uid > last_uid: new_count += 1 # Хватит — не тащим остальные страницы total_new = sum( 1 for e in envelopes if int(e.get("uid") or e.get("id") or 0) > last_uid ) if total_new >= limit: break # Если ВСЕ письма на этой странице уже старые (UID <= last_uid) — # дальше можно не ходить, там только старее (сортировка по UID descending) if len(batch) < page_size: break last_batch_min = min( int(e.get("uid") or e.get("id") or 0) for e in batch ) if last_batch_min <= last_uid: break page += 1 return envelopes def get_email_content(uid, folder): """ Получить тело письма и дополнительные заголовки. Возвращает (headers_dict, body_text). """ # Собираем аргументы --header header_args = [] for h in EXTRA_HEADERS: header_args.extend(["--header", h]) try: stdout = run_cmd( HIMALAYA_CMD + [ "message", "read", str(uid), "--folder", folder, "--preview", # не выставлять \Seen (письмо не становится прочитанным) ] + header_args, timeout=30, ) except RuntimeError: return {}, "(body unavailable)\n" # Разделяем на блок заголовков и тело # Первый \n\n — граница между запрошенными заголовками и остальным parts = stdout.split("\n\n", 1) header_block = parts[0] if parts else "" body = parts[1] if len(parts) > 1 else "" # Парсим заголовки headers = {} for line in header_block.split("\n"): if ":" in line: key, val = line.split(":", 1) headers[key.strip()] = val.strip() return headers, body def make_email_md(meta, extra_headers, body, folder_name): """ Собрать email.md с YAML-frontmatter из meta.json + extra_headers + body. """ lines = [] lines.append("---") # Основные поля из envelope lines.append(f"id: {meta.get('id', '0')}") lines.append(f"folder: {folder_name}") lines.append(f"subject: {yaml_val(meta.get('subject', ''))}") from_str = fmt_contact(meta.get("from")) to_str = fmt_contact(meta.get("to")) lines.append(f"from: {yaml_val(from_str)}") lines.append(f"to: {yaml_val(to_str)}") date_str = meta.get("date") or meta.get("internal_date") or "" lines.append(f"date: {yaml_val(date_str)}") lines.append(f"flags: {json.dumps(meta.get('flags', []))}") lines.append(f"has_attachment: {str(meta.get('has_attachment', False)).lower()}") # Дополнительные заголовки for h in EXTRA_HEADERS: val = extra_headers.get(h) if val: lines.append(f"{h}: {yaml_val(val)}") lines.append("---") lines.append("") # Тело письма lines.append(body.rstrip("\n")) return "\n".join(lines) def _himalaya_imap_credentials(): """ Достать IMAP-учётные данные из конфига himalaya (~/.config/himalaya/config.toml). Возвращает dict(host, port, login, password) для default-аккаунта. """ import tomllib cfg_path = Path.home() / ".config" / "himalaya" / "config.toml" with open(cfg_path, "rb") as f: cfg = tomllib.load(f) accounts = cfg.get("accounts", {}) # default-аккаунт: default = true; иначе первый name = next((n for n, a in accounts.items() if a.get("default")), None) if name is None and accounts: name = next(iter(accounts)) if not name: raise RuntimeError("himalaya config: no account found") be = accounts[name].get("backend", {}) auth = be.get("auth", {}) password = auth.get("raw") or auth.get("password") if not password: # auth.cmd — команда, выдающая пароль; запускаем её cmd = auth.get("cmd", "") if cmd: parts = cmd.split() password = run_cmd(parts, timeout=15).strip() if not password: raise RuntimeError(f"himalaya config: no password for account {name}") return { "host": be["host"], "port": be.get("port", 143), "login": be["login"], "password": password, } def _imap_utf7_encode(text): """ Convert a folder name to IMAP modified UTF-7 (RFC 3501 / RFC 2152). ASCII 0x20-0x7E (кроме '&') передаётся как есть; '&' -> '&-'; не-ASCII сегменты кодируются base64 (алфавит A-Za-z0-9+,) от UTF-16BE. """ import base64 out = [] buf = [] for ch in text: if 0x20 <= ord(ch) <= 0x7E: if buf: raw = "".join(buf).encode("utf-16-be") out.append("&" + base64.b64encode(raw, altchars=b",-").decode().rstrip("=") + "-") buf = [] if ch == "&": out.append("&-") else: out.append(ch) else: buf.append(ch) if buf: raw = "".join(buf).encode("utf-16-be") out.append("&" + base64.b64encode(raw, altchars=b",-").decode().rstrip("=") + "-") return "".join(out) def fetch_attachments_imaplib(uid, folder, dest_dir): """ Скачать вложения через сырой IMAP (stdlib socket + ssl) c BODY.PEEK[]. BODY.PEEK[] НЕ выставляет флаг \\Seen (в отличие от BODY[]). Проверено на Microsoft Exchange (mail.corpoffice.tech): флаги письма остаются без Seen. imaplib НЕ подходит: Exchange отвечает на UID FETCH ... BODY.PEEK[] так, что imaplib не может прочитать литерал (raw len 0). Возвращает True при успехе, False при любой ошибке (вызывающий делает фолбэк). Пароль не логируется. """ import socket import ssl as sslmod import email as emailmod import email.header as email_header try: creds = _himalaya_imap_credentials() except Exception as e: print(f" [WARN] нет IMAP-учётных из конфига himalaya: {e}", file=sys.stderr) return False sock = None try: sock = socket.create_connection((creds["host"], creds["port"]), timeout=30) sock.settimeout(30) greet = sock.recv(1024) if not greet.startswith(b"* OK"): raise RuntimeError(f"bad greeting: {greet[:80]!r}") sock.sendall(b"a1 STARTTLS\r\n") resp = sock.recv(1024) if b"OK" not in resp: raise RuntimeError(f"STARTTLS failed: {resp[:80]!r}") ctx = sslmod.create_default_context() sock = ctx.wrap_socket(sock, server_hostname=creds["host"]) sock.settimeout(30) def cmd(tag, line, expect_literal=None): sock.sendall(f"{tag} {line}\r\n".encode()) buf = b"" # читаем, пока не увидим завершающий " OK/NO/BAD" while True: d = sock.recv(65536) if not d: break buf += d if any(l.startswith(tag.encode()) for l in buf.split(b"\r\n")): # если ждём литерал {N} — продолжаем, пока не наберём N байт тела if expect_literal: marker = b"BODY[] {" idx = buf.find(marker) if idx != -1: close = buf.find(b"}\r\n", idx) if close != -1: n = int(buf[idx + len(marker):close]) if len(buf) >= close + 3 + n: break else: break return buf r = cmd("a2", f'LOGIN {creds["login"]} {creds["password"]}') if b"OK LOGIN" not in r: raise RuntimeError(f"LOGIN failed: {r[-120:]!r}") # Папка — в IMAP modified UTF-7 (кириллица иначе не находится на Exchange) mbox = _imap_utf7_encode(folder) r = cmd("a3", f'SELECT "{mbox}"') if b"OK" not in r.split(b"\r\n")[-2]: raise RuntimeError(f"SELECT failed: {r[-120:]!r}") # UID FETCH — по IMAP UID (номер в архиве = реальный UID письма) r = cmd("a4", f"UID FETCH {uid} (BODY.PEEK[])", expect_literal=True) # тело — в литерале {N}: формат "* FETCH (BODY[] {N}\r\n<тело>)\r\n OK" marker = b"BODY[] {" idx = r.find(marker) if idx == -1: raise RuntimeError(f"no BODY[] literal in response ({len(r)} bytes)") # после "BODY[] {N}" идёт "\r\n", затем ровно N байт тела close = r.find(b"}\r\n", idx) if close == -1: raise RuntimeError("malformed literal header") n = int(r[idx + len(marker):close]) body_start = close + 3 raw = r[body_start:body_start + n] if len(raw) != n: raise RuntimeError(f"literal truncated: got {len(raw)}, expected {n}") sock.sendall(b"a5 LOGOUT\r\n") sock.close() sock = None if not raw: raise RuntimeError("empty BODY.PEEK[] response") msg = emailmod.message_from_bytes(raw) saved = 0 for part in msg.walk(): filename = part.get_filename() if not filename and part.get_content_disposition() != "attachment": continue # Декодируем MIME-encoded word, если есть (напр. =?koi8-r?B?...?=) if filename and "=?" in filename: dec = email_header.decode_header(filename) filename = "".join( t.decode(c or "utf-8", errors="replace") if isinstance(t, bytes) else t for t, c in dec ) filename = (filename or "attachment.bin").replace("/", "_").replace("\\", "_") payload = part.get_payload(decode=True) if payload is None: continue dest_dir.mkdir(parents=True, exist_ok=True) (dest_dir / filename).write_bytes(payload) saved += 1 if saved: print(f" ✓ {len(list(dest_dir.iterdir()))} вложений через сырой IMAP (BODY.PEEK[])") return True except Exception as e: print(f" [WARN] сырое IMAP-скачивание не удалось: {e}", file=sys.stderr) return False finally: if sock is not None: try: sock.close() except Exception: pass def get_attachments(uid, folder, dest_dir, timeout=25): """ Скачать вложения письма в dest_dir. Спек email-attachments: правильный флаг — `--downloads-dir` (не `--dir`). Идемпотентность: если в dest_dir уже есть файлы — не качаем повторно. Таймаут 25с + 1 повтор: himalaya периодически зависает на больших/битых письмах (без retry такие письма застревали на 60с×N, замедляя бэкфилл). Анти-Seen (спек no-mark-seen-on-archive): сначала пробуем сырой IMAP BODY.PEEK[] (не ставит \\Seen); при неудаче — фолбэк на himalaya attachment download (ставит \\Seen!) + немедленный himalaya flag remove. """ try: existing = list(dest_dir.iterdir()) if dest_dir.exists() else [] if existing: print(f" вложения уже скачаны ({len(existing)} ф.) — пропускаю") return except OSError: pass # Основной путь: сырой IMAP BODY.PEEK[] — не трогает флаги if fetch_attachments_imaplib(uid, folder, dest_dir): return # Фолбэк: himalaya attachment download (ставит Seen) + снять Seen обратно cmd = HIMALAYA_CMD + [ "attachment", "download", str(uid), "--folder", folder, "--downloads-dir", str(dest_dir), ] last_err = None for attempt in (1, 2): try: run_cmd(cmd, timeout=timeout) # Снять Seen, если himalaya его выставил (письмо могло быть непрочитанным) # Синтаксис: himalaya flag remove ... --folder try: run_cmd( HIMALAYA_CMD + ["flag", "remove", str(uid), "seen", "--folder", folder], timeout=15, ) except RuntimeError: pass # флаг и так не стоял — не страшно return except RuntimeError as e: last_err = e if attempt == 1: print(f" [WARN] попытка {attempt} не удалась ({e}) — повторяю...", file=sys.stderr) time.sleep(2) # Обе попытки провалились — письмо не теряется, папка остаётся пустой. print(f" [WARN] вложения не скачаны: {last_err}", file=sys.stderr) def backfill_attachments(limit=None): """ Бэкфилл вложений: для всех существующих писем с has_attachment:true и пустыми attachments/ вызывает get_attachments() (письма, заархивированные до фикса --downloads-dir, вложения не получили). Возвращает (обработано, пропущено_из-за_ошибки). """ processed = 0 failed = 0 scanned = 0 for email_path in sorted(ARCHIVE_ROOT.rglob("email.md")): content = email_path.read_text(encoding="utf-8", errors="replace") if "has_attachment: true" not in content: continue msg_dir = email_path.parent att_dir = msg_dir / "attachments" # Уже скачано — пропустить (идемпотентность) if att_dir.exists() and any(att_dir.iterdir()): continue # UID и папка — из структуры пути и frontmatter. # Архив: /opt/hermes/email//YYYY/MM//email.md # Папка может быть вложенной: INBOX/!Битрикс/2026/09/1048/email.md # (год — первый компонент, состоящий из 4 цифр) rel = email_path.relative_to(ARCHIVE_ROOT).parts year_idx = next((i for i, c in enumerate(rel) if c.isdigit() and len(c) == 4), None) if year_idx is None or len(rel) - year_idx != 4: continue # нестандартная структура — пропускаем folder = "/".join(rel[:year_idx]) # всё до YYYY (INBOX/!Битрикс) year = rel[year_idx] uid = rel[year_idx + 2] if not uid.isdigit(): continue att_dir.mkdir(parents=True, exist_ok=True) try: get_attachments(int(uid), folder, att_dir) except Exception as e: print(f" [ERROR] {email_path}: {e}", file=sys.stderr) failed += 1 continue has_files = att_dir.exists() and any(att_dir.iterdir()) status = "✓" if has_files else "пусто" print(f" {status} {email_path.relative_to(ARCHIVE_ROOT)}") processed += 1 scanned += 1 if limit and processed >= limit: break return processed, failed def archive_folder(folder, limit=100): """ Архивировать все новые письма из папки. Возвращает количество заархивированных писем. """ state = load_state(folder) last_uid = state.get("last_uid", 0) print(f"\n📁 {folder} (last_uid={last_uid})") envelopes = get_envelopes(folder, limit=limit, last_uid=last_uid) if not envelopes: print(f" → нет писем или папка не найдена") return 0 # Отфильтровать новые по UID new = [] for env in envelopes: uid = env.get("uid") or env.get("id") or 0 try: uid = int(uid) except (ValueError, TypeError): continue if uid > last_uid: new.append((uid, env)) if not new: print(f" → новых писем нет") return 0 new.sort(key=lambda x: x[0]) processed = 0 for uid, env in new: if processed >= limit: print(f" → достигнут лимит {limit}, осталось ещё {len(new) - processed}") break # Год/месяц date_str = env.get("date") or env.get("internal_date") or "" year, month = parse_date(date_str) # Путь: /opt/hermes/email//YYYY/MM/UID/ msg_dir = ARCHIVE_ROOT / folder / f"{year:04d}" / f"{month:02d}" / str(uid) email_path = msg_dir / "email.md" # Проверка — уже сохранено if email_path.exists(): print(f" UID {uid} уже есть, пропускаю") last_uid = max(last_uid, uid) processed += 1 continue msg_dir.mkdir(parents=True, exist_ok=True) has_attachment = bool(env.get("has_attachment", False)) # Папку attachments/ создаём ТОЛЬКО если у письма есть вложения # (спек email-attachments: без вложений пустую папку не создаём). attachments_dir = None if has_attachment: attachments_dir = msg_dir / "attachments" attachments_dir.mkdir(parents=True, exist_ok=True) # Получаем заголовки и тело extra_headers, body = get_email_content(uid, folder) # Собираем email.md content = make_email_md(env, extra_headers, body, folder) email_path.write_text(content, encoding="utf-8") # Вложения if attachments_dir is not None: get_attachments(uid, folder, attachments_dir) subj = (env.get("subject") or "")[:60] print(f" ✓ UID {uid} ({subj})") last_uid = uid processed += 1 # Сохранить состояние state["last_uid"] = last_uid save_state(folder, state) print(f" → last_uid обновлён до {last_uid}, обработано {processed}") return processed def main(): parser = argparse.ArgumentParser( description="Архиватор почты — инкрементальное сохранение в /mnt/yandex-disk/hermes/email/" ) parser.add_argument( "--folder", default=None, help="Архивировать только одну папку (по умолчанию: все основные папки)" ) parser.add_argument( "--limit", type=int, default=200, help="Максимум писем за один запуск (по умолчанию: 200)" ) parser.add_argument( "--all", action="store_true", help="Архивировать включая вложенные папки INBOX" ) parser.add_argument( "--drain", action="store_true", help="Скачивать ВСЮ почту до конца: повторять проходы по каждой папке, " "пока за проход не обработано 0 писем (сколько бы ни накопилось сверх --limit)" ) parser.add_argument( "--attachments-backfill", action="store_true", help="Бэкфилл вложений: скачать вложения для всех существующих писем с " "has_attachment:true и пустыми attachments/ (письма, заархивированные " "до фикса --downloads-dir). Опционально --limit N ограничивает число писем." ) args = parser.parse_args() if args.attachments_backfill: print("Бэкфилл вложений (has_attachment:true, пустые attachments/)...") processed, failed = backfill_attachments(limit=args.limit if args.limit != 200 else None) print(f"\nГотово: обработано {processed}, ошибок {failed}") return 0 # Определить список папок if args.folder: folders_to_archive = [args.folder] elif args.all: # Динамическое обнаружение: получаем актуальные подпапки INBOX # через himalaya folder list (с fallback на хардкод) folders_to_archive = FOLDERS + get_inbox_subfolders() else: folders_to_archive = FOLDERS total = 0 for folder in folders_to_archive: try: if args.drain: # Режим "высушить": повторяем проходы, пока папка не опустеет # (новые письма могут приходить во время скачивания — шли процесс # идёт, пока проход не вернёт 0). folder_total = 0 # Предохранитель от бесконечного цикла: не больше N проходов. max_passes = 10_000 passes = 0 while passes < max_passes: count = archive_folder(folder, limit=args.limit) folder_total += count passes += 1 if count == 0: break total += folder_total if passes >= max_passes: print(f" [WARN] {folder}: достигнут потолок проходов " f"({max_passes}), возможен остаток", file=sys.stderr) else: count = archive_folder(folder, limit=args.limit) total += count except Exception as e: print(f" [ERROR] {folder}: {e}", file=sys.stderr) print(f"\n{'='*50}") print(f"Готово. Всего заархивировано писем: {total}") return 0 if __name__ == "__main__": sys.exit(main())