openspec: change contacts-caldav-server (CardDAV sync для контактов из почты)

This commit is contained in:
2026-09-13 15:59:49 +00:00
parent 2a597f7325
commit de07fae246
5 changed files with 368 additions and 0 deletions
@@ -0,0 +1,129 @@
# 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: <etag>**; при 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 + удалить коллекцию Контакты на ФС.
Локальная база/файлы не затрагиваются.