@littlebearapps/outlook-assistant 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.env.example +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +422 -0
  4. package/advanced/index.js +652 -0
  5. package/auth/index.js +32 -0
  6. package/auth/oauth-server.js +233 -0
  7. package/auth/token-manager.js +105 -0
  8. package/auth/token-storage.js +359 -0
  9. package/auth/tools.js +159 -0
  10. package/calendar/accept.js +72 -0
  11. package/calendar/cancel.js +72 -0
  12. package/calendar/create.js +115 -0
  13. package/calendar/decline.js +72 -0
  14. package/calendar/delete.js +67 -0
  15. package/calendar/index.js +130 -0
  16. package/calendar/list.js +108 -0
  17. package/categories/index.js +955 -0
  18. package/config.js +95 -0
  19. package/contacts/index.js +754 -0
  20. package/email/attachments.js +365 -0
  21. package/email/conversations.js +666 -0
  22. package/email/delta.js +210 -0
  23. package/email/export.js +572 -0
  24. package/email/folder-utils.js +192 -0
  25. package/email/headers.js +344 -0
  26. package/email/index.js +537 -0
  27. package/email/list.js +136 -0
  28. package/email/mark-as-read.js +114 -0
  29. package/email/mime.js +286 -0
  30. package/email/read.js +161 -0
  31. package/email/search.js +628 -0
  32. package/email/send.js +169 -0
  33. package/folder/create.js +137 -0
  34. package/folder/delete.js +108 -0
  35. package/folder/index.js +112 -0
  36. package/folder/list.js +289 -0
  37. package/folder/move.js +186 -0
  38. package/folder/stats.js +322 -0
  39. package/index.js +162 -0
  40. package/llms.txt +76 -0
  41. package/outlook-auth-server.js +384 -0
  42. package/package.json +97 -0
  43. package/rules/create.js +273 -0
  44. package/rules/index.js +276 -0
  45. package/rules/list.js +216 -0
  46. package/settings/index.js +678 -0
  47. package/utils/field-presets.js +311 -0
  48. package/utils/graph-api.js +268 -0
  49. package/utils/mock-data.js +154 -0
  50. package/utils/odata-helpers.js +33 -0
  51. package/utils/response-formatter.js +457 -0
  52. package/utils/safety.js +123 -0
@@ -0,0 +1,666 @@
1
+ /**
2
+ * Conversation threading functionality
3
+ *
4
+ * Manages email conversation threads - listing, retrieving, and exporting.
5
+ */
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const {
9
+ callGraphAPI,
10
+ callGraphAPIRaw,
11
+ callGraphAPIPaginated: _callGraphAPIPaginated,
12
+ } = require('../utils/graph-api');
13
+ const { ensureAuthenticated } = require('../auth');
14
+ const { getEmailFields } = require('../utils/field-presets');
15
+ const {
16
+ formatEmailContent,
17
+ VERBOSITY,
18
+ } = require('../utils/response-formatter');
19
+
20
+ /**
21
+ * Format a date for filenames
22
+ * @param {string} isoDate - ISO date string
23
+ * @returns {string} - Formatted date string
24
+ */
25
+ function formatDateForFilename(isoDate) {
26
+ if (!isoDate) return 'unknown-date';
27
+ const date = new Date(isoDate);
28
+ return date.toISOString().split('T')[0];
29
+ }
30
+
31
+ /**
32
+ * Sanitize string for filename
33
+ * @param {string} str - String to sanitize
34
+ * @param {number} maxLength - Max length
35
+ * @returns {string} - Sanitized string
36
+ */
37
+ function sanitizeForFilename(str, maxLength = 50) {
38
+ if (!str) return 'untitled';
39
+ return str
40
+ .replace(/[<>:"/\\|?*]/g, '')
41
+ .replace(/\s+/g, '-')
42
+ .substring(0, maxLength)
43
+ .trim();
44
+ }
45
+
46
+ /**
47
+ * List conversations handler - groups emails by conversationId
48
+ * @param {object} args - Tool arguments
49
+ * @param {string} [args.folder] - Folder to search (default: inbox)
50
+ * @param {number} [args.count] - Number of conversations to return (default: 20, max: 50)
51
+ * @param {string} [args.outputVerbosity] - Output verbosity level
52
+ * @returns {object} - MCP response with conversation list
53
+ */
54
+ async function handleListConversations(args) {
55
+ const folder = args.folder || 'inbox';
56
+ const count = Math.min(args.count || 20, 50);
57
+ const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
58
+
59
+ try {
60
+ const accessToken = await ensureAuthenticated();
61
+
62
+ // Get recent emails with conversation fields
63
+ const selectFields = [
64
+ 'id',
65
+ 'subject',
66
+ 'from',
67
+ 'receivedDateTime',
68
+ 'conversationId',
69
+ 'conversationIndex',
70
+ 'isRead',
71
+ 'bodyPreview',
72
+ ].join(',');
73
+
74
+ const endpoint = `me/mailFolders/${folder}/messages`;
75
+ const queryParams = {
76
+ $select: selectFields,
77
+ $orderby: 'receivedDateTime desc',
78
+ $top: 200, // Get more to group
79
+ };
80
+
81
+ const response = await callGraphAPI(
82
+ accessToken,
83
+ 'GET',
84
+ endpoint,
85
+ null,
86
+ queryParams
87
+ );
88
+ const messages = response.value || [];
89
+
90
+ // Group by conversationId
91
+ const conversations = new Map();
92
+
93
+ messages.forEach((msg) => {
94
+ const convId = msg.conversationId;
95
+ if (!conversations.has(convId)) {
96
+ conversations.set(convId, {
97
+ conversationId: convId,
98
+ subject: msg.subject,
99
+ messages: [],
100
+ participants: new Set(),
101
+ firstDate: msg.receivedDateTime,
102
+ lastDate: msg.receivedDateTime,
103
+ unreadCount: 0,
104
+ });
105
+ }
106
+
107
+ const conv = conversations.get(convId);
108
+ conv.messages.push(msg);
109
+ conv.lastDate = msg.receivedDateTime;
110
+ if (msg.from?.emailAddress?.address) {
111
+ conv.participants.add(msg.from.emailAddress.address);
112
+ }
113
+ if (!msg.isRead) {
114
+ conv.unreadCount++;
115
+ }
116
+ });
117
+
118
+ // Convert to array and sort by most recent
119
+ let conversationList = Array.from(conversations.values())
120
+ .sort((a, b) => new Date(b.lastDate) - new Date(a.lastDate))
121
+ .slice(0, count);
122
+
123
+ // Format output
124
+ let output = [];
125
+ output.push(`# Email Conversations\n`);
126
+ output.push(`**Folder**: ${folder}`);
127
+ output.push(`**Conversations**: ${conversationList.length}`);
128
+ output.push(`**Total Messages Scanned**: ${messages.length}\n`);
129
+ output.push('---\n');
130
+
131
+ conversationList.forEach((conv, index) => {
132
+ output.push(`## ${index + 1}. ${conv.subject || '(no subject)'}`);
133
+ output.push(`- **Messages**: ${conv.messages.length}`);
134
+ output.push(`- **Unread**: ${conv.unreadCount}`);
135
+ output.push(
136
+ `- **Participants**: ${Array.from(conv.participants).slice(0, 5).join(', ')}${conv.participants.size > 5 ? '...' : ''}`
137
+ );
138
+ output.push(
139
+ `- **Date Range**: ${formatDateForFilename(conv.firstDate)} → ${formatDateForFilename(conv.lastDate)}`
140
+ );
141
+ output.push(`- **Conversation ID**: \`${conv.conversationId}\``);
142
+
143
+ if (verbosity === VERBOSITY.FULL && conv.messages.length > 0) {
144
+ output.push(
145
+ `\n**Latest Preview**: ${conv.messages[0].bodyPreview?.substring(0, 100) || ''}...`
146
+ );
147
+ }
148
+ output.push('');
149
+ });
150
+
151
+ return {
152
+ content: [
153
+ {
154
+ type: 'text',
155
+ text: output.join('\n'),
156
+ },
157
+ ],
158
+ _meta: {
159
+ folder,
160
+ conversationCount: conversationList.length,
161
+ totalMessagesScanned: messages.length,
162
+ },
163
+ };
164
+ } catch (error) {
165
+ if (error.message === 'Authentication required') {
166
+ return {
167
+ content: [
168
+ {
169
+ type: 'text',
170
+ text: "Authentication required. Please use the 'authenticate' tool first.",
171
+ },
172
+ ],
173
+ };
174
+ }
175
+ return {
176
+ content: [
177
+ { type: 'text', text: `Error listing conversations: ${error.message}` },
178
+ ],
179
+ };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Get conversation handler - retrieves all messages in a thread
185
+ * @param {object} args - Tool arguments
186
+ * @param {string} args.conversationId - Conversation ID (required)
187
+ * @param {boolean} [args.includeHeaders] - Include email headers
188
+ * @param {string} [args.outputVerbosity] - Output verbosity level
189
+ * @returns {object} - MCP response with conversation messages
190
+ */
191
+ async function handleGetConversation(args) {
192
+ const conversationId = args.conversationId;
193
+ const includeHeaders = args.includeHeaders || false;
194
+ const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
195
+
196
+ if (!conversationId) {
197
+ return {
198
+ content: [{ type: 'text', text: 'Conversation ID is required.' }],
199
+ };
200
+ }
201
+
202
+ try {
203
+ const accessToken = await ensureAuthenticated();
204
+
205
+ // Select appropriate fields
206
+ const fieldPreset = includeHeaders ? 'forensic' : 'conversation';
207
+ const selectFields = getEmailFields(fieldPreset);
208
+
209
+ // Search all folders for messages with this conversation ID
210
+ const endpoint = 'me/messages';
211
+ const queryParams = {
212
+ $select: selectFields,
213
+ $filter: `conversationId eq '${conversationId}'`,
214
+ $orderby: 'receivedDateTime asc',
215
+ $top: 100,
216
+ };
217
+
218
+ let response;
219
+ try {
220
+ response = await callGraphAPI(
221
+ accessToken,
222
+ 'GET',
223
+ endpoint,
224
+ null,
225
+ queryParams
226
+ );
227
+ } catch (apiError) {
228
+ if (
229
+ apiError.message.includes('ErrorInvalidUrlQueryFilter') ||
230
+ apiError.message.includes('InefficientFilter') ||
231
+ apiError.message.includes('filter')
232
+ ) {
233
+ return {
234
+ content: [
235
+ {
236
+ type: 'text',
237
+ text: `Conversation retrieval by conversationId is not supported on personal Microsoft accounts. Use read-email with individual message IDs instead.`,
238
+ },
239
+ ],
240
+ };
241
+ }
242
+ throw apiError;
243
+ }
244
+ const messages = response.value || [];
245
+
246
+ if (messages.length === 0) {
247
+ return {
248
+ content: [
249
+ {
250
+ type: 'text',
251
+ text: `No messages found for conversation ID: ${conversationId}`,
252
+ },
253
+ ],
254
+ };
255
+ }
256
+
257
+ // Format output
258
+ let output = [];
259
+ output.push(`# Email Conversation\n`);
260
+ output.push(`**Subject**: ${messages[0].subject || '(no subject)'}`);
261
+ output.push(`**Messages**: ${messages.length}`);
262
+ output.push(`**Conversation ID**: \`${conversationId}\`\n`);
263
+ output.push('---\n');
264
+
265
+ messages.forEach((msg, index) => {
266
+ output.push(`## Message ${index + 1} of ${messages.length}`);
267
+ output.push(formatEmailContent(msg, verbosity, { includeHeaders }));
268
+ output.push('\n---\n');
269
+ });
270
+
271
+ return {
272
+ content: [
273
+ {
274
+ type: 'text',
275
+ text: output.join('\n'),
276
+ },
277
+ ],
278
+ _meta: {
279
+ conversationId,
280
+ messageCount: messages.length,
281
+ subject: messages[0]?.subject,
282
+ },
283
+ };
284
+ } catch (error) {
285
+ if (error.message === 'Authentication required') {
286
+ return {
287
+ content: [
288
+ {
289
+ type: 'text',
290
+ text: "Authentication required. Please use the 'authenticate' tool first.",
291
+ },
292
+ ],
293
+ };
294
+ }
295
+ return {
296
+ content: [
297
+ { type: 'text', text: `Error getting conversation: ${error.message}` },
298
+ ],
299
+ };
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Export conversation handler - exports entire thread to various formats
305
+ * @param {object} args - Tool arguments
306
+ * @param {string} args.conversationId - Conversation ID (required)
307
+ * @param {string} [args.format] - Export format (eml, mbox, markdown, json, html)
308
+ * @param {string} [args.outputDir] - Output directory (required)
309
+ * @param {boolean} [args.includeAttachments] - Include attachments (default: true)
310
+ * @param {string} [args.order] - Message order: 'chronological' or 'reverse' (default: chronological)
311
+ * @returns {object} - MCP response with export status
312
+ */
313
+ async function handleExportConversation(args) {
314
+ const conversationId = args.conversationId;
315
+ const format = (args.format || 'markdown').toLowerCase();
316
+ const outputDir = args.outputDir;
317
+ const _includeAttachments = args.includeAttachments !== false;
318
+ const order = args.order || 'chronological';
319
+
320
+ if (!conversationId) {
321
+ return {
322
+ content: [{ type: 'text', text: 'Conversation ID is required.' }],
323
+ };
324
+ }
325
+
326
+ if (!outputDir) {
327
+ return {
328
+ content: [{ type: 'text', text: 'Output directory is required.' }],
329
+ };
330
+ }
331
+
332
+ const validFormats = ['eml', 'mbox', 'markdown', 'json', 'html'];
333
+ if (!validFormats.includes(format)) {
334
+ return {
335
+ content: [
336
+ {
337
+ type: 'text',
338
+ text: `Invalid format. Use: ${validFormats.join(', ')}`,
339
+ },
340
+ ],
341
+ };
342
+ }
343
+
344
+ try {
345
+ const accessToken = await ensureAuthenticated();
346
+
347
+ // Get all messages in conversation
348
+ const selectFields = getEmailFields('export');
349
+ const endpoint = 'me/messages';
350
+ const queryParams = {
351
+ $select: selectFields,
352
+ $filter: `conversationId eq '${conversationId}'`,
353
+ $orderby: `receivedDateTime ${order === 'reverse' ? 'desc' : 'asc'}`,
354
+ $top: 100,
355
+ };
356
+
357
+ let response;
358
+ try {
359
+ response = await callGraphAPI(
360
+ accessToken,
361
+ 'GET',
362
+ endpoint,
363
+ null,
364
+ queryParams
365
+ );
366
+ } catch (apiError) {
367
+ if (
368
+ apiError.message.includes('ErrorInvalidUrlQueryFilter') ||
369
+ apiError.message.includes('InefficientFilter') ||
370
+ apiError.message.includes('filter')
371
+ ) {
372
+ return {
373
+ content: [
374
+ {
375
+ type: 'text',
376
+ text: `Conversation export is not supported on personal Microsoft accounts. Use export with target=message and individual message IDs instead.`,
377
+ },
378
+ ],
379
+ };
380
+ }
381
+ throw apiError;
382
+ }
383
+ const messages = response.value || [];
384
+
385
+ if (messages.length === 0) {
386
+ return {
387
+ content: [
388
+ {
389
+ type: 'text',
390
+ text: `No messages found for conversation ID: ${conversationId}`,
391
+ },
392
+ ],
393
+ };
394
+ }
395
+
396
+ // Create output directory
397
+ const resolvedDir = path.resolve(outputDir);
398
+ if (!fs.existsSync(resolvedDir)) {
399
+ fs.mkdirSync(resolvedDir, { recursive: true });
400
+ }
401
+
402
+ // Generate filename base
403
+ const subject = sanitizeForFilename(messages[0].subject);
404
+ const date = formatDateForFilename(messages[0].receivedDateTime);
405
+ const filenameBase = `${date}_${subject}_conversation`;
406
+
407
+ let exportedFiles = [];
408
+ let exportStats = { messages: messages.length, attachments: 0, bytes: 0 };
409
+
410
+ switch (format) {
411
+ case 'eml': {
412
+ // Export each message as individual .eml file
413
+ const emlDir = path.join(resolvedDir, filenameBase);
414
+ if (!fs.existsSync(emlDir)) {
415
+ fs.mkdirSync(emlDir, { recursive: true });
416
+ }
417
+
418
+ for (let i = 0; i < messages.length; i++) {
419
+ const msg = messages[i];
420
+ const mimeContent = await callGraphAPIRaw(accessToken, msg.id);
421
+ const msgDate = formatDateForFilename(msg.receivedDateTime);
422
+ const emlPath = path.join(
423
+ emlDir,
424
+ `${i + 1}_${msgDate}_${sanitizeForFilename(msg.from?.emailAddress?.name || 'unknown', 20)}.eml`
425
+ );
426
+ fs.writeFileSync(emlPath, mimeContent, 'utf8');
427
+ exportStats.bytes += Buffer.byteLength(mimeContent, 'utf8');
428
+ exportedFiles.push(emlPath);
429
+ }
430
+ break;
431
+ }
432
+
433
+ case 'mbox': {
434
+ // Export all messages to single MBOX file
435
+ const mboxPath = path.join(resolvedDir, `${filenameBase}.mbox`);
436
+ let mboxContent = '';
437
+
438
+ for (const msg of messages) {
439
+ const mimeContent = await callGraphAPIRaw(accessToken, msg.id);
440
+ const from = msg.from?.emailAddress?.address || 'unknown@unknown.com';
441
+ const date = new Date(msg.receivedDateTime);
442
+ const mboxDate = date.toUTCString().replace('GMT', '+0000');
443
+
444
+ // MBOX format: From line + MIME content + blank line
445
+ mboxContent += `From ${from} ${mboxDate}\n`;
446
+ mboxContent += mimeContent.replace(/^From /gm, '>From '); // Escape From lines
447
+ mboxContent += '\n\n';
448
+ }
449
+
450
+ fs.writeFileSync(mboxPath, mboxContent, 'utf8');
451
+ exportStats.bytes = Buffer.byteLength(mboxContent, 'utf8');
452
+ exportedFiles.push(mboxPath);
453
+ break;
454
+ }
455
+
456
+ case 'markdown': {
457
+ // Export as threaded Markdown document
458
+ const mdPath = path.join(resolvedDir, `${filenameBase}.md`);
459
+ let mdContent = [];
460
+
461
+ mdContent.push(
462
+ `# Email Conversation: ${messages[0].subject || '(no subject)'}\n`
463
+ );
464
+ mdContent.push(`**Exported**: ${new Date().toISOString()}`);
465
+ mdContent.push(`**Messages**: ${messages.length}`);
466
+ mdContent.push(`**Conversation ID**: \`${conversationId}\`\n`);
467
+ mdContent.push('---\n');
468
+
469
+ for (let i = 0; i < messages.length; i++) {
470
+ const msg = messages[i];
471
+ mdContent.push(`## Message ${i + 1}/${messages.length}\n`);
472
+ mdContent.push(
473
+ `**From**: ${msg.from?.emailAddress?.name || ''} <${msg.from?.emailAddress?.address || ''}>`
474
+ );
475
+ mdContent.push(
476
+ `**To**: ${msg.toRecipients?.map((r) => r.emailAddress?.address).join(', ') || ''}`
477
+ );
478
+ if (msg.ccRecipients?.length) {
479
+ mdContent.push(
480
+ `**CC**: ${msg.ccRecipients.map((r) => r.emailAddress?.address).join(', ')}`
481
+ );
482
+ }
483
+ mdContent.push(`**Date**: ${msg.receivedDateTime}`);
484
+ mdContent.push(`**Subject**: ${msg.subject || '(no subject)'}\n`);
485
+
486
+ // Body content
487
+ if (msg.body?.content) {
488
+ if (msg.body.contentType === 'html') {
489
+ // Simple HTML to text conversion
490
+ const text = msg.body.content
491
+ .replace(/<br\s*\/?>/gi, '\n')
492
+ .replace(/<\/p>/gi, '\n\n')
493
+ .replace(/<[^>]+>/g, '')
494
+ .replace(/&nbsp;/g, ' ')
495
+ .replace(/&lt;/g, '<')
496
+ .replace(/&gt;/g, '>')
497
+ .replace(/&amp;/g, '&');
498
+ mdContent.push(text.trim());
499
+ } else {
500
+ mdContent.push(msg.body.content);
501
+ }
502
+ }
503
+
504
+ if (msg.hasAttachments) {
505
+ mdContent.push(
506
+ `\n*[${msg.hasAttachments ? 'Has attachments' : 'No attachments'}]*`
507
+ );
508
+ }
509
+
510
+ mdContent.push('\n---\n');
511
+ }
512
+
513
+ const content = mdContent.join('\n');
514
+ fs.writeFileSync(mdPath, content, 'utf8');
515
+ exportStats.bytes = Buffer.byteLength(content, 'utf8');
516
+ exportedFiles.push(mdPath);
517
+ break;
518
+ }
519
+
520
+ case 'json': {
521
+ // Export as JSON
522
+ const jsonPath = path.join(resolvedDir, `${filenameBase}.json`);
523
+ const jsonContent = JSON.stringify(
524
+ {
525
+ conversationId,
526
+ subject: messages[0].subject,
527
+ exportedAt: new Date().toISOString(),
528
+ messageCount: messages.length,
529
+ messages: messages,
530
+ },
531
+ null,
532
+ 2
533
+ );
534
+
535
+ fs.writeFileSync(jsonPath, jsonContent, 'utf8');
536
+ exportStats.bytes = Buffer.byteLength(jsonContent, 'utf8');
537
+ exportedFiles.push(jsonPath);
538
+ break;
539
+ }
540
+
541
+ case 'html': {
542
+ // Export as HTML document
543
+ const htmlPath = path.join(resolvedDir, `${filenameBase}.html`);
544
+ let htmlContent = [];
545
+
546
+ htmlContent.push('<!DOCTYPE html>');
547
+ htmlContent.push('<html><head>');
548
+ htmlContent.push(
549
+ `<title>${messages[0].subject || 'Email Conversation'}</title>`
550
+ );
551
+ htmlContent.push('<meta charset="utf-8">');
552
+ htmlContent.push('<style>');
553
+ htmlContent.push(
554
+ 'body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }'
555
+ );
556
+ htmlContent.push(
557
+ '.message { border: 1px solid #ddd; margin: 20px 0; padding: 15px; border-radius: 5px; }'
558
+ );
559
+ htmlContent.push(
560
+ '.header { color: #666; font-size: 0.9em; margin-bottom: 10px; }'
561
+ );
562
+ htmlContent.push('.body { white-space: pre-wrap; }');
563
+ htmlContent.push('</style>');
564
+ htmlContent.push('</head><body>');
565
+ htmlContent.push(
566
+ `<h1>${messages[0].subject || 'Email Conversation'}</h1>`
567
+ );
568
+ htmlContent.push(
569
+ `<p><strong>Messages:</strong> ${messages.length} | <strong>Exported:</strong> ${new Date().toISOString()}</p>`
570
+ );
571
+ htmlContent.push('<hr>');
572
+
573
+ for (let i = 0; i < messages.length; i++) {
574
+ const msg = messages[i];
575
+ htmlContent.push('<div class="message">');
576
+ htmlContent.push('<div class="header">');
577
+ htmlContent.push(
578
+ `<strong>From:</strong> ${msg.from?.emailAddress?.name || ''} &lt;${msg.from?.emailAddress?.address || ''}&gt;<br>`
579
+ );
580
+ htmlContent.push(
581
+ `<strong>Date:</strong> ${msg.receivedDateTime}<br>`
582
+ );
583
+ htmlContent.push(
584
+ `<strong>Subject:</strong> ${msg.subject || '(no subject)'}`
585
+ );
586
+ htmlContent.push('</div>');
587
+ htmlContent.push('<div class="body">');
588
+
589
+ if (msg.body?.contentType === 'html') {
590
+ htmlContent.push(msg.body.content);
591
+ } else {
592
+ htmlContent.push(`<pre>${msg.body?.content || ''}</pre>`);
593
+ }
594
+
595
+ htmlContent.push('</div></div>');
596
+ }
597
+
598
+ htmlContent.push('</body></html>');
599
+ const content = htmlContent.join('\n');
600
+ fs.writeFileSync(htmlPath, content, 'utf8');
601
+ exportStats.bytes = Buffer.byteLength(content, 'utf8');
602
+ exportedFiles.push(htmlPath);
603
+ break;
604
+ }
605
+ }
606
+
607
+ // Format result
608
+ const sizeFormatted =
609
+ exportStats.bytes < 1024
610
+ ? `${exportStats.bytes} B`
611
+ : exportStats.bytes < 1024 * 1024
612
+ ? `${(exportStats.bytes / 1024).toFixed(1)} KB`
613
+ : `${(exportStats.bytes / (1024 * 1024)).toFixed(2)} MB`;
614
+
615
+ let output = [];
616
+ output.push(`# Conversation Exported\n`);
617
+ output.push(`**Subject**: ${messages[0].subject || '(no subject)'}`);
618
+ output.push(`**Format**: ${format.toUpperCase()}`);
619
+ output.push(`**Messages**: ${exportStats.messages}`);
620
+ output.push(`**Total Size**: ${sizeFormatted}`);
621
+ output.push(`**Output Directory**: ${resolvedDir}\n`);
622
+ output.push('## Exported Files\n');
623
+ exportedFiles.forEach((f) => output.push(`- \`${path.basename(f)}\``));
624
+
625
+ return {
626
+ content: [
627
+ {
628
+ type: 'text',
629
+ text: output.join('\n'),
630
+ },
631
+ ],
632
+ _meta: {
633
+ conversationId,
634
+ format,
635
+ messageCount: exportStats.messages,
636
+ bytes: exportStats.bytes,
637
+ files: exportedFiles,
638
+ },
639
+ };
640
+ } catch (error) {
641
+ if (error.message === 'Authentication required') {
642
+ return {
643
+ content: [
644
+ {
645
+ type: 'text',
646
+ text: "Authentication required. Please use the 'authenticate' tool first.",
647
+ },
648
+ ],
649
+ };
650
+ }
651
+ return {
652
+ content: [
653
+ {
654
+ type: 'text',
655
+ text: `Error exporting conversation: ${error.message}`,
656
+ },
657
+ ],
658
+ };
659
+ }
660
+ }
661
+
662
+ module.exports = {
663
+ handleListConversations,
664
+ handleGetConversation,
665
+ handleExportConversation,
666
+ };