Files
email-assistant/openspec/changes/contacts-caldav-server/specs/contacts/carddav-sync/spec.md
T

6.2 KiB
Raw Blame History

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.