Files

847 lines
33 KiB
Python
Executable File
Raw Permalink 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
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())