Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
07ce167
feat(calendar-sync): add provider/iCalUId to calendar events and cale…
Jul 11, 2026
d1f2edd
feat(calendar-sync): pass provider/iCalUId through CalendarService.im…
Jul 11, 2026
d9f6f88
feat(calendar-sync): provider abstraction and Microsoft Graph provider
Jul 11, 2026
41bbe5b
feat(calendar-sync): sync engine, mailbox resolver, provider factory …
Jul 11, 2026
08418e2
feat(calendar-sync): Calendar_Sync settings group, i18n and license-g…
Jul 11, 2026
370caa2
test(calendar-sync): unit tests for token manager, Graph provider, en…
Jul 11, 2026
3fe8330
fix(calendar-sync): correct settings import depth, SSRF opt-in for fi…
Jul 11, 2026
32b87b1
feat(calendar-sync): Exchange EWS provider with NTLM/Basic auth and i…
Jul 11, 2026
4602e8e
test(calendar-sync): EWS provider, SOAP, NTLM (MS-NLMP vectors) and a…
Jul 11, 2026
e837ac0
docs(calendar-sync): reflect EWS provider in changeset
Jul 11, 2026
10ecffd
feat(calendar-sync): free/busy-only mode driving presence without eve…
Jul 11, 2026
913177e
feat(calendar-sync): admin REST endpoints for test-connection, manual…
Jul 11, 2026
4783cfd
docs(calendar-sync): admin guide (Entra ID, ApplicationAccessPolicy, …
Jul 11, 2026
1687a49
feat(calendar-sync): national clouds, certificate credentials, role s…
Jul 11, 2026
4e92f44
feat(calendar-sync): EWS incremental sync via SyncFolderItems
Jul 11, 2026
d0796a7
feat(calendar-sync): Graph change-notification webhooks with subscrip…
Jul 12, 2026
3b156a0
docs(calendar-sync): webhooks section and Phase 4 changeset update
Jul 12, 2026
5117a4a
docs(calendar-sync): testing guide for M365 tenant and on-prem Exchan…
Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/server-calendar-sync.md
Original file line number Diff line number Diff line change
@@ -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.
185 changes: 185 additions & 0 deletions apps/meteor/ee/server/api/calendarSync/index.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>('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<boolean>('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<typeof calendarSyncEndpoints>;

declare module '@rocket.chat/rest-typings' {
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface
interface Endpoints extends CalendarSyncEndpoints {}
}
156 changes: 156 additions & 0 deletions apps/meteor/ee/server/api/calendarSync/schemas.ts
Original file line number Diff line number Diff line change
@@ -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<string>({ type: 'string' });

export const CalendarSyncWebhookAcceptedResponseSchema = ajv.compile<string>({ 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,
});
1 change: 1 addition & 0 deletions apps/meteor/ee/server/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ import '../apps/communication/uikit';
import './engagementDashboard';
import './audit';
import './abac';
import './calendarSync';
13 changes: 13 additions & 0 deletions apps/meteor/ee/server/configuration/calendarSync.ts
Original file line number Diff line number Diff line change
@@ -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();
}),
);
1 change: 1 addition & 0 deletions apps/meteor/ee/server/configuration/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import './calendarSync';
import './contact-verification';
import './ldap';
import './oauth';
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/ee/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading