Files
email-assistant/WALKTHROUGH.md
T

423 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <pass>`
(пароль в `/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.<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 через 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.<ts>`). **Правило: после смены пароля
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 <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-файлов чейнджа.
## 2026-09-15 — IMAP realtime sync (планирование) + Telegram-уведомления в ЛС
### 1. Диагностика авторизации: rate-limit, а не TLS-fingerprinting
**Симптом:** python/openssl/imaplib не логинятся на mail.corpoffice.tech:143
(`NO AUTHENTICATE failed`), himalaya (rustls) логинится.
**Что перепробовано:**
1. Raw-socket LOGIN для e.storozhenko / e.storozhenko@vinogorod.ru /
VINOGOROD\e.storozhenko — NO.
2. `ssl.wrap_socket` — `AttributeError` (убрано в py3.12) → `SSLContext.wrap_socket`.
3. himalaya — логинится (эталон).
4. Python с тем же base64 AUTHENTICATE PLAIN, что himalaya — NO.
5. Сравнение TLS-отпечатков openssl vs rustls (ClientHello, ciphers) — различаются
→ гипотеза **JA3 fingerprinting**.
6. Порт 993 (IMAPS) через imaplib — тоже NO.
7. **Опровержение:** чтение прод-кода `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!):**
```bash
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:
```bash
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).