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

Провайдер Exchange (EWS)

Не дублирует: admin.md (инструкция настройки), runbook-exchange-user-sync.md (диагностика sync)

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

ICalendarProvider
  └─ CalendarProviderBase<DefaultCalendarEvent>
       └─ ExchangeCalendarProvider : IDependency         // ExternalCalendars/Providers/Exchange/ExchangeCalendarProvider.cs (1988 строк)

Singleton. Name = CalendarProviderKeys.EWS = "ews".

Основные файлы

Файл Назначение
ExchangeCalendarProvider.cs Провайдер (CRUD, Messages, Unread, Permissions)
ExchangeExceptionsProcessor.cs Обработка ошибок EWS, автоотключение
ExchangeFolderService.cs Работа с папками Exchange + кэш прав
ExchangePermissionsService.cs Проверка прав просмотра чужих календарей
ExchangeReccurenceInfo.cs Конвертация рекурренции
ExchangeRecipient.cs Маппинг EmailAddressCalendarEventRecipient
ExchangeServiceFactory.cs Фабрика ExchangeService
ExchangeServicePool.cs Семафор конкурентности (не пул объектов)
ValhallaExchangeService.cs Обёртка над Microsoft.Exchange.WebServices.Data.ExchangeService
ExchangeSettingsService.cs CustomSettings для EWS
ExchangeNotificationService.cs Менеджер streaming subscriptions
ExchangeSubscriptionService.cs Одна подписка на пользователя

Кэши

Кэш TTL Назначение
UsersMessageCountCache 10 мин (MemoryCache) Счётчик непрочитанных
UserCalendarFolderIdCache ID папки Calendar
UserInboxFolderIdCache ID папки Inbox
ExchangeUsersPermissionsCache ExchangePermissionsCacheLifeTime (5 мин) Права пользователей
UserExchangeCalendarCategoriesCache Категории/цвета (24 цвета)

2. Аутентификация

Метод

Только Basic Auth: WebCredentials(login, password, domain) (ExchangeServiceFactory.cs:299).

OAuth не реализован.

Impersonation

Два варианта через ImpersonatedUserId: - SMTP (ConnectingIdType.SmtpAddress) — по умолчанию - SID (ConnectingIdType.SID) — при UseSIDForImpersonalization = true

Приоритет: DomainControllerDefaultEwsService.

Версия протокола

  • Exchange2010_SP1 — по умолчанию в фабрике
  • Exchange2013_SP1 — в пуле

Настройка ExchangeVersion в CustomSettings переопределяет оба значения по умолчанию.

Шифрование паролей

IEncryptionService (более надёжно, чем CalDAV c SimpleAES).

Delete mode

  • Без impersonation: HardDelete
  • С impersonation: MoveToDeletedItems

3. Режимы синхронизации

Три режима (подробности — в runbook-exchange-user-sync.md):

Режим DoSyncWithExchange CalendarEwsDirectSync Описание
Disabled false false Нет синхронизации
Sync (legacy) true false Событийная синхронизация
Direct / Online false true Прямое чтение из Exchange

Автоотключение при ошибках

ExchangeExceptionsProcessor (:66-159): - BadUser ошибки: ErrorNonExistentMailbox, ErrorItemNotFound, ErrorFolderNotFound, ErrorInvalidSmtpAddress, ErrorMailRecipientNotFound - AccessDenied: очистка EwsFolders/EwsFolderPermissions + инкремент fail counter - Превышение CalendarExchangeRetryLimit → Disabled - Job EnableDoSyncWithExchangeJob может переключить обратно

4. CRUD операции

Create (:1181-1233)

  1. Новый Appointment(service)UpdateAppointmentValuesappointment.Save
  2. SendInvitationsMode: SendToAllAndSaveCopy если есть attendees, иначе SendToNone
  3. Impersonated → WellKnownFolderName.Calendar, иначе → ExchangeFolderService.GetFolder

