36 KiB
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-5mincron должен обновлять список папок динамически, а не из хардкода- Реализовано (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): himalayamessage read/attachment downloadиспользуют IMAPBODY[], что выставляет\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;--metricsCLI - 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; remotegitverseдобавлен; master запушен; gitea: pull mirrorestorozhenko/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, без аргументов — демон
- IDLE-цикл:
- 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?...?=). Фолбэк: himalayaattachment 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 отрезает всё от первого найденного маркера, теряя подпись.
Текущее решение (итерация):
- Ищем самый ранний маркер цитирования среди всех паттернов (не первый совпавший)
- 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
}
Ключевые моменты:
- Сохранение Authorization: Caddy по умолчанию может удалять заголовки.
header_up Authorization {header.Authorization}гарантирует, что Radicale получит логин/пароль. - X-Script-Name (если в подпапке): для размещения по
/radicaleнуженhandle_pathдля удаления префикса пути + заголовокX-Script-Name, чтобы Radicale знал о своём расположении. - Обязательный
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, remotegitverse, 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 (трекер задач) | /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, IMAPmail.corpoffice.tech:143(STARTTLS) - Почта:
e.storozhenko@vinogorod.ru - LLM: Qwen3:8b (Ollama localhost:11434)
- SMTP: не настроен
- Файлы state:
/opt/hermes/email/state/