# Провайдер CalDAV

Документ описывает провайдер CalDAV: поддерживаемые серверы (стандартный CalDAV, Яндекс, Kerio, CommuniGate), подключение через почтовый ящик и Basic Auth, операции с событиями по протоколу CalDAV, формат ICS и маппинг полей. Для администраторов и инженеров, настраивающих интеграцию внешних календарей с 1Формой.

## 1. Архитектура

Провайдер CalDAV состоит из двух уровней:

- **высокоуровневый провайдер** — адаптер между календарём 1Формы и протоколом CalDAV (преобразует события 1Ф в формат CalDAV и обратно);
- **низкоуровневый HTTP-клиент** — реализация протокола CalDAV под конкретный тип сервера (стандартный, Яндекс, Kerio, CommuniGate).

Над ними — сервисный слой, который по почтовому ящику пользователя выбирает нужный тип клиента.

## 2. Поддерживаемые серверы

В таблице — особенности подключения для каждого типа CalDAV-сервера:

| Сервер | Особенности |
|--------|-------------|
| **Стандартный CalDAV** | Стандартная реализация протокола без особенностей |
| **Яндекс** | Обнаружение календарей идёт через HTTP GET (не PROPFIND), ответ разбирается как HTML. Не принимает участников при создании — событие записывается в два приёма |
| **Kerio** | Отбрасывает `@домен` из логина. Чтение через запрос REPORT. Идентификатор (UID) берётся из адреса события — UID в теле может отличаться |
| **CommuniGate** | К адресу добавляется `/CalDav/`. Чтение — REPORT с откатом на PROPFIND. Удаление из серии помечает событие как отменённое (`CANCELLED`), не удаляя его |

**Google Calendar не поддерживается:** только Basic Auth, OAuth2 не реализован.

**Делегирование календарей (работа в чужом календаре).** Поддерживается только сервером Kerio Connect. У Яндекса, CommuniGate и стандартного провайдера делегирование не поддерживается: права у сервера не запрашиваются вовсе, а попытка создать или изменить встречу в чужом календаре отклоняется сразу.

## 3. Аутентификация и подключение

Подключение выполняется через почтовый ящик (mailbox) пользователя с авторизацией **Basic Auth** (логин и пароль). Пароль хранится в зашифрованном виде. Адрес CalDAV берётся из персональной настройки ящика или из настроек почтового сервера.

Учитываются только почтовые ящики, у которых:

- задан CalDAV-логин;
- на почтовом сервере включён признак CalDAV;
- ящик не отключён.

Связанные таблицы БД:

| Таблица | Назначение |
|---------|-----------|
| `CalDavProviders` | Справочник типов CalDAV-серверов (Id, Name) |
| `EmailMailServers` | Почтовые серверы; признак `IsCalDav` и адрес `CalDavAddress`, ссылка на тип через `CalDavProviderId` |
| `EmailMailBoxes` (ящик пользователя) | `CalDavLogin`, `CalDavPassword` (зашифрован), `CalDavAddress` (персональный адрес) |

**Доступ к чужому календарю.** Свой календарь пользователь читает всегда. Чужой доступен только при праве «Просматривать календарные события членов группы», выданном на владельца календаря. Если права нет, список событий возвращается пустым — отказа или ошибки не будет, календарь просто выглядит пустым.

**Чужой календарь и запись в него.** Кроме чтения, пользователь может создавать, изменять и удалять встречи в чужом календаре, если владелец выдал ему соответствующие права на самом CalDAV-сервере (делегирование). Права берутся из ответа сервера на запрос `DAV:current-user-privilege-set` по адресу календаря владельца: создание — `DAV:bind`, изменение — `DAV:write-content`, удаление — `DAV:unbind`, полный просмотр — `DAV:read`. Если сервер не ответил, вернул 401, 403 или 404 либо не уложился в таймаут 3 с, права считаются отсутствующими и запись не выполняется. Полученные права кэшируются по ключу «инициатор — владелец — адрес календаря» на срок из настройки `CalDavPermissionsCacheLifeTime` (по умолчанию 240 минут), но перед записью права перечитываются заново, без кэша — поэтому отозванное делегирование отклоняет сохранение, хотя форма уже открыта.

## 4. Операции с событиями

Все операции выполняются по протоколу CalDAV (HTTP-запросы PUT / PROPFIND / REPORT / DELETE).

**Создание:**

1. Выбирается календарь (по наличию «calendar» в адресе, иначе — первый доступный).
2. Событие сериализуется в формат ICS.
3. Выполняется HTTP `PUT` события по адресу `{адрес-календаря}/{идентификатор}.ics`.
4. Для Яндекса — обходной путь: событие создаётся без участников, затем перечитывается и обновляется с участниками.

**Создание в чужом календаре (при делегировании).** Если пользователь создаёт встречу в календаре коллеги и на сервере ему выдано право создавать события, событие записывается в календарь владельца под учёткой текущего пользователя по точному адресу этого календаря. Организатором события указывается владелец календаря, а текущий пользователь — как действующий от его имени (`SENT-BY`): в iCalendar это `ORGANIZER;CN=владелец;SENT-BY="mailto:инициатор":mailto:владелец`. Перед записью права проверяются повторно, без кэша.

Перечитывание выполняется с ограниченным повтором: CalDAV-сервер отдаёт только что созданное событие не мгновенно. Делается до пяти попыток чтения с паузой 200 мс между ними; при первом успешном чтении повторы прекращаются и событие обновляется участниками. Если событие не удалось прочитать ни за одну попытку, операция завершается ошибкой — при этом само событие на сервере уже создано, но участники в него не записаны.

**Чтение:**

- одно событие — запрос `PROPFIND`, разбор и десериализация ICS;
- список — запрос `REPORT` с `calendar-query`.

**Список исключений `EXDATE` при чтении.** В одном свойстве `EXDATE` может стоять несколько дат через запятую (RFC 5545) — так их присылают некоторые серверы. Такая строка разбирается поэлементно, и в список исключений серии попадает каждая дата; экранированная запятая разделителем не считается, а значения, которые не разбираются как дата, пропускаются. Параметры свойства, например `TZID`, при разборе исключений не учитываются. При записи 1Форма формирует отдельное свойство `EXDATE` на каждую дату.

**Изменение:**

Текущее событие читается, изменяется и записывается обратно. Для отдельного экземпляра повторяющейся серии создаётся изменённый экземпляр с `RecurrenceId`, при необходимости правится список исключений (EXDATE). Записывается полный ICS (все экземпляры в одном `VCALENDAR`).

Встреча в чужом календаре изменяется и удаляется в календаре владельца под учёткой текущего пользователя — тем же точным адресом календаря, что и при создании. Перед записью права перечитываются без кэша: изменение — по праву `write-content`, удаление — по праву `unbind`. Организатором встречи остаётся владелец календаря.

Правка повторяющейся серии с даты («с этой даты и далее») провайдеру CalDAV недоступна: контроллер отклоняет такой запрос кодом `400` с текстом `Series from date edit is not supported for this calendar provider`, и провайдер до дела не доходит. Изменение всего ряда и изменение отдельного экземпляра работают как описано выше.

**Удаление:**

- одиночное событие — HTTP `DELETE`;
- из серии — добавление даты в список исключений (EXDATE) или удаление изменённого экземпляра с последующей записью.

**Ответ на приглашение:**

| Действие | Организатор | Участник |
|----------|-----------|----------|
| Принять | — | Статус ответа → ACCEPTED |
| Отклонить | = удаление | Статус ответа → DECLINED |
| Принять под вопросом | — | Статус ответа → TENTATIVE |
| Отменить | = удаление | — |

**Ответ на приглашение у Kerio.** У входящего приглашения, которое сервер доставил в календарь участника, имя ресурса оканчивается на `.eml`, а не на `.ics`. Ответ участника — «Принять», «Под вопросом», «Отклонить» — и ответ по всему ряду выполняются отдельной операцией: событие сначала читается по имени ресурса из ключа (`.eml` берётся как есть), затем в него записывается новый статус ответа и результат уходит на сервер тем же адресом ресурса. Ответ по ряду заменяет и ранее записанный ответ отдельного повторения. Имя ресурса без расширения — состояние только что созданной через 1Форму встречи до первого чтения; при чтении к нему добавляется `.ics`, как и раньше.

## 5. Формат ICS

**Порядок полей события (VEVENT):**

