Files

9.8 KiB
Raw Permalink Blame History

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_id
    • contacts.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):

  1. PROPFIND Depth:1 по /estorozhenko/Контакты/ → карта href → (ETag, content-type, vCard)` (vCard тянем GET'ом по href для сравнения содержимого, если нужно).
  2. 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).
  3. Push (база → сервер):
    • у контакта есть caldav.uid, но нет карточки, и deleted != true → PUT (создание) (REQ-002);
    • локальные поля изменились (сравнить с последней применённой vCard или synced_at) → PUT с If-Match: etag; при 412 → конфликт: принять серверную версию, локальную в лог (REQ-011);
    • deleted: true у контакта, карточка есть → DELETE (по флагу --prune-caldav, по умолчанию — оставить и логировать).
  4. --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 + удалить коллекцию Контакты на ФС. Локальная база/файлы не затрагиваются.