Files

36 KiB
Raw Permalink Blame History

Email Assistant — локальный архив и ассистент почты

Дата: 2026-09-15 Фаза: 1.5–1.7 + Портфель веб-UI (планирование) + Классификация/обработчики (в работе) + IMAP Realtime Sync (планирование)

Стек: Himalaya CLI → Python → SQLite → Ollama (Qwen3:8b) → Radicale (CalDAV) → Telegram


Архитектура проекта

/opt/hermes/email-assistant/
├── STATUS.md                              # этот файл
├── config/
│   ├── himalaya-config.toml              # Himalaya IMAP-конфиг
│   └── contacts-cron.sh                  # обёртка для cron контактов
├── scripts/
│   ├── mail_archive.py                   # инкрементальный архиватор писем с IMAP
│   ├── mail-archive.sh                   # shell-обёртка для systemd/cron
│   ├── migrate_to_email_md.py            # конвертер meta.json→email.md (deprecated)
│   ├── mail_index.py                     # SQLite FTS5-индекс всех писем
│   ├── contacts_extractor.py             # извлечение контактов через LLM
│   ├── sqlite_search.py                  # FTS5-поиск по архиву
│   ├── digest.py                         # еженедельный дайджест почты
│   └── mail_archive.py                   # основной архиватор
│
├── context/
│   ├── CONTEXT.md                        # архитектура и план развития
│   ├── SKILL.md                          # навык для Hermes (email-local-archive)
│   └── CONTACTS.md                       # описание contacts extractor

/opt/hermes/email/                          # архив писем (локальный диск)
├── INBOX/
│   └── YYYY/MM/UID/email.md              # YAML-frontmatter + тело
├── Sent/
├── Отправленные/
├── Archive/
├── state/                                 # mail-archive-last-*.json (last_uid per folder)
├── contacts/                              # адресная книга
│   ├── contacts.json                      # полная база контактов
│   ├── index.json                         # email → contact_id
│   ├── contacts.vcf                       # vCard 4.0 для импорта
│   └── last_scan.json                     # трекинг обработанных
├── digests/                               # еженедельные дайджесты
│   └── digest-YYYY-MM-DD.md
└── mail_index.db                          # SQLite + FTS5 (~5.4 MB)

Hermes cron:
  - mail-archive-every-5min   (no-agent, скрипт)
  - contacts-extractor-every-30m (скрипт, --limit 15)
  - digest: пока не поставлен
  - mail_index --incremental: пока не поставлен

Статус задач

Фаза 0.5: Рефакторинг формата хранения ✅

  • Перейти с meta.json + body.md на один email.md с YAML-frontmatter
  • Полные заголовки в frontmatter (Message-ID, References, In-Reply-To, CC, Content-Type)
  • State файлы в /opt/hermes/email/state/

Фаза 1: Локальный архив ✅

  • Структура /opt/hermes/email-assistant/
  • Himalaya (IMAP mail.corpoffice.tech:143 STARTTLS)
  • mail_archive.py — инкрементальный архиватор
  • Первый запуск: INBOX 585, Sent 515, Отправленные 510, Archive 373
  • systemd user timer + Hermes cron (every 5m)

Фаза 1.5: Индексация, поиск и дайджесты ✅⬜

  • mail_index.py — SQLite-индекс всех email.md (FTS5 + трекинг контактов)
  • sqlite_search.py — CLI-поиск по FTS5 (поддержка фильтров from:/subject:/folder)
  • digest.py — еженедельный дайджест через LLM (Qwen3:8b)
  • Поставить cron на mail_index.py --incremental (раз в 5-10 мин)
  • Поставить cron на digest.py (раз в неделю)

