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
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-14
@@ -0,0 +1,81 @@
# Design: не помечать письма прочитанными при архивации
## Контекст
- Сервер: Microsoft Exchange IMAP4 (`mail.corpoffice.tech:143`, STARTTLS).
- himalaya v1.2.0.
- Проверено на живом письме (UID 320, INBOX):
- `himalaya message read` — ставит `\Seen` (документировано; есть флаг `--preview`).
- `himalaya attachment download` — флага против Seen НЕТ (help проверен).
- Raw `FETCH ... BODY.PEEK[]` (сырой IMAP) — НЕ ставит `\Seen` (проверено:
флаги `()` до и после чтения тела 16430 байт).
## Решение
### 1. Чтение тела письма (get_email_content, mail_archive.py)
Было:
```python
HIMALAYA_CMD + ["message", "read", str(uid), "--folder", folder] + header_args
```
Стало:
```python
HIMALAYA_CMD + ["message", "read", str(uid), "--folder", folder, "--preview"] + header_args
```
`--preview` документирован: «Read the message **without** applying the "seen" flag».
### 2. Скачивание вложений (get_attachments, mail_archive.py)
`himalaya attachment download` ставит Seen, а `--preview` у него нет. Обходные
варианты:
**A. Сырой IMAP через stdlib `imaplib` (выбрано).**
Реализовать `fetch_attachments_imaplib(uid, folder, dest_dir)`:
1. Читать `account`/`password` из конфига himalaya (`~/.config/himalaya/config.toml`,
секция `[accounts.<default>]`, поля `backend.*`, пароль — `backend.auth.raw`).
2. `imaplib.IMAP4(host, 143)` + `starttls()` + `login()`.
3. `SELECT folder` (НЕ readonly — у Exchange readonly-режим может помешать
корректному FETCH, проверить; PEEK работает в любом режиме).
4. `UID FETCH <uid> (BODY.PEEK[])` — uid = номер письма **в папке** (как у нас
в структуре архива — он и есть UID, см. ниже).
5. Парсинг `email.message_from_bytes`, сбор частей с `get_filename()` или
`content-disposition: attachment`, запись в `dest_dir`.
Преимущества: убирает himalaya из критического пути (лечит и зависания),
гарантированно не ставит Seen. Недостатки: дублируется логика himalaya
(пароль в конфиге, parsing) — но конфиг-формат стабилен, парсётся stdlib tomllib.
**Б. himalaya + выставление Seen обратно после скачивания.**
`himalaya attachment download`, затем `himalaya flag remove <uid> --folder <folder> --flag seen`.
Минусы: на время скачивания письмо становится прочитанным (мгновенно, но
заметно на стороне Exchange-уведомлений); двойное обращение к IMAP; если
скачивание упадёт — письмо останется Seen.
Выбрано **А** (сырой IMAP): единственный вариант, который вообще не трогает
флаги. При этом `--preview` для тела — совместимость с himalaya-чтением.
### 3. Мелочи
- Оба места правятся в `mail_archive.py`; `email_classifier.py` и
`email_handlers.py` не трогаем (они читают локальные `email.md`, не IMAP).
- Пароль: НЕ логировать, НЕ выводить. Имя переменной — `imap_password`.
- Фолбэк: если `fetch_attachments_imaplib` падает (сервер не отдаёт PEEK) —
fallback на старый `himalaya attachment download` + `flag remove` (вариант Б).
## Проверка
1. `python3 -m py_compile scripts/mail_archive.py`
2. На живом письме UID 320 (непрочитанное, INBOX):
- `himalaya message read 320 --preview` → флаги остаются `()`.
- `fetch_attachments_imaplib(...)` → вложение скачано, флаги остаются `()`.
3. Прогнать `scripts/mail_archive.py --limit 2` на INBOX — флаги у обработанных
писем не меняются (сравнить флаги в frontmatter email.md до/после).
4. Включить cron обратно; наблюдать 1 цикл — новых `Seen` в frontmatter
у свежих писем нет.
## Rollback
1. `git checkout -- scripts/mail_archive.py` (если не закоммичено) или revert коммита.
2. Вернуть `--preview`/`fetch_attachments_imaplib` → исходные вызовы himalaya.
3. Флаги писем, уже помеченных Seen этим багом, НЕ восстанавливаются
(вне scope; отдельная задача при необходимости).
@@ -0,0 +1,57 @@
# Proposal: Не помечать письма прочитанными при архивации
## Problem
`mail_archive.py` читает каждое письмо через `himalaya message read` и
`himalaya attachment download`. Обе команды himalaya по умолчанию запрашивают
тело письма через IMAP `BODY[]`, из-за чего почтовый сервер автоматически
выставляет флаг `\Seen` — письмо в почтовом ящике становится **прочитанным**.
Подтверждение на живых данных (2026-09-14):
```
INBOX/!Персонал/2026/09/73/email.md flags: ["Seen", "Answered"]
INBOX/!Персонал/2026/09/70/email.md flags: ["Seen"]
```
Свежие входящие письма (сентябрь 2026) пришли непрочитанными, но после
автоматической архивации (cron каждые 5 мин) получили флаг `Seen`.
Статистика по всему архиву: 4223 письма с `Seen` против 973 без флагов.
Это нарушает пользовательское ожидание: вложение/тело скачивается автоматически,
но «руками» в ящике письмо никто не читал, и оно должно оставаться непрочитанным.
## Expected behavior
1. Архивация (включая скачивание вложений) НЕ выставляет флаг `\Seen`.
2. Скачивание вложений НЕ выставляет флаг `\Seen`.
3. Классификация/обработчики НЕ выставляют флаг `\Seen`.
4. Ручные команды чтения (`himalaya message read` без флагов) продолжают работать
как раньше (это личное действие пользователя).
## Accepted solution
- `himalaya message read` → добавить флаг `--preview` (документировано:
«Read the message **without** applying the "seen" flag to its corresponding
envelope»).
- `himalaya attachment download` → у команды НЕТ флага `--preview`. Обход:
перед скачиванием вложений выполнять только операции, не ставящие Seen;
сам `attachment download` заменить на извлечение вложений из уже скачанного
полного письма (`himalaya message export --full` + распаковка MIME в
stdlib Python) ЛИБО временно (до починки himalaya) — выставлять Seen обратно
через `himalaya flag remove` сразу после скачивания.
Выбор между двумя вариантами для вложений — в design.md (будет решён по
результатам проверки, ставит ли `message export` Seen).
## Out of scope
- Изменение поведения `himalaya message read` для интерактивного пользователя.
- Исправление бага himalaya (это upstream issue).
## Rollback
- Патч минимален: `--preview` в одном месте + option для вложений.
- Откат: удалить строку `--preview` (или вернуть способ скачивания вложений).
- Флаги уже помеченных писем этим патчем не возвращаются (отдельная задача,
вне scope).
@@ -0,0 +1,38 @@
# email-attachments Specification
## ADDED Requirements
### Requirement: Скачивание вложений не помечает письмо прочитанным
Скачивание вложений MUST NOT выставлять IMAP-флаг `\Seen` (письмо не должно
становиться «прочитанным» в почтовом ящике).
Способ: `himalaya attachment download` ставит `\Seen` (использует `BODY[]`), и
флага `--preview` у него нет. Поэтому `get_attachments()` MUST использовать
сырой IMAP-запрос `BODY.PEEK[]` через stdlib `imaplib` (не ставит `\Seen` на
Microsoft Exchange, проверено) и распаковку MIME через stdlib `email`.
#### Scenario: Скачивание вложения у непрочитанного письма
- **GIVEN** письмо в INBOX с флагами `()` (непрочитанное)
- **WHEN** `get_attachments()` скачивает его вложения
- **THEN** файлы вложений сохранены в `attachments/`, а флаги письма на IMAP
остаются `()` (флаг `\Seen` не выставлен)
#### Scenario: Фолбэк при сбое сырого IMAP
- **WHEN** `fetch_attachments_imaplib()` не может получить письмо (ошибка IMAP)
- **THEN** вложения скачиваются через `himalaya attachment download`, после чего
флаг `\Seen` снимается через `himalaya flag remove` (письмо временно
помечается, но восстанавливается) ИЛИ операция помечается как недоступная —
письмо НЕ остаётся прочитанным навсегда
### Requirement: Пароль IMAP для скачивания вложений
`fetch_attachments_imaplib()` MUST брать учётные данные IMAP (host, port, login,
пароль) из конфига himalaya (`~/.config/himalaya/config.toml`, секция
`[accounts.<default>]`, `backend.*`, пароль — `backend.auth.raw`) и MUST NOT
логировать или выводить пароль.
#### Scenario: Доступ к конфигу
- **WHEN** `get_attachments()` запускается для письма
- **THEN** подключение к IMAP выполняется с учётными данными из конфига
himalaya, пароль никуда не выводится
@@ -0,0 +1,21 @@
# email-storage-format Specification
## ADDED Requirements
### Requirement: Архивация не помечает письмо прочитанным
Чтение тела письма при архивации MUST NOT выставлять IMAP-флаг `\Seen`.
`mail_archive.py` для получения тела использует `himalaya message read` с
флагом `--preview` (документировано: читает БЕЗ установки `\Seen`).
#### Scenario: Архивация непрочитанного письма
- **GIVEN** письмо в INBOX с флагами `()` (непрочитанное)
- **WHEN** `mail_archive.py` архивирует письмо (пишет `email.md`)
- **THEN** в frontmatter `email.md` флаг `Seen` отсутствует И на IMAP флаги
письма остаются `()`
#### Scenario: Флаги в frontmatter соответствуют IMAP
- **GIVEN** письмо заархивировано после фикса
- **WHEN** флаги письма на IMAP меняются (пользователь прочитал/ответил)
- **THEN** frontmatter `email.md` после следующей синхронизации отражает флаги
IMAP (Seen появляется только если письмо реально прочитано пользователем)
@@ -0,0 +1,31 @@
# Tasks: не помечать письма прочитанными при архивации
## Задачи
- [x] 1. `mail_archive.py`, `get_email_content()`: добавлен `--preview` в вызов
`himalaya message read` — флаг НЕ выставляет `\Seen`.
Проверено: himalaya `message read --preview` (не ставит Seen).
- [x] 2. `mail_archive.py`, `get_attachments()`: `himalaya attachment download`
НЕ имеет флага против Seen. Вместо него — новый путь:
`fetch_attachments_imaplib()` — сырой IMAP (socket+ssl, stdlib)
c `UID FETCH ... (BODY.PEEK[])` — не выставляет `\Seen`.
Используется с фолбэком на himalaya + `flag remove seen` (страховка).
- [x] 3. `_imap_utf7_encode()` — конвертация имени папки в IMAP modified UTF-7
(кириллица в имени папки на Exchange иначе не находится).
- [x] 4. Парсинг литерала `{N}` в ответе UID FETCH: чтение чанками по 64 КБ,
ожидание полного литерала (письма до ~1.5 МБ).
- [x] 5. MIME-encoded word в `get_filename()`: декодирование
через `email.header.decode_header` (например `=?koi8-r?B?...?=`).
- [x] 6. Живая проверка на реальном письме (UID 14200, INBOX, непрочитанное,
вложение «Переместить стол.docx» 1.1 МБ):
флаги до `''` = после `''` — `\Seen` НЕ выставлен, файл скачан.
## Верификация (живой тест)
- Письмо: UID 14200, папка INBOX, флаги до: `()` (непрочитанное).
- `fetch_attachments_imaplib("14200", "INBOX", dest)` → True,
1 вложение: «Переместить стол.docx» (1 117 244 байт).
- Флаги после: `()` — без `\Seen`.
- Ранее: UID 320 (непрочитанное, без вложений): BODY.PEEK[] не меняет флаги.
- Письмо 52 (INBOX/Организация работы, кириллица в имени папки):
4 docx-вложения скачаны через mUTF-7-папку, флаги не тронуты.