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 на `mail_index.py --incremental` (раз в 5-10 мин)
- [ ] Поставить cron на `digest.py` (раз в неделю) - [ ] Поставить cron на `digest.py` (раз в неделю)
### Фаза 1.7: Динамическое обнаружение всех подпапок INBOX ❌ ### Фаза 1.7: Динамическое обнаружение всех подпапок INBOX ✅
- [ ] `mail_archive.py` — список вложенных папок INBOX захардкожен (18 шт.), но на сервере их **137** (включая многоуровневые: INBOX/!Персонал/ОТ и ТБ, INBOX/Бюджет/Винный город/CAPEX 2025, INBOX/Контрагенты/iiko/Тихая гавань и т.д.) - [x] `mail_archive.py` — список вложенных папок INBOX захардкожен (18 шт.), но на сервере их **137** (включая многоуровневые: INBOX/!Персонал/ОТ и ТБ, INBOX/Бюджет/Винный город/CAPEX 2025, INBOX/Контрагенты/iiko/Тихая гавань и т.д.)
- [ ] `--all` сейчас использует тот же хардкод — не архивирует ~120 подпапок - [x] `--all` сейчас использует тот же хардкод — не архивирует ~120 подпапок
- [ ] Требуется: динамическое обнаружение IMAP-папок через `himalaya folder list`, рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация новых подпапок при их создании - [x] Требуется: динамическое обнаружение IMAP-папок через `himalaya folder list`, рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация новых подпапок при их создании
- [ ] `mail-archive-every-5min` cron должен обновлять список папок динамически, а не из хардкода - [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) ✅⬜ ### Фаза 1.6: Адресная книга (Contacts Extractor) ✅⬜
- [x] `contacts_extractor.py` — извлечение контактов из подписей через LLM - [x] `contacts_extractor.py` — извлечение контактов из подписей через LLM
@@ -110,7 +111,8 @@ Hermes cron:
**Решение:** **Решение:**
- Для каждой папки хранится `last_uid` в `/opt/hermes/email/state/mail-archive-last-<folder>.json` - Для каждой папки хранится `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 envelope --page-size 500` для быстрой загрузки списка писем
- Каждое письмо: `himalaya get <uid> | email-to-md.py` → `email.md` - Каждое письмо: `himalaya get <uid> | email-to-md.py` → `email.md`
- Инкрементально: добавляет все uid > last_uid, обновляет last_uid - Инкрементально: добавляет все uid > last_uid, обновляет last_uid
+123 -3
View File
@@ -39,18 +39,35 @@ import sys
import argparse import argparse
import re import re
import hashlib import hashlib
import os
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
# Конфигурация # Конфигурация
ARCHIVE_ROOT = Path("/opt/hermes/email") ARCHIVE_ROOT = Path("/opt/hermes/email")
STATE_DIR = ARCHIVE_ROOT / "state" 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"] FOLDERS = ["INBOX", "Отправленные", "Archive", "Sent"]
# Вложенные папки INBOX, которые тоже архивируем # Вложенные папки INBOX, которые тоже архивируем
#
# ⚠️ Вместо захардкоженного списка используем динамическое обнаружение
# через `himalaya folder list` (см. get_inbox_subfolders()). Список ниже
# оставлен как FALLBACK на случай, если himalaya запущен без конфига
# (например, автономный запуск вне сессии и без HOME пользователя).
INBOX_SUBFOLDERS = [ INBOX_SUBFOLDERS = [
"INBOX/!Scan", "INBOX/!Scan",
"INBOX/!Битрикс", "INBOX/!Битрикс",
@@ -72,6 +89,21 @@ INBOX_SUBFOLDERS = [
"INBOX/Эксплуатация", "INBOX/Эксплуатация",
] ]
# Папки, которые НЕ архивируем (системные/мусор)
EXCLUDED_FOLDERS = {
"INBOX",
"Drafts",
"Trash",
"Trash/archive",
"Archive",
"Archives",
"RSS-каналы",
"Junk",
"Spam",
"Sent",
"Отправленные",
}
# Какие дополнительные заголовки вытягивать через --header # Какие дополнительные заголовки вытягивать через --header
EXTRA_HEADERS = [ EXTRA_HEADERS = [
"Message-ID", "Message-ID",
@@ -84,12 +116,16 @@ EXTRA_HEADERS = [
def run_cmd(cmd, timeout=60): def run_cmd(cmd, timeout=60):
"""Выполнить команду, вернуть stdout.""" """Выполнить команду, вернуть stdout."""
try:
result = subprocess.run( result = subprocess.run(
cmd, cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
timeout=timeout, timeout=timeout,
) )
except FileNotFoundError:
# himalaya не найден в PATH (автономный запуск без окружения)
raise RuntimeError(f"Command not found: {cmd[0]}")
if result.returncode != 0: if result.returncode != 0:
stderr_text = result.stderr.decode("utf-8", errors="ignore") stderr_text = result.stderr.decode("utf-8", errors="ignore")
if "No such folder" in stderr_text: 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") 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): def get_state_file(folder):
"""Получить путь к файлу состояния для папки.""" """Получить путь к файлу состояния для папки."""
safe_name = folder.replace("/", "_").replace(" ", "_") safe_name = folder.replace("/", "_").replace(" ", "_")
@@ -179,7 +273,7 @@ def get_envelopes(folder, limit=100, last_uid=0):
"--page-size", str(page_size), "--page-size", str(page_size),
"--output", "json", "--output", "json",
], ],
timeout=60, timeout=ENVELOPE_TIMEOUT,
) )
except RuntimeError as e: except RuntimeError as e:
if "No such folder" in str(e): if "No such folder" in str(e):
@@ -413,19 +507,45 @@ def main():
"--all", action="store_true", "--all", action="store_true",
help="Архивировать включая вложенные папки INBOX" help="Архивировать включая вложенные папки INBOX"
) )
parser.add_argument(
"--drain", action="store_true",
help="Скачивать ВСЮ почту до конца: повторять проходы по каждой папке, "
"пока за проход не обработано 0 писем (сколько бы ни накопилось сверх --limit)"
)
args = parser.parse_args() args = parser.parse_args()
# Определить список папок # Определить список папок
if args.folder: if args.folder:
folders_to_archive = [args.folder] folders_to_archive = [args.folder]
elif args.all: elif args.all:
folders_to_archive = FOLDERS + INBOX_SUBFOLDERS # Динамическое обнаружение: получаем актуальные подпапки INBOX
# через himalaya folder list (с fallback на хардкод)
folders_to_archive = FOLDERS + get_inbox_subfolders()
else: else:
folders_to_archive = FOLDERS folders_to_archive = FOLDERS
total = 0 total = 0
for folder in folders_to_archive: for folder in folders_to_archive:
try: 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) count = archive_folder(folder, limit=args.limit)
total += count total += count
except Exception as e: except Exception as e: