-
Notifications
You must be signed in to change notification settings - Fork 814
Expand file tree
/
Copy pathgetOutlookEvents.ts
More file actions
508 lines (437 loc) · 16.2 KB
/
getOutlookEvents.ts
File metadata and controls
508 lines (437 loc) · 16.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
/* eslint-disable new-cap */
import { XhrApi } from '@ewsjs/xhr';
import type { Appointment } from 'ews-javascript-api';
import {
FolderId,
CalendarView,
DateTime,
WellKnownFolderName,
BasePropertySet,
PropertySet,
ConfigurationApi,
WebCredentials,
ExchangeService,
ExchangeVersion,
Uri,
LegacyFreeBusyStatus,
} from 'ews-javascript-api';
import {
createClassifiedError,
formatErrorForLogging,
} from './errorClassification';
import {
outlookLog,
outlookError,
outlookWarn,
outlookEventDetail,
} from './logger';
import type { OutlookCredentials, AppointmentData } from './type';
/**
* Optional function to test basic connectivity to the Exchange server
* This is not called by default to avoid network delays, but can be enabled for debugging
*/
const testExchangeConnectivity = async (
exchangeUrl: string,
timeoutMs: number = 5000
): Promise<boolean> => {
try {
outlookLog('Testing connectivity to Exchange server:', exchangeUrl);
// Extract base URL for connectivity test
const url = new URL(exchangeUrl);
const baseUrl = `${url.protocol}//${url.host}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(baseUrl, {
method: 'HEAD',
signal: controller.signal,
// Don't follow redirects for this test
redirect: 'manual',
});
clearTimeout(timeoutId);
// Any response (including errors) indicates the server is reachable
outlookLog('Connectivity test result:', {
url: baseUrl,
status: response.status,
reachable: true,
});
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
outlookWarn('Connectivity test failed:', {
url: exchangeUrl,
error: errorMessage,
note: 'This may be normal if the server requires authentication or has CORS restrictions',
});
return false;
}
};
// Export the connectivity test function for manual testing/debugging
export const testExchangeServerConnectivity = testExchangeConnectivity;
/**
* Builds the EWS endpoint pathname on a URL object
* Normalizes path segments and ensures proper /ews/exchange.asmx suffix
*/
const buildEwsPathname = (url: URL): void => {
const pathSegments = url.pathname
.split('/')
.filter((segment) => segment.length > 0);
// Only strip trailing 'ews' and 'exchange.asmx' segments
while (pathSegments.length > 0) {
const last = pathSegments[pathSegments.length - 1].toLowerCase();
if (last === 'ews' || last === 'exchange.asmx') {
pathSegments.pop();
} else {
break;
}
}
pathSegments.push('ews', 'exchange.asmx');
url.pathname = `/${pathSegments.join('/')}`;
};
/**
* Converts URL to string, preserving explicit default ports
* Uses url.port to detect if a non-default port was explicitly set
*/
const buildFinalUrl = (url: URL, originalUrl: string): string => {
const baseUrl = url.toString();
// If URL already has a non-default port, it's preserved by toString()
if (url.port) {
return baseUrl;
}
// Check if original URL had an explicit default port that was normalized away
// Match port in authority section (after //) or at start (for URLs without protocol)
const portMatch = originalUrl.match(/(?:\/\/|^)[^/:]+:(\d+)/);
if (portMatch) {
const explicitPort = portMatch[1];
// Only restore if it's a default port that was dropped
if ((url.protocol === 'https:' && explicitPort === '443') ||
(url.protocol === 'http:' && explicitPort === '80')) {
return baseUrl.replace(url.host, `${url.hostname}:${explicitPort}`);
}
}
return baseUrl;
};
/**
* Sanitizes and constructs a proper Exchange Web Services (EWS) URL
* Handles various input formats and edge cases to prevent URL construction errors
*
* Features:
* - Native URL() constructor for robust parsing and validation
* - Intelligent path segment handling to prevent duplication
* - Support for various URL formats (base URLs, /ews paths, complete URLs)
* - Case-insensitive path normalization
* - Smart fallback for malformed URLs with protocol detection
* - Automatic connectivity testing for debugging
* - Extensive logging for troubleshooting
*
* @param serverUrl - The server URL from configuration (can be base URL or include EWS path)
* @returns A properly formatted EWS endpoint URL
*
* Examples:
* - 'https://mail.example.com' → 'https://mail.example.com/ews/exchange.asmx'
* - 'https://mail.example.com/' → 'https://mail.example.com/ews/exchange.asmx'
* - 'https://mail.example.com/ews' → 'https://mail.example.com/ews/exchange.asmx'
* - 'https://mail.example.com/ews/' → 'https://mail.example.com/ews/exchange.asmx'
* - 'https://mail.example.com/EWS' → 'https://mail.example.com/ews/exchange.asmx'
* - 'https://mail.example.com/ews/exchange.asmx' → 'https://mail.example.com/ews/exchange.asmx'
*
* Debugging:
* - Connectivity testing runs automatically on this debugging branch
* - Check console logs for detailed validation information
* - Use testExchangeServerConnectivity() function for manual connectivity testing
*/
export const sanitizeExchangeUrl = (serverUrl: string): string => {
if (!serverUrl || typeof serverUrl !== 'string') {
throw new Error('Invalid server URL: must be a non-empty string');
}
outlookLog('Starting URL sanitization for:', serverUrl);
try {
// Use URL constructor for parsing and validation
const url = new URL(serverUrl);
// Validate protocol
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error(
`Invalid protocol "${url.protocol}". Only HTTP and HTTPS are supported`
);
}
// Validate hostname
if (!url.hostname) {
throw new Error('URL must have a valid hostname');
}
// Normalize and construct EWS path using URL properties
buildEwsPathname(url);
const sanitizedUrl = buildFinalUrl(url, serverUrl);
outlookLog('URL sanitization completed:', {
input: serverUrl,
output: sanitizedUrl,
protocol: url.protocol,
hostname: url.hostname,
pathname: url.pathname,
});
if (!url.pathname.endsWith('/ews/exchange.asmx')) {
throw new Error('Failed to construct proper EWS endpoint URL');
}
return sanitizedUrl;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Re-throw validation errors without attempting fallback, except for cases
// where the URL looks like it's missing a protocol (e.g., "domain.com:443")
if (errorMessage.includes('Invalid protocol') && !serverUrl.match(/^[a-z0-9.-]+:\d+/i)) {
throw error;
}
// Enhanced fallback using URL constructor more intelligently
outlookWarn('URL parsing failed, attempting fallback method:', {
originalUrl: serverUrl,
error: errorMessage,
});
// Try to fix common URL issues
let fallbackUrl = serverUrl.trim();
// Add protocol if missing
if (!fallbackUrl.match(/^https?:\/\//)) {
fallbackUrl = `https://${fallbackUrl}`;
outlookLog('Added default HTTPS protocol');
}
// Remove multiple slashes (except after protocol)
fallbackUrl = fallbackUrl.replace(/([^:]\/)\/+/g, '$1');
try {
const url = new URL(fallbackUrl);
// Validate basic requirements
if (!url.hostname) {
throw new Error('URL must have a valid hostname');
}
// Validate protocol
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error(
`Invalid protocol "${url.protocol}". Only HTTP and HTTPS are supported`
);
}
// Use the same clean path construction as main logic
buildEwsPathname(url);
const finalUrl = buildFinalUrl(url, serverUrl);
outlookLog('Fallback URL construction successful:', {
input: serverUrl,
fallback: fallbackUrl,
output: finalUrl,
protocol: url.protocol,
hostname: url.hostname,
pathname: url.pathname,
});
return finalUrl;
} catch (fallbackError) {
const fallbackErrorMsg = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
// Re-throw validation errors directly
if (fallbackErrorMsg.includes('URL must have a valid hostname') ||
fallbackErrorMsg.includes('Invalid protocol')) {
throw fallbackError;
}
// Handle "Invalid URL" for empty hostname case
if (fallbackErrorMsg.includes('Invalid URL') && fallbackUrl === 'https://') {
throw new Error('URL must have a valid hostname');
}
const fallbackErrorMessage = `Failed to create valid Exchange URL from "${serverUrl}".
Original error: ${errorMessage}.
Fallback error: ${fallbackErrorMsg}.
Please ensure the URL includes a valid protocol (http:// or https://) and hostname.`;
outlookError('URL sanitization failed completely:', {
input: serverUrl,
originalError: errorMessage,
fallbackError: fallbackErrorMsg,
});
throw new Error(fallbackErrorMessage);
}
}
};
export const getOutlookEvents = async (
credentials: OutlookCredentials,
date: Date,
allowInsecure: boolean = false
): Promise<AppointmentData[]> => {
outlookLog('Starting getOutlookEvents', {
userId: credentials.userId,
serverUrl: credentials.serverUrl,
date: date.toISOString(),
hasLogin: !!credentials.login,
hasPassword: !!credentials.password,
});
try {
const { login, password, serverUrl } = credentials;
// Validate required credentials
if (!login || !password || !serverUrl) {
const error = new Error('Missing required Outlook credentials');
const classifiedError = createClassifiedError(error, {
operation: 'credential_validation',
hasLogin: !!login,
hasPassword: !!password,
hasServerUrl: !!serverUrl,
userId: credentials.userId,
});
outlookError(
formatErrorForLogging(classifiedError, 'Credential validation')
);
throw error;
}
outlookLog('Initializing Exchange connection');
// When allowInsecure is true, bypass SSL certificate validation
// for air-gapped environments with self-signed/internal CA certificates
const xhrApi = new XhrApi({
decompress: true,
rejectUnauthorized: !allowInsecure,
});
if (allowInsecure) {
outlookWarn('SSL certificate validation disabled (allowInsecure=true)');
}
xhrApi.useNtlmAuthentication(login, password);
ConfigurationApi.ConfigureXHR(xhrApi);
const exchange = new ExchangeService(ExchangeVersion.Exchange2013);
// This credentials object isn't used when ntlm is active, but the lib still requires it.
exchange.Credentials = new WebCredentials(login, password);
try {
const exchangeUrl = sanitizeExchangeUrl(serverUrl);
exchange.Url = new Uri(exchangeUrl);
outlookLog('Exchange URL set:', exchangeUrl);
} catch (error) {
const classifiedError = createClassifiedError(error as Error, {
operation: 'exchange_url_configuration',
serverUrl,
userId: credentials.userId,
});
outlookError(
formatErrorForLogging(classifiedError, 'Exchange URL configuration')
);
throw new Error(
`Invalid Exchange server URL configuration: ${classifiedError.technicalMessage}`
);
}
const validatedDate = new Date(date);
outlookLog('Searching for appointments on:', validatedDate.toDateString());
const folderId = new FolderId(WellKnownFolderName.Calendar);
const minTime = new DateTime(
validatedDate.getFullYear(),
validatedDate.getMonth() + 1,
validatedDate.getDate()
);
const maxTime = new DateTime(
validatedDate.getFullYear(),
validatedDate.getMonth() + 1,
validatedDate.getDate(),
23,
59,
59
);
const view = new CalendarView(minTime, maxTime);
let appointments: Appointment[] = [];
try {
outlookLog('Fetching appointments from Exchange server...');
const findResult = await exchange.FindAppointments(folderId, view);
appointments = findResult.Items as Appointment[];
outlookLog('Found', appointments.length, 'appointments');
outlookEventDetail(
'Raw Exchange appointments count:',
appointments.length
);
} catch (error) {
const classifiedError = createClassifiedError(error as Error, {
operation: 'fetch_appointments',
serverUrl: credentials.serverUrl,
userId: credentials.userId,
exchangeUrl: exchange.Url?.ToString(),
});
outlookError(
formatErrorForLogging(
classifiedError,
'Fetch appointments from Exchange'
)
);
throw new Error(
`Failed to fetch appointments: ${classifiedError.technicalMessage}`
);
}
// Filter out appointments that end exactly at midnight
const filtered = appointments.filter(
(appointment) => appointment.End > minTime
);
if (filtered.length === 0) {
return [];
}
const propertySet = new PropertySet(BasePropertySet.FirstClassProperties);
try {
outlookLog('Loading properties for', filtered.length, 'appointments');
await exchange.LoadPropertiesForItems(filtered, propertySet);
outlookLog('Successfully loaded appointment properties');
} catch (error) {
const classifiedError = createClassifiedError(error as Error, {
operation: 'load_appointment_properties',
appointmentCount: filtered.length,
serverUrl: credentials.serverUrl,
userId: credentials.userId,
});
outlookError(
formatErrorForLogging(classifiedError, 'Load appointment properties')
);
throw new Error(
`Failed to load appointment properties: ${classifiedError.technicalMessage}`
);
}
outlookLog('Processing', filtered.length, 'appointments');
return filtered.map<AppointmentData>((appointment, index) => {
let description = '';
try {
if (appointment.Body?.Text) {
description = appointment.Body.Text;
}
} catch (error) {
outlookWarn(
`Failed to get description for appointment ${index}:`,
error
);
// Ignore errors when the appointment body is missing.
}
// Check if the busy status is Busy
// LegacyFreeBusyStatus enum: Free = 0, Tentative = 1, Busy = 2, OOF = 3
const isBusy =
appointment.LegacyFreeBusyStatus === LegacyFreeBusyStatus.Busy;
try {
const appointmentData = {
id: appointment.Id.UniqueId,
subject: appointment.Subject,
startTime: appointment.Start.ToISOString(),
endTime: appointment.End.ToISOString(),
description,
isAllDay: appointment.IsAllDayEvent ?? false,
isCanceled: appointment.IsCancelled ?? false,
meetingUrl: appointment.JoinOnlineMeetingUrl ?? undefined,
reminderMinutesBeforeStart:
appointment.ReminderMinutesBeforeStart ?? undefined,
busy: isBusy,
};
outlookLog(`Processed appointment ${index + 1}/${filtered.length}:`, {
id: appointmentData.id,
subject: appointmentData.subject,
startTime: appointmentData.startTime,
busy: appointmentData.busy,
});
outlookEventDetail(`Mapped appointment ${index + 1}:`, appointmentData);
return appointmentData;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
outlookError(`Failed to process appointment ${index}:`, {
error: errorMessage,
appointmentId: appointment.Id?.UniqueId,
subject: appointment.Subject,
});
throw new Error(`Failed to process appointment: ${errorMessage}`);
}
});
} catch (error) {
const classifiedError = createClassifiedError(error as Error, {
operation: 'get_outlook_events',
serverUrl: credentials.serverUrl,
userId: credentials.userId,
date: date.toISOString(),
});
outlookError(formatErrorForLogging(classifiedError, 'Get Outlook Events'));
throw new Error(
`Outlook calendar sync failed: ${classifiedError.technicalMessage}`
);
}
};