diff --git a/STATUS.md b/STATUS.md index f5add24..81afc8e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -133,6 +133,8 @@ Hermes cron: ### Задача 8: Классификация писем и обработчики 🔵 (в работе, change `email-classification-handlers`) - [x] **Вложения**: фикс бага `himalaya --dir` → `--downloads-dir`; вложения в `/attachments/`; идемпотентно (2026-09-13 вечер, проверено на живом письме) +- [x] **СРОЧНЫЙ ПАТЧ (2026-09-14): архивация НЕ помечает письма «прочитанными»** (change `no-mark-seen-on-archive`): himalaya `message read`/`attachment download` используют IMAP `BODY[]`, что выставляет `\Seen` (RFC 3501; сервер — 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 слов, фолбэк himalaya + flag remove seen). Проверено живьём (UID 14200: флаги `()` → `()` при скачивании 1.1 МБ docx; кириллическая папка «Организация работы» — 4 docx, флаги не тронуты; UID 320 — без вложений, флаги не меняются) +- [x] **Бэкфилл вложений** (`--attachments-backfill`): докачка вложений для писем, заархивированных до фикса (пустой `attachments/` при `has_attachment: true`); корректно разбирает вложенные папки (`INBOX/!Битрикс`); письма, уже недоступные на IMAP, помечаются «пусто» без ошибки (2026-09-14, проверено живьём) - [x] **Классификатор**: `scripts/email_classifier.py` — Qwen3:8b (Ollama localhost:11434) → теги info/urgent/task/meeting + `classification`/`classification_reason` в frontmatter; идемпотентно; прогон прошёл (письмо 422 → task,meeting) - [x] **Обработчики**: `scripts/email_handlers.py` — urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO («Задачи»), meeting→Radicale VEVENT («Рабочий»), info→ничего; идемпотентно через `handled_*` - [x] **Фикс секретов (2026-09-14)**: скрипт теперь сам читает `radicale/.env` (RADICALE_PASS) и `/opt/vesti/.env` (VESTI_BOT_TOKEN) — раньше без ручного export был 401; добавлен stdlib-парсер .env (python-dotenv в системе нет) @@ -162,6 +164,7 @@ Hermes cron: - Каждое письмо: `himalaya get | email-to-md.py` → `email.md` - Инкрементально: добавляет все uid > last_uid, обновляет last_uid - Проблема: Himalaya v1.2.0 не поддерживает `danger_accept_invalid_certs` — используем `mail.corpoffice.tech` (валидный сертификат) +- **Seen-фикс (2026-09-14):** himalaya `message read` и `attachment download` ставят `\Seen` (BODY[]). Чтение тела: `message read --preview` (не ставит). Вложения: `fetch_attachments_imaplib()` — сырой IMAP (socket+ssl, stdlib), `UID FETCH (BODY.PEEK[])`, имя папки в modified UTF-7 (`_imap_utf7_encode()`, кириллица), чтение литерала чанками по 64 КБ (письма до 1.5 МБ), имя файла через `email.header.decode_header` (MIME-encoded word `=?koi8-r?B?...?=`). Фолбэк: himalaya `attachment download` + `flag remove seen` (страховка). ### `mail_index.py` — SQLite-индекс **Задача:** Быстрый полнотекстовый поиск по архиву, трекинг обработки контактов. diff --git a/TODO.md b/TODO.md index 6ab25f0..0c61422 100644 --- a/TODO.md +++ b/TODO.md @@ -48,4 +48,12 @@ | 2026-09-13 | Тестовый VTODO test-vikunja-removal-2026 в «Задачи» (проверка CalDAV VTODO) | ✅ закрыта | WALKTHROUGH | | 2026-09-13 | PRD.md создан (отсутствовал) | ✅ закрыта | PRD.md | | 2026-09-13 | Задача 2: Caddy reverse proxy — cal.nixg.ru работает (207), tasks.nixg.ru закомментирован | 🟡 частично (cal.nixg.ru готов) | STATUS.md | -| 2026-09-13 | Задача 3: Android — контакты синхронизированы (DAVx5), события/задачи ещё не проверены в приложении | 🔵 открыта | | \ No newline at end of file +| 2026-09-13 | Задача 3: Android — контакты синхронизированы (DAVx5), события/задачи ещё не проверены в приложении | 🔵 открыта | | + +## 2026-09-14 +| Дата | Задача | Статус | Закрыта в | +|---|---|---|---| +| 2026-09-14 | Живой прогон обработчиков (urgent→TG, task→VTODO, meeting→VEVENT) — проверен end-to-end | ✅ закрыта | STATUS.md §Задача 8 | +| 2026-09-14 | Бэкфилл вложений `--attachments-backfill` (докачка для 1697 писем has_attachment с пустыми attachments/) | ✅ закрыта | STATUS.md, openspec specs/email-attachments | +| 2026-09-14 | **СРОЧНЫЙ ПАТЧ: архивация ставила `\Seen` (письма «прочитанными»)** — himalaya BODY[]; фикс: `--preview` + `fetch_attachments_imaplib()` (BODY.PEEK[], сырой IMAP) | ✅ закрыта | openspec change `no-mark-seen-on-archive` | +| 2026-09-14 | Верификация Seen-фикса живьём: UID 14200 (docx 1.1МБ) флаги () → () — Seen не выставлен | ✅ закрыта | tasks.md change no-mark-seen-on-archive | \ No newline at end of file diff --git a/WALKTHROUGH.md b/WALKTHROUGH.md index 1e0fe98..a37fe64 100644 --- a/WALKTHROUGH.md +++ b/WALKTHROUGH.md @@ -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 (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 seen --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-файлов чейнджа. \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/.openspec.yaml b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/.openspec.yaml new file mode 100644 index 0000000..a40cb63 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/design.md b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/design.md new file mode 100644 index 0000000..012788a --- /dev/null +++ b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/design.md @@ -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.]`, поля `backend.*`, пароль — `backend.auth.raw`). +2. `imaplib.IMAP4(host, 143)` + `starttls()` + `login()`. +3. `SELECT folder` (НЕ readonly — у Exchange readonly-режим может помешать + корректному FETCH, проверить; PEEK работает в любом режиме). +4. `UID FETCH (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 --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; отдельная задача при необходимости). \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/proposal.md b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/proposal.md new file mode 100644 index 0000000..904c739 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/proposal.md @@ -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). \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/specs/email-attachments/spec.md b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/specs/email-attachments/spec.md new file mode 100644 index 0000000..32ca03c --- /dev/null +++ b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/specs/email-attachments/spec.md @@ -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.]`, `backend.*`, пароль — `backend.auth.raw`) и MUST NOT +логировать или выводить пароль. + +#### Scenario: Доступ к конфигу +- **WHEN** `get_attachments()` запускается для письма +- **THEN** подключение к IMAP выполняется с учётными данными из конфига + himalaya, пароль никуда не выводится \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/specs/email-storage-format/spec.md b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/specs/email-storage-format/spec.md new file mode 100644 index 0000000..7bdd7e9 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/specs/email-storage-format/spec.md @@ -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 появляется только если письмо реально прочитано пользователем) \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/tasks.md b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/tasks.md new file mode 100644 index 0000000..9a86011 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-no-mark-seen-on-archive/tasks.md @@ -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-папку, флаги не тронуты. \ No newline at end of file diff --git a/openspec/specs/email-attachments/spec.md b/openspec/specs/email-attachments/spec.md index e20d22a..4e324f7 100644 --- a/openspec/specs/email-attachments/spec.md +++ b/openspec/specs/email-attachments/spec.md @@ -45,3 +45,56 @@ #### Scenario: Повторный запуск - **WHEN** `mail_archive.py` запущен повторно на письме с уже скачанными вложениями - **THEN** вложения не скачиваются повторно (идемпотентность) + +### Requirement: Бэкфилл вложений для ранее заархивированных писем + +Письма, заархивированные до внедрения `--downloads-dir` (пустой `attachments/` +при `has_attachment: true`), MUST поддерживать докачку вложений через флаг +`--attachments-backfill`. Бэкфилл MUST NOT трогать письма, где вложения уже +скачаны, и MUST корректно определять IMAP-папку из пути +(`//YYYY/MM//`, папка может быть вложенной, например +`INBOX/!Битрикс`). + +#### Scenario: Запуск бэкфилла +- **WHEN** `mail_archive.py --attachments-backfill` запущен на архиве с письмами, + у которых `has_attachment: true`, но пустой `attachments/` +- **THEN** вложения скачиваются в эти папки; письма с уже скачанными вложениями пропускаются + +#### Scenario: Нестандартная структура пути +- **WHEN** бэкфилл встречает путь, не соответствующий `//YYYY/MM//email.md` +- **THEN** письмо пропускается без ошибки + +### 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.]`, `backend.*`, пароль — `backend.auth.raw`) и MUST NOT +логировать или выводить пароль. + +#### Scenario: Доступ к конфигу +- **WHEN** `get_attachments()` запускается для письма +- **THEN** подключение к IMAP выполняется с учётными данными из конфига + himalaya, пароль никуда не выводится diff --git a/openspec/specs/email-storage-format/spec.md b/openspec/specs/email-storage-format/spec.md index ceaf612..6b8dd20 100644 --- a/openspec/specs/email-storage-format/spec.md +++ b/openspec/specs/email-storage-format/spec.md @@ -48,3 +48,21 @@ **GIVEN** архив `/opt/hermes/email/` **WHEN** change применён **THEN** файлы писем остаются без изменений (проверка: `find /opt/hermes/email -name 'email.md' | wc -l` — то же число, что и до change). + +### 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 появляется только если письмо реально прочитано пользователем) diff --git a/scripts/mail_archive.py b/scripts/mail_archive.py index a3ed60d..c18e05d 100755 --- a/scripts/mail_archive.py +++ b/scripts/mail_archive.py @@ -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"" + # читаем, пока не увидим завершающий " 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}: формат "* FETCH (BODY[] {N}\r\n<тело>)\r\n 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 ... --folder + 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//YYYY/MM//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]