# 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 В конец существующего пайплайна (после 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 карточки: `/estorozhenko/Контакты/.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 # или 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 + удалить коллекцию Контакты на ФС. Локальная база/файлы не затрагиваются.