m365-agent-cli 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +916 -0
  3. package/package.json +50 -0
  4. package/src/cli.ts +100 -0
  5. package/src/commands/auto-reply.ts +182 -0
  6. package/src/commands/calendar.ts +576 -0
  7. package/src/commands/counter.ts +87 -0
  8. package/src/commands/create-event.ts +544 -0
  9. package/src/commands/delegates.ts +286 -0
  10. package/src/commands/delete-event.ts +321 -0
  11. package/src/commands/drafts.ts +502 -0
  12. package/src/commands/files.ts +532 -0
  13. package/src/commands/find.ts +195 -0
  14. package/src/commands/findtime.ts +270 -0
  15. package/src/commands/folders.ts +177 -0
  16. package/src/commands/forward-event.ts +49 -0
  17. package/src/commands/graph-calendar.ts +217 -0
  18. package/src/commands/login.ts +195 -0
  19. package/src/commands/mail.ts +950 -0
  20. package/src/commands/oof.ts +263 -0
  21. package/src/commands/outlook-categories.ts +173 -0
  22. package/src/commands/outlook-graph.ts +880 -0
  23. package/src/commands/planner.ts +1678 -0
  24. package/src/commands/respond.ts +291 -0
  25. package/src/commands/rooms.ts +210 -0
  26. package/src/commands/rules.ts +511 -0
  27. package/src/commands/schedule.ts +109 -0
  28. package/src/commands/send.ts +204 -0
  29. package/src/commands/serve.ts +14 -0
  30. package/src/commands/sharepoint.ts +179 -0
  31. package/src/commands/site-pages.ts +163 -0
  32. package/src/commands/subscribe.ts +103 -0
  33. package/src/commands/subscriptions.ts +29 -0
  34. package/src/commands/suggest.ts +155 -0
  35. package/src/commands/todo.ts +2092 -0
  36. package/src/commands/update-event.ts +608 -0
  37. package/src/commands/update.ts +88 -0
  38. package/src/commands/verify-token.ts +62 -0
  39. package/src/commands/whoami.ts +74 -0
  40. package/src/index.ts +190 -0
  41. package/src/lib/atomic-write.ts +20 -0
  42. package/src/lib/attach-link-spec.test.ts +24 -0
  43. package/src/lib/attach-link-spec.ts +70 -0
  44. package/src/lib/attachments.ts +79 -0
  45. package/src/lib/auth.ts +192 -0
  46. package/src/lib/calendar-range.test.ts +41 -0
  47. package/src/lib/calendar-range.ts +103 -0
  48. package/src/lib/dates.test.ts +74 -0
  49. package/src/lib/dates.ts +137 -0
  50. package/src/lib/delegate-client.test.ts +74 -0
  51. package/src/lib/delegate-client.ts +322 -0
  52. package/src/lib/ews-client.ts +3418 -0
  53. package/src/lib/git-commit.ts +4 -0
  54. package/src/lib/glitchtip-eligibility.ts +220 -0
  55. package/src/lib/glitchtip.ts +253 -0
  56. package/src/lib/global-env.ts +3 -0
  57. package/src/lib/graph-auth.ts +223 -0
  58. package/src/lib/graph-calendar-client.test.ts +118 -0
  59. package/src/lib/graph-calendar-client.ts +112 -0
  60. package/src/lib/graph-client.test.ts +107 -0
  61. package/src/lib/graph-client.ts +1058 -0
  62. package/src/lib/graph-constants.ts +12 -0
  63. package/src/lib/graph-directory.ts +116 -0
  64. package/src/lib/graph-event.ts +134 -0
  65. package/src/lib/graph-schedule.ts +173 -0
  66. package/src/lib/graph-subscriptions.ts +94 -0
  67. package/src/lib/graph-user-path.ts +13 -0
  68. package/src/lib/jwt-utils.ts +34 -0
  69. package/src/lib/markdown.test.ts +21 -0
  70. package/src/lib/markdown.ts +174 -0
  71. package/src/lib/mime-type.ts +106 -0
  72. package/src/lib/oof-client.test.ts +59 -0
  73. package/src/lib/oof-client.ts +122 -0
  74. package/src/lib/outlook-graph-client.test.ts +146 -0
  75. package/src/lib/outlook-graph-client.ts +649 -0
  76. package/src/lib/outlook-master-categories.ts +145 -0
  77. package/src/lib/package-info.ts +59 -0
  78. package/src/lib/places-client.ts +144 -0
  79. package/src/lib/planner-client.ts +1226 -0
  80. package/src/lib/rules-client.ts +178 -0
  81. package/src/lib/sharepoint-client.ts +101 -0
  82. package/src/lib/site-pages-client.ts +73 -0
  83. package/src/lib/todo-client.test.ts +298 -0
  84. package/src/lib/todo-client.ts +1309 -0
  85. package/src/lib/url-validation.ts +40 -0
  86. package/src/lib/utils.ts +45 -0
  87. package/src/lib/webhook-server.ts +51 -0
  88. package/src/test/auth.test.ts +104 -0
  89. package/src/test/cli.integration.test.ts +1083 -0
  90. package/src/test/ews-client.test.ts +268 -0
  91. package/src/test/mocks/index.ts +375 -0
  92. package/src/test/mocks/responses.ts +861 -0
