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,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 появляется только если письмо реально прочитано пользователем)