@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,311 @@
1
+ /**
2
+ * Field selection presets for Microsoft Graph API email queries
3
+ *
4
+ * Named presets for common use cases to optimize API calls
5
+ * and reduce response size/token usage.
6
+ */
7
+
8
+ /**
9
+ * Field presets for different use cases
10
+ */
11
+ const FIELD_PRESETS = {
12
+ /**
13
+ * Minimal fields for listing/scanning emails
14
+ * Use case: Quick folder scan, batch operations needing just IDs
15
+ */
16
+ list: ['id', 'subject', 'from', 'receivedDateTime', 'isRead'],
17
+
18
+ /**
19
+ * Minimal fields for reading a single email
20
+ * Use case: Quick read at minimal verbosity (includes bodyPreview + toRecipients)
21
+ */
22
+ 'read-minimal': [
23
+ 'id',
24
+ 'subject',
25
+ 'from',
26
+ 'toRecipients',
27
+ 'receivedDateTime',
28
+ 'bodyPreview',
29
+ 'hasAttachments',
30
+ 'importance',
31
+ 'isRead',
32
+ ],
33
+
34
+ /**
35
+ * Standard fields for reading email content
36
+ * Use case: Viewing email details with body
37
+ */
38
+ read: [
39
+ 'id',
40
+ 'subject',
41
+ 'from',
42
+ 'toRecipients',
43
+ 'ccRecipients',
44
+ 'receivedDateTime',
45
+ 'body',
46
+ 'bodyPreview',
47
+ 'hasAttachments',
48
+ 'importance',
49
+ 'isRead',
50
+ ],
51
+
52
+ /**
53
+ * Extended fields for legal/forensic use
54
+ * Use case: Evidence collection, compliance, legal review
55
+ */
56
+ forensic: [
57
+ 'id',
58
+ 'subject',
59
+ 'from',
60
+ 'toRecipients',
61
+ 'ccRecipients',
62
+ 'bccRecipients',
63
+ 'receivedDateTime',
64
+ 'sentDateTime',
65
+ 'body',
66
+ 'bodyPreview',
67
+ 'hasAttachments',
68
+ 'importance',
69
+ 'isRead',
70
+ 'internetMessageHeaders',
71
+ 'internetMessageId',
72
+ 'conversationId',
73
+ 'conversationIndex',
74
+ 'parentFolderId',
75
+ ],
76
+
77
+ /**
78
+ * Full fields for export operations
79
+ * Use case: Complete email backup, migration, archival
80
+ */
81
+ export: [
82
+ 'id',
83
+ 'subject',
84
+ 'from',
85
+ 'toRecipients',
86
+ 'ccRecipients',
87
+ 'bccRecipients',
88
+ 'replyTo',
89
+ 'receivedDateTime',
90
+ 'sentDateTime',
91
+ 'createdDateTime',
92
+ 'lastModifiedDateTime',
93
+ 'body',
94
+ 'bodyPreview',
95
+ 'hasAttachments',
96
+ 'importance',
97
+ 'isRead',
98
+ 'isDraft',
99
+ 'internetMessageHeaders',
100
+ 'internetMessageId',
101
+ 'conversationId',
102
+ 'conversationIndex',
103
+ 'parentFolderId',
104
+ 'categories',
105
+ 'flag',
106
+ 'webLink',
107
+ 'changeKey',
108
+ ],
109
+
110
+ /**
111
+ * Search result fields (optimized for relevance display)
112
+ * Use case: Search results with context
113
+ */
114
+ search: [
115
+ 'id',
116
+ 'subject',
117
+ 'from',
118
+ 'toRecipients',
119
+ 'receivedDateTime',
120
+ 'bodyPreview',
121
+ 'hasAttachments',
122
+ 'importance',
123
+ 'isRead',
124
+ 'parentFolderId',
125
+ ],
126
+
127
+ /**
128
+ * Delta query fields (for incremental sync)
129
+ * Use case: Tracking changes in folder
130
+ */
131
+ delta: [
132
+ 'id',
133
+ 'subject',
134
+ 'from',
135
+ 'receivedDateTime',
136
+ 'isRead',
137
+ 'parentFolderId',
138
+ 'changeKey',
139
+ ],
140
+
141
+ /**
142
+ * Conversation fields (for thread grouping)
143
+ * Use case: Viewing email threads
144
+ */
145
+ conversation: [
146
+ 'id',
147
+ 'subject',
148
+ 'from',
149
+ 'toRecipients',
150
+ 'receivedDateTime',
151
+ 'bodyPreview',
152
+ 'conversationId',
153
+ 'conversationIndex',
154
+ 'isRead',
155
+ ],
156
+ };
157
+
158
+ /**
159
+ * Extended email fields (comprehensive list)
160
+ * Used as reference for export preset
161
+ */
162
+ const EXTENDED_EMAIL_FIELDS = [
163
+ 'id',
164
+ 'subject',
165
+ 'from',
166
+ 'sender',
167
+ 'toRecipients',
168
+ 'ccRecipients',
169
+ 'bccRecipients',
170
+ 'replyTo',
171
+ 'receivedDateTime',
172
+ 'sentDateTime',
173
+ 'createdDateTime',
174
+ 'lastModifiedDateTime',
175
+ 'body',
176
+ 'bodyPreview',
177
+ 'hasAttachments',
178
+ 'importance',
179
+ 'isRead',
180
+ 'isDraft',
181
+ 'isDeliveryReceiptRequested',
182
+ 'isReadReceiptRequested',
183
+ 'internetMessageHeaders',
184
+ 'internetMessageId',
185
+ 'conversationId',
186
+ 'conversationIndex',
187
+ 'parentFolderId',
188
+ 'categories',
189
+ 'flag',
190
+ 'webLink',
191
+ 'changeKey',
192
+ 'inferenceClassification',
193
+ ];
194
+
195
+ /**
196
+ * Folder fields for folder operations
197
+ */
198
+ const FOLDER_FIELDS = {
199
+ /**
200
+ * Basic folder info
201
+ */
202
+ basic: ['id', 'displayName', 'parentFolderId'],
203
+
204
+ /**
205
+ * Folder with item counts
206
+ */
207
+ withCounts: [
208
+ 'id',
209
+ 'displayName',
210
+ 'parentFolderId',
211
+ 'totalItemCount',
212
+ 'unreadItemCount',
213
+ ],
214
+
215
+ /**
216
+ * Full folder details
217
+ * Note: sizeInBytes is NOT available on mailFolder resource type
218
+ */
219
+ full: [
220
+ 'id',
221
+ 'displayName',
222
+ 'parentFolderId',
223
+ 'childFolderCount',
224
+ 'totalItemCount',
225
+ 'unreadItemCount',
226
+ 'isHidden',
227
+ ],
228
+ };
229
+
230
+ /**
231
+ * Gets field selection string for Graph API $select parameter
232
+ * @param {string} preset - Preset name (list, read, forensic, export, search, delta, conversation)
233
+ * @returns {string} - Comma-separated field list
234
+ */
235
+ function getEmailFields(preset = 'list') {
236
+ const fields = FIELD_PRESETS[preset];
237
+ if (!fields) {
238
+ console.error(`Unknown preset: ${preset}, falling back to 'list'`);
239
+ return FIELD_PRESETS.list.join(',');
240
+ }
241
+ return fields.join(',');
242
+ }
243
+
244
+ /**
245
+ * Gets folder field selection string
246
+ * @param {string} preset - Preset name (basic, withCounts, full)
247
+ * @returns {string} - Comma-separated field list
248
+ */
249
+ function getFolderFields(preset = 'basic') {
250
+ const fields = FOLDER_FIELDS[preset];
251
+ if (!fields) {
252
+ console.error(`Unknown folder preset: ${preset}, falling back to 'basic'`);
253
+ return FOLDER_FIELDS.basic.join(',');
254
+ }
255
+ return fields.join(',');
256
+ }
257
+
258
+ /**
259
+ * Builds custom field selection from array
260
+ * @param {Array<string>} fields - Array of field names
261
+ * @returns {string} - Comma-separated field list
262
+ */
263
+ function buildFieldSelection(fields) {
264
+ if (!fields || !Array.isArray(fields) || fields.length === 0) {
265
+ return getEmailFields('list');
266
+ }
267
+ return fields.join(',');
268
+ }
269
+
270
+ /**
271
+ * Merges preset with additional fields
272
+ * @param {string} preset - Base preset name
273
+ * @param {Array<string>} additionalFields - Extra fields to include
274
+ * @returns {string} - Combined field selection
275
+ */
276
+ function mergeFields(preset, additionalFields = []) {
277
+ const baseFields = FIELD_PRESETS[preset] || FIELD_PRESETS.list;
278
+ const merged = [...new Set([...baseFields, ...additionalFields])];
279
+ return merged.join(',');
280
+ }
281
+
282
+ /**
283
+ * Validates that requested fields are valid Graph API fields
284
+ * @param {Array<string>} fields - Fields to validate
285
+ * @returns {object} - { valid: Array, invalid: Array }
286
+ */
287
+ function validateFields(fields) {
288
+ const validFields = new Set(EXTENDED_EMAIL_FIELDS);
289
+ const result = { valid: [], invalid: [] };
290
+
291
+ fields.forEach((field) => {
292
+ if (validFields.has(field)) {
293
+ result.valid.push(field);
294
+ } else {
295
+ result.invalid.push(field);
296
+ }
297
+ });
298
+
299
+ return result;
300
+ }
301
+
302
+ module.exports = {
303
+ FIELD_PRESETS,
304
+ EXTENDED_EMAIL_FIELDS,
305
+ FOLDER_FIELDS,
306
+ getEmailFields,
307
+ getFolderFields,
308
+ buildFieldSelection,
309
+ mergeFields,
310
+ validateFields,
311
+ };
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Microsoft Graph API helper functions
3
+ */
4
+ const https = require('https');
5
+ const config = require('../config');
6
+ const mockData = require('./mock-data');
7
+
8
+ /**
9
+ * Makes a request to the Microsoft Graph API
10
+ * @param {string} accessToken - The access token for authentication
11
+ * @param {string} method - HTTP method (GET, POST, etc.)
12
+ * @param {string} path - API endpoint path
13
+ * @param {object} data - Data to send for POST/PUT requests
14
+ * @param {object} queryParams - Query parameters
15
+ * @returns {Promise<object>} - The API response
16
+ */
17
+ async function callGraphAPI(
18
+ accessToken,
19
+ method,
20
+ path,
21
+ data = null,
22
+ queryParams = {}
23
+ ) {
24
+ // For test tokens, we'll simulate the API call
25
+ if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
26
+ return mockData.simulateGraphAPIResponse(method, path, data, queryParams);
27
+ }
28
+
29
+ try {
30
+ // Check if path already contains the full URL (from nextLink)
31
+ let finalUrl;
32
+ if (path.startsWith('http://') || path.startsWith('https://')) {
33
+ // Path is already a full URL (from pagination nextLink)
34
+ finalUrl = path;
35
+ } else {
36
+ // Build URL from path and queryParams
37
+ // Encode path segments properly
38
+ const encodedPath = path
39
+ .split('/')
40
+ .map((segment) => encodeURIComponent(segment))
41
+ .join('/');
42
+
43
+ // Build query string from parameters with special handling for OData filters
44
+ let queryString = '';
45
+ if (Object.keys(queryParams).length > 0) {
46
+ // Handle $filter parameter specially to ensure proper URI encoding
47
+ const filter = queryParams.$filter;
48
+ if (filter) {
49
+ delete queryParams.$filter; // Remove from regular params
50
+ }
51
+
52
+ // Build query string with proper encoding for regular params
53
+ const params = new URLSearchParams();
54
+ for (const [key, value] of Object.entries(queryParams)) {
55
+ params.append(key, value);
56
+ }
57
+
58
+ queryString = params.toString();
59
+
60
+ // Add filter parameter separately with proper encoding
61
+ if (filter) {
62
+ if (queryString) {
63
+ queryString += `&$filter=${encodeURIComponent(filter)}`;
64
+ } else {
65
+ queryString = `$filter=${encodeURIComponent(filter)}`;
66
+ }
67
+ }
68
+
69
+ if (queryString) {
70
+ queryString = '?' + queryString;
71
+ }
72
+ }
73
+
74
+ finalUrl = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`;
75
+ }
76
+
77
+ return new Promise((resolve, reject) => {
78
+ const options = {
79
+ method: method,
80
+ headers: {
81
+ Authorization: `Bearer ${accessToken}`,
82
+ 'Content-Type': 'application/json',
83
+ },
84
+ };
85
+
86
+ const req = https.request(finalUrl, options, (res) => {
87
+ let responseData = '';
88
+
89
+ res.on('data', (chunk) => {
90
+ responseData += chunk;
91
+ });
92
+
93
+ res.on('end', () => {
94
+ if (res.statusCode >= 200 && res.statusCode < 300) {
95
+ try {
96
+ responseData = responseData ? responseData : '{}';
97
+ const jsonResponse = JSON.parse(responseData);
98
+ resolve(jsonResponse);
99
+ } catch (error) {
100
+ reject(new Error(`Error parsing API response: ${error.message}`));
101
+ }
102
+ } else if (res.statusCode === 401) {
103
+ // Token expired or invalid
104
+ reject(new Error('UNAUTHORIZED'));
105
+ } else {
106
+ // Truncate response to avoid leaking sensitive data in error messages
107
+ const safeResponse = responseData.substring(0, 200);
108
+ reject(
109
+ new Error(
110
+ `API call failed with status ${res.statusCode}: ${safeResponse}`
111
+ )
112
+ );
113
+ }
114
+ });
115
+ });
116
+
117
+ req.on('error', (error) => {
118
+ reject(new Error(`Network error during API call: ${error.message}`));
119
+ });
120
+
121
+ if (
122
+ data &&
123
+ (method === 'POST' || method === 'PATCH' || method === 'PUT')
124
+ ) {
125
+ req.write(JSON.stringify(data));
126
+ }
127
+
128
+ req.end();
129
+ });
130
+ } catch (error) {
131
+ console.error('Error calling Graph API:', error);
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Calls Graph API with pagination support to retrieve all results up to maxCount
138
+ * @param {string} accessToken - The access token for authentication
139
+ * @param {string} method - HTTP method (GET only for pagination)
140
+ * @param {string} path - API endpoint path
141
+ * @param {object} queryParams - Initial query parameters
142
+ * @param {number} maxCount - Maximum number of items to retrieve (0 = all)
143
+ * @returns {Promise<object>} - Combined API response with all items
144
+ */
145
+ async function callGraphAPIPaginated(
146
+ accessToken,
147
+ method,
148
+ path,
149
+ queryParams = {},
150
+ maxCount = 0
151
+ ) {
152
+ if (method !== 'GET') {
153
+ throw new Error('Pagination only supports GET requests');
154
+ }
155
+
156
+ const allItems = [];
157
+ let nextLink;
158
+ let currentUrl = path;
159
+ let currentParams = { ...queryParams };
160
+
161
+ try {
162
+ do {
163
+ // Make API call
164
+ const response = await callGraphAPI(
165
+ accessToken,
166
+ method,
167
+ currentUrl,
168
+ null,
169
+ currentParams
170
+ );
171
+
172
+ // Add items from this page
173
+ if (response.value && Array.isArray(response.value)) {
174
+ allItems.push(...response.value);
175
+ }
176
+
177
+ // Check if we've reached the desired count
178
+ if (maxCount > 0 && allItems.length >= maxCount) {
179
+ break;
180
+ }
181
+
182
+ // Get next page URL
183
+ nextLink = response['@odata.nextLink'];
184
+
185
+ if (nextLink) {
186
+ // Pass the full nextLink URL directly to callGraphAPI
187
+ currentUrl = nextLink;
188
+ currentParams = {}; // nextLink already contains all params
189
+ }
190
+ } while (nextLink);
191
+
192
+ // Trim to exact count if needed
193
+ const finalItems = maxCount > 0 ? allItems.slice(0, maxCount) : allItems;
194
+
195
+ return {
196
+ value: finalItems,
197
+ '@odata.count': finalItems.length,
198
+ };
199
+ } catch (error) {
200
+ console.error('Error during pagination:', error);
201
+ throw error;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Calls Graph API to get raw MIME content (for email export)
207
+ * @param {string} accessToken - The access token for authentication
208
+ * @param {string} emailId - The email ID to export
209
+ * @returns {Promise<string>} - Raw MIME content as string
210
+ */
211
+ async function callGraphAPIRaw(accessToken, emailId) {
212
+ // Test mode: return mock MIME content
213
+ if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
214
+ return mockData.getMockMimeContent
215
+ ? mockData.getMockMimeContent(emailId)
216
+ : `MIME-Version: 1.0\nContent-Type: text/plain\n\nTest email content for ${emailId}`;
217
+ }
218
+
219
+ return new Promise((resolve, reject) => {
220
+ const path = `me/messages/${encodeURIComponent(emailId)}/$value`;
221
+ const finalUrl = `${config.GRAPH_API_ENDPOINT}${path}`;
222
+
223
+ const options = {
224
+ method: 'GET',
225
+ headers: {
226
+ Authorization: `Bearer ${accessToken}`,
227
+ Accept: 'message/rfc822', // Request MIME format
228
+ },
229
+ };
230
+
231
+ const req = https.request(finalUrl, options, (res) => {
232
+ let responseData = '';
233
+
234
+ // Collect data as UTF-8 string
235
+ res.setEncoding('utf8');
236
+
237
+ res.on('data', (chunk) => {
238
+ responseData += chunk;
239
+ });
240
+
241
+ res.on('end', () => {
242
+ if (res.statusCode >= 200 && res.statusCode < 300) {
243
+ resolve(responseData);
244
+ } else if (res.statusCode === 401) {
245
+ reject(new Error('UNAUTHORIZED'));
246
+ } else {
247
+ reject(
248
+ new Error(
249
+ `MIME export failed with status ${res.statusCode}: ${responseData.substring(0, 200)}`
250
+ )
251
+ );
252
+ }
253
+ });
254
+ });
255
+
256
+ req.on('error', (error) => {
257
+ reject(new Error(`Network error during MIME export: ${error.message}`));
258
+ });
259
+
260
+ req.end();
261
+ });
262
+ }
263
+
264
+ module.exports = {
265
+ callGraphAPI,
266
+ callGraphAPIPaginated,
267
+ callGraphAPIRaw,
268
+ };