# Design: CardDAV-сервер для контактов (Radicale sync) ## Context - Radicale уже развёрнут в `/opt/hermes/email-assistant/radicale/` (docker, порт 5232), работает CalDAV (календарь). Radicale из коробки умеет CardDAV — адресные книги создаются так же, как календари (коллекции на ФС), разница только в `resourcetype` (`` вместо ``). - Пользователь: `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/Контакты/ ``` → должен содержать ``. ### 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-ty`pe, 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 # или 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 + удалить коллекцию Контакты на ФС. Локальная база/файлы не затрагиваются.