Фаза 1.7: Динамическое обнаружение всех подпапок INBOX ✅

  • mail_archive.py — список вложенных папок INBOX захардкожен (18 шт.), но на сервере их 137 (включая многоуровневые: INBOX/!Персонал/ОТ и ТБ, INBOX/Бюджет/Винный город/CAPEX 2025, INBOX/Контрагенты/iiko/Тихая гавань и т.д.)
  • --all сейчас использует тот же хардкод — не архивирует ~120 подпапок
  • Требуется: динамическое обнаружение IMAP-папок через himalaya folder list, рекурсивный обход всех подпапок INBOX (любой глубины), автоматическая архивация новых подпапок при их создании
  • mail-archive-every-5min cron должен обновлять список папок динамически, а не из хардкода
  • Реализовано (2026-09-11): get_inbox_subfolders() через himalaya folder list — динамически находит 136 подпапок INBOX (глубина до 3), fallback на хардкод при ошибке. --all использует FOLDERS + get_inbox_subfolders(). Проверено: 140 папок в списке, smoke-тест на реальном запуске. Таймаут envelope list поднят до 180с (INBOX 14k писем >60с).

Фаза 1.6: Адресная книга (Contacts Extractor) ✅⬜

  • contacts_extractor.py — извлечение контактов из подписей через LLM
  • clean_body — удаление цитируемой переписки (Outlook/forwards/>)
  • SQLite-трекинг обработанных писем (contacts_extracted / contacts_skipped)
  • Инкрементальное сохранение каждые 5 писем
  • --limit N для дозированной обработки
  • vCard 4.0 генерация
  • Cron already set: contacts-extractor-every-30m (--limit 15)
  • Проверить качество извлечения: сейчас 5 контактов найдено, 5 skipped
  • Доделать парсинг темы письма (некоторые темы содержат вшитые заголовки)

Фаза 2: Векторизация и поиск ⬜

  • Выбор векторизатора (bge-m3 через Ollama — уже есть в Memory OS)
  • Индексация body в Qdrant
  • Поиск по письмам через агента

Фаза 3: Граф знаний ⬜

  • Извлечение связанных сущностей (отправители, темы, проекты)

Портфель: Веб-интерфейс + Календарь/Задачи (2026-09-11)

План: PLAN_WEBUI.md. Порядок: Задача 4 → 2 → 3 → 1 → 6 → 7.

Задача 4: Анализ ФС vs Maildir ✅ (закрыта 2026-09-11)

  • STORAGE_ANALYSIS.md — сравнение email.md/Maildir/MBOX/notmuch по 7 критериям
  • Рекомендация: остаться на email.md + добавить tags: [] в frontmatter + опц. экспорт в Maildir
  • Ссылка в README; change email-storage-analysis заархивирован (openspec-lab)
  • Факты: 4884 письма, 76 МБ, 81 контакт

Задача 2: Локальный календарь + трекер задач 🔵 (в работе)

  • Решение пользователя: Radicale (CalDAV) + Vikunja (трекер), всё в Docker-контейнерах
  • РЕШЕНИЕ 2026-09-13: Vikunja — ЛИШНЯЯ СУЩНОСТЬ, задачи через Radicale VTODO (change remove-vikunja-use-radicale-tasks). Radicale из коробки умеет VTODO (календарь «Задачи»), jtx board читает их по CalDAV. Vikunja выводится из эксплуатации.
  • Change local-calendar-tasks создан и валиден — proposal/specs/design/tasks (Radicale-часть актуальна, Vikunja-часть — SUPERSEDED)
  • Radicale развёрнут и РАБОТАЕТ (2026-09-13): контейнер на :5232, PROPFIND 207 с паролем / 401 без. Коллекции Личный/Рабочий/Задачи на ФС.
  • CardDAV-синк контактов (2026-09-13, change contacts-caldav-server): scripts/contacts_caldav_sync.py — двусторонний sync. PUSH: 81 контакт → vCard в Radicale. PULL: правки/создание/удаление карточек с телефона → contacts.json. Идемпотентно (162 unchanged, 0 PUT на повторе). Конфликты (412) — приоритет телефону, локальная версия в caldav-sync.log. Подробнее: scripts/contacts_caldav_sync.py --help, лог /opt/hermes/email/contacts/caldav-sync.log.
  • Vikunja ВЫВЕДЕНА ИЗ ЭКСПЛУАТАЦИИ (2026-09-13): контейнеры vikunja + vikunja-db удалены (docker compose down -v), каталог /opt/hermes/email-assistant/vikunja/ удалён, порт 3456 свободен. Tasks.nixg.ru закомментирован в Caddy (строки 114-120), Caddy перезагружен (бэкап Caddyfile.bak-vikunja-removed). |
  • Caddy reverse proxy (cal.nixg.ru → 5232) — РАБОТАЕТ (2026-09-13): PROPFIND 207 снаружи. tasks.nixg.ru закомментирован.
  • «Обход в Глории» — повторяющееся событие (Рабочий, VTIMEZONE Europe/Moscow, RRULE WEEKLY BYDAY=TU 11:00), подтверждено на телефоне (GMT+3 ✓)

