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:
@@ -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-индекс
|
||||
**Задача:** Быстрый полнотекстовый поиск по архиву, трекинг обработки контактов.
|
||||
|
||||
@@ -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 |
|
||||
@@ -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).
|
||||
+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-папку, флаги не тронуты.
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user