Перейти к содержанию

Провайдер CalDAV

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

Два уровня абстракции:

1.1 Высокоуровневый провайдер (UI календаря)

ICalendarProvider
  └─ CalendarProviderBase<T>                          // ExternalCalendars/Providers/CalendarProviderBase.cs
       └─ CalDavCalendarProvider : IDependency         // ExternalCalendars/Providers/CalDav/CalDavCalendarProvider.cs

CalDavCalendarProvider — адаптер между UI-контрактом 1Формы (DefaultCalendarEvent) и CalDAV-протоколом. Singleton.

1.2 Низкоуровневый HTTP-клиент (протокол CalDAV)

ICalDavProvider                                        // Valhalla.Interfaces/CalDav/ICalDavProvider.cs
  └─ CalDavProviderBase (abstract)                     // TCClassLib/CalDav/Providers/CalDavProviderBase.cs
       ├─ DefaultCalDavProvider                        // TCClassLib/CalDav/Providers/DefaultCalDavProvider.cs
       ├─ YandexCalDavProvider                         // TCClassLib/CalDav/Providers/YandexCalDavProvider.cs
       ├─ KerioCalDavProvider                          // TCClassLib/CalDav/Providers/KerioCalDavProvider.cs
       └─ CommunigateCalDavProvider                    // TCClassLib/CalDav/Providers/CommunigateCalDavProvider.cs

1.3 Сервисный слой

IValhallaCalDavService                                 // Valhalla.Interfaces/CalDav/IValhallaCalDavService.cs
  └─ ValhallaCalDavService<TCalDavProvider>            // TCClassLib/CalDav/ValhallaCalDavService.cs

CalDavServiceFactory                                   // TCClassLib/CalDav/CalDavServiceFactory.cs

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

Провайдер Класс Особенности
Generic DefaultCalDavProvider Стандартный CalDAV. Пустой класс — всё из CalDavProviderBase
Яндекс YandexCalDavProvider Discovery через HTTP GET (не PROPFIND). Парсит HTML вместо WebDAV XML. Не принимает attendees при создании — двойная запись
Kerio KerioCalDavProvider Обрезает @domain из логина. Чтение через REPORT (не PROPFIND). Подменяет UID из URL — UID в body может отличаться
CommuniGate CommunigateCalDavProvider URI: добавляет /CalDav/. REPORT + fallback PROPFIND. Удаление из recurrence — ставит CANCELLED (не удаляет). RecurrenceId обрезается до Date

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

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

Цепочка

  1. CalDavCalendarProviderCalDavServiceFactory.GetService(mailBoxId, userId)
  2. Factory берёт mailbox из MailBoxesCache, определяет тип провайдера через маппинг MailServer.CalDavProviderIdCalDavProviders
  3. Создаёт ValhallaCalDavService<TCalDavProvider> через Activator.CreateInstance
  4. ValhallaCalDavService конструктор:
  5. Расшифровывает пароль: SimpleAES.Decrypt(mailBox.CalDavPassword)
  6. URI: mailBox.CalDavAddress (персональный) или mailBox.MailServer.CalDavAddress (серверный)
  7. Создаёт TCalDavProvider(targetUri, login, password, sessionUser, isExactUri)

Auth-метод

Исключительно Basic Auth:

CalDavProviderBase.cs:459-463
httpClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Basic",
        Convert.ToBase64String(Encoding.ASCII.GetBytes($"{username}:{password}")));

Включение CalDAV для пользователя

Через mailbox. MailBoxesService.GetCalDavMailBoxes(userId) — фильтрует mailbox-ы где: - CalDavLogin != null - MailServer.IsCalDav = true - Disabled = false

Таблицы БД

Таблица Назначение
CalDavProviders Справочник провайдеров (Id, Name). Name = имя C#-класса
EmailMailServers Серверы, FK CalDavProviderId. Поля: IsCalDav, CalDavAddress
Mailbox (пользователь) CalDavLogin, CalDavPassword (AES), CalDavAddress (override)

4. CRUD операции

Все HTTP-запросы — в CalDavProviderBase.

Create

  1. CalDavCalendarProvider.CreateInner() (:326)
  2. Выбор календаря — хак: ищет "calendar" в URL, fallback на первый
  3. Формирует Event через маппинг CalDavEventExtentions
  4. CalDavProviderBase.CreateEventAsync() → HTTP PUT на {calendarUri}/{eventUid}.ics
  5. Яндекс-хак (:418-425): создание без attendees → прочитать → обновить с attendees

Read

  • Одно событие: CalDavProviderBase.GetEventFullAsync() → HTTP PROPFIND → парсинг calendar-data → десериализация ICS
  • Список: CalDavProviderBase.GetCalendarWithUriAsync() → HTTP REPORT с calendar-query → все calendar-data

Update

  1. GET текущего master event (read-modify-write)
  2. Для recurrence instance: находит/создаёт customized event с RecurrenceId, проверяет EXDATE
  3. HTTP PUT с полным ICS (все VEVENT в одном VCALENDAR)

Delete

  • Без recurrence: HTTP DELETE
  • С recurrence: read-modify-write — добавляет EXDATE или удаляет customized event → PUT

Action

