Files
email-assistant/openspec/changes/contacts-caldav-server/design.md
T

8.0 KiB
Raw 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

В конец существующего пайплайна (после save_progress) добавляется шаг sync_contacts_to_caldav(), который:

  1. Читает contacts.json → список контактов (email, full_name, phone, phone_secondary, position, company, address, contact_id).
  2. Генерирует vCard 4.0 на каждый контакт (FN, EMAIL, TEL, ORG, TITLE, ADR) — использует уже существующую generate_vcard() логику (перенести/ отрефакторить в общий модуль или дублировать минимально).
  3. Определяет URL карточки: <base>/estorozhenko/Контакты/<uid>.vcf, uid = стабильный хэш от email (contact_id из contacts.json).
  4. Делает PROPFIND Depth:1 по адресной книге, строит карту uid → (ETag, href) — чтобы знать, какие карточки уже есть (idempotency).
  5. Для каждого контакта:
    • если карточки ещё нет → PUT (без If-Match);
    • если есть и ETag тот же → пропуск (ничего не менять);
    • если есть и ETag отличается и локальные данные изменились → PUT с If-Match: ; при 412 → лог конфликта, пропуск.
  6. --prune-caldav: сравнить uid на сервере с uid в базе, отсутствующие в базе → DELETE. По умолчанию — выключено.
  7. Записывает лог синка: 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 + удалить коллекцию Контакты на ФС. Локальная база/файлы не затрагиваются.