Задача 3: Нативная синхронизация с Android 🔵 (в работе)

  • Контакты синхронизированы (DAVx5 → Radicale «Контакты»; CardDAV-sync двусторонний, change contacts-caldav-server)
  • Caddy reverse proxy (cal.nixg.ru → 5232) — работает, PROPFIND 207 снаружи
  • «Обход в Глории» — VEVENT подтверждён на телефоне (GMT+3 ✓)
  • Проверить появление событий/задач в приложении (тестовый VEVENT obhod-v-glorii-2026.ics в «Рабочий», тестовый VTODO test-vikunja-removal-2026 в «Задачи») — контакты синхронизируются, события/задачи на телефоне пока не проверены
  • Двусторонняя синхронизация: событие/задача с телефона → bigbox → база

Задача 8: Классификация писем и обработчики 🔵 (в работе, change email-classification-handlers)

  • Вложения: фикс бага himalaya --dir → --downloads-dir; вложения в <msg_dir>/attachments/; идемпотентно (2026-09-13 вечер, проверено на живом письме)
  • СРОЧНЫЙ ПАТЧ (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 — без вложений, флаги не меняются)
  • Бэкфилл вложений (--attachments-backfill): докачка вложений для писем, заархивированных до фикса (пустой attachments/ при has_attachment: true); корректно разбирает вложенные папки (INBOX/!Битрикс); письма, уже недоступные на IMAP, помечаются «пусто» без ошибки (2026-09-14, проверено живьём)
  • Классификатор: scripts/email_classifier.py — Qwen3:8b (Ollama localhost:11434) → теги info/urgent/task/meeting + classification/classification_reason в frontmatter; идемпотентно; прогон прошёл (письмо 422 → task,meeting)
  • Обработчики: scripts/email_handlers.py — urgent→Telegram (Bot API+SOCKS5), task→Radicale VTODO («Задачи»), meeting→Radicale VEVENT («Рабочий»), info→ничего; идемпотентно через handled_*
  • Фикс секретов (2026-09-14): скрипт теперь сам читает radicale/.env (RADICALE_PASS) и /opt/vesti/.env (VESTI_BOT_TOKEN) — раньше без ручного export был 401; добавлен stdlib-парсер .env (python-dotenv в системе нет)
  • Живой прогон (2026-09-14): письмо 2026/422 → VTODO «Задачи» (204) + VEVENT «Рабочий» (204); повтор — идемпотентно (0 дублей)
  • Cron (2026-09-14): mail-classify-handlers (6e1e78ceedfd, every 5m) — классификатор (--limit 10) → обработчики; end-to-end проверено: 5 новых «meeting» → 5 VEVENT (201)
  • Живое urgent-письмо → доставка в Telegram (2026-09-15): письмо с id 3216 («RE: Платежи Аврора», Archive/2026/08/3216) помечено classification: urgent → email_handlers.py с TELEGRAM_CHAT_ID=281328953 отправил в ЛС (private чат kpa39l) через бота @dedinit_controller_bot — msg_id=60, пользователь подтвердил получение; handled_urgent: true записан (идемпотентно, повторной отправки нет)
  • Telegram-секреты: токен VESTI_BOT_TOKEN читается из /opt/vesti/.env; канал-дефолт @dedinit_vesti — внимание: для ЛС нужно явно TELEGRAM_CHAT_ID=281328953 (private chat kpa39l); зафиксировать дефолт в ЛС — TODO при настройке stream-сервиса

