9.8 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
После извлечения/дедупликации запускается sync_carddav(), который
выполняет двустороннюю сверку между contacts.json и адресной книгой
Radicale.
Состояние:
- У каждого контакта в
contacts.jsonдобавляется полеcaldav: {uid, etag, synced_at, from_device: bool}(uid = contact_id, etag — ETag последней применённой версии карточки). - Локальная база остаётся источником истины для дедупликации по
email.
Алгоритм (запуск 1):
PROPFIND Depth:1по/estorozhenko/Контакты/→ картаhref → (ETag, content-type, vCard)` (vCard тянем GET'ом по href для сравнения содержимого, если нужно).- Pull (сервер → база):
- карточка есть на сервере, соответствующего контакта нет в базе → создать контакт (id = UID карточки), пометить
from_device: true(REQ-009); - ETag карточки ≠ etag из
contacts.json[caldav.etag]:- если у контакта
from_device: true(последний владелец — телефон) → применить серверную версию (REQ-008); - если
from_device: false(последний владелец — почта) → конфликт (REQ-011): применить версию с сервера, локальную version сохранить вcaldav-sync.log(conflict_local), сброситьetagна актуальный;
- если у контакта
- карточки на сервере нет, в базе есть
caldav.uid→ контактdeleted: true(REQ-010).
- карточка есть на сервере, соответствующего контакта нет в базе → создать контакт (id = UID карточки), пометить
- Push (база → сервер):
- у контакта есть
caldav.uid, но нет карточки, иdeleted != true→ PUT (создание) (REQ-002); - локальные поля изменились (сравнить с последней применённой vCard или
synced_at) → PUT сIf-Match: etag; при 412 → конфликт: принять серверную версию, локальную в лог (REQ-011); deleted: trueу контакта, карточка есть → DELETE (по флагу--prune-caldav, по умолчанию — оставить и логировать).
- у контакта есть
--prune-caldav(не по умолчанию): удалять с сервера карточки, у которых нет контакта в базе (REQ-005).
Ключевое правило конфликтов: приоритет — сервер (телефон) как актуальная
версия; локальная версия никогда не теряется (лог conflict_local). Это
сознательное решение: правки руками на телефоне считаются более «живыми»,
чем автопарсинг подписей писем.
Клиент: стандартный 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.
- Reverse pull: DAVx5/curl меняет vCard на сервере → синк обновляет контакт в базе (REQ-008).
- Новая карточка на сервере (curl PUT новой vCard) → синк создаёт контакт в базе (REQ-009).
- Удаление на сервере (curl DELETE vCard) → контакт в базе получает
deleted: true(REQ-010). - Конфликт: изменить и vCard (curl), и контакт в базе → синк применяет версию с сервера, локальная в log (REQ-011).
--prune-caldavудаляет отсутствующие карточки.- После синка: DAVx5 на телефоне видит контакты (ручная проверка).
Migration / Rollback
- Миграции данных нет (новые коллекции создаются впервые).
- Откат: убрать
--sync-caldavиз cron + удалить коллекцию Контакты на ФС. Локальная база/файлы не затрагиваются.