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
+3
View File
@@ -133,6 +133,8 @@ Hermes cron:
### Задача 8: Классификация писем и обработчики 🔵 (в работе, change `email-classification-handlers`)
- [x] **Вложения**: фикс бага `himalaya --dir` → `--downloads-dir`; вложения в `<msg_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 <uid> | 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 <uid> seen` (страховка).
### `mail_index.py` — SQLite-индекс
**Задача:** Быстрый полнотекстовый поиск по архиву, трекинг обработки контактов.
+8
View File
@@ -49,3 +49,11 @@
| 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), события/задачи ещё не проверены в приложении | 🔵 открыта | |
## 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 |
+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-файлов чейнджа.
@@ -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-папку, флаги не тронуты.
+53
View File
@@ -45,3 +45,56 @@
#### Scenario: Повторный запуск
- **WHEN** `mail_archive.py` запущен повторно на письме с уже скачанными вложениями
- **THEN** вложения не скачиваются повторно (идемпотентность)
### Requirement: Бэкфилл вложений для ранее заархивированных писем
Письма, заархивированные до внедрения `--downloads-dir` (пустой `attachments/`
при `has_attachment: true`), MUST поддерживать докачку вложений через флаг
`--attachments-backfill`. Бэкфилл MUST NOT трогать письма, где вложения уже
скачаны, и MUST корректно определять IMAP-папку из пути
(`/<folder>/YYYY/MM/<uid>/`, папка может быть вложенной, например
`INBOX/!Битрикс`).
#### Scenario: Запуск бэкфилла
- **WHEN** `mail_archive.py --attachments-backfill` запущен на архиве с письмами,
у которых `has_attachment: true`, но пустой `attachments/`
- **THEN** вложения скачиваются в эти папки; письма с уже скачанными вложениями пропускаются
#### Scenario: Нестандартная структура пути
- **WHEN** бэкфилл встречает путь, не соответствующий `/<folder>/YYYY/MM/<uid>/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.<default>]`, `backend.*`, пароль — `backend.auth.raw`) и MUST NOT
логировать или выводить пароль.
#### Scenario: Доступ к конфигу
- **WHEN** `get_attachments()` запускается для письма
- **THEN** подключение к IMAP выполняется с учётными данными из конфига
himalaya, пароль никуда не выводится
@@ -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 появляется только если письмо реально прочитано пользователем)
+293 -15
View File
@@ -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""
# читаем, пока не увидим завершающий "<tag> 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}: формат "* <seq> FETCH (BODY[] {N}\r\n<тело>)\r\n<tag> 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 <ID> <FLAG>... --folder <N>
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/<folder>/YYYY/MM/<uid>/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]