Update (:1235-1276)

  1. GetAppointmentUpdateAppointmentValuesappointment.Update(ConflictResolutionMode.AlwaysOverwrite)
  2. SendInvitationsOrCancellationsMode зависит от attendees

Delete (:1696-1724)

GetAppointmentappointment.Delete(service.GetDeleteMode)

Get (:1309-1490)

  • Appointment.Bind(service, itemId, _getEventFullPropertySet)
  • Три уровня доступа по FolderPermissionReadAccess:
  • TimeOnly — только время
  • TimeAndSubjectAndLocation — + тема, место
  • FullDetails — всё включая тело, участников, вложения
  • Приватные события (Sensitivity.Private) скрыты от чужих пользователей
  • MasterKey для Occurrence/Exception через BindToRecurringMaster

Маппинг полей (UpdateAppointmentValues, :862-966)

Поле 1Forma Поле Exchange Примечание
Subject Subject
Location Location
TextBody Body (HTML) Несмотря на имя «TextBody», передаётся HTML с forma-id-{taskId} блоком (см. UpdateAppointmentValues :851-860)
Start/End Start/End С коррекцией таймзон
IsAllDayEvent IsAllDayEvent
IsPrivate Sensitivity Private/Normal
FreeBusyStatus LegacyFreeBusyStatus Free/Busy/Tentative/OOF
RequiredAttendees RequiredAttendees Через ProcessAttendees
OptionalAttendees OptionalAttendees
Recurrence Recurrence Через ExchangeReccurenceInfo
Attachments Attachments FileAttachment

LinkedTaskId

v2.267+: HTML-блок <div class="forma-link"> в теле встречи более не вставляется 1Формой. Приглашения участникам формируются отдельным письмом через MailService.Send (см. ниже).

Для чтения существующих встреч (legacy) извлечение по-прежнему поддерживается: regex forma-id-(\d+) или Linked task id: \d+.

Email-уведомление участникам при создании встречи

При создании Exchange-встречи 1Форма отправляет участникам письмо через MailService.Send с шаблоном из MailTemplates:

Шаблон TCEvents ID Условие Тело письма
MeetingCreated 54 LinkedTaskId == null Текст пользователя
MeetingCreatedWithLinkedTask 55 LinkedTaskId != null Текст + блок «Перейти к задаче»

Шаблоны настраиваются через Администрирование → Почта → Шаблоны писем (api/admin/mail-templates). Поставляются как SystemTemplate = 1, IsActive = 1 через DB-миграцию.

До v2.267 логика была в ExchangeCalendarProvider.cs: GetLinkedTaskTemplate() строил HTML-блок, UpdateAppointmentValues() конкатенировал его к textBody, Remove1fContainer() удалял старый блок при обновлении. Все три метода удалены.

5. Streaming Subscriptions (push-синхронизация)

Архитектура

ExchangeNotificationService — singleton, IOnAppInitialize. Работает только на JobServer (Configuration.IsJobServer).

Инициализация: как определяется список EWS-сервисов

Перед подпиской на пользователей ExchangeNotificationService.RecreateSubscriptions() вызывает ExchangeServiceFactory.GetAllExchangeServices(), который строит список ExchangeService-объектов двумя путями:

Путь 1 — через домены AD-профилей (DomainToEwsSettingsCache):

EwsServiceSettings
  → SynchronizationProfileADSettings  (связка EWS ↔ профиль синхронизации)
  → SynchronizationProfile → ServiceSettings → ADServiceCredentials.DomainController
  → DomainToEwsSettingsCache (ключ = DomainController, значение = EwsServiceSettingsEntity)
  → ExchangeServiceFactory.GetAllExchangeServices() итерирует ключи кэша

Ключ кэша — значение поля DomainController из AD-учётных данных (ADServiceCredentials), привязанных к EWS-сервису через профиль синхронизации. Кэш пустой, если ни один EWS-сервис не связан с AD-профилем — тогда путь 1 не даёт ни одного сервиса.

Путь 2 — fallback через Settings.DefaultEwsServiceId:

