feat: динамическое обнаружение подпапок INBOX (Фаза 1.7)

- get_inbox_subfolders(): парсит 'himalaya folder list', находит 136
  подпапок INBOX (вместо хардкода 18), fallback на INBOX_SUBFOLDERS
- --all использует FOLDERS + get_inbox_subfolders()
- EXCLUDED_FOLDERS: Trash/Drafts/RSS/Junk/Spam/Archive не архивируются
- ENVELOPE_TIMEOUT=180: INBOX (14k писем) перечисляется >60с
- run_cmd() ловит FileNotFoundError (himalaya вне PATH)
- mail-archive.sh: HOME=/home/estorozhenko, цикл переписан на --all --drain
- STATUS.md: Фаза 1.7 ✅
This commit is contained in:
2026-09-11 11:58:27 +00:00
parent 7aa0d191db
commit c7430d1b8a
2 changed files with 139 additions and 17 deletions
+8 -6
View File
@@ -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-<folder>.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 <uid> | email-to-md.py` → `email.md`
- Инкрементально: добавляет все uid > last_uid, обновляет last_uid
+123 -3
View File
@@ -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."""
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,19 +507,45 @@ 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:
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: