Files
email-assistant/scripts/mail_archive.py
T
hermes c7430d1b8a 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 ✅
2026-09-11 11:58:27 +00:00

560 lines
19 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
mail_archive.py — инкрементальный архиватор почты в локальную файловую базу.
Сохраняет письма из IMAP-ящика в структуру:
/opt/hermes/email/<folder>/YYYY/MM/UID/
├── email.md # YAML-frontmatter + тело письма
└── attachments/ # вложения (если есть)
Формат email.md:
---
id: 255
folder: Sent
subject: "Re: ..."
from: "Name <addr>"
to: "Name <addr>"
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 <addr>"
content_type: multipart/mixed; boundary=...
---
Body text here...
Отслеживает last_uid для каждой папки в /opt/hermes/email/state/mail-archive-last-<folder>.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
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 <addr>'."""
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,
] + 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 get_attachments(uid, folder, dest_dir):
"""Скачать вложения письма в dest_dir."""
try:
run_cmd(
HIMALAYA_CMD + [
"attachment", "download", str(uid),
"--folder", folder,
"--dir", str(dest_dir),
],
timeout=60,
)
except RuntimeError:
pass # нет вложений — норм
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)
# Путь: /mnt/yandex-disk/hermes/email/<folder>/YYYY/MM/UID/
msg_dir = ARCHIVE_ROOT / folder / f"{year:04d}" / f"{month:02d}" / str(uid)
email_path = msg_dir / "email.md"
attachments_dir = msg_dir / "attachments"
# Проверка — уже сохранено
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)
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")
# Вложения
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)"
)
args = parser.parse_args()
# Определить список папок
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())