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,268 @@
1
+ import { afterEach, describe, expect, it, mock } from 'bun:test';
2
+
3
+ const okUpdateResponse = `<?xml version="1.0" encoding="utf-8"?>
4
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
5
+ <soap:Body>
6
+ <m:UpdateItemResponse>
7
+ <m:ResponseMessages>
8
+ <m:UpdateItemResponseMessage ResponseClass="Success">
9
+ <m:ResponseCode>NoError</m:ResponseCode>
10
+ <m:Items>
11
+ <t:CalendarItem>
12
+ <t:ItemId Id="updated-id" ChangeKey="new-ck" />
13
+ </t:CalendarItem>
14
+ </m:Items>
15
+ </m:UpdateItemResponseMessage>
16
+ </m:ResponseMessages>
17
+ </m:UpdateItemResponse>
18
+ </soap:Body>
19
+ </soap:Envelope>`;
20
+
21
+ describe('ews-client safety and conflict behavior', () => {
22
+ const originalFetch = globalThis.fetch;
23
+
24
+ afterEach(() => {
25
+ globalThis.fetch = originalFetch;
26
+ mock.restore();
27
+ });
28
+
29
+ it('retries updateEvent with AlwaysOverwrite after conflict when ChangeKey is provided', async () => {
30
+ const fetchCalls: string[] = [];
31
+ let callCount = 0;
32
+
33
+ globalThis.fetch = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
34
+ const body = String(init?.body || '');
35
+ fetchCalls.push(body);
36
+ callCount += 1;
37
+
38
+ if (callCount === 1) {
39
+ return new Response(
40
+ `<?xml version="1.0" encoding="utf-8"?>
41
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
42
+ <soap:Body>
43
+ <m:UpdateItemResponse>
44
+ <m:ResponseMessages>
45
+ <m:UpdateItemResponseMessage ResponseClass="Error">
46
+ <m:ResponseCode>ErrorIrresolvableConflict</m:ResponseCode>
47
+ <m:MessageText>The change key passed in the request does not match.</m:MessageText>
48
+ </m:UpdateItemResponseMessage>
49
+ </m:ResponseMessages>
50
+ </m:UpdateItemResponse>
51
+ </soap:Body>
52
+ </soap:Envelope>`,
53
+ { status: 200 }
54
+ );
55
+ }
56
+
57
+ return new Response(okUpdateResponse, { status: 200 });
58
+ }) as unknown as typeof fetch;
59
+
60
+ const { updateEvent } = await import('../lib/ews-client.js');
61
+ const result = await updateEvent({
62
+ token: 'token',
63
+ eventId: 'event-id',
64
+ subject: 'Updated title',
65
+ changeKey: 'client-ck'
66
+ });
67
+
68
+ expect(result.ok).toBe(true);
69
+ expect(fetchCalls.length).toBe(2);
70
+ expect(fetchCalls[0]).toContain('ConflictResolution="AutoResolve"');
71
+ expect(fetchCalls[0]).toContain('<t:ItemId Id="event-id" ChangeKey="client-ck" />');
72
+ expect(fetchCalls[1]).toContain('ConflictResolution="AlwaysOverwrite"');
73
+ expect(fetchCalls[1]).toContain('<t:ItemId Id="event-id" />');
74
+ });
75
+
76
+ it('sanitizes EWS QueryString control syntax in getEmails search', async () => {
77
+ const fetchCalls: string[] = [];
78
+
79
+ globalThis.fetch = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
80
+ const body = String(init?.body || '');
81
+ fetchCalls.push(body);
82
+ return new Response(
83
+ `<?xml version="1.0" encoding="utf-8"?>
84
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
85
+ <soap:Body>
86
+ <m:FindItemResponse>
87
+ <m:ResponseMessages>
88
+ <m:FindItemResponseMessage ResponseClass="Success">
89
+ <m:ResponseCode>NoError</m:ResponseCode>
90
+ <m:RootFolder IncludesLastItemInRange="true" TotalItemsInView="0" IndexedPagingOffset="0" />
91
+ </m:FindItemResponseMessage>
92
+ </m:ResponseMessages>
93
+ </m:FindItemResponse>
94
+ </soap:Body>
95
+ </soap:Envelope>`,
96
+ { status: 200 }
97
+ );
98
+ }) as unknown as typeof fetch;
99
+
100
+ const { getEmails } = await import('../lib/ews-client.js');
101
+ const query = 'urgent OR from:bob@example.com AND "project x"';
102
+ const result = await getEmails({ token: 'token', search: query });
103
+
104
+ expect(result.ok).toBe(true);
105
+ expect(fetchCalls.length).toBe(1);
106
+ expect(fetchCalls[0]).toContain(
107
+ '<m:QueryString>urgent OR from:bob@example.com AND &quot;project x&quot;</m:QueryString>'
108
+ );
109
+ });
110
+
111
+ it('returns explicit error when getOwaUserInfo fails instead of silent fallback', async () => {
112
+ globalThis.fetch = mock(async () => {
113
+ return new Response('gateway timeout', { status: 504 });
114
+ }) as unknown as typeof fetch;
115
+
116
+ const { getOwaUserInfo } = await import('../lib/ews-client.js');
117
+ const result = await getOwaUserInfo('token');
118
+
119
+ expect(result.ok).toBe(false);
120
+ expect(result.error?.code).toBe('EWS_ERROR');
121
+ expect(result.error?.message).toContain('Failed to resolve OWA user info');
122
+ });
123
+
124
+ it('parses TimeZone correctly from CalendarItem StartTimeZone and EndTimeZone', async () => {
125
+ globalThis.fetch = mock(async () => {
126
+ return new Response(
127
+ `<?xml version="1.0" encoding="utf-8"?>
128
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
129
+ <soap:Body>
130
+ <m:GetItemResponse>
131
+ <m:ResponseMessages>
132
+ <m:GetItemResponseMessage ResponseClass="Success">
133
+ <m:ResponseCode>NoError</m:ResponseCode>
134
+ <m:Items>
135
+ <t:CalendarItem>
136
+ <t:ItemId Id="event-id" ChangeKey="ck" />
137
+ <t:Subject>Timezone Test Event</t:Subject>
138
+ <t:Start>2026-03-30T10:00:00Z</t:Start>
139
+ <t:End>2026-03-30T11:00:00Z</t:End>
140
+ <t:StartTimeZone Id="Pacific Standard Time" />
141
+ <t:EndTimeZone Id="Pacific Standard Time" />
142
+ <t:IsAllDayEvent>false</t:IsAllDayEvent>
143
+ <t:IsCancelled>false</t:IsCancelled>
144
+ <t:Organizer><t:Mailbox><t:Name>Bob</t:Name><t:EmailAddress>bob@example.com</t:EmailAddress></t:Mailbox></t:Organizer>
145
+ </t:CalendarItem>
146
+ </m:Items>
147
+ </m:GetItemResponseMessage>
148
+ </m:ResponseMessages>
149
+ </m:GetItemResponse>
150
+ </soap:Body>
151
+ </soap:Envelope>`,
152
+ { status: 200 }
153
+ );
154
+ }) as unknown as typeof fetch;
155
+
156
+ const { getCalendarEvent } = await import('../lib/ews-client.js');
157
+ const result = await getCalendarEvent('token', 'event-id');
158
+
159
+ expect(result.ok).toBe(true);
160
+ expect(result.data?.Start.TimeZone).toBe('Pacific Standard Time');
161
+ expect(result.data?.End.TimeZone).toBe('Pacific Standard Time');
162
+ });
163
+
164
+ it('replyToEmail sends ReferenceItemId with ChangeKey after GetItem', async () => {
165
+ const fetchCalls: string[] = [];
166
+ let callCount = 0;
167
+
168
+ globalThis.fetch = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
169
+ const body = String(init?.body || '');
170
+ fetchCalls.push(body);
171
+ callCount += 1;
172
+ if (callCount === 1) {
173
+ return new Response(
174
+ `<?xml version="1.0" encoding="utf-8"?>
175
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
176
+ <soap:Body>
177
+ <m:ResponseCode>NoError</m:ResponseCode>
178
+ <m:GetItemResponse>
179
+ <m:Items>
180
+ <t:Message>
181
+ <t:ItemId Id="msg-1" ChangeKey="ck-from-get" />
182
+ <t:Subject>Subj</t:Subject>
183
+ </t:Message>
184
+ </m:Items>
185
+ </m:GetItemResponse>
186
+ </soap:Body>
187
+ </soap:Envelope>`,
188
+ { status: 200 }
189
+ );
190
+ }
191
+ return new Response(
192
+ `<?xml version="1.0" encoding="utf-8"?>
193
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
194
+ <soap:Body>
195
+ <m:ResponseCode>NoError</m:ResponseCode>
196
+ </soap:Body>
197
+ </soap:Envelope>`,
198
+ { status: 200 }
199
+ );
200
+ }) as unknown as typeof fetch;
201
+
202
+ const { replyToEmail } = await import('../lib/ews-client.js');
203
+ const result = await replyToEmail('token', 'msg-1', 'Thanks', false, false, undefined);
204
+
205
+ expect(result.ok).toBe(true);
206
+ expect(fetchCalls.length).toBe(2);
207
+ expect(fetchCalls[0]).toContain('<m:GetItem>');
208
+ expect(fetchCalls[1]).toContain('ReferenceItemId');
209
+ expect(fetchCalls[1]).toContain('ChangeKey="ck-from-get"');
210
+ expect(fetchCalls[1]).toContain('Id="msg-1"');
211
+ });
212
+
213
+ it('replyToEmailDraft sends ReferenceItemId with ChangeKey after GetItem', async () => {
214
+ const fetchCalls: string[] = [];
215
+ let callCount = 0;
216
+
217
+ globalThis.fetch = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
218
+ const body = String(init?.body || '');
219
+ fetchCalls.push(body);
220
+ callCount += 1;
221
+ if (callCount === 1) {
222
+ return new Response(
223
+ `<?xml version="1.0" encoding="utf-8"?>
224
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
225
+ <soap:Body>
226
+ <m:ResponseCode>NoError</m:ResponseCode>
227
+ <m:GetItemResponse>
228
+ <m:Items>
229
+ <t:Message>
230
+ <t:ItemId Id="msg-2" ChangeKey="ck-draft" />
231
+ <t:Subject>Subj</t:Subject>
232
+ </t:Message>
233
+ </m:Items>
234
+ </m:GetItemResponse>
235
+ </soap:Body>
236
+ </soap:Envelope>`,
237
+ { status: 200 }
238
+ );
239
+ }
240
+ return new Response(
241
+ `<?xml version="1.0" encoding="utf-8"?>
242
+ <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
243
+ <soap:Body>
244
+ <m:ResponseCode>NoError</m:ResponseCode>
245
+ <m:CreateItemResponse>
246
+ <m:Items>
247
+ <t:Message>
248
+ <t:ItemId Id="reply-draft-x" ChangeKey="rck" />
249
+ </t:Message>
250
+ </m:Items>
251
+ </m:CreateItemResponse>
252
+ </soap:Body>
253
+ </soap:Envelope>`,
254
+ { status: 200 }
255
+ );
256
+ }) as unknown as typeof fetch;
257
+
258
+ const { replyToEmailDraft } = await import('../lib/ews-client.js');
259
+ const result = await replyToEmailDraft('token', 'msg-2', 'Draft reply', false, false, undefined);
260
+
261
+ expect(result.ok).toBe(true);
262
+ expect(result.data?.draftId).toBe('reply-draft-x');
263
+ expect(fetchCalls.length).toBe(2);
264
+ expect(fetchCalls[0]).toContain('<m:GetItem>');
265
+ expect(fetchCalls[1]).toContain('ChangeKey="ck-draft"');
266
+ expect(fetchCalls[1]).toContain('MessageDisposition="SaveOnly"');
267
+ });
268
+ });
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Mock fetch setup for CLI integration tests.
3
+ * Intercepts all HTTP calls and returns mock responses based on URL and request body.
4
+ */
5
+ import { readFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import {
9
+ makeGetCalendarItemDetailResponse,
10
+ mockAddAttachmentResponse,
11
+ mockCalendarEventsEmptyResponse,
12
+ mockCalendarEventsResponse,
13
+ mockCancelEventSuccessResponse,
14
+ mockCreateDraftResponse,
15
+ mockCreateEventResponse,
16
+ mockCreateMailFolderResponse,
17
+ mockDeleteEventSuccessResponse,
18
+ mockDeleteMailFolderResponse,
19
+ mockForwardEmailResponse,
20
+ mockGetAttachmentsResponse,
21
+ mockGetDraftsResponse,
22
+ mockGetEmailDetailResponse,
23
+ mockGetEmailsResponse,
24
+ mockGetMailFoldersResponse,
25
+ mockGetRoomsFromListResponse,
26
+ mockGetRoomsResponse,
27
+ mockGetScheduleResponse,
28
+ mockGraphCheckinResponse,
29
+ mockGraphCreateUploadSessionResponse,
30
+ mockGraphDeleteResponse,
31
+ mockGraphGetFileMetadataResponse,
32
+ mockGraphListFilesResponse,
33
+ mockGraphSearchFilesResponse,
34
+ mockGraphShareResponse,
35
+ mockGraphUploadResponse,
36
+ mockMoveEmailResponse,
37
+ mockOAuthTokenResponse,
38
+ mockReplyToEmailResponse,
39
+ mockResolveNamesPeopleResponse,
40
+ mockResolveNamesResponse,
41
+ mockRespondListResponse,
42
+ mockRespondSuccessResponse,
43
+ mockSearchRoomsResponse,
44
+ mockSendEmailResponse,
45
+ mockUpdateEmailResponse,
46
+ mockUpdateEventResponse,
47
+ mockUpdateMailFolderResponse
48
+ } from './responses.js';
49
+
50
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../..');
51
+
52
+ function npmRegistryMockLatestVersion(): string {
53
+ try {
54
+ const raw = readFileSync(join(REPO_ROOT, 'package.json'), 'utf8');
55
+ return (JSON.parse(raw) as { version: string }).version;
56
+ } catch {
57
+ return '1.0.0';
58
+ }
59
+ }
60
+
61
+ type MockFn = (url: string, request: Request) => { status: number; body: string; contentType: string } | null;
62
+
63
+ let mockFetchImpl: MockFn | null = null;
64
+
65
+ export function setMockFetch(impl: MockFn): void {
66
+ mockFetchImpl = impl;
67
+ }
68
+
69
+ export function clearMockFetch(): void {
70
+ mockFetchImpl = null;
71
+ }
72
+
73
+ function makeResponse(body: string, status = 200, contentType = 'text/xml'): Response {
74
+ return new Response(body, {
75
+ status,
76
+ headers: { 'content-type': contentType }
77
+ });
78
+ }
79
+
80
+ function makeJsonResponse(body: object, status = 200): Response {
81
+ return new Response(JSON.stringify(body), {
82
+ status,
83
+ headers: { 'content-type': 'application/json' }
84
+ });
85
+ }
86
+
87
+ function extractSoapAction(body: string): string {
88
+ // Extract the first child element of soap:Body
89
+ const match = body.match(/<soap:Body[^>]*>([\s\S]*?)<\/soap:Body>/i);
90
+ if (!match) return '';
91
+ const inner = match[1].trim();
92
+ // Find the first tag name
93
+ const tagMatch = inner.match(/<(?:m:|t:)?(\w+)/);
94
+ return tagMatch ? tagMatch[1] : '';
95
+ }
96
+
97
+ function hasTag(body: string, tag: string): boolean {
98
+ return body.includes(`<${tag}`) || body.includes(`<m:${tag}`) || body.includes(`<t:${tag}`);
99
+ }
100
+
101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
+ export function createMockFetch(): any {
103
+ return async (input: string | URL | Request, init?: RequestInit) => {
104
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
105
+ const body = typeof init?.body === 'string' ? init.body : '';
106
+
107
+ // Let custom mock take priority
108
+ if (mockFetchImpl) {
109
+ const custom = mockFetchImpl(url, new Request(url, init as RequestInit));
110
+ if (custom) return makeResponse(custom.body, custom.status, custom.contentType);
111
+ }
112
+
113
+ // OAuth token endpoint
114
+ // Uses URL hostname parsing (not string includes) to avoid CodeQL injection alert
115
+ // and to be more precise — login.microsoftonline.com must be the actual host, not a query string value
116
+ try {
117
+ if (new URL(url).hostname === 'login.microsoftonline.com' && url.includes('/token')) {
118
+ return makeJsonResponse(JSON.parse(mockOAuthTokenResponse));
119
+ }
120
+ } catch {
121
+ // Not a valid URL, skip OAuth check
122
+ }
123
+
124
+ if (url.includes('registry.npmjs.org/m365-agent-cli/latest')) {
125
+ return makeJsonResponse({ version: npmRegistryMockLatestVersion() });
126
+ }
127
+
128
+ // EWS endpoint
129
+ if (url.includes('outlook.office365.com/EWS/Exchange.asmx')) {
130
+ const action = extractSoapAction(body);
131
+
132
+ // Auth check / ResolveNames (used by whoami and find)
133
+ if (action === 'ResolveNames' && hasTag(body, 'UnresolvedEntry')) {
134
+ // Check if this is calendar-related (respond list uses DistinguishedFolderId)
135
+ if (hasTag(body, 'DistinguishedFolderId') && hasTag(body, 'CalendarView')) {
136
+ return makeResponse(mockRespondListResponse);
137
+ }
138
+
139
+ // Distinguish whoami from find:
140
+ // - whoami: getOwaUserInfo calls ResolveNames with EWS_USERNAME (empty in tests)
141
+ // - find: resolveNames calls ResolveNames with the search query
142
+ // whoami has empty UnresolvedEntry + no RequiredAttendees
143
+ // find has (non-empty UnresolvedEntry) OR (has RequiredAttendees)
144
+ const unresolvedContent = body.match(/<m:UnresolvedEntry>([^<]*)<\/m:UnresolvedEntry>/)?.[1] || '';
145
+ const isPeopleSearch = hasTag(body, 'RequiredAttendees') || unresolvedContent.length > 0;
146
+
147
+ if (isPeopleSearch) {
148
+ return makeResponse(mockResolveNamesPeopleResponse);
149
+ }
150
+ // whoami gets here (empty UnresolvedEntry, no RequiredAttendees)
151
+ return makeResponse(mockResolveNamesResponse);
152
+ }
153
+
154
+ // Calendar events (used by calendar, respond, delete-event, update-event)
155
+ if (hasTag(body, 'FindItem') && hasTag(body, 'CalendarView')) {
156
+ // Check for specific event IDs (for respond)
157
+ if (body.includes('invite-')) {
158
+ return makeResponse(mockRespondListResponse);
159
+ }
160
+ // Default calendar events
161
+ return makeResponse(mockCalendarEventsResponse);
162
+ }
163
+
164
+ // Create calendar item
165
+ if (hasTag(body, 'CreateItem') && hasTag(body, 'CalendarItem')) {
166
+ return makeResponse(mockCreateEventResponse);
167
+ }
168
+
169
+ // CreateItem (new mail draft — SaveOnly Message; reply/forward use ReplyToItem/ForwardItem instead)
170
+ if (
171
+ hasTag(body, 'CreateItem') &&
172
+ hasTag(body, 'Message') &&
173
+ body.includes('MessageDisposition="SaveOnly"') &&
174
+ !hasTag(body, 'ReplyToItem') &&
175
+ !hasTag(body, 'ForwardItem')
176
+ ) {
177
+ return makeResponse(mockCreateDraftResponse);
178
+ }
179
+
180
+ // Update calendar item
181
+ if (hasTag(body, 'UpdateItem') && hasTag(body, 'CalendarItem')) {
182
+ return makeResponse(mockUpdateEventResponse);
183
+ }
184
+
185
+ // Delete calendar item
186
+ if (hasTag(body, 'DeleteItem')) {
187
+ return makeResponse(mockDeleteEventSuccessResponse);
188
+ }
189
+
190
+ // Cancel calendar item
191
+ if (hasTag(body, 'CancelItem')) {
192
+ return makeResponse(mockCancelEventSuccessResponse);
193
+ }
194
+
195
+ // Respond to item
196
+ if (
197
+ hasTag(body, 'RespondToItem') ||
198
+ hasTag(body, 'AcceptItem') ||
199
+ hasTag(body, 'DeclineItem') ||
200
+ hasTag(body, 'TentativelyAcceptItem')
201
+ ) {
202
+ return makeResponse(mockRespondSuccessResponse);
203
+ }
204
+
205
+ // FindItem for mail folders / emails
206
+ if (hasTag(body, 'FindItem') && hasTag(body, 'ItemShape')) {
207
+ // Check if it's a drafts query
208
+ if (hasTag(body, 'drafts') || body.includes('Drafts')) {
209
+ return makeResponse(mockGetDraftsResponse);
210
+ }
211
+ if (hasTag(body, 'sentitems') || body.includes('SentItems')) {
212
+ return makeResponse(mockGetEmailsResponse);
213
+ }
214
+ return makeResponse(mockGetEmailsResponse);
215
+ }
216
+
217
+ // GetItem: calendar vs mail (calendar flows prefetch ChangeKey before CreateItem/DeleteItem)
218
+ if (hasTag(body, 'GetItem')) {
219
+ const idMatch = body.match(/<t:ItemId\s+[^>]*Id="([^"]+)"/);
220
+ const reqId = idMatch?.[1] ?? '';
221
+ if (
222
+ reqId.startsWith('invite-') ||
223
+ reqId.startsWith('event-') ||
224
+ reqId.startsWith('new-event-id') ||
225
+ reqId === 'event-id' ||
226
+ reqId.startsWith('occurrence-') ||
227
+ reqId.startsWith('exception-') ||
228
+ reqId.startsWith('series-') ||
229
+ reqId.startsWith('cal-')
230
+ ) {
231
+ return makeResponse(makeGetCalendarItemDetailResponse(reqId));
232
+ }
233
+ return makeResponse(mockGetEmailDetailResponse);
234
+ }
235
+
236
+ // GetAttachment
237
+ if (hasTag(body, 'GetAttachment')) {
238
+ return makeResponse(mockGetAttachmentsResponse);
239
+ }
240
+
241
+ // UpdateItem for email (mark read/unread/flag)
242
+ if (hasTag(body, 'UpdateItem') && hasTag(body, 'Message')) {
243
+ return makeResponse(mockUpdateEmailResponse);
244
+ }
245
+
246
+ // MoveItem
247
+ if (hasTag(body, 'MoveItem')) {
248
+ return makeResponse(mockMoveEmailResponse);
249
+ }
250
+
251
+ // SendItem
252
+ if (hasTag(body, 'SendItem')) {
253
+ return makeResponse(mockSendEmailResponse);
254
+ }
255
+
256
+ // CreateItem (reply/forward draft)
257
+ if (hasTag(body, 'CreateItem') && (hasTag(body, 'ReplyToItem') || hasTag(body, 'ForwardItem'))) {
258
+ if (hasTag(body, 'ForwardItem')) {
259
+ return makeResponse(mockForwardEmailResponse);
260
+ }
261
+ return makeResponse(mockReplyToEmailResponse);
262
+ }
263
+
264
+ // GetFolder (mail folders)
265
+ if (hasTag(body, 'GetFolder') || (hasTag(body, 'FindFolder') && hasTag(body, 'DistinguishedFolderId'))) {
266
+ return makeResponse(mockGetMailFoldersResponse);
267
+ }
268
+
269
+ // CreateFolder
270
+ if (hasTag(body, 'CreateFolder')) {
271
+ return makeResponse(mockCreateMailFolderResponse);
272
+ }
273
+
274
+ // UpdateFolder (rename)
275
+ if (hasTag(body, 'UpdateFolder')) {
276
+ return makeResponse(mockUpdateMailFolderResponse);
277
+ }
278
+
279
+ // DeleteFolder
280
+ if (hasTag(body, 'DeleteFolder')) {
281
+ return makeResponse(mockDeleteMailFolderResponse);
282
+ }
283
+
284
+ // GetRoomLists
285
+ if (hasTag(body, 'GetRoomLists')) {
286
+ return makeResponse(mockGetRoomsResponse);
287
+ }
288
+
289
+ // GetRooms
290
+ if (hasTag(body, 'GetRooms')) {
291
+ return makeResponse(mockGetRoomsFromListResponse);
292
+ }
293
+
294
+ // ExpandDL (search rooms)
295
+ if (hasTag(body, 'ExpandDL')) {
296
+ return makeResponse(mockSearchRoomsResponse);
297
+ }
298
+
299
+ // GetSchedule (findtime)
300
+ if (hasTag(body, 'GetSchedule')) {
301
+ return makeResponse(mockGetScheduleResponse);
302
+ }
303
+
304
+ // CreateAttachment
305
+ if (hasTag(body, 'CreateAttachment')) {
306
+ return makeResponse(mockAddAttachmentResponse);
307
+ }
308
+
309
+ // Default: return empty calendar
310
+ return makeResponse(mockCalendarEventsEmptyResponse);
311
+ }
312
+
313
+ // Microsoft Graph API (files commands)
314
+ if (url.includes('graph.microsoft.com/v1.0')) {
315
+ // List files
316
+ if (url.includes('/me/drive/root/children') || (url.includes('/me/drive/items') && url.includes('/children'))) {
317
+ return makeJsonResponse(mockGraphListFilesResponse);
318
+ }
319
+ // Search files
320
+ if (url.includes('/me/drive/root/search')) {
321
+ return makeJsonResponse(mockGraphSearchFilesResponse);
322
+ }
323
+ // Upload file
324
+ if (url.includes('/me/drive/items/') && url.includes('/content')) {
325
+ return makeJsonResponse(mockGraphUploadResponse);
326
+ }
327
+ // Get file metadata
328
+ if (
329
+ url.includes('/me/drive/items/') &&
330
+ !url.includes('/children') &&
331
+ !url.includes('/content') &&
332
+ !url.includes('/createLink') &&
333
+ !url.includes('/checkin') &&
334
+ !url.includes('/checkout') &&
335
+ !url.includes('/createUploadSession')
336
+ ) {
337
+ return makeJsonResponse(mockGraphGetFileMetadataResponse);
338
+ }
339
+ // Create upload session
340
+ if (url.includes('/createUploadSession')) {
341
+ return makeJsonResponse(mockGraphCreateUploadSessionResponse);
342
+ }
343
+ // Delete file
344
+ if ((url.includes('/me/drive/items/') || url.includes('/me/drive/')) && init?.method === 'DELETE') {
345
+ return makeJsonResponse(mockGraphDeleteResponse);
346
+ }
347
+ // Share file / Office collaboration
348
+ if (url.includes('/me/drive/items/') && url.includes('/createLink')) {
349
+ return makeJsonResponse(mockGraphShareResponse);
350
+ }
351
+ // Checkin
352
+ if (url.includes('/me/drive/items/') && url.includes('/checkin')) {
353
+ return makeJsonResponse(mockGraphCheckinResponse);
354
+ }
355
+ // Checkout
356
+ if (url.includes('/me/drive/items/') && url.includes('/checkout')) {
357
+ return makeJsonResponse({});
358
+ }
359
+ return makeJsonResponse({ value: [] });
360
+ }
361
+
362
+ // Default: 404
363
+ return new Response('Not found', { status: 404 });
364
+ };
365
+ }
366
+
367
+ // Setup/teardown helpers for use in tests
368
+ export function setupMockFetch(): void {
369
+ globalThis.fetch = createMockFetch() as typeof fetch;
370
+ }
371
+
372
+ export function teardownMockFetch(): void {
373
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
374
+ (globalThis as any).fetch = undefined;
375
+ }