mirror of
https://gitverse.ru/kpa39l/email-assistant.git
synced 2026-09-28 21:05:07 +00:00
847 lines
33 KiB
Python
Executable File
847 lines
33 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
|
||
import time
|
||
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,
|
||
"--preview", # не выставлять \Seen (письмо не становится прочитанным)
|
||
] + 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 _himalaya_imap_credentials():
|
||
"""
|
||
Достать IMAP-учётные данные из конфига himalaya (~/.config/himalaya/config.toml).
|
||
Возвращает dict(host, port, login, password) для default-аккаунта.
|
||
"""
|
||
import tomllib
|
||
|
||
cfg_path = Path.home() / ".config" / "himalaya" / "config.toml"
|
||
with open(cfg_path, "rb") as f:
|
||
cfg = tomllib.load(f)
|
||
accounts = cfg.get("accounts", {})
|
||
# default-аккаунт: default = true; иначе первый
|
||
name = next((n for n, a in accounts.items() if a.get("default")), None)
|
||
if name is None and accounts:
|
||
name = next(iter(accounts))
|
||
if not name:
|
||
raise RuntimeError("himalaya config: no account found")
|
||
be = accounts[name].get("backend", {})
|
||
auth = be.get("auth", {})
|
||
password = auth.get("raw") or auth.get("password")
|
||
if not password:
|
||
# auth.cmd — команда, выдающая пароль; запускаем её
|
||
cmd = auth.get("cmd", "")
|
||
if cmd:
|
||
parts = cmd.split()
|
||
password = run_cmd(parts, timeout=15).strip()
|
||
if not password:
|
||
raise RuntimeError(f"himalaya config: no password for account {name}")
|
||
return {
|
||
"host": be["host"],
|
||
"port": be.get("port", 143),
|
||
"login": be["login"],
|
||
"password": password,
|
||
}
|
||
|
||
|
||
def _imap_utf7_encode(text):
|
||
"""
|
||
Convert a folder name to IMAP modified UTF-7 (RFC 3501 / RFC 2152).
|
||
ASCII 0x20-0x7E (кроме '&') передаётся как есть; '&' -> '&-';
|
||
не-ASCII сегменты кодируются base64 (алфавит A-Za-z0-9+,) от UTF-16BE.
|
||
"""
|
||
import base64
|
||
|
||
out = []
|
||
buf = []
|
||
for ch in text:
|
||
if 0x20 <= ord(ch) <= 0x7E:
|
||
if buf:
|
||
raw = "".join(buf).encode("utf-16-be")
|
||
out.append("&" + base64.b64encode(raw, altchars=b",-").decode().rstrip("=") + "-")
|
||
buf = []
|
||
if ch == "&":
|
||
out.append("&-")
|
||
else:
|
||
out.append(ch)
|
||
else:
|
||
buf.append(ch)
|
||
if buf:
|
||
raw = "".join(buf).encode("utf-16-be")
|
||
out.append("&" + base64.b64encode(raw, altchars=b",-").decode().rstrip("=") + "-")
|
||
return "".join(out)
|
||
|
||
|
||
def fetch_attachments_imaplib(uid, folder, dest_dir):
|
||
"""
|
||
Скачать вложения через сырой IMAP (stdlib socket + ssl) c BODY.PEEK[].
|
||
|
||
BODY.PEEK[] НЕ выставляет флаг \\Seen (в отличие от BODY[]). Проверено на
|
||
Microsoft Exchange (mail.corpoffice.tech): флаги письма остаются без Seen.
|
||
imaplib НЕ подходит: Exchange отвечает на UID FETCH ... BODY.PEEK[] так,
|
||
что imaplib не может прочитать литерал (raw len 0).
|
||
|
||
Возвращает True при успехе, False при любой ошибке (вызывающий делает фолбэк).
|
||
Пароль не логируется.
|
||
"""
|
||
import socket
|
||
import ssl as sslmod
|
||
import email as emailmod
|
||
import email.header as email_header
|
||
|
||
sock = None
|
||
try:
|
||
# Авторизация — через общий imap_client.imap_connect() (отдельная функция,
|
||
# с re-try против rate-limit Exchange). Креды читаются из конфига himalaya
|
||
# внутри imap_connect(). Возвращает (sock, creds).
|
||
import sys as _sys
|
||
from pathlib import Path as _Path
|
||
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
||
from imap_client import imap_connect
|
||
sock, creds = imap_connect()
|
||
|
||
def cmd(tag, line, expect_literal=None):
|
||
sock.sendall(f"{tag} {line}\r\n".encode())
|
||
buf = b""
|
||
# читаем, пока не увидим завершающий "<tag> OK/NO/BAD"
|
||
while True:
|
||
d = sock.recv(65536)
|
||
if not d:
|
||
break
|
||
buf += d
|
||
if any(l.startswith(tag.encode()) for l in buf.split(b"\r\n")):
|
||
# если ждём литерал {N} — продолжаем, пока не наберём N байт тела
|
||
if expect_literal:
|
||
marker = b"BODY[] {"
|
||
idx = buf.find(marker)
|
||
if idx != -1:
|
||
close = buf.find(b"}\r\n", idx)
|
||
if close != -1:
|
||
n = int(buf[idx + len(marker):close])
|
||
if len(buf) >= close + 3 + n:
|
||
break
|
||
else:
|
||
break
|
||
return buf
|
||
|
||
# Папка — в IMAP modified UTF-7 (кириллица иначе не находится на Exchange)
|
||
mbox = _imap_utf7_encode(folder)
|
||
r = cmd("a3", f'SELECT "{mbox}"')
|
||
if b"OK" not in r.split(b"\r\n")[-2]:
|
||
raise RuntimeError(f"SELECT failed: {r[-120:]!r}")
|
||
# UID FETCH — по IMAP UID (номер в архиве = реальный UID письма)
|
||
r = cmd("a4", f"UID FETCH {uid} (BODY.PEEK[])", expect_literal=True)
|
||
# тело — в литерале {N}: формат "* <seq> FETCH (BODY[] {N}\r\n<тело>)\r\n<tag> OK"
|
||
marker = b"BODY[] {"
|
||
idx = r.find(marker)
|
||
if idx == -1:
|
||
raise RuntimeError(f"no BODY[] literal in response ({len(r)} bytes)")
|
||
# после "BODY[] {N}" идёт "\r\n", затем ровно N байт тела
|
||
close = r.find(b"}\r\n", idx)
|
||
if close == -1:
|
||
raise RuntimeError("malformed literal header")
|
||
n = int(r[idx + len(marker):close])
|
||
body_start = close + 3
|
||
raw = r[body_start:body_start + n]
|
||
if len(raw) != n:
|
||
raise RuntimeError(f"literal truncated: got {len(raw)}, expected {n}")
|
||
sock.sendall(b"a5 LOGOUT\r\n")
|
||
sock.close()
|
||
sock = None
|
||
|
||
if not raw:
|
||
raise RuntimeError("empty BODY.PEEK[] response")
|
||
msg = emailmod.message_from_bytes(raw)
|
||
saved = 0
|
||
for part in msg.walk():
|
||
filename = part.get_filename()
|
||
if not filename and part.get_content_disposition() != "attachment":
|
||
continue
|
||
# Декодируем MIME-encoded word, если есть (напр. =?koi8-r?B?...?=)
|
||
if filename and "=?" in filename:
|
||
dec = email_header.decode_header(filename)
|
||
filename = "".join(
|
||
t.decode(c or "utf-8", errors="replace") if isinstance(t, bytes) else t
|
||
for t, c in dec
|
||
)
|
||
filename = (filename or "attachment.bin").replace("/", "_").replace("\\", "_")
|
||
payload = part.get_payload(decode=True)
|
||
if payload is None:
|
||
continue
|
||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||
(dest_dir / filename).write_bytes(payload)
|
||
saved += 1
|
||
if saved:
|
||
print(f" ✓ {len(list(dest_dir.iterdir()))} вложений через сырой IMAP (BODY.PEEK[])")
|
||
return True
|
||
except Exception as e:
|
||
print(f" [WARN] сырое IMAP-скачивание не удалось: {e}", file=sys.stderr)
|
||
return False
|
||
finally:
|
||
if sock is not None:
|
||
try:
|
||
sock.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def get_attachments(uid, folder, dest_dir, timeout=120):
|
||
"""
|
||
Скачать вложения письма в dest_dir.
|
||
|
||
Спек email-attachments: правильный флаг — `--downloads-dir` (не `--dir`).
|
||
Идемпотентность: если в dest_dir уже есть файлы — не качаем повторно.
|
||
Таймаут 25с + 1 повтор: himalaya периодически зависает на больших/битых
|
||
письмах (без retry такие письма застревали на 60с×N, замедляя бэкфилл).
|
||
|
||
Анти-Seen (спек no-mark-seen-on-archive): сначала пробуем сырой IMAP
|
||
BODY.PEEK[] (не ставит \\Seen); при неудаче — фолбэк на himalaya
|
||
attachment download (ставит \\Seen!) + немедленный himalaya flag remove.
|
||
"""
|
||
try:
|
||
existing = list(dest_dir.iterdir()) if dest_dir.exists() else []
|
||
if existing:
|
||
print(f" вложения уже скачаны ({len(existing)} ф.) — пропускаю")
|
||
return
|
||
except OSError:
|
||
pass
|
||
|
||
# Основной путь: сырой IMAP BODY.PEEK[] — не трогает флаги
|
||
if fetch_attachments_imaplib(uid, folder, dest_dir):
|
||
return
|
||
|
||
# Фолбэк: himalaya attachment download (ставит Seen) + снять Seen обратно
|
||
cmd = HIMALAYA_CMD + [
|
||
"attachment", "download", str(uid),
|
||
"--folder", folder,
|
||
"--downloads-dir", str(dest_dir),
|
||
]
|
||
last_err = None
|
||
for attempt in (1, 2):
|
||
try:
|
||
run_cmd(cmd, timeout=timeout)
|
||
# Снять Seen, если himalaya его выставил (письмо могло быть непрочитанным)
|
||
# Синтаксис: himalaya flag remove <ID> <FLAG>... --folder <N>
|
||
try:
|
||
run_cmd(
|
||
HIMALAYA_CMD + ["flag", "remove", str(uid), "seen", "--folder", folder],
|
||
timeout=15,
|
||
)
|
||
except RuntimeError:
|
||
pass # флаг и так не стоял — не страшно
|
||
return
|
||
except RuntimeError as e:
|
||
last_err = e
|
||
if attempt == 1:
|
||
print(f" [WARN] попытка {attempt} не удалась ({e}) — повторяю...",
|
||
file=sys.stderr)
|
||
time.sleep(2)
|
||
# Обе попытки провалились — письмо не теряется, папка остаётся пустой.
|
||
print(f" [WARN] вложения не скачаны: {last_err}", file=sys.stderr)
|
||
|
||
|
||
def backfill_attachments(limit=None):
|
||
"""
|
||
Бэкфилл вложений: для всех существующих писем с has_attachment:true и
|
||
пустыми attachments/ вызывает get_attachments() (письма, заархивированные
|
||
до фикса --downloads-dir, вложения не получили).
|
||
|
||
Возвращает (обработано, пропущено_из-за_ошибки).
|
||
"""
|
||
processed = 0
|
||
failed = 0
|
||
scanned = 0
|
||
for email_path in sorted(ARCHIVE_ROOT.rglob("email.md")):
|
||
content = email_path.read_text(encoding="utf-8", errors="replace")
|
||
if "has_attachment: true" not in content:
|
||
continue
|
||
msg_dir = email_path.parent
|
||
att_dir = msg_dir / "attachments"
|
||
# Уже скачано — пропустить (идемпотентность)
|
||
if att_dir.exists() and any(att_dir.iterdir()):
|
||
continue
|
||
# UID и папка — из структуры пути и frontmatter.
|
||
# Архив: /opt/hermes/email/<folder>/YYYY/MM/<uid>/email.md
|
||
# Папка может быть вложенной: INBOX/!Битрикс/2026/09/1048/email.md
|
||
# (год — первый компонент, состоящий из 4 цифр)
|
||
rel = email_path.relative_to(ARCHIVE_ROOT).parts
|
||
year_idx = next((i for i, c in enumerate(rel) if c.isdigit() and len(c) == 4), None)
|
||
if year_idx is None or len(rel) - year_idx != 4:
|
||
continue # нестандартная структура — пропускаем
|
||
folder = "/".join(rel[:year_idx]) # всё до YYYY (INBOX/!Битрикс)
|
||
year = rel[year_idx]
|
||
uid = rel[year_idx + 2]
|
||
if not uid.isdigit():
|
||
continue
|
||
att_dir.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
get_attachments(int(uid), folder, att_dir)
|
||
except Exception as e:
|
||
print(f" [ERROR] {email_path}: {e}", file=sys.stderr)
|
||
failed += 1
|
||
continue
|
||
has_files = att_dir.exists() and any(att_dir.iterdir())
|
||
status = "✓" if has_files else "пусто"
|
||
print(f" {status} {email_path.relative_to(ARCHIVE_ROOT)}")
|
||
processed += 1
|
||
scanned += 1
|
||
if limit and processed >= limit:
|
||
break
|
||
return processed, failed
|
||
|
||
|
||
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)
|
||
|
||
# Путь: /opt/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"
|
||
|
||
# Проверка — уже сохранено
|
||
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)
|
||
has_attachment = bool(env.get("has_attachment", False))
|
||
# Папку attachments/ создаём ТОЛЬКО если у письма есть вложения
|
||
# (спек email-attachments: без вложений пустую папку не создаём).
|
||
attachments_dir = None
|
||
if has_attachment:
|
||
attachments_dir = msg_dir / "attachments"
|
||
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")
|
||
|
||
# Вложения
|
||
if attachments_dir is not None:
|
||
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)"
|
||
)
|
||
parser.add_argument(
|
||
"--attachments-backfill", action="store_true",
|
||
help="Бэкфилл вложений: скачать вложения для всех существующих писем с "
|
||
"has_attachment:true и пустыми attachments/ (письма, заархивированные "
|
||
"до фикса --downloads-dir). Опционально --limit N ограничивает число писем."
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
if args.attachments_backfill:
|
||
print("Бэкфилл вложений (has_attachment:true, пустые attachments/)...")
|
||
processed, failed = backfill_attachments(limit=args.limit if args.limit != 200 else None)
|
||
print(f"\nГотово: обработано {processed}, ошибок {failed}")
|
||
return 0
|
||
|
||
# Определить список папок
|
||
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()) |