Сделано в сессии 2026-09-15 (IMAP realtime sync — планирование + Telegram-уведомления в ЛС)

  • Выделена авторизация IMAP в отдельную функцию — scripts/imap_client.py (новый): imap_connect() (socket+STARTTLS+LOGIN, re-try 5→60с, креды из config/himalaya-config.toml); mail_archive.py переведён на неё (sys.path-фикс для cron); проверено живьём: OK: connected+LOGIN as e.storozhenko @ mail.corpoffice.tech
  • Логирование авторизации + метрики (в imap_client.py): 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; --metrics CLI
  • Telegram-уведомления о важных письмах — в ЛС (kpa39l): создан /opt/hermes/email-assistant/.env с TELEGRAM_CHAT_ID=281328953; живой тест: письмо 3216 («RE: Платежи Аврора») помечено urgent → доставка в private chat 281328953 (bot @dedinit_controller_bot, msg_id=60), пользователь подтвердил
  • Найден и исправлен баг доставки: крон-скрипт mail-classify-handlers.sh использовал системный python3 без httpx → все Telegram-уведомления молча падали (накопилось 10 urgent-писем). Фикс: PY=/opt/vesti/.venv/bin/python + --limit 2 (дозированная отправка backlog, анти-спам)
  • Формат даты в уведомлениях: добавлены format_date_for_tg() (ISO → 02.09.2026 10:49) и _unquote_yaml() (снятие YAML-кавычек в parse_email_md); строка 📅 ДД.ММ.ГГГГ ЧЧ:ММ в handle_urgent
  • IMAP realtime sync (проект): proposal.md + design.md + tasks.md + spec-дельты (imap-realtime-sync, email-attachments, email-classification) — openspec change 'imap-realtime-sync' is valid; диагноз rate-limit (не TLS-fingerprinting); 2 микросервиса (imap_stream.py IDLE + change_analyzer.py ChangeLog SQLite), systemd, soft-delete
  • Backlog urgent-писем: 10 писем в INBOX с classification: urgent без handled_urgent (июль 2026 — сентябрь 2026, включая срочное «переоформление договоров… отключение интернета до 20-го») — отправляются по 2 за крон-прогон в ЛС
  • Репозиторий переведён на gitverse (источник истины): создан приватный kpa39l/email-assistant на gitverse.ru (id 336792) через API; remote gitverse добавлен; master запушен; gitea: pull mirror estorozhenko/email-assistant ← gitverse (12h, id 54) — gitea.nixg.ru остаётся зеркалом, не источником
  • AGENT.md создан — политика репозитория: gitverse = истина, gitea = зеркало, команды пуша, местоположение токенов (git-tokens.env, VESTI_BOT_TOKEN, TELEGRAM_CHAT_ID)

Сделано в сессии 2026-09-15 (вечер) — imap_stream.py реализован + backlog urgent закрыт

imap_stream.py — Фаза 1 (imap-realtime-sync, задачи 3–5) ✅

  • scripts/imap_stream.py (новый, ~690 строк) — постоянный IMAP IDLE-поток на сыром socket поверх imap_client.imap_connect (aioimaplib не ставится):
    • IDLE-цикл: idle_start → idle_wait(25с) → idle_done → перевыпуск (Exchange рвёт IDLE ~60с, поэтому IDLE_TIMEOUT=25с)
    • Reconnect с паузой CONNECT_PAUSE=45с (rate-limit Exchange), цикл переживает обрыв (проверено live: 150с демон → обрыв → reconnect → SELECT)
    • reconcile_new — новые UID (архивация через mail_archive) + reconcile_full каждые 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
    • CLI: --check, --test-idle, --status, --metrics, без аргументов — демон
  • Live-тесты Exchange (пауза ≥45с между коннектами) — все прошли: --check (LOGIN 365 писем), --test-idle (SELECT → reconcile → IDLE 30с → 0 событий → DONE, EXIT=0), демон 150с (обрыв+reconnect)
  • Исправлено live-тестами (питфолы, детали в WALKTHROUGH): HERMES_REAL_HOME в imap_client (_load_credentials), _cmd_ok (хвост \r\n), колонка references→refs (SQLite), UID-батчинг 1:1000 пуст → UID SEARCH ALL/UID <min>:* (иначе все письма ложно deleted), Exchange не шлёт + idling → + IDLE accepted
  • Запушено в gitverse: 5a81c86 (imap_stream + фикс токена TG + фильтр актуальности + HERMES_REAL_HOME)

