Провайдер 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. Аутентификация и подключение¶
Цепочка¶
CalDavCalendarProvider→CalDavServiceFactory.GetService(mailBoxId, userId)- Factory берёт mailbox из
MailBoxesCache, определяет тип провайдера через маппингMailServer.CalDavProviderId→CalDavProviders - Создаёт
ValhallaCalDavService<TCalDavProvider>черезActivator.CreateInstance ValhallaCalDavServiceконструктор:- Расшифровывает пароль:
SimpleAES.Decrypt(mailBox.CalDavPassword) - URI:
mailBox.CalDavAddress(персональный) илиmailBox.MailServer.CalDavAddress(серверный) - Создаёт
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¶
CalDavCalendarProvider.CreateInner()(:326)- Выбор календаря — хак: ищет "calendar" в URL, fallback на первый
- Формирует
Eventчерез маппингCalDavEventExtentions CalDavProviderBase.CreateEventAsync()→ HTTPPUTна{calendarUri}/{eventUid}.ics- Яндекс-хак (:418-425): создание без attendees → прочитать → обновить с attendees
Read¶
- Одно событие:
CalDavProviderBase.GetEventFullAsync()→ HTTPPROPFIND→ парсингcalendar-data→ десериализация ICS - Список:
CalDavProviderBase.GetCalendarWithUriAsync()→ HTTPREPORTсcalendar-query→ всеcalendar-data
Update¶
- GET текущего master event (read-modify-write)
- Для recurrence instance: находит/создаёт customized event с
RecurrenceId, проверяет EXDATE - 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/Agendadocs/domains/calendar/faq-caldav-html-description.md— FAQ по HTML в описанииdocs/domains/calendar/provider-ews.md— провайдер Exchange (EWS)