mirror of
https://gitverse.ru/kpa39l/email-assistant.git
synced 2026-09-29 21:25:10 +00:00
c7430d1b8a
- 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 ✅
560 lines
19 KiB
Python
Executable File
560 lines
19 KiB
Python
Executable File
#!/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()) |