TryToGetDefaultService() читает Settings.DefaultEwsServiceId. Если значение NULL — возвращает false и сервис не добавляется.

Сценарий тихого отказа:

Если оба пути вернули 0 сервисов, GetAllExchangeServices() возвращает пустой список → ExchangeSubscriptionService не создаётся → Start() не вызывается → подписки не активируются. При этом никакого лога не пишется (пустой else-блок в RecreateSubscriptions()), нет записей в AutomationScriptsLog (Type=10). Симптом: событие в Outlook создано, комментарий в задачу не приходит, лог пустой.

Диагностика:

-- 1. Проверить DefaultEwsServiceId
SELECT DefaultEwsServiceId FROM Settings;

-- 2. Проверить, что EWS-сервисы вообще заведены
SELECT Id, ServiceId, EwsUrl, EwsLogin FROM EwsServiceSettings;

-- 3. Проверить привязку AD-профилей к EWS (наполнение DomainToEwsSettingsCache)
SELECT sp.Id, sp.IsActive, sp.ServiceId, ads.Id AS ADSettingsId
FROM SynchronizationProfiles sp
LEFT JOIN SynchronizationProfilesADSettings ads ON ads.SynchronizationProfileId = sp.Id;

Если DefaultEwsServiceId = NULL и AD-профилей нет — подписки не стартуют. Фикс:

UPDATE Settings SET DefaultEwsServiceId = <Id>  -- Id из EwsServiceSettings

После обновления требуется перезапуск приложения.

Как работает

  1. При старте подписывает всех не-уволенных с CalendarEwsDirectSync || DoSyncWithExchange
  2. ExchangeSubscriptionService.SubscribeToStreamingNotifications для каждого email
  3. Event types Calendar: Created, Deleted, Modified, Moved, Copied, FreeBusyChanged
  4. Event types Inbox: только NewMail
  5. StreamingSubscriptionConnection с ExchangeConnectionLifetime (default 1 мин)
  6. Reconnect при disconnect

Notification chain (end-to-end)

Exchange Server
   ↓ StreamingSubscription.OnNotificationEvent
ExchangeNotificationService.SubscriptionService_OnExchangeNotification()
   ├→ PubSub.Publish(CalendarEventChanged)          ← внутренние подписчики
   ├→ UserNotificationSender.Send(CalendarNotification)
   │     ↓ SignalR NotificationHub.Notify("Calendar", {userId, events[]})
   │     ↓ Браузер получает event и перечитывает agenda
   └→ ExchangeNotificationAppointmentsService.OnExchangeNotificationEvent()
         ↓ Определение роли (organizer / attendee)
         ↓ Create/Modify/Delete handler
         ↓ Appointment синхронизирован в БД 1Ф

Обработка событий в ExchangeNotificationAppointmentsService

Файл: TCClassLib/Synchronization/External/Calendars/Exchange/NotificationServices/ExchangeNotificationAppointmentsService.cs

  1. Events фильтруются (исключаются folder-level)
  2. Группируются по appointment ID (Moved = несколько events)
  3. Определяется root event type по приоритету: Moved > Modified > Created > Deleted
  4. Загружаются минимальные поля appointment для проверки eligibility
  5. Проверки: recurring master, excluded categories, возраст события
  6. Dispatch по роли:
Роль Created Modified Moved
Organizer CreateExternalAppointmentHandler ModifyExternalAppointmentHandler Create или Cancel (зависит от направления)
Attendee CreateExternalAppointmentHandler ModifyExternalAppointmentHandler ModifyExternalAppointmentAttendeeHandler (rejection)

Что НЕ покрыто streaming subscriptions

Gap Описание
Нет push «новое приглашение» Inbox подписка ловит только NewMail (email). Meeting request в Inbox НЕ генерирует отдельного calendar notification. Пользователь узнает о приглашении только при открытии вкладки «События» или из email-клиента
FreeBusyChanged Подписка есть, явный handler не найден — вероятно игнорируется
Copied Подписка есть, явного handler нет — fallback
Responses (accept/decline) Organizer видит обновлённый статус в calendar, но нет отдельного notification «Пользователь X ответил»

