@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.
@@ -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 of emails)
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 calling
25
- * Graph — useful for confirming behaviour before mutating real data.
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
- patch.attendees = (attendees || []).map((email) => ({
106
- emailAddress: { address: email },
107
- type: 'required',
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
- // dryRun: don't touch Graph; just show the caller what would be sent.
197
- if (dryRun) {
198
- return {
199
- content: [
200
- {
201
- type: 'text',
202
- text: [
203
- `**Dry run** — would PATCH \`me/events/${eventId}\` with:`,
204
- '',
205
- '```json',
206
- JSON.stringify(patch, null, 2),
207
- '```',
208
- '',
209
- `Fields that would change: ${Object.keys(patch).join(', ')}`,
210
- ].join('\n'),
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
- _meta: {
214
- eventId,
215
- dryRun: true,
216
- patch,
217
- fieldsChanged: Object.keys(patch),
218
- },
219
- };
220
- }
252
+ };
253
+ }
221
254
 
222
- try {
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: `"${query}"`,
601
+ $search: quoteSearchPhrase(query),
601
602
  $top: count,
602
603
  $select:
603
604
  'id,displayName,scoredEmailAddresses,phones,companyName,jobTitle,department,userPrincipalName,personType',
@@ -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 outputPath = writeUniqueFile(
220
+ const safeName = safeAttachmentFilename(filename);
221
+ const ext = path.extname(safeName);
222
+ const outputPath = writeClaimedFile(
252
223
  outputDir,
253
- safeAttachmentFilename(filename),
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
  };
@@ -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 endpoint = `${prefix}/messages`;
283
- const queryParams = {
284
- $select: selectFields,
285
- $filter: `conversationId eq '${conversationId}'`,
286
- $orderby: 'receivedDateTime asc',
287
- $top: 100,
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 selectFields = getEmailFields('export');
420
- const endpoint = `${prefix}/messages`;
421
- const queryParams = {
422
- $select: selectFields,
423
- $filter: `conversationId eq '${conversationId}'`,
424
- $orderby: `receivedDateTime ${order === 'reverse' ? 'desc' : 'asc'}`,
425
- $top: 100,
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
- throw apiError;
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
- const emlDir = path.join(resolvedDir, filenameBase);
485
- if (!fs.existsSync(emlDir)) {
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 = path.join(
546
+ const emlPath = writeExport(
498
547
  emlDir,
499
- `${i + 1}_${msgDate}_${sanitizeForFilename(msg.from?.emailAddress?.name || 'unknown', 20)}.eml`
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
- fs.writeFileSync(mboxPath, mboxContent, 'utf8');
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
- fs.writeFileSync(mdPath, content, 'utf8');
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
- fs.writeFileSync(jsonPath, jsonContent, 'utf8');
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
- fs.writeFileSync(htmlPath, content, 'utf8');
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
- fs.writeFileSync(csvPath, csvContent, 'utf8');
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) {