mirror of
https://gitverse.ru/kpa39l/email-assistant.git
synced 2026-09-29 09:15:09 +00:00
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:
@@ -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
|
||||
|
||||
+131
-11
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user