Backlog urgent (10 писем) — закрыт ✅

  • Корень найден: email_handlers.py при наличии python-dotenv шёл в ветку if load_dotenv и НЕ грузил /opt/vesti/.env → VESTI_BOT_TOKEN пуст → все уведомления падали. Фикс: vesti/.env грузится всегда
  • 5 сентябрьских писем доставлены в ЛС (msg_id 62–64 и далее), помечены handled_urgent
  • Фильтр актуальности (запрос пользователя): URGENT_MAX_AGE_DAYS=3 + is_urgent_recent() — письма старше 3 дней НЕ шлются в Telegram (помечаются handled_urgent); 5 старых (июль/авг) пропущены корректно
  • Идемпотентность сохранена: дубли предотвращены (14229 помечен вручную)

Открыто на следующую сессию

  • Запуск imap_stream.py как постоянного сервиса (systemd/cron) — код готов, не запущен навсегда
  • Баг Radicale: [task] PUT 400: Bad Request (VTODO-задача, не относится к urgent) — не разобран
  • Полный reconcile по всем подпапкам INBOX (сейчас Фаза 1 — только INBOX)

Задача 1: Веб-интерфейс ассистента ⬜

  • FastAPI + SQLite FTS5: список писем (дата/адресант/тэги/папка)
  • Перемещение в папку; тэги
  • Кнопка «Создать задачу» → Radicale VTODO (Задача 6, вместо Vikunja API)
  • Страница авторизации (Задача 7)

Решения и проблемы скриптов

mail_archive.py — Инкрементальный архиватор

Задача: Качать письма с IMAP, сохранять в email.md с YAML-frontmatter.

Решение:

  • Для каждой папки хранится last_uid в /opt/hermes/email/state/mail-archive-last-<folder>.json
  • --limit N — максимум писем за проход (по умолчанию 200)
  • --drain — скачивать ВСЮ почту до конца: повторять проходы по каждой папке, пока за проход не обработано 0 писем. Нужен, когда новых писем накопилось больше батча (--limit) — скрипт сам себя повторяет до полного осущения папки, а не оставляет хвост до следующего запуска. Предохранитель от бесконечного цикла (10 000 проходов).
  • himalaya envelope --page-size 500 для быстрой загрузки списка писем
  • Каждое письмо: himalaya get <uid> | email-to-md.py → email.md
  • Инкрементально: добавляет все uid > last_uid, обновляет last_uid
  • Проблема: Himalaya v1.2.0 не поддерживает danger_accept_invalid_certs — используем mail.corpoffice.tech (валидный сертификат)
  • Seen-фикс (2026-09-14): himalaya message read и attachment download ставят \Seen (BODY[]). Чтение тела: message read --preview (не ставит). Вложения: fetch_attachments_imaplib() — сырой IMAP (socket+ssl, stdlib), UID FETCH (BODY.PEEK[]), имя папки в modified UTF-7 (_imap_utf7_encode(), кириллица), чтение литерала чанками по 64 КБ (письма до 1.5 МБ), имя файла через email.header.decode_header (MIME-encoded word =?koi8-r?B?...?=). Фолбэк: himalaya attachment download + flag remove <uid> seen (страховка).

mail_index.py — SQLite-индекс

Задача: Быстрый полнотекстовый поиск по архиву, трекинг обработки контактов.

Решение:

  • SQLite с FTS5 (unicode61 tokenizer) — 4 таблицы: emails, email_fts, триггеры синхронизации
  • Индексирует: path, uid, folder, date, from, to, subject, body_preview (первые 500 символов)
  • Поля contacts_extracted / contacts_skipped для совместной работы с contacts_extractor
  • Режимы: полная переиндексация (--incremental игнорирует mtime), поиск (--search), статистика (--stats)
  • Инкрементальный режим: проверяет file_mtime — пропускает неизменённые файлы

contacts_extractor.py — Извлечение контактов

Задача: Найти в подписи письма имя, должность, телефон, компанию отправителя.

Решение:

  • Берёт необработанные письма из SQLite (WHERE contacts_extracted=0 AND contacts_skipped=0)
  • clean_body(): удаляет HTML-теги, трекинг-ссылки, цитируемую переписку (Outlook-заголовки От:, From:, Sent:; forwarded; > quotes)
  • Отдаёт очищенный текст Qwen3:8b (Ollama, temperature=0.1)
  • LLM возвращает JSON: full_name, email, phone, position, company, address, raw_signature
  • Дедупликация по email: при повторной встрече обновляет поля
  • Инкрементальное сохранение: каждые 5 писем пишет contacts.json + contacts.vcf
  • Текущая проблема: clean_body может вырезать подпись вместе с цитатами (см. ниже)

