# WALKTHROUGH — Email Assistant (капитанский журнал) Воспроизводимость: хронология, команды, решения, ошибки и как чинили. ## 2026-09-11 ### Фаза 1.7: динамическое обнаружение подпапок INBOX **Проблема:** `mail_archive.py` хардкодил 18 подпапок INBOX, на сервере их 136+. **Решение в `mail_archive.py`:** 1. `get_inbox_subfolders()` — парсит `himalaya folder list` (таблица ASCII), фильтрует `INBOX/`, поддерживает глубину 3, fallback на хардкод при ошибке. 2. `--all` = `FOLDERS + get_inbox_subfolders()` → 140 папок. 3. `run_cmd()` — добавлен `try/except FileNotFoundError` (himalaya не в PATH). 4. Таймаут envelope list поднят 60 → 180с: INBOX (14k писем) перечислялась >60с. **Проверено:** - `get_inbox_subfolders()` → 136 папок, 120 глубоких. - `--folder "INBOX/!Реестр платежей/Акты" --limit 10 --drain` — работает. - `--folder "INBOX/!Завки/Либра" --limit 5 --drain` — 4 письма, OK. - Smoke `--all` с заглушкой archive_folder — 140 папок в списке. - Fallback (HIMALAYA_CMD=несуществующий) — работает. **Зависание `--all`:** `envelope list` на большой INBOX висел >60с (таймаут). Поднятие до 180с + `--drain` (предохранитель 10k проходов) решило. Крон-обёртка `mail-archive.sh` переведена на `--all --drain` + `HOME=/home/estorozhenko` (himalaya не находил конфиг из-за смены HOME). **Git:** c7430d1 «feat: динамическое обнаружение подпапок INBOX (Фаза 1.7)». ### Анализ Nylas CLI → отклонено **Задача:** пользователь спросил, упростит ли Nylas получение/отправку писем. **Вывод:** Nylas — **облачный SaaS** (api.us.nylas.com), не локальный CLI. Письма идут через серверы Nylas; платная подписка; IMAP-гранты гибнут при ротации пароля; Contacts API для generic IMAP платный. Для корпоративного IMAP + локального архива — **не подходит**. **Artifacts:** `NYLAS_ANALYSIS.md` + ссылка в README. Git: d4bf3ed. ### Задача 4: Анализ ФС vs Maildir → закрыт **Задача:** оценить, удобен ли формат `email.md` (ФС) vs Maildir/MBOX/notmuch, учитывая мотивацию — локальная LLM в скриптах без облака. **Реальные данные (замер):** 4884 email.md, 76 МБ; INBOX 2674 (вкл. 1224 в 2026/), Archive 876, Отправленные 790, Sent 544; контактов 81 (а не 5!). **Вывод (STORAGE_ANALYSIS.md):** **остаться на email.md** — он идеален для LLM-скриптов (`cat email.md | ollama run qwen3:8b`), человекочитаем, атомарен (папка UID на письмо). Maildir даёт стандартность, но raw-MIME (нужен парсер), нечитаемые имена, НЕТ тэгов. Эволюция: `tags: []` в frontmatter + опц. экспорт в Maildir + FTS5 остаётся. **OpenSpec:** change `email-storage-analysis` (3 требования) → validate → archive (delta → openspec/specs/email-storage-format/spec.md). Git e31b5f2. ### Портфель: веб-UI + календарь/задачи (ПЛАН) `PLAN_WEBUI.md` — 7 задач, порядок 4→2→3→1→6→7. Решения пользователя: - Трекер задач и календаря НЕТ → добавлять как отдельные сервисы. - **Все сервисы — в отдельных Docker-контейнерах.** - Стек: **Radicale (CalDAV) + Vikunja (трекер)**. ### Задача 2: Radicale (docker) — развёрнут частично **Сделано:** 1. `/opt/hermes/email-assistant/radicale/docker-compose.yml` (образ `kozea/radicale`, порт 5232). 2. Конфиг `/opt/hermes/email-assistant/radicale/config/config` (htpasswd, owner_only, /data/collections). 3. Пользователь `estorozhenko` — `htpasswd -c -b -m data/users estorozhenko ` (пароль в `/opt/hermes/email-assistant/radicale/.env`, `RADICALE_PASS`). 4. `docker compose up -d` → контейнер `radicale` работает. **Проверено:** `curl -X PROPFIND http://127.0.0.1:5232/ -u estorozhenko:PASS` → **207**; без пароля → **401** ✅. Radicale 3.8.1.dev0, слушает 0.0.0.0:5232. **Ошибка/урок:** `command: ["radicale", "-C", ...]` падал `unrecognized arguments: radicale` — entrypoint образа УЖЕ вызывает `radicale`, передавать только флаги: `command: ["-C", "/config/config"]`. **Заблокировано:** создание коллекций. `MKCOL /Личный/` → **403**. Radicale 3.x создаёт коллекции иначе (PUT ресурса с Content-Type: text/calendar в новую коллекцию). Попытка проверки через PUT тестового VEVENT была **заблокирована таймаутом команды** (execution guard) — жду решение пользователя/возобновление. **Осталось (Задача 2):** коллекции (Личный/Рабочий/Задачи), Vikunja (:3456, postgres), Caddy (cal.nixg.ru, tasks.nixg.ru), Android. ### Известные открытые хвосты (репозиторий) - `PLAN_WEBUI.md` — untracked (не закоммичен). - Cron mail-index-incremental и digest-weekly — не настроены. - Push mirror gitea → gitverse — не настроен (нужен токен [REDACTED]). ## 2026-09-13 ### Radicale: пароль сменён (пользователь не помнил старый) **Проблема:** старый пароль Radicale (md5/$apr1$-хэш в `data/users`) не подходил; пользователь не помнил пароль. **Решение:** `cp -a users users.bak.` → сгенерировали хэш `$apr1$` (`openssl passwd -apr1 '<пароль>'`, значение — `RADICALE_PASS` в `radicale/.env`) → перезаписали `data/users`. Проверка: `PROPFIND https://cal.nixg.ru/` → **207** (доступ подтверждён). **Урок:** Radicale хранит пароль как **Apache `$apr1$` (MD5-crypt)**, не как простой md5. Проверять пароль — `crypt.crypt(cand, hash) == hash`. ### «Обход в Глории» — повторяющееся событие (Рабочий) Добавлено через CalDAV PUT: `PUT /estorozhenko//obhod-v-glorii-2026.ics` → **201**. VTIMEZONE Europe/Moscow + RRULE:FREQ=WEEKLY;BYDAY=TU, DTSTART 11:00. Пользователь подтвердил: GMT+3 отображается корректно. **Урок:** сервер UTC, а у пользователя GMT+3 — обязательно указывать VTIMEZONE (Europe/Moscow), иначе время «поедет» в приложении. ### Чейндж: классификация писем + обработчики (email-classification-handlers) Пользователь попросил после скачивания письма классифицировать его локальной моделью (Qwen3:8b) и подключать обработчики по тегам. **Диагностика пайплайна (важно):** - Сейчас скачивается **только текст** + метаданные. Вложения — **НЕ качаются**. - **Баг:** `get_attachments()` в `mail_archive.py` вызывает `himalaya attachment download --dir `, но правильный флаг — **`--downloads-dir`** (не `--dir`). Команда падает (exit 2), ошибка молча глотается `except: pass`, папка `attachments/` всегда пустая. - Проверено на живом письме с `has_attachment: true`: папка пустая. - Классификатора/обработчиков нет; тэгов `tags:` нет ни в одном email.md. **Создан чейндж** `email-classification-handlers` (proposal/specs/design/tasks): - `email-attachments`: фикс вложений (`--downloads-dir`, в каталог письма `/attachments/`, идемпотентно) - `email-classification`: Qwen3:8b (Ollama localhost:11434) → теги info/urgent/task/meeting (+unclassified при ошибке), поле `classification` + `classification_reason` в frontmatter, идемпотентно, приватно - `email-handlers`: urgent→Telegram, task→Radicale VTODO, meeting→Radicale VEVENT, info→ничего; `handled_*` в frontmatter ### Vikunja — ЛИШНЯЯ СУЩНОСТЬ, удалена (remove-vikunja-use-radicale-tasks) **Решение пользователя (2026-09-13):** Vikunja не нужна — Radicale умеет задачи как VTODO (календарь «Задачи»), jtx board читает их по CalDAV. **Создан и применён чейндж** `remove-vikunja-use-radicale-tasks` (14 задач, все выполнены): - Обработчик `task` в classification → **Radicale CalDAV PUT VTODO** (SUMMARY=тема, DESCRIPTION=ссылка на email.md, DTSTART/DUE при наличии) - `docker compose down -v` в `/opt/hermes/email-assistant/vikunja/` → контейнеры `vikunja` + `vikunja-db` удалены, порт 3456 свободен - Каталог `vikunja/` удалён (db от root — `sudo rm -rf`) - Caddy vps02: блок `tasks.nixg.ru` закомментирован (строки 114-120), `caddy validate` → Valid, `caddy reload`. Бэкап Caddyfile: `/opt/caddy/Caddyfile.bak-vikunja-removed` - Проверка: `cal.nixg.ru` → 207 (работает), `tasks.nixg.ru` → 502 (не проксируется) - Тестовый VTODO `test-vikunja-removal-2026` создан в «Задачи» (PUT 201, GET 200) - TODO.md / STATUS.md / local-calendar-tasks (SUPERSEDED) обновлены - Бэкап Vikunja пропущен по явному решению пользователя **Урок:** Radicale нативно хранит VTODO (задачи) — отдельный трекер задач (Vikunja) был избыточен. При выборе сервисов календаря Radicale закрывает и календари, и задачи (VTODO), и контакты (CardDAV). ### Открытые хвосты (2026-09-13) - Чейндж `email-classification-handlers` — 0/23 задач (фикс вложений, скрипты классификатора/обработчиков, cron, Telegram-секреты). - Чейндж `contacts-caldav-server` — 16/30 (двусторонний sync контактов работает, есть ещё задачи). - Android-синхронизация: DAVx5 → Radicale (контакты синхронизировались), задачи VTODO → jtx board — не настроено. - Тестовый VTODO `test-vikunja-removal-2026` остался в «Задачи» (проверка). ## 2026-09-13 (вечер) — чейндж email-classification-handlers, автономный заход Сессия велась автономно (пользователь дал разрешение «работай без подтверждения»). Цель — закрыть чейндж `email-classification-handlers` (0/23 → прогресс). ### 1. Вложения (email-attachments) — сделано, проверено `scripts/mail_archive.py`: - `get_attachments()`: флаг `--dir` → **`--downloads-dir`** (подтверждено `himalaya attachment download --help`). - Папка `attachments/` создаётся **только** при `has_attachment: true` (раньше — безусловно, плодила 1695 пустых папок). - Идемпотентность: если в `dest_dir` уже есть файлы — повторно не качает. - Удалена мёртвая строка `attachments_dir = msg_dir / "attachments"` из цикла. - Проверено: `.xlsx` скачался на живом письме `Archive/2025/11/28`, повторный прогон не дублировал (контроль hashlib). ### 2. Классификатор (email-classification) — создан `scripts/email_classifier.py` (327 строк): - Читает `email.md` из архива, для писем **без** поля `classification` в frontmatter вызывает **Qwen3:8b через Ollama (localhost:11434)**. - Пишет `classification` (теги info/urgent/task/meeting) + `classification_reason` в frontmatter. Идемпотентно (повторный прогон пропускает уже классифицированные). - Ручной прогон: письмо `2026/422` получило теги `task,meeting` ✓. - Переиспользованы паттерны из `contacts_extractor.py` (clean_body, call_llm, константы MAX_BODY_CHARS/LLM_TIMEOUT). ### 3. Обработчики (email-handlers) — созданы, Radicale проверен живьём `scripts/email_handlers.py` (~470 строк): - `urgent` → **Telegram** (Bot API через SOCKS5 `socks5://127.0.0.1:1080`, токен `VESTI_BOT_TOKEN` из /opt/vesti/.env, канал `TELEGRAM_CHAT_ID`, дефолт `@dedinit_vesti`). - `task` → **Radicale CalDAV PUT VTODO** в календарь «Задачи». - `meeting` → **Radicale CalDAV PUT VEVENT** в календарь «Рабочий». - `info` → ничего. - Идемпотентность: после успеха пишет `handled_urgent/handled_task/handled_meeting: true` в frontmatter; повторный прогон пропускает. - Режимы: `--limit N`, `--folder`, `--dry-run`. **Подводные камни Radicale (важно для повторения!):** 1. **Пароль в `radicale/.env` НЕ совпадал с `radicale/data/users`** — пароль менялся в 13.09 17:55 (users.bak), но `.env` остался от 11.09. Доступ 401. Синхронизировал `.env` с актуальным паролем (бэкап `.env.bak.`). **Правило: после смены пароля Radicale обновлять и `.env` скриптов.** 2. **`urllib.request` НЕ работает с percent-encoded кириллицей в URL** CalDAV (`/estorozhenko/%D0%97%D0%B0%D0%B4%D0%B0%D1%87%D0%B8/...`) — падает `Errno -2 Name or service not known`. Решение: использовать **`http.client`** напрямую (HTTPConnection + request с готовым path). Проверено: PUT 201. 3. **PROPFIND без заголовка `Depth: 1`** возвращает только сам ресурс, без дочерних календарей → коллекции «не находились». Обязательно `headers["Depth"] = "1"`. 4. **Формат дат iCalendar единый `YYYYMMDDTHHMMSS`** — не смешивать `2026-09-14T11:00:00` (с дефисами) и `20260914T110000` (Radicale отклоняет первое, 400 Bad Request). 5. **Коллекции Radicale кэшируются** через `@lru_cache` (PROPFIND один раз за проход). **Проверено:** dry-run → `task` VTODO 204, `meeting` VEVENT 201, тестовые объекты удалены (DELETE 200). ### Статус чейнджа на конец сессии - 1. Вложения: готово (проверено). - 2. Классификатор: скрипт готов, прогон прошёл (письмо 422 → task,meeting). - 3. Обработчики: скрипт готов, dry-run чист (VTODO/VEVENT создаются). Осталось: живой прогон на реальном письме (без --dry-run), проверка urgent→Telegram. - 4. Секреты: Radicale-пароль в .env синхронизирован. Осталось: TELEGRAM_CHAT_ID в .env (токен есть в /opt/vesti/.env, VESTI_BOT_TOKEN). - 5. Cron: не настроен (после пунктов 3-4). - 6. Документация: этот WALKTHROUGH, STATUS/TODO — следующий заход. **Открыто на следующий заход:** живой прогон обработчиков (--limit 1 на каком-то письме с task/meeting), проверка доставки urgent в Telegram, cron (классификатор → обработчики после mail-archive), обновление STATUS.md/TODO.md, --- ## 2026-09-14 — СРОЧНЫЙ ПАТЧ: письма НЕ помечаются «прочитанными» (no-mark-seen-on-archive) **Симптом (от пользователя, срочно):** при скачивании письма в почтовом ящике становятся «прочитанными» (`\Seen`). Нужно, чтобы архивация/скачивание вложений НЕ трогали флаг. **Диагностика:** 1. `himalaya message read --help` — по умолчанию ставит Seen; есть `--preview` (читает без Seen). 2. `himalaya attachment download --help` — **НЕТ** флага против Seen. 3. `himalaya message export --full` — ищет по envelope id (sequence number), а не по IMAP UID (UID ≠ envelope id) → для бэкфилла по UID непригоден. 4. **Корень (100% подтверждён):** himalaya `message read` и `attachment download` шлют IMAP `BODY[]` (не `BODY.PEEK[]`). По RFC 3501 `BODY[]` автоматически выставляет `\Seen`. Сервер — **Microsoft Exchange** (mail.corpoffice.tech:143, STARTTLS), который это поведение соблюдает железно. 5. Живой тест: непрочитанное письмо UID 320 → raw `FETCH 320 BODY.PEEK[]` прочитал 16430 байт, флаги остались `()` (Seen НЕ выставлен). **Решение (2 правки в `scripts/mail_archive.py`):** - **Чтение тела** (`get_email_content`): заменить `himalaya message read` на `himalaya message read --preview` (документированный флаг, не ставит Seen). - **Вложения** (`get_attachments` → новый `fetch_attachments_imaplib()`): сырой IMAP на stdlib (socket + ssl), `UID FETCH (BODY.PEEK[])` — не ставит Seen. Пароль из `~/.config/himalaya/config.toml` (секция auth.raw). **Подводные камни, которые вскрылись при реализации:** 1. **imaplib не подходит**: `uid('fetch', ...)` возвращал 0 байт на этом Exchange-сервере (странный парсинг литеральных ответов). Решение — сырой socket + свой парсер. 2. **Модифицированный UTF-7**: кириллические имена папок (`INBOX/Организация работы`) через raw socket надо отправлять в IMAP modified UTF-7, иначе `SELECT` не находит папку. Написал `_imap_utf7_encode()` (ASCII как есть, `&` → `&-`, не-ASCII сегменты → base64-UTF16BE). 3. **Литералы >64 КБ**: сервер отвечает `BODY[] {1534256}` — нужно читать чанками по 64 КБ, пока не наберёшь полный литерал (N байт после `{N}\r\n`). Первая версия падала «literal truncated: got 49152, expected 1534256». 4. **MIME-encoded words в имени файла**: `part.get_filename()` возвращал `=?koi8-r?B?...?=` — обязательно `email.header.decode_header()`. 5. **Himalaya-фолбэк**: если сырой IMAP упал — himalaya `attachment download` (ставит Seen) + сразу `himalaya flag remove seen --folder ` (синтаксис: ID и флаги в одном списке). **Верификация (живой тест, 2026-09-14):** - UID 14200, INBOX, флаги ДО `()` (непрочитанное), вложение «Переместить стол.docx» (1 117 244 байт): `fetch_attachments_imaplib` → True, файл скачан, флаги ПОСЛЕ `()` — **Seen НЕ выставлен**. - UID 52, папка `INBOX/Организация работы` (кириллица): 4 docx скачаны, флаги не тронуты (mUTF-7 работает). - UID 320 (без вложений): BODY.PEEK[] не меняет флаги. **Cron:** mail-archive-every-5min (5f2305b2bbf8) приостанавливался на время отладки → **ВОЗОБНОВЛЁН** (next_run 08:05, state scheduled). вычитка openspec-файлов чейнджа.