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`)
|
### Задача 8: Классификация писем и обработчики 🔵 (в работе, change `email-classification-handlers`)
|
||||||
- [x] **Вложения**: фикс бага `himalaya --dir` → `--downloads-dir`; вложения в `<msg_dir>/attachments/`; идемпотентно (2026-09-13 вечер, проверено на живом письме)
|
- [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_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] **Обработчики**: `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 в системе нет)
|
- [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`
|
- Каждое письмо: `himalaya get <uid> | email-to-md.py` → `email.md`
|
||||||
- Инкрементально: добавляет все uid > last_uid, обновляет last_uid
|
- Инкрементально: добавляет все uid > last_uid, обновляет last_uid
|
||||||
- Проблема: Himalaya v1.2.0 не поддерживает `danger_accept_invalid_certs` — используем `mail.corpoffice.tech` (валидный сертификат)
|
- Проблема: 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-индекс
|
### `mail_index.py` — SQLite-индекс
|
||||||
**Задача:** Быстрый полнотекстовый поиск по архиву, трекинг обработки контактов.
|
**Задача:** Быстрый полнотекстовый поиск по архиву, трекинг обработки контактов.
|
||||||
|
|||||||
@@ -48,4 +48,12 @@
|
|||||||
| 2026-09-13 | Тестовый VTODO test-vikunja-removal-2026 в «Задачи» (проверка CalDAV VTODO) | ✅ закрыта | WALKTHROUGH |
|
| 2026-09-13 | Тестовый VTODO test-vikunja-removal-2026 в «Задачи» (проверка CalDAV VTODO) | ✅ закрыта | WALKTHROUGH |
|
||||||
| 2026-09-13 | PRD.md создан (отсутствовал) | ✅ закрыта | PRD.md |
|
| 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 | Задача 2: Caddy reverse proxy — cal.nixg.ru работает (207), tasks.nixg.ru закомментирован | 🟡 частично (cal.nixg.ru готов) | STATUS.md |
|
||||||
| 2026-09-13 | Задача 3: Android — контакты синхронизированы (DAVx5), события/задачи ещё не проверены в приложении | 🔵 открыта | |
|
| 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 на каком-то
|
**Открыто на следующий заход:** живой прогон обработчиков (--limit 1 на каком-то
|
||||||
письме с task/meeting), проверка доставки urgent в Telegram, cron
|
письме с task/meeting), проверка доставки urgent в Telegram, cron
|
||||||
(классификатор → обработчики после mail-archive), обновление STATUS.md/TODO.md,
|
(классификатор → обработчики после 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-файлов чейнджа.
|
вычитка 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: Повторный запуск
|
#### Scenario: Повторный запуск
|
||||||
- **WHEN** `mail_archive.py` запущен повторно на письме с уже скачанными вложениями
|
- **WHEN** `mail_archive.py` запущен повторно на письме с уже скачанными вложениями
|
||||||
- **THEN** вложения не скачиваются повторно (идемпотентность)
|
- **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/`
|
**GIVEN** архив `/opt/hermes/email/`
|
||||||
**WHEN** change применён
|
**WHEN** change применён
|
||||||
**THEN** файлы писем остаются без изменений (проверка: `find /opt/hermes/email -name 'email.md' | wc -l` — то же число, что и до 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 re
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -338,6 +339,7 @@ def get_email_content(uid, folder):
|
|||||||
HIMALAYA_CMD + [
|
HIMALAYA_CMD + [
|
||||||
"message", "read", str(uid),
|
"message", "read", str(uid),
|
||||||
"--folder", folder,
|
"--folder", folder,
|
||||||
|
"--preview", # не выставлять \Seen (письмо не становится прочитанным)
|
||||||
] + header_args,
|
] + header_args,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
@@ -397,12 +399,208 @@ def make_email_md(meta, extra_headers, body, folder_name):
|
|||||||
return "\n".join(lines)
|
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.
|
Скачать вложения письма в dest_dir.
|
||||||
|
|
||||||
Спек email-attachments: правильный флаг — `--downloads-dir` (не `--dir`).
|
Спек email-attachments: правильный флаг — `--downloads-dir` (не `--dir`).
|
||||||
Идемпотентность: если в dest_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:
|
try:
|
||||||
existing = list(dest_dir.iterdir()) if dest_dir.exists() else []
|
existing = list(dest_dir.iterdir()) if dest_dir.exists() else []
|
||||||
@@ -412,20 +610,88 @@ def get_attachments(uid, folder, dest_dir):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
# Основной путь: сырой IMAP BODY.PEEK[] — не трогает флаги
|
||||||
run_cmd(
|
if fetch_attachments_imaplib(uid, folder, dest_dir):
|
||||||
HIMALAYA_CMD + [
|
return
|
||||||
"attachment", "download", str(uid),
|
|
||||||
"--folder", folder,
|
# Фолбэк: himalaya attachment download (ставит Seen) + снять Seen обратно
|
||||||
"--downloads-dir", str(dest_dir),
|
cmd = HIMALAYA_CMD + [
|
||||||
],
|
"attachment", "download", str(uid),
|
||||||
timeout=60,
|
"--folder", folder,
|
||||||
)
|
"--downloads-dir", str(dest_dir),
|
||||||
except RuntimeError as e:
|
]
|
||||||
# Нет вложений / письмо не имеет вложений — норм для has_attachment=false.
|
last_err = None
|
||||||
# Но если письмо помечено has_attachment=true, а скачать не вышло —
|
for attempt in (1, 2):
|
||||||
# оставляем пустую папку и пишем warning (письмо не теряется).
|
try:
|
||||||
print(f" [WARN] вложения не скачаны: {e}", file=sys.stderr)
|
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):
|
def archive_folder(folder, limit=100):
|
||||||
@@ -534,8 +800,20 @@ def main():
|
|||||||
help="Скачивать ВСЮ почту до конца: повторять проходы по каждой папке, "
|
help="Скачивать ВСЮ почту до конца: повторять проходы по каждой папке, "
|
||||||
"пока за проход не обработано 0 писем (сколько бы ни накопилось сверх --limit)"
|
"пока за проход не обработано 0 писем (сколько бы ни накопилось сверх --limit)"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--attachments-backfill", action="store_true",
|
||||||
|
help="Бэкфилл вложений: скачать вложения для всех существующих писем с "
|
||||||
|
"has_attachment:true и пустыми attachments/ (письма, заархивированные "
|
||||||
|
"до фикса --downloads-dir). Опционально --limit N ограничивает число писем."
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
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:
|
if args.folder:
|
||||||
folders_to_archive = [args.folder]
|
folders_to_archive = [args.folder]
|
||||||
|
|||||||
Reference in New Issue
Block a user