Files

143 lines
9.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 + удалить коллекцию Контакты на ФС.
Локальная база/файлы не затрагиваются.