sqlite_search.py — FTS5-поиск

Задача: Быстрый поиск по архиву писем из консоли.

Решение:

  • FTS5-запрос к mail_index.db через SQL MATCH
  • Поддержка синтаксиса: "точная фраза", OR, -исключение, префикс*
  • Пользовательские префиксы: from:user@mail, subject:отчёт → LIKE-фильтр в WHERE
  • --folder INBOX/!Отчеты — фильтр по папке
  • --body — включает тело письма в поиск (медленнее, но полнее)
  • Вывод: дата, папка, отправитель, тема, полный путь к файлу

digest.py — Еженедельный дайджест

Задача: Сгенерировать краткое резюме всех писем за N дней для руководителя.

Решение:

  • SQLite-запрос: письма за последние N дней (по дате из frontmatter)
  • Группировка по папкам (INBOX/!ВГ Чек листы → "ВГ Чек листы")
  • Вызов Qwen3:8b с промптом: "напиши краткий дайджест на русском для руководителя"
  • LLM выделяет: общую статистику, темы по папкам, важные отправители
  • Сохраняет в /opt/hermes/email/digests/digest-YYYY-MM-DD.md
  • Режимы: stdout, file, both

Проблема: clean_body вырезает подпись вместе с цитатой

Корень: В письмах с цепочкой ответов (Outlook forwarding) подпись отправителя часто находится после маркера От: Стороженко... Отправлено:..., но до конца цитаты. clean_body отрезает всё от первого найденного маркера, теряя подпись.

Текущее решение (итерация):

  1. Ищем самый ранний маркер цитирования среди всех паттернов (не первый совпавший)
  2. Fallback: если ни один маркер не сработал — ищем От: / From: / Subject: в последних 500 символах

Что ещё можно сделать:

  • Двухпроходная очистка: сначала отрезать цепочки forward-заголовков, потом отделять подпись от тела
  • Определять границу подписи по паттернам С уважением, / Best regards, / — — она ближе к концу
  • Использовать LLM не только для извлечения, но и для нахождения подписи

Текущие метрики

Папка Писем (2026-09-11)
INBOX (вкл. подпапки) 2674
Archive 876
Отправленные 790
Sent 544
Всего 4884 (76 МБ, mail_index.db 5.4 MB)

Контакты: 81 в contacts.json (не «5» — устарело; проверено 2026-09-11).


Cron-задачи (Hermes)

ID Имя Расписание Тип Статус
5f2305b2bbf8 mail-archive-every-5min every 5m no-agent (скрипт) ✅ (Фаза 1.7: использует --all --drain с динамическим списком)
ea0fd1ab4f93 contacts-extractor-every-30m every 30m скрипт (--limit 15) ✅
6e1e78ceedfd mail-classify-handlers every 5m скрипт (classifier --limit 10 → handlers) ✅ (2026-09-14)
— mail-index-incremental not set — ❌
— digest-weekly not set — ❌

DAVx⁵ (Android: CalDAV/CardDAV-мост)

Назначение: DAVx⁵ — приложение-синхронизатор для Android, не имеет собственного UI для просмотра событий/контактов, а встраивается в стандартные системные приложения Android (Календарь, Контакты). Это стандарт де-факто для синхронизации с Radicale на Android.

  • Где взять: F-Droid (бесплатно) или Google Play (платно, поддержка разработчиков).
  • Как работает: добавляете аккаунт (URL сервера Radicale, логин, пароль) → DAVx⁵ сам находит доступные календари и адресные книги → выбираете, что синхронизировать с системой.
  • Для задач пользователь поставил jtx board (не DAVx⁵).

Настройка Caddy для Radicale

Проксирование Radicale через Caddy требует внимания к путям и заголовкам, иначе CalDAV/CardDAV-клиенты не найдут ресурсы.

Рабочий пример для домена dav.example.com (Radicale в корне):

