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:
@@ -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).
|
||||
+38
@@ -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, пароль никуда не выводится
|
||||
+21
@@ -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-папку, флаги не тронуты.
|
||||
Reference in New Issue
Block a user