@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.
- package/.env.example +22 -0
- package/LICENSE +21 -0
- package/README.md +422 -0
- package/advanced/index.js +652 -0
- package/auth/index.js +32 -0
- package/auth/oauth-server.js +233 -0
- package/auth/token-manager.js +105 -0
- package/auth/token-storage.js +359 -0
- package/auth/tools.js +159 -0
- package/calendar/accept.js +72 -0
- package/calendar/cancel.js +72 -0
- package/calendar/create.js +115 -0
- package/calendar/decline.js +72 -0
- package/calendar/delete.js +67 -0
- package/calendar/index.js +130 -0
- package/calendar/list.js +108 -0
- package/categories/index.js +955 -0
- package/config.js +95 -0
- package/contacts/index.js +754 -0
- package/email/attachments.js +365 -0
- package/email/conversations.js +666 -0
- package/email/delta.js +210 -0
- package/email/export.js +572 -0
- package/email/folder-utils.js +192 -0
- package/email/headers.js +344 -0
- package/email/index.js +537 -0
- package/email/list.js +136 -0
- package/email/mark-as-read.js +114 -0
- package/email/mime.js +286 -0
- package/email/read.js +161 -0
- package/email/search.js +628 -0
- package/email/send.js +169 -0
- package/folder/create.js +137 -0
- package/folder/delete.js +108 -0
- package/folder/index.js +112 -0
- package/folder/list.js +289 -0
- package/folder/move.js +186 -0
- package/folder/stats.js +322 -0
- package/index.js +162 -0
- package/llms.txt +76 -0
- package/outlook-auth-server.js +384 -0
- package/package.json +97 -0
- package/rules/create.js +273 -0
- package/rules/index.js +276 -0
- package/rules/list.js +216 -0
- package/settings/index.js +678 -0
- package/utils/field-presets.js +311 -0
- package/utils/graph-api.js +268 -0
- package/utils/mock-data.js +154 -0
- package/utils/odata-helpers.js +33 -0
- package/utils/response-formatter.js +457 -0
- package/utils/safety.js +123 -0
package/email/export.js
ADDED
|
@@ -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
|
+
};
|