Параллельность

Parallel.ForEach с семафором ExchangeSemaphoreCount (default 50).

6. Messages / Unread

GetMessages (:1805-1956)

Работает только при: - CalendarInboxAccessEnabled = true - Owner = текущий пользователь

Ищет в WellKnownFolderName.Inbox по ItemClass:

ItemClass Тип Описание
IPM.Schedule.Meeting.Request Request Приглашение на встречу
IPM.Schedule.Meeting.Canceled Canceled Отмена встречи
IPM.Schedule.Meeting.Resp.* Response Ответ участника (с ProposedStart/End)

Сортировка: DateTimeReceived DESC.

GetUnreadMessagesCount (:1958-1986)

  • Делегирует в UsersMessageCountCache
  • Кэш 10 мин (MemoryCache)
  • Фильтр: IsRead = false, DateTimeReceived >= -14 дней, ItemClass = Meeting.Request/Canceled
  • Ищет в Inbox folder

Инвалидация

SendReadMessagesSignal → очистка кэша + SignalR OnReadMessage.

7. Вложения

Добавление/обновление (UpdateAttachments, :1033-1097)

  1. Сравнение текущих вложений с переданными
  2. Удаление отсутствующих
  3. Загрузка через UploadFiles.GetPreuploadedFiles(withContent: true)
  4. appointment.Attachments.AddFileAttachment(name, content)
  5. Проверка запрещённых расширений через ConfigurationService.CheckForbiddenFileExtension
  6. Поддержка attachment.FileId — загрузка из файлов 1Формы

Получение (GetAttachment, :1726-1768)

  • service.GetAttachmentsFileAttachmentCalendarAttachment с MemoryStream
  • Только при FullDetails permission
  • Множественные вложения → ZIP (CalendarProviderBase.GetAttachments)

8. ProcessHtml (:419-456)

  • Парсит HTML через HtmlAgilityPack
  • Удаляет div.forma-link (Remove1fContainer)
  • Извлекает <body> из полного HTML-документа
  • Удаляет legacy-маркер -- Linked task id: \d+

9. Настройки (CustomSettings)

Пул и таймауты

Ключ Default Назначение
ExchangeConnectionPoolSize 100 Размер семафора конкурентности
CalendarRequestTimeoutInMilliseconds (EwsRequestsTimeout) Таймаут на запрос списка
Таймаут семафора 2 сек (хардкод) SemaphoreTimeoutException при превышении
Таймаут сервиса 30 мин (хардкод) В фабрике

Streaming subscriptions

Ключ Default Назначение
EnableEwsSubscriptions true Вкл/выкл streaming subscriptions
EnableEwsEmailSubscriptions true Подписки на Inbox
EnableEwsCalendarInboxAccess true Доступ к папке Inbox
ExchangeConnectionLifetime 1 мин Время жизни streaming connection
ExchangeSemaphoreCount 50 Параллельные подписки
ExchangeSemaphoreWait 0 Таймаут семафора подписок

Поведение

Ключ Default Назначение
ExchangePermissionsCacheLifeTime 5 мин TTL кэша прав
EnableEwsSetDirectSyncDisabledWhenEwsErrorsOccurs true Автоотключение при ошибках
WriteEwsRequestDurationToAutomationLog false Логирование длительности
ExchangeSubscriptionsToLog false Подробное логирование подписок

10. PropertySet-ы (уровни доступа)

5 наборов для разных уровней (:145-273):

PropertySet Когда используется Что включает
_getEventsTimeonlyPropertySet Нет прав или ShowBusyStatus Id, Start/End, TimeZone, IsAllDay, FreeBusy
_getEventsTimeAndSubjectPropertySet Частичный доступ + Subject, Location
_getEventsShortSearchPropertySet Полный доступ, список Все поля кроме Body/Attendees
_getEventsReadFullListPropertySet То же = ShortSearch
_getEventFullPropertySet Открытие одного события Всё: Body, Attendees, Recurrence, Attachments

