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)
This commit is contained in:
@@ -3,26 +3,225 @@ import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
||||
|
||||
/**
|
||||
* Exchange calendar provider — dispatches on exchangeMode ('graph' vs 'ews').
|
||||
* Uses @microsoft/microsoft-graph-client for Graph and ews-javascript-api for EWS.
|
||||
* Full implementation in Task 2.
|
||||
*
|
||||
* - '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 },
|
||||
source: {
|
||||
url: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
exchangeMode?: string | null;
|
||||
id: string;
|
||||
color?: string | null;
|
||||
},
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<CalendarEvent[]> {
|
||||
// Stub — implemented in Task 2
|
||||
return [];
|
||||
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 },
|
||||
source: {
|
||||
url: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
exchangeMode?: string | null;
|
||||
id: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
// Stub — implemented in Task 2
|
||||
return false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user