dav.example.com {
    # Важно: сохраняем заголовок Authorization для Radicale
    header_up Authorization {header.Authorization}

    # Если Radicale в подпапке — используйте handle_path (см. ниже)
    reverse_proxy localhost:5232
}

Ключевые моменты:

  1. Сохранение Authorization: Caddy по умолчанию может удалять заголовки. header_up Authorization {header.Authorization} гарантирует, что Radicale получит логин/пароль.
  2. X-Script-Name (если в подпапке): для размещения по /radicale нужен handle_path для удаления префикса пути + заголовок X-Script-Name, чтобы Radicale знал о своём расположении.
  3. Обязательный handle_path для подпапки: простой reverse_proxy внутри handle может не сработать — Radicale ожидает запросы без префикса (получает его через X-Script-Name).

Пример для подпапки /radicale:

dav.example.com {
    handle_path /radicale/* {
        header_up Authorization {header.Authorization}
        header_up X-Script-Name /radicale
        reverse_proxy localhost:5232
    }
}

В DAVx⁵ указывается базовый URL (например, https://dav.example.com или https://dav.example.com/radicale); пути к календарям/контактам приложение определяет автоматически.

Наш случай (следующая сессия): поддомен cal.nixg.ru (Caddy на vps02, reverse_proxy 10.8.0.2:5232 к bigbox) — Radicale слушает 127.0.0.1:5232, user estorozhenko, пароль RADICALE_PASS в radicale/.env. Адресная книга: Контакты (кириллица в URL — DAVx5 умеет). Задачи: Radicale VTODO (календарь «Задачи») → jtx board (Vikunja выведена, change remove-vikunja-use-radicale-tasks).


Конфигурация

  • Репозиторий (источник истины): https://gitverse.ru/kpa39l/email-assistant (private, remote gitverse, https с токеном из /opt/hermes/.hermes/secrets/git-tokens.env)
  • Gitea (зеркало): https://gitea.nixg.ru/estorozhenko/email-assistant (pull mirror ← gitverse, интервал 12h, id 54). Старый hermes/email-assistant — обычный репо, не зеркало.
  • AGENT.md — политика репозитория/токенов (см. файл)
  • Push mirror gitea → gitverse ✅ сделано 2026-09-15 (pull mirror наоборот: gitverse → gitea)

Ресурсы проекта (расположение и доступ)

Ресурс Где живёт Доступ
Caddy (reverse proxy) vps02 = 87.242.100.206, контейнер caddy SSH: ssh vps02 (alias в ~/.ssh/config, User estorozhenko, ключ cloudruVPS). Caddyfile: /opt/caddy/Caddyfile (root; правка через sudo). Reload: sudo docker exec caddy caddy reload --config /etc/caddy/Caddyfile
Radicale (CalDAV) bigbox, контейнер radicale, порт 5232 WGET: http://127.0.0.1:5232 (с bigbox), наружу: https://cal.nixg.ru. Конфиг: /opt/hermes/email-assistant/radicale/ (compose.yml, .env — пароль RADICALE_PASS). Коллекции на ФС: /opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/{Личный,Рабочий,Задачи}
Vikunja (трекер задач) bigbox, compose в /opt/hermes/email-assistant/vikunja/ ⛔ ВЫВЕДЕНА (change remove-vikunja-use-radicale-tasks). Задачи → Radicale VTODO (календарь «Задачи»).
WG (сеть хостов) 10.8.0.0/24 bigbox = 10.8.0.2 (цель reverse-proxy с vps02), vps01 = .1, vps03 = .3, vps02 = .4
SSH-ключи /home/estorozhenko/.ssh/ vps01_key (vps01), cloudruVPS (vps02), hostkeyVPS (vps03/root)

Домены (публичный DNS): cal.nixg.ru → 87.242.100.206 (vps02/Caddy → bigbox Radicale 5232). tasks.nixg.ru — НЕ используется (Vikunja выведена).

  • Himalaya: ~/.config/himalaya/config.toml
  • Аккаунт: vinogorod, IMAP mail.corpoffice.tech:143 (STARTTLS)
  • Почта: e.storozhenko@vinogorod.ru
  • LLM: Qwen3:8b (Ollama localhost:11434)
  • SMTP: не настроен
  • Файлы state: /opt/hermes/email/state/