@@ -0,0 +1,3418 @@
1
+ // ─── XML Utilities ───
2
+
3
+ export function xmlEscape(value: string): string {
4
+ return String(value)
5
+ .replace(/&/g, '&')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;')
9
+ .replace(/'/g, '&apos;');
10
+ }
11
+
12
+ function xmlDecode(value: string): string {
13
+ return String(value || '')
14
+ .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
15
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => {
16
+ const cp = parseInt(hex, 16);
17
+ return Number.isFinite(cp) ? String.fromCodePoint(cp) : _;
18
+ })
19
+ .replace(/&#([0-9]+);/g, (_, digits) => {
20
+ const cp = parseInt(digits, 10);
21
+ return Number.isFinite(cp) ? String.fromCodePoint(cp) : _;
22
+ })
23
+ .replace(/&lt;/g, '<')
24
+ .replace(/&gt;/g, '>')
25
+ .replace(/&quot;/g, '"')
26
+ .replace(/&apos;/g, "'")
27
+ .replace(/&amp;/g, '&')
28
+ .replace(/\r/g, '');
29
+ }
30
+
31
+ export function extractTag(xml: string, tagName: string): string {
32
+ const regex = new RegExp(
33
+ `<(?:[A-Za-z0-9_]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)<\\/(?:[A-Za-z0-9_]+:)?${tagName}>`,
34
+ 'i'
35
+ );
36
+ const match = xml.match(regex);
37
+ return match ? xmlDecode(match[1]) : '';
38
+ }
39
+
40
+ function _extractTagRaw(xml: string, tagName: string): string {
41
+ const regex = new RegExp(
42
+ `<(?:[A-Za-z0-9_]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)<\\/(?:[A-Za-z0-9_]+:)?${tagName}>`,
43
+ 'i'
44
+ );
45
+ const match = xml.match(regex);
46
+ return match ? match[1] : '';
47
+ }
48
+
49
+ export function extractAttribute(xml: string, tagName: string, attrName: string): string {
50
+ const regex = new RegExp(`<(?:[A-Za-z0-9_]+:)?${tagName}\\b[^>]*\\b${attrName}="([^"]*)"`, 'i');
51
+ const match = xml.match(regex);
52
+ return match ? xmlDecode(match[1]) : '';
53
+ }
54
+
55
+ export function extractBlocks(xml: string, tagName: string): string[] {
56
+ const regex = new RegExp(`<(?:[A-Za-z0-9_]+:)?${tagName}\\b[\\s\\S]*?<\\/(?:[A-Za-z0-9_]+:)?${tagName}>`, 'g');
57
+ return [...xml.matchAll(regex)].map((m) => m[0]);
58
+ }
59
+
60
+ export function extractSelfClosingOrBlock(xml: string, tagName: string): string {
61
+ // Matches both <Tag ... /> and <Tag ...>...</Tag>
62
+ const regex = new RegExp(
63
+ `<(?:[A-Za-z0-9_]+:)?${tagName}\\b[^>]*(?:\\/>|>[\\s\\S]*?<\\/(?:[A-Za-z0-9_]+:)?${tagName}>)`,
64
+ 'i'
65
+ );
66
+ const match = xml.match(regex);
67
+ return match ? match[0] : '';
68
+ }
69
+
70
+ function requireNonEmpty(value: string, fieldName: string): string {
71
+ if (!value?.trim()) {
72
+ throw new Error(`${fieldName} cannot be empty`);
73
+ }
74
+ return value.trim();
75
+ }
76
+
77
+ /** ReferenceItemId for CreateItem reply/forward/cancel shapes; ChangeKey avoids ErrorChangeKeyRequiredForWriteOperations. */
78
+ function referenceItemIdXml(itemId: string, changeKey?: string): string {
79
+ const ck = changeKey?.trim();
80
+ return ck
81
+ ? `<t:ReferenceItemId Id="${xmlEscape(itemId)}" ChangeKey="${xmlEscape(ck)}" />`
82
+ : `<t:ReferenceItemId Id="${xmlEscape(itemId)}" />`;
83
+ }
84
+
85
+ function itemIdXml(itemId: string, changeKey?: string): string {
86
+ const ck = changeKey?.trim();
87
+ return ck
88
+ ? `<t:ItemId Id="${xmlEscape(itemId)}" ChangeKey="${xmlEscape(ck)}" />`
89
+ : `<t:ItemId Id="${xmlEscape(itemId)}" />`;
90
+ }
91
+
92
+ /** Strip tags iteratively so nested markup is removed (avoids incomplete single-pass stripping). */
93
+ function stripXmlTagsFromXmlish(s: string): string {
94
+ let prev = '';
95
+ let cur = s;
96
+ while (cur !== prev) {
97
+ prev = cur;
98
+ cur = cur.replace(/<[^>]+>/g, '');
99
+ }
100
+ return cur;
101
+ }
102
+
103
+ // ─── SOAP Core ───
104
+
105
+ import { validateUrl } from './url-validation';
106
+
107
+ export const EWS_ENDPOINT = validateUrl(
108
+ process.env.EWS_ENDPOINT || 'https://outlook.office365.com/EWS/Exchange.asmx',
109
+ 'EWS_ENDPOINT'
110
+ );
111
+ export const EWS_USERNAME = process.env.EWS_USERNAME || '';
112
+ const EWS_TIMEOUT_MS = Number(process.env.EWS_TIMEOUT_MS) || 30_000; // 30s default
113
+
114
+ export function soapEnvelope(body: string, header?: string): string {
115
+ return `<?xml version="1.0" encoding="utf-8"?>
116
+ <soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
117
+ xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
118
+ xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types"
119
+ xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
120
+ <soap:Header>
121
+ <t:RequestServerVersion Version="Exchange2016" />
122
+ ${header || ''}
123
+ </soap:Header>
124
+ <soap:Body>
125
+ ${body}
126
+ </soap:Body>
127
+ </soap:Envelope>`;
128
+ }
129
+
130
+ /**
131
+ * Posts a SOAP envelope to EWS. Uses the first `ResponseCode` in the body; multi-response
132
+ * batches are not specially handled (same as typical single-operation CLI usage).
133
+ */
134
+ export async function callEws(token: string, envelope: string, mailbox?: string): Promise<string> {
135
+ const anchorMailbox = mailbox || EWS_USERNAME;
136
+ const controller = new AbortController();
137
+ const timeout = setTimeout(() => controller.abort(), EWS_TIMEOUT_MS);
138
+
139
+ let response: Response;
140
+ try {
141
+ // codeql[js/file-access-to-http]: Bearer token may originate from the local OAuth cache; SOAP body is built in-process (not arbitrary file reads).
142
+ response = await fetch(EWS_ENDPOINT, {
143
+ method: 'POST',
144
+ headers: {
145
+ Authorization: `Bearer ${token}`,
146
+ 'Content-Type': 'text/xml; charset=utf-8',
147
+ Accept: 'text/xml',
148
+ 'X-AnchorMailbox': anchorMailbox
149
+ },
150
+ body: envelope,
151
+ signal: controller.signal
152
+ });
153
+ } catch (err) {
154
+ clearTimeout(timeout);
155
+ if (err instanceof Error && err.name === 'AbortError') {
156
+ throw new Error(`EWS request timed out after ${EWS_TIMEOUT_MS / 1000}s`);
157
+ }
158
+ throw err;
159
+ }
160
+
161
+ const xml = await response.text();
162
+ clearTimeout(timeout);
163
+
164
+ if (!response.ok) {
165
+ const soapError = extractTag(xml, 'faultstring') || extractTag(xml, 'MessageText');
166
+ throw new Error(`EWS HTTP ${response.status}${soapError ? `: ${soapError}` : ''}`);
167
+ }
168
+
169
+ const responseCode = extractTag(xml, 'ResponseCode');
170
+ if (responseCode && responseCode !== 'NoError') {
171
+ const messageText = extractTag(xml, 'MessageText');
172
+ throw new Error(`EWS ${responseCode}${messageText ? `: ${messageText}` : ''}`);
173
+ }
174
+
175
+ return xml;
176
+ }
177
+
178
+ // ─── Types ───
179
+
180
+ export interface OwaError {
181
+ code: string;
182
+ message: string;
183
+ }
184
+
185
+ export interface OwaResponse<T = unknown> {
186
+ ok: boolean;
187
+ status: number;
188
+ data?: T;
189
+ error?: OwaError;
190
+ /** Informational message (e.g., fallback used, partial success) */
191
+ info?: string;
192
+ }
193
+
194
+ export interface OwaUserInfo {
195
+ displayName: string;
196
+ email: string;
197
+ }
198
+
199
+ export interface CalendarAttendee {
200
+ Type: 'Required' | 'Optional' | 'Resource';
201
+ Status: {
202
+ Response: 'None' | 'Organizer' | 'TentativelyAccepted' | 'Accepted' | 'Declined' | 'NotResponded';
203
+ Time: string;
204
+ };
205
+ EmailAddress: {
206
+ Name: string;
207
+ Address: string;
208
+ };
209
+ }
210
+
211
+ export const SENSITIVITY_MAP: Record<string, 'Normal' | 'Personal' | 'Private' | 'Confidential'> = {
212
+ normal: 'Normal',
213
+ personal: 'Personal',
214
+ private: 'Private',
215
+ confidential: 'Confidential'
216
+ };
217
+
218
+ export interface CalendarEvent {
219
+ Sensitivity?: 'Normal' | 'Personal' | 'Private' | 'Confidential';
220
+ Id: string;
221
+ ChangeKey?: string;
222
+ HasAttachments?: boolean;
223
+ Subject: string;
224
+ Start: { DateTime: string; TimeZone: string };
225
+ End: { DateTime: string; TimeZone: string };
226
+ Location?: { DisplayName?: string };
227
+ Organizer?: { EmailAddress?: { Name?: string; Address?: string } };
228
+ Attendees?: CalendarAttendee[];
229
+ IsAllDay?: boolean;
230
+ IsCancelled?: boolean;
231
+ IsOrganizer?: boolean;
232
+ BodyPreview?: string;
233
+ Categories?: string[];
234
+ ShowAs?: string;
235
+ Importance?: string;
236
+ IsOnlineMeeting?: boolean;
237
+ OnlineMeetingUrl?: string;
238
+ WebLink?: string;
239
+ // Recurrence info
240
+ IsRecurring?: boolean;
241
+ RecurrenceDescription?: string;
242
+ FirstOccurrence?: { Start: string; End: string; Id?: string };
243
+ LastOccurrence?: { Start: string; End: string; Id?: string };
244
+ ModifiedOccurrences?: Array<{ ItemId: string; Start: string; End: string; OriginalStart: string }>;
245
+ DeletedOccurrences?: Array<{ Start: string }>;
246
+ }
247
+
248
+ export interface RecurrencePattern {
249
+ Type: 'Daily' | 'Weekly' | 'AbsoluteMonthly' | 'RelativeMonthly' | 'AbsoluteYearly' | 'RelativeYearly';
250
+ Interval: number;
251
+ DaysOfWeek?: string[];
252
+ DayOfMonth?: number;
253
+ Month?: number;
254
+ Index?: 'First' | 'Second' | 'Third' | 'Fourth' | 'Last';
255
+ }
256
+
257
+ export interface RecurrenceRange {
258
+ Type: 'EndDate' | 'NoEnd' | 'Numbered';
259
+ StartDate: string;
260
+ EndDate?: string;
261
+ NumberOfOccurrences?: number;
262
+ }
263
+
264
+ export interface Recurrence {
265
+ Pattern: RecurrencePattern;
266
+ Range: RecurrenceRange;
267
+ }
268
+
269
+ export interface CreateEventOptions {
270
+ timezone?: string;
271
+ token: string;
272
+ subject: string;
273
+ start: string;
274
+ end: string;
275
+ body?: string;
276
+ location?: string;
277
+ attendees?: Array<{ email: string; name?: string; type?: 'Required' | 'Optional' | 'Resource' }>;
278
+ isOnlineMeeting?: boolean;
279
+ recurrence?: Recurrence;
280
+ isAllDay?: boolean;
281
+ sensitivity?: 'Normal' | 'Personal' | 'Private' | 'Confidential';
282
+ mailbox?: string;
283
+ categories?: string[];
284
+ /** File attachments added after the calendar item is created (EWS CreateAttachment). */
285
+ fileAttachments?: EmailAttachment[];
286
+ /** Reference (URL) attachments, e.g. cloud document links (EWS ReferenceAttachment). */
287
+ referenceAttachments?: ReferenceAttachmentInput[];
288
+ }
289
+
290
+ export interface CreatedEvent {
291
+ Id: string;
292
+ /** Present when the server returned it (needed for follow-up writes / attachments). */
293
+ ChangeKey?: string;
294
+ Subject: string;
295
+ Start: { DateTime: string; TimeZone: string };
296
+ End: { DateTime: string; TimeZone: string };
297
+ WebLink?: string;
298
+ OnlineMeetingUrl?: string;
299
+ }
300
+
301
+ export interface UpdateEventOptions {
302
+ timezone?: string;
303
+ token: string;
304
+ eventId: string;
305
+ changeKey?: string;
306
+ subject?: string;
307
+ start?: string;
308
+ end?: string;
309
+ body?: string;
310
+ location?: string;
311
+ attendees?: Array<{ email: string; name?: string; type?: 'Required' | 'Optional' | 'Resource' }>;
312
+ isOnlineMeeting?: boolean;
313
+ /** Use OccurrenceItemId for a specific occurrence, ItemId for the series master */
314
+ occurrenceItemId?: string;
315
+ isAllDay?: boolean;
316
+ sensitivity?: 'Normal' | 'Personal' | 'Private' | 'Confidential';
317
+ mailbox?: string;
318
+ categories?: string[];
319
+ }
320
+
321
+ export interface ScheduleInfo {
322
+ scheduleId: string;
323
+ availabilityView: string;
324
+ scheduleItems: Array<{
325
+ status: string;
326
+ start: { dateTime: string; timeZone: string };
327
+ end: { dateTime: string; timeZone: string };
328
+ subject?: string;
329
+ location?: string;
330
+ }>;
331
+ }
332
+
333
+ export interface FreeBusySlot {
334
+ status: 'Free' | 'Busy' | 'Tentative';
335
+ start: string;
336
+ end: string;
337
+ subject?: string;
338
+ }
339
+
340
+ export interface Room {
341
+ Address: string;
342
+ Name: string;
343
+ }
344
+
345
+ export interface RoomList {
346
+ Address: string;
347
+ Name: string;
348
+ }
349
+
350
+ export interface EmailAddress {
351
+ Name?: string;
352
+ Address?: string;
353
+ }
354
+
355
+ export interface EmailMessage {
356
+ Sensitivity?: 'Normal' | 'Personal' | 'Private' | 'Confidential';
357
+ Id: string;
358
+ ChangeKey?: string;
359
+ Subject?: string;
360
+ BodyPreview?: string;
361
+ Body?: { ContentType: string; Content: string };
362
+ From?: { EmailAddress?: EmailAddress };
363
+ ToRecipients?: Array<{ EmailAddress?: EmailAddress }>;
364
+ CcRecipients?: Array<{ EmailAddress?: EmailAddress }>;
365
+ ReceivedDateTime?: string;
366
+ SentDateTime?: string;
367
+ IsRead?: boolean;
368
+ IsDraft?: boolean;
369
+ HasAttachments?: boolean;
370
+ Importance?: 'Low' | 'Normal' | 'High';
371
+ /** Outlook category names (item-level; colors come from mailbox master category list). */
372
+ Categories?: string[];
373
+ Flag?: {
374
+ FlagStatus?: 'NotFlagged' | 'Flagged' | 'Complete';
375
+ StartDate?: { DateTime: string; TimeZone: string };
376
+ DueDate?: { DateTime: string; TimeZone: string };
377
+ };
378
+ }
379
+
380
+ export interface EmailListResponse {
381
+ value: EmailMessage[];
382
+ }
383
+
384
+ export interface GetEmailsOptions {
385
+ token: string;
386
+ folder?: string;
387
+ /** Shared or delegated mailbox (X-AnchorMailbox + folder scoping) */
388
+ mailbox?: string;
389
+ top?: number;
390
+ skip?: number;
391
+ search?: string;
392
+ select?: string[];
393
+ orderBy?: string;
394
+ isRead?: boolean;
395
+ flagStatus?: 'Flagged' | 'NotFlagged' | 'Complete';
396
+ }
397
+
398
+ export interface Attachment {
399
+ Id: string;
400
+ Name: string;
401
+ ContentType: string;
402
+ Size: number;
403
+ IsInline: boolean;
404
+ ContentId?: string;
405
+ ContentBytes?: string;
406
+ /** File bytes vs cloud/URL reference (EWS ReferenceAttachment). */
407
+ Kind?: 'file' | 'reference';
408
+ /** When Kind is reference, the linked URL (AttachLongPathName). */
409
+ AttachLongPathName?: string;
410
+ }
411
+
412
+ export interface AttachmentListResponse {
413
+ value: Attachment[];
414
+ }
415
+
416
+ export interface EmailAttachment {
417
+ name: string;
418
+ contentType: string;
419
+ contentBytes: string;
420
+ }
421
+
422
+ /** EWS ReferenceAttachment (link to a document or web resource). */
423
+ export interface ReferenceAttachmentInput {
424
+ name: string;
425
+ /** HTTPS URL stored as AttachLongPathName. */
426
+ url: string;
427
+ contentType?: string;
428
+ }
429
+
430
+ export interface MailFolder {
431
+ Id: string;
432
+ DisplayName: string;
433
+ ParentFolderId?: string;
434
+ ChildFolderCount: number;
435
+ UnreadItemCount: number;
436
+ TotalItemCount: number;
437
+ }
438
+
439
+ export interface MailFolderListResponse {
440
+ value: MailFolder[];
441
+ }
442
+
443
+ export type ResponseType = 'accept' | 'decline' | 'tentative';
444
+
445
+ export interface RespondToEventOptions {
446
+ token: string;
447
+ eventId: string;
448
+ response: ResponseType;
449
+ comment?: string;
450
+ sendResponse?: boolean;
451
+ mailbox?: string;
452
+ }
453
+
454
+ // ─── Parsing Helpers ───
455
+
456
+ /**
457
+ * Parse recurrence description from a CalendarItem XML block.
458
+ * Returns a human-readable string like "Weekly, every Monday until 2026-06-30"
459
+ * and also extracts FirstOccurrence/LastOccurrence when present.
460
+ */
461
+ function parseRecurrenceFromBlock(block: string): {
462
+ description?: string;
463
+ firstOccurrence?: { Start: string; End: string; Id?: string };
464
+ lastOccurrence?: { Start: string; End: string; Id?: string };
465
+ modifiedOccurrences?: Array<{ ItemId: string; Start: string; End: string; OriginalStart: string }>;
466
+ deletedOccurrences?: Array<{ Start: string }>;
467
+ } {
468
+ // Check if this item has any recurrence info
469
+ const recurrenceBlock = extractSelfClosingOrBlock(block, 'Recurrence');
470
+ if (!recurrenceBlock) {
471
+ // Also check for FirstOccurrence/LastOccurrence on series master items
472
+ const firstOccBlock = extractSelfClosingOrBlock(block, 'FirstOccurrence');
473
+ const lastOccBlock = extractSelfClosingOrBlock(block, 'LastOccurrence');
474
+ if (firstOccBlock || lastOccBlock) {
475
+ const firstOccurrence = firstOccBlock
476
+ ? {
477
+ Start: extractTag(firstOccBlock, 'Start') || '',
478
+ End: extractTag(firstOccBlock, 'End') || '',
479
+ Id: extractAttribute(firstOccBlock, 'ItemId', 'Id')
480
+ }
481
+ : undefined;
482
+ const lastOccurrence = lastOccBlock
483
+ ? {
484
+ Start: extractTag(lastOccBlock, 'Start') || '',
485
+ End: extractTag(lastOccBlock, 'End') || '',
486
+ Id: extractAttribute(lastOccBlock, 'ItemId', 'Id')
487
+ }
488
+ : undefined;
489
+ return { firstOccurrence, lastOccurrence };
490
+ }
491
+ return {};
492
+ }
493
+
494
+ const parts: string[] = [];
495
+
496
+ // Determine pattern type
497
+ const interval = extractTag(recurrenceBlock, 'Interval') || '1';
498
+ const dayOfMonth = extractTag(recurrenceBlock, 'DayOfMonth');
499
+ const month = extractTag(recurrenceBlock, 'Month');
500
+ const daysOfWeek = extractTag(recurrenceBlock, 'DaysOfWeek');
501
+ const dayOfWeekIndex = extractTag(recurrenceBlock, 'DayOfWeekIndex');
502
+
503
+ const monthNames = [
504
+ 'January',
505
+ 'February',
506
+ 'March',
507
+ 'April',
508
+ 'May',
509
+ 'June',
510
+ 'July',
511
+ 'August',
512
+ 'September',
513
+ 'October',
514
+ 'November',
515
+ 'December'
516
+ ];
517
+ const dayIndexNames: Record<string, string> = {
518
+ First: '1st',
519
+ Second: '2nd',
520
+ Third: '3rd',
521
+ Fourth: '4th',
522
+ Last: 'last'
523
+ };
524
+
525
+ if (recurrenceBlock.includes('DailyRecurrence')) {
526
+ parts.push(parseInt(interval, 10) === 1 ? 'Daily' : `Every ${interval} days`);
527
+ } else if (recurrenceBlock.includes('WeeklyRecurrence')) {
528
+ const days = daysOfWeek ? daysOfWeek.split(' ').filter(Boolean) : [];
529
+ const dayList = days.length > 0 ? days.join(', ') : 'week';
530
+ parts.push(parseInt(interval, 10) === 1 ? `Weekly on ${dayList}` : `Every ${interval} weeks on ${dayList}`);
531
+ } else if (recurrenceBlock.includes('AbsoluteMonthlyRecurrence')) {
532
+ parts.push(
533
+ parseInt(interval, 10) === 1
534
+ ? `Monthly on day ${dayOfMonth || 1}`
535
+ : `Every ${interval} months on day ${dayOfMonth || 1}`
536
+ );
537
+ } else if (recurrenceBlock.includes('RelativeMonthlyRecurrence')) {
538
+ const idx = dayIndexNames[dayOfWeekIndex || 'First'] || '1st';
539
+ const days = daysOfWeek ? daysOfWeek.split(' ').filter(Boolean).join(', ') : 'day';
540
+ parts.push(
541
+ parseInt(interval, 10) === 1 ? `Monthly on the ${idx} ${days}` : `Every ${interval} months on the ${idx} ${days}`
542
+ );
543
+ } else if (recurrenceBlock.includes('AbsoluteYearlyRecurrence')) {
544
+ const monthName = month ? monthNames[parseInt(month, 10) - 1] || month : 'the specified month';
545
+ parts.push(`Yearly on ${monthName} ${dayOfMonth || 1}`);
546
+ } else if (recurrenceBlock.includes('RelativeYearlyRecurrence')) {
547
+ const idx = dayIndexNames[dayOfWeekIndex || 'First'] || '1st';
548
+ const monthName = month ? monthNames[parseInt(month, 10) - 1] || month : 'the specified month';
549
+ const days = daysOfWeek ? daysOfWeek.split(' ').filter(Boolean).join(', ') : 'day';
550
+ parts.push(`Yearly on the ${idx} ${days} of ${monthName}`);
551
+ }
552
+
553
+ // Determine range
554
+ if (recurrenceBlock.includes('EndDateRecurrence')) {
555
+ const startDate = extractTag(recurrenceBlock, 'StartDate');
556
+ const endDate = extractTag(recurrenceBlock, 'EndDate');
557
+ if (endDate) {
558
+ const endStr = endDate.split('T')[0];
559
+ parts.push(`until ${endStr}`);
560
+ } else if (startDate) {
561
+ parts.push(`starting ${startDate.split('T')[0]}`);
562
+ }
563
+ } else if (recurrenceBlock.includes('NumberedRecurrence')) {
564
+ const num = extractTag(recurrenceBlock, 'NumberOfOccurrences');
565
+ parts.push(`for ${num || '10'} occurrences`);
566
+ } else if (recurrenceBlock.includes('NoEndRecurrence')) {
567
+ parts.push('(no end date)');
568
+ }
569
+
570
+ // Extract first/last occurrence bounds
571
+ const firstOccBlock = extractSelfClosingOrBlock(block, 'FirstOccurrence');
572
+ const lastOccBlock = extractSelfClosingOrBlock(block, 'LastOccurrence');
573
+ const firstOccurrence = firstOccBlock
574
+ ? {
575
+ Start: extractTag(firstOccBlock, 'Start') || '',
576
+ End: extractTag(firstOccBlock, 'End') || '',
577
+ Id: extractAttribute(firstOccBlock, 'ItemId', 'Id')
578
+ }
579
+ : undefined;
580
+ const lastOccurrence = lastOccBlock
581
+ ? {
582
+ Start: extractTag(lastOccBlock, 'Start') || '',
583
+ End: extractTag(lastOccBlock, 'End') || '',
584
+ Id: extractAttribute(lastOccBlock, 'ItemId', 'Id')
585
+ }
586
+ : undefined;
587
+
588
+ return {
589
+ description: parts.length > 0 ? parts.join(' ') : undefined,
590
+ firstOccurrence,
591
+ lastOccurrence
592
+ };
593
+ }
594
+
595
+ function parseCalendarItem(block: string, mailbox?: string): CalendarEvent {
596
+ const id = extractAttribute(block, 'ItemId', 'Id');
597
+ const changeKey = extractAttribute(block, 'ItemId', 'ChangeKey');
598
+ const subject = extractTag(block, 'Subject');
599
+ const start = extractTag(block, 'Start');
600
+ const startTz =
601
+ extractAttribute(block, 'StartTimeZone', 'Id') || extractAttribute(block, 'StartTimeZone', 'Name') || 'UTC';
602
+ const end = extractTag(block, 'End');
603
+ const endTz = extractAttribute(block, 'EndTimeZone', 'Id') || extractAttribute(block, 'EndTimeZone', 'Name') || 'UTC';
604
+ const location = extractTag(block, 'Location');
605
+ const isAllDay = extractTag(block, 'IsAllDayEvent').toLowerCase() === 'true';
606
+ const isCancelled = extractTag(block, 'IsCancelled').toLowerCase() === 'true';
607
+ const hasAttachments = extractTag(block, 'HasAttachments').toLowerCase() === 'true';
608
+ const bodyPreview = extractTag(block, 'TextBody') || extractTag(block, 'Body');
609
+ const importance = extractTag(block, 'Importance') || 'Normal';
610
+ const showAs = extractTag(block, 'LegacyFreeBusyStatus') || 'Busy';
611
+
612
+ // Organizer
613
+ const organizerBlock = extractSelfClosingOrBlock(block, 'Organizer');
614
+ const organizerName = extractTag(organizerBlock, 'Name');
615
+ const organizerEmail = extractTag(organizerBlock, 'EmailAddress');
616
+ const myResponseType = extractTag(block, 'MyResponseType');
617
+ const effectiveUser = mailbox || EWS_USERNAME;
618
+ const isOrganizer = myResponseType === 'Organizer' || organizerEmail.toLowerCase() === effectiveUser.toLowerCase();
619
+
620
+ // Attendees
621
+ const attendees: CalendarAttendee[] = [];
622
+
623
+ for (const type of ['RequiredAttendees', 'OptionalAttendees', 'Resources'] as const) {
624
+ const typeBlock = extractSelfClosingOrBlock(block, type);
625
+ const attendeeBlocks = extractBlocks(typeBlock, 'Attendee');
626
+ const attendeeType =
627
+ type === 'RequiredAttendees' ? 'Required' : type === 'OptionalAttendees' ? 'Optional' : 'Resource';
628
+
629
+ for (const ab of attendeeBlocks) {
630
+ const mailboxBlock = extractSelfClosingOrBlock(ab, 'Mailbox');
631
+ const name = extractTag(mailboxBlock, 'Name');
632
+ const email = extractTag(mailboxBlock, 'EmailAddress');
633
+ const responseType = extractTag(ab, 'ResponseType') || 'Unknown';
634
+ const lastResponseTime = extractTag(ab, 'LastResponseTime') || '';
635
+
636
+ // Map EWS ResponseType to our format
637
+ const responseMap: Record<string, CalendarAttendee['Status']['Response']> = {
638
+ Accept: 'Accepted',
639
+ Decline: 'Declined',
640
+ Tentative: 'TentativelyAccepted',
641
+ NoResponseReceived: 'NotResponded',
642
+ Organizer: 'Organizer',
643
+ Unknown: 'None'
644
+ };
645
+
646
+ attendees.push({
647
+ Type: attendeeType,
648
+ Status: {
649
+ Response: responseMap[responseType] || 'None',
650
+ Time: lastResponseTime
651
+ },
652
+ EmailAddress: { Name: name, Address: email }
653
+ });
654
+ }
655
+ }
656
+
657
+ // Categories
658
+ const categoriesBlock = extractSelfClosingOrBlock(block, 'Categories');
659
+ const categories = extractBlocks(categoriesBlock, 'String').map(
660
+ (b) => extractTag(b, 'String') || xmlDecode(stripXmlTagsFromXmlish(b))
661
+ );
662
+
663
+ // Recurrence info
664
+ const recurrenceInfo = parseRecurrenceFromBlock(block);
665
+
666
+ const modifiedOccurrencesBlock = extractSelfClosingOrBlock(block, 'ModifiedOccurrences');
667
+ let modifiedOccurrences: Array<{ ItemId: string; Start: string; End: string; OriginalStart: string }> | undefined;
668
+ if (modifiedOccurrencesBlock) {
669
+ modifiedOccurrences = extractBlocks(modifiedOccurrencesBlock, 'Occurrence').map((occ) => ({
670
+ ItemId: extractAttribute(occ, 'ItemId', 'Id'),
671
+ Start: extractTag(occ, 'Start'),
672
+ End: extractTag(occ, 'End'),
673
+ OriginalStart: extractTag(occ, 'OriginalStart')
674
+ }));
675
+ }
676
+
677
+ const deletedOccurrencesBlock = extractSelfClosingOrBlock(block, 'DeletedOccurrences');
678
+ let deletedOccurrences: Array<{ Start: string }> | undefined;
679
+ if (deletedOccurrencesBlock) {
680
+ deletedOccurrences = extractBlocks(deletedOccurrencesBlock, 'DeletedOccurrence').map((occ) => ({
681
+ Start: extractTag(occ, 'Start')
682
+ }));
683
+ }
684
+
685
+ return {
686
+ Id: id,
687
+ ChangeKey: changeKey,
688
+ HasAttachments: hasAttachments,
689
+ Subject: subject,
690
+ Start: { DateTime: start, TimeZone: startTz },
691
+ End: { DateTime: end, TimeZone: endTz },
692
+ Location: location ? { DisplayName: location } : undefined,
693
+ Organizer: { EmailAddress: { Name: organizerName, Address: organizerEmail } },
694
+ Attendees: attendees.length > 0 ? attendees : undefined,
695
+ IsAllDay: isAllDay,
696
+ IsCancelled: isCancelled,
697
+ IsOrganizer: isOrganizer,
698
+ BodyPreview: bodyPreview ? bodyPreview.substring(0, 200).replace(/\s+/g, ' ').trim() : undefined,
699
+ Categories: categories.length > 0 ? categories : undefined,
700
+ ShowAs: showAs,
701
+ Importance: importance,
702
+ IsRecurring:
703
+ recurrenceInfo.description !== undefined ||
704
+ recurrenceInfo.firstOccurrence !== undefined ||
705
+ recurrenceInfo.lastOccurrence !== undefined,
706
+ RecurrenceDescription: recurrenceInfo.description,
707
+ FirstOccurrence: recurrenceInfo.firstOccurrence,
708
+ LastOccurrence: recurrenceInfo.lastOccurrence,
709
+ ModifiedOccurrences: modifiedOccurrences,
710
+ DeletedOccurrences: deletedOccurrences
711
+ };
712
+ }
713
+
714
+ function parseEmailMessage(block: string): EmailMessage {
715
+ const id = extractAttribute(block, 'ItemId', 'Id');
716
+ const changeKey = extractAttribute(block, 'ItemId', 'ChangeKey');
717
+ const subject = extractTag(block, 'Subject');
718
+ const bodyContent = extractTag(block, 'Body') || extractTag(block, 'TextBody');
719
+ const bodyType = extractAttribute(block, 'Body', 'BodyType') || 'Text';
720
+ const preview =
721
+ extractTag(block, 'Preview') || (bodyContent ? bodyContent.substring(0, 200).replace(/\s+/g, ' ').trim() : '');
722
+ const receivedDateTime = extractTag(block, 'DateTimeReceived');
723
+ const sentDateTime = extractTag(block, 'DateTimeSent');
724
+ const isRead = extractTag(block, 'IsRead').toLowerCase() === 'true';
725
+ const isDraft = extractTag(block, 'IsDraft').toLowerCase() === 'true';
726
+ const hasAttachments = extractTag(block, 'HasAttachments').toLowerCase() === 'true';
727
+ const importance = (extractTag(block, 'Importance') || 'Normal') as 'Low' | 'Normal' | 'High';
728
+
729
+ // From
730
+ const fromBlock = extractSelfClosingOrBlock(block, 'From');
731
+ const fromMailbox = extractSelfClosingOrBlock(fromBlock, 'Mailbox');
732
+ const fromName = extractTag(fromMailbox, 'Name');
733
+ const fromEmail = extractTag(fromMailbox, 'EmailAddress');
734
+
735
+ // To
736
+ const toBlock = extractSelfClosingOrBlock(block, 'ToRecipients');
737
+ const toMailboxes = extractBlocks(toBlock, 'Mailbox');
738
+ const toRecipients = toMailboxes.map((mb) => ({
739
+ EmailAddress: {
740
+ Name: extractTag(mb, 'Name'),
741
+ Address: extractTag(mb, 'EmailAddress')
742
+ }
743
+ }));
744
+
745
+ // Cc
746
+ const ccBlock = extractSelfClosingOrBlock(block, 'CcRecipients');
747
+ const ccMailboxes = extractBlocks(ccBlock, 'Mailbox');
748
+ const ccRecipients = ccMailboxes.map((mb) => ({
749
+ EmailAddress: {
750
+ Name: extractTag(mb, 'Name'),
751
+ Address: extractTag(mb, 'EmailAddress')
752
+ }
753
+ }));
754
+
755
+ // Flag
756
+ const flagBlock = extractSelfClosingOrBlock(block, 'Flag');
757
+ const flagStatus = extractTag(flagBlock, 'FlagStatus') as 'NotFlagged' | 'Flagged' | 'Complete' | undefined;
758
+
759
+ const categoriesBlock = extractSelfClosingOrBlock(block, 'Categories');
760
+ const categories = extractBlocks(categoriesBlock, 'String').map(
761
+ (b) => extractTag(b, 'String') || xmlDecode(stripXmlTagsFromXmlish(b))
762
+ );
763
+
764
+ return {
765
+ Id: id,
766
+ ChangeKey: changeKey,
767
+ Subject: subject || undefined,
768
+ BodyPreview: preview || undefined,
769
+ Body: bodyContent ? { ContentType: bodyType, Content: bodyContent } : undefined,
770
+ From: fromEmail ? { EmailAddress: { Name: fromName, Address: fromEmail } } : undefined,
771
+ ToRecipients: toRecipients.length > 0 ? toRecipients : undefined,
772
+ CcRecipients: ccRecipients.length > 0 ? ccRecipients : undefined,
773
+ ReceivedDateTime: receivedDateTime || undefined,
774
+ SentDateTime: sentDateTime || undefined,
775
+ IsRead: isRead,
776
+ IsDraft: isDraft,
777
+ HasAttachments: hasAttachments,
778
+ Importance: importance,
779
+ Categories: categories.length > 0 ? categories : undefined,
780
+ Flag: flagStatus ? { FlagStatus: flagStatus } : undefined
781
+ };
782
+ }
783
+
784
+ function parseFolder(block: string): MailFolder {
785
+ return {
786
+ Id: extractAttribute(block, 'FolderId', 'Id'),
787
+ DisplayName: extractTag(block, 'DisplayName'),
788
+ ParentFolderId: extractAttribute(block, 'ParentFolderId', 'Id') || undefined,
789
+ ChildFolderCount: parseInt(extractTag(block, 'ChildFolderCount') || '0', 10),
790
+ UnreadItemCount: parseInt(extractTag(block, 'UnreadItemCount') || '0', 10),
791
+ TotalItemCount: parseInt(extractTag(block, 'TotalItemCount') || '0', 10)
792
+ };
793
+ }
794
+
795
+ export function ewsResult<T>(data: T): OwaResponse<T> {
796
+ return { ok: true, status: 200, data };
797
+ }
798
+
799
+ export function ewsError(err: unknown): OwaResponse<never> {
800
+ const message = err instanceof Error ? err.message : 'Unknown error';
801
+ return { ok: false, status: 0, error: { code: 'EWS_ERROR', message } };
802
+ }
803
+
804
+ // Map well-known folder names to EWS DistinguishedFolderId
805
+ const FOLDER_MAP: Record<string, string> = {
806
+ inbox: 'inbox',
807
+ drafts: 'drafts',
808
+ sentitems: 'sentitems',
809
+ sent: 'sentitems',
810
+ deleteditems: 'deleteditems',
811
+ deleted: 'deleteditems',
812
+ trash: 'deleteditems',
813
+ junkemail: 'junkemail',
814
+ junk: 'junkemail',
815
+ spam: 'junkemail',
816
+ outbox: 'outbox',
817
+ archive: 'archivemsgfolderoot'
818
+ };
819
+
820
+ function folderIdXml(folder: string, mailbox?: string): string {
821
+ const distinguished = FOLDER_MAP[folder.toLowerCase()];
822
+ if (distinguished) {
823
+ return mailbox
824
+ ? `<t:DistinguishedFolderId Id="${distinguished}"><t:Mailbox><t:EmailAddress>${xmlEscape(mailbox)}</t:EmailAddress></t:Mailbox></t:DistinguishedFolderId>`
825
+ : `<t:DistinguishedFolderId Id="${distinguished}" />`;
826
+ }
827
+ return `<t:FolderId Id="${xmlEscape(folder)}" />`;
828
+ }
829
+
830
+ function mailParentFolderXml(parentFolderId: string | undefined, mailbox?: string): string {
831
+ if (parentFolderId) {
832
+ return `<t:FolderId Id="${xmlEscape(parentFolderId)}" />`;
833
+ }
834
+ return mailbox
835
+ ? `<t:DistinguishedFolderId Id="msgfolderroot"><t:Mailbox><t:EmailAddress>${xmlEscape(mailbox)}</t:EmailAddress></t:Mailbox></t:DistinguishedFolderId>`
836
+ : '<t:DistinguishedFolderId Id="msgfolderroot" />';
837
+ }
838
+
839
+ // ─── Session Validation ───
840
+
841
+ export async function validateSession(token: string): Promise<boolean> {
842
+ try {
843
+ const envelope = soapEnvelope(`
844
+ <m:GetFolder>
845
+ <m:FolderShape><t:BaseShape>IdOnly</t:BaseShape></m:FolderShape>
846
+ <m:FolderIds>
847
+ <t:DistinguishedFolderId Id="inbox" />
848
+ </m:FolderIds>
849
+ </m:GetFolder>`);
850
+ await callEws(token, envelope);
851
+ return true;
852
+ } catch {
853
+ return false;
854
+ }
855
+ }
856
+
857
+ // ─── User Info ───
858
+
859
+ export async function getOwaUserInfo(token: string): Promise<OwaResponse<OwaUserInfo>> {
860
+ try {
861
+ const envelope = soapEnvelope(`
862
+ <m:ResolveNames ReturnFullContactData="true" SearchScope="ActiveDirectory">
863
+ <m:UnresolvedEntry>${xmlEscape(EWS_USERNAME)}</m:UnresolvedEntry>
864
+ </m:ResolveNames>`);
865
+ const xml = await callEws(token, envelope);
866
+
867
+ const resolution = extractBlocks(xml, 'Resolution')[0] || '';
868
+ const mailbox = extractSelfClosingOrBlock(resolution, 'Mailbox');
869
+ const name = extractTag(mailbox, 'Name') || EWS_USERNAME;
870
+ const email = extractTag(mailbox, 'EmailAddress') || EWS_USERNAME;
871
+
872
+ return ewsResult({ displayName: name, email });
873
+ } catch (err) {
874
+ return ewsError(
875
+ new Error(`Failed to resolve OWA user info: ${err instanceof Error ? err.message : 'Unknown error'}`)
876
+ );
877
+ }
878
+ }
879
+
880
+ // ─── Calendar Operations ───
881
+
882
+ export async function getCalendarEvents(
883
+ token: string,
884
+ startDateTime: string,
885
+ endDateTime: string,
886
+ mailbox?: string
887
+ ): Promise<OwaResponse<CalendarEvent[]>> {
888
+ try {
889
+ const calendarFolderXml = mailbox
890
+ ? `<t:DistinguishedFolderId Id="calendar"><t:Mailbox><t:EmailAddress>${xmlEscape(mailbox)}</t:EmailAddress></t:Mailbox></t:DistinguishedFolderId>`
891
+ : `<t:DistinguishedFolderId Id="calendar" />`;
892
+
893
+ const envelope = soapEnvelope(`
894
+ <m:FindItem Traversal="Shallow">
895
+ <m:ItemShape>
896
+ <t:BaseShape>Default</t:BaseShape>
897
+ <t:AdditionalProperties>
898
+ <t:FieldURI FieldURI="calendar:Location" />
899
+ <t:FieldURI FieldURI="calendar:Organizer" />
900
+ <t:FieldURI FieldURI="calendar:RequiredAttendees" />
901
+ <t:FieldURI FieldURI="calendar:OptionalAttendees" />
902
+ <t:FieldURI FieldURI="calendar:Resources" />
903
+ <t:FieldURI FieldURI="item:Categories" />
904
+ <t:FieldURI FieldURI="calendar:IsAllDayEvent" />
905
+ <t:FieldURI FieldURI="calendar:IsCancelled" />
906
+ <t:FieldURI FieldURI="calendar:MyResponseType" />
907
+ <t:FieldURI FieldURI="calendar:LegacyFreeBusyStatus" />
908
+ <t:FieldURI FieldURI="item:Importance" />
909
+ <t:FieldURI FieldURI="item:TextBody" />
910
+ <t:FieldURI FieldURI="calendar:Recurrence" />
911
+ <t:FieldURI FieldURI="calendar:FirstOccurrence" />
912
+ <t:FieldURI FieldURI="calendar:LastOccurrence" />
913
+ <t:FieldURI FieldURI="calendar:ModifiedOccurrences" />
914
+ <t:FieldURI FieldURI="calendar:DeletedOccurrences" />
915
+ <t:FieldURI FieldURI="calendar:StartTimeZone" />
916
+ <t:FieldURI FieldURI="calendar:EndTimeZone" />
917
+ <t:FieldURI FieldURI="item:HasAttachments" />
918
+ </t:AdditionalProperties>
919
+ </m:ItemShape>
920
+ <m:CalendarView StartDate="${xmlEscape(startDateTime)}" EndDate="${xmlEscape(endDateTime)}" />
921
+ <m:ParentFolderIds>
922
+ ${calendarFolderXml}
923
+ </m:ParentFolderIds>
924
+ </m:FindItem>`);
925
+
926
+ const xml = await callEws(token, envelope, mailbox);
927
+ const blocks = extractBlocks(xml, 'CalendarItem');
928
+ const events = blocks.map((block) => parseCalendarItem(block, mailbox));
929
+
930
+ return ewsResult(events);
931
+ } catch (err) {
932
+ return ewsError(err);
933
+ }
934
+ }
935
+
936
+ export async function getCalendarEvent(
937
+ token: string,
938
+ eventId: string,
939
+ mailbox?: string
940
+ ): Promise<OwaResponse<CalendarEvent>> {
941
+ try {
942
+ eventId = requireNonEmpty(eventId, 'eventId');
943
+ const envelope = soapEnvelope(`
944
+ <m:GetItem>
945
+ <m:ItemShape>
946
+ <t:BaseShape>Default</t:BaseShape>
947
+ <t:AdditionalProperties>
948
+ <t:FieldURI FieldURI="calendar:Location" />
949
+ <t:FieldURI FieldURI="calendar:Organizer" />
950
+ <t:FieldURI FieldURI="calendar:RequiredAttendees" />
951
+ <t:FieldURI FieldURI="calendar:OptionalAttendees" />
952
+ <t:FieldURI FieldURI="calendar:Resources" />
953
+ <t:FieldURI FieldURI="item:Categories" />
954
+ <t:FieldURI FieldURI="calendar:IsAllDayEvent" />
955
+ <t:FieldURI FieldURI="calendar:IsCancelled" />
956
+ <t:FieldURI FieldURI="calendar:MyResponseType" />
957
+ <t:FieldURI FieldURI="calendar:LegacyFreeBusyStatus" />
958
+ <t:FieldURI FieldURI="item:Importance" />
959
+ <t:FieldURI FieldURI="item:TextBody" />
960
+ <t:FieldURI FieldURI="calendar:Recurrence" />
961
+ <t:FieldURI FieldURI="calendar:FirstOccurrence" />
962
+ <t:FieldURI FieldURI="calendar:LastOccurrence" />
963
+ <t:FieldURI FieldURI="calendar:ModifiedOccurrences" />
964
+ <t:FieldURI FieldURI="calendar:DeletedOccurrences" />
965
+ <t:FieldURI FieldURI="calendar:StartTimeZone" />
966
+ <t:FieldURI FieldURI="calendar:EndTimeZone" />
967
+ <t:FieldURI FieldURI="item:HasAttachments" />
968
+ </t:AdditionalProperties>
969
+ </m:ItemShape>
970
+ <m:ItemIds>
971
+ <t:ItemId Id="${xmlEscape(eventId)}" />
972
+ </m:ItemIds>
973
+ </m:GetItem>`);
974
+
975
+ const xml = await callEws(token, envelope, mailbox);
976
+ const block = extractBlocks(xml, 'CalendarItem')[0];
977
+ if (!block) return { ok: false, status: 404, error: { code: 'NOT_FOUND', message: 'Event not found' } };
978
+
979
+ return ewsResult(parseCalendarItem(block, mailbox));
980
+ } catch (err) {
981
+ return ewsError(err);
982
+ }
983
+ }
984
+
985
+ async function resolveCalendarForWrite(
986
+ token: string,
987
+ itemId: string,
988
+ mailbox?: string
989
+ ): Promise<OwaResponse<{ id: string; changeKey?: string }>> {
990
+ const res = await getCalendarEvent(token, itemId, mailbox);
991
+ if (!res.ok || !res.data) {
992
+ if (!res.ok) return res as unknown as OwaResponse<{ id: string; changeKey?: string }>;
993
+ return { ok: false, status: 404, error: { code: 'NOT_FOUND', message: 'Event not found' } };
994
+ }
995
+ return ewsResult({ id: res.data.Id, changeKey: res.data.ChangeKey });
996
+ }
997
+
998
+ /**
999
+ * Validates required fields on a Recurrence input.
1000
+ * Throws a descriptive Error for any missing required field.
1001
+ */
1002
+ function validateRecurrenceInput(recurrence: Recurrence): void {
1003
+ if (!recurrence) {
1004
+ throw new Error('[Recurrence] recurrence object is required');
1005
+ }
1006
+ if (!recurrence.Pattern) {
1007
+ throw new Error('[Recurrence] recurrence.Pattern is required');
1008
+ }
1009
+ if (!recurrence.Range) {
1010
+ throw new Error('[Recurrence] recurrence.Range is required');
1011
+ }
1012
+
1013
+ const { Pattern: p, Range: r } = recurrence;
1014
+
1015
+ if (!r.StartDate || r.StartDate.trim() === '') {
1016
+ throw new Error('[Recurrence] recurrence.Range.StartDate is required');
1017
+ }
1018
+
1019
+ if (r.Type === 'EndDate' && (!r.EndDate || r.EndDate.trim() === '')) {
1020
+ throw new Error('[Recurrence] recurrence.Range.EndDate is required when Range.Type is "EndDate"');
1021
+ }
1022
+
1023
+ if (p.Interval === undefined || p.Interval <= 0) {
1024
+ throw new Error('[Recurrence] recurrence.Pattern.Interval must be a positive integer');
1025
+ }
1026
+ }
1027
+
1028
+ function buildRecurrenceXml(recurrence: Recurrence): string {
1029
+ validateRecurrenceInput(recurrence);
1030
+
1031
+ let patternXml = '';
1032
+ const p = recurrence.Pattern;
1033
+ const validTypes = [
1034
+ 'Daily',
1035
+ 'Weekly',
1036
+ 'AbsoluteMonthly',
1037
+ 'RelativeMonthly',
1038
+ 'AbsoluteYearly',
1039
+ 'RelativeYearly'
1040
+ ] as const;
1041
+
1042
+ switch (p.Type) {
1043
+ case 'Daily':
1044
+ patternXml = `<t:DailyRecurrence><t:Interval>${p.Interval}</t:Interval></t:DailyRecurrence>`;
1045
+ break;
1046
+ case 'Weekly': {
1047
+ const days = (p.DaysOfWeek || []).map((d) => `<t:DayOfWeek>${xmlEscape(d)}</t:DayOfWeek>`).join('');
1048
+ patternXml = `<t:WeeklyRecurrence><t:Interval>${p.Interval}</t:Interval><t:DaysOfWeek>${days || ''}</t:DaysOfWeek></t:WeeklyRecurrence>`;
1049
+ break;
1050
+ }
1051
+ case 'AbsoluteMonthly':
1052
+ patternXml = `<t:AbsoluteMonthlyRecurrence><t:Interval>${p.Interval}</t:Interval><t:DayOfMonth>${p.DayOfMonth || 1}</t:DayOfMonth></t:AbsoluteMonthlyRecurrence>`;
1053
+ break;
1054
+ case 'AbsoluteYearly':
1055
+ patternXml = `<t:AbsoluteYearlyRecurrence><t:DayOfMonth>${p.DayOfMonth || 1}</t:DayOfMonth><t:Month>${['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][(p.Month || 1) - 1]}</t:Month></t:AbsoluteYearlyRecurrence>`;
1056
+ break;
1057
+ case 'RelativeMonthly':
1058
+ patternXml = `<t:RelativeMonthlyRecurrence><t:Interval>${p.Interval}</t:Interval><t:DaysOfWeek>${(p.DaysOfWeek || []).map((d) => xmlEscape(d)).join(' ')}</t:DaysOfWeek><t:DayOfWeekIndex>${p.Index || 'First'}</t:DayOfWeekIndex></t:RelativeMonthlyRecurrence>`;
1059
+ break;
1060
+ case 'RelativeYearly':
1061
+ patternXml = `<t:RelativeYearlyRecurrence><t:DaysOfWeek>${(p.DaysOfWeek || []).map((d) => xmlEscape(d)).join(' ')}</t:DaysOfWeek><t:DayOfWeekIndex>${p.Index || 'First'}</t:DayOfWeekIndex><t:Month>${['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][(p.Month || 1) - 1]}</t:Month></t:RelativeYearlyRecurrence>`;
1062
+ break;
1063
+ default:
1064
+ if (!validTypes.includes(p.Type)) {
1065
+ console.warn(`[Recurrence] Unknown Pattern.Type "${p.Type}", defaulting to Daily`);
1066
+ }
1067
+ patternXml = `<t:DailyRecurrence><t:Interval>${p.Interval}</t:Interval></t:DailyRecurrence>`;
1068
+ }
1069
+
1070
+ let rangeXml = '';
1071
+ const r = recurrence.Range;
1072
+ switch (r.Type) {
1073
+ case 'EndDate': {
1074
+ const endDate = r.EndDate || r.StartDate;
1075
+ if (!r.EndDate) {
1076
+ console.warn(
1077
+ '[Recurrence] Range.EndDate is missing; EndDate will equal StartDate (effectively a single-occurrence event)'
1078
+ );
1079
+ }
1080
+ rangeXml = `<t:EndDateRecurrence><t:StartDate>${xmlEscape(r.StartDate)}</t:StartDate><t:EndDate>${xmlEscape(endDate)}</t:EndDate></t:EndDateRecurrence>`;
1081
+ break;
1082
+ }
1083
+ case 'Numbered':
1084
+ if (r.NumberOfOccurrences === undefined || r.NumberOfOccurrences <= 0) {
1085
+ console.warn('[Recurrence] Range.NumberOfOccurrences is missing or invalid, defaulting to 10');
1086
+ }
1087
+ rangeXml = `<t:NumberedRecurrence><t:StartDate>${xmlEscape(r.StartDate)}</t:StartDate><t:NumberOfOccurrences>${r.NumberOfOccurrences || 10}</t:NumberOfOccurrences></t:NumberedRecurrence>`;
1088
+ break;
1089
+ default:
1090
+ rangeXml = `<t:NoEndRecurrence><t:StartDate>${xmlEscape(r.StartDate)}</t:StartDate></t:NoEndRecurrence>`;
1091
+ }
1092
+
1093
+ return `<t:Recurrence>${patternXml}${rangeXml}</t:Recurrence>`;
1094
+ }
1095
+
1096
+ export async function createEvent(options: CreateEventOptions): Promise<OwaResponse<CreatedEvent>> {
1097
+ try {
1098
+ const {
1099
+ token,
1100
+ subject,
1101
+ start,
1102
+ end,
1103
+ body,
1104
+ location,
1105
+ attendees,
1106
+ isOnlineMeeting,
1107
+ recurrence,
1108
+ isAllDay,
1109
+ mailbox,
1110
+ timezone,
1111
+ categories,
1112
+ sensitivity,
1113
+ fileAttachments,
1114
+ referenceAttachments
1115
+ } = options;
1116
+
1117
+ let attendeesXml = '';
1118
+ if (attendees && attendees.length > 0) {
1119
+ const required = attendees.filter((a) => (a.type || 'Required') === 'Required');
1120
+ const optional = attendees.filter((a) => a.type === 'Optional');
1121
+ const resources = attendees.filter((a) => a.type === 'Resource');
1122
+
1123
+ if (required.length > 0) {
1124
+ attendeesXml += `<t:RequiredAttendees>${required
1125
+ .map(
1126
+ (a) =>
1127
+ `<t:Attendee><t:Mailbox><t:EmailAddress>${xmlEscape(a.email)}</t:EmailAddress>${a.name ? `<t:Name>${xmlEscape(a.name)}</t:Name>` : ''}</t:Mailbox></t:Attendee>`
1128
+ )
1129
+ .join('')}</t:RequiredAttendees>`;
1130
+ }
1131
+ if (optional.length > 0) {
1132
+ attendeesXml += `<t:OptionalAttendees>${optional
1133
+ .map(
1134
+ (a) =>
1135
+ `<t:Attendee><t:Mailbox><t:EmailAddress>${xmlEscape(a.email)}</t:EmailAddress>${a.name ? `<t:Name>${xmlEscape(a.name)}</t:Name>` : ''}</t:Mailbox></t:Attendee>`
1136
+ )
1137
+ .join('')}</t:OptionalAttendees>`;
1138
+ }
1139
+ if (resources.length > 0) {
1140
+ attendeesXml += `<t:Resources>${resources
1141
+ .map(
1142
+ (a) =>
1143
+ `<t:Attendee><t:Mailbox><t:EmailAddress>${xmlEscape(a.email)}</t:EmailAddress>${a.name ? `<t:Name>${xmlEscape(a.name)}</t:Name>` : ''}</t:Mailbox></t:Attendee>`
1144
+ )
1145
+ .join('')}</t:Resources>`;
1146
+ }
1147
+ }
1148
+
1149
+ const sendInvitations = attendees && attendees.length > 0 ? 'SendToAllAndSaveCopy' : 'SendToNone';
1150
+ const savedItemFolderIdXml = mailbox
1151
+ ? `<m:SavedItemFolderId><t:DistinguishedFolderId Id="calendar"><t:Mailbox><t:EmailAddress>${xmlEscape(mailbox)}</t:EmailAddress></t:Mailbox></t:DistinguishedFolderId></m:SavedItemFolderId>`
1152
+ : '';
1153
+
1154
+ const envelope = soapEnvelope(`
1155
+ <m:CreateItem SendMeetingInvitations="${sendInvitations}">
1156
+ ${savedItemFolderIdXml}
1157
+ <m:Items>
1158
+ <t:CalendarItem>
1159
+ <t:Subject>${xmlEscape(subject)}</t:Subject>
1160
+ ${sensitivity ? `<t:Sensitivity>${xmlEscape(sensitivity)}</t:Sensitivity>` : ''}
1161
+ ${body ? `<t:Body BodyType="Text">${xmlEscape(body)}</t:Body>` : ''}
1162
+ ${categories && categories.length > 0 ? `<t:Categories>${categories.map((c) => `<t:String>${xmlEscape(c)}</t:String>`).join('')}</t:Categories>` : ''}
1163
+ <t:Start>${xmlEscape(start)}</t:Start>
1164
+ <t:End>${xmlEscape(end)}</t:End>
1165
+ ${isAllDay ? '<t:IsAllDayEvent>true</t:IsAllDayEvent>' : ''}
1166
+ ${location ? `<t:Location>${xmlEscape(location)}</t:Location>` : ''}
1167
+ ${attendeesXml}
1168
+ ${recurrence ? buildRecurrenceXml(recurrence) : ''}
1169
+ ${timezone ? `<t:StartTimeZone Id="${xmlEscape(timezone)}"/><t:EndTimeZone Id="${xmlEscape(timezone)}"/>` : ''}
1170
+ ${isOnlineMeeting ? '<t:IsOnlineMeeting>true</t:IsOnlineMeeting>' : ''}
1171
+ </t:CalendarItem>
1172
+ </m:Items>
1173
+ </m:CreateItem>`);
1174
+
1175
+ const xml = await callEws(token, envelope, mailbox);
1176
+ const block = extractBlocks(xml, 'CalendarItem')[0] || '';
1177
+ const id = extractAttribute(block, 'ItemId', 'Id');
1178
+ let changeKey = extractAttribute(block, 'ItemId', 'ChangeKey')?.trim();
1179
+
1180
+ if (!id?.trim()) {
1181
+ return {
1182
+ ok: false,
1183
+ status: 400,
1184
+ error: { code: 'EWS_ERROR', message: 'Create event response did not include an item id' }
1185
+ };
1186
+ }
1187
+
1188
+ const files = fileAttachments ?? [];
1189
+ const refs = referenceAttachments ?? [];
1190
+ if (files.length > 0 || refs.length > 0) {
1191
+ let itemState: { id: string; changeKey?: string } = { id: id.trim(), changeKey: changeKey || undefined };
1192
+ for (const att of files) {
1193
+ itemState = await addAttachmentToItem(token, itemState.id, att, mailbox, itemState, 'calendar');
1194
+ }
1195
+ for (const ref of refs) {
1196
+ itemState = await addReferenceAttachmentToItem(token, itemState.id, ref, mailbox, itemState);
1197
+ }
1198
+ changeKey = itemState.changeKey || changeKey;
1199
+ }
1200
+
1201
+ return ewsResult({
1202
+ Id: id.trim(),
1203
+ ChangeKey: changeKey || undefined,
1204
+ Subject: subject,
1205
+ Start: { DateTime: start, TimeZone: timezone || 'UTC' },
1206
+ End: { DateTime: end, TimeZone: timezone || 'UTC' },
1207
+ WebLink: undefined,
1208
+ OnlineMeetingUrl: undefined
1209
+ });
1210
+ } catch (err) {
1211
+ return ewsError(err);
1212
+ }
1213
+ }
1214
+
1215
+ /**
1216
+ * Updates an existing calendar event.
1217
+ *
1218
+ * NOTE regarding attendees: In EWS, `SetItemField` for `calendar:RequiredAttendees` (or Optional/Resource)
1219
+ * replaces the *entire attendee list*. To add or remove an attendee, you must fetch the event,
1220
+ * manipulate the attendees array locally, and pass the full list back here.
1221
+ * This introduces a potential race condition: if an attendee is added/removed externally between the
1222
+ * fetch and update steps, those changes will be overwritten.
1223
+ *
1224
+ * Future improvement: provide explicit add/remove delta operations if needed.
1225
+ */
1226
+ export async function updateEvent(options: UpdateEventOptions): Promise<OwaResponse<CreatedEvent>> {
1227
+ try {
1228
+ const {
1229
+ token,
1230
+ eventId,
1231
+ changeKey,
1232
+ subject,
1233
+ start,
1234
+ end,
1235
+ body,
1236
+ location,
1237
+ attendees,
1238
+ occurrenceItemId,
1239
+ timezone,
1240
+ isAllDay,
1241
+ mailbox,
1242
+ categories,
1243
+ sensitivity
1244
+ } = options;
1245
+
1246
+ const updates: string[] = [];
1247
+
1248
+ if (subject !== undefined) {
1249
+ updates.push(
1250
+ `<t:SetItemField><t:FieldURI FieldURI="item:Subject" /><t:CalendarItem><t:Subject>${xmlEscape(subject)}</t:Subject></t:CalendarItem></t:SetItemField>`
1251
+ );
1252
+ }
1253
+ if (body !== undefined) {
1254
+ updates.push(
1255
+ `<t:SetItemField><t:FieldURI FieldURI="item:Body" /><t:CalendarItem><t:Body BodyType="Text">${xmlEscape(body)}</t:Body></t:CalendarItem></t:SetItemField>`
1256
+ );
1257
+ }
1258
+ if (timezone !== undefined) {
1259
+ updates.push(
1260
+ `<t:SetItemField><t:FieldURI FieldURI="calendar:StartTimeZone" /><t:CalendarItem><t:StartTimeZone Id="${xmlEscape(timezone)}"/></t:CalendarItem></t:SetItemField>`,
1261
+ `<t:SetItemField><t:FieldURI FieldURI="calendar:EndTimeZone" /><t:CalendarItem><t:EndTimeZone Id="${xmlEscape(timezone)}"/></t:CalendarItem></t:SetItemField>`
1262
+ );
1263
+ }
1264
+ if (start !== undefined) {
1265
+ updates.push(
1266
+ `<t:SetItemField><t:FieldURI FieldURI="calendar:Start" /><t:CalendarItem><t:Start>${xmlEscape(start)}</t:Start></t:CalendarItem></t:SetItemField>`
1267
+ );
1268
+ }
1269
+ if (end !== undefined) {
1270
+ updates.push(
1271
+ `<t:SetItemField><t:FieldURI FieldURI="calendar:End" /><t:CalendarItem><t:End>${xmlEscape(end)}</t:End></t:CalendarItem></t:SetItemField>`
1272
+ );
1273
+ }
1274
+ if (location !== undefined) {
1275
+ updates.push(
1276
+ `<t:SetItemField><t:FieldURI FieldURI="calendar:Location" /><t:CalendarItem><t:Location>${xmlEscape(location)}</t:Location></t:CalendarItem></t:SetItemField>`
1277
+ );
1278
+ }
1279
+ if (isAllDay !== undefined) {
1280
+ updates.push(
1281
+ `<t:SetItemField><t:FieldURI FieldURI="calendar:IsAllDayEvent" /><t:CalendarItem><t:IsAllDayEvent>${isAllDay}</t:IsAllDayEvent></t:CalendarItem></t:SetItemField>`
1282
+ );
1283
+ }
1284
+ if (categories !== undefined) {
1285
+ if (categories.length > 0) {
1286
+ updates.push(
1287
+ `<t:SetItemField><t:FieldURI FieldURI="item:Categories" /><t:CalendarItem><t:Categories>${categories.map((c) => `<t:String>${xmlEscape(c)}</t:String>`).join('')}</t:Categories></t:CalendarItem></t:SetItemField>`
1288
+ );
1289
+ } else {
1290
+ updates.push(`<t:DeleteItemField><t:FieldURI FieldURI="item:Categories" /></t:DeleteItemField>`);
1291
+ }
1292
+ }
1293
+ if (sensitivity !== undefined) {
1294
+ updates.push(
1295
+ `<t:SetItemField><t:FieldURI FieldURI="item:Sensitivity" /><t:CalendarItem><t:Sensitivity>${xmlEscape(sensitivity)}</t:Sensitivity></t:CalendarItem></t:SetItemField>`
1296
+ );
1297
+ }
1298
+ let hasAttendeeUpdates = false;
1299
+ if (attendees !== undefined) {
1300
+ hasAttendeeUpdates = true;
1301
+ const required = attendees.filter((a) => (a.type || 'Required') !== 'Optional' && a.type !== 'Resource');
1302
+ const optional = attendees.filter((a) => a.type === 'Optional');
1303
+ const resources = attendees.filter((a) => a.type === 'Resource');
1304
+
1305
+ if (required.length > 0) {
1306
+ updates.push(
1307
+ `<t:SetItemField><t:FieldURI FieldURI="calendar:RequiredAttendees" /><t:CalendarItem><t:RequiredAttendees>${required
1308
+ .map(
1309
+ (a) =>
1310
+ `<t:Attendee><t:Mailbox><t:EmailAddress>${xmlEscape(a.email)}</t:EmailAddress></t:Mailbox></t:Attendee>`
1311
+ )
1312
+ .join('')}</t:RequiredAttendees></t:CalendarItem></t:SetItemField>`
1313
+ );
1314
+ } else {
1315
+ updates.push(`<t:DeleteItemField><t:FieldURI FieldURI="calendar:RequiredAttendees" /></t:DeleteItemField>`);
1316
+ }
1317
+ if (optional.length > 0) {
1318
+ updates.push(
1319
+ `<t:SetItemField><t:FieldURI FieldURI="calendar:OptionalAttendees" /><t:CalendarItem><t:OptionalAttendees>${optional
1320
+ .map(
1321
+ (a) =>
1322
+ `<t:Attendee><t:Mailbox><t:EmailAddress>${xmlEscape(a.email)}</t:EmailAddress></t:Mailbox></t:Attendee>`
1323
+ )
1324
+ .join('')}</t:OptionalAttendees></t:CalendarItem></t:SetItemField>`
1325
+ );
1326
+ } else {
1327
+ updates.push(`<t:DeleteItemField><t:FieldURI FieldURI="calendar:OptionalAttendees" /></t:DeleteItemField>`);
1328
+ }
1329
+ if (resources.length > 0) {
1330
+ updates.push(
1331
+ `<t:SetItemField><t:FieldURI FieldURI="calendar:Resources" /><t:CalendarItem><t:Resources>${resources
1332
+ .map(
1333
+ (a) =>
1334
+ `<t:Attendee><t:Mailbox><t:EmailAddress>${xmlEscape(a.email)}</t:EmailAddress></t:Mailbox></t:Attendee>`
1335
+ )
1336
+ .join('')}</t:Resources></t:CalendarItem></t:SetItemField>`
1337
+ );
1338
+ } else {
1339
+ updates.push(`<t:DeleteItemField><t:FieldURI FieldURI="calendar:Resources" /></t:DeleteItemField>`);
1340
+ }
1341
+ }
1342
+
1343
+ if (updates.length === 0) {
1344
+ return { ok: false, status: 400, error: { code: 'NO_UPDATES', message: 'No fields to update' } };
1345
+ }
1346
+
1347
+ let effectiveChangeKey = changeKey;
1348
+ if (!effectiveChangeKey) {
1349
+ const lookupId = occurrenceItemId || eventId;
1350
+ const fetched = await getCalendarEvent(token, lookupId, mailbox);
1351
+ if (!fetched.ok) {
1352
+ return fetched as unknown as OwaResponse<CreatedEvent>;
1353
+ }
1354
+ if (fetched.data?.ChangeKey) {
1355
+ effectiveChangeKey = fetched.data.ChangeKey;
1356
+ }
1357
+ }
1358
+
1359
+ const sendUpdates = hasAttendeeUpdates ? 'SendToAllAndSaveCopy' : 'SendToNone';
1360
+
1361
+ const buildEnvelope = (
1362
+ conflictResolution: 'AutoResolve' | 'AlwaysOverwrite',
1363
+ includeChangeKey: boolean
1364
+ ): string => {
1365
+ const itemIdXml = occurrenceItemId
1366
+ ? `<t:ItemId Id="${xmlEscape(occurrenceItemId)}"${includeChangeKey && effectiveChangeKey ? ` ChangeKey="${xmlEscape(effectiveChangeKey)}"` : ''} />`
1367
+ : `<t:ItemId Id="${xmlEscape(eventId)}"${includeChangeKey && effectiveChangeKey ? ` ChangeKey="${xmlEscape(effectiveChangeKey)}"` : ''} />`;
1368
+
1369
+ return soapEnvelope(`
1370
+ <m:UpdateItem ConflictResolution="${conflictResolution}" SendMeetingInvitationsOrCancellations="${sendUpdates}">
1371
+ <m:ItemChanges>
1372
+ <t:ItemChange>
1373
+ ${itemIdXml}
1374
+ <t:Updates>
1375
+ ${updates.join('\n')}
1376
+ </t:Updates>
1377
+ </t:ItemChange>
1378
+ </m:ItemChanges>
1379
+ </m:UpdateItem>`);
1380
+ };
1381
+
1382
+ let xml: string;
1383
+ try {
1384
+ xml = await callEws(token, buildEnvelope(effectiveChangeKey ? 'AutoResolve' : 'AlwaysOverwrite', true), mailbox);
1385
+ } catch (err) {
1386
+ const message = err instanceof Error ? err.message : '';
1387
+ const isConflict =
1388
+ message.includes('ErrorIrresolvableConflict') ||
1389
+ message.includes('ErrorConflictResolutionRequired') ||
1390
+ message.includes('ErrorChangeKeyRequiredForWriteOperations');
1391
+
1392
+ if (!effectiveChangeKey || !isConflict) {
1393
+ throw err;
1394
+ }
1395
+
1396
+ xml = await callEws(token, buildEnvelope('AlwaysOverwrite', false), mailbox);
1397
+ }
1398
+ const block = extractBlocks(xml, 'CalendarItem')[0] || '';
1399
+ const newId = extractAttribute(block, 'ItemId', 'Id') || eventId;
1400
+ const newChangeKey = extractAttribute(block, 'ItemId', 'ChangeKey')?.trim();
1401
+
1402
+ return ewsResult({
1403
+ Id: newId,
1404
+ ChangeKey: newChangeKey || undefined,
1405
+ Subject: subject || '',
1406
+ Start: { DateTime: start || '', TimeZone: timezone || 'UTC' },
1407
+ End: { DateTime: end || '', TimeZone: timezone || 'UTC' }
1408
+ });
1409
+ } catch (err) {
1410
+ return ewsError(err);
1411
+ }
1412
+ }
1413
+
1414
+ export interface DeleteEventOptions {
1415
+ token: string;
1416
+ eventId: string;
1417
+ /** Use OccurrenceItemId for a specific occurrence, ItemId for the series master */
1418
+ occurrenceItemId?: string;
1419
+ /** Scope: 'all' (default), 'this' (single occurrence), 'future' (this and all future) */
1420
+ scope?: 'all' | 'this' | 'future';
1421
+ mailbox?: string;
1422
+ /** If true, delete without sending cancellation notices even if there are attendees */
1423
+ forceDelete?: boolean;
1424
+ /** Cancellation message to send to attendees (only supported for single occurrence deletes) */
1425
+ comment?: string;
1426
+ }
1427
+
1428
+ export async function deleteEvent(options: DeleteEventOptions): Promise<OwaResponse<void>> {
1429
+ try {
1430
+ const { token, eventId, occurrenceItemId, scope = 'all', mailbox, forceDelete, comment } = options;
1431
+ requireNonEmpty(eventId, 'eventId');
1432
+
1433
+ const targetId = occurrenceItemId || eventId;
1434
+ const resolved = await resolveCalendarForWrite(token, targetId, mailbox);
1435
+ if (!resolved.ok || !resolved.data) {
1436
+ return resolved as OwaResponse<void>;
1437
+ }
1438
+ const { id: calId, changeKey: calCk } = resolved.data;
1439
+
1440
+ // If a comment is provided for occurrence delete, use CancelCalendarItem instead
1441
+ if (comment && !forceDelete && (scope === 'this' || scope === 'future')) {
1442
+ const cancelEnvelope = soapEnvelope(`
1443
+ <m:CreateItem MessageDisposition="SendAndSaveCopy">
1444
+ <m:Items>
1445
+ <t:CancelCalendarItem>
1446
+ ${referenceItemIdXml(calId, calCk)}
1447
+ <t:NewBodyContent BodyType="Text">${xmlEscape(comment)}</t:NewBodyContent>
1448
+ </t:CancelCalendarItem>
1449
+ </m:Items>
1450
+ </m:CreateItem>`);
1451
+ try {
1452
+ await callEws(token, cancelEnvelope, mailbox);
1453
+ return { ok: true, status: 200 };
1454
+ } catch {
1455
+ // Fallback to DeleteItem if CancelCalendarItem fails
1456
+ }
1457
+ }
1458
+
1459
+ // Determine send mode based on scope and forceDelete flag
1460
+ let sendCancellations = 'SendToNone';
1461
+ if (!forceDelete && (scope === 'this' || scope === 'future')) {
1462
+ sendCancellations = 'SendToAllAndSaveCopy';
1463
+ }
1464
+
1465
+ const envelope = soapEnvelope(`
1466
+ <m:DeleteItem DeleteType="MoveToDeletedItems" SendMeetingCancellations="${sendCancellations}">
1467
+ <m:ItemIds>
1468
+ ${itemIdXml(calId, calCk)}
1469
+ </m:ItemIds>
1470
+ </m:DeleteItem>`);
1471
+ await callEws(token, envelope, mailbox);
1472
+ return { ok: true, status: 200 };
1473
+ } catch (err) {
1474
+ return ewsError(err);
1475
+ }
1476
+ }
1477
+
1478
+ export interface CancelEventOptions {
1479
+ token: string;
1480
+ eventId: string;
1481
+ comment?: string;
1482
+ mailbox?: string;
1483
+ }
1484
+
1485
+ export async function cancelEvent(options: CancelEventOptions): Promise<OwaResponse<void>> {
1486
+ const { token, eventId, comment, mailbox } = options;
1487
+ requireNonEmpty(eventId, 'eventId');
1488
+
1489
+ const resolved = await resolveCalendarForWrite(token, eventId, mailbox);
1490
+ if (!resolved.ok || !resolved.data) {
1491
+ return resolved as OwaResponse<void>;
1492
+ }
1493
+ const { id: calId, changeKey: calCk } = resolved.data;
1494
+
1495
+ // Primary: CancelCalendarItem
1496
+ try {
1497
+ const envelope = soapEnvelope(`
1498
+ <m:CreateItem MessageDisposition="SendAndSaveCopy">
1499
+ <m:Items>
1500
+ <t:CancelCalendarItem>
1501
+ ${referenceItemIdXml(calId, calCk)}
1502
+ ${comment ? `<t:NewBodyContent BodyType="Text">${xmlEscape(comment)}</t:NewBodyContent>` : ''}
1503
+ </t:CancelCalendarItem>
1504
+ </m:Items>
1505
+ </m:CreateItem>`);
1506
+ await callEws(token, envelope, mailbox);
1507
+ return { ok: true, status: 200 };
1508
+ } catch (primaryErr) {
1509
+ // Fallback: DeleteItem with SendMeetingCancellations
1510
+ try {
1511
+ const envelope = soapEnvelope(`
1512
+ <m:DeleteItem DeleteType="MoveToDeletedItems" SendMeetingCancellations="SendToAllAndSaveCopy">
1513
+ <m:ItemIds>
1514
+ ${itemIdXml(calId, calCk)}
1515
+ </m:ItemIds>
1516
+ </m:DeleteItem>`);
1517
+ await callEws(token, envelope, mailbox);
1518
+ // Fallback succeeded after primary failed — report it so caller knows what happened
1519
+ const primaryMsg = primaryErr instanceof Error ? primaryErr.message : String(primaryErr);
1520
+ return {
1521
+ ok: true,
1522
+ status: 200,
1523
+ info: `Primary cancellation failed (${primaryMsg}); cancellation sent via fallback DeleteItem instead.`
1524
+ };
1525
+ } catch (fallbackErr) {
1526
+ // Both failed — report both errors clearly
1527
+ const primaryMsg = primaryErr instanceof Error ? primaryErr.message : String(primaryErr);
1528
+ const fallbackMsg = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
1529
+ return {
1530
+ ok: false,
1531
+ status: 0,
1532
+ error: {
1533
+ code: 'EWS_CANCEL_FAILED',
1534
+ message: `Primary cancellation failed: ${primaryMsg}. Fallback also failed: ${fallbackMsg}`
1535
+ }
1536
+ };
1537
+ }
1538
+ }
1539
+ }
1540
+
1541
+ export async function respondToEvent(options: RespondToEventOptions): Promise<OwaResponse<void>> {
1542
+ try {
1543
+ const { token, eventId, response, comment, sendResponse = true, mailbox } = options;
1544
+ const resolved = await resolveCalendarForWrite(token, eventId, mailbox);
1545
+ if (!resolved.ok || !resolved.data) {
1546
+ return resolved as OwaResponse<void>;
1547
+ }
1548
+ const { id: calId, changeKey: calCk } = resolved.data;
1549
+
1550
+ const disposition = sendResponse ? 'SendAndSaveCopy' : 'SaveOnly';
1551
+
1552
+ const responseTagMap: Record<ResponseType, string> = {
1553
+ accept: 'AcceptItem',
1554
+ decline: 'DeclineItem',
1555
+ tentative: 'TentativelyAcceptItem'
1556
+ };
1557
+ const tag = responseTagMap[response];
1558
+
1559
+ const envelope = soapEnvelope(`
1560
+ <m:CreateItem MessageDisposition="${disposition}">
1561
+ <m:Items>
1562
+ <t:${tag}>
1563
+ ${referenceItemIdXml(calId, calCk)}
1564
+ ${comment ? `<t:Body BodyType="Text">${xmlEscape(comment)}</t:Body>` : ''}
1565
+ </t:${tag}>
1566
+ </m:Items>
1567
+ </m:CreateItem>`);
1568
+
1569
+ await callEws(token, envelope, mailbox);
1570
+ return { ok: true, status: 200 };
1571
+ } catch (err) {
1572
+ return ewsError(err);
1573
+ }
1574
+ }
1575
+
1576
+ // ─── Mail Operations ───
1577
+
1578
+ export async function getEmails(options: GetEmailsOptions): Promise<OwaResponse<EmailListResponse>> {
1579
+ try {
1580
+ const { token, folder = 'inbox', mailbox, top = 10, skip = 0, search, isRead, flagStatus } = options;
1581
+
1582
+ // Build restriction for filters
1583
+ let restrictionXml = '';
1584
+ if (!search) {
1585
+ const restrictions: string[] = [];
1586
+
1587
+ if (isRead !== undefined) {
1588
+ restrictions.push(`
1589
+ <t:IsEqualTo>
1590
+ <t:FieldURI FieldURI="message:IsRead" />
1591
+ <t:FieldURIOrConstant><t:Constant Value="${isRead ? 'true' : 'false'}" /></t:FieldURIOrConstant>
1592
+ </t:IsEqualTo>`);
1593
+ }
1594
+
1595
+ if (flagStatus) {
1596
+ restrictions.push(`
1597
+ <t:IsEqualTo>
1598
+ <t:FieldURI FieldURI="item:Flag/FlagStatus" />
1599
+ <t:FieldURIOrConstant><t:Constant Value="${flagStatus}" /></t:FieldURIOrConstant>
1600
+ </t:IsEqualTo>`);
1601
+ }
1602
+
1603
+ if (restrictions.length === 1) {
1604
+ restrictionXml = `<m:Restriction>${restrictions[0]}</m:Restriction>`;
1605
+ } else if (restrictions.length > 1) {
1606
+ restrictionXml = `<m:Restriction><t:And>${restrictions.join('')}</t:And></m:Restriction>`;
1607
+ }
1608
+ }
1609
+
1610
+ const queryStringXml = search ? `<m:QueryString>${xmlEscape(search)}</m:QueryString>` : '';
1611
+
1612
+ const envelope = soapEnvelope(`
1613
+ <m:FindItem Traversal="Shallow">
1614
+ <m:ItemShape>
1615
+ <t:BaseShape>IdOnly</t:BaseShape>
1616
+ <t:AdditionalProperties>
1617
+ <t:FieldURI FieldURI="item:Subject" />
1618
+ <t:FieldURI FieldURI="item:DateTimeReceived" />
1619
+ <t:FieldURI FieldURI="item:DateTimeSent" />
1620
+ <t:FieldURI FieldURI="item:HasAttachments" />
1621
+ <t:FieldURI FieldURI="item:Importance" />
1622
+ <t:FieldURI FieldURI="item:Preview" />
1623
+ <t:FieldURI FieldURI="message:From" />
1624
+ <t:FieldURI FieldURI="message:IsRead" />
1625
+ <t:FieldURI FieldURI="item:Flag" />
1626
+ <t:FieldURI FieldURI="item:IsDraft" />
1627
+ <t:FieldURI FieldURI="item:Categories" />
1628
+ </t:AdditionalProperties>
1629
+ </m:ItemShape>
1630
+ <m:IndexedPageItemView MaxEntriesReturned="${top}" Offset="${skip}" BasePoint="Beginning" />
1631
+ ${restrictionXml}
1632
+ ${
1633
+ !search
1634
+ ? `<m:SortOrder>
1635
+ <t:FieldOrder Order="Descending">
1636
+ <t:FieldURI FieldURI="item:DateTimeReceived" />
1637
+ </t:FieldOrder>
1638
+ </m:SortOrder>`
1639
+ : ''
1640
+ }
1641
+ ${queryStringXml}
1642
+ <m:ParentFolderIds>
1643
+ ${folderIdXml(folder, mailbox)}
1644
+ </m:ParentFolderIds>
1645
+ </m:FindItem>`);
1646
+
1647
+ const xml = await callEws(token, envelope, mailbox);
1648
+ const blocks = extractBlocks(xml, 'Message');
1649
+ const emails = blocks.map(parseEmailMessage);
1650
+
1651
+ return ewsResult({ value: emails });
1652
+ } catch (err) {
1653
+ return ewsError(err);
1654
+ }
1655
+ }
1656
+
1657
+ export async function getEmail(token: string, messageId: string, mailbox?: string): Promise<OwaResponse<EmailMessage>> {
1658
+ try {
1659
+ messageId = requireNonEmpty(messageId, 'messageId');
1660
+ const envelope = soapEnvelope(`
1661
+ <m:GetItem>
1662
+ <m:ItemShape>
1663
+ <t:BaseShape>Default</t:BaseShape>
1664
+ <t:BodyType>Text</t:BodyType>
1665
+ <t:AdditionalProperties>
1666
+ <t:FieldURI FieldURI="item:Body" />
1667
+ <t:FieldURI FieldURI="item:DateTimeReceived" />
1668
+ <t:FieldURI FieldURI="item:DateTimeSent" />
1669
+ <t:FieldURI FieldURI="item:HasAttachments" />
1670
+ <t:FieldURI FieldURI="message:From" />
1671
+ <t:FieldURI FieldURI="message:ToRecipients" />
1672
+ <t:FieldURI FieldURI="message:CcRecipients" />
1673
+ <t:FieldURI FieldURI="message:IsRead" />
1674
+ <t:FieldURI FieldURI="item:Flag" />
1675
+ <t:FieldURI FieldURI="item:Importance" />
1676
+ <t:FieldURI FieldURI="item:Categories" />
1677
+ </t:AdditionalProperties>
1678
+ </m:ItemShape>
1679
+ <m:ItemIds>
1680
+ <t:ItemId Id="${xmlEscape(messageId)}" />
1681
+ </m:ItemIds>
1682
+ </m:GetItem>`);
1683
+
1684
+ const xml = await callEws(token, envelope, mailbox);
1685
+ const block = extractBlocks(xml, 'Message')[0];
1686
+ if (!block) return { ok: false, status: 404, error: { code: 'NOT_FOUND', message: 'Message not found' } };
1687
+
1688
+ return ewsResult(parseEmailMessage(block));
1689
+ } catch (err) {
1690
+ return ewsError(err);
1691
+ }
1692
+ }
1693
+
1694
+ async function resolveMessageForWrite(
1695
+ token: string,
1696
+ messageId: string,
1697
+ mailbox?: string
1698
+ ): Promise<OwaResponse<{ id: string; changeKey?: string }>> {
1699
+ const res = await getEmail(token, messageId, mailbox);
1700
+ if (!res.ok || !res.data) {
1701
+ if (!res.ok) return res as unknown as OwaResponse<{ id: string; changeKey?: string }>;
1702
+ return { ok: false, status: 404, error: { code: 'NOT_FOUND', message: 'Message not found' } };
1703
+ }
1704
+ return ewsResult({ id: res.data.Id, changeKey: res.data.ChangeKey });
1705
+ }
1706
+
1707
+ export async function sendEmail(
1708
+ token: string,
1709
+ options: {
1710
+ to: string[];
1711
+ cc?: string[];
1712
+ bcc?: string[];
1713
+ subject: string;
1714
+ body: string;
1715
+ bodyType?: 'Text' | 'HTML';
1716
+ attachments?: EmailAttachment[];
1717
+ referenceAttachments?: ReferenceAttachmentInput[];
1718
+ mailbox?: string;
1719
+ categories?: string[];
1720
+ }
1721
+ ): Promise<OwaResponse<void>> {
1722
+ try {
1723
+ const { mailbox } = options;
1724
+
1725
+ const toXml = options.to
1726
+ .map((e) => `<t:Mailbox><t:EmailAddress>${xmlEscape(e)}</t:EmailAddress></t:Mailbox>`)
1727
+ .join('');
1728
+
1729
+ const ccXml =
1730
+ options.cc && options.cc.length > 0
1731
+ ? `<t:CcRecipients>${options.cc
1732
+ .map((e) => `<t:Mailbox><t:EmailAddress>${xmlEscape(e)}</t:EmailAddress></t:Mailbox>`)
1733
+ .join('')}</t:CcRecipients>`
1734
+ : '';
1735
+
1736
+ const bccXml =
1737
+ options.bcc && options.bcc.length > 0
1738
+ ? `<t:BccRecipients>${options.bcc
1739
+ .map((e) => `<t:Mailbox><t:EmailAddress>${xmlEscape(e)}</t:EmailAddress></t:Mailbox>`)
1740
+ .join('')}</t:BccRecipients>`
1741
+ : '';
1742
+
1743
+ const bodyType = options.bodyType || 'Text';
1744
+
1745
+ // Build From element for shared mailbox (Send As)
1746
+ const fromXml = mailbox
1747
+ ? `<t:From><t:Mailbox><t:EmailAddress>${xmlEscape(mailbox)}</t:EmailAddress></t:Mailbox></t:From>`
1748
+ : '';
1749
+
1750
+ // Build SavedItemFolderId targeting shared mailbox sentitems
1751
+ const savedItemFolderIdXml = mailbox
1752
+ ? `<m:SavedItemFolderId><t:DistinguishedFolderId Id="sentitems"><t:Mailbox><t:EmailAddress>${xmlEscape(mailbox)}</t:EmailAddress></t:Mailbox></t:DistinguishedFolderId></m:SavedItemFolderId>`
1753
+ : '';
1754
+
1755
+ const hasFileAtt = !!options.attachments && options.attachments.length > 0;
1756
+ const hasRefAtt = !!options.referenceAttachments && options.referenceAttachments.length > 0;
1757
+
1758
+ // If no attachments, send directly
1759
+ if (!hasFileAtt && !hasRefAtt) {
1760
+ const envelope = soapEnvelope(`
1761
+ <m:CreateItem MessageDisposition="SendAndSaveCopy">
1762
+ ${savedItemFolderIdXml}
1763
+ <m:Items>
1764
+ <t:Message>
1765
+ <t:Subject>${xmlEscape(options.subject)}</t:Subject>
1766
+ <t:Body BodyType="${bodyType}">${xmlEscape(options.body)}</t:Body>
1767
+ ${options.categories && options.categories.length > 0 ? `<t:Categories>${options.categories.map((c) => `<t:String>${xmlEscape(c)}</t:String>`).join('')}</t:Categories>` : ''}
1768
+ <t:ToRecipients>${toXml}</t:ToRecipients>
1769
+ ${ccXml}
1770
+ ${bccXml}
1771
+ ${fromXml}
1772
+ </t:Message>
1773
+ </m:Items>
1774
+ </m:CreateItem>`);
1775
+ await callEws(token, envelope, mailbox);
1776
+ return { ok: true, status: 200 };
1777
+ }
1778
+
1779
+ // With attachments: create draft, add attachments, send
1780
+ const draftResult = await createDraft(token, {
1781
+ to: options.to,
1782
+ cc: options.cc,
1783
+ subject: options.subject,
1784
+ body: options.body,
1785
+ bodyType,
1786
+ categories: options.categories,
1787
+ mailbox
1788
+ });
1789
+ if (!draftResult.ok || !draftResult.data) return draftResult as OwaResponse<void>;
1790
+
1791
+ let item: { id: string; changeKey?: string } = {
1792
+ id: draftResult.data.Id,
1793
+ changeKey: draftResult.data.ChangeKey
1794
+ };
1795
+ for (const att of options.attachments ?? []) {
1796
+ item = await addAttachmentToItem(token, item.id, att, mailbox, item, 'message');
1797
+ }
1798
+ for (const ref of options.referenceAttachments ?? []) {
1799
+ item = await addReferenceAttachmentToItem(token, item.id, ref, mailbox, item);
1800
+ }
1801
+
1802
+ await sendItemById(token, item.id, mailbox, item);
1803
+ return { ok: true, status: 200 };
1804
+ } catch (err) {
1805
+ return ewsError(err);
1806
+ }
1807
+ }
1808
+
1809
+ export async function replyToEmail(
1810
+ token: string,
1811
+ messageId: string,
1812
+ comment: string,
1813
+ replyAll: boolean = false,
1814
+ isHtml: boolean = false,
1815
+ mailbox?: string
1816
+ ): Promise<OwaResponse<void>> {
1817
+ try {
1818
+ const resolved = await resolveMessageForWrite(token, messageId, mailbox);
1819
+ if (!resolved.ok || !resolved.data) {
1820
+ return resolved as OwaResponse<void>;
1821
+ }
1822
+ const { id: refId, changeKey: refCk } = resolved.data;
1823
+
1824
+ const tag = replyAll ? 'ReplyAllToItem' : 'ReplyToItem';
1825
+ const bodyType = isHtml ? 'HTML' : 'Text';
1826
+
1827
+ const envelope = soapEnvelope(`
1828
+ <m:CreateItem MessageDisposition="SendAndSaveCopy">
1829
+ <m:Items>
1830
+ <t:${tag}>
1831
+ ${referenceItemIdXml(refId, refCk)}
1832
+ <t:NewBodyContent BodyType="${bodyType}">${xmlEscape(comment)}</t:NewBodyContent>
1833
+ </t:${tag}>
1834
+ </m:Items>
1835
+ </m:CreateItem>`);
1836
+
1837
+ await callEws(token, envelope, mailbox);
1838
+ return { ok: true, status: 200 };
1839
+ } catch (err) {
1840
+ return ewsError(err);
1841
+ }
1842
+ }
1843
+
1844
+ export async function replyToEmailDraft(
1845
+ token: string,
1846
+ messageId: string,
1847
+ comment: string,
1848
+ replyAll: boolean = false,
1849
+ isHtml: boolean = false,
1850
+ mailbox?: string
1851
+ ): Promise<OwaResponse<{ draftId: string }>> {
1852
+ try {
1853
+ const resolved = await resolveMessageForWrite(token, messageId, mailbox);
1854
+ if (!resolved.ok || !resolved.data) {
1855
+ return resolved as unknown as OwaResponse<{ draftId: string }>;
1856
+ }
1857
+ const { id: refId, changeKey: refCk } = resolved.data;
1858
+
1859
+ const tag = replyAll ? 'ReplyAllToItem' : 'ReplyToItem';
1860
+ const bodyType = isHtml ? 'HTML' : 'Text';
1861
+
1862
+ const envelope = soapEnvelope(`
1863
+ <m:CreateItem MessageDisposition="SaveOnly">
1864
+ <m:Items>
1865
+ <t:${tag}>
1866
+ ${referenceItemIdXml(refId, refCk)}
1867
+ <t:NewBodyContent BodyType="${bodyType}">${xmlEscape(comment)}</t:NewBodyContent>
1868
+ </t:${tag}>
1869
+ </m:Items>
1870
+ </m:CreateItem>`);
1871
+
1872
+ const xml = await callEws(token, envelope, mailbox);
1873
+ const draftId = extractAttribute(xml, 'ItemId', 'Id');
1874
+ if (!draftId?.trim()) {
1875
+ return {
1876
+ ok: false,
1877
+ status: 400,
1878
+ error: { code: 'EWS_ERROR', message: 'Reply draft response did not include an item id' }
1879
+ };
1880
+ }
1881
+ return ewsResult({ draftId });
1882
+ } catch (err) {
1883
+ return ewsError(err);
1884
+ }
1885
+ }
1886
+
1887
+ export async function forwardEmail(
1888
+ token: string,
1889
+ messageId: string,
1890
+ toRecipients: string[],
1891
+ comment?: string,
1892
+ mailbox?: string
1893
+ ): Promise<OwaResponse<void>> {
1894
+ try {
1895
+ const resolved = await resolveMessageForWrite(token, messageId, mailbox);
1896
+ if (!resolved.ok || !resolved.data) {
1897
+ return resolved as OwaResponse<void>;
1898
+ }
1899
+ const { id: refId, changeKey: refCk } = resolved.data;
1900
+
1901
+ const toXml = toRecipients
1902
+ .map((e) => `<t:Mailbox><t:EmailAddress>${xmlEscape(e)}</t:EmailAddress></t:Mailbox>`)
1903
+ .join('');
1904
+
1905
+ const envelope = soapEnvelope(`
1906
+ <m:CreateItem MessageDisposition="SendAndSaveCopy">
1907
+ <m:Items>
1908
+ <t:ForwardItem>
1909
+ ${referenceItemIdXml(refId, refCk)}
1910
+ <t:ToRecipients>${toXml}</t:ToRecipients>
1911
+ ${comment ? `<t:NewBodyContent BodyType="Text">${xmlEscape(comment)}</t:NewBodyContent>` : ''}
1912
+ </t:ForwardItem>
1913
+ </m:Items>
1914
+ </m:CreateItem>`);
1915
+
1916
+ await callEws(token, envelope, mailbox);
1917
+ return { ok: true, status: 200 };
1918
+ } catch (err) {
1919
+ return ewsError(err);
1920
+ }
1921
+ }
1922
+
1923
+ /** Create a forward draft (same as forward, but SaveOnly). Use addAttachmentToDraft / updateDraft, then sendDraftById. */
1924
+ export async function forwardEmailDraft(
1925
+ token: string,
1926
+ messageId: string,
1927
+ toRecipients: string[],
1928
+ comment?: string,
1929
+ mailbox?: string
1930
+ ): Promise<OwaResponse<{ draftId: string }>> {
1931
+ try {
1932
+ const resolved = await resolveMessageForWrite(token, messageId, mailbox);
1933
+ if (!resolved.ok || !resolved.data) {
1934
+ return resolved as unknown as OwaResponse<{ draftId: string }>;
1935
+ }
1936
+ const { id: refId, changeKey: refCk } = resolved.data;
1937
+
1938
+ const toXml = toRecipients
1939
+ .map((e) => `<t:Mailbox><t:EmailAddress>${xmlEscape(e)}</t:EmailAddress></t:Mailbox>`)
1940
+ .join('');
1941
+
1942
+ const envelope = soapEnvelope(`
1943
+ <m:CreateItem MessageDisposition="SaveOnly">
1944
+ <m:Items>
1945
+ <t:ForwardItem>
1946
+ ${referenceItemIdXml(refId, refCk)}
1947
+ <t:ToRecipients>${toXml}</t:ToRecipients>
1948
+ ${comment ? `<t:NewBodyContent BodyType="Text">${xmlEscape(comment)}</t:NewBodyContent>` : ''}
1949
+ </t:ForwardItem>
1950
+ </m:Items>
1951
+ </m:CreateItem>`);
1952
+
1953
+ const xml = await callEws(token, envelope, mailbox);
1954
+ const draftId = extractAttribute(xml, 'ItemId', 'Id');
1955
+ if (!draftId?.trim()) {
1956
+ return {
1957
+ ok: false,
1958
+ status: 400,
1959
+ error: { code: 'EWS_ERROR', message: 'Forward draft response did not include an item id' }
1960
+ };
1961
+ }
1962
+ return ewsResult({ draftId });
1963
+ } catch (err) {
1964
+ return ewsError(err);
1965
+ }
1966
+ }
1967
+
1968
+ export async function updateEmail(
1969
+ token: string,
1970
+ messageId: string,
1971
+ updates: {
1972
+ IsRead?: boolean;
1973
+ Sensitivity?: 'Normal' | 'Personal' | 'Private' | 'Confidential';
1974
+ /** Replace categories; omit to leave unchanged. Use empty array + clearCategories or use clearCategories only. */
1975
+ categories?: string[];
1976
+ /** When true, remove all categories (DeleteItemField). Ignored if categories is provided. */
1977
+ clearCategories?: boolean;
1978
+ Flag?: {
1979
+ FlagStatus: 'NotFlagged' | 'Flagged' | 'Complete';
1980
+ StartDate?: { DateTime: string; TimeZone: string };
1981
+ DueDate?: { DateTime: string; TimeZone: string };
1982
+ };
1983
+ },
1984
+ mailbox?: string
1985
+ ): Promise<OwaResponse<EmailMessage>> {
1986
+ try {
1987
+ const setFields: string[] = [];
1988
+ const deleteFields: string[] = [];
1989
+
1990
+ if (updates.IsRead !== undefined) {
1991
+ setFields.push(`
1992
+ <t:SetItemField>
1993
+ <t:FieldURI FieldURI="message:IsRead" />
1994
+ <t:Message><t:IsRead>${updates.IsRead}</t:IsRead></t:Message>
1995
+ </t:SetItemField>`);
1996
+ }
1997
+
1998
+ if (updates.Sensitivity !== undefined) {
1999
+ setFields.push(`
2000
+ <t:SetItemField>
2001
+ <t:FieldURI FieldURI="item:Sensitivity" />
2002
+ <t:Message><t:Sensitivity>${xmlEscape(updates.Sensitivity)}</t:Sensitivity></t:Message>
2003
+ </t:SetItemField>`);
2004
+ }
2005
+
2006
+ if (updates.categories !== undefined) {
2007
+ if (updates.categories.length === 0) {
2008
+ deleteFields.push(`<t:DeleteItemField><t:FieldURI FieldURI="item:Categories" /></t:DeleteItemField>`);
2009
+ } else {
2010
+ setFields.push(`
2011
+ <t:SetItemField>
2012
+ <t:FieldURI FieldURI="item:Categories" />
2013
+ <t:Message><t:Categories>${updates.categories.map((c) => `<t:String>${xmlEscape(c)}</t:String>`).join('')}</t:Categories></t:Message>
2014
+ </t:SetItemField>`);
2015
+ }
2016
+ } else if (updates.clearCategories) {
2017
+ deleteFields.push(`<t:DeleteItemField><t:FieldURI FieldURI="item:Categories" /></t:DeleteItemField>`);
2018
+ }
2019
+
2020
+ if (updates.Flag) {
2021
+ if (updates.Flag.StartDate?.TimeZone || updates.Flag.DueDate?.TimeZone) {
2022
+ console.warn('Warning: TimeZone property in Flag dates is ignored by EWS.');
2023
+ }
2024
+ let flagXml = `<t:FlagStatus>${xmlEscape(updates.Flag.FlagStatus)}</t:FlagStatus>`;
2025
+ if (updates.Flag.StartDate) {
2026
+ flagXml += `<t:StartDate>${xmlEscape(updates.Flag.StartDate.DateTime)}</t:StartDate>`;
2027
+ }
2028
+ if (updates.Flag.DueDate) {
2029
+ flagXml += `<t:DueDate>${xmlEscape(updates.Flag.DueDate.DateTime)}</t:DueDate>`;
2030
+ }
2031
+ setFields.push(`
2032
+ <t:SetItemField>
2033
+ <t:FieldURI FieldURI="item:Flag" />
2034
+ <t:Message><t:Flag>${flagXml}</t:Flag></t:Message>
2035
+ </t:SetItemField>`);
2036
+ }
2037
+
2038
+ const allUpdates = [...setFields, ...deleteFields];
2039
+ if (allUpdates.length === 0) {
2040
+ return { ok: false, status: 400, error: { code: 'NO_UPDATES', message: 'No fields to update' } };
2041
+ }
2042
+
2043
+ const resolved = await resolveMessageForWrite(token, messageId, mailbox);
2044
+ if (!resolved.ok || !resolved.data) {
2045
+ return resolved as unknown as OwaResponse<EmailMessage>;
2046
+ }
2047
+ const { id: msgId, changeKey: msgCk } = resolved.data;
2048
+
2049
+ const envelope = soapEnvelope(`
2050
+ <m:UpdateItem ConflictResolution="AlwaysOverwrite" MessageDisposition="SaveOnly" SuppressReadReceipts="true">
2051
+ <m:ItemChanges>
2052
+ <t:ItemChange>
2053
+ ${itemIdXml(msgId, msgCk)}
2054
+ <t:Updates>
2055
+ ${allUpdates.join('')}
2056
+ </t:Updates>
2057
+ </t:ItemChange>
2058
+ </m:ItemChanges>
2059
+ </m:UpdateItem>`);
2060
+
2061
+ const xml = await callEws(token, envelope, mailbox);
2062
+ const newId = extractAttribute(xml, 'ItemId', 'Id') || msgId;
2063
+ return ewsResult({ Id: newId } as EmailMessage);
2064
+ } catch (err) {
2065
+ return ewsError(err);
2066
+ }
2067
+ }
2068
+
2069
+ export async function moveEmail(
2070
+ token: string,
2071
+ messageId: string,
2072
+ destinationFolder: string,
2073
+ mailbox?: string
2074
+ ): Promise<OwaResponse<EmailMessage>> {
2075
+ try {
2076
+ const resolved = await resolveMessageForWrite(token, messageId, mailbox);
2077
+ if (!resolved.ok || !resolved.data) {
2078
+ return resolved as unknown as OwaResponse<EmailMessage>;
2079
+ }
2080
+ const { id: msgId, changeKey: msgCk } = resolved.data;
2081
+
2082
+ const envelope = soapEnvelope(`
2083
+ <m:MoveItem>
2084
+ <m:ToFolderId>
2085
+ ${folderIdXml(destinationFolder, mailbox)}
2086
+ </m:ToFolderId>
2087
+ <m:ItemIds>
2088
+ ${itemIdXml(msgId, msgCk)}
2089
+ </m:ItemIds>
2090
+ </m:MoveItem>`);
2091
+
2092
+ const xml = await callEws(token, envelope, mailbox);
2093
+ const newId = extractAttribute(xml, 'ItemId', 'Id') || msgId;
2094
+ return ewsResult({ Id: newId } as EmailMessage);
2095
+ } catch (err) {
2096
+ return ewsError(err);
2097
+ }
2098
+ }
2099
+
2100
+ // ─── Draft Operations ───
2101
+
2102
+ export async function createDraft(
2103
+ token: string,
2104
+ options: {
2105
+ to?: string[];
2106
+ cc?: string[];
2107
+ subject?: string;
2108
+ body?: string;
2109
+ bodyType?: 'Text' | 'HTML';
2110
+ categories?: string[];
2111
+ /** Save draft in this mailbox's drafts folder */
2112
+ mailbox?: string;
2113
+ }
2114
+ ): Promise<OwaResponse<{ Id: string; ChangeKey?: string }>> {
2115
+ try {
2116
+ const { mailbox } = options;
2117
+
2118
+ const toXml =
2119
+ options.to && options.to.length > 0
2120
+ ? `<t:ToRecipients>${options.to
2121
+ .map((e) => `<t:Mailbox><t:EmailAddress>${xmlEscape(e)}</t:EmailAddress></t:Mailbox>`)
2122
+ .join('')}</t:ToRecipients>`
2123
+ : '';
2124
+
2125
+ const ccXml =
2126
+ options.cc && options.cc.length > 0
2127
+ ? `<t:CcRecipients>${options.cc
2128
+ .map((e) => `<t:Mailbox><t:EmailAddress>${xmlEscape(e)}</t:EmailAddress></t:Mailbox>`)
2129
+ .join('')}</t:CcRecipients>`
2130
+ : '';
2131
+
2132
+ const bodyType = options.bodyType || 'Text';
2133
+
2134
+ const savedDraftFolderXml = mailbox
2135
+ ? `<m:SavedItemFolderId><t:DistinguishedFolderId Id="drafts"><t:Mailbox><t:EmailAddress>${xmlEscape(mailbox)}</t:EmailAddress></t:Mailbox></t:DistinguishedFolderId></m:SavedItemFolderId>`
2136
+ : '';
2137
+
2138
+ const envelope = soapEnvelope(`
2139
+ <m:CreateItem MessageDisposition="SaveOnly">
2140
+ ${savedDraftFolderXml}
2141
+ <m:Items>
2142
+ <t:Message>
2143
+ ${options.subject ? `<t:Subject>${xmlEscape(options.subject)}</t:Subject>` : ''}
2144
+ ${options.body ? `<t:Body BodyType="${bodyType}">${xmlEscape(options.body)}</t:Body>` : ''}
2145
+ ${options.categories && options.categories.length > 0 ? `<t:Categories>${options.categories.map((c) => `<t:String>${xmlEscape(c)}</t:String>`).join('')}</t:Categories>` : ''}
2146
+ ${toXml}
2147
+ ${ccXml}
2148
+ </t:Message>
2149
+ </m:Items>
2150
+ </m:CreateItem>`);
2151
+
2152
+ const xml = await callEws(token, envelope, mailbox);
2153
+ const id = extractAttribute(xml, 'ItemId', 'Id');
2154
+ if (!id?.trim()) {
2155
+ return {
2156
+ ok: false,
2157
+ status: 400,
2158
+ error: { code: 'EWS_ERROR', message: 'Create draft response did not include an item id' }
2159
+ };
2160
+ }
2161
+ const ck = extractAttribute(xml, 'ItemId', 'ChangeKey')?.trim();
2162
+ return ewsResult({ Id: id, ChangeKey: ck || undefined });
2163
+ } catch (err) {
2164
+ return ewsError(err);
2165
+ }
2166
+ }
2167
+
2168
+ export async function updateDraft(
2169
+ token: string,
2170
+ draftId: string,
2171
+ options: {
2172
+ to?: string[];
2173
+ cc?: string[];
2174
+ subject?: string;
2175
+ body?: string;
2176
+ bodyType?: 'Text' | 'HTML';
2177
+ categories?: string[];
2178
+ clearCategories?: boolean;
2179
+ mailbox?: string;
2180
+ }
2181
+ ): Promise<OwaResponse<void>> {
2182
+ try {
2183
+ const setFields: string[] = [];
2184
+ const deleteFields: string[] = [];
2185
+
2186
+ if (options.subject !== undefined) {
2187
+ setFields.push(
2188
+ `<t:SetItemField><t:FieldURI FieldURI="item:Subject" /><t:Message><t:Subject>${xmlEscape(options.subject)}</t:Subject></t:Message></t:SetItemField>`
2189
+ );
2190
+ }
2191
+ if (options.body !== undefined) {
2192
+ const bodyType = options.bodyType || 'Text';
2193
+ setFields.push(
2194
+ `<t:SetItemField><t:FieldURI FieldURI="item:Body" /><t:Message><t:Body BodyType="${bodyType}">${xmlEscape(options.body)}</t:Body></t:Message></t:SetItemField>`
2195
+ );
2196
+ }
2197
+ if (options.to !== undefined) {
2198
+ setFields.push(
2199
+ `<t:SetItemField><t:FieldURI FieldURI="message:ToRecipients" /><t:Message><t:ToRecipients>${options.to
2200
+ .map((e) => `<t:Mailbox><t:EmailAddress>${xmlEscape(e)}</t:EmailAddress></t:Mailbox>`)
2201
+ .join('')}</t:ToRecipients></t:Message></t:SetItemField>`
2202
+ );
2203
+ }
2204
+ if (options.cc !== undefined) {
2205
+ setFields.push(
2206
+ `<t:SetItemField><t:FieldURI FieldURI="message:CcRecipients" /><t:Message><t:CcRecipients>${options.cc
2207
+ .map((e) => `<t:Mailbox><t:EmailAddress>${xmlEscape(e)}</t:EmailAddress></t:Mailbox>`)
2208
+ .join('')}</t:CcRecipients></t:Message></t:SetItemField>`
2209
+ );
2210
+ }
2211
+
2212
+ if (options.categories !== undefined) {
2213
+ if (options.categories.length === 0) {
2214
+ deleteFields.push(`<t:DeleteItemField><t:FieldURI FieldURI="item:Categories" /></t:DeleteItemField>`);
2215
+ } else {
2216
+ setFields.push(`
2217
+ <t:SetItemField>
2218
+ <t:FieldURI FieldURI="item:Categories" />
2219
+ <t:Message><t:Categories>${options.categories.map((c) => `<t:String>${xmlEscape(c)}</t:String>`).join('')}</t:Categories></t:Message>
2220
+ </t:SetItemField>`);
2221
+ }
2222
+ } else if (options.clearCategories) {
2223
+ deleteFields.push(`<t:DeleteItemField><t:FieldURI FieldURI="item:Categories" /></t:DeleteItemField>`);
2224
+ }
2225
+
2226
+ const allUpdates = [...setFields, ...deleteFields];
2227
+ if (allUpdates.length === 0) {
2228
+ return { ok: false, status: 400, error: { code: 'NO_UPDATES', message: 'No fields to update' } };
2229
+ }
2230
+
2231
+ const resolved = await resolveMessageForWrite(token, draftId, options.mailbox);
2232
+ if (!resolved.ok || !resolved.data) {
2233
+ return resolved as OwaResponse<void>;
2234
+ }
2235
+ const { id: did, changeKey: dck } = resolved.data;
2236
+
2237
+ const envelope = soapEnvelope(`
2238
+ <m:UpdateItem ConflictResolution="AlwaysOverwrite" MessageDisposition="SaveOnly">
2239
+ <m:ItemChanges>
2240
+ <t:ItemChange>
2241
+ ${itemIdXml(did, dck)}
2242
+ <t:Updates>${allUpdates.join('')}</t:Updates>
2243
+ </t:ItemChange>
2244
+ </m:ItemChanges>
2245
+ </m:UpdateItem>`);
2246
+
2247
+ await callEws(token, envelope, options.mailbox);
2248
+ return { ok: true, status: 200 };
2249
+ } catch (err) {
2250
+ return ewsError(err);
2251
+ }
2252
+ }
2253
+
2254
+ async function sendItemById(
2255
+ token: string,
2256
+ itemId: string,
2257
+ mailbox?: string,
2258
+ preResolved?: { id: string; changeKey?: string }
2259
+ ): Promise<void> {
2260
+ let sid: string;
2261
+ let sck: string | undefined;
2262
+ if (preResolved?.id?.trim()) {
2263
+ sid = preResolved.id.trim();
2264
+ sck = preResolved.changeKey?.trim() || undefined;
2265
+ } else {
2266
+ const resolved = await resolveMessageForWrite(token, itemId, mailbox);
2267
+ if (!resolved.ok || !resolved.data) {
2268
+ throw new Error(resolved.error?.message || 'Failed to resolve item for send');
2269
+ }
2270
+ sid = resolved.data.id;
2271
+ sck = resolved.data.changeKey;
2272
+ }
2273
+
2274
+ const savedSentXml = mailbox
2275
+ ? `<m:SavedItemFolderId><t:DistinguishedFolderId Id="sentitems"><t:Mailbox><t:EmailAddress>${xmlEscape(mailbox)}</t:EmailAddress></t:Mailbox></t:DistinguishedFolderId></m:SavedItemFolderId>`
2276
+ : `<m:SavedItemFolderId>
2277
+ <t:DistinguishedFolderId Id="sentitems" />
2278
+ </m:SavedItemFolderId>`;
2279
+
2280
+ const envelope = soapEnvelope(`
2281
+ <m:SendItem SaveItemToFolder="true">
2282
+ <m:ItemIds>
2283
+ ${itemIdXml(sid, sck)}
2284
+ </m:ItemIds>
2285
+ ${savedSentXml}
2286
+ </m:SendItem>`);
2287
+ await callEws(token, envelope, mailbox);
2288
+ }
2289
+
2290
+ export async function sendDraftById(token: string, draftId: string, mailbox?: string): Promise<OwaResponse<void>> {
2291
+ try {
2292
+ draftId = requireNonEmpty(draftId, 'draftId');
2293
+ await sendItemById(token, draftId, mailbox);
2294
+ return { ok: true, status: 200 };
2295
+ } catch (err) {
2296
+ return ewsError(err);
2297
+ }
2298
+ }
2299
+
2300
+ export async function deleteDraftById(token: string, draftId: string, mailbox?: string): Promise<OwaResponse<void>> {
2301
+ try {
2302
+ draftId = requireNonEmpty(draftId, 'draftId');
2303
+ const resolved = await resolveMessageForWrite(token, draftId, mailbox);
2304
+ if (!resolved.ok || !resolved.data) {
2305
+ return resolved as OwaResponse<void>;
2306
+ }
2307
+ const { id: did, changeKey: dck } = resolved.data;
2308
+
2309
+ const envelope = soapEnvelope(`
2310
+ <m:DeleteItem DeleteType="HardDelete">
2311
+ <m:ItemIds>
2312
+ ${itemIdXml(did, dck)}
2313
+ </m:ItemIds>
2314
+ </m:DeleteItem>`);
2315
+ await callEws(token, envelope, mailbox);
2316
+ return { ok: true, status: 200 };
2317
+ } catch (err) {
2318
+ return ewsError(err);
2319
+ }
2320
+ }
2321
+
2322
+ type AttachmentParentKind = 'message' | 'calendar';
2323
+
2324
+ async function resolveParentForWrite(
2325
+ token: string,
2326
+ itemId: string,
2327
+ mailbox: string | undefined,
2328
+ kind: AttachmentParentKind
2329
+ ): Promise<OwaResponse<{ id: string; changeKey?: string }>> {
2330
+ if (kind === 'calendar') {
2331
+ return resolveCalendarForWrite(token, itemId, mailbox);
2332
+ }
2333
+ return resolveMessageForWrite(token, itemId, mailbox);
2334
+ }
2335
+
2336
+ async function addAttachmentToItem(
2337
+ token: string,
2338
+ itemId: string,
2339
+ attachment: EmailAttachment,
2340
+ mailbox?: string,
2341
+ preResolved?: { id: string; changeKey?: string },
2342
+ parentKind: AttachmentParentKind = 'message'
2343
+ ): Promise<{ id: string; changeKey?: string }> {
2344
+ let pid: string;
2345
+ let pck: string | undefined;
2346
+ if (preResolved?.id?.trim()) {
2347
+ pid = preResolved.id.trim();
2348
+ pck = preResolved.changeKey?.trim() || undefined;
2349
+ } else {
2350
+ const resolved = await resolveParentForWrite(token, itemId, mailbox, parentKind);
2351
+ if (!resolved.ok || !resolved.data) {
2352
+ throw new Error(resolved.error?.message || 'Failed to resolve parent item for attachment');
2353
+ }
2354
+ pid = resolved.data.id;
2355
+ pck = resolved.data.changeKey;
2356
+ }
2357
+ const parentCkAttr = pck ? ` ChangeKey="${xmlEscape(pck)}"` : '';
2358
+
2359
+ const envelope = soapEnvelope(`
2360
+ <m:CreateAttachment>
2361
+ <m:ParentItemId Id="${xmlEscape(pid)}"${parentCkAttr} />
2362
+ <m:Attachments>
2363
+ <t:FileAttachment>
2364
+ <t:Name>${xmlEscape(attachment.name)}</t:Name>
2365
+ <t:ContentType>${xmlEscape(attachment.contentType)}</t:ContentType>
2366
+ <t:Content>${attachment.contentBytes}</t:Content>
2367
+ </t:FileAttachment>
2368
+ </m:Attachments>
2369
+ </m:CreateAttachment>`);
2370
+ const xml = await callEws(token, envelope, mailbox);
2371
+
2372
+ const rootId = extractAttribute(xml, 'RootItemId', 'Id')?.trim();
2373
+ const rootCk = extractAttribute(xml, 'RootItemId', 'ChangeKey')?.trim();
2374
+ if (rootId) {
2375
+ return { id: rootId, changeKey: rootCk || pck };
2376
+ }
2377
+ return { id: pid, changeKey: pck };
2378
+ }
2379
+
2380
+ async function addReferenceAttachmentToItem(
2381
+ token: string,
2382
+ itemId: string,
2383
+ ref: ReferenceAttachmentInput,
2384
+ mailbox?: string,
2385
+ preResolved?: { id: string; changeKey?: string },
2386
+ parentKind: AttachmentParentKind = 'message'
2387
+ ): Promise<{ id: string; changeKey?: string }> {
2388
+ let pid: string;
2389
+ let pck: string | undefined;
2390
+ if (preResolved?.id?.trim()) {
2391
+ pid = preResolved.id.trim();
2392
+ pck = preResolved.changeKey?.trim() || undefined;
2393
+ } else {
2394
+ const resolved = await resolveParentForWrite(token, itemId, mailbox, parentKind);
2395
+ if (!resolved.ok || !resolved.data) {
2396
+ throw new Error(resolved.error?.message || 'Failed to resolve parent item for reference attachment');
2397
+ }
2398
+ pid = resolved.data.id;
2399
+ pck = resolved.data.changeKey;
2400
+ }
2401
+ const parentCkAttr = pck ? ` ChangeKey="${xmlEscape(pck)}"` : '';
2402
+ const ct = ref.contentType?.trim() || 'text/html';
2403
+
2404
+ const envelope = soapEnvelope(`
2405
+ <m:CreateAttachment>
2406
+ <m:ParentItemId Id="${xmlEscape(pid)}"${parentCkAttr} />
2407
+ <m:Attachments>
2408
+ <t:ReferenceAttachment>
2409
+ <t:Name>${xmlEscape(ref.name)}</t:Name>
2410
+ <t:ContentType>${xmlEscape(ct)}</t:ContentType>
2411
+ <t:AttachLongPathName>${xmlEscape(ref.url)}</t:AttachLongPathName>
2412
+ </t:ReferenceAttachment>
2413
+ </m:Attachments>
2414
+ </m:CreateAttachment>`);
2415
+ const xml = await callEws(token, envelope, mailbox);
2416
+
2417
+ const rootId = extractAttribute(xml, 'RootItemId', 'Id')?.trim();
2418
+ const rootCk = extractAttribute(xml, 'RootItemId', 'ChangeKey')?.trim();
2419
+ if (rootId) {
2420
+ return { id: rootId, changeKey: rootCk || pck };
2421
+ }
2422
+ return { id: pid, changeKey: pck };
2423
+ }
2424
+
2425
+ /** Add file and/or reference attachments to a calendar event (organizer item id or occurrence id). */
2426
+ export async function addCalendarEventAttachments(
2427
+ token: string,
2428
+ eventId: string,
2429
+ mailbox: string | undefined,
2430
+ fileAttachments: EmailAttachment[],
2431
+ referenceAttachments: ReferenceAttachmentInput[]
2432
+ ): Promise<OwaResponse<void>> {
2433
+ try {
2434
+ if (fileAttachments.length === 0 && referenceAttachments.length === 0) {
2435
+ return { ok: true, status: 200 };
2436
+ }
2437
+ const resolved = await resolveCalendarForWrite(token, eventId, mailbox);
2438
+ if (!resolved.ok || !resolved.data) {
2439
+ return resolved as OwaResponse<void>;
2440
+ }
2441
+ let item = resolved.data;
2442
+ for (const att of fileAttachments) {
2443
+ item = await addAttachmentToItem(token, item.id, att, mailbox, item, 'calendar');
2444
+ }
2445
+ for (const ref of referenceAttachments) {
2446
+ item = await addReferenceAttachmentToItem(token, item.id, ref, mailbox, item, 'calendar');
2447
+ }
2448
+ return { ok: true, status: 200 };
2449
+ } catch (err) {
2450
+ return ewsError(err);
2451
+ }
2452
+ }
2453
+
2454
+ export async function addAttachmentToDraft(
2455
+ token: string,
2456
+ draftId: string,
2457
+ attachment: { name: string; contentType: string; contentBytes: string },
2458
+ mailbox?: string
2459
+ ): Promise<OwaResponse<void>> {
2460
+ try {
2461
+ await addAttachmentToItem(token, draftId, attachment, mailbox, undefined, 'message');
2462
+ return { ok: true, status: 200 };
2463
+ } catch (err) {
2464
+ return ewsError(err);
2465
+ }
2466
+ }
2467
+
2468
+ export async function addReferenceAttachmentToDraft(
2469
+ token: string,
2470
+ draftId: string,
2471
+ ref: ReferenceAttachmentInput,
2472
+ mailbox?: string
2473
+ ): Promise<OwaResponse<void>> {
2474
+ try {
2475
+ await addReferenceAttachmentToItem(token, draftId, ref, mailbox, undefined, 'message');
2476
+ return { ok: true, status: 200 };
2477
+ } catch (err) {
2478
+ return ewsError(err);
2479
+ }
2480
+ }
2481
+
2482
+ // ─── Folder Operations ───
2483
+
2484
+ export async function getMailFolders(
2485
+ token: string,
2486
+ parentFolderId?: string,
2487
+ mailbox?: string
2488
+ ): Promise<OwaResponse<MailFolderListResponse>> {
2489
+ try {
2490
+ const parentXml = parentFolderId
2491
+ ? `<t:FolderId Id="${xmlEscape(parentFolderId)}" />`
2492
+ : mailParentFolderXml(undefined, mailbox);
2493
+
2494
+ const envelope = soapEnvelope(`
2495
+ <m:FindFolder Traversal="Shallow">
2496
+ <m:FolderShape>
2497
+ <t:BaseShape>Default</t:BaseShape>
2498
+ <t:AdditionalProperties>
2499
+ <t:FieldURI FieldURI="folder:ChildFolderCount" />
2500
+ <t:FieldURI FieldURI="folder:UnreadCount" />
2501
+ <t:FieldURI FieldURI="folder:TotalCount" />
2502
+ </t:AdditionalProperties>
2503
+ </m:FolderShape>
2504
+ <m:ParentFolderIds>
2505
+ ${parentXml}
2506
+ </m:ParentFolderIds>
2507
+ </m:FindFolder>`);
2508
+
2509
+ const xml = await callEws(token, envelope, mailbox);
2510
+ const blocks = extractBlocks(xml, 'Folder');
2511
+ const folders = blocks.map(parseFolder);
2512
+
2513
+ return ewsResult({ value: folders });
2514
+ } catch (err) {
2515
+ return ewsError(err);
2516
+ }
2517
+ }
2518
+
2519
+ export async function createMailFolder(
2520
+ token: string,
2521
+ displayName: string,
2522
+ parentFolderId?: string,
2523
+ mailbox?: string
2524
+ ): Promise<OwaResponse<MailFolder>> {
2525
+ try {
2526
+ const parentXml = parentFolderId
2527
+ ? `<t:FolderId Id="${xmlEscape(parentFolderId)}" />`
2528
+ : mailParentFolderXml(undefined, mailbox);
2529
+
2530
+ const envelope = soapEnvelope(`
2531
+ <m:CreateFolder>
2532
+ <m:ParentFolderId>
2533
+ ${parentXml}
2534
+ </m:ParentFolderId>
2535
+ <m:Folders>
2536
+ <t:Folder>
2537
+ <t:DisplayName>${xmlEscape(displayName)}</t:DisplayName>
2538
+ </t:Folder>
2539
+ </m:Folders>
2540
+ </m:CreateFolder>`);
2541
+
2542
+ const xml = await callEws(token, envelope, mailbox);
2543
+ const block = extractBlocks(xml, 'Folder')[0] || '';
2544
+
2545
+ return ewsResult({
2546
+ Id: extractAttribute(block, 'FolderId', 'Id'),
2547
+ DisplayName: displayName,
2548
+ ChildFolderCount: 0,
2549
+ UnreadItemCount: 0,
2550
+ TotalItemCount: 0
2551
+ });
2552
+ } catch (err) {
2553
+ return ewsError(err);
2554
+ }
2555
+ }
2556
+
2557
+ export async function updateMailFolder(
2558
+ token: string,
2559
+ folderId: string,
2560
+ displayName: string,
2561
+ mailbox?: string
2562
+ ): Promise<OwaResponse<MailFolder>> {
2563
+ try {
2564
+ const envelope = soapEnvelope(`
2565
+ <m:UpdateFolder>
2566
+ <m:FolderChanges>
2567
+ <t:FolderChange>
2568
+ <t:FolderId Id="${xmlEscape(folderId)}" />
2569
+ <t:Updates>
2570
+ <t:SetFolderField>
2571
+ <t:FieldURI FieldURI="folder:DisplayName" />
2572
+ <t:Folder>
2573
+ <t:DisplayName>${xmlEscape(displayName)}</t:DisplayName>
2574
+ </t:Folder>
2575
+ </t:SetFolderField>
2576
+ </t:Updates>
2577
+ </t:FolderChange>
2578
+ </m:FolderChanges>
2579
+ </m:UpdateFolder>`);
2580
+
2581
+ await callEws(token, envelope, mailbox);
2582
+
2583
+ return ewsResult({
2584
+ Id: folderId,
2585
+ DisplayName: displayName,
2586
+ ChildFolderCount: 0,
2587
+ UnreadItemCount: 0,
2588
+ TotalItemCount: 0
2589
+ });
2590
+ } catch (err) {
2591
+ return ewsError(err);
2592
+ }
2593
+ }
2594
+
2595
+ export async function deleteMailFolder(token: string, folderId: string, mailbox?: string): Promise<OwaResponse<void>> {
2596
+ try {
2597
+ const envelope = soapEnvelope(`
2598
+ <m:DeleteFolder DeleteType="MoveToDeletedItems">
2599
+ <m:FolderIds>
2600
+ <t:FolderId Id="${xmlEscape(folderId)}" />
2601
+ </m:FolderIds>
2602
+ </m:DeleteFolder>`);
2603
+
2604
+ await callEws(token, envelope, mailbox);
2605
+ return { ok: true, status: 200 };
2606
+ } catch (err) {
2607
+ return ewsError(err);
2608
+ }
2609
+ }
2610
+
2611
+ // ─── Attachment Operations ───
2612
+
2613
+ export async function getAttachments(
2614
+ token: string,
2615
+ itemId: string,
2616
+ mailbox?: string
2617
+ ): Promise<OwaResponse<AttachmentListResponse>> {
2618
+ try {
2619
+ const envelope = soapEnvelope(`
2620
+ <m:GetItem>
2621
+ <m:ItemShape>
2622
+ <t:BaseShape>IdOnly</t:BaseShape>
2623
+ <t:AdditionalProperties>
2624
+ <t:FieldURI FieldURI="item:Attachments" />
2625
+ </t:AdditionalProperties>
2626
+ </m:ItemShape>
2627
+ <m:ItemIds>
2628
+ <t:ItemId Id="${xmlEscape(itemId)}" />
2629
+ </m:ItemIds>
2630
+ </m:GetItem>`);
2631
+
2632
+ const xml = await callEws(token, envelope, mailbox);
2633
+ const attachments: Attachment[] = [];
2634
+
2635
+ for (const ab of extractBlocks(xml, 'FileAttachment')) {
2636
+ attachments.push({
2637
+ Id: extractAttribute(ab, 'AttachmentId', 'Id') || '',
2638
+ Name: extractTag(ab, 'Name'),
2639
+ ContentType: extractTag(ab, 'ContentType') || 'application/octet-stream',
2640
+ Size: parseInt(extractTag(ab, 'Size') || '0', 10),
2641
+ IsInline: extractTag(ab, 'IsInline').toLowerCase() === 'true',
2642
+ ContentId: extractTag(ab, 'ContentId') || undefined,
2643
+ Kind: 'file'
2644
+ });
2645
+ }
2646
+
2647
+ for (const ab of extractBlocks(xml, 'ReferenceAttachment')) {
2648
+ attachments.push({
2649
+ Id: extractAttribute(ab, 'AttachmentId', 'Id') || '',
2650
+ Name: extractTag(ab, 'Name'),
2651
+ ContentType: extractTag(ab, 'ContentType') || 'text/html',
2652
+ Size: parseInt(extractTag(ab, 'Size') || '0', 10),
2653
+ IsInline: false,
2654
+ Kind: 'reference',
2655
+ AttachLongPathName: extractTag(ab, 'AttachLongPathName') || undefined
2656
+ });
2657
+ }
2658
+
2659
+ return ewsResult({ value: attachments });
2660
+ } catch (err) {
2661
+ return ewsError(err);
2662
+ }
2663
+ }
2664
+
2665
+ // Note: parent item id is intentionally unused. EWS AttachmentId values are globally
2666
+ // unique within the mailbox — no parent item reference needed.
2667
+ export async function getAttachment(
2668
+ token: string,
2669
+ _parentItemId: string,
2670
+ attachmentId: string,
2671
+ mailbox?: string
2672
+ ): Promise<OwaResponse<Attachment>> {
2673
+ try {
2674
+ const envelope = soapEnvelope(`
2675
+ <m:GetAttachment>
2676
+ <m:AttachmentIds>
2677
+ <t:AttachmentId Id="${xmlEscape(attachmentId)}" />
2678
+ </m:AttachmentIds>
2679
+ </m:GetAttachment>`);
2680
+
2681
+ const xml = await callEws(token, envelope, mailbox);
2682
+ const fileBlock = extractBlocks(xml, 'FileAttachment')[0];
2683
+ if (fileBlock) {
2684
+ return ewsResult({
2685
+ Id: extractAttribute(fileBlock, 'AttachmentId', 'Id'),
2686
+ Name: extractTag(fileBlock, 'Name'),
2687
+ ContentType: extractTag(fileBlock, 'ContentType') || 'application/octet-stream',
2688
+ Size: parseInt(extractTag(fileBlock, 'Size') || '0', 10),
2689
+ IsInline: extractTag(fileBlock, 'IsInline').toLowerCase() === 'true',
2690
+ ContentId: extractTag(fileBlock, 'ContentId') || undefined,
2691
+ ContentBytes: extractTag(fileBlock, 'Content') || undefined,
2692
+ Kind: 'file'
2693
+ });
2694
+ }
2695
+
2696
+ const refBlock = extractBlocks(xml, 'ReferenceAttachment')[0];
2697
+ if (refBlock) {
2698
+ return ewsResult({
2699
+ Id: extractAttribute(refBlock, 'AttachmentId', 'Id'),
2700
+ Name: extractTag(refBlock, 'Name'),
2701
+ ContentType: extractTag(refBlock, 'ContentType') || 'text/html',
2702
+ Size: parseInt(extractTag(refBlock, 'Size') || '0', 10),
2703
+ IsInline: false,
2704
+ Kind: 'reference',
2705
+ AttachLongPathName: extractTag(refBlock, 'AttachLongPathName') || undefined
2706
+ });
2707
+ }
2708
+
2709
+ return { ok: false, status: 404, error: { code: 'NOT_FOUND', message: 'Attachment not found' } };
2710
+ } catch (err) {
2711
+ return ewsError(err);
2712
+ }
2713
+ }
2714
+
2715
+ // ─── People & Rooms ───
2716
+
2717
+ export async function resolveNames(
2718
+ token: string,
2719
+ query: string
2720
+ ): Promise<
2721
+ OwaResponse<
2722
+ Array<{
2723
+ DisplayName?: string;
2724
+ EmailAddress?: string;
2725
+ JobTitle?: string;
2726
+ Department?: string;
2727
+ OfficeLocation?: string;
2728
+ MailboxType?: string;
2729
+ }>
2730
+ >
2731
+ > {
2732
+ try {
2733
+ const envelope = soapEnvelope(`
2734
+ <m:ResolveNames ReturnFullContactData="true" SearchScope="ActiveDirectoryContacts">
2735
+ <m:UnresolvedEntry>${xmlEscape(query)}</m:UnresolvedEntry>
2736
+ </m:ResolveNames>`);
2737
+
2738
+ const xml = await callEws(token, envelope);
2739
+ const resolutions = extractBlocks(xml, 'Resolution');
2740
+
2741
+ const results = resolutions.map((block) => {
2742
+ const mailbox = extractSelfClosingOrBlock(block, 'Mailbox');
2743
+ const contact = extractSelfClosingOrBlock(block, 'Contact');
2744
+
2745
+ return {
2746
+ DisplayName: extractTag(mailbox, 'Name') || extractTag(contact, 'DisplayName'),
2747
+ EmailAddress: extractTag(mailbox, 'EmailAddress'),
2748
+ JobTitle: extractTag(contact, 'JobTitle') || undefined,
2749
+ Department: extractTag(contact, 'Department') || undefined,
2750
+ OfficeLocation: extractTag(contact, 'OfficeLocation') || undefined,
2751
+ MailboxType: extractTag(mailbox, 'MailboxType') || undefined
2752
+ };
2753
+ });
2754
+
2755
+ return ewsResult(results);
2756
+ } catch (err) {
2757
+ return ewsError(err);
2758
+ }
2759
+ }
2760
+
2761
+ export async function getRoomLists(token: string): Promise<OwaResponse<RoomList[]>> {
2762
+ try {
2763
+ const envelope = soapEnvelope('<m:GetRoomLists />');
2764
+ const xml = await callEws(token, envelope);
2765
+ const addresses = extractBlocks(xml, 'Address');
2766
+
2767
+ const lists: RoomList[] = addresses.map((block) => ({
2768
+ Name: extractTag(block, 'Name'),
2769
+ Address: extractTag(block, 'EmailAddress')
2770
+ }));
2771
+
2772
+ return ewsResult(lists);
2773
+ } catch (err) {
2774
+ return ewsError(err);
2775
+ }
2776
+ }
2777
+
2778
+ export async function getRooms(token: string, roomListAddress?: string): Promise<OwaResponse<Room[]>> {
2779
+ try {
2780
+ if (roomListAddress) {
2781
+ const envelope = soapEnvelope(`
2782
+ <m:GetRooms>
2783
+ <m:RoomList>
2784
+ <t:EmailAddress>${xmlEscape(roomListAddress)}</t:EmailAddress>
2785
+ </m:RoomList>
2786
+ </m:GetRooms>`);
2787
+ const xml = await callEws(token, envelope);
2788
+ const rooms = extractBlocks(xml, 'Room').map((block) => {
2789
+ const id = extractSelfClosingOrBlock(block, 'Id');
2790
+ return {
2791
+ Name: extractTag(id, 'Name'),
2792
+ Address: extractTag(id, 'EmailAddress')
2793
+ };
2794
+ });
2795
+ return ewsResult(rooms);
2796
+ }
2797
+
2798
+ // No room list specified: get all room lists first, then rooms from each
2799
+ const listsResult = await getRoomLists(token);
2800
+ if (!listsResult.ok || !listsResult.data || listsResult.data.length === 0) {
2801
+ return ewsResult([]);
2802
+ }
2803
+
2804
+ const results = await Promise.all(listsResult.data.map((list) => getRooms(token, list.Address)));
2805
+
2806
+ const allRooms: Room[] = [];
2807
+ for (const roomsResult of results) {
2808
+ if (roomsResult.ok && roomsResult.data) {
2809
+ allRooms.push(...roomsResult.data);
2810
+ }
2811
+ }
2812
+
2813
+ return ewsResult(allRooms);
2814
+ } catch (err) {
2815
+ return ewsError(err);
2816
+ }
2817
+ }
2818
+
2819
+ export async function searchRooms(token: string, query: string = 'room'): Promise<OwaResponse<Room[]>> {
2820
+ // Use ResolveNames to find rooms by name
2821
+ try {
2822
+ const result = await resolveNames(token, query);
2823
+ if (!result.ok || !result.data) return ewsResult([]);
2824
+
2825
+ // Try to filter to rooms (MailboxType might indicate this)
2826
+ const rooms: Room[] = result.data
2827
+ .filter((r) => r.EmailAddress)
2828
+ .map((r) => ({
2829
+ Name: r.DisplayName || '',
2830
+ Address: r.EmailAddress || ''
2831
+ }));
2832
+
2833
+ return ewsResult(rooms);
2834
+ } catch (err) {
2835
+ return ewsError(err);
2836
+ }
2837
+ }
2838
+
2839
+ // ─── Availability ───
2840
+
2841
+ export async function getScheduleViaOutlook(
2842
+ token: string,
2843
+ emails: string[],
2844
+ startDateTime: string,
2845
+ endDateTime: string,
2846
+ durationMinutes: number = 30,
2847
+ timeZone?: string,
2848
+ /** Optional anchor for EWS (e.g. shared mailbox context) */
2849
+ mailbox?: string
2850
+ ): Promise<OwaResponse<ScheduleInfo[]>> {
2851
+ try {
2852
+ if (!timeZone) {
2853
+ timeZone = 'UTC';
2854
+ }
2855
+
2856
+ const suggestStartD = new Date(startDateTime);
2857
+ suggestStartD.setUTCHours(0, 0, 0, 0);
2858
+ const suggestEndD = new Date(endDateTime);
2859
+ suggestEndD.setUTCHours(0, 0, 0, 0);
2860
+ suggestEndD.setUTCDate(suggestEndD.getUTCDate() + 1);
2861
+ const pad = (n: number) => String(n).padStart(2, '0');
2862
+ const toMidnight = (d: Date) => `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T00:00:00`;
2863
+ const suggestStart = toMidnight(suggestStartD);
2864
+ const suggestEnd = toMidnight(suggestEndD);
2865
+
2866
+ const mailboxDataXml = emails
2867
+ .map(
2868
+ (email) => `
2869
+ <t:MailboxData>
2870
+ <t:Email><t:Address>${xmlEscape(email)}</t:Address></t:Email>
2871
+ <t:AttendeeType>Required</t:AttendeeType>
2872
+ </t:MailboxData>`
2873
+ )
2874
+ .join('');
2875
+
2876
+ const envelope = soapEnvelope(
2877
+ `
2878
+ <m:GetUserAvailabilityRequest>
2879
+ <t:TimeZone>
2880
+ <t:Bias>0</t:Bias>
2881
+ <t:StandardTime>
2882
+ <t:Bias>0</t:Bias>
2883
+ <t:Time>02:00:00</t:Time>
2884
+ <t:DayOrder>1</t:DayOrder>
2885
+ <t:Month>1</t:Month>
2886
+ <t:DayOfWeek>Sunday</t:DayOfWeek>
2887
+ </t:StandardTime>
2888
+ <t:DaylightTime>
2889
+ <t:Bias>0</t:Bias>
2890
+ <t:Time>02:00:00</t:Time>
2891
+ <t:DayOrder>1</t:DayOrder>
2892
+ <t:Month>1</t:Month>
2893
+ <t:DayOfWeek>Sunday</t:DayOfWeek>
2894
+ </t:DaylightTime>
2895
+ </t:TimeZone>
2896
+ <m:MailboxDataArray>
2897
+ ${mailboxDataXml}
2898
+ </m:MailboxDataArray>
2899
+ <t:FreeBusyViewOptions>
2900
+ <t:TimeWindow>
2901
+ <t:StartTime>${xmlEscape(suggestStart)}</t:StartTime>
2902
+ <t:EndTime>${xmlEscape(suggestEnd)}</t:EndTime>
2903
+ </t:TimeWindow>
2904
+ <t:MergedFreeBusyIntervalInMinutes>${durationMinutes}</t:MergedFreeBusyIntervalInMinutes>
2905
+ <t:RequestedView>DetailedMerged</t:RequestedView>
2906
+ </t:FreeBusyViewOptions>
2907
+ <t:SuggestionsViewOptions>
2908
+ <t:GoodThreshold>25</t:GoodThreshold>
2909
+ <t:MaximumResultsByDay>10</t:MaximumResultsByDay>
2910
+ <t:MaximumNonWorkHourResultsByDay>0</t:MaximumNonWorkHourResultsByDay>
2911
+ <t:MeetingDurationInMinutes>${durationMinutes}</t:MeetingDurationInMinutes>
2912
+ <t:DetailedSuggestionsWindow>
2913
+ <t:StartTime>${xmlEscape(suggestStart)}</t:StartTime>
2914
+ <t:EndTime>${xmlEscape(suggestEnd)}</t:EndTime>
2915
+ </t:DetailedSuggestionsWindow>
2916
+ </t:SuggestionsViewOptions>
2917
+ </m:GetUserAvailabilityRequest>`,
2918
+ `<t:TimeZoneContext><t:TimeZoneDefinition Id="${xmlEscape(timeZone)}"/></t:TimeZoneContext>`
2919
+ );
2920
+
2921
+ const xml = await callEws(token, envelope, mailbox);
2922
+
2923
+ // Parse suggestions into free slots
2924
+ const schedules: ScheduleInfo[] = emails.map((email) => ({
2925
+ scheduleId: email,
2926
+ availabilityView: '',
2927
+ scheduleItems: []
2928
+ }));
2929
+
2930
+ // Extract suggestions
2931
+ const suggestions = extractBlocks(xml, 'Suggestion');
2932
+ const freeSlots: Array<{ start: string; end: string }> = [];
2933
+
2934
+ for (const suggestion of suggestions) {
2935
+ const meetingTime = extractTag(suggestion, 'MeetingTime');
2936
+ if (meetingTime) {
2937
+ const startTime = new Date(meetingTime);
2938
+ const endTime = new Date(startTime.getTime() + durationMinutes * 60 * 1000);
2939
+ freeSlots.push({
2940
+ start: startTime.toISOString(),
2941
+ end: endTime.toISOString()
2942
+ });
2943
+ }
2944
+ }
2945
+
2946
+ // Apply free slots to all schedules
2947
+ for (const schedule of schedules) {
2948
+ schedule.scheduleItems = freeSlots.map((slot) => ({
2949
+ status: 'Free',
2950
+ start: { dateTime: slot.start, timeZone: 'UTC' },
2951
+ end: { dateTime: slot.end, timeZone: 'UTC' }
2952
+ }));
2953
+ }
2954
+
2955
+ if (freeSlots.length === 0) {
2956
+ // Fall back to FreeBusyView data from the response XML instead of creating fake "Busy" entries
2957
+ const freeBusyResponses = extractBlocks(xml, 'FreeBusyResponse');
2958
+ const reqStart = new Date(startDateTime).getTime();
2959
+ const reqEnd = new Date(endDateTime).getTime();
2960
+
2961
+ for (let i = 0; i < freeBusyResponses.length && i < schedules.length; i++) {
2962
+ const resp = freeBusyResponses[i];
2963
+ const schedule = schedules[i];
2964
+ const calendarEvents = extractBlocks(resp, 'CalendarEvent');
2965
+ const items: ScheduleInfo['scheduleItems'] = [];
2966
+
2967
+ for (const event of calendarEvents) {
2968
+ const busyType = extractTag(event, 'BusyType');
2969
+ const startTime = extractTag(event, 'StartTime');
2970
+ const endTime = extractTag(event, 'EndTime');
2971
+
2972
+ if (startTime && endTime) {
2973
+ const evStart = new Date(startTime).getTime();
2974
+ const evEnd = new Date(endTime).getTime();
2975
+ // Only include events that overlap with the requested window
2976
+ if (evStart < reqEnd && evEnd > reqStart) {
2977
+ items.push({
2978
+ status: busyType === 'Free' ? 'Free' : busyType === 'Tentative' ? 'Tentative' : 'Busy',
2979
+ start: { dateTime: new Date(evStart).toISOString(), timeZone: 'UTC' },
2980
+ end: { dateTime: new Date(evEnd).toISOString(), timeZone: 'UTC' }
2981
+ });
2982
+ }
2983
+ }
2984
+ }
2985
+
2986
+ schedule.scheduleItems = items;
2987
+ }
2988
+ }
2989
+
2990
+ return ewsResult(schedules);
2991
+ } catch (err) {
2992
+ return ewsError(err);
2993
+ }
2994
+ }
2995
+
2996
+ /**
2997
+ * Returns the authenticated user's own calendar events as free/busy slots.
2998
+ *
2999
+ * NOTE: This does NOT return free/busy information for arbitrary email addresses —
3000
+ * it only returns the OAuth-token-owner's own calendar. For actual cross-user
3001
+ * free/busy lookups, use GetUserAvailabilityRequest (cf. isRoomFree).
3002
+ *
3003
+ * @deprecated Use getMyFreeBusySlots() for clarity, or implement GetUserAvailabilityRequest
3004
+ * for true free/busy of arbitrary email addresses.
3005
+ */
3006
+ export async function getFreeBusy(
3007
+ token: string,
3008
+ startDateTime: string,
3009
+ endDateTime: string,
3010
+ mailbox?: string
3011
+ ): Promise<OwaResponse<FreeBusySlot[]>> {
3012
+ return getMyFreeBusySlots(token, startDateTime, endDateTime, mailbox);
3013
+ }
3014
+
3015
+ /**
3016
+ * Returns the authenticated user's own calendar events as free/busy slots.
3017
+ *
3018
+ * This function queries the authenticated user's calendar and maps each non-cancelled
3019
+ * event to a FreeBusySlot. It does NOT perform actual free/busy queries for arbitrary
3020
+ * email addresses — that requires GetUserAvailabilityRequest.
3021
+ *
3022
+ * @param token - OAuth2 access token
3023
+ * @param startDateTime - Start of the time window (ISO 8601)
3024
+ * @param endDateTime - End of the time window (ISO 8601)
3025
+ */
3026
+ export async function getMyFreeBusySlots(
3027
+ token: string,
3028
+ startDateTime: string,
3029
+ endDateTime: string,
3030
+ mailbox?: string
3031
+ ): Promise<OwaResponse<FreeBusySlot[]>> {
3032
+ const result = await getCalendarEvents(token, startDateTime, endDateTime, mailbox);
3033
+ if (!result.ok || !result.data) return { ok: false, status: result.status, error: result.error };
3034
+
3035
+ const slots: FreeBusySlot[] = result.data
3036
+ .filter((event) => !event.IsCancelled)
3037
+ .map((event) => ({
3038
+ status:
3039
+ event.ShowAs === 'Free'
3040
+ ? ('Free' as const)
3041
+ : event.ShowAs === 'Tentative'
3042
+ ? ('Tentative' as const)
3043
+ : ('Busy' as const),
3044
+ start: event.Start.DateTime,
3045
+ end: event.End.DateTime,
3046
+ subject: event.Subject
3047
+ }));
3048
+
3049
+ return ewsResult(slots);
3050
+ }
3051
+
3052
+ export async function areRoomsFree(
3053
+ token: string,
3054
+ roomEmails: string[],
3055
+ startDateTime: string,
3056
+ endDateTime: string
3057
+ ): Promise<Map<string, boolean>> {
3058
+ const result = new Map<string, boolean>();
3059
+
3060
+ if (roomEmails.length === 0) return result;
3061
+
3062
+ const timeZone = 'UTC';
3063
+
3064
+ const BATCH_SIZE = 100;
3065
+ const batches: string[][] = [];
3066
+ for (let i = 0; i < roomEmails.length; i += BATCH_SIZE) {
3067
+ batches.push(roomEmails.slice(i, i + BATCH_SIZE));
3068
+ }
3069
+
3070
+ for (const batch of batches) {
3071
+ try {
3072
+ const envelope = soapEnvelope(
3073
+ `
3074
+ <m:GetUserAvailabilityRequest>
3075
+ <t:TimeZone>
3076
+ <t:Bias>0</t:Bias>
3077
+ <t:StandardTime>
3078
+ <t:Bias>0</t:Bias>
3079
+ <t:Time>02:00:00</t:Time>
3080
+ <t:DayOrder>1</t:DayOrder>
3081
+ <t:Month>1</t:Month>
3082
+ <t:DayOfWeek>Sunday</t:DayOfWeek>
3083
+ </t:StandardTime>
3084
+ <t:DaylightTime>
3085
+ <t:Bias>0</t:Bias>
3086
+ <t:Time>02:00:00</t:Time>
3087
+ <t:DayOrder>1</t:DayOrder>
3088
+ <t:Month>1</t:Month>
3089
+ <t:DayOfWeek>Sunday</t:DayOfWeek>
3090
+ </t:DaylightTime>
3091
+ </t:TimeZone>
3092
+ <m:MailboxDataArray>
3093
+ ${batch
3094
+ .map(
3095
+ (email) => `
3096
+ <t:MailboxData>
3097
+ <t:Email><t:Address>${xmlEscape(email)}</t:Address></t:Email>
3098
+ <t:AttendeeType>Required</t:AttendeeType>
3099
+ </t:MailboxData>`
3100
+ )
3101
+ .join('')}
3102
+ </m:MailboxDataArray>
3103
+ <t:FreeBusyViewOptions>
3104
+ <t:TimeWindow>
3105
+ <t:StartTime>${xmlEscape(startDateTime)}</t:StartTime>
3106
+ <t:EndTime>${xmlEscape(endDateTime)}</t:EndTime>
3107
+ </t:TimeWindow>
3108
+ <t:MergedFreeBusyIntervalInMinutes>15</t:MergedFreeBusyIntervalInMinutes>
3109
+ <t:RequestedView>FreeBusy</t:RequestedView>
3110
+ </t:FreeBusyViewOptions>
3111
+ </m:GetUserAvailabilityRequest>`,
3112
+ `<t:TimeZoneContext><t:TimeZoneDefinition Id="${xmlEscape(timeZone)}"/></t:TimeZoneContext>`
3113
+ );
3114
+
3115
+ const controller = new AbortController();
3116
+ const timeout = setTimeout(() => controller.abort(), EWS_TIMEOUT_MS);
3117
+
3118
+ let response: Response;
3119
+ let xml: string;
3120
+ try {
3121
+ // codeql[js/file-access-to-http]: Bearer token may originate from the local OAuth cache; SOAP body is built in-process.
3122
+ response = await fetch(EWS_ENDPOINT, {
3123
+ method: 'POST',
3124
+ headers: {
3125
+ Authorization: `Bearer ${token}`,
3126
+ 'Content-Type': 'text/xml; charset=utf-8',
3127
+ Accept: 'text/xml',
3128
+ 'X-AnchorMailbox': EWS_USERNAME
3129
+ },
3130
+ body: envelope,
3131
+ signal: controller.signal
3132
+ });
3133
+ xml = await response.text();
3134
+ } catch (err) {
3135
+ if (err instanceof Error && err.name === 'AbortError') {
3136
+ throw new Error(`EWS request timed out after ${EWS_TIMEOUT_MS / 1000}s`);
3137
+ }
3138
+ throw err;
3139
+ } finally {
3140
+ clearTimeout(timeout);
3141
+ }
3142
+
3143
+ if (!response.ok) {
3144
+ const soapError = extractTag(xml, 'faultstring') || extractTag(xml, 'MessageText');
3145
+ throw new Error(`EWS HTTP ${response.status}${soapError ? `: ${soapError}` : ''}`);
3146
+ }
3147
+
3148
+ // Parse FreeBusyResponse blocks to correlate mailboxes with their events
3149
+ const freeBusyResponses = extractBlocks(xml, 'FreeBusyResponse');
3150
+ const reqStart = new Date(startDateTime).getTime();
3151
+ const reqEnd = new Date(endDateTime).getTime();
3152
+
3153
+ for (let i = 0; i < freeBusyResponses.length; i++) {
3154
+ const resp = freeBusyResponses[i];
3155
+ const email = batch[i];
3156
+
3157
+ // Check for per-room errors (e.g., ErrorMailRecipientNotFound)
3158
+ const responseClass = extractAttribute(resp, 'ResponseMessage', 'ResponseClass');
3159
+ const responseCode = extractTag(resp, 'ResponseCode');
3160
+ if (responseClass && responseClass !== 'Success') {
3161
+ // Room errored - mark as not free (conservative)
3162
+ result.set(email, false);
3163
+ continue;
3164
+ }
3165
+ if (responseCode && responseCode !== 'NoError') {
3166
+ // Room errored - mark as not free (conservative)
3167
+ result.set(email, false);
3168
+ continue;
3169
+ }
3170
+
3171
+ const calendarEvents = extractBlocks(resp, 'CalendarEvent');
3172
+
3173
+ let isFree = true;
3174
+ for (const event of calendarEvents) {
3175
+ const busyType = extractTag(event, 'BusyType');
3176
+ if (busyType === 'Free') continue;
3177
+
3178
+ const evStart = new Date(extractTag(event, 'StartTime') || '').getTime();
3179
+ const evEnd = new Date(extractTag(event, 'EndTime') || '').getTime();
3180
+
3181
+ if (evStart < reqEnd && evEnd > reqStart) {
3182
+ isFree = false;
3183
+ break;
3184
+ }
3185
+ }
3186
+
3187
+ result.set(email, isFree);
3188
+ }
3189
+ } catch {
3190
+ // On error, mark all rooms in this batch as not-free (conservative)
3191
+ for (const email of batch) {
3192
+ result.set(email, false);
3193
+ }
3194
+ }
3195
+ }
3196
+
3197
+ return result;
3198
+ }
3199
+
3200
+ export interface AutoReplyRule {
3201
+ messageText: string;
3202
+ enabled: boolean;
3203
+ startTime?: Date;
3204
+ endTime?: Date;
3205
+ }
3206
+
3207
+ export async function getAutoReplyRule(token: string, mailbox?: string): Promise<OwaResponse<AutoReplyRule | null>> {
3208
+ try {
3209
+ const address = mailbox || EWS_USERNAME;
3210
+ const envelope = soapEnvelope(`
3211
+ <m:GetInboxRules>
3212
+ <m:MailboxSmtpAddress>${xmlEscape(address)}</m:MailboxSmtpAddress>
3213
+ </m:GetInboxRules>
3214
+ `);
3215
+
3216
+ const xml = await callEws(token, envelope, address);
3217
+
3218
+ // Parse the rules
3219
+ // Find the rule with DisplayName = "AutoReplyTemplate"
3220
+ const rulesRegex = /<t:Rule>(.*?)<\/t:Rule>/gs;
3221
+ let match: RegExpExecArray | null;
3222
+ let ruleXml = null;
3223
+ while (true) {
3224
+ match = rulesRegex.exec(xml);
3225
+ if (match === null) break;
3226
+ if (match[1].includes('<t:DisplayName>AutoReplyTemplate</t:DisplayName>')) {
3227
+ ruleXml = match[1];
3228
+ break;
3229
+ }
3230
+ }
3231
+
3232
+ if (!ruleXml) {
3233
+ return ewsResult(null);
3234
+ }
3235
+
3236
+ const enabledStr = extractTag(ruleXml, 'IsEnabled');
3237
+ const enabled = enabledStr.toLowerCase() === 'true';
3238
+
3239
+ // Dates
3240
+ const startStr = extractTag(ruleXml, 'StartDateTime');
3241
+ const endStr = extractTag(ruleXml, 'EndDateTime');
3242
+
3243
+ // To get the message text, we need the template item ID
3244
+ const templateId = extractAttribute(ruleXml, 'ItemId', 'Id');
3245
+ let messageText = '';
3246
+
3247
+ if (templateId) {
3248
+ // Fetch the template draft to read the body
3249
+ const getTemplateEnvelope = soapEnvelope(`
3250
+ <m:GetItem>
3251
+ <m:ItemShape>
3252
+ <t:BaseShape>Default</t:BaseShape>
3253
+ <t:AdditionalProperties>
3254
+ <t:FieldURI FieldURI="item:Body" />
3255
+ </t:AdditionalProperties>
3256
+ </m:ItemShape>
3257
+ <m:ItemIds>
3258
+ <t:ItemId Id="${xmlEscape(templateId)}" />
3259
+ </m:ItemIds>
3260
+ </m:GetItem>
3261
+ `);
3262
+
3263
+ const itemXml = await callEws(token, getTemplateEnvelope, address);
3264
+ // Extract the GetItemResponseMessage block first to avoid matching the
3265
+ // outer <soap:Body> wrapper before the actual <t:Body> item content
3266
+ const responseBlocks = extractBlocks(itemXml, 'GetItemResponseMessage');
3267
+ const itemBlock = responseBlocks[0] || itemXml;
3268
+ messageText = extractTag(itemBlock, 'Body');
3269
+ }
3270
+
3271
+ return ewsResult({
3272
+ messageText,
3273
+ enabled,
3274
+ startTime: startStr ? new Date(startStr) : undefined,
3275
+ endTime: endStr ? new Date(endStr) : undefined
3276
+ });
3277
+ } catch (err) {
3278
+ return ewsError(err);
3279
+ }
3280
+ }
3281
+
3282
+ export async function setAutoReplyRule(
3283
+ token: string,
3284
+ messageText: string,
3285
+ enabled: boolean,
3286
+ startTime?: Date,
3287
+ endTime?: Date,
3288
+ mailbox?: string
3289
+ ): Promise<OwaResponse<void>> {
3290
+ try {
3291
+ const address = mailbox || EWS_USERNAME;
3292
+
3293
+ // 1. See if the rule exists and extract the old template ID
3294
+ const getRulesEnvelope = soapEnvelope(`
3295
+ <m:GetInboxRules>
3296
+ <m:MailboxSmtpAddress>${xmlEscape(address)}</m:MailboxSmtpAddress>
3297
+ </m:GetInboxRules>
3298
+ `);
3299
+ const rulesXml = await callEws(token, getRulesEnvelope, address);
3300
+
3301
+ let ruleIdStr = '';
3302
+ let oldTemplateId = '';
3303
+ const rulesRegex = /<t:Rule>(.*?)<\/t:Rule>/gs;
3304
+ let match: RegExpExecArray | null;
3305
+ while (true) {
3306
+ match = rulesRegex.exec(rulesXml);
3307
+ if (match === null) break;
3308
+ if (match[1].includes('<t:DisplayName>AutoReplyTemplate</t:DisplayName>')) {
3309
+ ruleIdStr = extractTag(match[1], 'RuleId');
3310
+ oldTemplateId = extractAttribute(match[1], 'ItemId', 'Id');
3311
+ break;
3312
+ }
3313
+ }
3314
+
3315
+ // 2. Create a draft message for the template
3316
+ const draftEnvelope = soapEnvelope(`
3317
+ <m:CreateItem MessageDisposition="SaveOnly">
3318
+ <m:Items>
3319
+ <t:Message>
3320
+ <t:Subject>AutoReplyTemplate</t:Subject>
3321
+ <t:Body BodyType="HTML">${xmlEscape(messageText)}</t:Body>
3322
+ </t:Message>
3323
+ </m:Items>
3324
+ </m:CreateItem>
3325
+ `);
3326
+
3327
+ const draftXml = await callEws(token, draftEnvelope, address);
3328
+ const templateId = extractAttribute(draftXml, 'ItemId', 'Id');
3329
+ const templateChangeKey = extractAttribute(draftXml, 'ItemId', 'ChangeKey');
3330
+
3331
+ if (!templateId) {
3332
+ throw new Error('Failed to create template message');
3333
+ }
3334
+
3335
+ let deleteOp = '';
3336
+ if (ruleIdStr) {
3337
+ deleteOp = `
3338
+ <t:DeleteRuleOperation>
3339
+ <t:RuleId>${xmlEscape(ruleIdStr)}</t:RuleId>
3340
+ </t:DeleteRuleOperation>
3341
+ `;
3342
+ }
3343
+
3344
+ // 4. Create the new rule
3345
+ let dateRangeXml = '';
3346
+ if (startTime || endTime) {
3347
+ dateRangeXml = '<t:WithinDateRange>';
3348
+ if (startTime) dateRangeXml += `<t:StartDateTime>${startTime.toISOString()}</t:StartDateTime>`;
3349
+ if (endTime) dateRangeXml += `<t:EndDateTime>${endTime.toISOString()}</t:EndDateTime>`;
3350
+ dateRangeXml += '</t:WithinDateRange>';
3351
+ }
3352
+
3353
+ const conditionsXml = dateRangeXml ? `<t:Conditions>${dateRangeXml}</t:Conditions>` : '';
3354
+ const templateChangeKeyAttr = templateChangeKey ? ` ChangeKey="${xmlEscape(templateChangeKey)}"` : '';
3355
+
3356
+ const setRulesEnvelope = soapEnvelope(`
3357
+ <m:UpdateInboxRules>
3358
+ <m:MailboxSmtpAddress>${xmlEscape(address)}</m:MailboxSmtpAddress>
3359
+ <m:RemoveOutlookRuleBlob>false</m:RemoveOutlookRuleBlob>
3360
+ <m:Operations>
3361
+ ${deleteOp}
3362
+ <t:CreateRuleOperation>
3363
+ <t:Rule>
3364
+ <t:DisplayName>AutoReplyTemplate</t:DisplayName>
3365
+ <t:Sequence>1</t:Sequence>
3366
+ <t:IsEnabled>${enabled ? 'true' : 'false'}</t:IsEnabled>
3367
+ ${conditionsXml}
3368
+ <t:Actions>
3369
+ <t:ServerReplyWithMessage>
3370
+ <t:ItemId Id="${xmlEscape(templateId)}"${templateChangeKeyAttr} />
3371
+ </t:ServerReplyWithMessage>
3372
+ </t:Actions>
3373
+ </t:Rule>
3374
+ </t:CreateRuleOperation>
3375
+ </m:Operations>
3376
+ </m:UpdateInboxRules>
3377
+ `);
3378
+
3379
+ try {
3380
+ await callEws(token, setRulesEnvelope, address);
3381
+ } catch (err) {
3382
+ // Clean up the newly created draft template on failure
3383
+ try {
3384
+ const deleteTemplateEnvelope = soapEnvelope(`
3385
+ <m:DeleteItem DeleteType="HardDelete">
3386
+ <m:ItemIds>
3387
+ <t:ItemId Id="${xmlEscape(templateId)}" />
3388
+ </m:ItemIds>
3389
+ </m:DeleteItem>
3390
+ `);
3391
+ await callEws(token, deleteTemplateEnvelope, address);
3392
+ } catch {
3393
+ // Ignore cleanup errors
3394
+ }
3395
+ throw err;
3396
+ }
3397
+
3398
+ // 5. Delete the old template draft if it exists (after successful rule update)
3399
+ if (oldTemplateId) {
3400
+ try {
3401
+ const deleteTemplateEnvelope = soapEnvelope(`
3402
+ <m:DeleteItem DeleteType="HardDelete">
3403
+ <m:ItemIds>
3404
+ <t:ItemId Id="${xmlEscape(oldTemplateId)}" />
3405
+ </m:ItemIds>
3406
+ </m:DeleteItem>
3407
+ `);
3408
+ await callEws(token, deleteTemplateEnvelope, address);
3409
+ } catch (_err) {
3410
+ // Old template might already be deleted, continue
3411
+ }
3412
+ }
3413
+
3414
+ return ewsResult(undefined);
3415
+ } catch (err) {
3416
+ return ewsError(err);
3417
+ }
3418
+ }