143 lines
9.8 KiB
Markdown
143 lines
9.8 KiB
Markdown
# 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-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 <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 + удалить коллекцию Контакты на ФС.
|
||
Локальная база/файлы не затрагиваются. |