8.0 KiB
Design: CardDAV-сервер для контактов (Radicale sync)
Context
- Radicale уже развёрнут в
/opt/hermes/email-assistant/radicale/(docker, порт 5232), работает CalDAV (календарь). Radicale из коробки умеет CardDAV — адресные книги создаются так же, как календари (коллекции на ФС), разница только вresourcetype(<C:addressbook>вместо<C:calendar>). - Пользователь:
estorozhenko, пароль — вradicale/.env(RADICALE_PASS), htpasswd-файл/data/usersв контейнере. - Коллекции Radicale лежат на ФС:
/opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/(подпапки Личный, Рабочий, Задачи — календари; увидим, что у Задач resourcetype VTODO). - Контакты извлекаются
scripts/contacts_extractor.py(Qwen3:8b через Ollama), пишутся в/opt/hermes/email/contacts/:contacts.json— база{"contacts": [...], "by_email": {...}}index.json— email → contact_idcontacts.vcf— vCard 4.0 (для импорта)last_scan.json— трекинг обработанных писем
- Сеть: bigbox (10.8.0.2), наружу — Caddy на vps02 (cal.nixg.ru → 5232).
Решение
1. Адресная книга в Radicale (создание на ФС)
Radicale 3.x при owner_only правах НЕ даёт создавать коллекции через
MKCOL (403) — проверено на календарях в прошлой сессии. Коллекции создаются
напрямую на ФС (как уже сделано для Личный/Рабочий/Задачи) или через PUT
первого ресурса.
Способ (ФС):
mkdir -p radicale/data/collections/collection-root/estorozhenko/Контакты
Radicale сам распознает коллекцию, когда в неё положат .vcf (Radicale
создаёт .Radicale.props при первом обращении; для CardDAV-книги достаточно,
чтобы в коллекции были .vcf-файлы). Для явного resourcetype можно положить
.Radicale.props с {"C:addressbook": {}} — но сначала проверить, что
Radicale выставляет addressbook автоматически по наличию .vcf.
Проверка RS-типа:
curl -u estorozhenko:PASS -X PROPFIND -H 'Depth: 0' \
http://127.0.0.1:5232/estorozhenko/Контакты/
→ должен содержать <C:addressbook>.
2. Синк в contacts_extractor.py
В конец существующего пайплайна (после save_progress) добавляется шаг sync_contacts_to_caldav(), который:
- Читает
contacts.json→ список контактов (email, full_name, phone, phone_secondary, position, company, address, contact_id). - Генерирует vCard 4.0 на каждый контакт (FN, EMAIL, TEL, ORG, TITLE, ADR) — использует уже существующую generate_vcard() логику (перенести/ отрефакторить в общий модуль или дублировать минимально).
- Определяет URL карточки:
<base>/estorozhenko/Контакты/<uid>.vcf, uid = стабильный хэш от email (contact_id из contacts.json). - Делает PROPFIND Depth:1 по адресной книге, строит карту
uid → (ETag, href)— чтобы знать, какие карточки уже есть (idempotency). - Для каждого контакта:
- если карточки ещё нет → PUT (без If-Match);
- если есть и ETag тот же → пропуск (ничего не менять);
- если есть и ETag отличается и локальные данные изменились → PUT с If-Match: ; при 412 → лог конфликта, пропуск.
--prune-caldav: сравнить uid на сервере с uid в базе, отсутствующие в базе → DELETE. По умолчанию — выключено.- Записывает лог синка:
contacts/caldav-sync.log(дата, контакт, действие created/updated/skipped/conflict/deleted).
Клиент: стандартный urllib.request + base64 Basic Auth (без новых
зависимостей) или curl через subprocess. Предпочтительно urllib — синк
вызывается из cron (contacts-cron.sh) и не должен зависеть от curl-параметров.
Флаги CLI:
contacts_extractor.py --sync-caldav # включить синк после обработки
contacts_extractor.py --sync-caldav --prune-caldav
contacts_extractor.py --caldav-url http://127.0.0.1:5232
contacts_extractor.py --caldav-user estorozhenko
contacts_extractor.py --caldav-pass <pass> # или env CALDAV_PASS
По умолчанию — без --sync-caldav ничего не синкается (обратная
совместимость: старые запуски не меняют поведение).
Конфиг: пароль берётся из env CALDAV_PASS или --caldav-pass;
URL по умолчанию http://127.0.0.1:5232 (можно переопределить).
3. Cron
Добавить --sync-caldav в существующий config/contacts-cron.sh (тот же
cron contacts-extractor-every-30m, no-agent скрипт). Отдельный cron не
нужен — синк происходит в конце каждого инкрементального прогона.
CALDAV_PASS — из radicale/.env (источник пароля один).
4. Переменные/секрет
Пароль Radicale уже лежит в radicale/.env. contacts-cron.sh будет читать
RADICALE_PASS оттуда и передавать в --caldav-pass (или env).
В git-коммит .env не идёт (.gitignore) — секреты в репозитории нет.
Open Questions
- Radicale-версия: 3.8.1.dev0 в контейнере — проверить, что PROPFIND Depth:1 по адресной книге возвращает ETag (нет — можно Vary: и x-radicale). (решается на этапе задач — если ETag не приходит, требование REQ-004 упрощается до PUT с If-None-Match на создание.)
- Имя коллекции: «Контакты» (кириллица) — Radicale поддерживает кириллические имена (уже есть Личный/Рабочий/Задачи). Клиенты DAVx5 нормально работают с кириллическими путями.
Testing
- Создание книги: PROPFIND → 207 + addressbook RS.
- PUT vCard → 201; повторный PUT/Bad Request при невалидной vCard → 400.
- Повторный синк → 204/пропуск, дублей нет.
- Изменение контакта → PUT 204 + обновлённая vCard.
- Конфликт: на сервере вручную поменять vCard → синк даёт 412 + лог.
--prune-caldavудаляет отсутствующие карточки.- После синка: DAVx5 на телефоне видит контакты (ручная проверка).
Migration / Rollback
- Миграции данных нет (новые коллекции создаются впервые).
- Откат: убрать
--sync-caldavиз cron + удалить коллекцию Контакты на ФС. Локальная база/файлы не затрагиваются.