Действие Organizer Attendee
Accept Меняет RespStatus на ACCEPTED
Decline = Delete Меняет RespStatus на DECLINED
AcceptTentatively Меняет RespStatus на TENTATIVE
Cancel = Delete

5. ICS Builder (Event model)

Файл: TCClassLib/CalDav/Models/Event.cs

Сериализация (:156-215)

Порядок полей 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,
  [custom Properties], VALARM[]
END:VEVENT

Десериализация (:67-154)

Парсит ICS property по property. IsAllDay определяется как: Start == End || Start.AddDays(1) == End (:153). Второе условие — для Яндекса.

Формат дат

yyyyMMddTHHmmssZ (UTC). Line folding: строки > 75 символов разбиваются с \t continuation (Common.cs:238-245).

6. Маппинг полей (CalDavEventExtentions)

Файл: TCClassLib/CalDav/Extentions/CalDavEventExtentions.cs

Поле 1Forma (DefaultCalendarEvent) Поле VEVENT Примечание
Start DTSTART UTC через ValhallaTimeZoneExtensions.ToOtherTimeZone
End DTEND UTC
Subject SUMMARY .Trim()
TextBody DESCRIPTION .Trim(), без конвертации HTML → text
IsAllDayEvent IsAllDay
Location LOCATION ?? string.Empty
IsPrivate CLASS PRIVATE / PUBLIC
Organizer ORGANIZER Резолв через UsersCache
RequiredAttendees[] ATTENDEE[] Email + DisplayName, RespStatus = NeedsAction
Recurrence RRULE Switch по RecurrenceType

7. Sync-механизм

Модель: pull on demand. Нет автоматической синхронизации.

  • ListInner — полный запрос к CalDAV серверу при каждом открытии календаря
  • Ctag/SyncToken парсятся при discovery, но не используются для инкрементальной синхронизации
  • Нет push-подписок (WebHook/subscription) на CalDAV-сервер
  • Recurrence развёртывается на стороне 1Формы (CalDavCalendarProvider.cs:537-649), не на сервере

Absence Provider

CalDavAbsenceProvider — pull absence status. Для каждого пользователя запрашивает события на сегодня, батчами по 20 человек.

8. Ключ события

Формат: base64 от "{eventUid},{calendarUid},{recurrenceId.Ticks}"

Методы: WrapKey (:716) / UnwrapKey (:723)

9. Уведомления UI

Через CalendarEventChanged PubSub (TriggerNotifications, :130). Не streaming — разовое уведомление после каждой CUD-операции.

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

Критичные

# Проблема Детали
1 HTML в DESCRIPTION TextBody пишется as-is в DESCRIPTION (RFC 5545 = plain text). Сторонние календари показывают HTML-теги. См. faq-caldav-html-description.md
2 Нет инкрементальной синхронизации Каждый запрос — полный REPORT/PROPFIND. На больших календарях дорого
3 Google Calendar не поддерживается Только Basic Auth, нет OAuth2
4 Sync GetAwaiter().GetResult() Массовое использование в CalDavCalendarProvider (:117, 227, 341, 424, 830, 913, 942). Deadlock risk в ASP.NET

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

# Проблема Детали
5 Recurrence развёртывание кастомное Не RFC-compliant. Нет RelativeMonthly в развёртывании. Weekly: хрупкий DayOfWeek.ToString().StartsWith(x)
6 Нет VTIMEZONE Все даты конвертируются в UTC. Комментарий в коде: "сериализация/десериализация еще этого не поддерживает"
7 HttpClient в конструкторе new HttpClient() вместо IHttpClientFactory. Потенциальная проблема socket exhaustion
8 Потеря вложений ATTACH парсится при чтении, но не сериализуется при записи
9 Шифрование паролей SimpleAES — один ключ на всю систему, не per-user

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

# Проблема Детали
10 Яндекс: attendees при create Не принимает — двойная запись (create + update)
11 Kerio: расхождение UID UID в URL и в VEVENT body могут отличаться — подмена из URL
12 Communigate: разные стратегии версий REPORT + fallback PROPFIND
13 Выбор календаря Хак — поиск "calendar" в URL, нет UI для выбора
14 IsAllDay false positive Start.AddDays(1) == End срабатывает на часовые события с полуночи

11. Диагностика

Типичные симптомы

Симптом Где смотреть
Пустой список событий Логи CalDavProviderBase (HTTP responses). Проверить mailbox: CalDavLogin, CalDavAddress, MailServer.IsCalDav
Встреча не создаётся при включённом CalDAV Проверить выбор календаря (хак с "calendar" в URL). Проверить attendees при Яндексе
HTML-теги в описании Известное ограничение, см. FAQ
Ошибки аутентификации Basic Auth credentials в mailbox. SimpleAES.Decrypt пароля

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

-- Mailbox-ы пользователя с 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;

12. Связанные документы

  • docs/domains/calendar/backend.md — общая архитектура календарных провайдеров
  • docs/domains/calendar/data-flow.md — сквозные сценарии CRUD/Agenda
  • docs/domains/calendar/faq-caldav-html-description.md — FAQ по HTML в описании
  • docs/domains/calendar/provider-ews.md — провайдер Exchange (EWS)