diff --git a/.changeset/server-calendar-sync.md b/.changeset/server-calendar-sync.md new file mode 100644 index 0000000000000..843a7b8e6a1d3 --- /dev/null +++ b/.changeset/server-calendar-sync.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/models': minor +--- + +Added a server-to-server Outlook/Exchange calendar sync (Premium, `outlook-calendar` module). A new `Calendar_Sync` admin settings group configures a scheduled server-side synchronization of users' calendars using the Microsoft Graph API with the OAuth 2.0 client credentials flow — users no longer need to authenticate against Outlook from their own clients. Synced events flow through the existing calendar service, driving the same reminders and busy-presence behavior as the client-based integration, which remains available and unchanged. Two providers are available: Microsoft Graph for Exchange Online / Microsoft 365, and Exchange Web Services (NTLM or Basic authentication with a service account holding the ApplicationImpersonation role) for on-premises Exchange Server — the EWS provider never contacts Microsoft cloud endpoints, making it suitable for air-gapped deployments. Includes delta-query incremental sync (Graph), throttling-aware retries, per-user sync state with diagnostics fields, and mailbox mapping via verified email or a user custom field. A privacy-minimizing free/busy-only mode drives user presence from availability alone without ingesting event subjects or details (needing only the `Calendars.ReadBasic` application permission on Graph). Admin REST endpoints (`calendar-sync.test-connection`, `calendar-sync.run`, `calendar-sync.status`, `calendar-sync.user-status`, gated by a new `manage-calendar-sync` permission) cover connection validation with actionable errors, manual sync runs, and per-user diagnostics; a test-connection button is available directly in the settings group. Also included: Microsoft national clouds (GCC High/DoD), certificate credentials as an alternative to client secrets, role-based sync scoping, EWS incremental sync via SyncFolderItems, and optional Graph change-notification webhooks with automatic subscription lifecycle management for near-instant updates on publicly reachable workspaces. diff --git a/apps/meteor/ee/server/api/calendarSync/index.ts b/apps/meteor/ee/server/api/calendarSync/index.ts new file mode 100644 index 0000000000000..8f0db565c08d7 --- /dev/null +++ b/apps/meteor/ee/server/api/calendarSync/index.ts @@ -0,0 +1,185 @@ +import { CalendarSyncState } from '@rocket.chat/models'; +import { validateForbiddenErrorResponse, validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings'; + +import { + CalendarSyncGenericErrorSchema, + CalendarSyncWebhookAcceptedResponseSchema, + CalendarSyncWebhookValidationResponseSchema, + GETCalendarSyncStatusResponseSchema, + GETCalendarSyncUserStatusQuerySchema, + GETCalendarSyncUserStatusResponseSchema, + GETCalendarSyncWebhookQuerySchema, + POSTCalendarSyncRunResponseSchema, + POSTCalendarSyncTestConnectionBodySchema, + POSTCalendarSyncTestConnectionResponseSchema, +} from './schemas'; +import { settings } from '../../../../app/settings/server'; +import { API } from '../../../../server/api'; +import type { ExtractRoutesFromAPI } from '../../../../server/api/ApiClass'; +import { getConfiguredProvider } from '../../lib/calendarSync/factory'; +import { calendarSyncEngine } from '../../lib/calendarSync/startup'; +import type { IGraphNotificationPayload } from '../../lib/calendarSync/webhooks'; +import { processGraphNotifications } from '../../lib/calendarSync/webhooks'; + +const calendarSyncEndpoints = API.v1 + .post( + 'calendar-sync.test-connection', + { + authRequired: true, + permissionsRequired: ['manage-calendar-sync'], + license: ['outlook-calendar'], + body: POSTCalendarSyncTestConnectionBodySchema, + response: { + 200: POSTCalendarSyncTestConnectionResponseSchema, + 400: CalendarSyncGenericErrorSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const provider = getConfiguredProvider(); + if (!provider) { + return API.v1.failure('calendar-sync-provider-not-configured'); + } + + // testConnection never throws; it reports a sanitized, actionable error + const connection = await provider.testConnection(this.bodyParams.probeMailbox); + return API.v1.success({ connection }); + }, + ) + .post( + 'calendar-sync.run', + { + authRequired: true, + permissionsRequired: ['manage-calendar-sync'], + license: ['outlook-calendar'], + response: { + 200: POSTCalendarSyncRunResponseSchema, + 400: CalendarSyncGenericErrorSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + if (!settings.get('CalendarSync_Enabled')) { + return API.v1.failure('calendar-sync-not-enabled'); + } + if (!getConfiguredProvider()) { + return API.v1.failure('calendar-sync-provider-not-configured'); + } + + // Sync runs can take minutes on large workspaces; kick it off and return. + // Progress is visible via calendar-sync.status. + const started = !calendarSyncEngine.isRunning(); + if (started) { + void calendarSyncEngine.runSync(); + } + + return API.v1.success({ started }); + }, + ) + .get( + 'calendar-sync.status', + { + authRequired: true, + permissionsRequired: ['manage-calendar-sync'], + license: ['outlook-calendar'], + response: { + 200: GETCalendarSyncStatusResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const [total, failing] = await Promise.all([ + CalendarSyncState.countDocuments({}), + CalendarSyncState.countDocuments({ consecutiveFailures: { $gt: 0 } }), + ]); + + return API.v1.success({ + lastRun: calendarSyncEngine.getLastRunSummary(), + states: { total, failing }, + }); + }, + ) + .post( + 'calendar-sync.webhook', + { + // Called by Microsoft's cloud, not by users; notifications are authenticated + // against the per-subscription clientState secret stored in the sync state + authRequired: false, + // Microsoft batches and retries aggressively; the default per-IP limiter would drop bursts + rateLimiterOptions: false, + query: GETCalendarSyncWebhookQuerySchema, + response: { + 200: CalendarSyncWebhookValidationResponseSchema, + 202: CalendarSyncWebhookAcceptedResponseSchema, + }, + }, + async function action() { + // Subscription validation handshake: echo the token back as plain text + const { validationToken } = this.queryParams; + if (typeof validationToken === 'string' && validationToken) { + return { + statusCode: 200 as const, + headers: { 'content-type': 'text/plain' }, + body: validationToken, + }; + } + + // Always accept quickly (Graph requires a response within a few seconds); + // processing is authenticated per notification and runs in the background + if (settings.get('CalendarSync_Webhooks_Enabled') === true) { + const payload = this.bodyParams as IGraphNotificationPayload; + const { logger } = this; + setImmediate(() => { + void processGraphNotifications(payload, calendarSyncEngine, logger).catch((error) => + logger.error(`Unable to process calendar change notifications: ${(error as Error).message}`), + ); + }); + } + + return { + statusCode: 202 as const, + headers: { 'content-type': 'text/plain' }, + body: '', + }; + }, + ) + .get( + 'calendar-sync.user-status', + { + authRequired: true, + permissionsRequired: ['manage-calendar-sync'], + license: ['outlook-calendar'], + query: GETCalendarSyncUserStatusQuerySchema, + response: { + 200: GETCalendarSyncUserStatusResponseSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const state = await CalendarSyncState.findOneByUserId(this.queryParams.userId); + + return API.v1.success({ + // The delta token is deliberately omitted: it is bulky transport state, not a diagnostic + state: state && { + uid: state.uid, + mailbox: state.mailbox, + provider: state.provider, + ...(state.lastSyncAt && { lastSyncAt: state.lastSyncAt }), + ...(state.lastSuccessAt && { lastSuccessAt: state.lastSuccessAt }), + ...(state.lastError && { lastError: state.lastError }), + consecutiveFailures: state.consecutiveFailures ?? 0, + }, + }); + }, + ); + +export type CalendarSyncEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends CalendarSyncEndpoints {} +} diff --git a/apps/meteor/ee/server/api/calendarSync/schemas.ts b/apps/meteor/ee/server/api/calendarSync/schemas.ts new file mode 100644 index 0000000000000..e085ab086c6ec --- /dev/null +++ b/apps/meteor/ee/server/api/calendarSync/schemas.ts @@ -0,0 +1,156 @@ +import { ajv, ajvQuery } from '@rocket.chat/rest-typings'; + +const errorPair = { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['code', 'message'], + additionalProperties: false, +}; + +export const POSTCalendarSyncTestConnectionBodySchema = ajv.compile<{ probeMailbox?: string }>({ + type: 'object', + properties: { + probeMailbox: { type: 'string', minLength: 3, maxLength: 320 }, + }, + additionalProperties: false, +}); + +export const POSTCalendarSyncTestConnectionResponseSchema = ajv.compile<{ + connection: { ok: boolean; error?: { code: string; message: string } }; +}>({ + type: 'object', + properties: { + connection: { + type: 'object', + properties: { + ok: { type: 'boolean' }, + error: errorPair, + }, + required: ['ok'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['connection', 'success'], + additionalProperties: false, +}); + +export const POSTCalendarSyncRunResponseSchema = ajv.compile<{ started: boolean }>({ + type: 'object', + properties: { + started: { type: 'boolean' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['started', 'success'], + additionalProperties: false, +}); + +const runSummary = { + type: 'object', + properties: { + startedAt: { type: 'string' }, + durationMs: { type: 'number' }, + usersProcessed: { type: 'number' }, + usersSkippedNoMailbox: { type: 'number' }, + usersFailed: { type: 'number' }, + eventsUpserted: { type: 'number' }, + eventsDeleted: { type: 'number' }, + }, + required: ['startedAt', 'durationMs', 'usersProcessed', 'usersSkippedNoMailbox', 'usersFailed', 'eventsUpserted', 'eventsDeleted'], + additionalProperties: false, +}; + +export const GETCalendarSyncStatusResponseSchema = ajv.compile<{ + lastRun: unknown; + states: { total: number; failing: number }; +}>({ + type: 'object', + properties: { + lastRun: { anyOf: [runSummary, { type: 'null' }] }, + states: { + type: 'object', + properties: { + total: { type: 'number' }, + failing: { type: 'number' }, + }, + required: ['total', 'failing'], + additionalProperties: false, + }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['lastRun', 'states', 'success'], + additionalProperties: false, +}); + +export const GETCalendarSyncUserStatusQuerySchema = ajvQuery.compile<{ userId: string }>({ + type: 'object', + properties: { + userId: { type: 'string', minLength: 1 }, + }, + required: ['userId'], + additionalProperties: false, +}); + +const userSyncState = { + type: 'object', + properties: { + uid: { type: 'string' }, + mailbox: { type: 'string' }, + provider: { type: 'string', enum: ['microsoft-graph', 'exchange-ews'] }, + lastSyncAt: { type: 'string' }, + lastSuccessAt: { type: 'string' }, + consecutiveFailures: { type: 'number' }, + lastError: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + at: { type: 'string' }, + }, + required: ['code', 'message'], + additionalProperties: false, + }, + }, + required: ['uid', 'mailbox', 'provider'], + additionalProperties: false, +}; + +export const GETCalendarSyncUserStatusResponseSchema = ajv.compile<{ state: unknown }>({ + type: 'object', + properties: { + state: { anyOf: [userSyncState, { type: 'null' }] }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['state', 'success'], + additionalProperties: false, +}); + +export const GETCalendarSyncWebhookQuerySchema = ajvQuery.compile<{ validationToken?: string }>({ + type: 'object', + properties: { + validationToken: { type: 'string' }, + }, + additionalProperties: true, +}); + +/** + * Both webhook responses are plain text: the validation handshake echoes the token, + * notification deliveries get an empty 202 (Graph only checks the status code). + */ +export const CalendarSyncWebhookValidationResponseSchema = ajv.compile({ type: 'string' }); + +export const CalendarSyncWebhookAcceptedResponseSchema = ajv.compile({ type: 'string' }); + +export const CalendarSyncGenericErrorSchema = ajv.compile<{ success: boolean; error?: string }>({ + type: 'object', + properties: { + success: { type: 'boolean', enum: [false] }, + error: { type: 'string' }, + errorType: { type: 'string' }, + }, + required: ['success'], + additionalProperties: true, +}); diff --git a/apps/meteor/ee/server/api/index.ts b/apps/meteor/ee/server/api/index.ts index 35e8b0f0f60df..b8f9faf495a6e 100644 --- a/apps/meteor/ee/server/api/index.ts +++ b/apps/meteor/ee/server/api/index.ts @@ -8,3 +8,4 @@ import '../apps/communication/uikit'; import './engagementDashboard'; import './audit'; import './abac'; +import './calendarSync'; diff --git a/apps/meteor/ee/server/configuration/calendarSync.ts b/apps/meteor/ee/server/configuration/calendarSync.ts new file mode 100644 index 0000000000000..d9a73e247fc7f --- /dev/null +++ b/apps/meteor/ee/server/configuration/calendarSync.ts @@ -0,0 +1,13 @@ +import { License } from '@rocket.chat/license'; +import { Meteor } from 'meteor/meteor'; + +import { configureCalendarSync, createPermissions } from '../lib/calendarSync/startup'; +import { addSettings } from '../settings/calendarSync'; + +Meteor.startup(() => + License.onLicense('outlook-calendar', async () => { + addSettings(); + await createPermissions(); + await configureCalendarSync(); + }), +); diff --git a/apps/meteor/ee/server/configuration/index.ts b/apps/meteor/ee/server/configuration/index.ts index 8e422c47cc139..ac24db8608f62 100644 --- a/apps/meteor/ee/server/configuration/index.ts +++ b/apps/meteor/ee/server/configuration/index.ts @@ -1,3 +1,4 @@ +import './calendarSync'; import './contact-verification'; import './ldap'; import './oauth'; diff --git a/apps/meteor/ee/server/index.ts b/apps/meteor/ee/server/index.ts index e6b0cd350d530..2ef882ce27238 100644 --- a/apps/meteor/ee/server/index.ts +++ b/apps/meteor/ee/server/index.ts @@ -10,6 +10,7 @@ import '../app/settings/server/index'; import './requestSeatsRoute'; import './configuration/index'; import './local-services/ldap/service'; +import './meteor-methods/calendarSyncTestConnection'; import './meteor-methods/getReadReceipts'; import './patches'; import './hooks/federation'; diff --git a/apps/meteor/ee/server/lib/calendarSync/CalendarSyncEngine.ts b/apps/meteor/ee/server/lib/calendarSync/CalendarSyncEngine.ts new file mode 100644 index 0000000000000..87ac3977cdaf2 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/CalendarSyncEngine.ts @@ -0,0 +1,507 @@ +import { randomUUID } from 'crypto'; + +import { Calendar, Presence } from '@rocket.chat/core-services'; +import type { ICalendarSyncState, IUser } from '@rocket.chat/core-typings'; +import { UserStatus } from '@rocket.chat/core-typings'; +import { CalendarEvent, CalendarSyncState, Users } from '@rocket.chat/models'; + +import type { ICalendarSyncProvider, ICalendarSyncWindow, IExternalCalendarEvent, IFreeBusyInterval } from './definition'; +import { CalendarSyncError } from './definition'; +import { sanitizeError } from './logSanitizer'; +import type { MailboxSource } from './mailboxResolver'; +import { resolveMailbox } from './mailboxResolver'; +import { i18n } from '../../../../server/lib/i18n'; + +export interface ICalendarSyncEngineConfig { + mode: 'full-events' | 'free-busy-only'; + windowDays: number; + batchSize: number; + presenceEnabled: boolean; + mailboxSource: MailboxSource; + mailboxCustomField: string; + defaultLanguage: string; + /** Restrict sync to users holding any of these roles; empty = all active users */ + roles: string[]; + /** Change-notification webhooks (optional optimization; polling always continues) */ + webhooksEnabled: boolean; + /** Public URL of the calendar-sync.webhook endpoint; empty when not derivable */ + webhookUrl: string; +} + +export interface ICalendarSyncRunSummary { + startedAt: Date; + durationMs: number; + usersProcessed: number; + usersSkippedNoMailbox: number; + usersFailed: number; + eventsUpserted: number; + eventsDeleted: number; +} + +interface ILoggerLike { + debug(...args: unknown[]): void; + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; +} + +/** Keep using a delta token while the requested window still fits its epoch window */ +const DELTA_EPOCH_BUFFER_MS = 24 * 60 * 60 * 1000; + +/** Availability lookahead in free/busy-only mode; long meetings keep extending on later runs */ +const FREE_BUSY_WINDOW_HOURS = 4; + +/** Shared presence-claim id — must match the legacy CalendarService's claim so both paths compose */ +const CALENDAR_STATUS_ID = 'calendar'; + +/** Renew change-notification subscriptions when they have less than this left */ +const SUBSCRIPTION_RENEW_THRESHOLD_MS = 24 * 60 * 60 * 1000; + +type SyncableUser = Pick; + +/** + * Returns when the user's current busy block ends, merging back-to-back intervals, + * or null when the user is not busy at `now`. Expects no particular input order. + */ +export function computeBusyUntil(intervals: IFreeBusyInterval[], now: Date): Date | null { + const sorted = [...intervals].sort((a, b) => a.start.getTime() - b.start.getTime()); + + let busyUntil: Date | null = null; + for (const interval of sorted) { + const current: Date | null = busyUntil; + const coversNow = interval.start <= now && interval.end > now; + const extendsBlock = current !== null && interval.start <= current && interval.end > current; + if ((coversNow || extendsBlock) && (current === null || interval.end > current)) { + busyUntil = interval.end; + } + } + + return busyUntil; +} + +export class CalendarSyncEngine { + private running = false; + + private lastRunSummary: ICalendarSyncRunSummary | null = null; + + constructor( + private readonly getProvider: () => ICalendarSyncProvider | null, + private readonly getConfig: () => ICalendarSyncEngineConfig, + private readonly logger: ILoggerLike, + ) {} + + public getLastRunSummary(): ICalendarSyncRunSummary | null { + return this.lastRunSummary; + } + + public isRunning(): boolean { + return this.running; + } + + public async runSync(): Promise { + if (this.running) { + this.logger.warn('Previous calendar sync run still in progress; skipping this cycle'); + return null; + } + + this.running = true; + try { + return await this.doRun(); + } finally { + this.running = false; + } + } + + private async doRun(): Promise { + const provider = this.getProvider(); + if (!provider) { + this.logger.warn('Calendar sync is enabled but the configured provider is not available'); + return null; + } + + const config = this.getConfig(); + if (config.mode === 'free-busy-only' && !config.presenceEnabled) { + this.logger.warn('Calendar sync is in free/busy-only mode but presence updates are disabled; nothing to do'); + return null; + } + + const startedAt = new Date(); + const summary: ICalendarSyncRunSummary = { + startedAt, + durationMs: 0, + usersProcessed: 0, + usersSkippedNoMailbox: 0, + usersFailed: 0, + eventsUpserted: 0, + eventsDeleted: 0, + }; + + // Free/busy only needs to know whether users are busy right now (and until when); + // full event sync uses the admin-configured rolling window + const window: ICalendarSyncWindow = { + start: startedAt, + end: + config.mode === 'free-busy-only' + ? new Date(startedAt.getTime() + FREE_BUSY_WINDOW_HOURS * 60 * 60 * 1000) + : new Date(startedAt.getTime() + config.windowDays * 24 * 60 * 60 * 1000), + }; + + const projection: Record = { emails: 1, username: 1, language: 1 }; + if (config.mailboxSource === 'custom-field' && config.mailboxCustomField) { + projection[`customFields.${config.mailboxCustomField}`] = 1; + } + + const cursor = Users.find( + { active: true, type: 'user', ...(config.roles.length && { roles: { $in: config.roles } }) }, + { projection }, + ); + + const batchSize = Math.max(1, config.batchSize); + let batch: SyncableUser[] = []; + + const flush = async (): Promise => { + if (!batch.length) { + return; + } + const users = batch; + batch = []; + if (config.mode === 'free-busy-only') { + await this.syncBatchFreeBusy(users, provider, window, config, summary); + return; + } + await Promise.all(users.map((user) => this.syncUser(user, provider, window, config, summary))); + }; + + for await (const user of cursor) { + batch.push(user); + if (batch.length >= batchSize) { + await flush(); + } + } + await flush(); + + summary.durationMs = Date.now() - startedAt.getTime(); + this.lastRunSummary = summary; + this.logger.info( + `Calendar sync run finished in ${summary.durationMs}ms: ${summary.usersProcessed} synced, ` + + `${summary.usersSkippedNoMailbox} without mailbox, ${summary.usersFailed} failed, ` + + `${summary.eventsUpserted} events upserted, ${summary.eventsDeleted} deleted`, + ); + + return summary; + } + + private async syncUser( + user: Pick, + provider: ICalendarSyncProvider, + window: ICalendarSyncWindow, + config: ICalendarSyncEngineConfig, + summary: ICalendarSyncRunSummary, + ): Promise { + const mailbox = resolveMailbox(user, config.mailboxSource, config.mailboxCustomField); + if (!mailbox) { + summary.usersSkippedNoMailbox++; + return; + } + + try { + const state = await CalendarSyncState.findOneByUserId(user._id); + const deltaToken = this.getUsableDeltaToken(provider, state, mailbox, window); + + // When establishing a new delta epoch, extend the window so the token stays + // usable for subsequent runs until the rolling window outgrows the buffer + const effectiveWindow: ICalendarSyncWindow = deltaToken + ? window + : { start: window.start, end: new Date(window.end.getTime() + DELTA_EPOCH_BUFFER_MS) }; + + const result = await provider.listEvents(mailbox, effectiveWindow, deltaToken); + + const seenExternalIds = new Set(); + for (const event of result.events) { + if (event.isCancelled) { + summary.eventsDeleted += await this.deleteByExternalId(user._id, event.externalId); + continue; + } + seenExternalIds.add(event.externalId); + await this.upsertEvent(user._id, provider, event, config); + summary.eventsUpserted++; + } + + for (const externalId of result.deletedEventIds) { + summary.eventsDeleted += await this.deleteByExternalId(user._id, externalId); + } + + if (result.full) { + summary.eventsDeleted += await this.deleteMissingEvents(user._id, effectiveWindow, seenExternalIds); + } + + await CalendarSyncState.recordSuccess(user._id, { + mailbox, + provider: provider.type, + at: new Date(), + ...(result.nextDeltaToken !== undefined && { + deltaToken: result.nextDeltaToken, + deltaWindowStart: result.full ? effectiveWindow.start : state?.deltaWindowStart, + deltaWindowEnd: result.full ? effectiveWindow.end : state?.deltaWindowEnd, + }), + }); + + await this.maintainSubscription(user._id, mailbox, provider, state, config); + + summary.usersProcessed++; + } catch (error) { + summary.usersFailed++; + const sanitized = sanitizeError(error); + this.logger.error(`Calendar sync failed for user ${user._id}: [${sanitized.code}] ${sanitized.message}`); + await CalendarSyncState.recordFailure(user._id, { + mailbox, + provider: provider.type, + error: { ...sanitized, at: new Date() }, + }).catch((stateError) => this.logger.error(`Unable to record calendar sync failure for user ${user._id}`, stateError)); + } + } + + /** + * Immediately re-syncs a single user, used by change-notification webhooks. + * Returns false when the user is out of scope or the sync failed. + */ + public async syncUserById(uid: IUser['_id']): Promise { + const provider = this.getProvider(); + const config = this.getConfig(); + if (!provider || config.mode !== 'full-events') { + return false; + } + + const projection: Record = { emails: 1, username: 1, language: 1 }; + if (config.mailboxSource === 'custom-field' && config.mailboxCustomField) { + projection[`customFields.${config.mailboxCustomField}`] = 1; + } + + const user = await Users.findOneById(uid, { projection }); + if (!user) { + return false; + } + + const now = new Date(); + const window: ICalendarSyncWindow = { + start: now, + end: new Date(now.getTime() + config.windowDays * 24 * 60 * 60 * 1000), + }; + const summary: ICalendarSyncRunSummary = { + startedAt: now, + durationMs: 0, + usersProcessed: 0, + usersSkippedNoMailbox: 0, + usersFailed: 0, + eventsUpserted: 0, + eventsDeleted: 0, + }; + + await this.syncUser(user, provider, window, config, summary); + return summary.usersProcessed === 1; + } + + /** + * Keeps the user's change-notification subscription alive: creates one when + * missing, renews it inside the threshold. Failures are logged, never thrown — + * webhooks are an optimization on top of polling, not a dependency. + */ + private async maintainSubscription( + uid: IUser['_id'], + mailbox: string, + provider: ICalendarSyncProvider, + state: ICalendarSyncState | null, + config: ICalendarSyncEngineConfig, + ): Promise { + if (!config.webhooksEnabled || !config.webhookUrl || !provider.supportsWebhooks) { + return; + } + if (!provider.createSubscription || !provider.renewSubscription) { + return; + } + + try { + const remainingMs = state?.subscriptionExpiresAt ? state.subscriptionExpiresAt.getTime() - Date.now() : 0; + if (state?.subscriptionId && remainingMs > SUBSCRIPTION_RENEW_THRESHOLD_MS) { + return; + } + + if (state?.subscriptionId && state.subscriptionClientState && remainingMs > 0) { + try { + const renewed = await provider.renewSubscription(state.subscriptionId); + await CalendarSyncState.setSubscription(uid, { + id: renewed.id, + expiresAt: renewed.expiresAt, + clientState: state.subscriptionClientState, + }); + return; + } catch { + // expired or gone on the provider side — recreate below + } + } + + const clientState = randomUUID(); + const created = await provider.createSubscription(mailbox, config.webhookUrl, clientState); + await CalendarSyncState.setSubscription(uid, { id: created.id, expiresAt: created.expiresAt, clientState }); + } catch (error) { + const sanitized = sanitizeError(error); + this.logger.warn(`Unable to maintain the calendar subscription for user ${uid}: [${sanitized.code}] ${sanitized.message}`); + } + } + + /** + * Free/busy-only mode: one availability request per user batch drives presence + * directly, without ever ingesting or storing event subjects/details. + */ + private async syncBatchFreeBusy( + users: SyncableUser[], + provider: ICalendarSyncProvider, + window: ICalendarSyncWindow, + config: ICalendarSyncEngineConfig, + summary: ICalendarSyncRunSummary, + ): Promise { + const entries = users.flatMap((user) => { + const mailbox = resolveMailbox(user, config.mailboxSource, config.mailboxCustomField); + if (!mailbox) { + summary.usersSkippedNoMailbox++; + return []; + } + return [{ user, mailbox }]; + }); + + if (!entries.length) { + return; + } + + let results; + try { + results = await provider.getFreeBusy( + entries.map((entry) => entry.mailbox), + window, + ); + } catch (error) { + const sanitized = sanitizeError(error); + this.logger.error(`Calendar free/busy sync failed for a batch of ${entries.length} users: [${sanitized.code}] ${sanitized.message}`); + const at = new Date(); + await Promise.all( + entries.map(({ user, mailbox }) => { + summary.usersFailed++; + return CalendarSyncState.recordFailure(user._id, { mailbox, provider: provider.type, error: { ...sanitized, at } }).catch( + (stateError) => this.logger.error(`Unable to record calendar sync failure for user ${user._id}`, stateError), + ); + }), + ); + return; + } + + const byMailbox = new Map(results.map((result) => [result.mailbox.toLowerCase(), result])); + const now = new Date(); + + await Promise.all( + entries.map(async ({ user, mailbox }) => { + const result = byMailbox.get(mailbox.toLowerCase()); + try { + if (!result || result.error) { + throw new CalendarSyncError( + result?.error?.code ?? 'missing-availability', + result?.error?.message ?? 'no availability returned', + ); + } + + await this.applyFreeBusyPresence(user, result.intervals, now, config); + await CalendarSyncState.recordSuccess(user._id, { mailbox, provider: provider.type, at: now }); + summary.usersProcessed++; + } catch (error) { + summary.usersFailed++; + const sanitized = sanitizeError(error); + this.logger.error(`Calendar free/busy sync failed for user ${user._id}: [${sanitized.code}] ${sanitized.message}`); + await CalendarSyncState.recordFailure(user._id, { mailbox, provider: provider.type, error: { ...sanitized, at: now } }).catch( + (stateError) => this.logger.error(`Unable to record calendar sync failure for user ${user._id}`, stateError), + ); + } + }), + ); + } + + /** Applies (or ends) the same presence claim the legacy calendar service uses */ + private async applyFreeBusyPresence( + user: SyncableUser, + intervals: IFreeBusyInterval[], + now: Date, + config: ICalendarSyncEngineConfig, + ): Promise { + const busyUntil = computeBusyUntil(intervals, now); + + if (!busyUntil) { + await Presence.endActiveState(user._id, CALENDAR_STATUS_ID); + return; + } + + await Presence.setActiveState(user._id, { + statusDefault: UserStatus.BUSY, + statusText: i18n.t('Presence_status_outlook_in_a_meeting', { lng: user.language || config.defaultLanguage }), + statusSource: 'external', + statusExpiresAt: busyUntil, + statusId: CALENDAR_STATUS_ID, + }); + } + + private getUsableDeltaToken( + provider: ICalendarSyncProvider, + state: ICalendarSyncState | null, + mailbox: string, + window: ICalendarSyncWindow, + ): string | undefined { + if (!provider.supportsDelta || !state?.deltaToken) { + return undefined; + } + if (state.provider !== provider.type || state.mailbox !== mailbox) { + return undefined; + } + if (!state.deltaWindowEnd || window.end.getTime() > state.deltaWindowEnd.getTime()) { + return undefined; + } + return state.deltaToken; + } + + private async upsertEvent( + uid: IUser['_id'], + provider: ICalendarSyncProvider, + event: IExternalCalendarEvent, + config: ICalendarSyncEngineConfig, + ): Promise { + await Calendar.import({ + uid, + externalId: event.externalId, + subject: event.subject, + description: event.description, + startTime: event.startTime, + endTime: event.endTime, + busy: config.presenceEnabled ? event.busy : false, + provider: provider.type, + ...(event.iCalUId && { iCalUId: event.iCalUId }), + ...(event.meetingUrl && { meetingUrl: event.meetingUrl }), + }); + } + + private async deleteByExternalId(uid: IUser['_id'], externalId: string): Promise { + const event = await CalendarEvent.findOneByExternalIdAndUserId(externalId, uid); + // Only remove events owned by server sync; a provider-id collision must never delete client-pushed events + if (!event?.provider) { + return 0; + } + await Calendar.delete(event._id); + return 1; + } + + private async deleteMissingEvents(uid: IUser['_id'], window: ICalendarSyncWindow, seenExternalIds: Set): Promise { + let deleted = 0; + for await (const event of CalendarEvent.findServerSyncedByUserIdBetweenDates(uid, window.start, window.end)) { + if (event.externalId && !seenExternalIds.has(event.externalId)) { + await Calendar.delete(event._id); + deleted++; + } + } + return deleted; + } +} diff --git a/apps/meteor/ee/server/lib/calendarSync/definition.ts b/apps/meteor/ee/server/lib/calendarSync/definition.ts new file mode 100644 index 0000000000000..0bb298c8b1355 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/definition.ts @@ -0,0 +1,101 @@ +import type { CalendarSyncProviderType } from '@rocket.chat/core-typings'; + +export interface ICalendarSyncWindow { + start: Date; + end: Date; +} + +export interface IExternalCalendarEvent { + /** Provider-stable event id, used as the upsert key (`externalId`) */ + externalId: string; + /** iCalendar UID, stable across providers when available */ + iCalUId?: string; + subject: string; + description: string; + startTime: Date; + endTime: Date; + busy: boolean; + meetingUrl?: string; + isCancelled?: boolean; +} + +export interface ICalendarSyncListResult { + events: IExternalCalendarEvent[]; + /** Provider event ids reported as removed (delta/incremental results only) */ + deletedEventIds: string[]; + nextDeltaToken?: string; + /** True when `events` is a complete snapshot of the window (enables deletion diffing) */ + full: boolean; +} + +export type FreeBusyStatus = 'busy' | 'tentative' | 'oof'; + +export interface IFreeBusyInterval { + start: Date; + end: Date; + status: FreeBusyStatus; +} + +export interface IFreeBusyResult { + mailbox: string; + intervals: IFreeBusyInterval[]; + error?: { code: string; message: string }; +} + +export interface IConnectionTestResult { + ok: boolean; + error?: { code: string; message: string }; +} + +export interface ICalendarSubscription { + id: string; + expiresAt: Date; +} + +export interface ICalendarSyncProvider { + readonly type: CalendarSyncProviderType; + readonly supportsDelta: boolean; + readonly supportsWebhooks: boolean; + + /** With `probeMailbox` the check also proves calendar access/impersonation on that mailbox */ + testConnection(probeMailbox?: string): Promise; + getFreeBusy(mailboxes: string[], window: ICalendarSyncWindow): Promise; + listEvents(mailbox: string, window: ICalendarSyncWindow, deltaToken?: string): Promise; + + /** Change-notification subscriptions; only present when supportsWebhooks is true */ + createSubscription?(mailbox: string, notificationUrl: string, clientState: string): Promise; + renewSubscription?(subscriptionId: string): Promise; + deleteSubscription?(subscriptionId: string): Promise; +} + +export class CalendarSyncError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'CalendarSyncError'; + } +} + +/** + * Minimal response surface consumed by the providers; matches the shape returned + * by @rocket.chat/server-fetch so a plain stub can be injected in unit tests. + */ +export interface IMinimalFetchResponse { + ok: boolean; + status: number; + headers: { get(name: string): string | null }; + json(): Promise; + text(): Promise; +} + +export type CalendarSyncFetchFn = ( + url: string, + options?: { + method?: string; + headers?: Record; + body?: string; + timeout?: number; + }, +) => Promise; diff --git a/apps/meteor/ee/server/lib/calendarSync/factory.ts b/apps/meteor/ee/server/lib/calendarSync/factory.ts new file mode 100644 index 0000000000000..0550e590cdece --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/factory.ts @@ -0,0 +1,84 @@ +import { serverFetch } from '@rocket.chat/server-fetch'; + +import type { CalendarSyncFetchFn, ICalendarSyncProvider } from './definition'; +import { ExchangeEwsCalendarProvider } from './providers/ews/ExchangeEwsCalendarProvider'; +import { MicrosoftGraphCalendarProvider } from './providers/graph/MicrosoftGraphCalendarProvider'; +import { GRAPH_CLOUDS, resolveGraphCloud } from './providers/graph/clouds'; +import { settings } from '../../../../app/settings/server'; + +// The provider only ever targets the hardcoded Microsoft cloud hosts, so SSRF +// validation (meant for user-supplied URLs) does not apply — same as the Slack bridge +const fetchAdapter: CalendarSyncFetchFn = (url, options) => serverFetch(url, { ...options, ignoreSsrfValidation: true }); + +let cached: { key: string; provider: ICalendarSyncProvider } | null = null; + +/** + * Builds (and caches) the sync provider for the current settings. The cache keeps + * the Graph token cache warm across sync runs; it is invalidated whenever any of + * the credential settings change. Returns null when no provider is configured — + * note the Microsoft Graph provider is only ever instantiated when explicitly + * selected, so no Microsoft-cloud endpoint is ever contacted otherwise (air-gap + * requirement for on-prem EWS deployments). + */ +export function getConfiguredProvider(): ICalendarSyncProvider | null { + const type = settings.get('CalendarSync_Provider'); + + if (type === 'microsoft-graph') { + const tenantId = settings.get('CalendarSync_Graph_TenantId')?.trim(); + const clientId = settings.get('CalendarSync_Graph_ClientId')?.trim(); + const authMethod = + settings.get('CalendarSync_Graph_Auth_Method') === 'certificate' ? ('certificate' as const) : ('client-secret' as const); + const clientSecret = settings.get('CalendarSync_Graph_ClientSecret'); + const certificatePem = settings.get('CalendarSync_Graph_Certificate'); + const privateKeyPem = settings.get('CalendarSync_Graph_PrivateKey'); + const cloud = resolveGraphCloud(settings.get('CalendarSync_Graph_Cloud')); + + if (!tenantId || !clientId) { + return null; + } + if (authMethod === 'certificate' ? !certificatePem || !privateKeyPem : !clientSecret) { + return null; + } + + const key = JSON.stringify([type, tenantId, clientId, authMethod, clientSecret, certificatePem, privateKeyPem, cloud]); + if (cached?.key !== key) { + cached = { + key, + provider: new MicrosoftGraphCalendarProvider( + { + tenantId, + clientId, + authMethod, + ...(authMethod === 'certificate' ? { certificatePem, privateKeyPem } : { clientSecret }), + ...GRAPH_CLOUDS[cloud], + }, + fetchAdapter, + ), + }; + } + return cached.provider; + } + + if (type === 'exchange-ews') { + const url = settings.get('CalendarSync_Ews_Url')?.trim(); + const username = settings.get('CalendarSync_Ews_Username')?.trim(); + const password = settings.get('CalendarSync_Ews_Password'); + const authMethod = settings.get('CalendarSync_Ews_AuthMethod') === 'basic' ? ('basic' as const) : ('ntlm' as const); + const allowSelfSignedCerts = settings.get('CalendarSync_Ews_AllowSelfSignedCerts') === true; + + if (!url || !username || !password) { + return null; + } + + const key = JSON.stringify([type, url, username, password, authMethod, allowSelfSignedCerts]); + if (cached?.key !== key) { + cached = { + key, + provider: new ExchangeEwsCalendarProvider({ url, username, password, authMethod, allowSelfSignedCerts }), + }; + } + return cached.provider; + } + + return null; +} diff --git a/apps/meteor/ee/server/lib/calendarSync/logSanitizer.ts b/apps/meteor/ee/server/lib/calendarSync/logSanitizer.ts new file mode 100644 index 0000000000000..703c89b470501 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/logSanitizer.ts @@ -0,0 +1,28 @@ +const SENSITIVE_PATTERNS: [RegExp, string][] = [ + [/Bearer\s+[\w.~+/=-]+/gi, 'Bearer [redacted]'], + [/(client_secret=)[^&\s]+/gi, '$1[redacted]'], + [/(password[">:=\s]+)[^<&"\s]+/gi, '$1[redacted]'], + [/(authorization[":\s]+)[^,}"\s]+/gi, '$1[redacted]'], + [/<(?:\w+:)?Password>[\s\S]*?<\/(?:\w+:)?Password>/gi, '[redacted]'], +]; + +export function sanitizeSensitiveText(text: string): string { + return SENSITIVE_PATTERNS.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text); +} + +/** + * Converts any thrown value into a `{ code, message }` pair safe for logging and + * persistence: tokens, secrets and auth headers are scrubbed, and event contents + * are never included (callers must only pass transport/provider errors here). + */ +export function sanitizeError(error: unknown): { code: string; message: string } { + if (error && typeof error === 'object') { + const { code, message } = error as { code?: unknown; message?: unknown }; + return { + code: typeof code === 'string' ? code : 'unknown-error', + message: sanitizeSensitiveText(typeof message === 'string' ? message : String(error)), + }; + } + + return { code: 'unknown-error', message: sanitizeSensitiveText(String(error)) }; +} diff --git a/apps/meteor/ee/server/lib/calendarSync/mailboxResolver.ts b/apps/meteor/ee/server/lib/calendarSync/mailboxResolver.ts new file mode 100644 index 0000000000000..a3284219a79c1 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/mailboxResolver.ts @@ -0,0 +1,31 @@ +import type { IUser } from '@rocket.chat/core-typings'; + +export type MailboxSource = 'email' | 'custom-field'; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Resolves the Exchange mailbox address for a user. Returns null when the user + * has no resolvable mailbox — callers must skip (and count) these users, never + * fail the batch. + * + * With the default `email` source only *verified* addresses are used: an + * unverified address could point at someone else's mailbox and leak their + * calendar into this user's account. + */ +export function resolveMailbox( + user: Pick, + source: MailboxSource, + customFieldName?: string, +): string | null { + if (source === 'custom-field') { + if (!customFieldName) { + return null; + } + const value = user.customFields?.[customFieldName]; + return typeof value === 'string' && EMAIL_PATTERN.test(value.trim()) ? value.trim() : null; + } + + const verified = user.emails?.find((email) => email.verified && EMAIL_PATTERN.test(email.address)); + return verified?.address ?? null; +} diff --git a/apps/meteor/ee/server/lib/calendarSync/providers/ews/ExchangeEwsCalendarProvider.ts b/apps/meteor/ee/server/lib/calendarSync/providers/ews/ExchangeEwsCalendarProvider.ts new file mode 100644 index 0000000000000..39c21e5fe7073 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/providers/ews/ExchangeEwsCalendarProvider.ts @@ -0,0 +1,248 @@ +import { EwsHttpClient } from './ewsHttp'; +import type { IEwsHttpResponse, IEwsHttpConfig } from './ewsHttp'; +import type { IEwsCalendarItem } from './soap'; +import { + assertGetFolderSuccess, + buildFindCalendarItemsRequest, + buildGetCalendarFolderRequest, + buildGetItemBodiesRequest, + buildGetUserAvailabilityRequest, + buildSyncFolderItemsRequest, + mapEwsResponseCode, + parseAvailabilityResponse, + parseFindItemResponse, + parseGetItemBodiesResponse, + parseSyncFolderItemsResponse, +} from './soap'; +import type { + FreeBusyStatus, + ICalendarSyncListResult, + ICalendarSyncProvider, + ICalendarSyncWindow, + IConnectionTestResult, + IExternalCalendarEvent, + IFreeBusyResult, +} from '../../definition'; +import { CalendarSyncError } from '../../definition'; +import { sanitizeError } from '../../logSanitizer'; + +type SleepFn = (ms: number) => Promise; + +const defaultSleep: SleepFn = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const THROTTLE_RETRY_MS = 5_000; +const AVAILABILITY_MAX_MAILBOXES = 50; +const BUSY_STATUSES = new Set(['Busy', 'OOF']); +/** Cap on SyncFolderItems round-trips per user per run (512 changes each) */ +const SYNC_MAX_PAGES = 20; + +function mapBusyType(busyType: string): FreeBusyStatus { + if (busyType === 'OOF') { + return 'oof'; + } + if (busyType === 'Tentative') { + return 'tentative'; + } + return 'busy'; +} + +export interface IEwsProviderClient { + post(soapXml: string): Promise; +} + +/** + * Calendar sync provider for on-premises Exchange Server (2016/2019/Subscription + * Edition) over Exchange Web Services with a service account holding the + * ApplicationImpersonation role. This module holds no Microsoft-cloud hostname: + * every request goes to the admin-configured EWS endpoint only (air-gap safe). + */ +export class ExchangeEwsCalendarProvider implements ICalendarSyncProvider { + public readonly type = 'exchange-ews' as const; + + public readonly supportsDelta = true; + + public readonly supportsWebhooks = false; + + private readonly client: IEwsProviderClient; + + constructor( + config: IEwsHttpConfig, + client?: IEwsProviderClient, + private readonly sleep: SleepFn = defaultSleep, + ) { + this.client = client ?? new EwsHttpClient(config); + } + + public async testConnection(probeMailbox?: string): Promise { + try { + // Without a probe mailbox this validates endpoint + credentials against the + // service account's own calendar; with one it additionally proves impersonation + const response = await this.post(buildGetCalendarFolderRequest(probeMailbox)); + assertGetFolderSuccess(response.body); + return { ok: true }; + } catch (error) { + return { ok: false, error: sanitizeError(error) }; + } + } + + public async listEvents(mailbox: string, window: ICalendarSyncWindow, deltaToken?: string): Promise { + if (deltaToken) { + try { + return await this.listEventsIncremental(mailbox, window, deltaToken); + } catch (error) { + if (error instanceof CalendarSyncError && error.code === 'delta-token-expired') { + // Stale/invalid SyncState: fall back to a full window snapshot + return this.listEvents(mailbox, window); + } + throw error; + } + } + + // Establish the SyncState BEFORE the snapshot so nothing created in between + // is missed — re-reported items are upserted idempotently anyway + const nextDeltaToken = await this.establishSyncState(mailbox); + + const findResponse = await this.post(buildFindCalendarItemsRequest(mailbox, window)); + const items = parseFindItemResponse(findResponse.body); + const events = await this.buildEvents(mailbox, items); + + return { events, deletedEventIds: [], full: true, ...(nextDeltaToken && { nextDeltaToken }) }; + } + + /** Incremental changes via SyncFolderItems; the folder is not window-scoped, so filtering happens here */ + private async listEventsIncremental(mailbox: string, window: ICalendarSyncWindow, syncState: string): Promise { + const changed: IEwsCalendarItem[] = []; + const deletedEventIds: string[] = []; + let state = syncState; + + for (let page = 0; page < SYNC_MAX_PAGES; page++) { + const parsed = parseSyncFolderItemsResponse((await this.post(buildSyncFolderItemsRequest(mailbox, state))).body); + state = parsed.syncState; + changed.push(...parsed.items); + deletedEventIds.push(...parsed.deletedItemIds); + if (parsed.includesLastItemInRange) { + break; + } + } + + const inWindow: IEwsCalendarItem[] = []; + for (const item of changed) { + if (item.start.getTime() < window.end.getTime() && item.end.getTime() > window.start.getTime()) { + inWindow.push(item); + } else { + // The event may have been rescheduled out of the window; deleting is idempotent + // and only ever touches events this integration owns + deletedEventIds.push(item.itemId); + } + } + + return { + events: await this.buildEvents(mailbox, inWindow), + deletedEventIds, + nextDeltaToken: state, + full: false, + }; + } + + /** Fast-forwards SyncFolderItems to the current state; undefined when the mailbox is too large to page through */ + private async establishSyncState(mailbox: string): Promise { + let state: string | undefined; + for (let page = 0; page < SYNC_MAX_PAGES; page++) { + const parsed = parseSyncFolderItemsResponse((await this.post(buildSyncFolderItemsRequest(mailbox, state))).body); + state = parsed.syncState; + if (parsed.includesLastItemInRange) { + return state; + } + } + return undefined; + } + + private async buildEvents(mailbox: string, items: IEwsCalendarItem[]): Promise { + const activeItems = items.filter((item) => !item.isCancelled); + const bodies = activeItems.length + ? parseGetItemBodiesResponse( + ( + await this.post( + buildGetItemBodiesRequest( + mailbox, + activeItems.map((item) => item.itemId), + ), + ) + ).body, + ) + : new Map(); + + return items.map((item) => ({ + externalId: item.itemId, + ...(item.uid && { iCalUId: item.uid }), + subject: item.subject, + description: bodies.get(item.itemId) ?? '', + startTime: item.start, + endTime: item.end, + busy: BUSY_STATUSES.has(item.legacyFreeBusyStatus), + ...(item.isCancelled && { isCancelled: true }), + })); + } + + public async getFreeBusy(mailboxes: string[], window: ICalendarSyncWindow): Promise { + const results: IFreeBusyResult[] = []; + + for (let offset = 0; offset < mailboxes.length; offset += AVAILABILITY_MAX_MAILBOXES) { + const chunk = mailboxes.slice(offset, offset + AVAILABILITY_MAX_MAILBOXES); + const response = await this.post(buildGetUserAvailabilityRequest(chunk, window)); + const parsed = parseAvailabilityResponse(response.body); + + chunk.forEach((mailbox, index) => { + const entry = parsed[index]; + if (!entry || entry.errorCode) { + results.push({ + mailbox, + intervals: [], + error: { code: 'schedule-unavailable', message: entry?.errorCode ?? 'missing response entry' }, + }); + return; + } + + results.push({ + mailbox, + intervals: entry.events + .filter((event) => event.busyType !== 'Free' && event.busyType !== 'WorkingElsewhere' && event.busyType !== 'NoData') + .map((event) => ({ + start: event.start, + end: event.end, + status: mapBusyType(event.busyType), + })), + }); + }); + } + + return results; + } + + /** POSTs to the configured endpoint, mapping HTTP auth failures and retrying once on server-busy */ + private async post(soapXml: string, isRetry = false): Promise { + const response = await this.client.post(soapXml); + + if (response.statusCode === 401 || response.statusCode === 403) { + throw new CalendarSyncError( + 'invalid-credentials', + `The EWS endpoint rejected the service account credentials (HTTP ${response.statusCode})`, + ); + } + + if (response.statusCode === 503 && !isRetry) { + await this.sleep(THROTTLE_RETRY_MS); + return this.post(soapXml, true); + } + + if (response.statusCode !== 200 && response.statusCode !== 500) { + // 500 carries SOAP faults which the parsers map to precise error codes + throw mapEwsResponseCode('HttpError', `EWS endpoint returned HTTP ${response.statusCode}`); + } + + return response; + } +} diff --git a/apps/meteor/ee/server/lib/calendarSync/providers/ews/ewsHttp.ts b/apps/meteor/ee/server/lib/calendarSync/providers/ews/ewsHttp.ts new file mode 100644 index 0000000000000..4eee3688580e9 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/providers/ews/ewsHttp.ts @@ -0,0 +1,196 @@ +import http from 'http'; +import https from 'https'; +import { URL } from 'url'; + +import { createType1Message, createType3Message, parseNtlmUsername, parseType2Message } from './ntlm'; +import { CalendarSyncError } from '../../definition'; + +export interface IEwsHttpResponse { + statusCode: number; + headers: Record; + body: string; +} + +export interface IEwsRequestOptions { + url: string; + headers: Record; + body: string; + agent: http.Agent | https.Agent; + timeoutMs: number; +} + +export type EwsRequestFn = (options: IEwsRequestOptions) => Promise; + +export interface IEwsHttpConfig { + url: string; + username: string; + password: string; + authMethod: 'ntlm' | 'basic'; + /** Explicit admin opt-in only; scoped to the EWS endpoint, never global */ + allowSelfSignedCerts: boolean; + timeoutMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Default transport on top of node:http(s).request. serverFetch is not used here: + * NTLM authenticates the TCP connection (not the request), so the whole handshake + * must be pinned to a single kept-alive socket, which requires owning the Agent. + */ +export const nodeRequest: EwsRequestFn = ({ url, headers, body, agent, timeoutMs }) => + new Promise((resolve, reject) => { + const parsed = new URL(url); + const transport = parsed.protocol === 'https:' ? https : http; + + const request = transport.request( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: `${parsed.pathname}${parsed.search}`, + method: 'POST', + headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, + agent, + timeout: timeoutMs, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => + resolve({ + statusCode: response.statusCode ?? 0, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + response.on('error', reject); + }, + ); + + request.on('timeout', () => { + request.destroy(new Error(`Request to the EWS endpoint timed out after ${timeoutMs}ms`)); + }); + request.on('error', reject); + request.end(body); + }); + +/** + * POSTs SOAP payloads to the admin-configured EWS endpoint using Basic or NTLM + * (NTLMv2) service-account authentication. This client only ever contacts the + * configured endpoint — it holds no other hostname. + */ +export class EwsHttpClient { + private readonly agent: http.Agent | https.Agent; + + private readonly timeoutMs: number; + + /** NTLM handshakes must not interleave on the shared socket */ + private queue: Promise = Promise.resolve(); + + constructor( + private readonly config: IEwsHttpConfig, + private readonly requestFn: EwsRequestFn = nodeRequest, + ) { + const isHttps = new URL(config.url).protocol === 'https:'; + this.agent = isHttps + ? new https.Agent({ + keepAlive: true, + maxSockets: 1, + ...(config.allowSelfSignedCerts && { rejectUnauthorized: false }), + }) + : new http.Agent({ keepAlive: true, maxSockets: 1 }); + this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + } + + public async post(soapXml: string): Promise { + const run = this.queue.then(() => this.doPost(soapXml)); + // Keep the chain alive even when a request fails + this.queue = run.catch(() => undefined); + return run; + } + + public destroy(): void { + this.agent.destroy(); + } + + private baseHeaders(): Record { + return { + 'Content-Type': 'text/xml; charset=utf-8', + 'Accept': 'text/xml', + 'Connection': 'keep-alive', + }; + } + + private async doPost(soapXml: string): Promise { + try { + if (this.config.authMethod === 'basic') { + return await this.requestFn({ + url: this.config.url, + headers: { + ...this.baseHeaders(), + Authorization: `Basic ${Buffer.from(`${this.config.username}:${this.config.password}`).toString('base64')}`, + }, + body: soapXml, + agent: this.agent, + timeoutMs: this.timeoutMs, + }); + } + + return await this.doNtlmPost(soapXml); + } catch (error) { + if (error instanceof CalendarSyncError) { + throw error; + } + throw new CalendarSyncError('network-error', `Unable to reach the EWS endpoint: ${(error as Error).message}`); + } + } + + private async doNtlmPost(soapXml: string): Promise { + const { username, domain } = parseNtlmUsername(this.config.username); + + const negotiate = await this.requestFn({ + url: this.config.url, + headers: { ...this.baseHeaders(), Authorization: createType1Message() }, + body: '', + agent: this.agent, + timeoutMs: this.timeoutMs, + }); + + if (negotiate.statusCode !== 401) { + // Endpoint did not challenge us — some proxies/pre-auth setups respond directly + if (negotiate.statusCode >= 200 && negotiate.statusCode < 300) { + throw new CalendarSyncError('provider-error', 'The EWS endpoint accepted an unauthenticated request; verify the endpoint URL'); + } + throw new CalendarSyncError('auth-failed', `Expected an NTLM challenge but received HTTP ${negotiate.statusCode}`); + } + + const authenticateHeader = negotiate.headers['www-authenticate']; + const headerValue = Array.isArray(authenticateHeader) ? authenticateHeader.join(', ') : (authenticateHeader ?? ''); + if (!headerValue.includes('NTLM')) { + throw new CalendarSyncError('auth-failed', 'The EWS endpoint does not offer NTLM authentication'); + } + + let challenge; + try { + challenge = parseType2Message(headerValue); + } catch (error) { + throw new CalendarSyncError('auth-failed', (error as Error).message); + } + + return this.requestFn({ + url: this.config.url, + headers: { + ...this.baseHeaders(), + Authorization: createType3Message(challenge, { + username, + password: this.config.password, + domain, + workstation: 'ROCKETCHAT', + }), + }, + body: soapXml, + agent: this.agent, + timeoutMs: this.timeoutMs, + }); + } +} diff --git a/apps/meteor/ee/server/lib/calendarSync/providers/ews/md4.ts b/apps/meteor/ee/server/lib/calendarSync/providers/ews/md4.ts new file mode 100644 index 0000000000000..2e691ad6ab45a --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/providers/ews/md4.ts @@ -0,0 +1,83 @@ +/** + * Pure-TypeScript MD4 (RFC 1320). Required for NTLM's NTOWFv2 key derivation: + * OpenSSL 3 (bundled with modern Node.js) removed MD4 from the default provider, + * so `crypto.createHash('md4')` is not available. MD4 is used here exclusively + * as a key-derivation step of the NTLM protocol — not for general hashing. + */ + +function leftRotate(value: number, shift: number): number { + return ((value << shift) | (value >>> (32 - shift))) >>> 0; +} + +export function md4(input: Buffer): Buffer { + // Pre-processing: pad to 64-byte blocks with 0x80, zeros, and the 64-bit bit length + const bitLength = input.length * 8; + const paddedLength = (((input.length + 8) >> 6) + 1) << 6; + const padded = Buffer.alloc(paddedLength); + input.copy(padded); + padded[input.length] = 0x80; + padded.writeUInt32LE(bitLength >>> 0, paddedLength - 8); + padded.writeUInt32LE(Math.floor(bitLength / 0x100000000), paddedLength - 4); + + let a = 0x67452301; + let b = 0xefcdab89; + let c = 0x98badcfe; + let d = 0x10325476; + + const x = new Array(16); + + for (let block = 0; block < paddedLength; block += 64) { + for (let i = 0; i < 16; i++) { + x[i] = padded.readUInt32LE(block + i * 4); + } + + const aa = a; + const bb = b; + const cc = c; + const dd = d; + + const f = (v: number, w: number, z: number): number => (v & w) | (~v & z); + const g = (v: number, w: number, z: number): number => (v & w) | (v & z) | (w & z); + const h = (v: number, w: number, z: number): number => v ^ w ^ z; + + const round1 = (p: number, q: number, r: number, s: number, k: number, sh: number): number => + leftRotate((p + f(q, r, s) + x[k]) >>> 0, sh); + const round2 = (p: number, q: number, r: number, s: number, k: number, sh: number): number => + leftRotate((p + g(q, r, s) + x[k] + 0x5a827999) >>> 0, sh); + const round3 = (p: number, q: number, r: number, s: number, k: number, sh: number): number => + leftRotate((p + h(q, r, s) + x[k] + 0x6ed9eba1) >>> 0, sh); + + for (const k of [0, 4, 8, 12]) { + a = round1(a, b, c, d, k, 3); + d = round1(d, a, b, c, k + 1, 7); + c = round1(c, d, a, b, k + 2, 11); + b = round1(b, c, d, a, k + 3, 19); + } + + for (const k of [0, 1, 2, 3]) { + a = round2(a, b, c, d, k, 3); + d = round2(d, a, b, c, k + 4, 5); + c = round2(c, d, a, b, k + 8, 9); + b = round2(b, c, d, a, k + 12, 13); + } + + for (const k of [0, 2, 1, 3]) { + a = round3(a, b, c, d, k, 3); + d = round3(d, a, b, c, k + 8, 9); + c = round3(c, d, a, b, k + 4, 11); + b = round3(b, c, d, a, k + 12, 15); + } + + a = (a + aa) >>> 0; + b = (b + bb) >>> 0; + c = (c + cc) >>> 0; + d = (d + dd) >>> 0; + } + + const digest = Buffer.alloc(16); + digest.writeUInt32LE(a, 0); + digest.writeUInt32LE(b, 4); + digest.writeUInt32LE(c, 8); + digest.writeUInt32LE(d, 12); + return digest; +} diff --git a/apps/meteor/ee/server/lib/calendarSync/providers/ews/ntlm.ts b/apps/meteor/ee/server/lib/calendarSync/providers/ews/ntlm.ts new file mode 100644 index 0000000000000..62bb49829479b --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/providers/ews/ntlm.ts @@ -0,0 +1,151 @@ +/** + * Minimal NTLM (NTLMv2 only) message construction/parsing for EWS service-account + * authentication, implementing the subset of [MS-NLMP] needed for an HTTP handshake: + * Type 1 (NEGOTIATE) -> Type 2 (CHALLENGE, from server) -> Type 3 (AUTHENTICATE). + * + * Vendored on purpose: the available npm NTLM packages are unmaintained, and this + * keeps the wire behavior small and auditable (air-gapped deployments). + */ + +import crypto from 'crypto'; + +import { md4 } from './md4'; + +const SIGNATURE = 'NTLMSSP\0'; + +const NEGOTIATE_UNICODE = 0x00000001; +const REQUEST_TARGET = 0x00000004; +const NEGOTIATE_NTLM = 0x00000200; +const NEGOTIATE_ALWAYS_SIGN = 0x00008000; +const NEGOTIATE_EXTENDED_SESSIONSECURITY = 0x00080000; + +const TYPE1_FLAGS = NEGOTIATE_UNICODE | REQUEST_TARGET | NEGOTIATE_NTLM | NEGOTIATE_ALWAYS_SIGN | NEGOTIATE_EXTENDED_SESSIONSECURITY; + +export interface INtlmCredentials { + username: string; + password: string; + /** NETBIOS domain; parsed from DOMAIN\username when present */ + domain: string; + workstation: string; +} + +/** Splits `DOMAIN\user` service-account usernames; UPNs pass through with an empty domain */ +export function parseNtlmUsername(raw: string): { username: string; domain: string } { + const separator = raw.indexOf('\\'); + if (separator === -1) { + return { username: raw, domain: '' }; + } + return { domain: raw.slice(0, separator), username: raw.slice(separator + 1) }; +} + +export function createType1Message(): string { + const header = Buffer.alloc(32); + header.write(SIGNATURE, 0, 'ascii'); + header.writeUInt32LE(1, 8); // message type + header.writeUInt32LE(TYPE1_FLAGS, 12); + // Empty domain and workstation security buffers (offsets point past the header) + header.writeUInt32LE(32, 16 + 4); + header.writeUInt32LE(32, 24 + 4); + return `NTLM ${header.toString('base64')}`; +} + +export interface INtlmChallenge { + serverChallenge: Buffer; + targetInfo: Buffer; + flags: number; +} + +export function parseType2Message(authenticateHeader: string): INtlmChallenge { + const match = /NTLM\s+([A-Za-z0-9+/=]+)/.exec(authenticateHeader); + if (!match) { + throw new Error('Server did not return an NTLM challenge'); + } + + const message = Buffer.from(match[1], 'base64'); + if (message.length < 48 || message.toString('ascii', 0, 8) !== SIGNATURE || message.readUInt32LE(8) !== 2) { + throw new Error('Malformed NTLM Type 2 message'); + } + + const flags = message.readUInt32LE(20); + const serverChallenge = Buffer.from(message.subarray(24, 32)); + + const targetInfoLength = message.readUInt16LE(40); + const targetInfoOffset = message.readUInt32LE(44); + const targetInfo = + targetInfoLength > 0 && targetInfoOffset + targetInfoLength <= message.length + ? Buffer.from(message.subarray(targetInfoOffset, targetInfoOffset + targetInfoLength)) + : Buffer.alloc(0); + + return { serverChallenge, targetInfo, flags }; +} + +function hmacMd5(key: Buffer, data: Buffer): Buffer { + return crypto.createHmac('md5', key).update(data).digest(); +} + +/** NTOWFv2 ([MS-NLMP] 3.3.2): HMAC_MD5(MD4(UTF16LE(password)), UTF16LE(UPPER(user) + domain)) */ +export function ntowfv2(username: string, password: string, domain: string): Buffer { + const passwordHash = md4(Buffer.from(password, 'utf16le')); + return hmacMd5(passwordHash, Buffer.from(username.toUpperCase() + domain, 'utf16le')); +} + +function unixToFiletime(unixMs: number): Buffer { + const filetime = BigInt(unixMs + 11644473600000) * BigInt(10000); + const buffer = Buffer.alloc(8); + buffer.writeBigUInt64LE(filetime); + return buffer; +} + +export function createType3Message( + challenge: INtlmChallenge, + credentials: INtlmCredentials, + options: { clientNonce?: Buffer; now?: number } = {}, +): string { + const clientNonce = options.clientNonce ?? crypto.randomBytes(8); + const timestamp = unixToFiletime(options.now ?? Date.now()); + + const responseKey = ntowfv2(credentials.username, credentials.password, credentials.domain); + + // NTLMv2 blob: version, timestamp, client nonce, and the server's target info echoed back + const blob = Buffer.concat([ + Buffer.from([0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + timestamp, + clientNonce, + Buffer.from([0x00, 0x00, 0x00, 0x00]), + challenge.targetInfo, + Buffer.from([0x00, 0x00, 0x00, 0x00]), + ]); + + const ntProof = hmacMd5(responseKey, Buffer.concat([challenge.serverChallenge, blob])); + const ntResponse = Buffer.concat([ntProof, blob]); + const lmResponse = Buffer.concat([hmacMd5(responseKey, Buffer.concat([challenge.serverChallenge, clientNonce])), clientNonce]); + + const domainBytes = Buffer.from(credentials.domain, 'utf16le'); + const usernameBytes = Buffer.from(credentials.username, 'utf16le'); + const workstationBytes = Buffer.from(credentials.workstation, 'utf16le'); + const sessionKeyBytes = Buffer.alloc(0); + + const headerLength = 64; + const header = Buffer.alloc(headerLength); + header.write(SIGNATURE, 0, 'ascii'); + header.writeUInt32LE(3, 8); // message type + + let offset = headerLength; + const writeSecurityBuffer = (position: number, data: Buffer): void => { + header.writeUInt16LE(data.length, position); + header.writeUInt16LE(data.length, position + 2); + header.writeUInt32LE(offset, position + 4); + offset += data.length; + }; + + writeSecurityBuffer(12, lmResponse); + writeSecurityBuffer(20, ntResponse); + writeSecurityBuffer(28, domainBytes); + writeSecurityBuffer(36, usernameBytes); + writeSecurityBuffer(44, workstationBytes); + writeSecurityBuffer(52, sessionKeyBytes); + header.writeUInt32LE(TYPE1_FLAGS, 60); + + const message = Buffer.concat([header, lmResponse, ntResponse, domainBytes, usernameBytes, workstationBytes, sessionKeyBytes]); + return `NTLM ${message.toString('base64')}`; +} diff --git a/apps/meteor/ee/server/lib/calendarSync/providers/ews/soap.ts b/apps/meteor/ee/server/lib/calendarSync/providers/ews/soap.ts new file mode 100644 index 0000000000000..b8522253daefb --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/providers/ews/soap.ts @@ -0,0 +1,350 @@ +import { DOMParser } from '@xmldom/xmldom'; + +import { CalendarSyncError } from '../../definition'; +import type { ICalendarSyncWindow } from '../../definition'; + +export const EWS_TYPES_NS = 'http://schemas.microsoft.com/exchange/services/2006/types'; +export const EWS_MESSAGES_NS = 'http://schemas.microsoft.com/exchange/services/2006/messages'; +const SOAP_NS = 'http://schemas.xmlsoap.org/soap/envelope/'; + +export function escapeXml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +/** + * Wraps an EWS operation in a SOAP envelope. When `impersonatedMailbox` is set, + * the ExchangeImpersonation header instructs Exchange to run the operation as + * that mailbox (requires the ApplicationImpersonation RBAC role). + */ +export function soapEnvelope(body: string, impersonatedMailbox?: string): string { + const impersonation = impersonatedMailbox + ? `${escapeXml( + impersonatedMailbox, + )}` + : ''; + + return ( + `` + + `` + + `${impersonation}` + + `${body}` + + `` + ); +} + +const CALENDAR_ITEM_SHAPE = + `IdOnly` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + +export function buildFindCalendarItemsRequest(mailbox: string, window: ICalendarSyncWindow, maxEntries = 512): string { + return soapEnvelope( + `` + + `${CALENDAR_ITEM_SHAPE}` + + `` + + `` + + `${escapeXml(mailbox)}` + + `` + + ``, + mailbox, + ); +} + +export function buildSyncFolderItemsRequest(mailbox: string, syncState?: string, maxChanges = 512): string { + return soapEnvelope( + `` + + `${CALENDAR_ITEM_SHAPE}` + + `` + + `${escapeXml(mailbox)}` + + `${ + syncState ? `${escapeXml(syncState)}` : '' + }${maxChanges}` + + ``, + mailbox, + ); +} + +export function buildGetItemBodiesRequest(mailbox: string, itemIds: string[]): string { + const ids = itemIds.map((id) => ``).join(''); + return soapEnvelope( + `` + + `IdOnly` + + `` + + `` + + `${ids}` + + ``, + mailbox, + ); +} + +export function buildGetUserAvailabilityRequest(mailboxes: string[], window: ICalendarSyncWindow): string { + const mailboxData = mailboxes + .map( + (mailbox) => + `${escapeXml(mailbox)}` + + `Requiredfalse`, + ) + .join(''); + + // A zero-bias timezone makes every time in the request/response UTC + return soapEnvelope( + `` + + `0` + + `002:00:0011Sunday` + + `002:00:0017Sunday` + + `` + + `${mailboxData}` + + `` + + `${window.start.toISOString()}${window.end.toISOString()}` + + `15` + + `FreeBusy` + + `` + + ``, + ); +} + +export function buildGetCalendarFolderRequest(mailbox?: string): string { + const mailboxElement = mailbox ? `${escapeXml(mailbox)}` : ''; + return soapEnvelope( + `` + + `IdOnly` + + `${mailboxElement}` + + ``, + mailbox, + ); +} + +const EWS_ERROR_CODES: Record = { + ErrorImpersonateUserDenied: 'impersonation-denied', + ErrorImpersonationDenied: 'impersonation-denied', + ErrorImpersonationFailed: 'impersonation-denied', + ErrorNonExistentMailbox: 'mailbox-not-found', + ErrorMailboxMoveInProgress: 'mailbox-not-found', + ErrorAccessDenied: 'access-denied', + ErrorServerBusy: 'throttled', + ErrorTooManyObjectsOpened: 'throttled', + ErrorInvalidServerVersion: 'provider-error', + // Expired/invalid incremental sync state — same recovery path as Graph's 410 + ErrorInvalidSyncStateData: 'delta-token-expired', + ErrorSyncFolderNotFound: 'delta-token-expired', +}; + +export function mapEwsResponseCode(responseCode: string, messageText?: string): CalendarSyncError { + const code = EWS_ERROR_CODES[responseCode] ?? 'provider-error'; + return new CalendarSyncError(code, `${responseCode}${messageText ? `: ${messageText}` : ''}`); +} + +function parseXml(xml: string): Document { + // The silent errorHandler keeps xmldom from writing warnings for vendor-specific quirks + // to the console; a fatally-broken document is caught by the documentElement check below + const doc = new DOMParser({ errorHandler: { warning: () => undefined, error: () => undefined } }).parseFromString(xml, 'text/xml'); + if (!doc?.documentElement) { + throw new CalendarSyncError('provider-error', 'Unable to parse the EWS response as XML'); + } + return doc as unknown as Document; +} + +function elementsNS(parent: Document | Element, ns: string, localName: string): Element[] { + return Array.from(parent.getElementsByTagNameNS(ns, localName)); +} + +function textNS(parent: Element, ns: string, localName: string): string | undefined { + const [element] = elementsNS(parent, ns, localName); + return element?.textContent ?? undefined; +} + +/** EWS availability times come back without a zone suffix; a zero-bias request makes them UTC */ +export function parseEwsDate(value: string | undefined): Date | null { + if (!value) { + return null; + } + const date = new Date(/[Zz]|[+-]\d\d:\d\d$/.test(value) ? value : `${value}Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** Throws a mapped CalendarSyncError when the response message reports an error or a SOAP fault is present */ +function assertResponseSuccess(doc: Document, responseMessageName: string): Element[] { + const faults = elementsNS(doc, SOAP_NS, 'Fault'); + if (faults.length) { + const faultCode = textNS(faults[0], EWS_TYPES_NS, 'ResponseCode') ?? faults[0].getElementsByTagName('faultcode')[0]?.textContent ?? ''; + const faultString = faults[0].getElementsByTagName('faultstring')[0]?.textContent ?? 'SOAP fault'; + throw mapEwsResponseCode(faultCode || 'SoapFault', faultString); + } + + const messages = elementsNS(doc, EWS_MESSAGES_NS, responseMessageName); + if (!messages.length) { + throw new CalendarSyncError('provider-error', `Unexpected EWS response: missing ${responseMessageName}`); + } + + for (const message of messages) { + if (message.getAttribute('ResponseClass') === 'Error') { + const responseCode = textNS(message, EWS_MESSAGES_NS, 'ResponseCode') ?? 'UnknownError'; + const messageText = textNS(message, EWS_MESSAGES_NS, 'MessageText'); + throw mapEwsResponseCode(responseCode, messageText); + } + } + + return messages; +} + +export interface IEwsCalendarItem { + itemId: string; + subject: string; + start: Date; + end: Date; + legacyFreeBusyStatus: string; + uid?: string; + isCancelled: boolean; +} + +function parseCalendarItem(element: Element): IEwsCalendarItem | null { + const [itemIdElement] = elementsNS(element, EWS_TYPES_NS, 'ItemId'); + const itemId = itemIdElement?.getAttribute('Id'); + const start = parseEwsDate(textNS(element, EWS_TYPES_NS, 'Start')); + const end = parseEwsDate(textNS(element, EWS_TYPES_NS, 'End')); + if (!itemId || !start || !end) { + return null; + } + + return { + itemId, + subject: textNS(element, EWS_TYPES_NS, 'Subject') ?? '', + start, + end, + legacyFreeBusyStatus: textNS(element, EWS_TYPES_NS, 'LegacyFreeBusyStatus') ?? 'Busy', + uid: textNS(element, EWS_TYPES_NS, 'UID'), + isCancelled: textNS(element, EWS_TYPES_NS, 'IsCancelled') === 'true', + }; +} + +export function parseFindItemResponse(xml: string): IEwsCalendarItem[] { + const doc = parseXml(xml); + assertResponseSuccess(doc, 'FindItemResponseMessage'); + + return elementsNS(doc, EWS_TYPES_NS, 'CalendarItem') + .map(parseCalendarItem) + .filter((item): item is IEwsCalendarItem => item !== null); +} + +export interface IEwsSyncFolderItemsResult { + syncState: string; + includesLastItemInRange: boolean; + /** Created or updated calendar items */ + items: IEwsCalendarItem[]; + deletedItemIds: string[]; +} + +export function parseSyncFolderItemsResponse(xml: string): IEwsSyncFolderItemsResult { + const doc = parseXml(xml); + const [message] = assertResponseSuccess(doc, 'SyncFolderItemsResponseMessage'); + + const syncState = textNS(message, EWS_MESSAGES_NS, 'SyncState'); + if (!syncState) { + throw new CalendarSyncError('provider-error', 'SyncFolderItems response is missing the sync state'); + } + + const items: IEwsCalendarItem[] = []; + const deletedItemIds: string[] = []; + + const [changes] = elementsNS(message, EWS_MESSAGES_NS, 'Changes'); + if (changes) { + for (const change of Array.from(changes.childNodes)) { + if (change.nodeType !== 1) { + continue; + } + const element = change as Element; + if (element.localName === 'Create' || element.localName === 'Update') { + const [calendarItem] = elementsNS(element, EWS_TYPES_NS, 'CalendarItem'); + const item = calendarItem && parseCalendarItem(calendarItem); + if (item) { + items.push(item); + } + continue; + } + if (element.localName === 'Delete') { + const [itemIdElement] = elementsNS(element, EWS_TYPES_NS, 'ItemId'); + const itemId = itemIdElement?.getAttribute('Id'); + if (itemId) { + deletedItemIds.push(itemId); + } + } + } + } + + return { + syncState, + includesLastItemInRange: textNS(message, EWS_MESSAGES_NS, 'IncludesLastItemInRange') !== 'false', + items, + deletedItemIds, + }; +} + +export function parseGetItemBodiesResponse(xml: string): Map { + const doc = parseXml(xml); + assertResponseSuccess(doc, 'GetItemResponseMessage'); + + const bodies = new Map(); + for (const element of elementsNS(doc, EWS_TYPES_NS, 'CalendarItem')) { + const [itemIdElement] = elementsNS(element, EWS_TYPES_NS, 'ItemId'); + const itemId = itemIdElement?.getAttribute('Id'); + const body = textNS(element, EWS_TYPES_NS, 'TextBody'); + if (itemId && body !== undefined) { + bodies.set(itemId, body); + } + } + return bodies; +} + +export interface IEwsFreeBusyEvent { + start: Date; + end: Date; + busyType: string; +} + +export interface IEwsFreeBusyResponse { + events: IEwsFreeBusyEvent[]; + errorCode?: string; +} + +/** Returns one entry per requested mailbox, in request order */ +export function parseAvailabilityResponse(xml: string): IEwsFreeBusyResponse[] { + const doc = parseXml(xml); + + const faults = elementsNS(doc, SOAP_NS, 'Fault'); + if (faults.length) { + const faultString = faults[0].getElementsByTagName('faultstring')[0]?.textContent ?? 'SOAP fault'; + throw mapEwsResponseCode('SoapFault', faultString); + } + + const responses = elementsNS(doc, EWS_MESSAGES_NS, 'FreeBusyResponse'); + if (!responses.length) { + throw new CalendarSyncError('provider-error', 'Unexpected EWS response: missing FreeBusyResponse'); + } + + return responses.map((response) => { + const [responseMessage] = elementsNS(response, EWS_MESSAGES_NS, 'ResponseMessage'); + if (responseMessage?.getAttribute('ResponseClass') === 'Error') { + return { events: [], errorCode: textNS(responseMessage, EWS_MESSAGES_NS, 'ResponseCode') ?? 'UnknownError' }; + } + + const events: IEwsFreeBusyEvent[] = []; + for (const element of elementsNS(response, EWS_TYPES_NS, 'CalendarEvent')) { + const start = parseEwsDate(textNS(element, EWS_TYPES_NS, 'StartTime')); + const end = parseEwsDate(textNS(element, EWS_TYPES_NS, 'EndTime')); + if (!start || !end) { + continue; + } + events.push({ start, end, busyType: textNS(element, EWS_TYPES_NS, 'BusyType') ?? 'Busy' }); + } + return { events }; + }); +} + +export function assertGetFolderSuccess(xml: string): void { + assertResponseSuccess(parseXml(xml), 'GetFolderResponseMessage'); +} diff --git a/apps/meteor/ee/server/lib/calendarSync/providers/graph/GraphTokenManager.ts b/apps/meteor/ee/server/lib/calendarSync/providers/graph/GraphTokenManager.ts new file mode 100644 index 0000000000000..e48db8b30ea32 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/providers/graph/GraphTokenManager.ts @@ -0,0 +1,144 @@ +import { buildClientAssertion } from './clientAssertion'; +import type { CalendarSyncFetchFn } from '../../definition'; +import { CalendarSyncError } from '../../definition'; + +export interface IGraphTokenManagerConfig { + tenantId: string; + clientId: string; + /** Defaults to 'client-secret' */ + authMethod?: 'client-secret' | 'certificate'; + clientSecret?: string; + /** PEM certificate uploaded to the Entra ID app registration (certificate auth) */ + certificatePem?: string; + /** PEM private key matching the certificate; never leaves the server (certificate auth) */ + privateKeyPem?: string; + /** e.g. https://login.microsoftonline.com — configurable for national clouds */ + loginHost: string; + /** e.g. https://graph.microsoft.com — used as the token scope audience */ + graphHost: string; +} + +/** Refresh this many ms before the token actually expires */ +const EXPIRY_SAFETY_MARGIN_MS = 2 * 60 * 1000; + +const AADSTS_ERROR_CODES: [RegExp, string][] = [ + [/AADSTS90002|AADSTS900023/, 'invalid-tenant'], + [/AADSTS700016/, 'invalid-client'], + [/AADSTS7000215|AADSTS7000222/, 'invalid-client-secret'], + [/AADSTS500011|AADSTS65001/, 'consent-missing'], +]; + +/** + * Acquires and caches OAuth 2.0 client-credentials tokens for Microsoft Graph. + * Tokens live in memory only and are refreshed ahead of expiry; concurrent + * callers share a single in-flight request. + */ +export class GraphTokenManager { + private accessToken: string | null = null; + + private expiresAt = 0; + + private inflight: Promise | null = null; + + constructor( + private readonly config: IGraphTokenManagerConfig, + private readonly fetchFn: CalendarSyncFetchFn, + ) {} + + public invalidate(): void { + this.accessToken = null; + this.expiresAt = 0; + } + + public async getToken(): Promise { + if (this.accessToken && Date.now() < this.expiresAt - EXPIRY_SAFETY_MARGIN_MS) { + return this.accessToken; + } + + if (!this.inflight) { + this.inflight = this.requestToken().finally(() => { + this.inflight = null; + }); + } + + return this.inflight; + } + + private async requestToken(): Promise { + const { + tenantId, + clientId, + authMethod = 'client-secret', + clientSecret, + certificatePem, + privateKeyPem, + loginHost, + graphHost, + } = this.config; + + if (!tenantId || !clientId) { + throw new CalendarSyncError('missing-credentials', 'Microsoft Graph tenant id and client id must be configured'); + } + + const url = `${loginHost}/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`; + + const credentialParams: Record = {}; + if (authMethod === 'certificate') { + if (!certificatePem || !privateKeyPem) { + throw new CalendarSyncError('missing-credentials', 'Certificate authentication requires both a certificate and a private key'); + } + credentialParams.client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'; + credentialParams.client_assertion = buildClientAssertion({ clientId, tokenUrl: url, certificatePem, privateKeyPem }); + } else { + if (!clientSecret) { + throw new CalendarSyncError('missing-credentials', 'Microsoft Graph tenant id, client id and client secret must be configured'); + } + credentialParams.client_secret = clientSecret; + } + + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + scope: `${graphHost}/.default`, + ...credentialParams, + }).toString(); + + let response; + try { + response = await this.fetchFn(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + } catch (error) { + throw new CalendarSyncError('network-error', `Unable to reach the Microsoft identity platform: ${(error as Error).message}`); + } + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + throw this.mapTokenError(response.status, payload); + } + + if (typeof payload.access_token !== 'string' || typeof payload.expires_in !== 'number') { + throw new CalendarSyncError('provider-error', 'Unexpected token response from the Microsoft identity platform'); + } + + this.accessToken = payload.access_token; + this.expiresAt = Date.now() + payload.expires_in * 1000; + + return payload.access_token; + } + + private mapTokenError(status: number, payload: { error?: string; error_description?: string }): CalendarSyncError { + const description: string = payload.error_description || payload.error || `Token request failed with status ${status}`; + + const [, code] = AADSTS_ERROR_CODES.find(([pattern]) => pattern.test(description)) ?? []; + if (code) { + // AADSTS descriptions are safe (no secrets), but keep only the first line + return new CalendarSyncError(code, description.split(/[\r\n]/)[0]); + } + + return new CalendarSyncError(status === 429 ? 'throttled' : 'auth-failed', description.split(/[\r\n]/)[0]); + } +} diff --git a/apps/meteor/ee/server/lib/calendarSync/providers/graph/MicrosoftGraphCalendarProvider.ts b/apps/meteor/ee/server/lib/calendarSync/providers/graph/MicrosoftGraphCalendarProvider.ts new file mode 100644 index 0000000000000..7f5ba529f1aef --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/providers/graph/MicrosoftGraphCalendarProvider.ts @@ -0,0 +1,378 @@ +import type { IGraphTokenManagerConfig } from './GraphTokenManager'; +import { GraphTokenManager } from './GraphTokenManager'; +import type { + CalendarSyncFetchFn, + FreeBusyStatus, + ICalendarSubscription, + ICalendarSyncListResult, + ICalendarSyncProvider, + ICalendarSyncWindow, + IConnectionTestResult, + IExternalCalendarEvent, + IFreeBusyResult, + IMinimalFetchResponse, +} from '../../definition'; +import { CalendarSyncError } from '../../definition'; +import { sanitizeError } from '../../logSanitizer'; + +export interface IMicrosoftGraphProviderConfig extends IGraphTokenManagerConfig { + /** Page size requested from Graph (odata.maxpagesize) */ + pageSize?: number; +} + +type SleepFn = (ms: number) => Promise; + +const defaultSleep: SleepFn = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** Calendar-event subscriptions are capped at 4230 minutes by Graph; stay just under */ +const SUBSCRIPTION_TTL_MS = 4200 * 60 * 1000; + +const MAX_THROTTLE_RETRIES = 3; +const MAX_RETRY_AFTER_MS = 30_000; +const DEFAULT_RETRY_AFTER_MS = 5_000; +const MAX_PAGES_PER_SYNC = 50; +const GET_SCHEDULE_MAX_MAILBOXES = 20; + +const EVENT_SELECT_FIELDS = 'id,iCalUId,subject,bodyPreview,start,end,showAs,isCancelled,onlineMeeting,onlineMeetingUrl'; + +const BUSY_STATUSES = new Set(['busy', 'oof']); + +function mapFreeBusyStatus(status: string): FreeBusyStatus { + if (status === 'oof' || status === 'tentative') { + return status; + } + return 'busy'; +} + +export class MicrosoftGraphCalendarProvider implements ICalendarSyncProvider { + public readonly type = 'microsoft-graph' as const; + + public readonly supportsDelta = true; + + public readonly supportsWebhooks = true; + + private readonly tokens: GraphTokenManager; + + private readonly graphHost: string; + + private readonly pageSize: number; + + constructor( + config: IMicrosoftGraphProviderConfig, + private readonly fetchFn: CalendarSyncFetchFn, + private readonly sleep: SleepFn = defaultSleep, + ) { + this.tokens = new GraphTokenManager(config, fetchFn); + this.graphHost = config.graphHost.replace(/\/+$/, ''); + this.pageSize = config.pageSize ?? 50; + } + + public async testConnection(probeMailbox?: string): Promise { + try { + await this.tokens.getToken(); + + if (probeMailbox) { + const now = new Date(); + const url = `${this.graphHost}/v1.0/users/${encodeURIComponent(probeMailbox)}/calendarView?startDateTime=${now.toISOString()}&endDateTime=${new Date( + now.getTime() + 60_000, + ).toISOString()}&$top=1&$select=id`; + await this.requestJson('GET', url); + } + + return { ok: true }; + } catch (error) { + return { ok: false, error: sanitizeError(error) }; + } + } + + public async listEvents(mailbox: string, window: ICalendarSyncWindow, deltaToken?: string): Promise { + const baseUrl = `${this.graphHost}/v1.0/users/${encodeURIComponent(mailbox)}/calendarView/delta`; + + let url: string; + if (deltaToken) { + url = `${baseUrl}?$deltatoken=${encodeURIComponent(deltaToken)}`; + } else { + const params = new URLSearchParams({ + startDateTime: window.start.toISOString(), + endDateTime: window.end.toISOString(), + $select: EVENT_SELECT_FIELDS, + }); + url = `${baseUrl}?${params.toString()}`; + } + + const events: IExternalCalendarEvent[] = []; + const deletedEventIds: string[] = []; + let nextDeltaToken: string | undefined; + + for (let page = 0; page < MAX_PAGES_PER_SYNC; page++) { + let payload; + try { + payload = await this.requestJson('GET', url, undefined, { + Prefer: `odata.maxpagesize=${this.pageSize}, outlook.timezone="UTC"`, + }); + } catch (error) { + if (deltaToken && error instanceof CalendarSyncError && error.code === 'delta-token-expired') { + // Token no longer valid (410 Gone): restart with a full window sync + return this.listEvents(mailbox, window); + } + throw error; + } + + for (const entry of payload.value ?? []) { + if (entry['@removed']) { + if (typeof entry.id === 'string') { + deletedEventIds.push(entry.id); + } + continue; + } + + const event = this.mapEvent(entry); + if (event) { + events.push(event); + } + } + + if (typeof payload['@odata.deltaLink'] === 'string') { + nextDeltaToken = this.extractQueryParam(payload['@odata.deltaLink'], '$deltatoken'); + break; + } + + if (typeof payload['@odata.nextLink'] === 'string') { + url = this.assertGraphUrl(payload['@odata.nextLink']); + continue; + } + + break; + } + + return { + events, + deletedEventIds, + nextDeltaToken, + full: !deltaToken, + }; + } + + public async getFreeBusy(mailboxes: string[], window: ICalendarSyncWindow): Promise { + const results: IFreeBusyResult[] = []; + + for (let offset = 0; offset < mailboxes.length; offset += GET_SCHEDULE_MAX_MAILBOXES) { + const chunk = mailboxes.slice(offset, offset + GET_SCHEDULE_MAX_MAILBOXES); + const url = `${this.graphHost}/v1.0/users/${encodeURIComponent(chunk[0])}/calendar/getSchedule`; + + const payload = await this.requestJson( + 'POST', + url, + JSON.stringify({ + schedules: chunk, + startTime: { dateTime: window.start.toISOString(), timeZone: 'UTC' }, + endTime: { dateTime: window.end.toISOString(), timeZone: 'UTC' }, + availabilityViewInterval: 15, + }), + { 'Content-Type': 'application/json', 'Prefer': 'outlook.timezone="UTC"' }, + ); + + for (const schedule of payload.value ?? []) { + if (schedule.error) { + results.push({ + mailbox: schedule.scheduleId, + intervals: [], + error: sanitizeError({ code: 'schedule-unavailable', message: schedule.error.message }), + }); + continue; + } + + const intervals = (schedule.scheduleItems ?? []) + .filter((item: { status?: string }) => item.status && item.status !== 'free' && item.status !== 'workingElsewhere') + .map((item: { status: string; start: { dateTime: string }; end: { dateTime: string } }) => ({ + start: this.parseGraphDate(item.start?.dateTime), + end: this.parseGraphDate(item.end?.dateTime), + status: mapFreeBusyStatus(item.status), + })) + .filter((interval: { start: Date | null; end: Date | null }) => interval.start && interval.end); + + results.push({ mailbox: schedule.scheduleId, intervals }); + } + } + + return results; + } + + public async createSubscription(mailbox: string, notificationUrl: string, clientState: string): Promise { + const payload = await this.requestJson( + 'POST', + `${this.graphHost}/v1.0/subscriptions`, + JSON.stringify({ + changeType: 'created,updated,deleted', + notificationUrl, + resource: `/users/${mailbox}/events`, + expirationDateTime: new Date(Date.now() + SUBSCRIPTION_TTL_MS).toISOString(), + clientState, + }), + { 'Content-Type': 'application/json' }, + ); + return this.mapSubscription(payload); + } + + public async renewSubscription(subscriptionId: string): Promise { + const payload = await this.requestJson( + 'PATCH', + `${this.graphHost}/v1.0/subscriptions/${encodeURIComponent(subscriptionId)}`, + JSON.stringify({ expirationDateTime: new Date(Date.now() + SUBSCRIPTION_TTL_MS).toISOString() }), + { 'Content-Type': 'application/json' }, + ); + return this.mapSubscription(payload); + } + + public async deleteSubscription(subscriptionId: string): Promise { + await this.requestJson('DELETE', `${this.graphHost}/v1.0/subscriptions/${encodeURIComponent(subscriptionId)}`); + } + + private mapSubscription(payload: Record): ICalendarSubscription { + const expiresAt = this.parseGraphDate(payload.expirationDateTime); + if (typeof payload.id !== 'string' || !expiresAt) { + throw new CalendarSyncError('provider-error', 'Unexpected subscription response from Microsoft Graph'); + } + return { id: payload.id, expiresAt }; + } + + private mapEvent(entry: Record): IExternalCalendarEvent | null { + if (typeof entry.id !== 'string') { + return null; + } + + const startTime = this.parseGraphDate(entry.start?.dateTime); + const endTime = this.parseGraphDate(entry.end?.dateTime); + if (!startTime || !endTime) { + return null; + } + + return { + externalId: entry.id, + ...(typeof entry.iCalUId === 'string' && { iCalUId: entry.iCalUId }), + subject: typeof entry.subject === 'string' ? entry.subject : '', + description: typeof entry.bodyPreview === 'string' ? entry.bodyPreview : '', + startTime, + endTime, + busy: BUSY_STATUSES.has(entry.showAs), + ...(this.extractMeetingUrl(entry) && { meetingUrl: this.extractMeetingUrl(entry) }), + ...(entry.isCancelled === true && { isCancelled: true }), + }; + } + + private extractMeetingUrl(entry: Record): string | undefined { + if (typeof entry.onlineMeeting?.joinUrl === 'string') { + return entry.onlineMeeting.joinUrl; + } + if (typeof entry.onlineMeetingUrl === 'string' && entry.onlineMeetingUrl) { + return entry.onlineMeetingUrl; + } + return undefined; + } + + /** Graph returns e.g. `2026-07-11T10:00:00.0000000` in the requested (UTC) timezone, without a zone suffix */ + private parseGraphDate(value: unknown): Date | null { + if (typeof value !== 'string' || !value) { + return null; + } + const date = new Date(/[Zz]|[+-]\d\d:\d\d$/.test(value) ? value : `${value}Z`); + return Number.isNaN(date.getTime()) ? null : date; + } + + private extractQueryParam(link: string, param: string): string | undefined { + try { + return new URL(this.assertGraphUrl(link)).searchParams.get(param) ?? undefined; + } catch (error) { + if (error instanceof CalendarSyncError) { + throw error; + } + return undefined; + } + } + + private assertGraphUrl(link: string): string { + if (!link.startsWith(`${this.graphHost}/`)) { + throw new CalendarSyncError('provider-error', 'Received a pagination link outside the configured Microsoft Graph host'); + } + return link; + } + + private async requestJson(method: string, url: string, body?: string, headers: Record = {}): Promise { + let refreshedToken = false; + + for (let attempt = 0; ; attempt++) { + const token = await this.tokens.getToken(); + + let response: IMinimalFetchResponse; + try { + response = await this.fetchFn(url, { + method, + headers: { + ...headers, + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + ...(body !== undefined && { body }), + }); + } catch (error) { + throw new CalendarSyncError('network-error', `Unable to reach Microsoft Graph: ${(error as Error).message}`); + } + + if (response.status === 401 && !refreshedToken) { + refreshedToken = true; + this.tokens.invalidate(); + continue; + } + + if ((response.status === 429 || response.status === 503 || response.status === 504) && attempt < MAX_THROTTLE_RETRIES) { + const retryAfterSeconds = Number(response.headers.get('Retry-After')); + const waitMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : DEFAULT_RETRY_AFTER_MS; + await this.sleep(Math.min(waitMs, MAX_RETRY_AFTER_MS)); + continue; + } + + if (response.status === 410) { + throw new CalendarSyncError('delta-token-expired', 'The Microsoft Graph delta token is no longer valid'); + } + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + throw this.mapGraphError(response.status, payload); + } + + return payload; + } + } + + private mapGraphError(status: number, payload: { error?: { code?: string; message?: string } }): CalendarSyncError { + const graphCode = payload.error?.code ?? ''; + const message = (payload.error?.message ?? `Microsoft Graph request failed with status ${status}`).split(/[\r\n]/)[0]; + + if (status === 429) { + return new CalendarSyncError('throttled', 'Microsoft Graph throttling limit reached'); + } + if (status === 403 || graphCode === 'Authorization_RequestDenied' || graphCode === 'ErrorAccessDenied') { + return new CalendarSyncError( + 'consent-missing', + `Access denied by Microsoft Graph — check application permissions and admin consent (${message})`, + ); + } + if ( + status === 404 || + graphCode === 'ErrorItemNotFound' || + graphCode === 'ResourceNotFound' || + graphCode === 'MailboxNotEnabledForRESTAPI' + ) { + return new CalendarSyncError('mailbox-not-found', message); + } + if (status === 401) { + return new CalendarSyncError('auth-failed', message); + } + + return new CalendarSyncError('provider-error', `${graphCode || status}: ${message}`); + } +} diff --git a/apps/meteor/ee/server/lib/calendarSync/providers/graph/clientAssertion.ts b/apps/meteor/ee/server/lib/calendarSync/providers/graph/clientAssertion.ts new file mode 100644 index 0000000000000..9751fecf29df7 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/providers/graph/clientAssertion.ts @@ -0,0 +1,61 @@ +import crypto from 'crypto'; + +import { CalendarSyncError } from '../../definition'; + +const base64url = (input: Buffer | string): string => + (typeof input === 'string' ? Buffer.from(input) : input).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); + +/** SHA-1 thumbprint (x5t) of the certificate, as Entra ID expects in the assertion header */ +export function certificateThumbprint(certificatePem: string): string { + const match = /-----BEGIN CERTIFICATE-----([A-Za-z0-9+/=\s]+)-----END CERTIFICATE-----/.exec(certificatePem); + if (!match) { + throw new CalendarSyncError('invalid-certificate', 'The configured value is not a PEM-encoded certificate'); + } + const der = Buffer.from(match[1].replace(/\s+/g, ''), 'base64'); + return base64url(crypto.createHash('sha1').update(der).digest()); +} + +/** + * Builds the signed JWT (client_assertion) for the OAuth 2.0 client credentials + * flow with a certificate credential, per the Microsoft identity platform + * certificate-credentials spec. RS256 over the app's private key; the private + * key never leaves the process. + */ +export function buildClientAssertion({ + clientId, + tokenUrl, + certificatePem, + privateKeyPem, + now = Date.now(), + jti = crypto.randomUUID(), +}: { + clientId: string; + tokenUrl: string; + certificatePem: string; + privateKeyPem: string; + now?: number; + jti?: string; +}): string { + const header = { alg: 'RS256', typ: 'JWT', x5t: certificateThumbprint(certificatePem) }; + const nowSeconds = Math.floor(now / 1000); + const payload = { + aud: tokenUrl, + iss: clientId, + sub: clientId, + jti, + nbf: nowSeconds - 60, + iat: nowSeconds, + exp: nowSeconds + 10 * 60, + }; + + const signingInput = `${base64url(JSON.stringify(header))}.${base64url(JSON.stringify(payload))}`; + + let signature: Buffer; + try { + signature = crypto.createSign('RSA-SHA256').update(signingInput).sign(privateKeyPem); + } catch (error) { + throw new CalendarSyncError('invalid-private-key', `Unable to sign the client assertion: ${(error as Error).message}`); + } + + return `${signingInput}.${base64url(signature)}`; +} diff --git a/apps/meteor/ee/server/lib/calendarSync/providers/graph/clouds.ts b/apps/meteor/ee/server/lib/calendarSync/providers/graph/clouds.ts new file mode 100644 index 0000000000000..f5970c15610e5 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/providers/graph/clouds.ts @@ -0,0 +1,24 @@ +export type GraphCloud = 'commercial' | 'gcc-high' | 'dod'; + +/** + * Microsoft national-cloud endpoints. GCC High and DoD (Azure Government) matter + * for Rocket.Chat's federal customers; both authenticate against the .us login host. + */ +export const GRAPH_CLOUDS: Record = { + 'commercial': { + loginHost: 'https://login.microsoftonline.com', + graphHost: 'https://graph.microsoft.com', + }, + 'gcc-high': { + loginHost: 'https://login.microsoftonline.us', + graphHost: 'https://graph.microsoft.us', + }, + 'dod': { + loginHost: 'https://login.microsoftonline.us', + graphHost: 'https://dod-graph.microsoft.us', + }, +}; + +export function resolveGraphCloud(value: string | undefined): GraphCloud { + return value === 'gcc-high' || value === 'dod' ? value : 'commercial'; +} diff --git a/apps/meteor/ee/server/lib/calendarSync/startup.ts b/apps/meteor/ee/server/lib/calendarSync/startup.ts new file mode 100644 index 0000000000000..e7294f6270ac0 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/startup.ts @@ -0,0 +1,102 @@ +import { cronJobs } from '@rocket.chat/cron'; +import { Logger } from '@rocket.chat/logger'; +import { CalendarSyncState, Permissions } from '@rocket.chat/models'; + +import { CalendarSyncEngine } from './CalendarSyncEngine'; +import type { ICalendarSyncEngineConfig } from './CalendarSyncEngine'; +import { getConfiguredProvider } from './factory'; +import { settings } from '../../../../app/settings/server'; + +const JOB_NAME = 'calendar-sync'; + +const logger = new Logger('CalendarSync'); + +function getWebhookUrl(): string { + const siteUrl = (settings.get('Site_Url') || '').replace(/\/+$/, ''); + // Microsoft only delivers notifications to public HTTPS endpoints + if (!siteUrl.startsWith('https://')) { + return ''; + } + return `${siteUrl}/api/v1/calendar-sync.webhook`; +} + +function getEngineConfig(): ICalendarSyncEngineConfig { + return { + mode: settings.get('CalendarSync_Mode') || 'full-events', + windowDays: Math.max(1, settings.get('CalendarSync_Window_Days') || 7), + batchSize: Math.max(1, settings.get('CalendarSync_Batch_Size') || 10), + presenceEnabled: settings.get('CalendarSync_Presence_Enabled') ?? true, + mailboxSource: settings.get('CalendarSync_Mailbox_Source') === 'custom-field' ? 'custom-field' : 'email', + mailboxCustomField: settings.get('CalendarSync_Mailbox_CustomField') || '', + defaultLanguage: settings.get('Language') || 'en', + roles: (settings.get('CalendarSync_User_Roles') || '') + .split(',') + .map((role) => role.trim()) + .filter(Boolean), + webhooksEnabled: settings.get('CalendarSync_Webhooks_Enabled') === true, + webhookUrl: getWebhookUrl(), + }; +} + +export const calendarSyncEngine = new CalendarSyncEngine(getConfiguredProvider, getEngineConfig, logger); + +export async function createPermissions(): Promise { + await Permissions.create('manage-calendar-sync', ['admin']); +} + +async function deployJob(): Promise { + const enabled = settings.get('CalendarSync_Enabled'); + const interval = Math.max(1, settings.get('CalendarSync_Interval') || 5); + + if (await cronJobs.has(JOB_NAME)) { + await cronJobs.remove(JOB_NAME); + } + + if (!enabled) { + return; + } + + await cronJobs.add(JOB_NAME, `${interval} minutes`, async () => { + await calendarSyncEngine.runSync(); + }); + logger.info(`Calendar sync job scheduled every ${interval} minute(s)`); +} + +/** + * Settings whose change invalidates all per-user sync state (delta tokens would + * refer to another mailbox/provider or carry the wrong privacy footprint). + */ +const STATE_RESET_SETTINGS = [ + 'CalendarSync_Provider', + 'CalendarSync_Mode', + 'CalendarSync_Presence_Enabled', + 'CalendarSync_Mailbox_Source', + 'CalendarSync_Mailbox_CustomField', + 'CalendarSync_Graph_Cloud', +]; + +const JOB_SETTINGS = ['CalendarSync_Enabled', 'CalendarSync_Interval']; + +export async function configureCalendarSync(): Promise { + const snapshot = new Map(STATE_RESET_SETTINGS.map((id) => [id, settings.get(id)])); + + settings.watchMultiple([...JOB_SETTINGS, ...STATE_RESET_SETTINGS], async () => { + let stateReset = false; + for (const id of STATE_RESET_SETTINGS) { + const value = settings.get(id); + if (snapshot.get(id) !== value) { + snapshot.set(id, value); + stateReset = true; + } + } + + if (stateReset) { + logger.info('Calendar sync configuration changed; resetting per-user sync state to force a full resync'); + await CalendarSyncState.removeAll(); + } + + await deployJob(); + }); + + await deployJob(); +} diff --git a/apps/meteor/ee/server/lib/calendarSync/webhooks.ts b/apps/meteor/ee/server/lib/calendarSync/webhooks.ts new file mode 100644 index 0000000000000..d48c04ed10f68 --- /dev/null +++ b/apps/meteor/ee/server/lib/calendarSync/webhooks.ts @@ -0,0 +1,58 @@ +import { CalendarSyncState } from '@rocket.chat/models'; + +import type { CalendarSyncEngine } from './CalendarSyncEngine'; + +export interface IGraphChangeNotification { + subscriptionId?: string; + clientState?: string; +} + +export interface IGraphNotificationPayload { + value?: IGraphChangeNotification[]; +} + +interface ILoggerLike { + warn(...args: unknown[]): void; + error(...args: unknown[]): void; +} + +/** + * Handles a Microsoft Graph change-notification payload: notifications are matched + * to their per-user subscription and authenticated via the stored clientState + * (unknown or mismatched notifications are dropped), then the affected users are + * re-synced immediately. Sync content never comes from the notification itself — + * it only triggers the regular (delta) sync path. + */ +export async function processGraphNotifications( + payload: IGraphNotificationPayload, + engine: Pick, + logger: ILoggerLike, +): Promise { + const notifications = Array.isArray(payload?.value) ? payload.value : []; + const uids = new Set(); + + for (const notification of notifications) { + if (typeof notification?.subscriptionId !== 'string' || typeof notification?.clientState !== 'string') { + continue; + } + + const state = await CalendarSyncState.findOneBySubscriptionId(notification.subscriptionId); + if (!state) { + continue; + } + if (state.subscriptionClientState !== notification.clientState) { + logger.warn(`Dropping calendar change notification with mismatched clientState for subscription ${notification.subscriptionId}`); + continue; + } + + uids.add(state.uid); + } + + for (const uid of uids) { + try { + await engine.syncUserById(uid); + } catch (error) { + logger.error(`Calendar sync triggered by change notification failed for user ${uid}`, error); + } + } +} diff --git a/apps/meteor/ee/server/meteor-methods/calendarSyncTestConnection.ts b/apps/meteor/ee/server/meteor-methods/calendarSyncTestConnection.ts new file mode 100644 index 0000000000000..e1eb113531902 --- /dev/null +++ b/apps/meteor/ee/server/meteor-methods/calendarSyncTestConnection.ts @@ -0,0 +1,40 @@ +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import type { TranslationKey } from '@rocket.chat/ui-contexts'; +import { Meteor } from 'meteor/meteor'; + +import { hasPermissionAsync } from '../../../server/lib/authorization/hasPermission'; +import { getConfiguredProvider } from '../lib/calendarSync/factory'; + +declare module '@rocket.chat/ddp-client' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface ServerMethods { + calendarSyncTestConnection(): { message: TranslationKey; params: string[] }; + } +} + +Meteor.methods({ + async calendarSyncTestConnection() { + const uid = Meteor.userId(); + if (!uid || !(await hasPermissionAsync(uid, 'manage-calendar-sync'))) { + throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'calendarSyncTestConnection' }); + } + + const provider = getConfiguredProvider(); + if (!provider) { + throw new Meteor.Error('error-calendar-sync-provider-not-configured', 'The calendar sync provider is not fully configured', { + method: 'calendarSyncTestConnection', + }); + } + + // Errors are already sanitized (no tokens/credentials) and carry actionable codes + const result = await provider.testConnection(); + if (!result.ok) { + throw new Meteor.Error(`${result.error?.code}: ${result.error?.message}`, undefined, { method: 'calendarSyncTestConnection' }); + } + + return { + message: 'CalendarSync_Connection_Successful' as TranslationKey, + params: [], + }; + }, +}); diff --git a/apps/meteor/ee/server/settings/calendarSync.ts b/apps/meteor/ee/server/settings/calendarSync.ts new file mode 100644 index 0000000000000..10cedf48923b8 --- /dev/null +++ b/apps/meteor/ee/server/settings/calendarSync.ts @@ -0,0 +1,206 @@ +import { settingsRegistry } from '../../../app/settings/server'; + +export function addSettings(): void { + void settingsRegistry.addGroup('Calendar_Sync', async function () { + await this.with( + { + enterprise: true, + modules: ['outlook-calendar'], + }, + async function () { + await this.add('CalendarSync_Enabled', false, { + type: 'boolean', + public: true, + invalidValue: false, + }); + + await this.add('CalendarSync_Provider', 'microsoft-graph', { + type: 'select', + values: [ + { key: 'microsoft-graph', i18nLabel: 'CalendarSync_Provider_Microsoft_Graph' }, + { key: 'exchange-ews', i18nLabel: 'CalendarSync_Provider_Exchange_EWS' }, + ], + invalidValue: 'microsoft-graph', + enableQuery: { _id: 'CalendarSync_Enabled', value: true }, + }); + + await this.add('CalendarSync_Mode', 'full-events', { + type: 'select', + values: [ + { key: 'full-events', i18nLabel: 'CalendarSync_Mode_Full_Events' }, + { key: 'free-busy-only', i18nLabel: 'CalendarSync_Mode_Free_Busy_Only' }, + ], + invalidValue: 'full-events', + enableQuery: { _id: 'CalendarSync_Enabled', value: true }, + }); + + await this.section('CalendarSync_Section_Microsoft_Graph', async function () { + const graphQuery = { _id: 'CalendarSync_Provider', value: 'microsoft-graph' }; + + await this.add('CalendarSync_Graph_Cloud', 'commercial', { + type: 'select', + values: [ + { key: 'commercial', i18nLabel: 'CalendarSync_Graph_Cloud_Commercial' }, + { key: 'gcc-high', i18nLabel: 'CalendarSync_Graph_Cloud_GccHigh' }, + { key: 'dod', i18nLabel: 'CalendarSync_Graph_Cloud_Dod' }, + ], + invalidValue: 'commercial', + enableQuery: graphQuery, + }); + + await this.add('CalendarSync_Graph_TenantId', '', { + type: 'string', + invalidValue: '', + enableQuery: graphQuery, + }); + + await this.add('CalendarSync_Graph_ClientId', '', { + type: 'string', + invalidValue: '', + enableQuery: graphQuery, + }); + + await this.add('CalendarSync_Graph_Auth_Method', 'client-secret', { + type: 'select', + values: [ + { key: 'client-secret', i18nLabel: 'CalendarSync_Graph_Auth_Method_Client_Secret' }, + { key: 'certificate', i18nLabel: 'CalendarSync_Graph_Auth_Method_Certificate' }, + ], + invalidValue: 'client-secret', + enableQuery: graphQuery, + }); + + await this.add('CalendarSync_Graph_ClientSecret', '', { + type: 'password', + secret: true, + invalidValue: '', + enableQuery: [graphQuery, { _id: 'CalendarSync_Graph_Auth_Method', value: 'client-secret' }], + }); + + await this.add('CalendarSync_Graph_Certificate', '', { + type: 'string', + multiline: true, + invalidValue: '', + enableQuery: [graphQuery, { _id: 'CalendarSync_Graph_Auth_Method', value: 'certificate' }], + }); + + await this.add('CalendarSync_Graph_PrivateKey', '', { + type: 'string', + multiline: true, + secret: true, + invalidValue: '', + enableQuery: [graphQuery, { _id: 'CalendarSync_Graph_Auth_Method', value: 'certificate' }], + }); + }); + + await this.section('CalendarSync_Section_Exchange_EWS', async function () { + const ewsQuery = { _id: 'CalendarSync_Provider', value: 'exchange-ews' }; + + await this.add('CalendarSync_Ews_Url', '', { + type: 'string', + invalidValue: '', + placeholder: 'https://mail.example.com/EWS/Exchange.asmx', + enableQuery: ewsQuery, + }); + + await this.add('CalendarSync_Ews_Username', '', { + type: 'string', + invalidValue: '', + placeholder: 'DOMAIN\\serviceaccount', + enableQuery: ewsQuery, + }); + + await this.add('CalendarSync_Ews_Password', '', { + type: 'password', + secret: true, + invalidValue: '', + enableQuery: ewsQuery, + }); + + await this.add('CalendarSync_Ews_AuthMethod', 'ntlm', { + type: 'select', + values: [ + { key: 'ntlm', i18nLabel: 'CalendarSync_Ews_AuthMethod_Ntlm' }, + { key: 'basic', i18nLabel: 'CalendarSync_Ews_AuthMethod_Basic' }, + ], + invalidValue: 'ntlm', + enableQuery: ewsQuery, + }); + + await this.add('CalendarSync_Ews_AllowSelfSignedCerts', false, { + type: 'boolean', + invalidValue: false, + enableQuery: ewsQuery, + }); + }); + + await this.section('CalendarSync_Section_Schedule', async function () { + await this.add('CalendarSync_Interval', 5, { + type: 'int', + invalidValue: 5, + enableQuery: { _id: 'CalendarSync_Enabled', value: true }, + }); + + await this.add('CalendarSync_Window_Days', 7, { + type: 'int', + invalidValue: 7, + enableQuery: { _id: 'CalendarSync_Enabled', value: true }, + }); + + await this.add('CalendarSync_Batch_Size', 10, { + type: 'int', + invalidValue: 10, + enableQuery: { _id: 'CalendarSync_Enabled', value: true }, + }); + + await this.add('CalendarSync_Webhooks_Enabled', false, { + type: 'boolean', + invalidValue: false, + enableQuery: [ + { _id: 'CalendarSync_Enabled', value: true }, + { _id: 'CalendarSync_Provider', value: 'microsoft-graph' }, + ], + }); + }); + + await this.add('CalendarSync_User_Roles', '', { + type: 'string', + invalidValue: '', + enableQuery: { _id: 'CalendarSync_Enabled', value: true }, + }); + + await this.add('CalendarSync_Test_Connection', 'calendarSyncTestConnection', { + type: 'action', + actionText: 'Test_Connection', + i18nLabel: 'Test_Connection', + }); + + await this.section('CalendarSync_Section_Mailbox_Mapping', async function () { + await this.add('CalendarSync_Mailbox_Source', 'email', { + type: 'select', + values: [ + { key: 'email', i18nLabel: 'CalendarSync_Mailbox_Source_Email' }, + { key: 'custom-field', i18nLabel: 'CalendarSync_Mailbox_Source_Custom_Field' }, + ], + invalidValue: 'email', + enableQuery: { _id: 'CalendarSync_Enabled', value: true }, + }); + + await this.add('CalendarSync_Mailbox_CustomField', '', { + type: 'string', + invalidValue: '', + enableQuery: { _id: 'CalendarSync_Mailbox_Source', value: 'custom-field' }, + }); + }); + + await this.section('CalendarSync_Section_Presence', async function () { + await this.add('CalendarSync_Presence_Enabled', true, { + type: 'boolean', + invalidValue: false, + enableQuery: { _id: 'CalendarSync_Enabled', value: true }, + }); + }); + }, + ); + }); +} diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts index b98418674e216..d8e994539f253 100644 --- a/apps/meteor/server/models.ts +++ b/apps/meteor/server/models.ts @@ -8,6 +8,7 @@ import { BannersDismissRaw, BannersRaw, CalendarEventRaw, + CalendarSyncStateRaw, CallHistoryRaw, CredentialTokensRaw, CronHistoryRaw, @@ -90,6 +91,7 @@ registerModel('IAvatarsModel', new AvatarsRaw(db)); registerModel('IBannersDismissModel', new BannersDismissRaw(db)); registerModel('IBannersModel', new BannersRaw(db)); registerModel('ICalendarEventModel', new CalendarEventRaw(db)); +registerModel('ICalendarSyncStateModel', new CalendarSyncStateRaw(db)); registerModel('ICallHistoryModel', new CallHistoryRaw(db)); registerModel('ICredentialTokensModel', new CredentialTokensRaw(db)); registerModel('ICronHistoryModel', new CronHistoryRaw(db)); diff --git a/apps/meteor/server/services/calendar/service.ts b/apps/meteor/server/services/calendar/service.ts index 9e842397a5202..02fd140afc762 100644 --- a/apps/meteor/server/services/calendar/service.ts +++ b/apps/meteor/server/services/calendar/service.ts @@ -54,7 +54,7 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe return this.create(data); } - const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, busy } = data; + const { uid, startTime, endTime, subject, description, reminderMinutesBeforeStart, busy, provider, iCalUId } = data; const meetingUrl = data.meetingUrl ? data.meetingUrl : await this.parseDescriptionForMeetingUrl(description); const reminderTime = reminderMinutesBeforeStart ? getShiftedTime(startTime, -reminderMinutesBeforeStart) : undefined; @@ -68,6 +68,8 @@ export class CalendarService extends ServiceClassInternal implements ICalendarSe reminderTime, externalId, ...(busy !== undefined && { busy }), + ...(provider !== undefined && { provider }), + ...(iCalUId !== undefined && { iCalUId }), }; const event = await this.findImportedEvent(externalId, uid); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/CalendarSyncEngine.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/CalendarSyncEngine.spec.ts new file mode 100644 index 0000000000000..e6e92b9318187 --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/CalendarSyncEngine.spec.ts @@ -0,0 +1,616 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const calendarImportStub = sinon.stub(); +const calendarDeleteStub = sinon.stub(); +const usersFindStub = sinon.stub(); +const usersFindOneByIdStub = sinon.stub(); +const setSubscriptionStub = sinon.stub(); +const findOneByExternalIdAndUserIdStub = sinon.stub(); +const findServerSyncedStub = sinon.stub(); +const syncStateFindOneStub = sinon.stub(); +const recordSuccessStub = sinon.stub(); +const recordFailureStub = sinon.stub(); +const setActiveStateStub = sinon.stub(); +const endActiveStateStub = sinon.stub(); + +const { CalendarSyncEngine, computeBusyUntil } = proxyquire + .noCallThru() + .load('../../../../../ee/server/lib/calendarSync/CalendarSyncEngine.ts', { + '@rocket.chat/core-services': { + Calendar: { + import: calendarImportStub, + delete: calendarDeleteStub, + }, + Presence: { + setActiveState: setActiveStateStub, + endActiveState: endActiveStateStub, + }, + }, + '@rocket.chat/models': { + Users: { find: usersFindStub, findOneById: usersFindOneByIdStub }, + CalendarEvent: { + findOneByExternalIdAndUserId: findOneByExternalIdAndUserIdStub, + findServerSyncedByUserIdBetweenDates: findServerSyncedStub, + }, + CalendarSyncState: { + findOneByUserId: syncStateFindOneStub, + recordSuccess: recordSuccessStub, + recordFailure: recordFailureStub, + setSubscription: setSubscriptionStub, + }, + }, + '../../../../server/lib/i18n': { + i18n: { t: (key: string) => key }, + }, + }); + +const silentLogger = { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, +}; + +const DEFAULT_CONFIG = { + mode: 'full-events' as 'full-events' | 'free-busy-only', + windowDays: 7, + batchSize: 10, + presenceEnabled: true, + mailboxSource: 'email' as const, + mailboxCustomField: '', + defaultLanguage: 'en', + roles: [] as string[], + webhooksEnabled: false, + webhookUrl: '', +}; + +const user = (id: string, email = `${id}@example.com`) => ({ + _id: id, + username: id, + emails: [{ address: email, verified: true }], +}); + +const externalEvent = (externalId: string, overrides: Record = {}) => ({ + externalId, + iCalUId: `ical-${externalId}`, + subject: `Meeting ${externalId}`, + description: 'agenda', + startTime: new Date('2026-07-12T10:00:00Z'), + endTime: new Date('2026-07-12T11:00:00Z'), + busy: true, + ...overrides, +}); + +const makeProvider = (overrides: Record = {}): any => ({ + type: 'microsoft-graph', + supportsDelta: true, + supportsWebhooks: false, + testConnection: sinon.stub().resolves({ ok: true }), + getFreeBusy: sinon.stub().resolves([]), + listEvents: sinon.stub().resolves({ events: [], deletedEventIds: [], full: true }), + ...overrides, +}); + +const makeEngine = (provider: any, config: Partial = {}) => + new CalendarSyncEngine( + () => provider, + () => ({ ...DEFAULT_CONFIG, ...config }), + silentLogger, + ); + +describe('calendarSync/CalendarSyncEngine', () => { + beforeEach(() => { + calendarImportStub.reset(); + calendarImportStub.resolves('event-id'); + calendarDeleteStub.reset(); + calendarDeleteStub.resolves({ deletedCount: 1 }); + usersFindStub.reset(); + findOneByExternalIdAndUserIdStub.reset(); + findOneByExternalIdAndUserIdStub.resolves(null); + findServerSyncedStub.reset(); + findServerSyncedStub.returns([]); + syncStateFindOneStub.reset(); + syncStateFindOneStub.resolves(null); + recordSuccessStub.reset(); + recordSuccessStub.resolves({}); + recordFailureStub.reset(); + recordFailureStub.resolves({}); + setActiveStateStub.reset(); + setActiveStateStub.resolves(true); + endActiveStateStub.reset(); + endActiveStateStub.resolves(true); + usersFindOneByIdStub.reset(); + usersFindOneByIdStub.resolves(null); + setSubscriptionStub.reset(); + setSubscriptionStub.resolves({}); + }); + + it('should upsert fetched events through Calendar.import with the provider discriminator', async () => { + usersFindStub.returns([user('u1')]); + const provider = makeProvider({ + listEvents: sinon.stub().resolves({ events: [externalEvent('e1')], deletedEventIds: [], full: true, nextDeltaToken: 'd1' }), + }); + + const summary = await makeEngine(provider).runSync(); + + expect(calendarImportStub.calledOnce).to.be.true; + const imported = calendarImportStub.firstCall.args[0]; + expect(imported).to.include({ + uid: 'u1', + externalId: 'e1', + subject: 'Meeting e1', + busy: true, + provider: 'microsoft-graph', + iCalUId: 'ical-e1', + }); + expect(summary?.usersProcessed).to.equal(1); + expect(summary?.eventsUpserted).to.equal(1); + + expect(recordSuccessStub.calledOnce).to.be.true; + expect(recordSuccessStub.firstCall.args[0]).to.equal('u1'); + expect(recordSuccessStub.firstCall.args[1].deltaToken).to.equal('d1'); + }); + + it('should force busy=false on imported events when presence is disabled', async () => { + usersFindStub.returns([user('u1')]); + const provider = makeProvider({ + listEvents: sinon.stub().resolves({ events: [externalEvent('e1', { busy: true })], deletedEventIds: [], full: true }), + }); + + await makeEngine(provider, { presenceEnabled: false }).runSync(); + + expect(calendarImportStub.firstCall.args[0].busy).to.be.false; + }); + + it('should skip and count users without a resolvable mailbox without failing the batch', async () => { + usersFindStub.returns([{ _id: 'u1', emails: [{ address: 'no@example.com', verified: false }] }, user('u2')]); + const provider = makeProvider({ + listEvents: sinon.stub().resolves({ events: [externalEvent('e1')], deletedEventIds: [], full: true }), + }); + + const summary = await makeEngine(provider).runSync(); + + expect(summary?.usersSkippedNoMailbox).to.equal(1); + expect(summary?.usersProcessed).to.equal(1); + expect(summary?.usersFailed).to.equal(0); + expect(calendarImportStub.calledOnce).to.be.true; + expect(calendarImportStub.firstCall.args[0].uid).to.equal('u2'); + }); + + it('should delete events reported as removed, but only when they belong to server sync', async () => { + usersFindStub.returns([user('u1')]); + findOneByExternalIdAndUserIdStub.withArgs('gone-server', 'u1').resolves({ _id: 'ev1', provider: 'microsoft-graph' }); + findOneByExternalIdAndUserIdStub.withArgs('gone-legacy', 'u1').resolves({ _id: 'ev2' }); + const provider = makeProvider({ + listEvents: sinon.stub().resolves({ events: [], deletedEventIds: ['gone-server', 'gone-legacy'], full: false }), + }); + + const summary = await makeEngine(provider).runSync(); + + expect(calendarDeleteStub.calledOnceWith('ev1')).to.be.true; + expect(summary?.eventsDeleted).to.equal(1); + }); + + it('should diff full snapshots against stored events and delete the missing ones', async () => { + usersFindStub.returns([user('u1')]); + findServerSyncedStub.returns([ + { _id: 'ev-stale', externalId: 'stale', provider: 'microsoft-graph' }, + { _id: 'ev-kept', externalId: 'e1', provider: 'microsoft-graph' }, + ]); + const provider = makeProvider({ + listEvents: sinon.stub().resolves({ events: [externalEvent('e1')], deletedEventIds: [], full: true }), + }); + + const summary = await makeEngine(provider).runSync(); + + expect(calendarDeleteStub.calledOnceWith('ev-stale')).to.be.true; + expect(summary?.eventsDeleted).to.equal(1); + }); + + it('should not diff-delete on incremental (delta) results', async () => { + usersFindStub.returns([user('u1')]); + syncStateFindOneStub.resolves({ + uid: 'u1', + mailbox: 'u1@example.com', + provider: 'microsoft-graph', + deltaToken: 'd0', + deltaWindowStart: new Date('2026-01-01T00:00:00Z'), + deltaWindowEnd: new Date('2100-01-01T00:00:00Z'), + }); + const listEvents = sinon.stub().resolves({ events: [], deletedEventIds: [], full: false, nextDeltaToken: 'd1' }); + const provider = makeProvider({ listEvents }); + + await makeEngine(provider).runSync(); + + expect(listEvents.firstCall.args[2]).to.equal('d0'); + expect(findServerSyncedStub.called).to.be.false; + expect(calendarDeleteStub.called).to.be.false; + }); + + it('should ignore the stored delta token when the window outgrew its epoch, the mailbox or provider changed', async () => { + usersFindStub.returns([user('u1')]); + syncStateFindOneStub.resolves({ + uid: 'u1', + mailbox: 'u1@example.com', + provider: 'microsoft-graph', + deltaToken: 'd0', + deltaWindowStart: new Date('2026-07-01T00:00:00Z'), + // epoch already ended: rolling window end is past it + deltaWindowEnd: new Date('2026-07-10T00:00:00Z'), + }); + const listEvents = sinon.stub().resolves({ events: [], deletedEventIds: [], full: true, nextDeltaToken: 'd1' }); + const provider = makeProvider({ listEvents }); + + await makeEngine(provider).runSync(); + + expect(listEvents.firstCall.args[2]).to.be.undefined; + }); + + it('should treat cancelled events as deletions', async () => { + usersFindStub.returns([user('u1')]); + findOneByExternalIdAndUserIdStub.withArgs('e1', 'u1').resolves({ _id: 'ev1', provider: 'microsoft-graph' }); + const provider = makeProvider({ + listEvents: sinon.stub().resolves({ events: [externalEvent('e1', { isCancelled: true })], deletedEventIds: [], full: false }), + }); + + await makeEngine(provider).runSync(); + + expect(calendarImportStub.called).to.be.false; + expect(calendarDeleteStub.calledOnceWith('ev1')).to.be.true; + }); + + it('should record per-user failures and keep processing the remaining users', async () => { + usersFindStub.returns([user('u1'), user('u2')]); + const listEvents = sinon.stub(); + listEvents + .withArgs('u1@example.com', sinon.match.any, sinon.match.any) + .rejects(Object.assign(new Error('denied'), { code: 'consent-missing' })); + listEvents.withArgs('u2@example.com', sinon.match.any, sinon.match.any).resolves({ events: [], deletedEventIds: [], full: true }); + const provider = makeProvider({ listEvents }); + + const summary = await makeEngine(provider).runSync(); + + expect(summary?.usersFailed).to.equal(1); + expect(summary?.usersProcessed).to.equal(1); + expect(recordFailureStub.calledOnce).to.be.true; + expect(recordFailureStub.firstCall.args[0]).to.equal('u1'); + expect(recordFailureStub.firstCall.args[1].error.code).to.equal('consent-missing'); + expect(recordSuccessStub.calledOnceWith('u2')).to.be.true; + }); + + describe('change-notification subscriptions', () => { + const WEBHOOK_CONFIG = { webhooksEnabled: true, webhookUrl: 'https://chat.example.com/api/v1/calendar-sync.webhook' }; + + const subscribableProvider = (overrides: Record = {}) => + makeProvider({ + supportsWebhooks: true, + createSubscription: sinon.stub().resolves({ id: 'sub-new', expiresAt: new Date(Date.now() + 70 * 60 * 60 * 1000) }), + renewSubscription: sinon.stub().resolves({ id: 'sub-old', expiresAt: new Date(Date.now() + 70 * 60 * 60 * 1000) }), + ...overrides, + }); + + it('should create a subscription after a successful sync when none exists', async () => { + usersFindStub.returns([user('u1')]); + const provider = subscribableProvider(); + + await makeEngine(provider, WEBHOOK_CONFIG).runSync(); + + expect(provider.createSubscription.calledOnce).to.be.true; + const [mailbox, url, clientState] = provider.createSubscription.firstCall.args; + expect(mailbox).to.equal('u1@example.com'); + expect(url).to.equal(WEBHOOK_CONFIG.webhookUrl); + expect(clientState).to.be.a('string').with.lengthOf.greaterThan(20); + + expect(setSubscriptionStub.calledOnce).to.be.true; + expect(setSubscriptionStub.firstCall.args[1]).to.deep.include({ id: 'sub-new', clientState }); + }); + + it('should renew a subscription close to expiry and keep its clientState', async () => { + usersFindStub.returns([user('u1')]); + syncStateFindOneStub.resolves({ + uid: 'u1', + mailbox: 'u1@example.com', + provider: 'microsoft-graph', + subscriptionId: 'sub-old', + subscriptionExpiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1h left + subscriptionClientState: 'keep-me', + }); + const provider = subscribableProvider(); + + await makeEngine(provider, WEBHOOK_CONFIG).runSync(); + + expect(provider.renewSubscription.calledOnceWith('sub-old')).to.be.true; + expect(provider.createSubscription.called).to.be.false; + expect(setSubscriptionStub.firstCall.args[1].clientState).to.equal('keep-me'); + }); + + it('should recreate the subscription when renewal fails', async () => { + usersFindStub.returns([user('u1')]); + syncStateFindOneStub.resolves({ + uid: 'u1', + mailbox: 'u1@example.com', + provider: 'microsoft-graph', + subscriptionId: 'sub-old', + subscriptionExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + subscriptionClientState: 'old-state', + }); + const provider = subscribableProvider({ + renewSubscription: sinon.stub().rejects(new Error('gone')), + }); + + await makeEngine(provider, WEBHOOK_CONFIG).runSync(); + + expect(provider.createSubscription.calledOnce).to.be.true; + }); + + it('should leave healthy subscriptions alone and do nothing when webhooks are disabled', async () => { + usersFindStub.returns([user('u1')]); + syncStateFindOneStub.resolves({ + uid: 'u1', + mailbox: 'u1@example.com', + provider: 'microsoft-graph', + subscriptionId: 'sub-old', + subscriptionExpiresAt: new Date(Date.now() + 60 * 60 * 60 * 1000), // 60h left + subscriptionClientState: 's', + }); + const healthy = subscribableProvider(); + await makeEngine(healthy, WEBHOOK_CONFIG).runSync(); + expect(healthy.createSubscription.called).to.be.false; + expect(healthy.renewSubscription.called).to.be.false; + + usersFindStub.returns([user('u1')]); + syncStateFindOneStub.resolves(null); + const disabled = subscribableProvider(); + await makeEngine(disabled).runSync(); + expect(disabled.createSubscription.called).to.be.false; + }); + + it('should never fail the user sync because of subscription errors', async () => { + usersFindStub.returns([user('u1')]); + const provider = subscribableProvider({ + createSubscription: sinon.stub().rejects(new Error('subscription quota exceeded')), + }); + + const summary = await makeEngine(provider, WEBHOOK_CONFIG).runSync(); + + expect(summary?.usersProcessed).to.equal(1); + expect(summary?.usersFailed).to.equal(0); + }); + }); + + describe('syncUserById', () => { + it('should sync a single user on demand', async () => { + usersFindOneByIdStub.withArgs('u1').resolves(user('u1')); + const provider = makeProvider({ + listEvents: sinon.stub().resolves({ events: [externalEvent('e1')], deletedEventIds: [], full: true }), + }); + + const ok = await makeEngine(provider).syncUserById('u1'); + + expect(ok).to.be.true; + expect(calendarImportStub.calledOnce).to.be.true; + expect(calendarImportStub.firstCall.args[0].uid).to.equal('u1'); + }); + + it('should return false for unknown users, failed syncs and non-event modes', async () => { + const provider = makeProvider(); + expect(await makeEngine(provider).syncUserById('missing')).to.be.false; + + usersFindOneByIdStub.withArgs('u1').resolves(user('u1')); + expect(await makeEngine(provider, { mode: 'free-busy-only' }).syncUserById('u1')).to.be.false; + + const failing = makeProvider({ listEvents: sinon.stub().rejects(new Error('down')) }); + expect(await makeEngine(failing).syncUserById('u1')).to.be.false; + }); + }); + + it('should restrict the user query to the configured roles', async () => { + usersFindStub.returns([]); + const provider = makeProvider(); + + await makeEngine(provider, { roles: ['sales', 'support'] }).runSync(); + expect(usersFindStub.firstCall.args[0]).to.deep.include({ roles: { $in: ['sales', 'support'] } }); + + usersFindStub.resetHistory(); + usersFindStub.returns([]); + await makeEngine(provider).runSync(); + expect(usersFindStub.firstCall.args[0]).to.not.have.property('roles'); + }); + + it('should do nothing when no provider is configured', async () => { + const summary = await makeEngine(null).runSync(); + expect(summary).to.be.null; + expect(usersFindStub.called).to.be.false; + }); + + it('should skip free-busy-only runs when presence updates are disabled', async () => { + const provider = makeProvider(); + const summary = await makeEngine(provider, { mode: 'free-busy-only', presenceEnabled: false }).runSync(); + expect(summary).to.be.null; + expect(provider.listEvents.called).to.be.false; + expect(provider.getFreeBusy.called).to.be.false; + }); + + describe('free-busy-only mode', () => { + const NOW_MS_TOLERANCE = 5_000; + const inMinutes = (minutes: number) => new Date(Date.now() + minutes * 60_000); + + it('should drive presence from availability without creating any event records', async () => { + usersFindStub.returns([user('u1')]); + const getFreeBusy = sinon.stub().resolves([ + { + mailbox: 'u1@example.com', + intervals: [{ start: inMinutes(-30), end: inMinutes(30), status: 'busy' }], + }, + ]); + const provider = makeProvider({ getFreeBusy }); + + const summary = await makeEngine(provider, { mode: 'free-busy-only' }).runSync(); + + expect(provider.listEvents.called).to.be.false; + expect(calendarImportStub.called).to.be.false; + + expect(setActiveStateStub.calledOnce).to.be.true; + const [uid, state] = setActiveStateStub.firstCall.args; + expect(uid).to.equal('u1'); + expect(state.statusId).to.equal('calendar'); + expect(state.statusSource).to.equal('external'); + expect(state.statusText).to.equal('Presence_status_outlook_in_a_meeting'); + expect(state.statusExpiresAt.getTime()).to.be.closeTo(inMinutes(30).getTime(), NOW_MS_TOLERANCE); + + expect(summary?.usersProcessed).to.equal(1); + expect(summary?.eventsUpserted).to.equal(0); + expect(recordSuccessStub.calledOnce).to.be.true; + expect(recordSuccessStub.firstCall.args[1].deltaToken).to.be.undefined; + }); + + it('should merge back-to-back busy intervals into one expiry', async () => { + usersFindStub.returns([user('u1')]); + const provider = makeProvider({ + getFreeBusy: sinon.stub().resolves([ + { + mailbox: 'u1@example.com', + intervals: [ + { start: inMinutes(-10), end: inMinutes(20), status: 'busy' }, + { start: inMinutes(20), end: inMinutes(50), status: 'tentative' }, + { start: inMinutes(120), end: inMinutes(150), status: 'busy' }, // detached: must not extend + ], + }, + ]), + }); + + await makeEngine(provider, { mode: 'free-busy-only' }).runSync(); + + expect(setActiveStateStub.firstCall.args[1].statusExpiresAt.getTime()).to.be.closeTo(inMinutes(50).getTime(), NOW_MS_TOLERANCE); + }); + + it('should end the calendar claim when the user is not busy now', async () => { + usersFindStub.returns([user('u1')]); + const provider = makeProvider({ + getFreeBusy: sinon.stub().resolves([ + { + mailbox: 'u1@example.com', + intervals: [{ start: inMinutes(60), end: inMinutes(90), status: 'busy' }], + }, + ]), + }); + + const summary = await makeEngine(provider, { mode: 'free-busy-only' }).runSync(); + + expect(setActiveStateStub.called).to.be.false; + expect(endActiveStateStub.calledOnceWith('u1', 'calendar')).to.be.true; + expect(summary?.usersProcessed).to.equal(1); + }); + + it('should batch all mailboxes into a single availability request', async () => { + usersFindStub.returns([user('u1'), user('u2'), { _id: 'u3', emails: [{ address: 'x', verified: false }] }]); + const getFreeBusy = sinon.stub().resolves([ + { mailbox: 'u1@example.com', intervals: [] }, + { mailbox: 'u2@example.com', intervals: [] }, + ]); + const provider = makeProvider({ getFreeBusy }); + + const summary = await makeEngine(provider, { mode: 'free-busy-only' }).runSync(); + + expect(getFreeBusy.calledOnce).to.be.true; + expect(getFreeBusy.firstCall.args[0]).to.deep.equal(['u1@example.com', 'u2@example.com']); + expect(summary?.usersSkippedNoMailbox).to.equal(1); + expect(summary?.usersProcessed).to.equal(2); + }); + + it('should record per-mailbox availability errors without failing the batch', async () => { + usersFindStub.returns([user('u1'), user('u2')]); + const provider = makeProvider({ + getFreeBusy: sinon.stub().resolves([ + { mailbox: 'u1@example.com', intervals: [], error: { code: 'schedule-unavailable', message: 'nope' } }, + { mailbox: 'u2@example.com', intervals: [] }, + ]), + }); + + const summary = await makeEngine(provider, { mode: 'free-busy-only' }).runSync(); + + expect(summary?.usersFailed).to.equal(1); + expect(summary?.usersProcessed).to.equal(1); + expect(recordFailureStub.calledOnce).to.be.true; + expect(recordFailureStub.firstCall.args[0]).to.equal('u1'); + expect(recordFailureStub.firstCall.args[1].error.code).to.equal('schedule-unavailable'); + }); + + it('should fail the whole batch gracefully when the availability request throws', async () => { + usersFindStub.returns([user('u1'), user('u2')]); + const provider = makeProvider({ + getFreeBusy: sinon.stub().rejects(Object.assign(new Error('down'), { code: 'network-error' })), + }); + + const summary = await makeEngine(provider, { mode: 'free-busy-only' }).runSync(); + + expect(summary?.usersFailed).to.equal(2); + expect(recordFailureStub.callCount).to.equal(2); + expect(setActiveStateStub.called).to.be.false; + }); + }); + + describe('computeBusyUntil', () => { + const now = new Date('2026-07-11T12:00:00Z'); + const at = (iso: string) => new Date(iso); + + it('should return null when no interval covers now', () => { + expect(computeBusyUntil([], now)).to.be.null; + expect(computeBusyUntil([{ start: at('2026-07-11T13:00:00Z'), end: at('2026-07-11T14:00:00Z'), status: 'busy' }], now)).to.be.null; + }); + + it('should return the end of the covering interval', () => { + const result = computeBusyUntil([{ start: at('2026-07-11T11:00:00Z'), end: at('2026-07-11T12:30:00Z'), status: 'busy' }], now); + expect(result?.toISOString()).to.equal('2026-07-11T12:30:00.000Z'); + }); + + it('should chain overlapping and adjacent intervals regardless of input order', () => { + const result = computeBusyUntil( + [ + { start: at('2026-07-11T13:00:00Z'), end: at('2026-07-11T14:00:00Z'), status: 'busy' }, + { start: at('2026-07-11T11:30:00Z'), end: at('2026-07-11T12:15:00Z'), status: 'busy' }, + { start: at('2026-07-11T12:10:00Z'), end: at('2026-07-11T13:00:00Z'), status: 'oof' }, + ], + now, + ); + expect(result?.toISOString()).to.equal('2026-07-11T14:00:00.000Z'); + }); + + it('should not extend past a gap', () => { + const result = computeBusyUntil( + [ + { start: at('2026-07-11T11:00:00Z'), end: at('2026-07-11T12:30:00Z'), status: 'busy' }, + { start: at('2026-07-11T12:45:00Z'), end: at('2026-07-11T14:00:00Z'), status: 'busy' }, + ], + now, + ); + expect(result?.toISOString()).to.equal('2026-07-11T12:30:00.000Z'); + }); + }); + + it('should not overlap concurrent runs', async () => { + usersFindStub.returns([user('u1')]); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const listEvents = sinon.stub().callsFake(async () => { + await gate; + return { events: [], deletedEventIds: [], full: true }; + }); + const provider = makeProvider({ listEvents }); + const engine = makeEngine(provider); + + const first = engine.runSync(); + const second = await engine.runSync(); + expect(second).to.be.null; + + release(); + const summary = await first; + expect(summary?.usersProcessed).to.equal(1); + expect(listEvents.calledOnce).to.be.true; + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/ExchangeEwsCalendarProvider.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/ExchangeEwsCalendarProvider.spec.ts new file mode 100644 index 0000000000000..cdac138df15f2 --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/ExchangeEwsCalendarProvider.spec.ts @@ -0,0 +1,316 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import sinon from 'sinon'; + +import { ExchangeEwsCalendarProvider } from '../../../../../ee/server/lib/calendarSync/providers/ews/ExchangeEwsCalendarProvider'; + +const WINDOW = { + start: new Date('2026-07-11T00:00:00Z'), + end: new Date('2026-07-18T00:00:00Z'), +}; + +const CONFIG = { + url: 'https://mail.example.mil/EWS/Exchange.asmx', + username: 'CONTOSO\\svc', + password: 'pw', + authMethod: 'ntlm' as const, + allowSelfSignedCerts: false, +}; + +const M = 'http://schemas.microsoft.com/exchange/services/2006/messages'; +const T = 'http://schemas.microsoft.com/exchange/services/2006/types'; + +const envelope = (body: string) => + `${body}`; + +const itemXml = ({ + id, + subject = `Meeting ${id}`, + start = '2026-07-12T10:00:00Z', + end = '2026-07-12T11:00:00Z', + status = 'Busy', + uid = `UID-${id}`, + cancelled = false, +}: { + id: string; + subject?: string; + start?: string; + end?: string; + status?: string; + uid?: string; + cancelled?: boolean; +}) => + `${subject}` + + `${start}${end}` + + `${status}${uid}` + + `${cancelled}`; + +const findItemResponse = (items: string[]) => + envelope( + `` + + `NoError` + + `${items.join('')}` + + ``, + ); + +const getItemResponse = (bodies: [string, string][]) => + envelope( + `` + + `NoError${bodies + .map(([id, body]) => `${body}`) + .join('')}`, + ); + +const syncResponse = ({ + state, + last = true, + creates = [], + deletes = [], +}: { + state: string; + last?: boolean; + creates?: string[]; + deletes?: string[]; +}) => + envelope( + `` + + `NoError` + + `${state}${last}` + + `${creates.map((item) => `${item}`).join('')}${deletes + .map((id) => ``) + .join('')}` + + ``, + ); + +const syncStateErrorResponse = envelope( + `` + + `ErrorInvalidSyncStateData` + + ``, +); + +const getFolderResponse = envelope( + `` + + `NoError` + + ``, +); + +/** Routes stubbed responses by SOAP operation; records request bodies per operation */ +const routedClient = (routes: { + sync?: (call: number) => string; + find?: string; + getItem?: string; + availability?: string; + getFolder?: string; +}) => { + const requests: Record = { sync: [], find: [], getItem: [], availability: [], getFolder: [] }; + let syncCalls = 0; + const post = sinon.stub().callsFake(async (soapXml: string) => { + const respond = (body?: string): { statusCode: number; headers: Record; body: string } => { + if (body === undefined) { + throw new Error(`Unexpected SOAP operation in test: ${soapXml.slice(0, 400)}`); + } + return { statusCode: 200, headers: {}, body }; + }; + + if (soapXml.includes('')) { + requests.sync.push(soapXml); + return respond(routes.sync?.(syncCalls++)); + } + if (soapXml.includes('')) { + requests.getItem.push(soapXml); + return respond(routes.getItem); + } + if (soapXml.includes('')) { + requests.availability.push(soapXml); + return respond(routes.availability); + } + if (soapXml.includes('')) { + requests.getFolder.push(soapXml); + return respond(routes.getFolder); + } + throw new Error('Unknown SOAP operation'); + }); + return { post, requests }; +}; + +const noSleep = async () => undefined; + +describe('calendarSync/ExchangeEwsCalendarProvider', () => { + it('should support incremental sync but not webhooks', () => { + const provider = new ExchangeEwsCalendarProvider(CONFIG, { post: sinon.stub() }, noSleep); + expect(provider.type).to.equal('exchange-ews'); + expect(provider.supportsDelta).to.be.true; + expect(provider.supportsWebhooks).to.be.false; + }); + + describe('full snapshot (no sync state)', () => { + it('should establish the SyncState before the snapshot and map FindItem + GetItem results', async () => { + const client = routedClient({ + sync: () => syncResponse({ state: 'ST-1' }), + find: findItemResponse([ + itemXml({ id: 'id-1' }), + itemXml({ id: 'id-2', status: 'Tentative', start: '2026-07-13T10:00:00Z', end: '2026-07-13T11:00:00Z' }), + itemXml({ id: 'id-3', cancelled: true, start: '2026-07-14T10:00:00Z', end: '2026-07-14T11:00:00Z' }), + ]), + getItem: getItemResponse([['id-1', 'agenda body']]), + }); + const provider = new ExchangeEwsCalendarProvider(CONFIG, client, noSleep); + + const result = await provider.listEvents('user@example.mil', WINDOW); + + expect(result.full).to.be.true; + expect(result.nextDeltaToken).to.equal('ST-1'); + expect(result.events).to.have.length(3); + + const [standup, optional, cancelled] = result.events; + expect(standup).to.deep.include({ externalId: 'id-1', iCalUId: 'UID-id-1', busy: true, description: 'agenda body' }); + expect(optional.busy).to.be.false; // Tentative is not busy + expect(cancelled.isCancelled).to.be.true; + + // SyncState established first; impersonation on every mailbox-scoped call; + // GetItem asks bodies only for non-cancelled items + expect(client.post.firstCall.args[0]).to.include(''); + expect(client.requests.find[0]).to.include('user@example.mil'); + expect(client.requests.getItem[0]).to.include(''); + expect(client.requests.getItem[0]).to.not.include(''); + }); + + it('should return no delta token when the mailbox cannot be fast-forwarded within the page cap', async () => { + const client = routedClient({ + sync: (call) => syncResponse({ state: `ST-${call}`, last: false }), + find: findItemResponse([]), + }); + const provider = new ExchangeEwsCalendarProvider(CONFIG, client, noSleep); + + const result = await provider.listEvents('user@example.mil', WINDOW); + + expect(result.full).to.be.true; + expect(result.nextDeltaToken).to.be.undefined; + expect(client.requests.sync).to.have.length(20); + expect(client.requests.getItem).to.be.empty; // no items → no GetItem call + }); + }); + + describe('incremental sync (with sync state)', () => { + it('should page through changes, filter to the window, and surface deletions', async () => { + const client = routedClient({ + sync: (call) => + call === 0 + ? syncResponse({ + state: 'ST-2', + last: false, + creates: [itemXml({ id: 'in-window' })], + deletes: ['gone-1'], + }) + : syncResponse({ + state: 'ST-3', + last: true, + creates: [itemXml({ id: 'out-of-window', start: '2026-09-01T10:00:00Z', end: '2026-09-01T11:00:00Z' })], + }), + getItem: getItemResponse([['in-window', 'body']]), + }); + const provider = new ExchangeEwsCalendarProvider(CONFIG, client, noSleep); + + const result = await provider.listEvents('user@example.mil', WINDOW, 'ST-1'); + + expect(result.full).to.be.false; + expect(result.nextDeltaToken).to.equal('ST-3'); + expect(result.events.map((event) => event.externalId)).to.deep.equal(['in-window']); + // explicit Delete change + item rescheduled out of the window + expect(result.deletedEventIds).to.deep.equal(['gone-1', 'out-of-window']); + + expect(client.requests.sync[0]).to.include('ST-1'); + expect(client.requests.sync[1]).to.include('ST-2'); + expect(client.requests.find).to.be.empty; + }); + + it('should fall back to a full snapshot when the sync state is rejected', async () => { + let firstSyncCall = true; + const client = routedClient({ + sync: () => { + if (firstSyncCall) { + firstSyncCall = false; + return syncStateErrorResponse; + } + return syncResponse({ state: 'ST-fresh' }); + }, + find: findItemResponse([itemXml({ id: 'id-1' })]), + getItem: getItemResponse([['id-1', 'body']]), + }); + const provider = new ExchangeEwsCalendarProvider(CONFIG, client, noSleep); + + const result = await provider.listEvents('user@example.mil', WINDOW, 'stale-state'); + + expect(result.full).to.be.true; + expect(result.nextDeltaToken).to.equal('ST-fresh'); + expect(result.events).to.have.length(1); + }); + }); + + it('should map HTTP 401 to invalid-credentials', async () => { + const client = { post: sinon.stub().resolves({ statusCode: 401, headers: {}, body: '' }) }; + const provider = new ExchangeEwsCalendarProvider(CONFIG, client, noSleep); + + await provider.listEvents('user@example.mil', WINDOW).then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('invalid-credentials'), + ); + }); + + it('should surface impersonation-denied from SOAP response codes', async () => { + const denied = envelope( + `` + + `ErrorImpersonateUserDenied` + + ``, + ); + const client = { post: sinon.stub().resolves({ statusCode: 500, headers: {}, body: denied }) }; + const provider = new ExchangeEwsCalendarProvider(CONFIG, client, noSleep); + + await provider.listEvents('user@example.mil', WINDOW).then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('impersonation-denied'), + ); + }); + + it('should retry once after HTTP 503 before giving up', async () => { + const sleeps: number[] = []; + const client = { post: sinon.stub() }; + client.post.onFirstCall().resolves({ statusCode: 503, headers: {}, body: '' }); + client.post.onSecondCall().resolves({ statusCode: 200, headers: {}, body: getFolderResponse }); + const provider = new ExchangeEwsCalendarProvider(CONFIG, client, async (ms) => { + sleeps.push(ms); + }); + + const result = await provider.testConnection(); + expect(result.ok).to.be.true; + expect(sleeps).to.have.length(1); + }); + + it('should report free/busy intervals per mailbox', async () => { + const availability = envelope( + `` + + `` + + `2026-07-11T14:00:002026-07-11T15:00:00OOF` + + `` + + ``, + ); + const client = routedClient({ availability }); + const provider = new ExchangeEwsCalendarProvider(CONFIG, client, noSleep); + + const results = await provider.getFreeBusy(['a@example.mil'], WINDOW); + expect(results).to.have.length(1); + expect(results[0].intervals[0].status).to.equal('oof'); + }); + + it('should validate impersonation in testConnection when a probe mailbox is given', async () => { + const client = routedClient({ getFolder: getFolderResponse }); + const provider = new ExchangeEwsCalendarProvider(CONFIG, client, noSleep); + + expect((await provider.testConnection('probe@example.mil')).ok).to.be.true; + expect(client.requests.getFolder[0]).to.include('probe@example.mil'); + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/GraphTokenManager.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/GraphTokenManager.spec.ts new file mode 100644 index 0000000000000..59f8e656ac5ad --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/GraphTokenManager.spec.ts @@ -0,0 +1,169 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import sinon from 'sinon'; + +import { GraphTokenManager } from '../../../../../ee/server/lib/calendarSync/providers/graph/GraphTokenManager'; + +const CONFIG = { + tenantId: 'tenant-1', + clientId: 'client-1', + clientSecret: 'secret-1', + loginHost: 'https://login.microsoftonline.com', + graphHost: 'https://graph.microsoft.com', +}; + +const tokenResponse = (accessToken: string, expiresIn = 3600) => ({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ access_token: accessToken, expires_in: expiresIn, token_type: 'Bearer' }), + text: async () => '', +}); + +const errorResponse = (status: number, description: string) => ({ + ok: false, + status, + headers: { get: () => null }, + json: async () => ({ error: 'invalid_request', error_description: description }), + text: async () => '', +}); + +describe('calendarSync/GraphTokenManager', () => { + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(new Date('2026-07-11T12:00:00Z')); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should request a client-credentials token with the .default graph scope', async () => { + const fetchFn = sinon.stub().resolves(tokenResponse('tok-1')); + const manager = new GraphTokenManager(CONFIG, fetchFn); + + expect(await manager.getToken()).to.equal('tok-1'); + + expect(fetchFn.calledOnce).to.be.true; + const [url, options] = fetchFn.firstCall.args; + expect(url).to.equal('https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token'); + expect(options.method).to.equal('POST'); + expect(options.body).to.include('grant_type=client_credentials'); + expect(options.body).to.include(`scope=${encodeURIComponent('https://graph.microsoft.com/.default')}`); + }); + + it('should cache the token and refresh it when close to expiry', async () => { + const fetchFn = sinon.stub(); + fetchFn.onFirstCall().resolves(tokenResponse('tok-1', 3600)); + fetchFn.onSecondCall().resolves(tokenResponse('tok-2', 3600)); + const manager = new GraphTokenManager(CONFIG, fetchFn); + + expect(await manager.getToken()).to.equal('tok-1'); + expect(await manager.getToken()).to.equal('tok-1'); + expect(fetchFn.calledOnce).to.be.true; + + // Advance to within the 2-minute safety margin of expiry + clock.tick((3600 - 60) * 1000); + expect(await manager.getToken()).to.equal('tok-2'); + expect(fetchFn.calledTwice).to.be.true; + }); + + it('should share a single in-flight request between concurrent callers', async () => { + const fetchFn = sinon.stub().resolves(tokenResponse('tok-1')); + const manager = new GraphTokenManager(CONFIG, fetchFn); + + const [a, b] = await Promise.all([manager.getToken(), manager.getToken()]); + expect(a).to.equal('tok-1'); + expect(b).to.equal('tok-1'); + expect(fetchFn.calledOnce).to.be.true; + }); + + it('should fail fast without a network call when credentials are not configured', async () => { + const fetchFn = sinon.stub(); + const manager = new GraphTokenManager({ ...CONFIG, clientSecret: '' }, fetchFn); + + await manager.getToken().then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('missing-credentials'), + ); + expect(fetchFn.called).to.be.false; + }); + + const errorCases: [string, string][] = [ + ['AADSTS90002: Tenant not found', 'invalid-tenant'], + ["AADSTS700016: Application with identifier 'x' was not found", 'invalid-client'], + ['AADSTS7000215: Invalid client secret provided', 'invalid-client-secret'], + ['AADSTS65001: The user or administrator has not consented', 'consent-missing'], + ['something else entirely', 'auth-failed'], + ]; + + for (const [description, expectedCode] of errorCases) { + it(`should map "${description.slice(0, 12)}..." to ${expectedCode}`, async () => { + const fetchFn = sinon.stub().resolves(errorResponse(400, description)); + const manager = new GraphTokenManager(CONFIG, fetchFn); + + await manager.getToken().then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal(expectedCode), + ); + }); + } + + it('should send a client assertion instead of a secret when using certificate auth', async () => { + const crypto = require('crypto'); + const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const certificatePem = + '-----BEGIN CERTIFICATE-----\nMIICtjCCAZ4CCQDMv8cc+9Zt7DANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBJj\n-----END CERTIFICATE-----'; + + const fetchFn = sinon.stub().resolves(tokenResponse('tok-1')); + const manager = new GraphTokenManager( + { + ...CONFIG, + clientSecret: undefined, + authMethod: 'certificate', + certificatePem, + privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + }, + fetchFn, + ); + + expect(await manager.getToken()).to.equal('tok-1'); + const { body } = fetchFn.firstCall.args[1]; + expect(body).to.include(`client_assertion_type=${encodeURIComponent('urn:ietf:params:oauth:client-assertion-type:jwt-bearer')}`); + expect(body).to.include('client_assertion='); + expect(body).to.not.include('client_secret'); + }); + + it('should fail fast when certificate auth is selected but the key is missing', async () => { + const fetchFn = sinon.stub(); + const manager = new GraphTokenManager({ ...CONFIG, clientSecret: undefined, authMethod: 'certificate' }, fetchFn); + + await manager.getToken().then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('missing-credentials'), + ); + expect(fetchFn.called).to.be.false; + }); + + it('should map transport failures to network-error', async () => { + const fetchFn = sinon.stub().rejects(new Error('ECONNREFUSED')); + const manager = new GraphTokenManager(CONFIG, fetchFn); + + await manager.getToken().then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('network-error'), + ); + }); + + it('should allow a new request after invalidate()', async () => { + const fetchFn = sinon.stub(); + fetchFn.onFirstCall().resolves(tokenResponse('tok-1')); + fetchFn.onSecondCall().resolves(tokenResponse('tok-2')); + const manager = new GraphTokenManager(CONFIG, fetchFn); + + expect(await manager.getToken()).to.equal('tok-1'); + manager.invalidate(); + expect(await manager.getToken()).to.equal('tok-2'); + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/MicrosoftGraphCalendarProvider.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/MicrosoftGraphCalendarProvider.spec.ts new file mode 100644 index 0000000000000..386054a10ff5b --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/MicrosoftGraphCalendarProvider.spec.ts @@ -0,0 +1,319 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import sinon from 'sinon'; + +import { MicrosoftGraphCalendarProvider } from '../../../../../ee/server/lib/calendarSync/providers/graph/MicrosoftGraphCalendarProvider'; + +const CONFIG = { + tenantId: 'tenant-1', + clientId: 'client-1', + clientSecret: 'secret-1', + loginHost: 'https://login.microsoftonline.com', + graphHost: 'https://graph.microsoft.com', +}; + +const WINDOW = { + start: new Date('2026-07-11T00:00:00Z'), + end: new Date('2026-07-18T00:00:00Z'), +}; + +const jsonResponse = (payload: unknown, status = 200, headers: Record = {}) => ({ + ok: status >= 200 && status < 300, + status, + headers: { get: (name: string) => headers[name] ?? null }, + json: async () => payload, + text: async () => JSON.stringify(payload), +}); + +const TOKEN_PAYLOAD = { access_token: 'tok-1', expires_in: 3600, token_type: 'Bearer' }; + +const isTokenUrl = (url: string) => url.includes('/oauth2/v2.0/token'); + +const graphEvent = (id: string, overrides: Record = {}) => ({ + id, + iCalUId: `ical-${id}`, + subject: `Meeting ${id}`, + bodyPreview: `Agenda for ${id}`, + start: { dateTime: '2026-07-12T10:00:00.0000000', timeZone: 'UTC' }, + end: { dateTime: '2026-07-12T11:00:00.0000000', timeZone: 'UTC' }, + showAs: 'busy', + isCancelled: false, + ...overrides, +}); + +/** Routes the token endpoint automatically and serves graph pages in order */ +const makeFetch = (pages: ((url: string, options: any) => any)[]) => { + let call = 0; + const graphCalls: { url: string; options: any }[] = []; + const fetchFn = sinon.stub().callsFake(async (url: string, options: any) => { + if (isTokenUrl(url)) { + return jsonResponse(TOKEN_PAYLOAD); + } + graphCalls.push({ url, options }); + const page = pages[Math.min(call, pages.length - 1)]; + call++; + return page(url, options); + }); + return { fetchFn, graphCalls }; +}; + +const noSleep = async () => undefined; + +describe('calendarSync/MicrosoftGraphCalendarProvider', () => { + describe('listEvents', () => { + it('should perform a full window sync, follow nextLink pages and extract the delta token', async () => { + const { fetchFn, graphCalls } = makeFetch([ + () => + jsonResponse({ + 'value': [graphEvent('e1')], + '@odata.nextLink': 'https://graph.microsoft.com/v1.0/users/u/calendarView/delta?$skiptoken=abc', + }), + () => + jsonResponse({ + 'value': [graphEvent('e2', { showAs: 'free' })], + '@odata.deltaLink': 'https://graph.microsoft.com/v1.0/users/u/calendarView/delta?$deltatoken=delta-123', + }), + ]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + const result = await provider.listEvents('user@example.com', WINDOW); + + expect(result.full).to.be.true; + expect(result.nextDeltaToken).to.equal('delta-123'); + expect(result.deletedEventIds).to.deep.equal([]); + expect(result.events).to.have.length(2); + + const [first, second] = result.events; + expect(first.externalId).to.equal('e1'); + expect(first.iCalUId).to.equal('ical-e1'); + expect(first.subject).to.equal('Meeting e1'); + expect(first.description).to.equal('Agenda for e1'); + expect(first.busy).to.be.true; + expect(first.startTime.toISOString()).to.equal('2026-07-12T10:00:00.000Z'); + expect(second.busy).to.be.false; + + // First call carries the window; second follows the nextLink verbatim + expect(graphCalls[0].url).to.include('/users/user%40example.com/calendarView/delta'); + expect(graphCalls[0].url).to.include('startDateTime=2026-07-11T00%3A00%3A00.000Z'); + expect(graphCalls[1].url).to.include('$skiptoken=abc'); + expect(graphCalls[0].options.headers.Authorization).to.equal('Bearer tok-1'); + }); + + it('should resume from a delta token and surface removed events', async () => { + const { fetchFn, graphCalls } = makeFetch([ + () => + jsonResponse({ + 'value': [graphEvent('e1'), { '@removed': { reason: 'deleted' }, 'id': 'e-gone' }], + '@odata.deltaLink': 'https://graph.microsoft.com/v1.0/users/u/calendarView/delta?$deltatoken=delta-next', + }), + ]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + const result = await provider.listEvents('user@example.com', WINDOW, 'delta-prev'); + + expect(result.full).to.be.false; + expect(result.deletedEventIds).to.deep.equal(['e-gone']); + expect(result.events.map((e) => e.externalId)).to.deep.equal(['e1']); + expect(result.nextDeltaToken).to.equal('delta-next'); + expect(graphCalls[0].url).to.include('$deltatoken=delta-prev'); + }); + + it('should restart with a full sync when the delta token is rejected with 410', async () => { + let served410 = false; + const { fetchFn, graphCalls } = makeFetch([ + (url: string) => { + if (url.includes('$deltatoken=stale') && !served410) { + served410 = true; + return jsonResponse({ error: { code: 'SyncStateNotFound' } }, 410); + } + return jsonResponse({ + 'value': [graphEvent('e1')], + '@odata.deltaLink': 'https://graph.microsoft.com/v1.0/users/u/calendarView/delta?$deltatoken=fresh', + }); + }, + ]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + const result = await provider.listEvents('user@example.com', WINDOW, 'stale'); + + expect(result.full).to.be.true; + expect(result.nextDeltaToken).to.equal('fresh'); + expect(graphCalls[1].url).to.include('startDateTime='); + }); + + it('should honor Retry-After on 429 and then succeed', async () => { + const sleeps: number[] = []; + const sleep = async (ms: number) => { + sleeps.push(ms); + }; + let throttled = false; + const { fetchFn } = makeFetch([ + () => { + if (!throttled) { + throttled = true; + return jsonResponse({ error: { code: 'TooManyRequests' } }, 429, { 'Retry-After': '7' }); + } + return jsonResponse({ + 'value': [graphEvent('e1')], + '@odata.deltaLink': 'https://graph.microsoft.com/v1.0/users/u/calendarView/delta?$deltatoken=d', + }); + }, + ]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, sleep); + + const result = await provider.listEvents('user@example.com', WINDOW); + + expect(sleeps).to.deep.equal([7000]); + expect(result.events).to.have.length(1); + }); + + it('should give up with a throttled error after exhausting retries', async () => { + const { fetchFn } = makeFetch([() => jsonResponse({ error: { code: 'TooManyRequests' } }, 429, { 'Retry-After': '1' })]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + await provider.listEvents('user@example.com', WINDOW).then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('throttled'), + ); + }); + + it('should refuse pagination links pointing outside the configured graph host', async () => { + const { fetchFn } = makeFetch([ + () => + jsonResponse({ + 'value': [], + '@odata.nextLink': 'https://evil.example.com/v1.0/whatever', + }), + ]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + await provider.listEvents('user@example.com', WINDOW).then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('provider-error'), + ); + }); + + it('should map 403 responses to consent-missing', async () => { + const { fetchFn } = makeFetch([() => jsonResponse({ error: { code: 'Authorization_RequestDenied', message: 'denied' } }, 403)]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + await provider.listEvents('user@example.com', WINDOW).then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('consent-missing'), + ); + }); + + it('should map 404 responses to mailbox-not-found', async () => { + const { fetchFn } = makeFetch([() => jsonResponse({ error: { code: 'ErrorItemNotFound', message: 'no mailbox' } }, 404)]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + await provider.listEvents('user@example.com', WINDOW).then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('mailbox-not-found'), + ); + }); + }); + + describe('getFreeBusy', () => { + it('should post the mailboxes to getSchedule and map busy intervals', async () => { + const { fetchFn, graphCalls } = makeFetch([ + () => + jsonResponse({ + value: [ + { + scheduleId: 'a@example.com', + scheduleItems: [ + { status: 'busy', start: { dateTime: '2026-07-11T10:00:00.0000000' }, end: { dateTime: '2026-07-11T11:00:00.0000000' } }, + { status: 'free', start: { dateTime: '2026-07-11T11:00:00.0000000' }, end: { dateTime: '2026-07-11T12:00:00.0000000' } }, + { status: 'oof', start: { dateTime: '2026-07-11T13:00:00.0000000' }, end: { dateTime: '2026-07-11T14:00:00.0000000' } }, + ], + }, + { scheduleId: 'b@example.com', error: { message: 'Mailbox not found' } }, + ], + }), + ]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + const results = await provider.getFreeBusy(['a@example.com', 'b@example.com'], WINDOW); + + expect(graphCalls[0].url).to.include('/users/a%40example.com/calendar/getSchedule'); + const body = JSON.parse(graphCalls[0].options.body); + expect(body.schedules).to.deep.equal(['a@example.com', 'b@example.com']); + + expect(results).to.have.length(2); + expect(results[0].intervals).to.have.length(2); + expect(results[0].intervals[0].status).to.equal('busy'); + expect(results[0].intervals[1].status).to.equal('oof'); + expect(results[1].error).to.exist; + expect(results[1].intervals).to.be.empty; + }); + }); + + describe('subscriptions', () => { + it('should create a change-notification subscription for the mailbox events resource', async () => { + const { fetchFn, graphCalls } = makeFetch([() => jsonResponse({ id: 'sub-1', expirationDateTime: '2026-07-14T10:00:00.0000000Z' })]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + const subscription = await provider.createSubscription( + 'user@example.com', + 'https://chat.example.com/api/v1/calendar-sync.webhook', + 'secret', + ); + + expect(subscription.id).to.equal('sub-1'); + expect(subscription.expiresAt.toISOString()).to.equal('2026-07-14T10:00:00.000Z'); + + expect(graphCalls[0].url).to.equal('https://graph.microsoft.com/v1.0/subscriptions'); + const body = JSON.parse(graphCalls[0].options.body); + expect(body.changeType).to.equal('created,updated,deleted'); + expect(body.resource).to.equal('/users/user@example.com/events'); + expect(body.notificationUrl).to.equal('https://chat.example.com/api/v1/calendar-sync.webhook'); + expect(body.clientState).to.equal('secret'); + expect(body.expirationDateTime).to.be.a('string'); + }); + + it('should renew subscriptions with a PATCH carrying a new expiry', async () => { + const { fetchFn, graphCalls } = makeFetch([() => jsonResponse({ id: 'sub-1', expirationDateTime: '2026-07-14T10:00:00.0000000Z' })]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + const renewed = await provider.renewSubscription('sub-1'); + + expect(renewed.id).to.equal('sub-1'); + expect(graphCalls[0].url).to.equal('https://graph.microsoft.com/v1.0/subscriptions/sub-1'); + expect(graphCalls[0].options.method).to.equal('PATCH'); + expect(JSON.parse(graphCalls[0].options.body)).to.have.property('expirationDateTime'); + }); + }); + + describe('testConnection', () => { + it('should succeed when a token can be acquired', async () => { + const fetchFn = sinon.stub().callsFake(async (url: string) => { + expect(isTokenUrl(url)).to.be.true; + return jsonResponse(TOKEN_PAYLOAD); + }); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + expect(await provider.testConnection()).to.deep.equal({ ok: true }); + }); + + it('should probe the mailbox when one is provided and report actionable errors', async () => { + const { fetchFn } = makeFetch([() => jsonResponse({ error: { code: 'Authorization_RequestDenied', message: 'denied' } }, 403)]); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + const result = await provider.testConnection('probe@example.com'); + expect(result.ok).to.be.false; + expect(result.error?.code).to.equal('consent-missing'); + }); + + it('should report token failures without throwing', async () => { + const fetchFn = sinon + .stub() + .resolves(jsonResponse({ error: 'invalid_request', error_description: 'AADSTS90002: Tenant not found' }, 400)); + const provider = new MicrosoftGraphCalendarProvider(CONFIG, fetchFn, noSleep); + + const result = await provider.testConnection(); + expect(result.ok).to.be.false; + expect(result.error?.code).to.equal('invalid-tenant'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/airGap.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/airGap.spec.ts new file mode 100644 index 0000000000000..5a22ead890701 --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/airGap.spec.ts @@ -0,0 +1,186 @@ +import fs from 'fs'; +import path from 'path'; + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import { ExchangeEwsCalendarProvider } from '../../../../../ee/server/lib/calendarSync/providers/ews/ExchangeEwsCalendarProvider'; +import { EwsHttpClient } from '../../../../../ee/server/lib/calendarSync/providers/ews/ewsHttp'; + +/** + * Air-gap requirement: with the EWS provider selected, the integration must never + * attempt to contact any Microsoft cloud endpoint. Verified three ways: + * 1. statically — no Microsoft-cloud hostname exists anywhere in the EWS provider + * sources or in the provider-agnostic sync engine; + * 2. at the factory — selecting exchange-ews instantiates only the EWS provider + * (the Graph provider, which owns the cloud hostnames, is never constructed); + * 3. at runtime — a full sync run against the EWS provider touches exactly one + * host: the admin-configured EWS endpoint. + */ +describe('calendarSync/air-gap (provider = exchange-ews)', () => { + const MICROSOFT_CLOUD_PATTERNS = [/microsoftonline/i, /graph\.microsoft/i, /office365/i, /office\.com/i, /outlook\.com/i]; + const ENDPOINT = 'https://mail.airgapped.example.mil/EWS/Exchange.asmx'; + + const calendarSyncRoot = path.resolve(__dirname, '../../../../../ee/server/lib/calendarSync'); + + const listFiles = (dir: string): string[] => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + return entry.isDirectory() ? listFiles(full) : [full]; + }); + + it('static: no Microsoft-cloud hostname exists in the EWS provider sources or the sync engine', () => { + const providerAgnosticFiles = ['CalendarSyncEngine.ts', 'definition.ts', 'logSanitizer.ts', 'mailboxResolver.ts', 'startup.ts'].map( + (file) => path.join(calendarSyncRoot, file), + ); + const ewsFiles = listFiles(path.join(calendarSyncRoot, 'providers/ews')); + + for (const file of [...providerAgnosticFiles, ...ewsFiles]) { + const source = fs.readFileSync(file, 'utf8'); + for (const pattern of MICROSOFT_CLOUD_PATTERNS) { + expect(pattern.test(source), `${path.relative(calendarSyncRoot, file)} must not reference ${pattern}`).to.be.false; + } + } + }); + + it('factory: selecting exchange-ews never constructs the Graph provider', () => { + const settingsValues: Record = { + CalendarSync_Provider: 'exchange-ews', + CalendarSync_Ews_Url: ENDPOINT, + CalendarSync_Ews_Username: 'CONTOSO\\svc', + CalendarSync_Ews_Password: 'pw', + CalendarSync_Ews_AuthMethod: 'ntlm', + CalendarSync_Ews_AllowSelfSignedCerts: false, + }; + + const graphConstructor = sinon.stub(); + const serverFetchStub = sinon.stub(); + + const { getConfiguredProvider } = proxyquire.noCallThru().load('../../../../../ee/server/lib/calendarSync/factory.ts', { + '../../../../app/settings/server': { settings: { get: (id: string) => settingsValues[id] } }, + '@rocket.chat/server-fetch': { serverFetch: serverFetchStub }, + './providers/graph/MicrosoftGraphCalendarProvider': { + MicrosoftGraphCalendarProvider: class { + constructor() { + graphConstructor(); + } + }, + }, + }); + + const provider = getConfiguredProvider(); + + expect(provider).to.be.instanceOf(ExchangeEwsCalendarProvider); + expect(graphConstructor.called).to.be.false; + expect(serverFetchStub.called).to.be.false; + }); + + it('runtime: a full sync run contacts only the configured EWS endpoint', async () => { + const requestedUrls: string[] = []; + + // Synthetic NTLM Type 2 challenge so the real handshake code runs end to end + const type2Message = (() => { + const message = Buffer.alloc(48); + message.write('NTLMSSP\0', 0, 'ascii'); + message.writeUInt32LE(2, 8); + Buffer.from('0123456789abcdef', 'hex').copy(message, 24); + return `NTLM ${message.toString('base64')}`; + })(); + + const M = 'http://schemas.microsoft.com/exchange/services/2006/messages'; + const T = 'http://schemas.microsoft.com/exchange/services/2006/types'; + const findItemResponse = + `` + + `` + + `NoError` + + `Mtg` + + `2026-07-12T10:00:00Z2026-07-12T11:00:00Z` + + `BusyUID-1` + + ``; + const getItemResponse = + `` + + `` + + `NoError` + + `body` + + ``; + + const syncFolderItemsResponse = + `` + + `` + + `NoError` + + `ST-1true` + + ``; + + const recordingRequestFn = async (options: { url: string; headers: Record; body: string }) => { + requestedUrls.push(options.url); + if ( + options.headers.Authorization?.startsWith('NTLM ') && + Buffer.from(options.headers.Authorization.slice(5), 'base64').readUInt32LE(8) === 1 + ) { + return { statusCode: 401, headers: { 'www-authenticate': type2Message }, body: '' }; + } + if (options.body.includes('')) { + return { statusCode: 200, headers: {}, body: syncFolderItemsResponse }; + } + if (options.body.includes(' [{ _id: 'u1', emails: [{ address: 'user@airgapped.example.mil', verified: true }] }] }, + CalendarEvent: { + findOneByExternalIdAndUserId: sinon.stub().resolves(null), + findServerSyncedByUserIdBetweenDates: () => [], + }, + CalendarSyncState: { + findOneByUserId: sinon.stub().resolves(null), + recordSuccess: sinon.stub().resolves({}), + recordFailure: sinon.stub().resolves({}), + }, + }, + }); + + const engine = new CalendarSyncEngine( + () => provider, + () => ({ + mode: 'full-events', + windowDays: 7, + batchSize: 10, + presenceEnabled: true, + mailboxSource: 'email', + mailboxCustomField: '', + defaultLanguage: 'en', + roles: [], + }), + { debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined }, + ); + + const summary = await engine.runSync(); + + expect(summary?.usersProcessed).to.equal(1); + expect(importStub.calledOnce).to.be.true; + expect(requestedUrls.length).to.be.greaterThan(0); + for (const url of requestedUrls) { + expect(url).to.equal(ENDPOINT); + for (const pattern of MICROSOFT_CLOUD_PATTERNS) { + expect(pattern.test(url)).to.be.false; + } + } + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/clientAssertion.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/clientAssertion.spec.ts new file mode 100644 index 0000000000000..b7337ad76f61c --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/clientAssertion.spec.ts @@ -0,0 +1,92 @@ +import crypto from 'crypto'; + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { buildClientAssertion, certificateThumbprint } from '../../../../../ee/server/lib/calendarSync/providers/graph/clientAssertion'; + +// Self-signed test certificate (no secret material); SHA-1 fingerprint: +// FD:0D:4C:E7:12:6D:0A:86:53:49:A8:A9:C0:A5:8D:A0:DF:75:70:F4 +const TEST_CERT = `-----BEGIN CERTIFICATE----- +MIICtjCCAZ4CCQDMv8cc+9Zt7DANBgkqhkiG9w0BAQsFADAdMRswGQYDVQQDDBJj +YWxlbmRhci1zeW5jLXRlc3QwHhcNMjYwNzExMjIzNjU4WhcNMzYwNzA4MjIzNjU4 +WjAdMRswGQYDVQQDDBJjYWxlbmRhci1zeW5jLXRlc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDFwL8KF/OtOtGBekuepBXZ26HuuhO+RE1DX1F5X1iR +esG/ysOgi+tmHMS+XJz4aMHZ6+e4A8gappvopegC8/TyOgZf5GqkESEc0IqY3DFo +DxMuR/hPmgSw7x/kM7b3vEmB/IThni5blOek+2A+pRdcPYuNWTcHXPt8gpSDCqQA +ppvjgp5601NUg7MUMoLCmAfWFZNVXhVa2M8pnvXl4Q5nxwdFFEjXPX5ko1QXKsJa +5j72AhPjbPjrGoYhforRb5zhLo38KK4odFO4/LRW4VzWz0+aHwnY120Phz8lPbAi +7GgvuxA2LRlByRRHgPTLU19Ze5rtxzl65d0CUl+wu3HrAgMBAAEwDQYJKoZIhvcN +AQELBQADggEBAI7Axqc8DHooUuORy1otGK5f+6uSZaEVUCTBdpJIcBH3hevgK/4S +pr1pKX7EzkEH6qvfLh4nAVtdqOaiMElStNEmdphO+QISPRf0Z73LMjn+DWDA2Itm +pxUmvAlFM60YQ8P6gt+c6bY1WeP0Lscljunreywug1yC112HmV7u5bAaARZJZKyE +oLLbetiyukt9uDnEttIBt96I4XzEjCbCd4FOuVXtr5CoCIOoCHCFrfHPw98WA4fl +VPkIVs97CgYMq+qvQNbNnNETnvcx9m0V7yFSRojNQzrmhIBh0Eh9XK/nwvJy6Qcc +Z7VrHhPvCjx/LWvTh5gGh8tB9mwKCDriqZA= +-----END CERTIFICATE-----`; + +const b64urlDecode = (part: string): Buffer => Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + +describe('calendarSync/graph/clientAssertion', () => { + describe('certificateThumbprint', () => { + it('should compute the base64url-encoded SHA-1 thumbprint of the DER certificate', () => { + const expected = Buffer.from('FD0D4CE7126D0A865349A8A9C0A58DA0DF7570F4', 'hex') + .toString('base64') + .replace(/=+$/, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + expect(certificateThumbprint(TEST_CERT)).to.equal(expected); + }); + + it('should reject values that are not PEM certificates', () => { + expect(() => certificateThumbprint('not a cert')) + .to.throw() + .and.satisfy((error: any) => error.code === 'invalid-certificate'); + }); + }); + + describe('buildClientAssertion', () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); + + it('should produce an RS256 JWT with x5t header and Entra-compatible claims, verifiable with the public key', () => { + const tokenUrl = 'https://login.microsoftonline.us/tenant-1/oauth2/v2.0/token'; + const now = new Date('2026-07-11T12:00:00Z').getTime(); + + const assertion = buildClientAssertion({ + clientId: 'client-1', + tokenUrl, + certificatePem: TEST_CERT, + privateKeyPem, + now, + jti: 'fixed-jti', + }); + + const [headerPart, payloadPart, signaturePart] = assertion.split('.'); + const header = JSON.parse(b64urlDecode(headerPart).toString()); + const payload = JSON.parse(b64urlDecode(payloadPart).toString()); + + expect(header).to.deep.equal({ alg: 'RS256', typ: 'JWT', x5t: certificateThumbprint(TEST_CERT) }); + expect(payload.aud).to.equal(tokenUrl); + expect(payload.iss).to.equal('client-1'); + expect(payload.sub).to.equal('client-1'); + expect(payload.jti).to.equal('fixed-jti'); + expect(payload.exp - payload.iat).to.equal(600); + expect(payload.nbf).to.equal(payload.iat - 60); + + const verified = crypto + .createVerify('RSA-SHA256') + .update(`${headerPart}.${payloadPart}`) + .verify(publicKey, b64urlDecode(signaturePart)); + expect(verified).to.be.true; + }); + + it('should fail with invalid-private-key on garbage key material', () => { + expect(() => + buildClientAssertion({ clientId: 'c', tokenUrl: 'https://x/token', certificatePem: TEST_CERT, privateKeyPem: 'garbage' }), + ) + .to.throw() + .and.satisfy((error: any) => error.code === 'invalid-private-key'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/ewsHttp.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/ewsHttp.spec.ts new file mode 100644 index 0000000000000..1e2c0192c9a2d --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/ewsHttp.spec.ts @@ -0,0 +1,124 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import sinon from 'sinon'; + +import type { IEwsRequestOptions } from '../../../../../ee/server/lib/calendarSync/providers/ews/ewsHttp'; +import { EwsHttpClient } from '../../../../../ee/server/lib/calendarSync/providers/ews/ewsHttp'; + +const ENDPOINT = 'https://mail.example.mil/EWS/Exchange.asmx'; + +// Synthetic Type 2 challenge (signature, type 2, flags, server challenge, empty target info) +const type2 = (() => { + const message = Buffer.alloc(48); + message.write('NTLMSSP\0', 0, 'ascii'); + message.writeUInt32LE(2, 8); + message.writeUInt32LE(0x00088201, 20); + Buffer.from('0123456789abcdef', 'hex').copy(message, 24); + return `NTLM ${message.toString('base64')}`; +})(); + +const response = (statusCode: number, headers: Record = {}, body = '') => ({ statusCode, headers, body }); + +describe('calendarSync/ews/EwsHttpClient', () => { + it('should send a single Basic-authenticated request', async () => { + const requestFn = sinon.stub().resolves(response(200, {}, '')); + const client = new EwsHttpClient( + { url: ENDPOINT, username: 'svc@example.mil', password: 'pw', authMethod: 'basic', allowSelfSignedCerts: false }, + requestFn, + ); + + const result = await client.post(''); + + expect(result.statusCode).to.equal(200); + expect(requestFn.calledOnce).to.be.true; + const options: IEwsRequestOptions = requestFn.firstCall.args[0]; + expect(options.url).to.equal(ENDPOINT); + expect(options.headers.Authorization).to.equal(`Basic ${Buffer.from('svc@example.mil:pw').toString('base64')}`); + expect(options.body).to.equal(''); + }); + + it('should perform the NTLM handshake: empty-body Type 1, then Type 3 with the payload', async () => { + const requestFn = sinon.stub(); + requestFn.onFirstCall().resolves(response(401, { 'www-authenticate': type2 })); + requestFn.onSecondCall().resolves(response(200, {}, '')); + + const client = new EwsHttpClient( + { url: ENDPOINT, username: 'CONTOSO\\svc', password: 'pw', authMethod: 'ntlm', allowSelfSignedCerts: false }, + requestFn, + ); + + const result = await client.post(''); + expect(result.body).to.equal(''); + + const negotiate: IEwsRequestOptions = requestFn.firstCall.args[0]; + expect(negotiate.headers.Authorization).to.match(/^NTLM /); + expect(Buffer.from(negotiate.headers.Authorization.slice(5), 'base64').readUInt32LE(8)).to.equal(1); + expect(negotiate.body).to.equal(''); + + const authenticate: IEwsRequestOptions = requestFn.secondCall.args[0]; + expect(authenticate.headers.Authorization).to.match(/^NTLM /); + expect(Buffer.from(authenticate.headers.Authorization.slice(5), 'base64').readUInt32LE(8)).to.equal(3); + expect(authenticate.body).to.equal(''); + + // Both legs must ride the same keep-alive agent (NTLM authenticates the connection) + expect(negotiate.agent).to.equal(authenticate.agent); + }); + + it('should serialize concurrent NTLM posts so handshakes never interleave', async () => { + const order: string[] = []; + const requestFn = sinon.stub().callsFake(async (options: IEwsRequestOptions) => { + const type = Buffer.from(options.headers.Authorization.slice(5), 'base64').readUInt32LE(8); + order.push(`type${type}`); + return type === 1 ? response(401, { 'www-authenticate': type2 }) : response(200, {}, ''); + }); + + const client = new EwsHttpClient( + { url: ENDPOINT, username: 'CONTOSO\\svc', password: 'pw', authMethod: 'ntlm', allowSelfSignedCerts: false }, + requestFn, + ); + + await Promise.all([client.post(''), client.post('')]); + + expect(order).to.deep.equal(['type1', 'type3', 'type1', 'type3']); + }); + + it('should fail with auth-failed when the endpoint does not offer NTLM', async () => { + const requestFn = sinon.stub().resolves(response(401, { 'www-authenticate': 'Negotiate' })); + const client = new EwsHttpClient( + { url: ENDPOINT, username: 'CONTOSO\\svc', password: 'pw', authMethod: 'ntlm', allowSelfSignedCerts: false }, + requestFn, + ); + + await client.post('').then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('auth-failed'), + ); + }); + + it('should map transport failures to network-error', async () => { + const requestFn = sinon.stub().rejects(new Error('ECONNREFUSED 10.0.0.5:443')); + const client = new EwsHttpClient( + { url: ENDPOINT, username: 'svc', password: 'pw', authMethod: 'basic', allowSelfSignedCerts: false }, + requestFn, + ); + + await client.post('').then( + () => expect.fail('expected rejection'), + (error) => expect(error.code).to.equal('network-error'), + ); + }); + + it('should keep accepting posts after a failed one', async () => { + const requestFn = sinon.stub(); + requestFn.onFirstCall().rejects(new Error('boom')); + requestFn.onSecondCall().resolves(response(200, {}, '')); + const client = new EwsHttpClient( + { url: ENDPOINT, username: 'svc', password: 'pw', authMethod: 'basic', allowSelfSignedCerts: false }, + requestFn, + ); + + await client.post('').catch(() => undefined); + const result = await client.post(''); + expect(result.statusCode).to.equal(200); + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/ewsSoap.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/ewsSoap.spec.ts new file mode 100644 index 0000000000000..3440ff1465558 --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/ewsSoap.spec.ts @@ -0,0 +1,217 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { + buildFindCalendarItemsRequest, + buildGetItemBodiesRequest, + buildGetUserAvailabilityRequest, + buildGetCalendarFolderRequest, + parseAvailabilityResponse, + parseFindItemResponse, + parseGetItemBodiesResponse, +} from '../../../../../ee/server/lib/calendarSync/providers/ews/soap'; + +const WINDOW = { + start: new Date('2026-07-11T00:00:00Z'), + end: new Date('2026-07-18T00:00:00Z'), +}; + +const T = 'http://schemas.microsoft.com/exchange/services/2006/types'; +const M = 'http://schemas.microsoft.com/exchange/services/2006/messages'; + +const envelope = (body: string) => + `${body}`; + +describe('calendarSync/ews/soap', () => { + describe('request builders', () => { + it('should set the ExchangeImpersonation header on FindItem requests', () => { + const xml = buildFindCalendarItemsRequest('user@example.mil', WINDOW); + expect(xml).to.include('user@example.mil'); + expect(xml).to.include(''); + expect(xml).to.include('StartDate="2026-07-11T00:00:00.000Z"'); + expect(xml).to.include('EndDate="2026-07-18T00:00:00.000Z"'); + expect(xml).to.include(''); + expect(xml).to.include('FieldURI="calendar:UID"'); + expect(xml).to.include('FieldURI="calendar:IsCancelled"'); + }); + + it('should escape XML-relevant characters in the mailbox', () => { + const xml = buildFindCalendarItemsRequest(`o'brien&x<>@example.com`, WINDOW); + expect(xml).to.include('o'brien&x<>@example.com'); + expect(xml).to.not.include(`o'brien&x<>`); + }); + + it('should impersonate and batch item ids on GetItem body requests', () => { + const xml = buildGetItemBodiesRequest('user@example.mil', ['AAA==', 'BBB==']); + expect(xml).to.include('user@example.mil'); + expect(xml).to.include(''); + expect(xml).to.include(''); + expect(xml).to.include('FieldURI="item:TextBody"'); + }); + + it('should request availability with a zero-bias timezone and no impersonation header', () => { + const xml = buildGetUserAvailabilityRequest(['a@example.com', 'b@example.com'], WINDOW); + expect(xml).to.not.include('ExchangeImpersonation'); + expect(xml).to.include('0'); + expect((xml.match(//g) ?? []).length).to.equal(2); + expect(xml).to.include('FreeBusy'); + }); + + it('should only impersonate on GetFolder when a probe mailbox is provided', () => { + expect(buildGetCalendarFolderRequest()).to.not.include('ExchangeImpersonation'); + expect(buildGetCalendarFolderRequest('probe@example.com')).to.include( + 'probe@example.com', + ); + }); + }); + + describe('parseFindItemResponse', () => { + it('should extract calendar items with their EWS ids, UIDs and flags', () => { + const xml = envelope( + `` + + `NoError` + + `` + + `Standup` + + `2026-07-12T10:00:00Z2026-07-12T10:30:00Z` + + `BusyUID-1false` + + `Cancelled mtg` + + `2026-07-13T10:00:00Z2026-07-13T11:00:00Z` + + `Freetrue` + + ``, + ); + + const items = parseFindItemResponse(xml); + expect(items).to.have.length(2); + expect(items[0]).to.deep.include({ + itemId: 'id-1', + subject: 'Standup', + legacyFreeBusyStatus: 'Busy', + uid: 'UID-1', + isCancelled: false, + }); + expect(items[0].start.toISOString()).to.equal('2026-07-12T10:00:00.000Z'); + expect(items[1].isCancelled).to.be.true; + expect(items[1].uid).to.be.undefined; + }); + + it('should map ErrorImpersonateUserDenied to impersonation-denied', () => { + const xml = envelope( + `` + + `denied` + + `ErrorImpersonateUserDenied` + + ``, + ); + + expect(() => parseFindItemResponse(xml)) + .to.throw() + .and.satisfy((error: any) => error.code === 'impersonation-denied'); + }); + + it('should map ErrorNonExistentMailbox to mailbox-not-found', () => { + const xml = envelope( + `` + + `ErrorNonExistentMailbox` + + ``, + ); + + expect(() => parseFindItemResponse(xml)) + .to.throw() + .and.satisfy((error: any) => error.code === 'mailbox-not-found'); + }); + + it('should map SOAP faults to provider errors', () => { + const xml = + `` + + `a:ErrorSchemaValidationThe request failed schema validation` + + ``; + + expect(() => parseFindItemResponse(xml)).to.throw('schema validation'); + }); + }); + + describe('SyncFolderItems', () => { + it('should build requests with the sync state and impersonation only when present', async () => { + const { buildSyncFolderItemsRequest } = await import('../../../../../ee/server/lib/calendarSync/providers/ews/soap'); + + const initial = buildSyncFolderItemsRequest('user@example.mil'); + expect(initial).to.not.include(''); + expect(initial).to.include('512'); + expect(initial).to.include('user@example.mil'); + + const incremental = buildSyncFolderItemsRequest('user@example.mil', 'STATE=='); + expect(incremental).to.include('STATE=='); + }); + + it('should parse creates, updates, deletes and the new sync state', async () => { + const { parseSyncFolderItemsResponse } = await import('../../../../../ee/server/lib/calendarSync/providers/ews/soap'); + const xml = envelope( + `` + + `NoError` + + `NEW-STATEfalse` + + `` + + `New` + + `2026-07-12T10:00:00Z2026-07-12T11:00:00Z` + + `Moved` + + `2026-07-13T10:00:00Z2026-07-13T11:00:00Z` + + `` + + `` + + ``, + ); + + const result = parseSyncFolderItemsResponse(xml); + expect(result.syncState).to.equal('NEW-STATE'); + expect(result.includesLastItemInRange).to.be.false; + expect(result.items.map((item) => item.itemId)).to.deep.equal(['new-1', 'upd-1']); + expect(result.deletedItemIds).to.deep.equal(['gone-1']); + }); + + it('should map ErrorInvalidSyncStateData to delta-token-expired', async () => { + const { parseSyncFolderItemsResponse } = await import('../../../../../ee/server/lib/calendarSync/providers/ews/soap'); + const xml = envelope( + `` + + `ErrorInvalidSyncStateData` + + ``, + ); + + expect(() => parseSyncFolderItemsResponse(xml)) + .to.throw() + .and.satisfy((error: any) => error.code === 'delta-token-expired'); + }); + }); + + describe('parseGetItemBodiesResponse', () => { + it('should map item ids to their text bodies', () => { + const xml = envelope( + `` + + `NoError` + + `join here: https://meet.example.com?callUrl=abc` + + ``, + ); + + const bodies = parseGetItemBodiesResponse(xml); + expect(bodies.get('id-1')).to.include('callUrl=abc'); + }); + }); + + describe('parseAvailabilityResponse', () => { + it('should return one entry per mailbox in request order, including per-mailbox errors', () => { + const xml = envelope( + `` + + `NoError` + + `FreeBusy` + + `2026-07-11T14:00:002026-07-11T15:00:00Busy` + + `2026-07-11T16:00:002026-07-11T17:00:00Free` + + `` + + `ErrorMailRecipientNotFound` + + ``, + ); + + const results = parseAvailabilityResponse(xml); + expect(results).to.have.length(2); + expect(results[0].events).to.have.length(2); + // Times without a zone suffix are UTC because the request pinned a zero-bias timezone + expect(results[0].events[0].start.toISOString()).to.equal('2026-07-11T14:00:00.000Z'); + expect(results[1].errorCode).to.equal('ErrorMailRecipientNotFound'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/factory.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/factory.spec.ts new file mode 100644 index 0000000000000..d13bac0ccf14b --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/factory.spec.ts @@ -0,0 +1,80 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const loadFactory = (settingsValues: Record) => { + const graphConstructor = sinon.stub(); + const factory = proxyquire.noCallThru().load('../../../../../ee/server/lib/calendarSync/factory.ts', { + '../../../../app/settings/server': { settings: { get: (id: string) => settingsValues[id] } }, + '@rocket.chat/server-fetch': { serverFetch: sinon.stub() }, + './providers/graph/MicrosoftGraphCalendarProvider': { + MicrosoftGraphCalendarProvider: class { + constructor(config: unknown) { + graphConstructor(config); + } + }, + }, + }); + return { getConfiguredProvider: factory.getConfiguredProvider, graphConstructor }; +}; + +const GRAPH_BASE = { + CalendarSync_Provider: 'microsoft-graph', + CalendarSync_Graph_TenantId: 'tenant-1', + CalendarSync_Graph_ClientId: 'client-1', + CalendarSync_Graph_Auth_Method: 'client-secret', + CalendarSync_Graph_ClientSecret: 'secret-1', + CalendarSync_Graph_Cloud: 'commercial', +}; + +describe('calendarSync/factory (graph)', () => { + it('should build the Graph provider with commercial-cloud hosts by default', () => { + const { getConfiguredProvider, graphConstructor } = loadFactory(GRAPH_BASE); + + expect(getConfiguredProvider()).to.not.be.null; + const config = graphConstructor.firstCall.args[0]; + expect(config.loginHost).to.equal('https://login.microsoftonline.com'); + expect(config.graphHost).to.equal('https://graph.microsoft.com'); + expect(config.clientSecret).to.equal('secret-1'); + }); + + it('should use Azure Government hosts for GCC High and DoD', () => { + const gccHigh = loadFactory({ ...GRAPH_BASE, CalendarSync_Graph_Cloud: 'gcc-high' }); + gccHigh.getConfiguredProvider(); + expect(gccHigh.graphConstructor.firstCall.args[0].loginHost).to.equal('https://login.microsoftonline.us'); + expect(gccHigh.graphConstructor.firstCall.args[0].graphHost).to.equal('https://graph.microsoft.us'); + + const dod = loadFactory({ ...GRAPH_BASE, CalendarSync_Graph_Cloud: 'dod' }); + dod.getConfiguredProvider(); + expect(dod.graphConstructor.firstCall.args[0].graphHost).to.equal('https://dod-graph.microsoft.us'); + }); + + it('should pass certificate credentials through and require both parts', () => { + const certSettings = { + ...GRAPH_BASE, + CalendarSync_Graph_Auth_Method: 'certificate', + CalendarSync_Graph_ClientSecret: '', + CalendarSync_Graph_Certificate: 'CERT-PEM', + CalendarSync_Graph_PrivateKey: 'KEY-PEM', + }; + + const complete = loadFactory(certSettings); + expect(complete.getConfiguredProvider()).to.not.be.null; + const config = complete.graphConstructor.firstCall.args[0]; + expect(config.authMethod).to.equal('certificate'); + expect(config.certificatePem).to.equal('CERT-PEM'); + expect(config.privateKeyPem).to.equal('KEY-PEM'); + expect(config.clientSecret).to.be.undefined; + + const missingKey = loadFactory({ ...certSettings, CalendarSync_Graph_PrivateKey: '' }); + expect(missingKey.getConfiguredProvider()).to.be.null; + expect(missingKey.graphConstructor.called).to.be.false; + }); + + it('should return null when client-secret auth lacks a secret', () => { + const { getConfiguredProvider, graphConstructor } = loadFactory({ ...GRAPH_BASE, CalendarSync_Graph_ClientSecret: '' }); + expect(getConfiguredProvider()).to.be.null; + expect(graphConstructor.called).to.be.false; + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/logSanitizer.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/logSanitizer.spec.ts new file mode 100644 index 0000000000000..e6e91e48546af --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/logSanitizer.spec.ts @@ -0,0 +1,32 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { sanitizeError, sanitizeSensitiveText } from '../../../../../ee/server/lib/calendarSync/logSanitizer'; + +describe('calendarSync/logSanitizer', () => { + it('should scrub bearer tokens', () => { + expect(sanitizeSensitiveText('failed with Authorization: Bearer eyJhbGciOi.abc-123_x')).to.not.include('eyJhbGciOi'); + }); + + it('should scrub client_secret values from url-encoded bodies', () => { + const result = sanitizeSensitiveText('request body client_secret=s3cr3t~value&grant_type=client_credentials'); + expect(result).to.include('client_secret=[redacted]'); + expect(result).to.not.include('s3cr3t~value'); + }); + + it('should scrub SOAP password elements', () => { + const result = sanitizeSensitiveText('hunter2'); + expect(result).to.not.include('hunter2'); + }); + + it('should produce a code/message pair from CalendarSyncError-like objects', () => { + const result = sanitizeError({ code: 'throttled', message: 'slow down Bearer abc.def' }); + expect(result.code).to.equal('throttled'); + expect(result.message).to.not.include('abc.def'); + }); + + it('should handle non-object errors', () => { + expect(sanitizeError('boom')).to.deep.equal({ code: 'unknown-error', message: 'boom' }); + expect(sanitizeError(undefined).code).to.equal('unknown-error'); + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/mailboxResolver.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/mailboxResolver.spec.ts new file mode 100644 index 0000000000000..38eb905d42eb8 --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/mailboxResolver.spec.ts @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { resolveMailbox } from '../../../../../ee/server/lib/calendarSync/mailboxResolver'; + +describe('calendarSync/mailboxResolver', () => { + describe('email source', () => { + it('should resolve the first verified email address', () => { + const user = { + emails: [ + { address: 'unverified@example.com', verified: false }, + { address: 'verified@example.com', verified: true }, + ], + }; + expect(resolveMailbox(user, 'email')).to.equal('verified@example.com'); + }); + + it('should return null when the user only has unverified emails', () => { + const user = { emails: [{ address: 'unverified@example.com', verified: false }] }; + expect(resolveMailbox(user, 'email')).to.be.null; + }); + + it('should return null when the user has no emails', () => { + expect(resolveMailbox({}, 'email')).to.be.null; + }); + + it('should skip verified entries that are not valid email addresses', () => { + const user = { emails: [{ address: 'not-an-email', verified: true }] }; + expect(resolveMailbox(user, 'email')).to.be.null; + }); + }); + + describe('custom-field source', () => { + it('should resolve a valid email from the configured custom field', () => { + const user = { customFields: { mailbox: ' corp.user@example.mil ' } }; + expect(resolveMailbox(user, 'custom-field', 'mailbox')).to.equal('corp.user@example.mil'); + }); + + it('should return null when the custom field is missing or invalid', () => { + expect(resolveMailbox({ customFields: {} }, 'custom-field', 'mailbox')).to.be.null; + expect(resolveMailbox({ customFields: { mailbox: 'nope' } }, 'custom-field', 'mailbox')).to.be.null; + expect(resolveMailbox({ customFields: { mailbox: 42 } }, 'custom-field', 'mailbox')).to.be.null; + }); + + it('should return null when no custom field name is configured', () => { + expect(resolveMailbox({ customFields: { mailbox: 'a@b.co' } }, 'custom-field')).to.be.null; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/ntlm.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/ntlm.spec.ts new file mode 100644 index 0000000000000..0ae8d97eddaf8 --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/ntlm.spec.ts @@ -0,0 +1,127 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { md4 } from '../../../../../ee/server/lib/calendarSync/providers/ews/md4'; +import { + createType1Message, + createType3Message, + ntowfv2, + parseNtlmUsername, + parseType2Message, +} from '../../../../../ee/server/lib/calendarSync/providers/ews/ntlm'; + +describe('calendarSync/ews/ntlm', () => { + describe('md4', () => { + // RFC 1320 test vectors + const vectors: [string, string][] = [ + ['', '31d6cfe0d16ae931b73c59d7e0c089c0'], + ['a', 'bde52cb31de33e46245e05fbdbd6fb24'], + ['abc', 'a448017aaf21d8525fc10ae87aa6729d'], + ['message digest', 'd9130a8164549fe818874806e1c7014b'], + ['12345678901234567890123456789012345678901234567890123456789012345678901234567890', 'e33b4ddc9c38f2199c3e7b164fcc0536'], + ]; + + for (const [input, digest] of vectors) { + it(`should match the RFC 1320 vector for ${JSON.stringify(input.slice(0, 16))}`, () => { + expect(md4(Buffer.from(input, 'ascii')).toString('hex')).to.equal(digest); + }); + } + }); + + describe('ntowfv2', () => { + it('should match the [MS-NLMP] 4.2.4.1.1 test vector', () => { + // User "User", domain "Domain", password "Password" + expect(ntowfv2('User', 'Password', 'Domain').toString('hex')).to.equal('0c868a403bfd7a93a3001ef22ef02e3f'); + }); + }); + + describe('parseNtlmUsername', () => { + it('should split DOMAIN\\user into its parts', () => { + expect(parseNtlmUsername('CONTOSO\\svc-rocketchat')).to.deep.equal({ domain: 'CONTOSO', username: 'svc-rocketchat' }); + }); + + it('should pass UPNs through with an empty domain', () => { + expect(parseNtlmUsername('svc@contoso.mil')).to.deep.equal({ domain: '', username: 'svc@contoso.mil' }); + }); + }); + + describe('handshake messages', () => { + it('should produce a well-formed Type 1 message', () => { + const header = createType1Message(); + expect(header).to.match(/^NTLM [A-Za-z0-9+/=]+$/); + + const message = Buffer.from(header.slice(5), 'base64'); + expect(message.toString('ascii', 0, 8)).to.equal('NTLMSSP\0'); + expect(message.readUInt32LE(8)).to.equal(1); + }); + + it('should parse the server challenge and target info out of a Type 2 message', () => { + // Build a synthetic Type 2: signature, type, target name (empty), flags, challenge, context, target info + const targetInfo = Buffer.from('020004004c004100', 'hex'); // MsvAvNbDomainName "LA" + const message = Buffer.alloc(48 + targetInfo.length); + message.write('NTLMSSP\0', 0, 'ascii'); + message.writeUInt32LE(2, 8); + message.writeUInt32LE(0x00088201, 20); + Buffer.from('0123456789abcdef', 'hex').copy(message, 24); + message.writeUInt16LE(targetInfo.length, 40); + message.writeUInt16LE(targetInfo.length, 42); + message.writeUInt32LE(48, 44); + targetInfo.copy(message, 48); + + const challenge = parseType2Message(`NTLM ${message.toString('base64')}`); + expect(challenge.serverChallenge.toString('hex')).to.equal('0123456789abcdef'); + expect(challenge.targetInfo.equals(targetInfo)).to.be.true; + }); + + it('should reject headers without an NTLM challenge', () => { + expect(() => parseType2Message('Negotiate')).to.throw('NTLM challenge'); + }); + + it('should build a Type 3 message whose NT response verifies against the challenge', () => { + const targetInfo = Buffer.from('020004004c004100', 'hex'); + const challenge = { + serverChallenge: Buffer.from('0123456789abcdef', 'hex'), + targetInfo, + flags: 0x00088201, + }; + const clientNonce = Buffer.from('aaaaaaaaaaaaaaaa', 'hex'); + const header = createType3Message( + challenge, + { username: 'User', password: 'Password', domain: 'Domain', workstation: 'WS' }, + { clientNonce, now: 1467321600000 }, + ); + + const message = Buffer.from(header.slice(5), 'base64'); + expect(message.toString('ascii', 0, 8)).to.equal('NTLMSSP\0'); + expect(message.readUInt32LE(8)).to.equal(3); + + const readBuffer = (position: number): Buffer => { + const length = message.readUInt16LE(position); + const offset = message.readUInt32LE(position + 4); + return Buffer.from(message.subarray(offset, offset + length)); + }; + + const lmResponse = readBuffer(12); + const ntResponse = readBuffer(20); + const domain = readBuffer(28).toString('utf16le'); + const username = readBuffer(36).toString('utf16le'); + + expect(domain).to.equal('Domain'); + expect(username).to.equal('User'); + expect(lmResponse).to.have.length(24); // 16-byte HMAC + 8-byte client nonce + expect(lmResponse.subarray(16).equals(clientNonce)).to.be.true; + + // Recompute the NTLMv2 proof from the blob the message carries and verify it matches + const crypto = require('crypto'); + const blob = ntResponse.subarray(16); + const expectedProof = crypto + .createHmac('md5', ntowfv2('User', 'Password', 'Domain')) + .update(Buffer.concat([challenge.serverChallenge, blob])) + .digest(); + expect(ntResponse.subarray(0, 16).equals(expectedProof)).to.be.true; + + // The blob must echo the server's target info + expect(blob.includes(targetInfo)).to.be.true; + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/ee/calendarSync/webhooks.spec.ts b/apps/meteor/tests/unit/server/ee/calendarSync/webhooks.spec.ts new file mode 100644 index 0000000000000..dac358e0819f0 --- /dev/null +++ b/apps/meteor/tests/unit/server/ee/calendarSync/webhooks.spec.ts @@ -0,0 +1,84 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const findOneBySubscriptionIdStub = sinon.stub(); + +const { processGraphNotifications } = proxyquire.noCallThru().load('../../../../../ee/server/lib/calendarSync/webhooks.ts', { + '@rocket.chat/models': { + CalendarSyncState: { findOneBySubscriptionId: findOneBySubscriptionIdStub }, + }, +}); + +const silentLogger = { warn: () => undefined, error: () => undefined }; + +describe('calendarSync/webhooks', () => { + let syncUserById: sinon.SinonStub; + + beforeEach(() => { + findOneBySubscriptionIdStub.reset(); + findOneBySubscriptionIdStub.resolves(null); + syncUserById = sinon.stub().resolves(true); + }); + + it('should re-sync the user for an authenticated notification', async () => { + findOneBySubscriptionIdStub.withArgs('sub-1').resolves({ uid: 'u1', subscriptionClientState: 'secret-1' }); + + await processGraphNotifications({ value: [{ subscriptionId: 'sub-1', clientState: 'secret-1' }] }, { syncUserById }, silentLogger); + + expect(syncUserById.calledOnceWith('u1')).to.be.true; + }); + + it('should dedupe multiple notifications for the same user', async () => { + findOneBySubscriptionIdStub.withArgs('sub-1').resolves({ uid: 'u1', subscriptionClientState: 'secret-1' }); + + await processGraphNotifications( + { + value: [ + { subscriptionId: 'sub-1', clientState: 'secret-1' }, + { subscriptionId: 'sub-1', clientState: 'secret-1' }, + ], + }, + { syncUserById }, + silentLogger, + ); + + expect(syncUserById.calledOnce).to.be.true; + }); + + it('should drop notifications with a mismatched clientState', async () => { + findOneBySubscriptionIdStub.withArgs('sub-1').resolves({ uid: 'u1', subscriptionClientState: 'secret-1' }); + + await processGraphNotifications({ value: [{ subscriptionId: 'sub-1', clientState: 'forged' }] }, { syncUserById }, silentLogger); + + expect(syncUserById.called).to.be.false; + }); + + it('should ignore unknown subscriptions and malformed payloads without throwing', async () => { + await processGraphNotifications({ value: [{ subscriptionId: 'unknown', clientState: 'x' }] }, { syncUserById }, silentLogger); + await processGraphNotifications({} as any, { syncUserById }, silentLogger); + await processGraphNotifications({ value: [{}] } as any, { syncUserById }, silentLogger); + + expect(syncUserById.called).to.be.false; + }); + + it('should keep processing other users when one sync fails', async () => { + findOneBySubscriptionIdStub.withArgs('sub-1').resolves({ uid: 'u1', subscriptionClientState: 's1' }); + findOneBySubscriptionIdStub.withArgs('sub-2').resolves({ uid: 'u2', subscriptionClientState: 's2' }); + syncUserById.withArgs('u1').rejects(new Error('boom')); + + await processGraphNotifications( + { + value: [ + { subscriptionId: 'sub-1', clientState: 's1' }, + { subscriptionId: 'sub-2', clientState: 's2' }, + ], + }, + { syncUserById }, + silentLogger, + ); + + expect(syncUserById.calledWith('u2')).to.be.true; + }); +}); diff --git a/docs/features/server-calendar-sync-testing.md b/docs/features/server-calendar-sync-testing.md new file mode 100644 index 0000000000000..947fe30c0cc6f --- /dev/null +++ b/docs/features/server-calendar-sync-testing.md @@ -0,0 +1,260 @@ +# Testing Guide: Server-to-Server Calendar Sync + +How to stand up Outlook/Exchange test environments from scratch and exercise the [server calendar sync](./server-calendar-sync.md) feature end to end. Two independent tracks: **Microsoft 365 (Graph provider)** and **on-premises Exchange (EWS provider)** — you only need the one you intend to test. + +--- + +## Part 1 — Microsoft 365 test tenant (Graph provider) + +### 1.1 Get a tenant + +Any of the following works; you need admin rights and at least two licensed mailboxes: + +| Option | Notes | +| --- | --- | +| **Microsoft 365 Developer Program sandbox** | Instant sandbox with 25 prefilled users. Requires an eligible Visual Studio Enterprise subscription since the 2024 program changes. Best option if available — users and sample data come pre-provisioned. | +| **Microsoft 365 Business Standard trial** | 30 days, sign up at microsoft.com/microsoft-365 with a fresh email. Gives a `*.onmicrosoft.com` tenant with Exchange Online. | +| **Existing corporate dev/staging tenant** | Fine too — the app registration below is read-only (`Calendars.Read`) and can be scoped to a test group with an application access policy. | + +### 1.2 Create test users + +In the [Microsoft 365 admin center](https://admin.microsoft.com) → **Users → Active users → Add a user**: + +- Create at least three users, e.g. `alice@.onmicrosoft.com`, `bob@…`, `carol@…`, each with an Exchange Online license (mailboxes take a few minutes to provision). +- Keep one user (carol) **unlicensed or excluded** later — she is your negative test case. + +### 1.3 Register the Entra ID application + +In the [Entra admin center](https://entra.microsoft.com) → **Identity → Applications → App registrations → New registration**: + +1. Name: `Rocket.Chat Calendar Sync (test)`. Supported account types: **Accounts in this organizational directory only**. No redirect URI. Register. +2. From **Overview**, copy the **Directory (tenant) ID** and **Application (client) ID**. +3. **Certificates & secrets → New client secret** → copy the secret **Value** immediately (it is shown once). +4. **API permissions → Add a permission → Microsoft Graph → Application permissions**: + - Full event sync: `Calendars.Read` + - Free/busy-only testing: `Calendars.ReadBasic` is sufficient + - Webhooks testing: no extra permission needed (subscriptions ride on the calendar permission) +5. Click **Grant admin consent for ``** — the status column must show a green check. Skipping this is the #1 cause of `consent-missing`. + +#### Certificate credential (optional, to test that path) + +```bash +openssl req -x509 -newkey rsa:2048 -keyout graph-key.pem -out graph-cert.pem \ + -days 365 -nodes -subj "/CN=rocketchat-calendar-sync-test" +``` + +Upload `graph-cert.pem` under **Certificates & secrets → Certificates**. In Rocket.Chat you will paste `graph-cert.pem` into **Certificate (PEM)** and `graph-key.pem` into **Private key (PEM)**. + +### 1.4 (Optional) Scope the app to a test group + +From an Exchange Online PowerShell session (`Install-Module ExchangeOnlineManagement; Connect-ExchangeOnline`): + +```powershell +New-DistributionGroup -Name "CalSync Test Users" -Type Security +Add-DistributionGroupMember -Identity "CalSync Test Users" -Member alice@.onmicrosoft.com +Add-DistributionGroupMember -Identity "CalSync Test Users" -Member bob@.onmicrosoft.com +New-ApplicationAccessPolicy -AppId -PolicyScopeGroupId "CalSync Test Users" ` + -AccessRight RestrictAccess -Description "Rocket.Chat calendar sync test scope" +Test-ApplicationAccessPolicy -AppId -Identity alice@.onmicrosoft.com # Granted +Test-ApplicationAccessPolicy -AppId -Identity carol@.onmicrosoft.com # Denied +``` + +Carol now doubles as the "policy-denied" test case. + +### 1.5 Seed calendar data + +Sign in to [outlook.office.com](https://outlook.office.com) as each test user and create a spread of events: + +- A meeting starting ~10 minutes from now, 30 min long, **Show as: Busy** (drives the presence test). +- An all-day event marked **Free** (must NOT drive presence). +- A **Tentative** event (must not drive presence either). +- A recurring daily meeting (exercises delta updates). +- One event you will later **cancel/delete** (deletion propagation test). + +--- + +## Part 2 — On-premises Exchange lab (EWS provider) + +### 2.1 Lab topology + +Minimum viable lab — one Windows Server VM (Exchange officially wants a separate DC, but a single combined lab box works for testing): + +| VM | Roles | Specs | +| --- | --- | --- | +| `DC01` | AD DS, DNS | 2 vCPU, 4 GB | +| `EX01` | Exchange Server 2019 Mailbox | 4+ vCPU, **16 GB+ RAM**, 120 GB disk | + +Software: Windows Server 2019/2022 evaluation ISO + **Exchange Server 2019 evaluation** (180-day trial from the Microsoft Evaluation Center). Exchange SE follows the same steps. + +High-level install sequence (allow 2–4 hours): + +1. On `DC01`: install AD DS, promote to a new forest (e.g. `corp.lab`), make it the DNS server. +2. On `EX01` (domain-joined): install Exchange prerequisites (.NET 4.8, VC++ redists, IIS features, UCMA runtime — the Exchange setup wizard lists what's missing), then from an elevated prompt on the ISO: + ``` + Setup.exe /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareSchema + Setup.exe /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /PrepareAD /OrganizationName:"CorpLab" + Setup.exe /IAcceptExchangeServerLicenseTerms_DiagnosticDataOFF /Mode:Install /Role:Mailbox + ``` +3. Verify OWA at `https://ex01.corp.lab/owa` and EWS at `https://ex01.corp.lab/EWS/Exchange.asmx` (a 401 challenge in the browser is the expected healthy response for EWS). + +### 2.2 Create mailboxes and the service account + +From the Exchange Management Shell on `EX01`: + +```powershell +# Test user mailboxes +1..3 | ForEach-Object { + $name = @('alice','bob','carol')[$_-1] + New-Mailbox -Name $name -UserPrincipalName "$name@corp.lab" ` + -Password (ConvertTo-SecureString 'P@ssw0rd!Lab1' -AsPlainText -Force) +} + +# Service account (mailbox-enabled is simplest) +New-Mailbox -Name svc-rocketchat -UserPrincipalName svc-rocketchat@corp.lab ` + -Password (ConvertTo-SecureString '' -AsPlainText -Force) + +# Grant impersonation +New-ManagementRoleAssignment -Name "RocketChat Calendar Sync" ` + -Role ApplicationImpersonation -User "CORP\svc-rocketchat" +``` + +To test the **scoped** variant (and the `impersonation-denied` error path), scope the assignment to a group that excludes carol: + +```powershell +New-ManagementScope -Name "CalSync Scope" ` + -RecipientRestrictionFilter { MemberOfGroup -eq "CN=CalSyncUsers,CN=Users,DC=corp,DC=lab" } +Set-ManagementRoleAssignment "RocketChat Calendar Sync" -CustomRecipientWriteScope "CalSync Scope" +``` + +### 2.3 Auth method notes + +- **NTLM** (default) works out of the box against the EWS virtual directory. Username format: `CORP\svc-rocketchat` (UPN also works). +- **Basic** must be explicitly enabled if you want to test it: + ```powershell + Set-WebServicesVirtualDirectory -Identity "EX01\EWS (Default Web Site)" -BasicAuthentication $true + ``` + +### 2.4 TLS + +Exchange installs with a self-signed certificate. Two ways to make the Rocket.Chat host trust it, in order of preference: + +1. Export the cert (`Export-Certificate` or from the browser) and start Rocket.Chat with `NODE_EXTRA_CA_CERTS=/path/to/ex01-ca.pem`. +2. Or enable **Allow self-signed TLS certificates** in the Calendar Sync EWS settings (scoped to the EWS endpoint only). + +Also make sure the Rocket.Chat host resolves `ex01.corp.lab` (add to `/etc/hosts` if the lab DNS isn't reachable). + +### 2.5 Seed calendar data + +Use OWA (`https://ex01.corp.lab/owa`) as each user and create the same event spread as §1.5. + +### 2.6 Air-gap verification (optional but recommended for this provider) + +With the EWS provider enabled, verify no Microsoft-cloud traffic leaves the host: + +```bash +# While a sync runs, watch outbound DNS/connections on the Rocket.Chat host: +sudo tcpdump -n 'port 53' | grep -Ei 'microsoftonline|graph\.microsoft|office' +# expected: no output ever +``` + +The unit-level guarantee is also asserted by `apps/meteor/tests/unit/server/ee/calendarSync/airGap.spec.ts`. + +--- + +## Part 3 — Rocket.Chat configuration + +### 3.1 Prerequisites + +- A workspace with a **Premium license including the `outlook-calendar` module** (the `Calendar_Sync` admin group is invisible without it). For local development use your usual EE development license. +- Local dev: `yarn && yarn dev` from the repo root (MongoDB must run as a replica set). + +### 3.2 Map Rocket.Chat users to mailboxes + +For each test mailbox create a Rocket.Chat user whose **email matches the mailbox address and is marked verified** (Admin → Users → edit → check *Mark email as verified*). Unverified emails are skipped **by design** — leave one user unverified as the "skipped, counted in diagnostics" test case. + +To test custom-field mapping instead: add `{"exchangeMailbox": {"type": "text", "required": false}}` to **Accounts → Registration → Custom Fields**, set the field on a user, and point **Mailbox address source** at it (field name `exchangeMailbox`). + +### 3.3 Settings (Admin → Settings → Calendar Sync) + +| Setting | Graph test | EWS test | +| --- | --- | --- | +| Enable server calendar sync | ✅ | ✅ | +| Calendar provider | Exchange Online / Microsoft 365 | Exchange Server on-premises (EWS) | +| Cloud environment | Commercial (unless testing GCC High/DoD) | — | +| Directory (tenant) ID / Application (client) ID | from §1.3 | — | +| Credential type + secret/cert | client secret from §1.3 (or PEMs) | — | +| EWS endpoint URL | — | `https://ex01.corp.lab/EWS/Exchange.asmx` | +| Service account username/password | — | `CORP\svc-rocketchat` + password | +| Authentication method | — | NTLM (then repeat with Basic) | +| Sync mode | Full event sync (repeat later with Free/busy only) | same | +| Sync interval | 1 minute (fast feedback while testing) | same | +| Sync window (days) / batch size | defaults (7 / 10) | same | + +Click the **Test connection** button — expect the success toast. Then break something on purpose (wrong secret, wrong tenant, revoke consent) and confirm the toast reports `invalid-client-secret` / `invalid-tenant` / `consent-missing` respectively (EWS: wrong password → `invalid-credentials`; carol with scoped impersonation → `impersonation-denied` when probed). + +### 3.4 End-to-end verification + +All REST calls below need an admin PAT (`X-User-Id` / `X-Auth-Token` headers) and the `manage-calendar-sync` permission. + +```bash +BASE=http://localhost:3000/api/v1 +AUTH='-H "X-User-Id: " -H "X-Auth-Token: "' + +# 1. Deep connection test incl. mailbox access/impersonation: +curl -s -X POST $BASE/calendar-sync.test-connection $AUTH \ + -H 'Content-Type: application/json' -d '{"probeMailbox":"alice@"}' + +# 2. Trigger a run and watch it: +curl -s -X POST $BASE/calendar-sync.run $AUTH +curl -s $BASE/calendar-sync.status $AUTH # lastRun counters +curl -s "$BASE/calendar-sync.user-status?userId=" $AUTH +``` + +Then verify, in order: + +| # | Check | Expected | +| --- | --- | --- | +| 1 | `calendar-sync.status` after first run | `usersProcessed` = mapped users, `usersSkippedNoMailbox` ≥ 1 (the unverified user), `eventsUpserted` > 0 | +| 2 | `GET /v1/calendar-events.list?date=` as alice | Her seeded events, with subjects/times; Free/Tentative events present but with `busy: false` | +| 3 | Kebab menu → Calendar in any channel (as alice) | Same events in the UI | +| 4 | When alice's Busy meeting starts (≤1 sync interval later) | Alice's status flips to **busy** with "In a meeting"; flips back after it ends | +| 5 | Set bob's status manually to DND before his meeting starts | Bob **stays DND** through the meeting (manual always wins) | +| 6 | Delete an event in Outlook/OWA, wait one interval | Event disappears from `calendar-events.list` | +| 7 | Move an event's time in Outlook | Time updates in Rocket.Chat (delta/`SyncFolderItems` path) | +| 8 | Second run in `status` | Faster/smaller than the first (incremental — delta tokens/SyncState in effect) | +| 9 | Switch **Sync mode** to *Free/busy only* | State resets; no new event records ingested, but check #4 still works; check #2 shows no *new* events | +| 10 | Break credentials mid-flight | `calendar-sync.user-status` shows sanitized `lastError` + growing `consecutiveFailures`; **no secrets/tokens** in `lastError.message` or server logs | + +Log filtering: the feature logs under the `CalendarSync` logger — set **Admin → Logs → Log Level** to debug while testing and grep for `CalendarSync`. + +### 3.5 Webhooks (Graph only, optional) + +Change notifications need a **public HTTPS** notification URL, so a plain localhost dev server can't receive them. Two options: + +- **Tunnel**: `cloudflared tunnel --url http://localhost:3000` (or ngrok), set **Site URL** to the tunnel URL, restart, then enable **Enable change notifications (webhooks)**. +- **Staging deployment** with a real TLS certificate — set the toggle there. + +Verify: + +1. After the next sync run, `calendar_sync_state` documents have `subscriptionId`/`subscriptionExpiresAt` (check via mongo shell), and no warnings from `CalendarSync` about subscriptions. +2. Create an event in Outlook → it appears in Rocket.Chat within **seconds**, without waiting for the polling interval. +3. Forge a notification to confirm authentication: `curl -X POST '/api/v1/calendar-sync.webhook' -H 'Content-Type: application/json' -d '{"value":[{"subscriptionId":"","clientState":"wrong"}]}'` → 202, a "mismatched clientState" warning in the logs, and **no** sync triggered. +4. Validation handshake sanity check: `curl '/api/v1/calendar-sync.webhook?validationToken=hello' -X POST` → responds `hello` as `text/plain`. + +### 3.6 Coexistence sanity check (optional) + +With the legacy desktop-app Outlook integration active for the same user, expect the documented duplicate-event limitation (different external ids across the two paths). Confirm that server-driven deletions never remove the client-pushed copies (only events with a `provider` field are ever touched by the engine). + +--- + +## Quick troubleshooting + +| Symptom | Likely cause | +| --- | --- | +| Settings group not visible | License lacks the `outlook-calendar` module | +| Everyone lands in `usersSkippedNoMailbox` | Emails not marked *verified*, or custom-field mapping misconfigured | +| `consent-missing` on test connection | Admin consent not granted (§1.3 step 5) or app blocked by an access policy | +| `impersonation-denied` (EWS) | Role assignment missing or the probe mailbox is outside the management scope | +| `network-error` (EWS) | Hostname not resolvable from the Rocket.Chat host, or TLS trust (see §2.4) | +| Webhook subscriptions never appear | Site URL not HTTPS/public, or `CalendarSync_Webhooks_Enabled` off | +| Presence never changes | `CalendarSync_Presence_Enabled` off, or (full-events mode) legacy `Calendar_BusyStatus_Enabled` disabled | diff --git a/docs/features/server-calendar-sync.md b/docs/features/server-calendar-sync.md new file mode 100644 index 0000000000000..f6c5351a95ede --- /dev/null +++ b/docs/features/server-calendar-sync.md @@ -0,0 +1,134 @@ +# Server-to-Server Outlook/Exchange Calendar Sync + +## Overview + +Premium feature (license module `outlook-calendar`). The server periodically fetches users' Outlook/Exchange calendars using administrator-provided credentials and imports them through the existing calendar service (`calendar_event` collection, reminders, busy-presence). Unlike the client-based Outlook Calendar integration — which keeps working unchanged alongside this one — users never authenticate individually and no desktop app is required. + +> Setting up a test environment (M365 tenant or on-prem Exchange lab) and a full verification checklist: see the [testing guide](./server-calendar-sync-testing.md). + +Key source locations: + +- Sync engine and providers: `apps/meteor/ee/server/lib/calendarSync/` +- Settings group `Calendar_Sync`: `apps/meteor/ee/server/settings/calendarSync.ts` +- REST endpoints: `apps/meteor/ee/server/api/calendarSync/` +- Startup wiring (license-gated): `apps/meteor/ee/server/configuration/calendarSync.ts` +- Per-user sync state: `calendar_sync_state` collection (`packages/models/src/models/CalendarSyncState.ts`) + +## Deployment models + +| Deployment | Provider setting | Protocol | Auth | +| --- | --- | --- | --- | +| Exchange Online / Microsoft 365 | `microsoft-graph` | Microsoft Graph REST | OAuth 2.0 client credentials (app-only) | +| Exchange Server 2016/2019/SE on-premises | `exchange-ews` | Exchange Web Services (SOAP) | Service account, NTLM (default) or Basic | + +Do **not** use EWS against Exchange Online: Microsoft begins disabling EWS in Exchange Online in October 2026, with full shutdown in April 2027. Graph is the only supported mechanism for cloud tenants. + +**Air-gap guarantee:** when `exchange-ews` is selected, the integration makes zero connection attempts to any Microsoft cloud endpoint — no probes, no telemetry. Only the admin-configured EWS URL is ever contacted. This is enforced by construction (the Graph provider is never instantiated) and verified by unit tests (`apps/meteor/tests/unit/server/ee/calendarSync/airGap.spec.ts`). + +## Sync modes + +- **Full event sync** (`full-events`, default): imports subjects, times, and event body text (used for meeting-URL detection), matching the client integration's behavior. Drives busy presence through the calendar service. +- **Free/busy only** (`free-busy-only`): fetches availability blocks (Graph `getSchedule` / EWS `GetUserAvailability`) and drives presence directly. **No event subjects or details are ever requested or stored.** Requires a smaller permission footprint (see below); intended for security-conscious deployments. Requires "Update user presence from calendar" to be enabled — the mode has no other effect. + +## Microsoft Graph setup (Exchange Online) + +1. In **Microsoft Entra ID → App registrations → New registration**, create a single-tenant app (no redirect URI needed). +2. Under **Certificates & secrets**, create a client secret. Note its expiry and plan rotation. +3. Under **API permissions → Add a permission → Microsoft Graph → Application permissions**, add: + - Full event sync: `Calendars.Read` + - Free/busy only: `Calendars.ReadBasic` is sufficient — it is the least-privileged application permission for `getSchedule` per the [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/api/calendar-getschedule). (`Schedule.Read.All` is a Teams Shifts permission and is *not* what free/busy needs.) +4. Click **Grant admin consent** for the tenant. Without consent, test-connection reports `consent-missing`. +5. In Rocket.Chat, fill in **Directory (tenant) ID**, **Application (client) ID**, and **Client secret** under Admin → Settings → Calendar Sync. + +### Limiting which mailboxes the app can read + +Application permissions are tenant-wide by default. Scope them with an application access policy (Exchange Online PowerShell): + +```powershell +New-DistributionGroup -Name "RocketChat Calendar Sync Users" -Type Security +New-ApplicationAccessPolicy -AppId -PolicyScopeGroupId "RocketChat Calendar Sync Users" ` + -AccessRight RestrictAccess -Description "Restrict Rocket.Chat calendar sync to members" +Test-ApplicationAccessPolicy -AppId -Identity user@example.com +``` + +Users outside the policy fail with `consent-missing`/access-denied and are counted in sync diagnostics; they do not halt the batch. + +## EWS setup (on-premises Exchange) + +1. Create a dedicated service account (regular mailbox-enabled AD account; strong password; no interactive logon rights). +2. Grant it the `ApplicationImpersonation` RBAC role (Exchange Management Shell): + +```powershell +New-ManagementRoleAssignment -Name "RocketChat Calendar Sync" ` + -Role ApplicationImpersonation -User "CONTOSO\svc-rocketchat" +``` + + To limit impersonation to a subset of mailboxes, add a management scope: + +```powershell +New-ManagementScope -Name "RocketChat Sync Users" -RecipientRestrictionFilter { MemberOfGroup -eq "CN=RocketChatSyncUsers,..." } +New-ManagementRoleAssignment -Name "RocketChat Calendar Sync" -Role ApplicationImpersonation ` + -User "CONTOSO\svc-rocketchat" -CustomRecipientWriteScope "RocketChat Sync Users" +``` + +3. In Rocket.Chat, set the **EWS endpoint URL** (e.g. `https://mail.example.mil/EWS/Exchange.asmx`), the service account username (`DOMAIN\username` for NTLM; a UPN also works) and password, and the auth method. NTLM (NTLMv2) is the default; use Basic only where the endpoint requires it (always over HTTPS). +4. TLS: if the endpoint uses a private CA, prefer starting the server with `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`. The "Allow self-signed TLS certificates" setting disables certificate validation **for the EWS endpoint only** and is an explicit opt-in for trusted networks. + +Free/busy-only mode uses `GetUserAvailability` under the service account's own identity and relies on organization-default free/busy visibility; impersonation is only exercised by full event sync (`FindItem`/`GetItem`). + +## User → mailbox mapping + +- Default: the user's **verified** Rocket.Chat email address. Unverified addresses are deliberately not used (an unverified address could point at someone else's mailbox and leak their calendar); such users are skipped and counted in diagnostics. +- Alternative: a user custom field (set **Mailbox address source** to "User custom field" and name the field) — for deployments where the corporate SMTP address differs from the login email (common with LDAP). + +## Presence behavior + +Both modes issue the same presence claim the legacy integration uses (`statusId: 'calendar'`, `statusSource: 'external'`), so all rules are enforced centrally by the presence engine: + +- Presence is only overridden when the user is effectively available; a **manually set status (e.g. DND) always outranks** the calendar claim. +- The claim carries an expiry (end of the current busy block); the previous status is restored automatically when it lapses or the busy block disappears. +- Full-events mode goes through the calendar service and therefore also respects the legacy `Calendar_BusyStatus_Enabled` setting; free/busy-only mode is controlled solely by `CalendarSync_Presence_Enabled`. + +## Coexistence with the client integration + +Server-synced events carry a `provider` discriminator (`microsoft-graph`/`exchange-ews`) and, when available, an `iCalUId`; client-pushed events have neither. Deletion and snapshot-diffing only ever touch server-synced events. Note that if a user has the desktop-app sync *and* server sync active, the same meeting can appear twice (the desktop app's EWS item id and Graph's event id are different identifiers, and legacy events carry no iCal UID to correlate on) — steer users off the client integration once server sync covers them. + +## REST API (admin, `manage-calendar-sync` permission) + +| Endpoint | Method | Purpose | +| --- | --- | --- | +| `/api/v1/calendar-sync.test-connection` | POST | Validates credentials; optional `probeMailbox` body field additionally proves calendar access/impersonation on that mailbox. Returns `{ connection: { ok, error? } }` with actionable error codes. | +| `/api/v1/calendar-sync.run` | POST | Triggers a sync run immediately (returns `{ started: false }` if one is in progress). | +| `/api/v1/calendar-sync.status` | GET | Last run summary (users processed/skipped/failed, events upserted/deleted) plus state counts. | +| `/api/v1/calendar-sync.user-status?userId=…` | GET | Per-user sync state: mailbox, provider, last sync/success, last sanitized error, consecutive failures. | +| `/api/v1/calendar-sync.webhook` | POST | Receiver for Microsoft Graph change notifications (unauthenticated by design; each notification is authenticated against the per-subscription `clientState` secret). Handles the subscription validation handshake. | + +## Change notifications (webhooks, optional) + +With **Enable change notifications** turned on (Graph provider only), the sync engine creates one Graph subscription per synced user (`/users/{mailbox}/events`, ~70 h TTL) and renews it during regular sync runs when less than 24 h remain — no extra scheduler. Notifications carry no calendar data; a valid notification only triggers an immediate delta sync for that user. Requirements and caveats: + +- The workspace **Site URL must be HTTPS and publicly reachable** by Microsoft's cloud. When it isn't, the setting is effectively inert. +- Polling continues on its normal interval regardless — webhooks are an optimization, never a dependency (firewalled deployments simply leave this off). +- Notifications with an unknown subscription id or a mismatched `clientState` are dropped and logged. +- When disabled again, existing subscriptions lapse on their own within ~3 days; incoming notifications are ignored immediately. + +## Troubleshooting error codes + +| Code | Meaning / fix | +| --- | --- | +| `invalid-tenant`, `invalid-client`, `invalid-client-secret` | Graph credential settings are wrong (tenant id, client id, secret — check expiry). | +| `consent-missing` | Admin consent not granted, permission missing, or mailbox excluded by an application access policy. | +| `invalid-credentials` | EWS endpoint rejected the service account (HTTP 401/403). | +| `impersonation-denied` | Service account lacks `ApplicationImpersonation` for the target mailbox (check role assignment/scope). | +| `mailbox-not-found` | Resolved mailbox address has no mailbox — check the mailbox mapping source. | +| `throttled` | Provider throttling; the engine backs off automatically. Reduce batch size or increase the interval if persistent. | +| `network-error` | Endpoint unreachable — DNS/proxy/firewall/TLS. For private CAs see the TLS note above. | + +Sync errors are logged with user id and error code only — never event contents; tokens and passwords are scrubbed from all errors before logging or persistence. + +## Limitations + +- No write-back to Exchange. Webhooks are Graph-only (EWS deployments poll, with `SyncFolderItems` incremental sync between daily snapshot refreshes). +- EWS `CalendarView` is capped at 512 events per user per window; NTLM requests are serialized on one authenticated connection per workspace (tune batch size). Mailboxes too large to fast-forward through `SyncFolderItems` (>10k items) fall back to windowed polling automatically. +- Switching provider, mode, or mailbox mapping resets per-user sync state and forces a full resync. Events imported before switching to free/busy-only mode are not retroactively deleted. +- Kerberos authentication is not supported. diff --git a/packages/core-typings/src/ICalendarEvent.ts b/packages/core-typings/src/ICalendarEvent.ts index 2c5ad1e1ec959..b6fd1869b6ca8 100644 --- a/packages/core-typings/src/ICalendarEvent.ts +++ b/packages/core-typings/src/ICalendarEvent.ts @@ -1,3 +1,4 @@ +import type { CalendarSyncProviderType } from './ICalendarSyncState'; import type { IRocketChatRecord } from './IRocketChatRecord'; import type { IUser } from './IUser'; @@ -13,6 +14,10 @@ export interface ICalendarEvent extends IRocketChatRecord { externalId?: string | null; meetingUrl?: string | null; + /** Which server-side sync provider imported this event; undefined for client-pushed (legacy) events */ + provider?: CalendarSyncProviderType; + iCalUId?: string; + reminderMinutesBeforeStart?: number; reminderTime?: Date; diff --git a/packages/core-typings/src/ICalendarSyncState.ts b/packages/core-typings/src/ICalendarSyncState.ts new file mode 100644 index 0000000000000..79f6dc52b8cdf --- /dev/null +++ b/packages/core-typings/src/ICalendarSyncState.ts @@ -0,0 +1,31 @@ +import type { IRocketChatRecord } from './IRocketChatRecord'; +import type { IUser } from './IUser'; + +export type CalendarSyncProviderType = 'microsoft-graph' | 'exchange-ews'; + +export interface ICalendarSyncStateError { + code: string; + message: string; + at: Date; +} + +export interface ICalendarSyncState extends IRocketChatRecord { + uid: IUser['_id']; + mailbox: string; + provider: CalendarSyncProviderType; + + deltaToken?: string; + /** The window the delta token was established for; drifting past it forces a full resync */ + deltaWindowStart?: Date; + deltaWindowEnd?: Date; + + lastSyncAt?: Date; + lastSuccessAt?: Date; + lastError?: ICalendarSyncStateError; + consecutiveFailures: number; + + /** Graph change-notification subscription (optional optimization; polling continues regardless) */ + subscriptionId?: string; + subscriptionExpiresAt?: Date; + subscriptionClientState?: string; +} diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index f44681ebc9f60..584ab96e16407 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -113,6 +113,7 @@ export type * from './VideoConferenceCapabilities'; export type * from './SpotlightUser'; export type * from './ICalendarEvent'; +export type * from './ICalendarSyncState'; export type * from './search'; export * from './omnichannel'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c839b5d3e99ac..2d9335ccef6f4 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1040,6 +1040,73 @@ "Calendar_MeetingUrl_Regex": "Meeting url Regular Expression", "Calendar_MeetingUrl_Regex_Description": "Expression used to detect meeting URLs in event descriptions. The first matching group with a valid url will be used. HTML encoded urls will be decoded automatically.", "Calendar_settings": "Calendar settings", + "Calendar_Sync": "Calendar Sync (Server)", + "Calendar_Sync_Description": "Server-to-server calendar synchronization with Microsoft Outlook/Exchange. Events are fetched by the server using administrator-provided credentials — users do not need to authenticate individually. Works alongside the client-based Outlook Calendar integration.", + "CalendarSync_Batch_Size": "Sync batch size", + "CalendarSync_Batch_Size_Description": "Number of users synchronized concurrently in each batch. Lower this value if the Exchange server or Microsoft Graph reports throttling.", + "CalendarSync_Connection_Successful": "Connection successful. The configured calendar provider accepted the credentials.", + "CalendarSync_Enabled": "Enable server calendar sync", + "CalendarSync_Enabled_Description": "Periodically synchronize users' calendars from Microsoft Exchange/Outlook into Rocket.Chat using a server-side connection.", + "CalendarSync_Ews_AllowSelfSignedCerts": "Allow self-signed TLS certificates", + "CalendarSync_Ews_AllowSelfSignedCerts_Description": "Skip TLS certificate validation for the EWS endpoint only. Use exclusively with self-signed certificates on trusted networks; prefer adding your CA via NODE_EXTRA_CA_CERTS instead.", + "CalendarSync_Ews_AuthMethod": "Authentication method", + "CalendarSync_Ews_AuthMethod_Basic": "Basic", + "CalendarSync_Ews_AuthMethod_Description": "How the service account authenticates against the EWS endpoint. NTLM is the default for on-premises Exchange; use Basic only where the endpoint requires it.", + "CalendarSync_Ews_AuthMethod_Ntlm": "NTLM", + "CalendarSync_Ews_Password": "Service account password", + "CalendarSync_Ews_Password_Description": "Password of the Exchange service account. Stored as a secret setting and never exposed through the API.", + "CalendarSync_Ews_Url": "EWS endpoint URL", + "CalendarSync_Ews_Url_Description": "Full URL of the Exchange Web Services endpoint, e.g. https://mail.example.com/EWS/Exchange.asmx. Only this endpoint is ever contacted when the EWS provider is selected.", + "CalendarSync_Ews_Username": "Service account username", + "CalendarSync_Ews_Username_Description": "Exchange service account granted the ApplicationImpersonation role. For NTLM use the DOMAIN\\username format; a UPN (user@domain) also works.", + "CalendarSync_Graph_Auth_Method": "Credential type", + "CalendarSync_Graph_Auth_Method_Certificate": "Certificate", + "CalendarSync_Graph_Auth_Method_Client_Secret": "Client secret", + "CalendarSync_Graph_Auth_Method_Description": "How the app authenticates against Microsoft Entra ID. Certificate credentials avoid secret expiry and are recommended for production.", + "CalendarSync_Graph_Certificate": "Certificate (PEM)", + "CalendarSync_Graph_Certificate_Description": "PEM-encoded certificate uploaded to the Entra ID app registration. Used to compute the assertion thumbprint; contains no secret material.", + "CalendarSync_Graph_ClientId": "Application (client) ID", + "CalendarSync_Graph_ClientId_Description": "Application (client) ID of the Microsoft Entra ID app registration used for the client credentials flow.", + "CalendarSync_Graph_ClientSecret": "Client secret", + "CalendarSync_Graph_ClientSecret_Description": "Client secret of the Microsoft Entra ID app registration. Stored as a secret setting and never exposed through the API.", + "CalendarSync_Graph_Cloud": "Cloud environment", + "CalendarSync_Graph_Cloud_Commercial": "Commercial (login.microsoftonline.com)", + "CalendarSync_Graph_Cloud_Description": "Microsoft cloud to connect to. Choose GCC High or DoD for US Government (Azure Government) tenants.", + "CalendarSync_Graph_Cloud_Dod": "US Government DoD (dod-graph.microsoft.us)", + "CalendarSync_Graph_Cloud_GccHigh": "US Government GCC High (graph.microsoft.us)", + "CalendarSync_Graph_PrivateKey": "Private key (PEM)", + "CalendarSync_Graph_PrivateKey_Description": "PEM-encoded private key matching the certificate. Stored as a secret setting, used only to sign token requests, and never sent anywhere.", + "CalendarSync_Graph_TenantId": "Directory (tenant) ID", + "CalendarSync_Graph_TenantId_Description": "Directory (tenant) ID of the Microsoft Entra ID tenant that hosts the mailboxes.", + "CalendarSync_Interval": "Sync interval (minutes)", + "CalendarSync_Interval_Description": "How often the server synchronizes calendars, in minutes. Minimum 1 minute.", + "CalendarSync_Mailbox_CustomField": "Mailbox custom field name", + "CalendarSync_Mailbox_CustomField_Description": "Name of the user custom field that holds the Exchange mailbox address, when the mailbox source is set to custom field.", + "CalendarSync_Mailbox_Source": "Mailbox address source", + "CalendarSync_Mailbox_Source_Custom_Field": "User custom field", + "CalendarSync_Mailbox_Source_Description": "Where to obtain each user's Exchange mailbox address. By default the user's verified email address is used; a user custom field can be used instead when corporate mailbox addresses differ from login emails.", + "CalendarSync_Mailbox_Source_Email": "Verified email address", + "CalendarSync_Mode": "Sync mode", + "CalendarSync_Mode_Description": "Full event sync imports event subjects and times. Free/busy only drives user presence from availability without ingesting event details, requiring a smaller permission footprint.", + "CalendarSync_Mode_Free_Busy_Only": "Free/busy only", + "CalendarSync_Mode_Full_Events": "Full event sync", + "CalendarSync_Presence_Enabled": "Update user presence from calendar", + "CalendarSync_Presence_Enabled_Description": "Automatically set users to busy during synced meetings, restoring their previous status afterwards. Manually set statuses are never overridden.", + "CalendarSync_Provider": "Calendar provider", + "CalendarSync_Provider_Description": "Microsoft Graph is used for Exchange Online / Microsoft 365. Exchange Web Services (EWS) is used for on-premises Exchange Server and never contacts Microsoft cloud endpoints.", + "CalendarSync_Provider_Exchange_EWS": "Exchange Server on-premises (EWS)", + "CalendarSync_Provider_Microsoft_Graph": "Exchange Online / Microsoft 365 (Microsoft Graph)", + "CalendarSync_Section_Exchange_EWS": "Exchange Web Services (on-premises)", + "CalendarSync_Section_Mailbox_Mapping": "Mailbox mapping", + "CalendarSync_Section_Microsoft_Graph": "Microsoft Graph", + "CalendarSync_Section_Presence": "Presence", + "CalendarSync_Section_Schedule": "Schedule", + "CalendarSync_User_Roles": "Limit to roles", + "CalendarSync_User_Roles_Description": "Comma-separated list of role IDs. When set, only active users holding at least one of these roles are synchronized; leave empty to synchronize all active users. Mirror this scoping on the Exchange side with an ApplicationAccessPolicy or a scoped impersonation role assignment.", + "CalendarSync_Webhooks_Enabled": "Enable change notifications (webhooks)", + "CalendarSync_Webhooks_Enabled_Description": "Subscribe to Microsoft Graph change notifications so calendar changes sync within seconds instead of waiting for the next polling cycle. Requires the workspace Site URL to be HTTPS and publicly reachable by Microsoft's cloud. Polling continues regardless — this is an optimization, never a dependency.", + "CalendarSync_Window_Days": "Sync window (days)", + "CalendarSync_Window_Days_Description": "Events starting between now and this many days ahead are synchronized.", "Call": "Call", "Call_Already_Ended": "Call Already Ended", "Call_ID": "Call ID", diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 13922815f45aa..64462955e9a1c 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -65,6 +65,7 @@ export type * from './models/IUsersSessionsModel'; export type * from './models/IVideoConferenceModel'; export type * from './models/IWebdavAccountsModel'; export type * from './models/ICalendarEventModel'; +export type * from './models/ICalendarSyncStateModel'; export type * from './models/IOmnichannelServiceLevelAgreementsModel'; export type * from './models/IAppLogsModel'; export type * from './models/IAppsModel'; diff --git a/packages/model-typings/src/models/ICalendarEventModel.ts b/packages/model-typings/src/models/ICalendarEventModel.ts index 9636185a90cfc..bfa7e31d0d8e8 100644 --- a/packages/model-typings/src/models/ICalendarEventModel.ts +++ b/packages/model-typings/src/models/ICalendarEventModel.ts @@ -16,4 +16,5 @@ export interface ICalendarEventModel extends IBaseModel { findOverlappingEvents(eventId: ICalendarEvent['_id'], uid: IUser['_id'], startTime: Date, endTime: Date): FindCursor; findNextFutureEvent(startTime: Date): Promise; findEventsStartingNow({ now, offset }: { now: Date; offset?: number }): FindCursor; + findServerSyncedByUserIdBetweenDates(uid: IUser['_id'], startTime: Date, endTime: Date): FindCursor; } diff --git a/packages/model-typings/src/models/ICalendarSyncStateModel.ts b/packages/model-typings/src/models/ICalendarSyncStateModel.ts new file mode 100644 index 0000000000000..8f650a3ed74d4 --- /dev/null +++ b/packages/model-typings/src/models/ICalendarSyncStateModel.ts @@ -0,0 +1,32 @@ +import type { ICalendarSyncState, ICalendarSyncStateError, IUser } from '@rocket.chat/core-typings'; +import type { DeleteResult, UpdateResult } from 'mongodb'; + +import type { IBaseModel } from './IBaseModel'; + +export interface ICalendarSyncStateModel extends IBaseModel { + findOneByUserId(uid: IUser['_id']): Promise; + recordSuccess( + uid: IUser['_id'], + data: { + mailbox: string; + provider: ICalendarSyncState['provider']; + at: Date; + deltaToken?: string; + deltaWindowStart?: Date; + deltaWindowEnd?: Date; + }, + ): Promise; + recordFailure( + uid: IUser['_id'], + data: { + mailbox: string; + provider: ICalendarSyncState['provider']; + error: ICalendarSyncStateError; + }, + ): Promise; + findOneBySubscriptionId(subscriptionId: string): Promise; + setSubscription(uid: IUser['_id'], data: { id: string; expiresAt: Date; clientState: string }): Promise; + clearSubscription(uid: IUser['_id']): Promise; + removeByUserId(uid: IUser['_id']): Promise; + removeAll(): Promise; +} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index a47569e0145ff..bbd80362c077f 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -65,6 +65,7 @@ import type { IVideoConferenceModel, IWebdavAccountsModel, ICalendarEventModel, + ICalendarSyncStateModel, IOmnichannelServiceLevelAgreementsModel, IAppsModel, IAppsPersistenceModel, @@ -199,6 +200,7 @@ export const UsersSessions = proxify('IUsersSessionsModel') export const VideoConference = proxify('IVideoConferenceModel'); export const WebdavAccounts = proxify('IWebdavAccountsModel'); export const CalendarEvent = proxify('ICalendarEventModel'); +export const CalendarSyncState = proxify('ICalendarSyncStateModel'); export const OmnichannelServiceLevelAgreements = proxify( 'IOmnichannelServiceLevelAgreementsModel', ); diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index c876b5c1476dd..7526394e90590 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -8,6 +8,7 @@ export * from './models/Avatars'; export * from './models/Banners'; export * from './models/BannersDismiss'; export * from './models/CalendarEvent'; +export * from './models/CalendarSyncState'; export * from './models/CustomSounds'; export * from './models/CustomUserStatus'; export * from './models/EmailInbox'; diff --git a/packages/models/src/models/CalendarEvent.ts b/packages/models/src/models/CalendarEvent.ts index 2c7a567080f2e..78655113395c9 100644 --- a/packages/models/src/models/CalendarEvent.ts +++ b/packages/models/src/models/CalendarEvent.ts @@ -50,7 +50,18 @@ export class CalendarEventRaw extends BaseRaw implements ICalend public async updateEvent( eventId: ICalendarEvent['_id'], - { subject, description, startTime, endTime, meetingUrl, reminderMinutesBeforeStart, reminderTime, busy }: Partial, + { + subject, + description, + startTime, + endTime, + meetingUrl, + reminderMinutesBeforeStart, + reminderTime, + busy, + provider, + iCalUId, + }: Partial, ): Promise { return this.updateOne( { _id: eventId }, @@ -64,11 +75,26 @@ export class CalendarEventRaw extends BaseRaw implements ICalend ...(reminderMinutesBeforeStart ? { reminderMinutesBeforeStart } : {}), ...(reminderTime ? { reminderTime } : {}), ...(typeof busy === 'boolean' && { busy }), + ...(provider !== undefined ? { provider } : {}), + ...(iCalUId !== undefined ? { iCalUId } : {}), }, }, ); } + public findServerSyncedByUserIdBetweenDates(uid: IUser['_id'], startTime: Date, endTime: Date): FindCursor { + return this.find( + { + uid, + provider: { $exists: true }, + startTime: { $gte: startTime, $lt: endTime }, + }, + { + projection: { _id: 1, externalId: 1, provider: 1 }, + }, + ); + } + public async findNextNotificationDate(): Promise { const nextEvent = await this.findOne>( { diff --git a/packages/models/src/models/CalendarSyncState.ts b/packages/models/src/models/CalendarSyncState.ts new file mode 100644 index 0000000000000..affae1e78dcc9 --- /dev/null +++ b/packages/models/src/models/CalendarSyncState.ts @@ -0,0 +1,121 @@ +import type { ICalendarSyncState, ICalendarSyncStateError, IUser, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { ICalendarSyncStateModel } from '@rocket.chat/model-typings'; +import type { Collection, Db, DeleteResult, IndexDescription, UpdateResult } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class CalendarSyncStateRaw extends BaseRaw implements ICalendarSyncStateModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'calendar_sync_state', trash); + } + + protected override modelIndexes(): IndexDescription[] { + return [ + { + key: { uid: 1 }, + unique: true, + }, + ]; + } + + public async findOneByUserId(uid: IUser['_id']): Promise { + return this.findOne({ uid }); + } + + public async recordSuccess( + uid: IUser['_id'], + { + mailbox, + provider, + at, + deltaToken, + deltaWindowStart, + deltaWindowEnd, + }: { + mailbox: string; + provider: ICalendarSyncState['provider']; + at: Date; + deltaToken?: string; + deltaWindowStart?: Date; + deltaWindowEnd?: Date; + }, + ): Promise { + return this.updateOne( + { uid }, + { + $set: { + mailbox, + provider, + lastSyncAt: at, + lastSuccessAt: at, + consecutiveFailures: 0, + ...(deltaToken !== undefined && { deltaToken, deltaWindowStart, deltaWindowEnd }), + }, + $unset: { + lastError: 1, + ...(deltaToken === undefined && { deltaToken: 1, deltaWindowStart: 1, deltaWindowEnd: 1 }), + }, + }, + { upsert: true }, + ); + } + + public async recordFailure( + uid: IUser['_id'], + { + mailbox, + provider, + error, + }: { + mailbox: string; + provider: ICalendarSyncState['provider']; + error: ICalendarSyncStateError; + }, + ): Promise { + return this.updateOne( + { uid }, + { + $set: { + mailbox, + provider, + lastSyncAt: error.at, + lastError: error, + }, + $inc: { consecutiveFailures: 1 }, + }, + { upsert: true }, + ); + } + + public async findOneBySubscriptionId(subscriptionId: string): Promise { + return this.findOne({ subscriptionId }); + } + + public async setSubscription( + uid: IUser['_id'], + { id, expiresAt, clientState }: { id: string; expiresAt: Date; clientState: string }, + ): Promise { + return this.updateOne( + { uid }, + { + $set: { + subscriptionId: id, + subscriptionExpiresAt: expiresAt, + subscriptionClientState: clientState, + }, + }, + ); + } + + public async clearSubscription(uid: IUser['_id']): Promise { + return this.updateOne({ uid }, { $unset: { subscriptionId: 1, subscriptionExpiresAt: 1, subscriptionClientState: 1 } }); + } + + public async removeByUserId(uid: IUser['_id']): Promise { + return this.deleteOne({ uid }); + } + + public async removeAll(): Promise { + return this.deleteMany({}); + } +}