Files
tessera-ctl/apps/api/src/calendar/providers/exchange.provider.ts
T
schalli 389ac9b692 feat(05-03): calendar providers (CalDAV, ICS, Exchange)
- Implement ICSProvider with fetch + node-ical parsing + RRULE expansion
- Implement CalDAVProvider with tsdav DAVClient + time-range filtering
- Implement ExchangeProvider dispatching on exchangeMode (graph vs ews)
- Graph mode uses @microsoft/microsoft-graph-client /me/calendarView
- EWS mode uses ews-javascript-api FindAppointments
- Exchange gracefully degrades: returns empty array on failure (D-08)
- No provider logs decrypted passwords (T-05-13)
2026-06-24 15:13:34 +02:00

228 lines
6.7 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { CalendarEvent, CalendarProvider } from '../calendar.service';
/**
* Exchange calendar provider — dispatches on exchangeMode ('graph' vs 'ews').
*
* - 'graph': Uses @microsoft/microsoft-graph-client for Exchange Online / M365
* - 'ews': Uses ews-javascript-api for on-premise Exchange Server
*
* Both modes gracefully degrade: on auth failure, returns empty array and
* surfaces a generic error (no credential details — Security V7 / T-05-13).
*/
@Injectable()
export class ExchangeProvider implements CalendarProvider {
private readonly logger = new Logger(ExchangeProvider.name);
/**
* Fetches events from Exchange, dispatching by exchangeMode.
* D-08: source TYPE is configurable and attempted — widget must not crash.
*/
async fetchEvents(
source: {
url: string;
username?: string;
password?: string;
exchangeMode?: string | null;
id: string;
color?: string | null;
},
from: Date,
to: Date,
): Promise<CalendarEvent[]> {
const mode = source.exchangeMode || 'graph';
try {
if (mode === 'graph') {
return await this.fetchViaGraph(source, from, to);
} else {
return await this.fetchViaEws(source, from, to);
}
} catch (error) {
// Graceful degradation — T-05-13: no credential details in error
this.logger.error(
`Exchange (${mode}) fetch failed for source ${source.id}: ${(error as Error).message}`,
);
return [];
}
}
/**
* Tests connection to Exchange. Returns false on any auth/network failure.
*/
async testConnection(
source: {
url: string;
username?: string;
password?: string;
exchangeMode?: string | null;
id: string;
},
): Promise<boolean> {
const mode = source.exchangeMode || 'graph';
try {
if (mode === 'graph') {
return await this.testGraphConnection(source);
} else {
return await this.testEwsConnection(source);
}
} catch {
return false;
}
}
/**
* Fetches events via Microsoft Graph API (Exchange Online / M365).
* Uses @microsoft/microsoft-graph-client with /me/calendarView.
*/
private async fetchViaGraph(
source: {
url: string;
username?: string;
password?: string;
id: string;
color?: string | null;
},
from: Date,
to: Date,
): Promise<CalendarEvent[]> {
// Dynamic import to avoid loading Graph SDK when not needed
const { Client: GraphClient } = await import(
'@microsoft/microsoft-graph-client'
);
const client = GraphClient.init({
authProvider: (done: (error: any, token: string) => void) => {
// Use the password as the access token (OAuth bearer token)
// Users configure their OAuth token in the password field for Graph API
done(null, source.password || '');
},
});
const result = await client
.api('/me/calendarView')
.query({
startDateTime: from.toISOString(),
endDateTime: to.toISOString(),
})
.select('id,subject,start,end,isAllDay,location,bodyPreview')
.orderby('start/dateTime')
.top(100)
.get();
const events: CalendarEvent[] = [];
if (result?.value) {
for (const item of result.value) {
events.push({
id: `${source.id}-${item.id}`,
sourceId: source.id,
title: item.subject || 'Untitled',
start: new Date(item.start?.dateTime + 'Z'),
end: new Date(item.end?.dateTime + 'Z'),
allDay: item.isAllDay || false,
location: item.location?.displayName || undefined,
description: item.bodyPreview || undefined,
color: source.color ?? undefined,
});
}
}
return events;
}
/**
* Fetches events via Exchange Web Services (on-premise Exchange).
* Uses ews-javascript-api with FindAppointments over a CalendarView.
*
* Note: ews-javascript-api has no TypeScript definitions;
* we use dynamic import + any casting for type safety.
*/
private async fetchViaEws(
source: {
url: string;
username?: string;
password?: string;
id: string;
color?: string | null;
},
from: Date,
to: Date,
): Promise<CalendarEvent[]> {
// Dynamic import — ews-javascript-api is JS-only, no .d.ts
const ews: any = await import('ews-javascript-api');
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013);
service.Url = new ews.Uri(source.url);
service.Credentials = new ews.WebCredentials(
source.username || '',
source.password || '',
);
// CalendarView constructor accepts JS Dates in ews-javascript-api
const calendarView = new ews.CalendarView(from, to, 100);
const findResults = await service.FindAppointments(
ews.WellKnownFolderName.Calendar,
calendarView,
);
const events: CalendarEvent[] = [];
for (const appointment of findResults.Items) {
events.push({
id: `${source.id}-${appointment.Id?.UniqueId || String(Date.now())}`,
sourceId: source.id,
title: appointment.Subject || 'Untitled',
start: appointment.Start ? new Date(String(appointment.Start)) : new Date(),
end: appointment.End ? new Date(String(appointment.End)) : new Date(),
allDay: appointment.IsAllDayEvent || false,
location: appointment.Location || undefined,
description: undefined, // Body requires separate load call
color: source.color ?? undefined,
});
}
return events;
}
/**
* Tests Graph API connection by requesting calendar list.
*/
private async testGraphConnection(
source: { url: string; password?: string },
): Promise<boolean> {
const { Client: GraphClient } = await import(
'@microsoft/microsoft-graph-client'
);
const client = GraphClient.init({
authProvider: (done: (error: any, token: string) => void) => {
done(null, source.password || '');
},
});
const result = await client.api('/me/calendars').top(1).get();
return !!result?.value;
}
/**
* Tests EWS connection by binding to the calendar folder.
*/
private async testEwsConnection(
source: { url: string; username?: string; password?: string },
): Promise<boolean> {
const ews: any = await import('ews-javascript-api');
const service = new ews.ExchangeService(ews.ExchangeVersion.Exchange2013);
service.Url = new ews.Uri(source.url);
service.Credentials = new ews.WebCredentials(
source.username || '',
source.password || '',
);
await ews.Folder.Bind(service, ews.WellKnownFolderName.Calendar);
return true;
}
}