fix: архивация/вложения больше не помечают письма прочитанными (Seen)

Причина: himalaya message read и attachment download используют IMAP BODY[],
который по RFC 3501 выставляет \Seen на сервере (Microsoft Exchange).
Пользователь: письма в ящике после скачивания становятся прочитанными.

Фикс:
- чтение тела: himalaya message read --preview (не ставит Seen)
- вложения: fetch_attachments_imaplib() — сырой IMAP stdlib (socket+ssl),
  UID FETCH (BODY.PEEK[]), папки в modified UTF-7, литералы до 1.5МБ,
  MIME-encoded words, фолбэк himalaya + flag remove seen

Проверено живьём: UID 14200 (INBOX, docx 1.1МБ) флаги ()->() — Seen не выставлен.
Openspec: change no-mark-seen-on-archive заархивирован (2026-09-14-no-mark-seen-on-archive), 7/7 validate OK
This commit is contained in:
2026-09-14 08:08:48 +00:00
parent 8bff6f9aa4
commit 39df85b51c
12 changed files with 662 additions and 16 deletions
+293 -15
View File
@@ -40,6 +40,7 @@ import argparse
import re
import hashlib
import os
import time
from datetime import datetime
from pathlib import Path
@@ -338,6 +339,7 @@ def get_email_content(uid, folder):
HIMALAYA_CMD + [
"message", "read", str(uid),
"--folder", folder,
"--preview", # не выставлять \Seen (письмо не становится прочитанным)
] + header_args,
timeout=30,
)
@@ -397,12 +399,208 @@ def make_email_md(meta, extra_headers, body, folder_name):
return "\n".join(lines)
def get_attachments(uid, folder, dest_dir):
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
try:
creds = _himalaya_imap_credentials()
except Exception as e:
print(f" [WARN] нет IMAP-учётных из конфига himalaya: {e}", file=sys.stderr)
return False
sock = None
try:
sock = socket.create_connection((creds["host"], creds["port"]), timeout=30)
sock.settimeout(30)
greet = sock.recv(1024)
if not greet.startswith(b"* OK"):
raise RuntimeError(f"bad greeting: {greet[:80]!r}")
sock.sendall(b"a1 STARTTLS\r\n")
resp = sock.recv(1024)
if b"OK" not in resp:
raise RuntimeError(f"STARTTLS failed: {resp[:80]!r}")
ctx = sslmod.create_default_context()
sock = ctx.wrap_socket(sock, server_hostname=creds["host"])
sock.settimeout(30)
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
r = cmd("a2", f'LOGIN {creds["login"]} {creds["password"]}')
if b"OK LOGIN" not in r:
raise RuntimeError(f"LOGIN failed: {r[-120:]!r}")
# Папка — в 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=25):
"""
Скачать вложения письма в 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 []
@@ -412,20 +610,88 @@ def get_attachments(uid, folder, dest_dir):
except OSError:
pass
try:
run_cmd(
HIMALAYA_CMD + [
"attachment", "download", str(uid),
"--folder", folder,
"--downloads-dir", str(dest_dir),
],
timeout=60,
)
except RuntimeError as e:
# Нет вложений / письмо не имеет вложений — норм для has_attachment=false.
# Но если письмо помечено has_attachment=true, а скачать не вышло —
# оставляем пустую папку и пишем warning (письмо не теряется).
print(f" [WARN] вложения не скачаны: {e}", file=sys.stderr)
# Основной путь: сырой 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):
@@ -534,8 +800,20 @@ def main():
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]