mirror of
https://gitverse.ru/kpa39l/email-assistant.git
synced 2026-09-29 09:15:09 +00:00
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:
+293
-15
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user