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.
- package/LICENSE +22 -0
- package/README.md +916 -0
- package/package.json +50 -0
- package/src/cli.ts +100 -0
- package/src/commands/auto-reply.ts +182 -0
- package/src/commands/calendar.ts +576 -0
- package/src/commands/counter.ts +87 -0
- package/src/commands/create-event.ts +544 -0
- package/src/commands/delegates.ts +286 -0
- package/src/commands/delete-event.ts +321 -0
- package/src/commands/drafts.ts +502 -0
- package/src/commands/files.ts +532 -0
- package/src/commands/find.ts +195 -0
- package/src/commands/findtime.ts +270 -0
- package/src/commands/folders.ts +177 -0
- package/src/commands/forward-event.ts +49 -0
- package/src/commands/graph-calendar.ts +217 -0
- package/src/commands/login.ts +195 -0
- package/src/commands/mail.ts +950 -0
- package/src/commands/oof.ts +263 -0
- package/src/commands/outlook-categories.ts +173 -0
- package/src/commands/outlook-graph.ts +880 -0
- package/src/commands/planner.ts +1678 -0
- package/src/commands/respond.ts +291 -0
- package/src/commands/rooms.ts +210 -0
- package/src/commands/rules.ts +511 -0
- package/src/commands/schedule.ts +109 -0
- package/src/commands/send.ts +204 -0
- package/src/commands/serve.ts +14 -0
- package/src/commands/sharepoint.ts +179 -0
- package/src/commands/site-pages.ts +163 -0
- package/src/commands/subscribe.ts +103 -0
- package/src/commands/subscriptions.ts +29 -0
- package/src/commands/suggest.ts +155 -0
- package/src/commands/todo.ts +2092 -0
- package/src/commands/update-event.ts +608 -0
- package/src/commands/update.ts +88 -0
- package/src/commands/verify-token.ts +62 -0
- package/src/commands/whoami.ts +74 -0
- package/src/index.ts +190 -0
- package/src/lib/atomic-write.ts +20 -0
- package/src/lib/attach-link-spec.test.ts +24 -0
- package/src/lib/attach-link-spec.ts +70 -0
- package/src/lib/attachments.ts +79 -0
- package/src/lib/auth.ts +192 -0
- package/src/lib/calendar-range.test.ts +41 -0
- package/src/lib/calendar-range.ts +103 -0
- package/src/lib/dates.test.ts +74 -0
- package/src/lib/dates.ts +137 -0
- package/src/lib/delegate-client.test.ts +74 -0
- package/src/lib/delegate-client.ts +322 -0
- package/src/lib/ews-client.ts +3418 -0
- package/src/lib/git-commit.ts +4 -0
- package/src/lib/glitchtip-eligibility.ts +220 -0
- package/src/lib/glitchtip.ts +253 -0
- package/src/lib/global-env.ts +3 -0
- package/src/lib/graph-auth.ts +223 -0
- package/src/lib/graph-calendar-client.test.ts +118 -0
- package/src/lib/graph-calendar-client.ts +112 -0
- package/src/lib/graph-client.test.ts +107 -0
- package/src/lib/graph-client.ts +1058 -0
- package/src/lib/graph-constants.ts +12 -0
- package/src/lib/graph-directory.ts +116 -0
- package/src/lib/graph-event.ts +134 -0
- package/src/lib/graph-schedule.ts +173 -0
- package/src/lib/graph-subscriptions.ts +94 -0
- package/src/lib/graph-user-path.ts +13 -0
- package/src/lib/jwt-utils.ts +34 -0
- package/src/lib/markdown.test.ts +21 -0
- package/src/lib/markdown.ts +174 -0
- package/src/lib/mime-type.ts +106 -0
- package/src/lib/oof-client.test.ts +59 -0
- package/src/lib/oof-client.ts +122 -0
- package/src/lib/outlook-graph-client.test.ts +146 -0
- package/src/lib/outlook-graph-client.ts +649 -0
- package/src/lib/outlook-master-categories.ts +145 -0
- package/src/lib/package-info.ts +59 -0
- package/src/lib/places-client.ts +144 -0
- package/src/lib/planner-client.ts +1226 -0
- package/src/lib/rules-client.ts +178 -0
- package/src/lib/sharepoint-client.ts +101 -0
- package/src/lib/site-pages-client.ts +73 -0
- package/src/lib/todo-client.test.ts +298 -0
- package/src/lib/todo-client.ts +1309 -0
- package/src/lib/url-validation.ts +40 -0
- package/src/lib/utils.ts +45 -0
- package/src/lib/webhook-server.ts +51 -0
- package/src/test/auth.test.ts +104 -0
- package/src/test/cli.integration.test.ts +1083 -0
- package/src/test/ews-client.test.ts +268 -0
- package/src/test/mocks/index.ts +375 -0
- 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, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, ''');
|
|
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(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>')
|
|
25
|
+
.replace(/"/g, '"')
|
|
26
|
+
.replace(/'/g, "'")
|
|
27
|
+
.replace(/&/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
|
+
}
|