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
+56
View File
@@ -247,4 +247,60 @@ VTIMEZONE Europe/Moscow + RRULE:FREQ=WEEKLY;BYDAY=TU, DTSTART 11:00.
**Открыто на следующий заход:** живой прогон обработчиков (--limit 1 на каком-то
письме с task/meeting), проверка доставки urgent в Telegram, cron
(классификатор → обработчики после mail-archive), обновление STATUS.md/TODO.md,
---
## 2026-09-14 — СРОЧНЫЙ ПАТЧ: письма НЕ помечаются «прочитанными» (no-mark-seen-on-archive)
**Симптом (от пользователя, срочно):** при скачивании письма в почтовом ящике
становятся «прочитанными» (`\Seen`). Нужно, чтобы архивация/скачивание вложений
НЕ трогали флаг.
**Диагностика:**
1. `himalaya message read --help` — по умолчанию ставит Seen; есть `--preview`
(читает без Seen).
2. `himalaya attachment download --help` — **НЕТ** флага против Seen.
3. `himalaya message export --full` — ищет по envelope id (sequence number),
а не по IMAP UID (UID ≠ envelope id) → для бэкфилла по UID непригоден.
4. **Корень (100% подтверждён):** himalaya `message read` и `attachment download`
шлют IMAP `BODY[]` (не `BODY.PEEK[]`). По RFC 3501 `BODY[]` автоматически
выставляет `\Seen`. Сервер — **Microsoft Exchange** (mail.corpoffice.tech:143,
STARTTLS), который это поведение соблюдает железно.
5. Живой тест: непрочитанное письмо UID 320 → raw `FETCH 320 BODY.PEEK[]`
прочитал 16430 байт, флаги остались `()` (Seen НЕ выставлен).
**Решение (2 правки в `scripts/mail_archive.py`):**
- **Чтение тела** (`get_email_content`): заменить `himalaya message read`
на `himalaya message read --preview` (документированный флаг, не ставит Seen).
- **Вложения** (`get_attachments` → новый `fetch_attachments_imaplib()`):
сырой IMAP на stdlib (socket + ssl), `UID FETCH <uid> (BODY.PEEK[])` —
не ставит Seen. Пароль из `~/.config/himalaya/config.toml` (секция auth.raw).
**Подводные камни, которые вскрылись при реализации:**
1. **imaplib не подходит**: `uid('fetch', ...)` возвращал 0 байт на этом
Exchange-сервере (странный парсинг литеральных ответов). Решение — сырой
socket + свой парсер.
2. **Модифицированный UTF-7**: кириллические имена папок (`INBOX/Организация
работы`) через raw socket надо отправлять в IMAP modified UTF-7, иначе
`SELECT` не находит папку. Написал `_imap_utf7_encode()` (ASCII как есть,
`&` → `&-`, не-ASCII сегменты → base64-UTF16BE).
3. **Литералы >64 КБ**: сервер отвечает `BODY[] {1534256}` — нужно читать
чанками по 64 КБ, пока не наберёшь полный литерал (N байт после `{N}\r\n`).
Первая версия падала «literal truncated: got 49152, expected 1534256».
4. **MIME-encoded words в имени файла**: `part.get_filename()` возвращал
`=?koi8-r?B?...?=` — обязательно `email.header.decode_header()`.
5. **Himalaya-фолбэк**: если сырой IMAP упал — himalaya `attachment download`
(ставит Seen) + сразу `himalaya flag remove <uid> seen --folder <folder>`
(синтаксис: ID и флаги в одном списке).
**Верификация (живой тест, 2026-09-14):**
- UID 14200, INBOX, флаги ДО `()` (непрочитанное), вложение «Переместить
стол.docx» (1 117 244 байт): `fetch_attachments_imaplib` → True, файл скачан,
флаги ПОСЛЕ `()` — **Seen НЕ выставлен**.
- UID 52, папка `INBOX/Организация работы` (кириллица): 4 docx скачаны,
флаги не тронуты (mUTF-7 работает).
- UID 320 (без вложений): BODY.PEEK[] не меняет флаги.
**Cron:** mail-archive-every-5min (5f2305b2bbf8) приостанавливался на время
отладки → **ВОЗОБНОВЛЁН** (next_run 08:05, state scheduled).
вычитка openspec-файлов чейнджа.