CalendarView limit: 5000 events.

11. All-Day Events и таймзоны

GetAdjustedAllDayEventDates (:1278-1307) — коррекция дат all-day events с учётом: - Таймзоны текущего пользователя - Таймзоны создателя - Таймзоны Exchange-сервиса

Проблемная область — причина многих багов при кросс-таймзонных сценариях.

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

# Проблема Детали
1 Нет OAuth Только Basic Auth + Impersonation. Проблема для Exchange Online / M365
2 Семафор, не пул Сервис создаётся на каждый Rent. Семафор только ограничивает конкурентность (100). Таймаут — 2 сек
3 ServerBusyException Нет retry-логики — логируется и возвращается пустой результат
4 BindToRecurringMaster тормозит Комментарий: "получение мастер встречи очень тормознутая операция". Отключено в списке, только при Get
5 Messages только для owner GetMessages работает только для текущего пользователя. Чужие inbox недоступны
6 CalendarView limit 5000 Хардкод. При превышении — обрезка
7 Streaming только на JobServer Если JobServer не запущен — нет push-уведомлений
8 BUG: UserInboxFolderIdCache.GetOriginalValues Copy-paste баг: GetOriginalValues читает CalendarFolderValue вместо InboxFolderValue (строка 131). При старте приложения кэш Inbox наполняется Calendar folder ID → GetMessages ищет meeting requests в Calendar folder → пустой результат. GetValueFromDatabase читает правильно — поэтому после expiry кэша (24ч) может заработать. ветка feature/2026629-fix-calendar-messages. Фикс: одна строка
9 Нет push о meeting request в Inbox Streaming subscription на Inbox ловит только NewMail. Вкладка «События» (/api/calendar/messages) — чистый поллинг по запросу. Нет realtime-уведомления «вам пришло приглашение на встречу»

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

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

Симптом Где смотреть
Пустая вкладка «События» 1) BUG #8: UserInboxFolderIdCache.GetOriginalValues подменяет Inbox → Calendar folder ID. 2) EnableEwsCalendarInboxAccess. 3) owner = current user. 4) /api/mail/find-messages работает → EWS connection ок, проблема в кэше
Нет push-обновлений EnableEwsSubscriptions, Configuration.IsJobServer. Логи ExchangeNotificationService
SemaphoreTimeoutException Нагрузка > 100 параллельных запросов. Увеличить ExchangeConnectionPoolSize
Автоотключение sync SyncWithExchangeFailedAttempts > CalendarExchangeRetryLimit. Reset через admin API
Приватные события не видны Sensitivity.Private — by design. FolderPermissionReadAccess уровень

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

-- Режим синхронизации пользователя
select u.UserID, u.UserName, u.Email, u.SID,
       u.CalendarEwsDirectSync, u.DoSyncWithExchange,
       u.SyncWithExchangeFailedAttempts, u.DomainController
from Users u
where u.UserID = @userId;

-- Кэш непрочитанных сообщений
select c.[Key], c.Value, c.UpdatedDate
from UsersMessageCountCache c
where c.[Key] = cast(@userId as varchar(50));

-- EWS-права пользователя на чужие папки
select f.UserID, f.FolderOwnerUserID, f.FolderId,
       p.ReadAccess, p.PermissionLevel
from EwsFolders f
join EwsFolderPermissions p on f.ID = p.EwsFolderID
where f.UserID = @userId;

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

  • admin.md — пошаговая настройка
  • docs/domains/calendar/runbook-exchange-user-sync.md — диагностика ошибок синхронизации
  • docs/domains/calendar/backend.md — общая архитектура провайдеров
  • docs/domains/calendar/data-flow.md — сквозные сценарии
  • docs/domains/calendar/provider-caldav.md — провайдер CalDAV