Провайдер 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 |
Маппинг EmailAddress → CalendarEventRecipient |
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
Приоритет: DomainController → DefaultEwsService.
Версия протокола¶
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)¶
- Новый
Appointment(service)→UpdateAppointmentValues→appointment.Save SendInvitationsMode:SendToAllAndSaveCopyесли есть attendees, иначеSendToNone- Impersonated →
WellKnownFolderName.Calendar, иначе →ExchangeFolderService.GetFolder
Update (:1235-1276)¶
GetAppointment→UpdateAppointmentValues→appointment.Update(ConflictResolutionMode.AlwaysOverwrite)SendInvitationsOrCancellationsModeзависит от attendees
Delete (:1696-1724)¶
GetAppointment → appointment.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
После обновления требуется перезапуск приложения.
Как работает¶
- При старте подписывает всех не-уволенных с
CalendarEwsDirectSync || DoSyncWithExchange ExchangeSubscriptionService.SubscribeToStreamingNotificationsдля каждого email- Event types Calendar:
Created, Deleted, Modified, Moved, Copied, FreeBusyChanged - Event types Inbox: только
NewMail StreamingSubscriptionConnectionсExchangeConnectionLifetime(default 1 мин)- 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
- Events фильтруются (исключаются folder-level)
- Группируются по appointment ID (Moved = несколько events)
- Определяется root event type по приоритету: Moved > Modified > Created > Deleted
- Загружаются минимальные поля appointment для проверки eligibility
- Проверки: recurring master, excluded categories, возраст события
- 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)¶
- Сравнение текущих вложений с переданными
- Удаление отсутствующих
- Загрузка через
UploadFiles.GetPreuploadedFiles(withContent: true) appointment.Attachments.AddFileAttachment(name, content)- Проверка запрещённых расширений через
ConfigurationService.CheckForbiddenFileExtension - Поддержка
attachment.FileId— загрузка из файлов 1Формы
Получение (GetAttachment, :1726-1768)¶
service.GetAttachments→FileAttachment→CalendarAttachmentс MemoryStream- Только при
FullDetailspermission - Множественные вложения → 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