Initial commit: Email Assistant project

This commit is contained in:
2026-07-19 16:19:14 +00:00
commit 004977b1f8
16 changed files with 2653 additions and 0 deletions
+440
View File
@@ -0,0 +1,440 @@
#!/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
from datetime import datetime
from pathlib import Path
# Конфигурация
ARCHIVE_ROOT = Path("/opt/hermes/email")
STATE_DIR = ARCHIVE_ROOT / "state"
HIMALAYA_CMD = ["himalaya"]
# Папки для полной архивации (основные папки)
FOLDERS = ["INBOX", "Отправленные", "Archive", "Sent"]
# Вложенные папки INBOX, которые тоже архивируем
INBOX_SUBFOLDERS = [
"INBOX/!Scan",
"INBOX/!Битрикс",
"INBOX/!ВГ Чек листы",
"INBOX/!Документооборот",
"INBOX/!Завки",
"INBOX/!Материалы",
"INBOX/!Отчеты",
"INBOX/!Персонал",
"INBOX/!Протоколы",
"INBOX/!Реестр оплаты",
"INBOX/!Торик",
"INBOX/Бюджет",
"INBOX/Контрагенты",
"INBOX/ЛНД",
"INBOX/Организация работы",
"INBOX/Приемка и стройка",
"INBOX/Системы",
"INBOX/Эксплуатация",
]
# Какие дополнительные заголовки вытягивать через --header
EXTRA_HEADERS = [
"Message-ID",
"References",
"In-Reply-To",
"CC",
"Content-Type",
]
def run_cmd(cmd, timeout=60):
"""Выполнить команду, вернуть stdout."""
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
)
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")
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=60,
)
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"
)
args = parser.parse_args()
# Определить список папок
if args.folder:
folders_to_archive = [args.folder]
elif args.all:
folders_to_archive = FOLDERS + 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
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())