@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,154 @@
1
+ /**
2
+ * Mock data functions for test mode
3
+ */
4
+
5
+ /**
6
+ * Simulates Microsoft Graph API responses for testing
7
+ * @param {string} method - HTTP method
8
+ * @param {string} path - API path
9
+ * @param {object} data - Request data
10
+ * @param {object} queryParams - Query parameters
11
+ * @returns {object} - Simulated API response
12
+ */
13
+ function simulateGraphAPIResponse(method, path, _data, _queryParams) {
14
+ console.error(`Simulating response for: ${method} ${path}`);
15
+
16
+ if (method === 'GET') {
17
+ if (path.includes('messages') && !path.includes('sendMail')) {
18
+ // Simulate a successful email list/search response
19
+ if (path.includes('/messages/')) {
20
+ // Single email response
21
+ return {
22
+ id: 'simulated-email-id',
23
+ subject: 'Simulated Email Subject',
24
+ from: {
25
+ emailAddress: {
26
+ name: 'Simulated Sender',
27
+ address: 'sender@example.com',
28
+ },
29
+ },
30
+ toRecipients: [
31
+ {
32
+ emailAddress: {
33
+ name: 'Recipient Name',
34
+ address: 'recipient@example.com',
35
+ },
36
+ },
37
+ ],
38
+ ccRecipients: [],
39
+ bccRecipients: [],
40
+ receivedDateTime: new Date().toISOString(),
41
+ bodyPreview: 'This is a simulated email preview...',
42
+ body: {
43
+ contentType: 'text',
44
+ content:
45
+ "This is the full content of the simulated email. Since we can't connect to the real Microsoft Graph API, we're returning this placeholder content instead.",
46
+ },
47
+ hasAttachments: false,
48
+ importance: 'normal',
49
+ isRead: false,
50
+ internetMessageHeaders: [],
51
+ };
52
+ } else {
53
+ // Email list response
54
+ return {
55
+ value: [
56
+ {
57
+ id: 'simulated-email-1',
58
+ subject: 'Important Meeting Tomorrow',
59
+ from: {
60
+ emailAddress: {
61
+ name: 'John Doe',
62
+ address: 'john@example.com',
63
+ },
64
+ },
65
+ toRecipients: [
66
+ {
67
+ emailAddress: {
68
+ name: 'You',
69
+ address: 'you@example.com',
70
+ },
71
+ },
72
+ ],
73
+ ccRecipients: [],
74
+ receivedDateTime: new Date().toISOString(),
75
+ bodyPreview: "Let's discuss the project status...",
76
+ hasAttachments: false,
77
+ importance: 'high',
78
+ isRead: false,
79
+ },
80
+ {
81
+ id: 'simulated-email-2',
82
+ subject: 'Weekly Report',
83
+ from: {
84
+ emailAddress: {
85
+ name: 'Jane Smith',
86
+ address: 'jane@example.com',
87
+ },
88
+ },
89
+ toRecipients: [
90
+ {
91
+ emailAddress: {
92
+ name: 'You',
93
+ address: 'you@example.com',
94
+ },
95
+ },
96
+ ],
97
+ ccRecipients: [],
98
+ receivedDateTime: new Date(Date.now() - 86400000).toISOString(), // Yesterday
99
+ bodyPreview: 'Please find attached the weekly report...',
100
+ hasAttachments: true,
101
+ importance: 'normal',
102
+ isRead: true,
103
+ },
104
+ {
105
+ id: 'simulated-email-3',
106
+ subject: 'Question about the project',
107
+ from: {
108
+ emailAddress: {
109
+ name: 'Bob Johnson',
110
+ address: 'bob@example.com',
111
+ },
112
+ },
113
+ toRecipients: [
114
+ {
115
+ emailAddress: {
116
+ name: 'You',
117
+ address: 'you@example.com',
118
+ },
119
+ },
120
+ ],
121
+ ccRecipients: [],
122
+ receivedDateTime: new Date(Date.now() - 172800000).toISOString(), // 2 days ago
123
+ bodyPreview: 'I had a question about the timeline...',
124
+ hasAttachments: false,
125
+ importance: 'normal',
126
+ isRead: false,
127
+ },
128
+ ],
129
+ };
130
+ }
131
+ } else if (path.includes('mailFolders')) {
132
+ // Simulate a mail folders response
133
+ return {
134
+ value: [
135
+ { id: 'inbox', displayName: 'Inbox' },
136
+ { id: 'drafts', displayName: 'Drafts' },
137
+ { id: 'sentItems', displayName: 'Sent Items' },
138
+ { id: 'deleteditems', displayName: 'Deleted Items' },
139
+ ],
140
+ };
141
+ }
142
+ } else if (method === 'POST' && path.includes('sendMail')) {
143
+ // Simulate a successful email send
144
+ return {};
145
+ }
146
+
147
+ // If we get here, we don't have a simulation for this endpoint
148
+ console.error(`No simulation available for: ${method} ${path}`);
149
+ return {};
150
+ }
151
+
152
+ module.exports = {
153
+ simulateGraphAPIResponse,
154
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * OData helper functions for Microsoft Graph API
3
+ */
4
+
5
+ /**
6
+ * Escapes a string for use in OData queries
7
+ * @param {string} str - The string to escape
8
+ * @returns {string} - The escaped string
9
+ */
10
+ function escapeODataString(str) {
11
+ if (!str) return str;
12
+
13
+ // Replace single quotes with double single quotes (OData escaping)
14
+ return str.replace(/'/g, "''");
15
+ }
16
+
17
+ /**
18
+ * Builds an OData filter from filter conditions
19
+ * @param {Array<string>} conditions - Array of filter conditions
20
+ * @returns {string} - Combined OData filter expression
21
+ */
22
+ function buildODataFilter(conditions) {
23
+ if (!conditions || conditions.length === 0) {
24
+ return '';
25
+ }
26
+
27
+ return conditions.join(' and ');
28
+ }
29
+
30
+ module.exports = {
31
+ escapeODataString,
32
+ buildODataFilter,
33
+ };
@@ -0,0 +1,457 @@
1
+ /**
2
+ * Response formatting utilities for token-efficient MCP responses
3
+ *
4
+ * Provides:
5
+ * - Markdown formatting (15% more token-efficient than JSON)
6
+ * - Verbosity levels (minimal/standard/full)
7
+ * - Truncation with metadata
8
+ * - Response metadata (_meta blocks)
9
+ */
10
+
11
+ /**
12
+ * Verbosity levels for response formatting
13
+ */
14
+ const VERBOSITY = {
15
+ MINIMAL: 'minimal', // IDs + subject only (for batch operations)
16
+ STANDARD: 'standard', // Key fields (default)
17
+ FULL: 'full', // All available fields
18
+ };
19
+
20
+ /**
21
+ * Default limits for token efficiency
22
+ */
23
+ const DEFAULT_LIMITS = {
24
+ listEmails: 25,
25
+ searchEmails: 10,
26
+ bodyPreviewLength: 100,
27
+ batchExport: 25,
28
+ maxBodyTruncation: 2000,
29
+ maxTableRows: 50,
30
+ };
31
+
32
+ /**
33
+ * Truncates text with metadata for long content
34
+ * @param {string} text - Text to truncate
35
+ * @param {number} maxChars - Maximum characters (default 2000)
36
+ * @returns {object|string} - Truncated content with metadata or original text
37
+ */
38
+ function truncateWithMeta(text, maxChars = DEFAULT_LIMITS.maxBodyTruncation) {
39
+ if (!text || text.length <= maxChars) {
40
+ return text;
41
+ }
42
+
43
+ return {
44
+ content: text.substring(0, maxChars),
45
+ _truncated: true,
46
+ _fullLength: text.length,
47
+ _hint: 'Use read-email with includeFullBody=true for complete content',
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Formats a single email for list display (minimal verbosity)
53
+ * @param {object} email - Email object from Graph API
54
+ * @param {number} index - Index in list (1-based)
55
+ * @returns {string} - Minimal format: ID, subject, from
56
+ */
57
+ function formatEmailMinimal(email, index) {
58
+ const from = email.from?.emailAddress?.address || 'unknown';
59
+ return `${index}. ${email.id} | ${email.subject || '(no subject)'} | ${from}`;
60
+ }
61
+
62
+ /**
63
+ * Formats a single email for list display (standard verbosity)
64
+ * @param {object} email - Email object from Graph API
65
+ * @param {number} index - Index in list (1-based)
66
+ * @returns {string} - Standard markdown format
67
+ */
68
+ function formatEmailStandard(email, index) {
69
+ const from = email.from?.emailAddress || {
70
+ name: 'Unknown',
71
+ address: 'unknown',
72
+ };
73
+ const date = formatDate(email.receivedDateTime);
74
+ const readStatus = email.isRead ? '' : '**[UNREAD]** ';
75
+ const attachIcon = email.hasAttachments ? ' 📎' : '';
76
+
77
+ return `${index}. ${readStatus}**${email.subject || '(no subject)'}**${attachIcon}
78
+ From: ${from.name} <${from.address}>
79
+ Date: ${date}
80
+ ID: \`${email.id}\``;
81
+ }
82
+
83
+ /**
84
+ * Formats a single email for list display (full verbosity)
85
+ * @param {object} email - Email object from Graph API
86
+ * @param {number} index - Index in list (1-based)
87
+ * @returns {string} - Full markdown format with all fields
88
+ */
89
+ function formatEmailFull(email, index) {
90
+ const from = email.from?.emailAddress || {
91
+ name: 'Unknown',
92
+ address: 'unknown',
93
+ };
94
+ const to = formatRecipients(email.toRecipients);
95
+ const cc = formatRecipients(email.ccRecipients);
96
+ const date = formatDate(email.receivedDateTime);
97
+ const readStatus = email.isRead ? 'Read' : '**UNREAD**';
98
+ const importance = email.importance || 'normal';
99
+ const preview = email.bodyPreview
100
+ ? truncateText(email.bodyPreview, DEFAULT_LIMITS.bodyPreviewLength)
101
+ : '';
102
+
103
+ let output = `### ${index}. ${email.subject || '(no subject)'}
104
+
105
+ | Field | Value |
106
+ |-------|-------|
107
+ | From | ${from.name} <${from.address}> |
108
+ | To | ${to} |
109
+ | Date | ${date} |
110
+ | Status | ${readStatus} |
111
+ | Importance | ${importance} |
112
+ | Attachments | ${email.hasAttachments ? 'Yes' : 'No'} |
113
+ | ID | \`${email.id}\` |`;
114
+
115
+ if (cc) {
116
+ output += `\n| CC | ${cc} |`;
117
+ }
118
+
119
+ if (preview) {
120
+ output += `\n\n> ${preview}`;
121
+ }
122
+
123
+ return output;
124
+ }
125
+
126
+ /**
127
+ * Formats an email list as Markdown
128
+ * @param {Array} emails - Array of email objects from Graph API
129
+ * @param {string} folder - Folder name
130
+ * @param {string} verbosity - Verbosity level (minimal/standard/full)
131
+ * @param {object} meta - Metadata (totalAvailable, hasMore, nextPageToken)
132
+ * @returns {string} - Formatted Markdown string
133
+ */
134
+ function formatEmailList(
135
+ emails,
136
+ folder,
137
+ verbosity = VERBOSITY.STANDARD,
138
+ meta = {}
139
+ ) {
140
+ if (!emails || emails.length === 0) {
141
+ return `No emails found in ${folder}.`;
142
+ }
143
+
144
+ const count = emails.length;
145
+ let output = `## Emails in ${folder} (${count}${meta.totalAvailable ? `/${meta.totalAvailable}` : ''})\n\n`;
146
+
147
+ // Format based on verbosity
148
+ const formatFn =
149
+ {
150
+ [VERBOSITY.MINIMAL]: formatEmailMinimal,
151
+ [VERBOSITY.STANDARD]: formatEmailStandard,
152
+ [VERBOSITY.FULL]: formatEmailFull,
153
+ }[verbosity] || formatEmailStandard;
154
+
155
+ output += emails.map((email, i) => formatFn(email, i + 1)).join('\n\n');
156
+
157
+ // Add metadata footer
158
+ if (meta.hasMore || meta.nextPageToken) {
159
+ output += `\n\n---\n_More emails available. ${meta.nextPageToken ? 'Use nextPageToken to continue.' : ''}_`;
160
+ }
161
+
162
+ return output;
163
+ }
164
+
165
+ /**
166
+ * Formats an email list as Markdown table (compact format)
167
+ * @param {Array} emails - Array of email objects
168
+ * @param {string} folder - Folder name
169
+ * @param {object} meta - Metadata
170
+ * @returns {string} - Markdown table
171
+ */
172
+ function formatEmailListAsTable(emails, folder, meta = {}) {
173
+ if (!emails || emails.length === 0) {
174
+ return `No emails found in ${folder}.`;
175
+ }
176
+
177
+ let output = `## Emails in ${folder} (${emails.length}${meta.totalAvailable ? `/${meta.totalAvailable}` : ''})\n\n`;
178
+ output += '| # | Status | Date | From | Subject | ID |\n';
179
+ output += '|---|--------|------|------|---------|----|\n';
180
+
181
+ const rows = emails.slice(0, DEFAULT_LIMITS.maxTableRows);
182
+ rows.forEach((email, i) => {
183
+ const from =
184
+ email.from?.emailAddress?.name ||
185
+ email.from?.emailAddress?.address ||
186
+ 'Unknown';
187
+ const date = formatDateShort(email.receivedDateTime);
188
+ const status = email.isRead ? '📖' : '📬';
189
+ const subject = truncateText(email.subject || '(no subject)', 40);
190
+ const shortId = email.id.substring(0, 20) + '...';
191
+
192
+ output += `| ${i + 1} | ${status} | ${date} | ${truncateText(from, 20)} | ${subject} | \`${shortId}\` |\n`;
193
+ });
194
+
195
+ if (emails.length > DEFAULT_LIMITS.maxTableRows) {
196
+ output += `\n_Showing ${DEFAULT_LIMITS.maxTableRows} of ${emails.length} emails._`;
197
+ }
198
+
199
+ if (meta.hasMore) {
200
+ output += `\n\n---\n_More emails available._`;
201
+ }
202
+
203
+ return output;
204
+ }
205
+
206
+ /**
207
+ * Formats a single email for reading (full content)
208
+ * @param {object} email - Email object from Graph API
209
+ * @param {string} verbosity - Verbosity level
210
+ * @param {object} options - Additional options (includeHeaders, includeRaw)
211
+ * @returns {string} - Formatted Markdown string
212
+ */
213
+ function formatEmailContent(
214
+ email,
215
+ verbosity = VERBOSITY.STANDARD,
216
+ options = {}
217
+ ) {
218
+ const from = formatEmailAddress(email.from?.emailAddress);
219
+ const to = formatRecipients(email.toRecipients);
220
+ const cc = formatRecipients(email.ccRecipients);
221
+ const bcc = formatRecipients(email.bccRecipients);
222
+ const date = formatDate(email.receivedDateTime);
223
+
224
+ let output = `# ${email.subject || '(no subject)'}
225
+
226
+ **From:** ${from}`;
227
+
228
+ if (to) output += `\n**To:** ${to}`;
229
+
230
+ output += `\n**Date:** ${date}`;
231
+
232
+ if (cc) output += `\n**CC:** ${cc}`;
233
+ if (bcc) output += `\n**BCC:** ${bcc}`;
234
+
235
+ output += `\n**Importance:** ${email.importance || 'normal'}`;
236
+ output += `\n**Attachments:** ${email.hasAttachments ? 'Yes' : 'No'}`;
237
+
238
+ if (verbosity === VERBOSITY.FULL) {
239
+ output += `\n**ID:** \`${email.id}\``;
240
+ if (email.conversationId)
241
+ output += `\n**Conversation ID:** \`${email.conversationId}\``;
242
+ if (email.internetMessageId)
243
+ output += `\n**Message-ID:** \`${email.internetMessageId}\``;
244
+ }
245
+
246
+ output += '\n\n---\n\n';
247
+
248
+ // Body content
249
+ let body;
250
+ if (verbosity === VERBOSITY.MINIMAL) {
251
+ // At minimal verbosity, show bodyPreview only
252
+ body = email.bodyPreview || '_(Body omitted at minimal verbosity)_';
253
+ } else if (email.body) {
254
+ body =
255
+ email.body.contentType === 'html'
256
+ ? stripHtml(email.body.content)
257
+ : email.body.content;
258
+ } else {
259
+ body = email.bodyPreview || 'No content';
260
+ }
261
+
262
+ // Truncate if needed (unless full verbosity requested)
263
+ if (verbosity !== VERBOSITY.FULL) {
264
+ const truncated = truncateWithMeta(body, DEFAULT_LIMITS.maxBodyTruncation);
265
+ if (typeof truncated === 'object') {
266
+ output += truncated.content;
267
+ output += `\n\n---\n_Content truncated (${truncated._fullLength} chars). ${truncated._hint}_`;
268
+ } else {
269
+ output += body;
270
+ }
271
+ } else {
272
+ output += body;
273
+ }
274
+
275
+ // Headers if requested
276
+ if (options.includeHeaders && email.internetMessageHeaders) {
277
+ output += formatEmailHeaders(
278
+ email.internetMessageHeaders,
279
+ options.includeAllHeaders
280
+ );
281
+ }
282
+
283
+ return output;
284
+ }
285
+
286
+ /**
287
+ * Formats email headers for legal/forensic use
288
+ * @param {Array} headers - Array of header objects
289
+ * @param {boolean} includeAll - Include all headers (not just important ones)
290
+ * @returns {string} - Formatted headers section
291
+ */
292
+ function formatEmailHeaders(headers, includeAll = false) {
293
+ if (!headers || headers.length === 0) return '';
294
+
295
+ const importantHeaders = [
296
+ 'Message-ID',
297
+ 'Date',
298
+ 'Received',
299
+ 'DKIM-Signature',
300
+ 'Authentication-Results',
301
+ 'X-MS-Exchange-Organization-AuthSource',
302
+ 'X-MS-Exchange-Organization-AuthAs',
303
+ 'Return-Path',
304
+ 'X-Originating-IP',
305
+ 'X-MS-Has-Attach',
306
+ 'SPF',
307
+ 'DMARC',
308
+ ];
309
+
310
+ const filteredHeaders = includeAll
311
+ ? headers
312
+ : headers.filter((h) =>
313
+ importantHeaders.some((ih) =>
314
+ h.name.toLowerCase().startsWith(ih.toLowerCase())
315
+ )
316
+ );
317
+
318
+ if (filteredHeaders.length === 0) return '';
319
+
320
+ let output = '\n\n---\n\n## Email Headers (Legal/Forensic)\n\n';
321
+ output += '| Header | Value |\n|--------|-------|\n';
322
+
323
+ filteredHeaders.forEach((h) => {
324
+ const value = truncateText(h.value, 60);
325
+ output += `| ${h.name} | \`${value}\` |\n`;
326
+ });
327
+
328
+ return output;
329
+ }
330
+
331
+ /**
332
+ * Creates a response metadata block
333
+ * @param {object} data - Response data
334
+ * @returns {object} - Metadata object
335
+ */
336
+ function createResponseMeta(data) {
337
+ return {
338
+ returned: data.returned || 0,
339
+ totalAvailable: data.totalAvailable || null,
340
+ hasMore: data.hasMore || false,
341
+ nextPageToken: data.nextPageToken || null,
342
+ verbosity: data.verbosity || VERBOSITY.STANDARD,
343
+ truncated: data.truncated || false,
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Wraps response content with MCP format
349
+ * @param {string} text - Response text
350
+ * @param {object} meta - Optional metadata
351
+ * @returns {object} - MCP response object
352
+ */
353
+ function wrapMcpResponse(text, meta = null) {
354
+ const response = {
355
+ content: [{ type: 'text', text }],
356
+ };
357
+
358
+ if (meta) {
359
+ response._meta = meta;
360
+ }
361
+
362
+ return response;
363
+ }
364
+
365
+ // ============ Helper Functions ============
366
+
367
+ /**
368
+ * Formats a date string
369
+ */
370
+ function formatDate(dateStr) {
371
+ if (!dateStr) return 'Unknown';
372
+ const date = new Date(dateStr);
373
+ return date.toLocaleString('en-AU', {
374
+ day: '2-digit',
375
+ month: 'short',
376
+ year: 'numeric',
377
+ hour: '2-digit',
378
+ minute: '2-digit',
379
+ hour12: true,
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Formats a date string (short version for tables)
385
+ */
386
+ function formatDateShort(dateStr) {
387
+ if (!dateStr) return 'Unknown';
388
+ const date = new Date(dateStr);
389
+ return date.toLocaleDateString('en-AU', {
390
+ day: '2-digit',
391
+ month: 'short',
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Formats an email address object
397
+ */
398
+ function formatEmailAddress(addr) {
399
+ if (!addr) return 'Unknown';
400
+ return `${addr.name || addr.address} <${addr.address}>`;
401
+ }
402
+
403
+ /**
404
+ * Formats recipient array to string
405
+ */
406
+ function formatRecipients(recipients) {
407
+ if (!recipients || recipients.length === 0) return '';
408
+ return recipients.map((r) => formatEmailAddress(r.emailAddress)).join(', ');
409
+ }
410
+
411
+ /**
412
+ * Truncates text to specified length
413
+ */
414
+ function truncateText(text, maxLength) {
415
+ if (!text) return '';
416
+ if (text.length <= maxLength) return text;
417
+ return text.substring(0, maxLength - 3) + '...';
418
+ }
419
+
420
+ /**
421
+ * Strips HTML tags (simple implementation)
422
+ */
423
+ function stripHtml(html) {
424
+ if (!html) return '';
425
+ return html
426
+ .replace(/<br\s*\/?>/gi, '\n')
427
+ .replace(/<\/p>/gi, '\n\n')
428
+ .replace(/<[^>]*>/g, '')
429
+ .replace(/&nbsp;/g, ' ')
430
+ .replace(/&amp;/g, '&')
431
+ .replace(/&lt;/g, '<')
432
+ .replace(/&gt;/g, '>')
433
+ .replace(/&quot;/g, '"')
434
+ .replace(/\n{3,}/g, '\n\n')
435
+ .trim();
436
+ }
437
+
438
+ module.exports = {
439
+ VERBOSITY,
440
+ DEFAULT_LIMITS,
441
+ truncateWithMeta,
442
+ formatEmailMinimal,
443
+ formatEmailStandard,
444
+ formatEmailFull,
445
+ formatEmailList,
446
+ formatEmailListAsTable,
447
+ formatEmailContent,
448
+ formatEmailHeaders,
449
+ createResponseMeta,
450
+ wrapMcpResponse,
451
+ formatDate,
452
+ formatDateShort,
453
+ formatEmailAddress,
454
+ formatRecipients,
455
+ truncateText,
456
+ stripHtml,
457
+ };