34 KiB
WALKTHROUGH — Email Assistant (капитанский журнал)
Воспроизводимость: хронология, команды, решения, ошибки и как чинили.
2026-09-15 (вечер, сессия @session:default/20260915_174925_ec1ca4)
imap_stream.py — Фаза 1 (IDLE-цикл) реализован
Цель: постоянный IMAP-поток (задачи 3–5 change imap-realtime-sync). aioimaplib
не ставится → свой клиент на сыром socket поверх imap_client.imap_connect.
Архитектура scripts/imap_stream.py:
ImapStream— обёртка над сокетом: теги,_cmd,_cmd_ok, чтение до тега.- IDLE-цикл:
idle_start→idle_wait(25с)→idle_done→ перевыпуск. - Reconnect: при ошибке
CONNECT_PAUSE=45с(rate-limit Exchange), повтор. reconcile_new(folder)— поиск UID > last_uid, архивация через mail_archive, запись вmailbox_state+ событиеmailbox_events.reconcile_full(folder)— полное сравнение UID/флагов (каждый 5-й цикл).- SQLite:
/opt/hermes/email/state/mailbox.db, таблицыmailbox_state(uid, folder, message_id, in_reply_to, refs, flags, has_attachment, archive_path, last_seen, deleted) иmailbox_events(event/ts/folder/uid/detail). - CLI:
--check(авторизация),--test-idle(IDLE 30с),--status,--metrics, без аргументов — демон.
Питфолы Exchange, найденные live-тестами (пауза ≥45с между коннектами):
_load_credentialsв imap_client.py читалPath.home()— в сессии Hermes HOME≠реальный. Фикс:HERMES_REAL_HOME(env) →/home/estorozhenko. Cron уже делает это в mail-archive.sh._cmd_ok:resp.split(b"\r\n")[-1]давал пустой элемент (хвост\r\n) — команда считалась failed даже при OK. Фикс: разбор строк до тега.- Колонка
references— зарезервированное слово SQLite →refs. - UID-батчинг сломан:
UID SEARCH UID 1:1000пуст (UID — глобальный номер ~14200+, не порядковый); первый пустой батч обрывал цикл, все письма ложно помечались deleted. Фикс:UID SEARCH ALL/UID SEARCH UID <min>:*(проверено: 365 UID). - Exchange отвечает на IDLE
+ IDLE accepted, awaiting DONE command.— НЕ+ idling. Фикс: матч+ IDLEили+ idling. - Exchange рвёт IDLE-соединение ~60с (не 30 мин) —
IDLE_TIMEOUT=25с(перевыпуск до серверного лимита). Демон переживает обрыв: reconnect 45с.
Live-тесты (все с паузой ≥45с):
--check: connect+LOGIN+SELECT 365 писем → OK.--test-idle: SELECT → reconcile (0 новых, deleted=0, flags=18) → IDLE 30с → 0 событий → DONE → EXIT=0.- Демон 150с: stream_started → SELECT 365 → обрыв IDLE (~60с) → reconnect_pause 45с → SELECT 365. Цикл переподключения работает.
Git: 5a81c86 запушен в gitverse (defcf53..5a81c86). Файлы: scripts/imap_stream.py
(новый, ~690 строк), scripts/email_handlers.py (+фильтр актуальности), scripts/imap_client.py
(+HERMES_REAL_HOME).
Backlog urgent-pисем: корень найден + фильтр актуальности
Проблема: 10 urgent-писем не уходили в Telegram. Причина: при наличии
python-dotenv email_handlers.py шёл в ветку if load_dotenv — грузил только
BASE_DIR/.env и radicale/.env, НО НЕ /opt/vesti/.env → VESTI_BOT_TOKEN
пуст → все обработки падали. Строка ✗ [urgent] ...: Нет токена Telegram.
Фикс (email_handlers.py): /opt/vesti/.env грузится всегда (до ветвления).
Запрос пользователя (mid-turn): «перестань слать неактуальные срочные уведомления; встроить проверку на актуальность — сравнивать дату письма и текущую». Реализовано:
URGENT_MAX_AGE_DAYS = 3— письма старше 3 дней не шлются.is_urgent_recent(headers)— парсит дату из frontmatter, сравнивает с now.- Старое письмо:
✓ [urgent] ...: пропущено (актуальность истекла), помечаетсяhandled_urgent(идемпотентность, не перебирается).
Результат backlog: 5 сентябрьских доставлены в ЛС (msg_id 62–64 и далее),
5 старых (июль/авг) — пропущены фильтром корректно. Январьское «Сервер» — на
деле classification: info (не urgent, ошибка подсчёта). 14229 помечен вручную
(уже дважды уведомлён — предотвращён дубль).
Известный отдельный баг (не urgent): ✗ [task] ...: PUT 400: Bad Request
(Radicale VTODO-задача) — не разобран, открыт на следующую сессию.
Cron-заметка
mail-classify-handlers (в venv /opt/vesti/.venv/bin/python, --limit 2/прогон)
работает; после фикса токена обрабатывает по 2 письма за прогон идемпотентно.
2026-09-11
Фаза 1.7: динамическое обнаружение подпапок INBOX
Проблема: mail_archive.py хардкодил 18 подпапок INBOX, на сервере их 136+.
Решение в mail_archive.py:
get_inbox_subfolders()— парситhimalaya folder list(таблица ASCII), фильтруетINBOX/, поддерживает глубину 3, fallback на хардкод при ошибке.--all=FOLDERS + get_inbox_subfolders()→ 140 папок.run_cmd()— добавленtry/except FileNotFoundError(himalaya не в PATH).- Таймаут 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) — развёрнут частично
Сделано:
/opt/hermes/email-assistant/radicale/docker-compose.yml(образkozea/radicale, порт 5232).- Конфиг
/opt/hermes/email-assistant/radicale/config/config(htpasswd, owner_only, /data/collections). - Пользователь
estorozhenko—htpasswd -c -b -m data/users estorozhenko <pass>(пароль в/opt/hermes/email-assistant/radicale/.env,RADICALE_PASS). 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.<ts> → сгенерировали хэш $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/<urlencoded 'Рабочий'>/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 <dest>, но правильный флаг —--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, в каталог письма<msg_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 через SOCKS5socks5://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 (важно для повторения!):
- Пароль в
radicale/.envНЕ совпадал сradicale/data/users— пароль менялся в 13.09 17:55 (users.bak), но.envостался от 11.09. Доступ 401. Синхронизировал.envс актуальным паролем (бэкап.env.bak.<ts>). Правило: после смены пароля Radicale обновлять и.envскриптов. 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.- PROPFIND без заголовка
Depth: 1возвращает только сам ресурс, без дочерних календарей → коллекции «не находились». Обязательноheaders["Depth"] = "1". - Формат дат iCalendar единый
YYYYMMDDTHHMMSS— не смешивать2026-09-14T11:00:00(с дефисами) и20260914T110000(Radicale отклоняет первое, 400 Bad Request). - Коллекции Radicale кэшируются через
@lru_cache(PROPFIND один раз за проход).
Проверено: dry-run → task VTODO 204, meeting VEVENT 201, тестовые объекты
удалены (DELETE 200).
Статус чейнджа на конец сессии
-
- Вложения: готово (проверено).
-
- Классификатор: скрипт готов, прогон прошёл (письмо 422 → task,meeting).
-
- Обработчики: скрипт готов, dry-run чист (VTODO/VEVENT создаются). Осталось: живой прогон на реальном письме (без --dry-run), проверка urgent→Telegram.
-
- Секреты: Radicale-пароль в .env синхронизирован. Осталось: TELEGRAM_CHAT_ID в .env (токен есть в /opt/vesti/.env, VESTI_BOT_TOKEN).
-
- Cron: не настроен (после пунктов 3-4).
-
- Документация: этот 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). Нужно, чтобы архивация/скачивание вложений
НЕ трогали флаг.
Диагностика:
himalaya message read --help— по умолчанию ставит Seen; есть--preview(читает без Seen).himalaya attachment download --help— НЕТ флага против Seen.himalaya message export --full— ищет по envelope id (sequence number), а не по IMAP UID (UID ≠ envelope id) → для бэкфилла по UID непригоден.- Корень (100% подтверждён): himalaya
message readиattachment downloadшлют IMAPBODY[](неBODY.PEEK[]). По RFC 3501BODY[]автоматически выставляет\Seen. Сервер — Microsoft Exchange (mail.corpoffice.tech:143, STARTTLS), который это поведение соблюдает железно. - Живой тест: непрочитанное письмо 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).
Подводные камни, которые вскрылись при реализации:
- imaplib не подходит:
uid('fetch', ...)возвращал 0 байт на этом Exchange-сервере (странный парсинг литеральных ответов). Решение — сырой socket + свой парсер. - Модифицированный UTF-7: кириллические имена папок (
INBOX/Организация работы) через raw socket надо отправлять в IMAP modified UTF-7, иначеSELECTне находит папку. Написал_imap_utf7_encode()(ASCII как есть,&→&-, не-ASCII сегменты → base64-UTF16BE). - Литералы >64 КБ: сервер отвечает
BODY[] {1534256}— нужно читать чанками по 64 КБ, пока не наберёшь полный литерал (N байт после{N}\r\n). Первая версия падала «literal truncated: got 49152, expected 1534256». - MIME-encoded words в имени файла:
part.get_filename()возвращал=?koi8-r?B?...?=— обязательноemail.header.decode_header(). - 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-файлов чейнджа.
2026-09-15 — IMAP realtime sync (планирование) + Telegram-уведомления в ЛС
1. Диагностика авторизации: rate-limit, а не TLS-fingerprinting
Симптом: python/openssl/imaplib не логинятся на mail.corpoffice.tech:143
(NO AUTHENTICATE failed), himalaya (rustls) логинится.
Что перепробовано:
- Raw-socket LOGIN для e.storozhenko / e.storozhenko@vinogorod.ru / VINOGOROD\e.storozhenko — NO.
ssl.wrap_socket—AttributeError(убрано в py3.12) →SSLContext.wrap_socket.- himalaya — логинится (эталон).
- Python с тем же base64 AUTHENTICATE PLAIN, что himalaya — NO.
- Сравнение TLS-отпечатков openssl vs rustls (ClientHello, ciphers) — различаются → гипотеза JA3 fingerprinting.
- Порт 993 (IMAPS) через imaplib — тоже NO.
- Опровержение: чтение прод-кода
mail_archive.py(~528) — он логинится простымLOGIN login password(не AUTH PLAIN). После паузы 45с — успех. Вывод: rate-limit Exchange (сервер молчит timeout после ~10 быстрых подключений), НЕ fingerprinting, НЕ бан IP.
Урок: не спешить с «экзотическими» диагнозами (JA3); сначала прочитать существующий прод-код — там уже рабочее решение.
2. scripts/imap_client.py — отдельная функция авторизации + наблюдаемость
imap_connect(): socket → STARTTLS → LOGIN (простая форма, как в проде), re-try с экспоненциальной паузой 5→60с, креды из config/himalaya-config.toml.- JSON-лог в
/opt/hermes/email/logs/imap_client.log: conn_ok/conn_error/ auth_ok/auth_failed/session_started/session_ended (пароль никогда). imap_metrics(): auth_success_rate, conn_ok/err, sessions_active.imap_session(contextmanager) — гарантирует session_ended.- CLI:
python3 imap_client.py(self-test),--metrics. mail_archive.py: блок соединения заменён наimap_connect();sys.path.insert(0, str(Path(__file__).parent))перед импортом imap_client (важно для cron: иначе импорт падает из-за cwd).- Проверено:
HOME=/home/estorozhenko python3 imap_client.py→OK: connected+LOGIN as e.storozhenko @ mail.corpoffice.tech. ВАЖНО: запускать сHOME=/home/estorozhenko(конфиг himalaya там).
3. Telegram-уведомления о важных письмах — в ЛС (kpa39l)
- Создан
/opt/hermes/email-assistant/.env:TELEGRAM_CHAT_ID=281328953(private chat kpa39l, бот @dedinit_controller_bot id 7765665742). Файл в .gitignore (строка 9) — не попадёт в git. - Порядок загрузки .env в email_handlers.py: проект
.env→ radicale/.env → /opt/vesti/.env;override=False→ проект не перезаписывается. - Живой тест: письмо 3216 («RE: Платежи Аврора») помечено
classification: urgent→ отправлено в ЛС (msg_id=60), пользователь подтвердил получение.handled_urgent: trueзаписан.
4. БАГ: крон не отправлял уведомления (python3 без httpx)
Симптом: 10 urgent-писем накопились без уведомлений.
Причина: scripts/mail-classify-handlers.sh вызывал python3 (системный),
у которого нет httpx → tg_call бросал RuntimeError, скрипт глушил ошибку
|| echo "[handlers] ошибка".
Фикс: PY=/opt/vesti/.venv/bin/python (fallback python3) + --limit 2
(дозированно, анти-спам backlog). Проверено: bash -n OK.
5. Формат даты в уведомлениях
format_date_for_tg():2026-09-02 10:49+03:00→02.09.2026 10:49(ДД.ММ.ГГГГ ЧЧ:ММ; RFC3339 и Z тоже; незнакомое — as-is)._unquote_yaml()в parse_email_md: снятие YAML-кавычек ("..."→ значение).- Строка
📅 <дата>в handle_urgent под заголовком.
6. Репозиторий: gitverse = источник истины + gitea mirror
Решение пользователя (2026-09-15): gitverse.ru — источник истины для всех репозиториев; gitea.nixg.ru — зеркало.
Токен: единый источник — /opt/hermes/.hermes/secrets/git-tokens.env
(chmod 600): GITVERSE_LOGIN=kpa39l (НЕ estorozhenko!), GITVERSE_PAT,
GITVERSE_API=https://api.gitverse.ru, GITEA_API=http://127.0.0.1:3000/api/v1,
GITEA_TOKEN. Загрузка: set -a && source ... && set +a.
Создание репо на gitverse (private, auto_init:false!):
curl -X POST "$GITVERSE_API/user/repos" \
-H "Authorization: Bearer $GITVERSE_PAT" \
-H "Accept: application/vnd.gitverse.object+json;version=1" \
-H "Content-Type: application/json" \
-d '{"name":"email-assistant","private":true,"auto_init":false}' # → 201
Push в gitverse: remote gitverse = https://kpa39l:$GITVERSE_PAT@gitverse.ru/kpa39l/email-assistant.git; git push gitverse master.
Pull mirror в gitea (12h): migrate API:
curl -X POST "$GITEA_API/repos/migrate" -H "Authorization: token $GITEA_TOKEN" \
-d '{"clone_addr":"https://oauth2:$GITVERSE_PAT@gitverse.ru/$GITVERSE_LOGIN/email-assistant.git",
"repo_name":"email-assistant","repo_owner":"estorozhenko","service":"git",
"mirror":true,"mirror_interval":"12h"}' # → 201
Проверка: git ls-remote https://oauth2:$PAT@gitverse.ru/kpa39l/email-assistant.git HEAD ==
docker exec gitea git --git-dir=/data/git/repositories/estorozhenko/email-assistant.git rev-parse HEAD;
mirror-sync POST $GITEA_API/repos/estorozhenko/email-assistant/mirror-sync → 200.
Питфолы:
- База gitverse API —
api.gitverse.ru, НЕgitverse.ru/api/v1(там HTML SPA). - Gitea migrate только с
https://oauth2:<PAT>@...(ssh:// → 422). auto_init:falseобязательно (иначе первый push упадёт).GITVERSE_APIуже содержитhttps://— не добавлять его повторно.- После пуша токен в remote URL — оставить (git config, не в коммитах); НЕ вычищать, иначе следующий push потребует креды.
Открытые хвосты (2026-09-15)
- imap_stream.py (Фаза 1): скелет IDLE-цикла; aioimaplib не установлен — выбор: ставить его или свой клиент поверх imap_connect() (raw socket).
- Backlog 10 urgent-писем (июль-сентябрь): отправляются по 2/прогон крона в ЛС; часть просрочена — решить, пропускать ли (пометить handled).
- Live-тесты Exchange: пауза ≥45с между подключениями (rate-limit).