@littlebearapps/outlook-assistant 3.12.0 → 3.12.1
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/.env.example +8 -0
- package/README.md +8 -3
- package/advanced/index.js +80 -36
- package/calendar/attendees.js +101 -0
- package/calendar/cancel.js +5 -4
- package/calendar/create.js +15 -4
- package/calendar/decline.js +10 -5
- package/calendar/index.js +33 -10
- package/calendar/list.js +10 -19
- package/calendar/update.js +65 -33
- package/config.js +30 -0
- package/contacts/index.js +2 -1
- package/email/attachments.js +7 -35
- package/email/conversations.js +155 -88
- package/email/delta.js +29 -9
- package/email/draft.js +66 -9
- package/email/export.js +5 -79
- package/email/folder-utils.js +0 -123
- package/email/index.js +12 -10
- package/email/search.js +9 -4
- package/folder/index.js +1 -1
- package/folder/resolve.js +3 -2
- package/index.js +9 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +324 -218
- package/utils/mock-data.js +3 -0
- package/utils/odata-helpers.js +24 -0
- package/utils/safe-write.js +151 -0
- package/calendar/accept.js +0 -72
package/calendar/update.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - subject
|
|
12
12
|
* - start (ISO string or {dateTime, timeZone} object)
|
|
13
13
|
* - end (same shape as start)
|
|
14
|
-
* - attendees (full replacement list
|
|
14
|
+
* - attendees (full replacement list; email strings or {email, type})
|
|
15
15
|
* - body (sent as HTML)
|
|
16
16
|
* - location (displayName)
|
|
17
17
|
* - isOnlineMeeting
|
|
@@ -21,12 +21,16 @@
|
|
|
21
21
|
* - categories (full replacement array of category names)
|
|
22
22
|
* - reminderMinutesBeforeStart
|
|
23
23
|
*
|
|
24
|
-
* `dryRun: true` returns a preview of the PATCH payload without
|
|
25
|
-
*
|
|
24
|
+
* `dryRun: true` returns a preview of the PATCH payload without changing
|
|
25
|
+
* anything — useful for confirming behaviour before mutating real data.
|
|
26
|
+
* When an attendee has no explicit type, the event's current attendees are
|
|
27
|
+
* read first (also on dryRun) so existing optional/resource attendees keep
|
|
28
|
+
* their type (#249).
|
|
26
29
|
*/
|
|
27
30
|
const { callGraphAPI } = require('../utils/graph-api');
|
|
28
31
|
const { ensureAuthenticated } = require('../auth');
|
|
29
32
|
const { DEFAULT_TIMEZONE } = require('../config');
|
|
33
|
+
const { normaliseAttendees, buildAttendees } = require('./attendees');
|
|
30
34
|
|
|
31
35
|
const SENSITIVITY_VALUES = new Set([
|
|
32
36
|
'normal',
|
|
@@ -82,6 +86,9 @@ async function handleUpdateEvent(args) {
|
|
|
82
86
|
// Graph treats absent properties as "no change", so we never overwrite
|
|
83
87
|
// something the user didn't intend to touch.
|
|
84
88
|
const patch = {};
|
|
89
|
+
// Attendee entries without an explicit type, which need the event's
|
|
90
|
+
// current attendees to resolve (#249).
|
|
91
|
+
let untypedAttendees = null;
|
|
85
92
|
|
|
86
93
|
if (subject !== undefined) patch.subject = subject;
|
|
87
94
|
|
|
@@ -102,10 +109,17 @@ async function handleUpdateEvent(args) {
|
|
|
102
109
|
if (attendees !== undefined) {
|
|
103
110
|
// Replaces the full attendee list — Graph PATCH on this property is
|
|
104
111
|
// not additive. Caller must pass the desired complete list.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
})
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = normaliseAttendees(attendees || []);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: 'text', text: error.message }],
|
|
118
|
+
isError: true,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
patch.attendees = buildAttendees(entries);
|
|
122
|
+
if (entries.some((entry) => !entry.type)) untypedAttendees = entries;
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
if (body !== undefined) {
|
|
@@ -193,34 +207,52 @@ async function handleUpdateEvent(args) {
|
|
|
193
207
|
};
|
|
194
208
|
}
|
|
195
209
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
try {
|
|
211
|
+
let accessToken;
|
|
212
|
+
|
|
213
|
+
// Keep the type of attendees already on the event: an entry without an
|
|
214
|
+
// explicit type takes its current type, so a room or optional attendee
|
|
215
|
+
// isn't turned into a required one. Runs before the dryRun return so the
|
|
216
|
+
// preview shows the resolved types.
|
|
217
|
+
if (untypedAttendees) {
|
|
218
|
+
accessToken = await ensureAuthenticated();
|
|
219
|
+
const current = await callGraphAPI(
|
|
220
|
+
accessToken,
|
|
221
|
+
'GET',
|
|
222
|
+
`me/events/${eventId}`,
|
|
223
|
+
null,
|
|
224
|
+
{ $select: 'attendees' }
|
|
225
|
+
);
|
|
226
|
+
patch.attendees = buildAttendees(untypedAttendees, current?.attendees);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// dryRun: show the caller what would be sent without changing anything.
|
|
230
|
+
if (dryRun) {
|
|
231
|
+
return {
|
|
232
|
+
content: [
|
|
233
|
+
{
|
|
234
|
+
type: 'text',
|
|
235
|
+
text: [
|
|
236
|
+
`**Dry run** — would PATCH \`me/events/${eventId}\` with:`,
|
|
237
|
+
'',
|
|
238
|
+
'```json',
|
|
239
|
+
JSON.stringify(patch, null, 2),
|
|
240
|
+
'```',
|
|
241
|
+
'',
|
|
242
|
+
`Fields that would change: ${Object.keys(patch).join(', ')}`,
|
|
243
|
+
].join('\n'),
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
_meta: {
|
|
247
|
+
eventId,
|
|
248
|
+
dryRun: true,
|
|
249
|
+
patch,
|
|
250
|
+
fieldsChanged: Object.keys(patch),
|
|
211
251
|
},
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
eventId,
|
|
215
|
-
dryRun: true,
|
|
216
|
-
patch,
|
|
217
|
-
fieldsChanged: Object.keys(patch),
|
|
218
|
-
},
|
|
219
|
-
};
|
|
220
|
-
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
221
254
|
|
|
222
|
-
|
|
223
|
-
const accessToken = await ensureAuthenticated();
|
|
255
|
+
accessToken = accessToken || (await ensureAuthenticated());
|
|
224
256
|
const endpoint = `me/events/${eventId}`;
|
|
225
257
|
|
|
226
258
|
const response = await callGraphAPI(accessToken, 'PATCH', endpoint, patch);
|
package/config.js
CHANGED
|
@@ -123,6 +123,29 @@ const BASE_SCOPES = [
|
|
|
123
123
|
// 'Place.Read.All', // find-meeting-rooms tool
|
|
124
124
|
];
|
|
125
125
|
|
|
126
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse OUTLOOK_REQUEST_TIMEOUT_MS: per-attempt Graph inactivity timeout
|
|
130
|
+
* (ms with no data received). Unset or invalid → 60000 (invalid values warn).
|
|
131
|
+
* @param {string|undefined} raw
|
|
132
|
+
* @returns {number}
|
|
133
|
+
*/
|
|
134
|
+
function parseRequestTimeoutMs(raw) {
|
|
135
|
+
if (raw === undefined || String(raw).trim() === '') {
|
|
136
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
137
|
+
}
|
|
138
|
+
const value = Number(raw);
|
|
139
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
140
|
+
console.warn(
|
|
141
|
+
`[outlook-assistant] OUTLOOK_REQUEST_TIMEOUT_MS="${raw}" is not a positive integer. ` +
|
|
142
|
+
`Using the default of ${DEFAULT_REQUEST_TIMEOUT_MS} ms.`
|
|
143
|
+
);
|
|
144
|
+
return DEFAULT_REQUEST_TIMEOUT_MS;
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
126
149
|
module.exports = {
|
|
127
150
|
// Server information
|
|
128
151
|
SERVER_NAME: 'outlook-assistant',
|
|
@@ -196,6 +219,13 @@ module.exports = {
|
|
|
196
219
|
// Immutable IDs (opt-in: IDs persist through folder moves)
|
|
197
220
|
USE_IMMUTABLE_IDS: process.env.OUTLOOK_IMMUTABLE_IDS === 'true',
|
|
198
221
|
|
|
222
|
+
// Per-attempt Graph inactivity timeout: an attempt that receives no data
|
|
223
|
+
// for this many ms is abandoned (not an overall deadline). Throttled and
|
|
224
|
+
// transient responses are retried by utils/graph-api.js.
|
|
225
|
+
REQUEST_TIMEOUT_MS: parseRequestTimeoutMs(
|
|
226
|
+
process.env.OUTLOOK_REQUEST_TIMEOUT_MS
|
|
227
|
+
),
|
|
228
|
+
|
|
199
229
|
// Timezone — IANA zone (e.g. "Australia/Melbourne", "Europe/London",
|
|
200
230
|
// "America/New_York"). Override per-deployment via OUTLOOK_DEFAULT_TIMEZONE.
|
|
201
231
|
// Default preserves the historic value for backwards compatibility.
|
package/contacts/index.js
CHANGED
|
@@ -8,6 +8,7 @@ const {
|
|
|
8
8
|
callGraphAPIPaginated: _callGraphAPIPaginated,
|
|
9
9
|
} = require('../utils/graph-api');
|
|
10
10
|
const { ensureAuthenticated } = require('../auth');
|
|
11
|
+
const { quoteSearchPhrase } = require('../utils/odata-helpers');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Contact field presets for different use cases
|
|
@@ -597,7 +598,7 @@ async function handleSearchPeople(args) {
|
|
|
597
598
|
|
|
598
599
|
const endpoint = 'me/people';
|
|
599
600
|
const queryParams = {
|
|
600
|
-
$search:
|
|
601
|
+
$search: quoteSearchPhrase(query),
|
|
601
602
|
$top: count,
|
|
602
603
|
$select:
|
|
603
604
|
'id,displayName,scoredEmailAddresses,phones,companyName,jobTitle,department,userPrincipalName,personType',
|
package/email/attachments.js
CHANGED
|
@@ -10,6 +10,7 @@ const _config = require('../config'); // Reserved for future use
|
|
|
10
10
|
const { callGraphAPI } = require('../utils/graph-api');
|
|
11
11
|
const { ensureAuthenticated } = require('../auth');
|
|
12
12
|
const { buildMailboxPrefix } = require('../utils/mailbox');
|
|
13
|
+
const { writeClaimedFile } = require('../utils/safe-write');
|
|
13
14
|
|
|
14
15
|
const MAX_FILENAME_LENGTH = 200;
|
|
15
16
|
|
|
@@ -38,38 +39,6 @@ function safeAttachmentFilename(name) {
|
|
|
38
39
|
return base.slice(0, MAX_FILENAME_LENGTH - ext.length) + ext;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
/**
|
|
42
|
-
* Write a buffer into outputDir without ever overwriting an existing entry
|
|
43
|
-
* or following a symlink: `wx` fails on any existing path (including a
|
|
44
|
-
* dangling symlink), so collisions get a numbered suffix instead.
|
|
45
|
-
* @param {string} outputDir - Target directory
|
|
46
|
-
* @param {string} filename - Safe basename from safeAttachmentFilename
|
|
47
|
-
* @param {Buffer} buffer - File contents
|
|
48
|
-
* @returns {string} - Absolute path actually written
|
|
49
|
-
*/
|
|
50
|
-
function writeUniqueFile(outputDir, filename, buffer) {
|
|
51
|
-
const root = path.resolve(outputDir);
|
|
52
|
-
const ext = path.extname(filename);
|
|
53
|
-
const stem = filename.slice(0, filename.length - ext.length);
|
|
54
|
-
|
|
55
|
-
for (let i = 0; i < 1000; i++) {
|
|
56
|
-
const candidate = path.join(
|
|
57
|
-
root,
|
|
58
|
-
i === 0 ? filename : `${stem}-${i}${ext}`
|
|
59
|
-
);
|
|
60
|
-
if (path.dirname(candidate) !== root) {
|
|
61
|
-
throw new Error('Refusing to write attachment outside outputDir');
|
|
62
|
-
}
|
|
63
|
-
try {
|
|
64
|
-
fs.writeFileSync(candidate, buffer, { flag: 'wx' });
|
|
65
|
-
return candidate;
|
|
66
|
-
} catch (error) {
|
|
67
|
-
if (error.code !== 'EEXIST') throw error;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
throw new Error(`Too many files named ${filename} in ${root}`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
42
|
/**
|
|
74
43
|
* List attachments for a specific email
|
|
75
44
|
* @param {object} args - Tool arguments
|
|
@@ -248,9 +217,13 @@ async function handleDownloadAttachment(args) {
|
|
|
248
217
|
|
|
249
218
|
// Decode base64 and save to file
|
|
250
219
|
const buffer = Buffer.from(contentBytes, 'base64');
|
|
251
|
-
const
|
|
220
|
+
const safeName = safeAttachmentFilename(filename);
|
|
221
|
+
const ext = path.extname(safeName);
|
|
222
|
+
const outputPath = writeClaimedFile(
|
|
252
223
|
outputDir,
|
|
253
|
-
|
|
224
|
+
safeName.slice(0, safeName.length - ext.length),
|
|
225
|
+
ext.slice(1),
|
|
226
|
+
null,
|
|
254
227
|
buffer
|
|
255
228
|
);
|
|
256
229
|
|
|
@@ -445,5 +418,4 @@ module.exports = {
|
|
|
445
418
|
// Shared with email/export.js so exported attachments get the same
|
|
446
419
|
// GHSA-755c-c45g-69rv filename hardening.
|
|
447
420
|
safeAttachmentFilename,
|
|
448
|
-
writeUniqueFile,
|
|
449
421
|
};
|
package/email/conversations.js
CHANGED
|
@@ -14,6 +14,8 @@ const { ensureAuthenticated } = require('../auth');
|
|
|
14
14
|
const { getEmailFields } = require('../utils/field-presets');
|
|
15
15
|
const { resolveFolderPath } = require('./folder-utils');
|
|
16
16
|
const { buildMailboxPrefix } = require('../utils/mailbox');
|
|
17
|
+
const { writeClaimedFile, makeClaimedDir } = require('../utils/safe-write');
|
|
18
|
+
const { escapeODataString } = require('../utils/odata-helpers');
|
|
17
19
|
const {
|
|
18
20
|
formatEmailContent,
|
|
19
21
|
formatEmailsAsCSV,
|
|
@@ -250,6 +252,102 @@ async function handleListConversations(args) {
|
|
|
250
252
|
}
|
|
251
253
|
}
|
|
252
254
|
|
|
255
|
+
// Upper bounds on the messages one conversation read/export will load. The
|
|
256
|
+
// inline read returns every message in the tool result, so it stops sooner.
|
|
257
|
+
const GET_CONVERSATION_MESSAGE_LIMIT = 100;
|
|
258
|
+
const EXPORT_CONVERSATION_MESSAGE_LIMIT = 1000;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Fetch the messages in a conversation, oldest first (or newest first).
|
|
262
|
+
*
|
|
263
|
+
* Graph rejects `$filter=conversationId eq '…'` combined with `$orderby` on
|
|
264
|
+
* personal Microsoft accounts (400 InefficientFilter), so the query carries no
|
|
265
|
+
* `$orderby`: the pages are fetched and the messages are sorted here. Paging
|
|
266
|
+
* stops at the caller's `limit` or if Graph repeats a nextLink; either
|
|
267
|
+
* way the result is marked truncated. (Not callGraphAPIPaginated: it can't
|
|
268
|
+
* report truncation or catch a repeated nextLink.)
|
|
269
|
+
* @param {string} accessToken - Access token
|
|
270
|
+
* @param {string} prefix - Mailbox prefix (`me` or `users/{mailbox}`)
|
|
271
|
+
* @param {string} conversationId - Conversation ID
|
|
272
|
+
* @param {string} selectFields - `$select` fields
|
|
273
|
+
* @param {object} options
|
|
274
|
+
* @param {number} options.limit - Most messages to load
|
|
275
|
+
* @param {boolean} [options.newestFirst=false] - Sort newest first instead
|
|
276
|
+
* @returns {Promise<{messages: Array<object>, truncated: boolean}>}
|
|
277
|
+
*/
|
|
278
|
+
async function fetchConversationMessages(
|
|
279
|
+
accessToken,
|
|
280
|
+
prefix,
|
|
281
|
+
conversationId,
|
|
282
|
+
selectFields,
|
|
283
|
+
{ limit, newestFirst = false }
|
|
284
|
+
) {
|
|
285
|
+
let messages = [];
|
|
286
|
+
let truncated = false;
|
|
287
|
+
const seenLinks = new Set();
|
|
288
|
+
let url = `${prefix}/messages`;
|
|
289
|
+
let queryParams = {
|
|
290
|
+
$select: selectFields,
|
|
291
|
+
$filter: `conversationId eq '${escapeODataString(String(conversationId))}'`,
|
|
292
|
+
$top: Math.min(100, limit),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
while (url) {
|
|
296
|
+
const response = await callGraphAPI(
|
|
297
|
+
accessToken,
|
|
298
|
+
'GET',
|
|
299
|
+
url,
|
|
300
|
+
null,
|
|
301
|
+
queryParams
|
|
302
|
+
);
|
|
303
|
+
messages.push(...(response.value || []));
|
|
304
|
+
const nextLink = response['@odata.nextLink'];
|
|
305
|
+
if (messages.length > limit) {
|
|
306
|
+
messages = messages.slice(0, limit);
|
|
307
|
+
truncated = true;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
if (nextLink && (messages.length >= limit || seenLinks.has(nextLink))) {
|
|
311
|
+
truncated = true;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
if (nextLink) seenLinks.add(nextLink);
|
|
315
|
+
url = nextLink;
|
|
316
|
+
queryParams = {}; // the nextLink already carries every parameter
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { messages: sortByReceivedDate(messages, newestFirst), truncated };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Note added to output when a conversation hit a fetch limit.
|
|
324
|
+
* @param {number} count - Messages loaded
|
|
325
|
+
* @returns {string}
|
|
326
|
+
*/
|
|
327
|
+
function truncationNote(count) {
|
|
328
|
+
return `**Note**: Conversation truncated at ${count} messages.`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Sort messages by receivedDateTime (stable; messages without a valid date
|
|
333
|
+
* go last in either direction).
|
|
334
|
+
* @param {Array<object>} messages - Messages to sort
|
|
335
|
+
* @param {boolean} newestFirst - Sort newest first instead of oldest first
|
|
336
|
+
* @returns {Array<object>} - New sorted array
|
|
337
|
+
*/
|
|
338
|
+
function sortByReceivedDate(messages, newestFirst) {
|
|
339
|
+
const time = (msg) => {
|
|
340
|
+
const t = Date.parse(msg.receivedDateTime);
|
|
341
|
+
return Number.isNaN(t) ? null : t;
|
|
342
|
+
};
|
|
343
|
+
return [...messages].sort((a, b) => {
|
|
344
|
+
const ta = time(a);
|
|
345
|
+
const tb = time(b);
|
|
346
|
+
if (ta === null || tb === null) return (ta === null) - (tb === null);
|
|
347
|
+
return newestFirst ? tb - ta : ta - tb;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
253
351
|
/**
|
|
254
352
|
* Get conversation handler - retrieves all messages in a thread
|
|
255
353
|
* @param {object} args - Tool arguments
|
|
@@ -279,41 +377,13 @@ async function handleGetConversation(args) {
|
|
|
279
377
|
const selectFields = getEmailFields(fieldPreset);
|
|
280
378
|
|
|
281
379
|
// Search all folders for messages with this conversation ID
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
let response;
|
|
291
|
-
try {
|
|
292
|
-
response = await callGraphAPI(
|
|
293
|
-
accessToken,
|
|
294
|
-
'GET',
|
|
295
|
-
endpoint,
|
|
296
|
-
null,
|
|
297
|
-
queryParams
|
|
298
|
-
);
|
|
299
|
-
} catch (apiError) {
|
|
300
|
-
if (
|
|
301
|
-
apiError.message.includes('ErrorInvalidUrlQueryFilter') ||
|
|
302
|
-
apiError.message.includes('InefficientFilter') ||
|
|
303
|
-
apiError.message.includes('filter')
|
|
304
|
-
) {
|
|
305
|
-
return {
|
|
306
|
-
content: [
|
|
307
|
-
{
|
|
308
|
-
type: 'text',
|
|
309
|
-
text: `Conversation retrieval by conversationId is not supported on personal Microsoft accounts. Use read-email with individual message IDs instead.`,
|
|
310
|
-
},
|
|
311
|
-
],
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
throw apiError;
|
|
315
|
-
}
|
|
316
|
-
const messages = response.value || [];
|
|
380
|
+
const { messages, truncated } = await fetchConversationMessages(
|
|
381
|
+
accessToken,
|
|
382
|
+
prefix,
|
|
383
|
+
conversationId,
|
|
384
|
+
selectFields,
|
|
385
|
+
{ limit: GET_CONVERSATION_MESSAGE_LIMIT }
|
|
386
|
+
);
|
|
317
387
|
|
|
318
388
|
if (messages.length === 0) {
|
|
319
389
|
return {
|
|
@@ -332,6 +402,7 @@ async function handleGetConversation(args) {
|
|
|
332
402
|
output.push(`**Subject**: ${messages[0].subject || '(no subject)'}`);
|
|
333
403
|
output.push(`**Messages**: ${messages.length}`);
|
|
334
404
|
output.push(`**Conversation ID**: \`${conversationId}\`\n`);
|
|
405
|
+
if (truncated) output.push(`${truncationNote(messages.length)}\n`);
|
|
335
406
|
output.push('---\n');
|
|
336
407
|
|
|
337
408
|
messages.forEach((msg, index) => {
|
|
@@ -351,6 +422,7 @@ async function handleGetConversation(args) {
|
|
|
351
422
|
conversationId,
|
|
352
423
|
messageCount: messages.length,
|
|
353
424
|
subject: messages[0]?.subject,
|
|
425
|
+
truncated,
|
|
354
426
|
},
|
|
355
427
|
};
|
|
356
428
|
} catch (error) {
|
|
@@ -416,42 +488,16 @@ async function handleExportConversation(args) {
|
|
|
416
488
|
const accessToken = await ensureAuthenticated();
|
|
417
489
|
|
|
418
490
|
// Get all messages in conversation
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
let response;
|
|
429
|
-
try {
|
|
430
|
-
response = await callGraphAPI(
|
|
431
|
-
accessToken,
|
|
432
|
-
'GET',
|
|
433
|
-
endpoint,
|
|
434
|
-
null,
|
|
435
|
-
queryParams
|
|
436
|
-
);
|
|
437
|
-
} catch (apiError) {
|
|
438
|
-
if (
|
|
439
|
-
apiError.message.includes('ErrorInvalidUrlQueryFilter') ||
|
|
440
|
-
apiError.message.includes('InefficientFilter') ||
|
|
441
|
-
apiError.message.includes('filter')
|
|
442
|
-
) {
|
|
443
|
-
return {
|
|
444
|
-
content: [
|
|
445
|
-
{
|
|
446
|
-
type: 'text',
|
|
447
|
-
text: `Conversation export is not supported on personal Microsoft accounts. Use export with target=message and individual message IDs instead.`,
|
|
448
|
-
},
|
|
449
|
-
],
|
|
450
|
-
};
|
|
491
|
+
const { messages, truncated } = await fetchConversationMessages(
|
|
492
|
+
accessToken,
|
|
493
|
+
prefix,
|
|
494
|
+
conversationId,
|
|
495
|
+
getEmailFields('export'),
|
|
496
|
+
{
|
|
497
|
+
limit: EXPORT_CONVERSATION_MESSAGE_LIMIT,
|
|
498
|
+
newestFirst: order === 'reverse',
|
|
451
499
|
}
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
const messages = response.value || [];
|
|
500
|
+
);
|
|
455
501
|
|
|
456
502
|
if (messages.length === 0) {
|
|
457
503
|
return {
|
|
@@ -475,16 +521,19 @@ async function handleExportConversation(args) {
|
|
|
475
521
|
const date = formatDateForFilename(messages[0].receivedDateTime);
|
|
476
522
|
const filenameBase = `${date}_${subject}_conversation`;
|
|
477
523
|
|
|
524
|
+
// Every file is written exclusively (never overwrites, never follows a
|
|
525
|
+
// symlink); a name already taken gets a -1, -2, … suffix.
|
|
526
|
+
const writeExport = (dir, base, extension, content) =>
|
|
527
|
+
writeClaimedFile(dir, base, extension, null, content, 'utf8');
|
|
528
|
+
|
|
478
529
|
const exportedFiles = [];
|
|
479
530
|
const exportStats = { messages: messages.length, attachments: 0, bytes: 0 };
|
|
480
531
|
|
|
481
532
|
switch (format) {
|
|
482
533
|
case 'eml': {
|
|
483
|
-
// Export each message as individual .eml file
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
fs.mkdirSync(emlDir, { recursive: true });
|
|
487
|
-
}
|
|
534
|
+
// Export each message as individual .eml file, into a directory this
|
|
535
|
+
// export creates (never an existing one, which could be a symlink).
|
|
536
|
+
const emlDir = makeClaimedDir(resolvedDir, filenameBase);
|
|
488
537
|
|
|
489
538
|
for (let i = 0; i < messages.length; i++) {
|
|
490
539
|
const msg = messages[i];
|
|
@@ -494,11 +543,12 @@ async function handleExportConversation(args) {
|
|
|
494
543
|
prefix
|
|
495
544
|
);
|
|
496
545
|
const msgDate = formatDateForFilename(msg.receivedDateTime);
|
|
497
|
-
const emlPath =
|
|
546
|
+
const emlPath = writeExport(
|
|
498
547
|
emlDir,
|
|
499
|
-
`${i + 1}_${msgDate}_${sanitizeForFilename(msg.from?.emailAddress?.name || 'unknown', 20)}
|
|
548
|
+
`${i + 1}_${msgDate}_${sanitizeForFilename(msg.from?.emailAddress?.name || 'unknown', 20)}`,
|
|
549
|
+
'eml',
|
|
550
|
+
mimeContent
|
|
500
551
|
);
|
|
501
|
-
fs.writeFileSync(emlPath, mimeContent, 'utf8');
|
|
502
552
|
exportStats.bytes += Buffer.byteLength(mimeContent, 'utf8');
|
|
503
553
|
exportedFiles.push(emlPath);
|
|
504
554
|
}
|
|
@@ -507,7 +557,6 @@ async function handleExportConversation(args) {
|
|
|
507
557
|
|
|
508
558
|
case 'mbox': {
|
|
509
559
|
// Export all messages to single MBOX file
|
|
510
|
-
const mboxPath = path.join(resolvedDir, `${filenameBase}.mbox`);
|
|
511
560
|
let mboxContent = '';
|
|
512
561
|
|
|
513
562
|
for (const msg of messages) {
|
|
@@ -526,7 +575,12 @@ async function handleExportConversation(args) {
|
|
|
526
575
|
mboxContent += '\n\n';
|
|
527
576
|
}
|
|
528
577
|
|
|
529
|
-
|
|
578
|
+
const mboxPath = writeExport(
|
|
579
|
+
resolvedDir,
|
|
580
|
+
filenameBase,
|
|
581
|
+
'mbox',
|
|
582
|
+
mboxContent
|
|
583
|
+
);
|
|
530
584
|
exportStats.bytes = Buffer.byteLength(mboxContent, 'utf8');
|
|
531
585
|
exportedFiles.push(mboxPath);
|
|
532
586
|
break;
|
|
@@ -534,7 +588,6 @@ async function handleExportConversation(args) {
|
|
|
534
588
|
|
|
535
589
|
case 'markdown': {
|
|
536
590
|
// Export as threaded Markdown document
|
|
537
|
-
const mdPath = path.join(resolvedDir, `${filenameBase}.md`);
|
|
538
591
|
const mdContent = [];
|
|
539
592
|
|
|
540
593
|
mdContent.push(
|
|
@@ -581,7 +634,7 @@ async function handleExportConversation(args) {
|
|
|
581
634
|
}
|
|
582
635
|
|
|
583
636
|
const content = mdContent.join('\n');
|
|
584
|
-
|
|
637
|
+
const mdPath = writeExport(resolvedDir, filenameBase, 'md', content);
|
|
585
638
|
exportStats.bytes = Buffer.byteLength(content, 'utf8');
|
|
586
639
|
exportedFiles.push(mdPath);
|
|
587
640
|
break;
|
|
@@ -589,7 +642,6 @@ async function handleExportConversation(args) {
|
|
|
589
642
|
|
|
590
643
|
case 'json': {
|
|
591
644
|
// Export as JSON
|
|
592
|
-
const jsonPath = path.join(resolvedDir, `${filenameBase}.json`);
|
|
593
645
|
const jsonContent = JSON.stringify(
|
|
594
646
|
{
|
|
595
647
|
conversationId,
|
|
@@ -602,7 +654,12 @@ async function handleExportConversation(args) {
|
|
|
602
654
|
2
|
|
603
655
|
);
|
|
604
656
|
|
|
605
|
-
|
|
657
|
+
const jsonPath = writeExport(
|
|
658
|
+
resolvedDir,
|
|
659
|
+
filenameBase,
|
|
660
|
+
'json',
|
|
661
|
+
jsonContent
|
|
662
|
+
);
|
|
606
663
|
exportStats.bytes = Buffer.byteLength(jsonContent, 'utf8');
|
|
607
664
|
exportedFiles.push(jsonPath);
|
|
608
665
|
break;
|
|
@@ -610,7 +667,6 @@ async function handleExportConversation(args) {
|
|
|
610
667
|
|
|
611
668
|
case 'html': {
|
|
612
669
|
// Export as HTML document
|
|
613
|
-
const htmlPath = path.join(resolvedDir, `${filenameBase}.html`);
|
|
614
670
|
const htmlContent = [];
|
|
615
671
|
|
|
616
672
|
htmlContent.push('<!DOCTYPE html>');
|
|
@@ -667,7 +723,12 @@ async function handleExportConversation(args) {
|
|
|
667
723
|
|
|
668
724
|
htmlContent.push('</body></html>');
|
|
669
725
|
const content = htmlContent.join('\n');
|
|
670
|
-
|
|
726
|
+
const htmlPath = writeExport(
|
|
727
|
+
resolvedDir,
|
|
728
|
+
filenameBase,
|
|
729
|
+
'html',
|
|
730
|
+
content
|
|
731
|
+
);
|
|
671
732
|
exportStats.bytes = Buffer.byteLength(content, 'utf8');
|
|
672
733
|
exportedFiles.push(htmlPath);
|
|
673
734
|
break;
|
|
@@ -675,9 +736,13 @@ async function handleExportConversation(args) {
|
|
|
675
736
|
|
|
676
737
|
case 'csv': {
|
|
677
738
|
// Export as CSV
|
|
678
|
-
const csvPath = path.join(resolvedDir, `${filenameBase}.csv`);
|
|
679
739
|
const csvContent = formatEmailsAsCSV(messages);
|
|
680
|
-
|
|
740
|
+
const csvPath = writeExport(
|
|
741
|
+
resolvedDir,
|
|
742
|
+
filenameBase,
|
|
743
|
+
'csv',
|
|
744
|
+
csvContent
|
|
745
|
+
);
|
|
681
746
|
exportStats.bytes = Buffer.byteLength(csvContent, 'utf8');
|
|
682
747
|
exportedFiles.push(csvPath);
|
|
683
748
|
break;
|
|
@@ -699,6 +764,7 @@ async function handleExportConversation(args) {
|
|
|
699
764
|
output.push(`**Subject**: ${messages[0].subject || '(no subject)'}`);
|
|
700
765
|
output.push(`**Format**: ${format.toUpperCase()}`);
|
|
701
766
|
output.push(`**Messages**: ${exportStats.messages}`);
|
|
767
|
+
if (truncated) output.push(truncationNote(exportStats.messages));
|
|
702
768
|
output.push(`**Total Size**: ${sizeFormatted}`);
|
|
703
769
|
output.push(`**Output Directory**: ${resolvedDir}\n`);
|
|
704
770
|
output.push('## Exported Files\n');
|
|
@@ -717,6 +783,7 @@ async function handleExportConversation(args) {
|
|
|
717
783
|
messageCount: exportStats.messages,
|
|
718
784
|
bytes: exportStats.bytes,
|
|
719
785
|
files: exportedFiles,
|
|
786
|
+
truncated,
|
|
720
787
|
},
|
|
721
788
|
};
|
|
722
789
|
} catch (error) {
|