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,2 @@
schema: spec-driven
created: 2026-09-13
@@ -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 + удалить коллекцию Контакты на ФС.
Локальная база/файлы не затрагиваются.
@@ -0,0 +1,65 @@
# Proposal: CardDAV-сервер для синхронизации контактов
## Why
Сейчас контакты, извлечённые LLM из подписей писем, лежат только в файлах
`/opt/hermes/email/contacts/{contacts.json, contacts.vcf, index.json}` и никуда
не синхронизируются. Чтобы пользоваться ими на телефоне (Android) и в других
клиентах, нужен CardDAV-сервер с адресными книгами, куда контакты пишутся
сразу при извлечении.
Почему CardDAV, а не просто наличие .vcf: нативный Android (DAVx5) и
большинство клиентов умеют только CardDAV-протокол. Отдельный файл .vcf на
диске никто не читает.
## What Changes
1. **Radicale расширяется на CardDAV** — это тот же сервис Radicale (:5232),
который уже развёрнут для CalDAV (календарь). Radicale из коробки умеет
CardDAV (addressbook collections). Нужно только:
- создать адресную книгу (например `Контакты`) в коллекциях Radicale
- проверить CardDAV-endpoint (`/estorozhenko/Контакты/`)
2. **Контакты пишутся сразу в карточки** — contacts_extractor.py после
извлечения и дедупликации пишет/обновляет vCard в Radicale через
CardDAV PUT, а не только в локальные файлы:
- на каждый контакт — один `.vcf` в адресной книге
- при обновлении контакта — PUT с новым ETag
- удаление контакта, которого больше нет в базе — DELETE (опционально, см. design)
3. **Остаётся локальная база contacts.json** — она продолжает быть источником
истины (дедупликация, трекинг processed_uids), а Radicale — цель синка.
## Capabilities
### New Capabilities
- `contacts/carddav-sync`: Синхронизация извлечённых из почты контактов
в CardDAV-сервер (Radicale) — создание/обновление/удаление vCard-карточек,
доступных клиентам (DAVx5 на Android и др.)
### Modified Capabilities
<!-- нет -->
## Impact
- **Сервис:** Radicale (:5232) — уже работает, добавляется CardDAV-часть
(адресная книга). Новых портов нет.
- **Скрипт:** `scripts/contacts_extractor.py` — добавляется синк в Radicale
(PUT/DELETE vCard), появляется зависимость от CardDAV-клиента/HTTP.
- **Данные:** контакты синхронизируются на сервер; конфликты при
параллельном редактировании на телефоне решаются по ETag (см. design).
- **Документация:** обновить README/STATUS (как подключить адресную книгу
на Android, порты).
- **Риски:** двусторонняя синхронизация (правка на телефоне → обратно в базу)
— НЕ в скоупе этого change (односторонний синк: почта → сервер).
Односторонний синк без конфликтов: только PUT новых/обновлённых, DELETE
только явно помеченных.
## Rollback
1. Отключить синк: убрать шаг синка в `contacts_extractor.py` (флаг
`--no-caldav` / откат коммита) — локальные файлы и база не затрагиваются.
2. Удалить адресную книгу из Radicale: `rm -rf
/opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/Контакты`
(или через DAVx5).
3. Radicale сам не откатывается — он как был, так и остаётся (CalDAV
календарь продолжает работать).
4. Данные локально не теряются: contacts.json/contacts.vcf остаются.
@@ -0,0 +1,108 @@
# contacts/carddav-sync Specification
## Purpose
Односторонняя синхронизация контактов, извлечённых из подписей писем
(pipeline contacts_extractor), в CardDAV-сервер Radicale (:5232), чтобы
контакты были доступны клиентам (Android/DAVx5, десктопные клиенты).
## ADDED Requirements
### Requirement: REQ-CON-CARDDAV-001: Адресная книга в Radicale
**MUST** — в Radicale должна существовать адресная книга пользователя
`estorozhenko` (коллекция `Контакты`, доступ по CardDAV
`/estorozhenko/Контакты/`), созданная до начала синка.
#### Scenario: Проверка адресной книги
**GIVEN** Radicale запущен на :5232
**WHEN** выполняется `curl -X PROPFIND -u estorozhenko:<pass>
http://127.0.0.1:5232/estorozhenko/Контакты/`
**THEN** возвращается HTTP 207 (Multi-Status) и в ответе есть
`<D:resourcetype>` с `C:addressbook`.
### Requirement: REQ-CON-CARDDAV-002: vCard для каждого контакта
**MUST** — для каждого контакта из `contacts.json` (поле `contacts`),
имеющего поле `email`, в адресной книге лежит ровно одна vCard-карточка
(.vcf), идентифицируемая по UID, с полями: FN, EMAIL, TEL (если есть),
ORG (если есть), TITLE (если есть), ADR (если есть).
#### Scenario: Карточка создана
**GIVEN** в contacts.json есть контакт `{email: "a@b.ru", full_name: "Иван"}`
**WHEN** выполняется `curl -X PROPFIND .../Контакты/...a@b.ru.vcf`
**THEN** возвращается 200/207 и vCard содержит `FN:Иван`, `EMAIL:a@b.ru`.
### Requirement: REQ-CON-CARDDAV-003: Повторная синхронизация идемпотентна
**MUST** — повторный запуск синка с теми же данными не создаёт дублей
(vCard уже существует → только обновление по ETag, не новый ресурс).
#### Scenario: Двойной запуск
**GIVEN** синк выполнен один раз
**WHEN** синк выполняется второй раз без изменений данных
**THEN** количество ресурсов в адресной книге не изменяется.
### Requirement: REQ-CON-CARDDAV-004: Обновление существующей карточки
**MUST** — при изменении данных контакта в contacts.json (например,
у контакта появился телефон) карточка в Radicale обновляется (PUT с If-Match
по ETag). Если клиент на телефоне уже изменил карточку (ETag не совпал) —
изменения локальной базы не перезаписывают карточку молча; синк пропускает
обновление и логирует конфликт (см. также REQ-CON-CARDDAV-006).
#### Scenario: Контакт обновлён локально
**GIVEN** у контакта в базе появился `phone`
**WHEN** выполняется синк
**THEN** карточка в Radicale содержит новый `TEL`.
### Requirement: REQ-CON-CARDDAV-005: Удаление карточек
**MUST** — контакты, которых больше нет в `contacts.json` (поле `contacts`),
могут удаляться из адресной книги (DELETE). Удаление не выполняется по
умолчанию (флаг `--prune-caldav`); по умолчанию карточки без соответствующего
контакта остаются.
#### Scenario: Удаление по флагу
**GIVEN** контакт удалён из contacts.json
**WHEN** синк запущен с `--prune-caldav`
**THEN** соответствующая vCard удаляется из адресной книги.
### Requirement: REQ-CON-CARDDAV-006: Неконфликтная работа с клиентами
**MUST** — синк должен использовать ETag (If-Match/If-None-Match) при
PUT, чтобы не затирать изменения, сделанные клиентами на телефоне между
запусками синка. При конфликте ETag — пропустить и записать предупреждение
в лог (файл лога синка).
#### Scenario: Конфликт ETag
**GIVEN** клиент (DAVx5) изменил vCard на сервере после последнего синка
**WHEN** выполняется синк с изменёнными локальными данными этого контакта
**THEN** PUT возвращает 412, синк логирует конфликт и продолжает остальные
карточки.
### Requirement: REQ-CON-CARDDAV-007: Локальная база остаётся источником
**MUST** — contacts.json, contacts.vcf, index.json продолжают обновляться
как раньше (источник истины для дедупликации и трекинга). CardDAV — цель
синка, не замена локальной базе.
#### Scenario: Локальная база не затронута
**GIVEN** синк выполнен
**WHEN** проверяется содержимое /opt/hermes/email/contacts/contacts.json
**THEN** файл существует и содержит актуальную базу контактов.
## Non-Goals
- Двусторонняя синхронизация (правки на телефоне не пишутся обратно в
contacts.json).
- Миграция существующих .vcf из локальных файлов (contacts.vcf остаётся
как есть, синк идёт из contacts.json).
- Синхронизация с внешними CardDAV/Google/Cloud — только локальный Radicale.
@@ -0,0 +1,64 @@
# Tasks: CardDAV-сервер для синхронизации контактов
## 1. Адресная книга в Radicale
- [ ] 1.1 Создать адресную книгу «Контакты» на ФС:
`mkdir -p /opt/hermes/email-assistant/radicale/data/collections/collection-root/estorozhenko/Контакты`
и проверить, что Radicale видит её как addressbook:
`curl -u estorozhenko:$RADICALE_PASS -X PROPFIND -H 'Depth: 0' http://127.0.0.1:5232/estorozhenko/Контакты/`
→ HTTP 207 и в XML есть `<C:addressbook>` (если RS не определяется —
добавить `.Radicale.props` с addressbook и повторить).
- [ ] 1.2 Положить тестовую vCard (test.vcf) в коллекцию и проверить, что
она отдаётся: `curl ... /estorozhenko/Контакты/test.vcf` → 200 + vCard;
затем удалить тестовую карточку.
## 2. Синк в contacts_extractor.py
- [ ] 2.1 Рефакторинг: вынести генерацию vCard 4.0 из существующей
generate_vcard() в отдельную функцию `contact_to_vcard(contact) -> str`,
чтобы переиспользовать для CardDAV-карточек. Проверка: скрипт
запускается без ошибок, contacts.vcf генерируется как раньше.
- [ ] 2.2 Добавить функцию `sync_contacts_to_caldav(contacts_dir, base_url,
user, password, prune=False)`: читает contacts.json, строит карту
uid→(ETag, href) через PROPFIND Depth:1, PUT создаёт/обновляет vCard,
при `prune=True` DELETE удаляет лишние. Использовать urllib + Basic
Auth. Проверка: юнит-запуск с тестовым Radicale (см. 3.x).
- [ ] 2.3 CLI-флаги: `--sync-caldav`, `--prune-caldav`, `--caldav-url`,
`--caldav-user`, `--caldav-pass` (или env CALDAV_PASS). По умолчанию
синк выключен. Проверка: `python3 scripts/contacts_extractor.py --help`
показывает все флаги; без `--sync-caldav` поведение прежнее.
- [ ] 2.4 ETag-конфликты: при PUT с If-Match и ответе 412 — записать
`caldav-sync.log` (контакт, conflict) и продолжить. Проверка: ручной
тест — изменить vCard на сервере, запустить синк, увидеть conflict
в логе.
## 3. Сквозной тест sync
- [ ] 3.1 Запустить синк с реальной базой:
`python3 scripts/contacts_extractor.py --sync-caldav`
(или отдельный скрипт) → в Radicale появились карточки (счётчик
PROPFIND/cards): `curl ... PROPFIND Depth:1 /estorozhenko/Контакты/`
показывает N карточек ≈ количеству контактов в contacts.json с email.
- [ ] 3.2 Повторный запуск — количество карточек не растёт (идемпотентность).
- [ ] 3.3 Изменить контакт в contacts.json (добавить телефон) → повторный
синк обновляет карточку (TEL появился, ETag изменился).
- [ ] 3.4 `--prune-caldav`: удалить контакт из contacts.json → карточка
удалена с сервера.
## 4. Интеграция в cron и документация
- [ ] 4.1 Обновить `config/contacts-cron.sh`: добавить `--sync-caldav` и
подтянуть пароль из radicale/.env (env CALDAV_PASS). Проверка:
запуск cron-скрипта вручную синкает контакты без ошибок.
- [ ] 4.2 Обновить README/STATUS: раздел «CardDAV (контакты)» — как
подключить на Android (DAVx5, URL http://cal.nixg.ru:5232
или cal.nixg.ru, логин estorozhenko), порты, флаги синка.
- [ ] 4.3 `openspec validate contacts-caldav-server` → valid.
- [ ] 4.4 Git commit и push (gitea.nixg.ru/hermes/email-assistant).
## Verification
- [ ] V1: `curl -X PROPFIND -u estorozhenko:PASS http://127.0.0.1:5232/estorozhenko/Контакты/` → 207 + addressbook
- [ ] V2: после синка количество vCard в адресной книге == числу контактов с email в contacts.json
- [ ] V3: повторный синк → 0 новых карточек
- [ ] V4: `python3 scripts/contacts_extractor.py --sync-caldav` из cron-обёртки завершается кодом 0 и пишет caldav-sync.log