@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,572 @@
1
+ /**
2
+ * Email export functionality
3
+ *
4
+ * Export emails to disk in MIME, Markdown, or JSON format.
5
+ * Supports single and batch export with attachment handling.
6
+ */
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+ const { callGraphAPI, callGraphAPIRaw } = require('../utils/graph-api');
11
+ const { ensureAuthenticated } = require('../auth');
12
+ const {
13
+ formatEmailContent,
14
+ VERBOSITY,
15
+ } = require('../utils/response-formatter');
16
+ const { getEmailFields } = require('../utils/field-presets');
17
+
18
+ // Export format constants
19
+ const EXPORT_FORMATS = {
20
+ MIME: 'mime',
21
+ EML: 'eml', // Alias for MIME
22
+ MARKDOWN: 'markdown',
23
+ JSON: 'json',
24
+ };
25
+
26
+ /**
27
+ * Export single email handler
28
+ * @param {object} args - Tool arguments
29
+ * @param {string} args.id - Email ID (required)
30
+ * @param {string} [args.format] - Export format (mime, eml, markdown, json)
31
+ * @param {string} [args.savePath] - File path to save (optional)
32
+ * @param {boolean} [args.includeAttachments] - Include attachments (default: true)
33
+ * @returns {object} - MCP response with export status
34
+ */
35
+ async function handleExportEmail(args) {
36
+ const emailId = args.id;
37
+ const format = (args.format || EXPORT_FORMATS.MARKDOWN).toLowerCase();
38
+ const savePath = args.savePath;
39
+ const includeAttachments = args.includeAttachments !== false;
40
+
41
+ if (!emailId) {
42
+ return {
43
+ content: [
44
+ {
45
+ type: 'text',
46
+ text: 'Email ID is required.',
47
+ },
48
+ ],
49
+ };
50
+ }
51
+
52
+ try {
53
+ const accessToken = await ensureAuthenticated();
54
+
55
+ // Get email metadata first (for filename and markdown export)
56
+ const selectFields = getEmailFields('export');
57
+ const email = await callGraphAPI(
58
+ accessToken,
59
+ 'GET',
60
+ `me/messages/${emailId}`,
61
+ null,
62
+ { $select: selectFields }
63
+ );
64
+
65
+ if (!email) {
66
+ return {
67
+ content: [
68
+ {
69
+ type: 'text',
70
+ text: `Email with ID ${emailId} not found.`,
71
+ },
72
+ ],
73
+ };
74
+ }
75
+
76
+ // Generate filename based on email metadata
77
+ const timestamp = new Date(email.receivedDateTime)
78
+ .toISOString()
79
+ .slice(0, 10);
80
+ const safeSubject = sanitizeFilename(email.subject || 'no-subject');
81
+ const extension = getExtension(format);
82
+ const defaultFilename = `${timestamp}_${safeSubject}.${extension}`;
83
+
84
+ // Determine final save path
85
+ let finalPath;
86
+ if (savePath) {
87
+ // If savePath is a directory, append filename
88
+ if (fs.existsSync(savePath) && fs.statSync(savePath).isDirectory()) {
89
+ finalPath = path.join(savePath, defaultFilename);
90
+ } else {
91
+ finalPath = savePath;
92
+ }
93
+ } else {
94
+ // Default to OS temp directory to avoid polluting the working directory
95
+ finalPath = path.join(os.tmpdir(), defaultFilename);
96
+ }
97
+
98
+ // Export based on format
99
+ let content;
100
+ let attachmentsSaved = [];
101
+
102
+ if (format === EXPORT_FORMATS.MIME || format === EXPORT_FORMATS.EML) {
103
+ // MIME export - raw RFC822 format
104
+ content = await callGraphAPIRaw(accessToken, emailId);
105
+ } else if (format === EXPORT_FORMATS.MARKDOWN) {
106
+ // Markdown export using existing formatter
107
+ content = formatEmailContent(email, VERBOSITY.FULL, {
108
+ includeHeaders: true,
109
+ includeAllHeaders: true,
110
+ });
111
+ } else if (format === EXPORT_FORMATS.JSON) {
112
+ // JSON export - full email object
113
+ content = JSON.stringify(email, null, 2);
114
+ } else {
115
+ return {
116
+ content: [
117
+ {
118
+ type: 'text',
119
+ text: `Unknown format: ${format}. Supported: mime, eml, markdown, json`,
120
+ },
121
+ ],
122
+ };
123
+ }
124
+
125
+ // Save main file
126
+ fs.writeFileSync(finalPath, content, 'utf8');
127
+
128
+ // Handle attachments
129
+ if (includeAttachments && email.hasAttachments) {
130
+ attachmentsSaved = await saveAttachments(
131
+ accessToken,
132
+ emailId,
133
+ path.dirname(finalPath)
134
+ );
135
+ }
136
+
137
+ // Build response
138
+ let resultText = `## Export Complete\n\n`;
139
+ resultText += `| Property | Value |\n`;
140
+ resultText += `|----------|-------|\n`;
141
+ resultText += `| File | \`${finalPath}\` |\n`;
142
+ resultText += `| Format | ${format.toUpperCase()} |\n`;
143
+ resultText += `| Size | ${content.length.toLocaleString()} bytes |\n`;
144
+ resultText += `| Subject | ${email.subject} |\n`;
145
+ resultText += `| From | ${email.from?.emailAddress?.name || email.from?.emailAddress?.address} |\n`;
146
+ resultText += `| Date | ${new Date(email.receivedDateTime).toLocaleString('en-AU')} |\n`;
147
+
148
+ if (attachmentsSaved.length > 0) {
149
+ resultText += `\n### Attachments (${attachmentsSaved.length})\n\n`;
150
+ for (const att of attachmentsSaved) {
151
+ resultText += `- \`${att.filename}\` (${att.size.toLocaleString()} bytes)\n`;
152
+ }
153
+ }
154
+
155
+ return {
156
+ content: [
157
+ {
158
+ type: 'text',
159
+ text: resultText,
160
+ },
161
+ ],
162
+ _meta: {
163
+ filePath: finalPath,
164
+ format: format,
165
+ sizeBytes: content.length,
166
+ attachmentsSaved: attachmentsSaved.length,
167
+ emailId: emailId,
168
+ },
169
+ };
170
+ } catch (error) {
171
+ if (error.message === 'Authentication required') {
172
+ return {
173
+ content: [
174
+ {
175
+ type: 'text',
176
+ text: "Authentication required. Please use the 'authenticate' tool first.",
177
+ },
178
+ ],
179
+ };
180
+ }
181
+
182
+ return {
183
+ content: [
184
+ {
185
+ type: 'text',
186
+ text: `Export failed: ${error.message}`,
187
+ },
188
+ ],
189
+ };
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Batch export emails handler
195
+ * @param {object} args - Tool arguments
196
+ * @param {string[]} [args.emailIds] - Array of email IDs to export
197
+ * @param {object} [args.searchQuery] - Search query to find emails
198
+ * @param {string} [args.format] - Export format (mime, markdown, json)
199
+ * @param {string} args.outputDir - Output directory (required)
200
+ * @param {boolean} [args.includeAttachments] - Include attachments (default: false for batch)
201
+ * @returns {object} - MCP response with batch export status
202
+ */
203
+ async function handleBatchExportEmails(args) {
204
+ const emailIds = args.emailIds || [];
205
+ const searchQuery = args.searchQuery || {};
206
+ const format = (args.format || EXPORT_FORMATS.MARKDOWN).toLowerCase();
207
+ const outputDir = args.outputDir;
208
+ const includeAttachments = args.includeAttachments === true; // Default false for batch
209
+
210
+ if (!outputDir) {
211
+ return {
212
+ content: [
213
+ {
214
+ type: 'text',
215
+ text: 'Output directory is required.',
216
+ },
217
+ ],
218
+ };
219
+ }
220
+
221
+ // Ensure output directory exists
222
+ if (!fs.existsSync(outputDir)) {
223
+ fs.mkdirSync(outputDir, { recursive: true });
224
+ }
225
+
226
+ try {
227
+ const accessToken = await ensureAuthenticated();
228
+ let idsToExport = [...emailIds];
229
+
230
+ // If searchQuery provided, fetch matching emails
231
+ if (Object.keys(searchQuery).length > 0 && emailIds.length === 0) {
232
+ const searchResults = await searchEmailsForExport(
233
+ accessToken,
234
+ searchQuery
235
+ );
236
+ idsToExport = searchResults.map((e) => e.id);
237
+ }
238
+
239
+ if (idsToExport.length === 0) {
240
+ return {
241
+ content: [
242
+ {
243
+ type: 'text',
244
+ text: 'No emails to export. Provide emailIds or searchQuery.',
245
+ },
246
+ ],
247
+ };
248
+ }
249
+
250
+ // Limit batch size (per plan: max 100)
251
+ const maxBatch = 100;
252
+ if (idsToExport.length > maxBatch) {
253
+ idsToExport = idsToExport.slice(0, maxBatch);
254
+ console.error(`Batch export limited to ${maxBatch} emails`);
255
+ }
256
+
257
+ // Export emails with concurrency limit (4 concurrent per Graph API limits)
258
+ const results = await exportWithConcurrency(
259
+ accessToken,
260
+ idsToExport,
261
+ format,
262
+ outputDir,
263
+ includeAttachments,
264
+ 4 // Max concurrent
265
+ );
266
+
267
+ // Build response
268
+ const successful = results.filter((r) => r.success);
269
+ const failed = results.filter((r) => !r.success);
270
+
271
+ let resultText = `## Batch Export Complete\n\n`;
272
+ resultText += `| Metric | Value |\n`;
273
+ resultText += `|--------|-------|\n`;
274
+ resultText += `| Total | ${results.length} |\n`;
275
+ resultText += `| Successful | ${successful.length} |\n`;
276
+ resultText += `| Failed | ${failed.length} |\n`;
277
+ resultText += `| Output Directory | \`${outputDir}\` |\n`;
278
+ resultText += `| Format | ${format.toUpperCase()} |\n`;
279
+
280
+ // Total size
281
+ const totalBytes = successful.reduce(
282
+ (sum, r) => sum + (r.sizeBytes || 0),
283
+ 0
284
+ );
285
+ resultText += `| Total Size | ${(totalBytes / 1024).toFixed(1)} KB |\n`;
286
+
287
+ if (failed.length > 0) {
288
+ resultText += `\n### Failed Exports\n\n`;
289
+ for (const f of failed.slice(0, 10)) {
290
+ resultText += `- ID \`${f.emailId}\`: ${f.error}\n`;
291
+ }
292
+ if (failed.length > 10) {
293
+ resultText += `- ... and ${failed.length - 10} more\n`;
294
+ }
295
+ }
296
+
297
+ return {
298
+ content: [
299
+ {
300
+ type: 'text',
301
+ text: resultText,
302
+ },
303
+ ],
304
+ _meta: {
305
+ outputDir: outputDir,
306
+ format: format,
307
+ total: results.length,
308
+ successful: successful.length,
309
+ failed: failed.length,
310
+ totalBytes: totalBytes,
311
+ },
312
+ };
313
+ } catch (error) {
314
+ if (error.message === 'Authentication required') {
315
+ return {
316
+ content: [
317
+ {
318
+ type: 'text',
319
+ text: "Authentication required. Please use the 'authenticate' tool first.",
320
+ },
321
+ ],
322
+ };
323
+ }
324
+
325
+ return {
326
+ content: [
327
+ {
328
+ type: 'text',
329
+ text: `Batch export failed: ${error.message}`,
330
+ },
331
+ ],
332
+ };
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Search emails for batch export
338
+ */
339
+ async function searchEmailsForExport(accessToken, query) {
340
+ const folder = query.folder || 'inbox';
341
+ const maxResults = Math.min(query.maxResults || 25, 100);
342
+
343
+ // Build filter conditions
344
+ const filterParts = [];
345
+ if (query.receivedAfter) {
346
+ filterParts.push(
347
+ `receivedDateTime ge ${new Date(query.receivedAfter).toISOString()}`
348
+ );
349
+ }
350
+ if (query.receivedBefore) {
351
+ filterParts.push(
352
+ `receivedDateTime le ${new Date(query.receivedBefore).toISOString()}`
353
+ );
354
+ }
355
+
356
+ const params = {
357
+ $select: 'id',
358
+ $top: maxResults,
359
+ $orderby: 'receivedDateTime desc',
360
+ };
361
+
362
+ if (filterParts.length > 0) {
363
+ params.$filter = filterParts.join(' and ');
364
+ }
365
+
366
+ // Add search for from/subject if provided
367
+ const searchParts = [];
368
+ if (query.from) {
369
+ searchParts.push(`from:${query.from}`);
370
+ }
371
+ if (query.subject) {
372
+ searchParts.push(`subject:${query.subject}`);
373
+ }
374
+ if (searchParts.length > 0) {
375
+ params.$search = `"${searchParts.join(' ')}"`;
376
+ delete params.$orderby; // Can't combine $search with $orderby
377
+ }
378
+
379
+ const response = await callGraphAPI(
380
+ accessToken,
381
+ 'GET',
382
+ `me/mailFolders/${folder}/messages`,
383
+ null,
384
+ params
385
+ );
386
+
387
+ return response.value || [];
388
+ }
389
+
390
+ /**
391
+ * Export emails with concurrency limit
392
+ */
393
+ async function exportWithConcurrency(
394
+ accessToken,
395
+ emailIds,
396
+ format,
397
+ outputDir,
398
+ includeAttachments,
399
+ maxConcurrent
400
+ ) {
401
+ const results = [];
402
+ const inProgress = new Set();
403
+ let index = 0;
404
+
405
+ while (index < emailIds.length || inProgress.size > 0) {
406
+ // Start new exports up to concurrency limit
407
+ while (index < emailIds.length && inProgress.size < maxConcurrent) {
408
+ const emailId = emailIds[index];
409
+ const promise = exportSingleForBatch(
410
+ accessToken,
411
+ emailId,
412
+ format,
413
+ outputDir,
414
+ includeAttachments
415
+ ).then((result) => {
416
+ inProgress.delete(promise);
417
+ results.push(result);
418
+ return result;
419
+ });
420
+ inProgress.add(promise);
421
+ index++;
422
+ }
423
+
424
+ // Wait for at least one to complete
425
+ if (inProgress.size > 0) {
426
+ await Promise.race(inProgress);
427
+ }
428
+ }
429
+
430
+ return results;
431
+ }
432
+
433
+ /**
434
+ * Export single email for batch operation
435
+ */
436
+ async function exportSingleForBatch(
437
+ accessToken,
438
+ emailId,
439
+ format,
440
+ outputDir,
441
+ includeAttachments
442
+ ) {
443
+ try {
444
+ const selectFields = getEmailFields('export');
445
+ const email = await callGraphAPI(
446
+ accessToken,
447
+ 'GET',
448
+ `me/messages/${emailId}`,
449
+ null,
450
+ { $select: selectFields }
451
+ );
452
+
453
+ const timestamp = new Date(email.receivedDateTime)
454
+ .toISOString()
455
+ .slice(0, 10);
456
+ const safeSubject = sanitizeFilename(email.subject || 'no-subject');
457
+ const extension = getExtension(format);
458
+ const filename = `${timestamp}_${safeSubject}.${extension}`;
459
+ const filePath = path.join(outputDir, filename);
460
+
461
+ let content;
462
+ if (format === EXPORT_FORMATS.MIME || format === EXPORT_FORMATS.EML) {
463
+ content = await callGraphAPIRaw(accessToken, emailId);
464
+ } else if (format === EXPORT_FORMATS.MARKDOWN) {
465
+ content = formatEmailContent(email, VERBOSITY.FULL, {
466
+ includeHeaders: true,
467
+ });
468
+ } else {
469
+ content = JSON.stringify(email, null, 2);
470
+ }
471
+
472
+ fs.writeFileSync(filePath, content, 'utf8');
473
+
474
+ // Handle attachments if requested
475
+ let attachmentCount = 0;
476
+ if (includeAttachments && email.hasAttachments) {
477
+ const saved = await saveAttachments(accessToken, emailId, outputDir);
478
+ attachmentCount = saved.length;
479
+ }
480
+
481
+ return {
482
+ success: true,
483
+ emailId: emailId,
484
+ filePath: filePath,
485
+ sizeBytes: content.length,
486
+ attachments: attachmentCount,
487
+ };
488
+ } catch (error) {
489
+ return {
490
+ success: false,
491
+ emailId: emailId,
492
+ error: error.message,
493
+ };
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Save email attachments to directory
499
+ */
500
+ async function saveAttachments(accessToken, emailId, outputDir) {
501
+ const saved = [];
502
+
503
+ try {
504
+ const response = await callGraphAPI(
505
+ accessToken,
506
+ 'GET',
507
+ `me/messages/${emailId}/attachments`,
508
+ null,
509
+ { $select: 'id,name,contentBytes,size,contentType' }
510
+ );
511
+
512
+ if (!response.value) return saved;
513
+
514
+ for (const att of response.value) {
515
+ if (att.contentBytes) {
516
+ const safeFilename = sanitizeFilename(att.name || 'attachment');
517
+ const filePath = path.join(
518
+ outputDir,
519
+ `${emailId.substring(0, 8)}_${safeFilename}`
520
+ );
521
+ const buffer = Buffer.from(att.contentBytes, 'base64');
522
+ fs.writeFileSync(filePath, buffer);
523
+ saved.push({
524
+ filename: safeFilename,
525
+ path: filePath,
526
+ size: buffer.length,
527
+ });
528
+ }
529
+ }
530
+ } catch (error) {
531
+ console.error(`Failed to save attachments: ${error.message}`);
532
+ }
533
+
534
+ return saved;
535
+ }
536
+
537
+ /**
538
+ * Sanitize filename for filesystem
539
+ */
540
+ function sanitizeFilename(name) {
541
+ return (
542
+ name
543
+ // eslint-disable-next-line no-control-regex
544
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') // Replace illegal chars including control chars
545
+ .replace(/\s+/g, '_') // Replace spaces
546
+ .replace(/_+/g, '_') // Collapse multiple underscores
547
+ .substring(0, 50) // Limit length
548
+ .replace(/^[._]+|[._]+$/g, '')
549
+ ); // Remove leading/trailing dots/underscores
550
+ }
551
+
552
+ /**
553
+ * Get file extension for format
554
+ */
555
+ function getExtension(format) {
556
+ switch (format) {
557
+ case EXPORT_FORMATS.MIME:
558
+ case EXPORT_FORMATS.EML:
559
+ return 'eml';
560
+ case EXPORT_FORMATS.JSON:
561
+ return 'json';
562
+ case EXPORT_FORMATS.MARKDOWN:
563
+ default:
564
+ return 'md';
565
+ }
566
+ }
567
+
568
+ module.exports = {
569
+ handleExportEmail,
570
+ handleBatchExportEmails,
571
+ EXPORT_FORMATS,
572
+ };