```
BEGIN:VEVENT
  UID, ATTENDEE[], CATEGORIES, RRULE[], CLASS, CREATED,
  DESCRIPTION, DTEND, DTSTAMP, DTSTART,
  LAST-MODIFIED, LOCATION, ORGANIZER, PRIORITY, SEQUENCE,
  RECURRENCE-ID, EXDATE[], STATUS, SUMMARY, TRANSP, URL,
  [пользовательские свойства], VALARM[]
END:VEVENT
```

**Формат дат:** `yyyyMMddTHHmmssZ` (UTC). Строки длиннее 75 символов переносятся по правилам ICS (line folding).

### Часовые пояса при импорте и экспорте

Контракт времени — абсолютный момент, а не настенные часы.

**При чтении события (внешний календарь → 1Форма)** момент вычисляется по собственной зоне значения: суффикс `Z` — это UTC, параметр `TZID=X` — настенное время зоны X, значение без того и другого — настенное время серверной зоны площадки (`Settings.ServerTimeZone`). Полученный момент приводится к серверной зоне и в этом виде хранится в платформе. Зона разрешается по имени: принимаются и IANA-идентификаторы (`Asia/Yekaterinburg`), и Windows (`Ekaterinburg Standard Time`). Если имя неизвестно, смещение берётся из встроенного блока `VTIMEZONE` этого же события, а при его отсутствии — из серверной зоны, с записью в журнал. Параметры свойства (`TZID`, `VALUE`) разбираются вместе со значением.

**При записи (1Форма → внешний календарь)** момент уходит в UTC с суффиксом `Z`. Событие «весь день» пишется как `DTSTART;VALUE=DATE` и занимает ровно одни сутки — дата не сдвигается ни в UTC, ни между зонами. Все даты серии «весь день» (`DTSTART`, `DTEND`, `RECURRENCE-ID`, `EXDATE`) считаются в календарной шкале зоны начала события.

**Что это значит при приёмке.** Настенные часы в 1Форме и во внешнем календаре совпадать не обязаны: если часовой пояс профиля в 1Форме отличается от зоны учётной записи на CalDAV-сервере, встреча показывается со сдвигом ровно на разницу зон. Это корректное поведение, а не дефект — в обеих системах речь об одном моменте времени. Смена часового пояса в профиле 1Формы на уже синхронизированные события не влияет: сервер принимает и отдаёт UTC, а отображение считает клиент (см. «Часовой пояс сетки календаря» в frontend.md).

Общий контракт хранения времени в платформе — раздел «Подсистема таймзон» в system/backend.md.

**Экранирование текстовых полей.** В теме, описании, месте и категориях служебные символы экранируются по правилам ICS: обратная косая черта, точка с запятой и запятая предваряются обратной косой, переносы строк передаются служебной последовательностью. Точка с запятой или запятая в теме события не разрывает поле и не портит структуру файла.

**Признак «весь день»** определяется как `Начало == Конец` или `Начало + 1 день == Конец` (второе условие — для Яндекса).

## 6. Маппинг полей

Соответствие полей события 1Ф и полей VEVENT в ICS:

| Поле 1Forma | Поле VEVENT | Примечание |
|-------------|-------------|------------|
| Start | DTSTART | При записи — UTC; при чтении зона берётся из параметров свойства (`TZID`, `VALUE`), см. «Часовые пояса при импорте и экспорте» |
| End | DTEND | Так же, как Start |
| Subject | SUMMARY | |
| TextBody | DESCRIPTION | При записи (1Ф → CalDAV) — **без конвертации HTML → текст**; при чтении (CalDAV → 1Ф) описание очищается от HTML-тегов и приводится к обычному тексту (см. [FAQ: HTML-теги в описании](https://help.1forma.ru/domains/calendar/faq-caldav-html-description.md)) |
| IsAllDayEvent | IsAllDay | |
| Location | LOCATION | |
| IsPrivate | CLASS | PRIVATE / PUBLIC |
| Organizer | ORGANIZER | По справочнику пользователей |
| RequiredAttendees[] | ATTENDEE[] | Email + имя, статус ответа = NeedsAction |
| Recurrence | RRULE | По типу повторяемости |
| LinkedTaskId (поле «Связано с») | — | Не хранится: у CalDAV нет задачи-базы, в VEVENT для связи нет свойства |

**Статус занятости события.** Стандартный iCalendar (RFC 5545) передаёт занятость свойством `TRANSP`, однако детальные статусы отсутствия передаются расширениями. События CalDAV читаются с разбором свойства `X-MICROSOFT-CDO-BUSYSTATUS`: если событие содержит признак отсутствия (`OOF`, Out of Office), на экране календаря и в виджетах показывается статус «Нет на месте». Если свойства нет или его значение не распознано, событие отдаётся с неопределённой занятостью (`NoData`), а не как «Занят». При сохранении события статус занятости записывается в то же свойство — так его видят внешние календари.

**Связь встречи с задачей (`LinkedTaskId`).** У встроенного календаря 1Формы связь живёт в дополнительном параметре задачи-базы, которой у CalDAV нет, а в ICS-событии для неё нет свойства — поэтому `CalDavCalendarProvider` не читает её из события и не записывает при создании и изменении. Связать встречу CalDAV с задачей нельзя, и поле «Связано с» скрыто в интерфейсе — и при создании, и в карточке (см. [бизнес-описание карточки](https://help.1forma.ru/domains/calendar/business.md#поля-карточки)); молчаливой потери значения не происходит. У Exchange (EWS) связь работает: она держится машинным маркером в описании события (см. [провайдер EWS](https://help.1forma.ru/domains/calendar/provider-ews.md)).

## 7. Механизм синхронизации

**Модель: чтение по запросу.** Автоматической синхронизации нет:

- при каждом открытии календаря выполняется полный запрос к CalDAV-серверу;
- метки изменений (`Ctag` / `SyncToken`) считываются, но для инкрементальной синхронизации не используются;
- push-подписок на CalDAV-сервер нет;
- повторяющиеся события разворачиваются на стороне 1Формы, а не на сервере.

Статус занятости (absence) подтягивается тоже по запросу: для каждого пользователя запрашиваются события на сегодня, обработка идёт пачками по 20 человек.

Уведомления интерфейса отправляются разово после каждой операции создания / изменения / удаления (не потоково).

## 8. Ограничения и известные проблемы

### Критичные

Ограничения, заметные пользователю или влияющие на работоспособность интеграции:

| # | Проблема | Детали |
|---|----------|--------|
| 1 | **HTML в DESCRIPTION** | Описание пишется в поле `DESCRIPTION` как есть, а по стандарту RFC 5545 это обычный текст. Сторонние календари показывают HTML-теги. См. [FAQ: HTML-теги в описании](https://help.1forma.ru/domains/calendar/faq-caldav-html-description.md) |
| 2 | **Нет инкрементальной синхронизации** | Каждый запрос — полный REPORT/PROPFIND. На больших календарях дорого |
| 3 | **Google Calendar не поддерживается** | Только Basic Auth, нет OAuth2 |

### Архитектурные

Особенности реализации, которые стоит учитывать при настройке:

| # | Проблема | Детали |
|---|----------|--------|
| 4 | **Развёртывание повторяемости нестандартное** | Не полностью соответствует RFC; нет относительного ежемесячного правила (например, «третий понедельник месяца») |
| 5 | **VTIMEZONE при экспорте не пишется** | Свои события провайдер отдаёт в UTC с суффиксом `Z` и отдельный блок `VTIMEZONE` в файл не добавляет. Чужие `TZID` и встроенные `VTIMEZONE` при чтении разбираются (см. «Часовые пояса при импорте и экспорте»), поэтому внешнему календарю пояс события передаётся только как момент UTC. Остаётся известная граница: `DTSTART` внутри `VTIMEZONE` при сериализации пишется с суффиксом `Z`, хотя RFC 5545 требует настенное время без него; на живом пути провайдера `VTIMEZONE` не пишется, поэтому эффект не проявляется |
| 6 | **Потеря вложений** | Вложения (`ATTACH`) читаются, но при записи не сохраняются |
| 7 | **Шифрование паролей** | Единый ключ шифрования на всю систему, не отдельный на пользователя |
| 8 | **Нет связи встречи с задачей** | Поле «Связано с» не работает и скрыто в интерфейсе: хранить связь негде, в VEVENT для неё нет свойства |
| 9 | **Правка серии с даты не поддерживается** | Запрос на изменение повторяющейся серии с указанной даты отклоняется кодом `400` (`Series from date edit is not supported for this calendar provider`). Признак `CanEditSeriesFromDate` у календарей CalDAV отрицателен, поэтому интерфейс и не предлагает такой вариант правки. Изменение всего ряда и отдельного повторения работают как раньше |

### Серверо-специфичные

Особенности отдельных типов CalDAV-серверов:

| # | Проблема | Детали |
|---|----------|--------|
| 10 | **Яндекс: участники при создании** | Не принимаются — запись в два приёма (создание + обновление) |
| 11 | **Kerio: расхождение UID и расширение ресурса** | UID в адресе и в теле события могут отличаться — берётся из адреса ресурса. Имя ресурса сохраняется как есть: входящее приглашение, которое Kerio доставляет в календарь участника, лежит под именем `‹uid›.eml`, встречи, созданные через 1Форму, — под `‹uid›.ics`, а имя без расширения (состояние встречи сразу после создания) при чтении получает суффикс `.ics` |
| 12 | **CommuniGate: смешанные запросы** | REPORT с откатом на PROPFIND |
| 13 | **Выбор календаря** | Эвристика — поиск «calendar» в адресе; отдельного выбора в интерфейсе нет |
| 14 | **Ложный признак «весь день»** | Правило `Начало + 1 день == Конец` срабатывает на событиях ровно с полуночи |
| 15 | **Права на чужой календарь — только у Kerio** | Работа в чужом календаре по делегированию поддерживается только сервером Kerio Connect. Если право отозвано на сервере, сохранение встречи отклоняется, хотя форма уже открыта. При недоступном сервере или таймауте (3 с) запрос прав завершается отказом с записью в журнал — интерфейс не зависает |

## 9. Диагностика

Частые симптомы при работе CalDAV-интеграции и где искать причину:

| Симптом | Где смотреть |
|---------|-------------|
| Пустой список событий | Логи CalDAV-запросов (HTTP-ответы). Проверить ящик: CalDavLogin, CalDavAddress, признак CalDAV на сервере |
| Встреча не создаётся при включённом CalDAV | Проверить выбор календаря (поиск «calendar» в адресе) и участников для Яндекса |
| Встреча создалась, но участники не проставились | Исчерпан лимит попыток чтения созданного события (пять попыток с паузой 200 мс) — сервер не отдал событие на чтение. Смотреть логи CalDAV-запросов: доступность и время ответа сервера сразу после создания |
| HTML-теги в описании | Относится к направлению «запись»: описание уходит в `DESCRIPTION` как есть, и сторонний календарь показывает теги. При чтении события из CalDAV теги вычищаются, в 1Форме описание отображается обычным текстом. См. FAQ |
| Ошибки аутентификации | Логин и пароль (Basic Auth) в настройках почтового ящика |
| Время встречи в 1Форме и во внешнем календаре расходится | Сначала посчитать разницу. Если она равна разнице часовых поясов профиля 1Формы и учётной записи на CalDAV-сервере — это корректное поведение (см. «Часовые пояса при импорте и экспорте»). Если расхождение разницей зон не объясняется или событие «весь день» занимает не те сутки — смотреть логи CalDAV-запросов и параметры `TZID` / `VALUE=DATE` в сыром событии на сервере |
| Событие «весь день» сдвинулось на сутки | Проверить сборку: запись «весь день» как `DTSTART;VALUE=DATE` ровно на одни сутки входит в сборку 2.268 «Скульптор» и новее. Проверить, не пришло ли от сервера значение со временем и суффиксом `Z` вместо `VALUE=DATE` |

### Диагностический SQL

Запросы для проверки настроек CalDAV у пользователя:

```sql
-- Почтовые ящики пользователя с CalDAV
select m.ID, m.EmailLogin, m.CalDavLogin, m.CalDavAddress,
       s.Name as ServerName, s.CalDavAddress as ServerCalDavAddress,
       s.IsCalDav, p.Name as ProviderName
from EmailMailBoxes m
join EmailMailServers s on m.MailServerID = s.ID
left join CalDavProviders p on s.CalDavProviderId = p.Id
where m.UserID = @userId
  and m.CalDavLogin is not null
  and s.IsCalDav = 1
  and m.Disabled = 0;

-- Справочник типов CalDAV-серверов
select * from CalDavProviders;
```

**Связанные документы:**

- [FAQ: HTML-теги в описании событий (CalDAV)](https://help.1forma.ru/domains/calendar/faq-caldav-html-description.md)
- [Провайдер Exchange (EWS)](https://help.1forma.ru/domains/calendar/provider-ews.md)
