diff --git a/STATUS.md b/STATUS.md index c99cc73..420d98f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -76,11 +76,12 @@ Hermes cron: - [ ] Поставить cron на `mail_index.py --incremental` (раз в 5-10 мин) - [ ] Поставить cron на `digest.py` (раз в неделю) -### Фаза 1.7: Динамическое обнаружение всех подпапок INBOX ❌ -- [ ] `mail_archive.py` — список вложенных папок INBOX захардкожен (18 шт.), но на сервере их **137** (включая многоуровневые: INBOX/!Персонал/ОТ и ТБ, INBOX/Бюджет/Винный город/CAPEX 2025, INBOX/Контрагенты/iiko/Тихая гавань и т.д.) -- [ ] `--all` сейчас использует тот же хардкод — не архивирует ~120 подпапок -- [ ] Требуется: динамическое обнаружение IMAP-папок через `himalaya folder list`, рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация новых подпапок при их создании -- [ ] `mail-archive-every-5min` cron должен обновлять список папок динамически, а не из хардкода +### Фаза 1.7: Динамическое обнаружение всех подпапок INBOX ✅ +- [x] `mail_archive.py` — список вложенных папок INBOX захардкожен (18 шт.), но на сервере их **137** (включая многоуровневые: INBOX/!Персонал/ОТ и ТБ, INBOX/Бюджет/Винный город/CAPEX 2025, INBOX/Контрагенты/iiko/Тихая гавань и т.д.) +- [x] `--all` сейчас использует тот же хардкод — не архивирует ~120 подпапок +- [x] Требуется: динамическое обнаружение IMAP-папок через `himalaya folder list`, рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация новых подпапок при их создании +- [x] `mail-archive-every-5min` cron должен обновлять список папок динамически, а не из хардкода +- [x] **Реализовано (2026-09-11):** `get_inbox_subfolders()` через `himalaya folder list` — динамически находит **136** подпапок INBOX (глубина до 3), fallback на хардкод при ошибке. `--all` использует `FOLDERS + get_inbox_subfolders()`. Проверено: 140 папок в списке, smoke-тест на реальном запуске. Таймаут envelope list поднят до 180с (INBOX 14k писем >60с). ### Фаза 1.6: Адресная книга (Contacts Extractor) ✅⬜ - [x] `contacts_extractor.py` — извлечение контактов из подписей через LLM @@ -110,7 +111,8 @@ Hermes cron: **Решение:** - Для каждой папки хранится `last_uid` в `/opt/hermes/email/state/mail-archive-last-.json` -- Himalaya читает envelope (from, to, subject, date, message-id) → YAML frontmatter +- `--limit N` — максимум писем за проход (по умолчанию 200) +- `--drain` — скачивать ВСЮ почту до конца: повторять проходы по каждой папке, пока за проход не обработано 0 писем. Нужен, когда новых писем накопилось больше батча (`--limit`) — скрипт сам себя повторяет до полного осущения папки, а не оставляет хвост до следующего запуска. Предохранитель от бесконечного цикла (10 000 проходов). - `himalaya envelope --page-size 500` для быстрой загрузки списка писем - Каждое письмо: `himalaya get | email-to-md.py` → `email.md` - Инкрементально: добавляет все uid > last_uid, обновляет last_uid diff --git a/scripts/mail_archive.py b/scripts/mail_archive.py index 8ba835b..82de047 100755 --- a/scripts/mail_archive.py +++ b/scripts/mail_archive.py @@ -39,18 +39,35 @@ import sys import argparse import re import hashlib +import os from datetime import datetime from pathlib import Path # Конфигурация ARCHIVE_ROOT = Path("/opt/hermes/email") STATE_DIR = ARCHIVE_ROOT / "state" -HIMALAYA_CMD = ["himalaya"] + +# 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/!Битрикс", @@ -72,6 +89,21 @@ INBOX_SUBFOLDERS = [ "INBOX/Эксплуатация", ] +# Папки, которые НЕ архивируем (системные/мусор) +EXCLUDED_FOLDERS = { + "INBOX", + "Drafts", + "Trash", + "Trash/archive", + "Archive", + "Archives", + "RSS-каналы", + "Junk", + "Spam", + "Sent", + "Отправленные", +} + # Какие дополнительные заголовки вытягивать через --header EXTRA_HEADERS = [ "Message-ID", @@ -84,12 +116,16 @@ EXTRA_HEADERS = [ def run_cmd(cmd, timeout=60): """Выполнить команду, вернуть stdout.""" - result = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=timeout, - ) + 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: @@ -98,6 +134,64 @@ def run_cmd(cmd, timeout=60): 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(" ", "_") @@ -179,7 +273,7 @@ def get_envelopes(folder, limit=100, last_uid=0): "--page-size", str(page_size), "--output", "json", ], - timeout=60, + timeout=ENVELOPE_TIMEOUT, ) except RuntimeError as e: if "No such folder" in str(e): @@ -413,21 +507,47 @@ def main(): "--all", action="store_true", help="Архивировать включая вложенные папки INBOX" ) + parser.add_argument( + "--drain", action="store_true", + help="Скачивать ВСЮ почту до конца: повторять проходы по каждой папке, " + "пока за проход не обработано 0 писем (сколько бы ни накопилось сверх --limit)" + ) args = parser.parse_args() # Определить список папок if args.folder: folders_to_archive = [args.folder] elif args.all: - folders_to_archive = FOLDERS + INBOX_SUBFOLDERS + # Динамическое обнаружение: получаем актуальные подпапки 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: - count = archive_folder(folder, limit=args.limit) - total += count + 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)