@littlebearapps/outlook-assistant 3.5.2 → 3.7.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.
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Shared rule builder utilities for creating and updating mail rules.
3
+ * Converts flat MCP tool parameters into Microsoft Graph API rule objects.
4
+ */
5
+ const { getFolderIdByName } = require('../email/folder-utils');
6
+ const { checkRecipientAllowlist } = require('../utils/safety');
7
+
8
+ const VALID_IMPORTANCE = ['low', 'normal', 'high'];
9
+ const VALID_SENSITIVITY = ['normal', 'personal', 'private', 'confidential'];
10
+
11
+ /**
12
+ * Parse a comma-separated string into a trimmed, non-empty array.
13
+ * @param {string} str - Comma-separated values
14
+ * @returns {string[]}
15
+ */
16
+ function parseCommaSeparated(str) {
17
+ if (!str) return [];
18
+ return str
19
+ .split(',')
20
+ .map((s) => s.trim())
21
+ .filter((s) => s.length > 0);
22
+ }
23
+
24
+ /**
25
+ * Convert a comma-separated email string into Graph API recipient objects.
26
+ * @param {string} emailString - Comma-separated email addresses
27
+ * @returns {Array<{emailAddress: {address: string}}>}
28
+ */
29
+ function formatRecipientObjects(emailString) {
30
+ return parseCommaSeparated(emailString).map((email) => ({
31
+ emailAddress: { address: email },
32
+ }));
33
+ }
34
+
35
+ /**
36
+ * Build a Graph API conditions object from flat tool parameters.
37
+ * @param {object} args - Tool arguments
38
+ * @returns {{ conditions: object, warnings: string[] }}
39
+ */
40
+ function buildConditions(args) {
41
+ const conditions = {};
42
+ const warnings = [];
43
+
44
+ // String collections (comma-separated → array with OR logic)
45
+ if (args.containsSubject) {
46
+ conditions.subjectContains = parseCommaSeparated(args.containsSubject);
47
+ }
48
+ if (args.bodyContains) {
49
+ conditions.bodyContains = parseCommaSeparated(args.bodyContains);
50
+ }
51
+ if (args.bodyOrSubjectContains) {
52
+ conditions.bodyOrSubjectContains = parseCommaSeparated(
53
+ args.bodyOrSubjectContains
54
+ );
55
+ }
56
+ if (args.senderContains) {
57
+ conditions.senderContains = parseCommaSeparated(args.senderContains);
58
+ }
59
+ if (args.recipientContains) {
60
+ conditions.recipientContains = parseCommaSeparated(args.recipientContains);
61
+ }
62
+
63
+ // Recipient collections (comma-separated emails → recipient objects)
64
+ if (args.fromAddresses) {
65
+ const recipients = formatRecipientObjects(args.fromAddresses);
66
+ if (recipients.length > 0) {
67
+ conditions.fromAddresses = recipients;
68
+ }
69
+ }
70
+ if (args.sentToAddresses) {
71
+ const recipients = formatRecipientObjects(args.sentToAddresses);
72
+ if (recipients.length > 0) {
73
+ conditions.sentToAddresses = recipients;
74
+ }
75
+ }
76
+
77
+ // Boolean conditions
78
+ if (args.hasAttachments === true) conditions.hasAttachment = true;
79
+ if (args.sentToMe === true) conditions.sentToMe = true;
80
+ if (args.sentOnlyToMe === true) conditions.sentOnlyToMe = true;
81
+ if (args.sentCcMe === true) conditions.sentCcMe = true;
82
+ if (args.isAutomaticReply === true) conditions.isAutomaticReply = true;
83
+
84
+ // Enum conditions
85
+ if (args.importance) {
86
+ if (!VALID_IMPORTANCE.includes(args.importance)) {
87
+ warnings.push(
88
+ `Invalid importance "${args.importance}". Must be: ${VALID_IMPORTANCE.join(', ')}`
89
+ );
90
+ } else {
91
+ conditions.importance = args.importance;
92
+ }
93
+ }
94
+ if (args.sensitivity) {
95
+ if (!VALID_SENSITIVITY.includes(args.sensitivity)) {
96
+ warnings.push(
97
+ `Invalid sensitivity "${args.sensitivity}". Must be: ${VALID_SENSITIVITY.join(', ')}`
98
+ );
99
+ } else {
100
+ conditions.sensitivity = args.sensitivity;
101
+ }
102
+ }
103
+
104
+ return { conditions, warnings };
105
+ }
106
+
107
+ /**
108
+ * Build a Graph API actions object from flat tool parameters.
109
+ * Async because folder resolution requires API calls.
110
+ * @param {object} args - Tool arguments
111
+ * @param {string} accessToken - Graph API access token
112
+ * @returns {Promise<{ actions: object, warnings: string[] }>}
113
+ */
114
+ async function buildActions(args, accessToken) {
115
+ const actions = {};
116
+ const warnings = [];
117
+
118
+ // Folder-based actions (name → ID resolution)
119
+ if (args.moveToFolder) {
120
+ const folderId = await getFolderIdByName(accessToken, args.moveToFolder);
121
+ if (!folderId) {
122
+ warnings.push(`Target folder "${args.moveToFolder}" not found.`);
123
+ } else {
124
+ actions.moveToFolder = folderId;
125
+ }
126
+ }
127
+ if (args.copyToFolder) {
128
+ const folderId = await getFolderIdByName(accessToken, args.copyToFolder);
129
+ if (!folderId) {
130
+ warnings.push(`Copy-to folder "${args.copyToFolder}" not found.`);
131
+ } else {
132
+ actions.copyToFolder = folderId;
133
+ }
134
+ }
135
+
136
+ // Boolean actions
137
+ if (args.markAsRead === true) actions.markAsRead = true;
138
+ if (args.stopProcessingRules === true) actions.stopProcessingRules = true;
139
+ if (args.deleteMessage === true) {
140
+ actions.delete = true;
141
+ warnings.push('This rule will move matching messages to Deleted Items.');
142
+ }
143
+
144
+ // Enum actions
145
+ if (args.markImportance) {
146
+ if (!VALID_IMPORTANCE.includes(args.markImportance)) {
147
+ warnings.push(
148
+ `Invalid markImportance "${args.markImportance}". Must be: ${VALID_IMPORTANCE.join(', ')}`
149
+ );
150
+ } else {
151
+ actions.markImportance = args.markImportance;
152
+ }
153
+ }
154
+
155
+ // Recipient-based actions (comma-separated emails → recipient objects)
156
+ if (args.forwardTo) {
157
+ const recipients = formatRecipientObjects(args.forwardTo);
158
+ if (recipients.length > 0) {
159
+ // Check allowlist if configured
160
+ const allowlistError = checkRecipientAllowlist(recipients);
161
+ if (allowlistError) {
162
+ warnings.push(
163
+ `Forward recipients blocked by allowlist: ${args.forwardTo}`
164
+ );
165
+ } else {
166
+ actions.forwardTo = recipients;
167
+ warnings.push(
168
+ `This rule will forward all matching emails to: ${args.forwardTo}. Verify these addresses are correct.`
169
+ );
170
+ }
171
+ }
172
+ }
173
+ if (args.redirectTo) {
174
+ const recipients = formatRecipientObjects(args.redirectTo);
175
+ if (recipients.length > 0) {
176
+ const allowlistError = checkRecipientAllowlist(recipients);
177
+ if (allowlistError) {
178
+ warnings.push(
179
+ `Redirect recipients blocked by allowlist: ${args.redirectTo}`
180
+ );
181
+ } else {
182
+ actions.redirectTo = recipients;
183
+ warnings.push(
184
+ `This rule will redirect all matching emails to: ${args.redirectTo}. The original sender will appear as the sender.`
185
+ );
186
+ }
187
+ }
188
+ }
189
+
190
+ // Category actions
191
+ if (args.assignCategories) {
192
+ actions.assignCategories = parseCommaSeparated(args.assignCategories);
193
+ }
194
+
195
+ return { actions, warnings };
196
+ }
197
+
198
+ /**
199
+ * Build a Graph API exceptions object from except* prefixed parameters.
200
+ * @param {object} args - Tool arguments
201
+ * @returns {{ exceptions: object, warnings: string[] }}
202
+ */
203
+ function buildExceptions(args) {
204
+ const exceptions = {};
205
+ const warnings = [];
206
+
207
+ if (args.exceptFromAddresses) {
208
+ const recipients = formatRecipientObjects(args.exceptFromAddresses);
209
+ if (recipients.length > 0) {
210
+ exceptions.fromAddresses = recipients;
211
+ }
212
+ }
213
+ if (args.exceptSubjectContains) {
214
+ exceptions.subjectContains = parseCommaSeparated(
215
+ args.exceptSubjectContains
216
+ );
217
+ }
218
+ if (args.exceptSenderContains) {
219
+ exceptions.senderContains = parseCommaSeparated(args.exceptSenderContains);
220
+ }
221
+ if (args.exceptBodyContains) {
222
+ exceptions.bodyContains = parseCommaSeparated(args.exceptBodyContains);
223
+ }
224
+ if (args.exceptHasAttachments === true) {
225
+ exceptions.hasAttachment = true;
226
+ }
227
+
228
+ return { exceptions, warnings };
229
+ }
230
+
231
+ /**
232
+ * Check if any condition was provided in the args.
233
+ * @param {object} args - Tool arguments
234
+ * @returns {boolean}
235
+ */
236
+ function hasAnyCondition(args) {
237
+ return Boolean(
238
+ args.fromAddresses ||
239
+ args.containsSubject ||
240
+ args.hasAttachments === true ||
241
+ args.bodyContains ||
242
+ args.bodyOrSubjectContains ||
243
+ args.senderContains ||
244
+ args.recipientContains ||
245
+ args.sentToAddresses ||
246
+ args.importance ||
247
+ args.sensitivity ||
248
+ args.sentToMe === true ||
249
+ args.sentOnlyToMe === true ||
250
+ args.sentCcMe === true ||
251
+ args.isAutomaticReply === true
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Check if any action was provided in the args.
257
+ * @param {object} args - Tool arguments
258
+ * @returns {boolean}
259
+ */
260
+ function hasAnyAction(args) {
261
+ return Boolean(
262
+ args.moveToFolder ||
263
+ args.copyToFolder ||
264
+ args.markAsRead === true ||
265
+ args.markImportance ||
266
+ args.forwardTo ||
267
+ args.redirectTo ||
268
+ args.assignCategories ||
269
+ args.stopProcessingRules === true ||
270
+ args.deleteMessage === true
271
+ );
272
+ }
273
+
274
+ module.exports = {
275
+ VALID_IMPORTANCE,
276
+ VALID_SENSITIVITY,
277
+ parseCommaSeparated,
278
+ formatRecipientObjects,
279
+ buildConditions,
280
+ buildActions,
281
+ buildExceptions,
282
+ hasAnyCondition,
283
+ hasAnyAction,
284
+ };
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Update rule functionality
3
+ */
4
+ const { callGraphAPI } = require('../utils/graph-api');
5
+ const { ensureAuthenticated } = require('../auth');
6
+ const { checkRateLimit, formatRuleDryRunPreview } = require('../utils/safety');
7
+ const { getInboxRules } = require('./list');
8
+ const {
9
+ buildConditions,
10
+ buildActions,
11
+ buildExceptions,
12
+ } = require('./rule-builder');
13
+
14
+ /**
15
+ * Update rule handler — modify an existing rule by name or ID.
16
+ * Conditions, actions, and exceptions use replace semantics (not merge).
17
+ * @param {object} args - Tool arguments
18
+ * @returns {object} - MCP response
19
+ */
20
+ async function handleUpdateRule(args) {
21
+ const { ruleName, ruleId, name, isEnabled, sequence, dryRun } = args;
22
+
23
+ // Rate limit
24
+ const rateLimitError = checkRateLimit('manage-rules');
25
+ if (rateLimitError) return rateLimitError;
26
+
27
+ if (!ruleName && !ruleId) {
28
+ return {
29
+ content: [
30
+ {
31
+ type: 'text',
32
+ text: 'Either ruleName or ruleId is required to identify the rule to update.',
33
+ },
34
+ ],
35
+ };
36
+ }
37
+
38
+ if (sequence !== undefined && (isNaN(sequence) || sequence < 1)) {
39
+ return {
40
+ content: [
41
+ {
42
+ type: 'text',
43
+ text: 'Sequence must be a positive number greater than zero.',
44
+ },
45
+ ],
46
+ };
47
+ }
48
+
49
+ try {
50
+ const accessToken = await ensureAuthenticated();
51
+
52
+ // Resolve rule ID
53
+ let resolvedId = ruleId;
54
+ let currentRule = null;
55
+
56
+ const rules = await getInboxRules(accessToken);
57
+
58
+ if (resolvedId) {
59
+ currentRule = rules.find((r) => r.id === resolvedId);
60
+ } else {
61
+ currentRule = rules.find((r) => r.displayName === ruleName);
62
+ if (currentRule) resolvedId = currentRule.id;
63
+ }
64
+
65
+ if (!currentRule) {
66
+ return {
67
+ content: [
68
+ {
69
+ type: 'text',
70
+ text: `Rule "${ruleName || ruleId}" not found.`,
71
+ },
72
+ ],
73
+ };
74
+ }
75
+
76
+ // Build PATCH payload — only include fields the user provided
77
+ const patch = {};
78
+ const allWarnings = [];
79
+
80
+ if (name) patch.displayName = name;
81
+ if (isEnabled !== undefined) patch.isEnabled = isEnabled;
82
+ if (sequence) patch.sequence = Math.max(1, Math.floor(sequence));
83
+
84
+ // Build conditions if any condition param was provided
85
+ const { conditions, warnings: condWarnings } = buildConditions(args);
86
+ if (Object.keys(conditions).length > 0) {
87
+ patch.conditions = conditions;
88
+ allWarnings.push(
89
+ 'Conditions replaced (not merged). Use dryRun=true to preview before updating.'
90
+ );
91
+ }
92
+ allWarnings.push(...condWarnings);
93
+
94
+ // Build actions if any action param was provided
95
+ const { actions, warnings: actWarnings } = await buildActions(
96
+ args,
97
+ accessToken
98
+ );
99
+ if (Object.keys(actions).length > 0) {
100
+ patch.actions = actions;
101
+ allWarnings.push(
102
+ 'Actions replaced (not merged). Use dryRun=true to preview before updating.'
103
+ );
104
+ }
105
+ allWarnings.push(...actWarnings);
106
+
107
+ // Check for fatal folder-not-found
108
+ const folderNotFound = actWarnings.some((w) => w.includes('not found'));
109
+ if (folderNotFound && Object.keys(actions).length === 0) {
110
+ return {
111
+ content: [
112
+ {
113
+ type: 'text',
114
+ text: actWarnings.filter((w) => w.includes('not found')).join('\n'),
115
+ },
116
+ ],
117
+ };
118
+ }
119
+
120
+ // Build exceptions if any except* param was provided
121
+ const { exceptions, warnings: excWarnings } = buildExceptions(args);
122
+ if (Object.keys(exceptions).length > 0) {
123
+ patch.exceptions = exceptions;
124
+ }
125
+ allWarnings.push(...excWarnings);
126
+
127
+ // Check that something is actually being changed
128
+ if (Object.keys(patch).length === 0) {
129
+ return {
130
+ content: [
131
+ {
132
+ type: 'text',
133
+ text: `No changes specified for rule "${currentRule.displayName}". Provide at least one parameter to update (name, isEnabled, sequence, conditions, actions, or exceptions).`,
134
+ },
135
+ ],
136
+ };
137
+ }
138
+
139
+ // Dry-run: show before/after preview
140
+ if (dryRun) {
141
+ const updatedRule = { ...currentRule, ...patch };
142
+ const currentPreview = formatRuleDryRunPreview(currentRule);
143
+ const updatedPreview = formatRuleDryRunPreview(updatedRule);
144
+
145
+ let text = `DRY RUN — Update preview for "${currentRule.displayName}" (not applied):\n\n`;
146
+ text += `CURRENT:\n${currentPreview}\n\n`;
147
+ text += `AFTER UPDATE:\n${updatedPreview}`;
148
+ if (allWarnings.length > 0) {
149
+ text += `\n\nWarnings:\n${allWarnings.map((w) => `- ${w}`).join('\n')}`;
150
+ }
151
+ return {
152
+ content: [{ type: 'text', text }],
153
+ };
154
+ }
155
+
156
+ // Execute PATCH
157
+ await callGraphAPI(
158
+ accessToken,
159
+ 'PATCH',
160
+ `me/mailFolders/inbox/messageRules/${resolvedId}`,
161
+ patch
162
+ );
163
+
164
+ const changedFields = Object.keys(patch)
165
+ .map((k) => {
166
+ if (k === 'displayName') return `name → "${patch.displayName}"`;
167
+ if (k === 'isEnabled') {
168
+ return patch.isEnabled ? 'enabled' : 'disabled';
169
+ }
170
+ if (k === 'sequence') return `sequence → ${patch.sequence}`;
171
+ if (k === 'conditions') return 'conditions updated';
172
+ if (k === 'actions') return 'actions updated';
173
+ if (k === 'exceptions') return 'exceptions updated';
174
+ return k;
175
+ })
176
+ .join(', ');
177
+
178
+ let text = `Successfully updated rule "${currentRule.displayName}": ${changedFields}.`;
179
+ // Filter out the replace/merge info warnings from the response since the update is done
180
+ const relevantWarnings = allWarnings.filter(
181
+ (w) => !w.includes('replaced (not merged)')
182
+ );
183
+ if (relevantWarnings.length > 0) {
184
+ text += `\n\nNotes:\n${relevantWarnings.map((w) => `- ${w}`).join('\n')}`;
185
+ }
186
+
187
+ return {
188
+ content: [{ type: 'text', text }],
189
+ };
190
+ } catch (error) {
191
+ if (error.message === 'Authentication required') {
192
+ return {
193
+ content: [
194
+ {
195
+ type: 'text',
196
+ text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
197
+ },
198
+ ],
199
+ };
200
+ }
201
+
202
+ return {
203
+ content: [
204
+ {
205
+ type: 'text',
206
+ text: `Error updating rule: ${error.message}`,
207
+ },
208
+ ],
209
+ };
210
+ }
211
+ }
212
+
213
+ module.exports = handleUpdateRule;
package/settings/index.js CHANGED
@@ -56,13 +56,7 @@ function formatAutomaticReplies(settings) {
56
56
 
57
57
  const lines = [];
58
58
  lines.push(
59
- `**Status**: ${
60
- settings.status === 'disabled'
61
- ? '🔴 Disabled'
62
- : settings.status === 'alwaysEnabled'
63
- ? '🟢 Always Enabled'
64
- : '📅 Scheduled'
65
- }`
59
+ `**Status**: ${{ disabled: '🔴 Disabled', alwaysEnabled: '🟢 Always Enabled' }[settings.status] || '📅 Scheduled'}`
66
60
  );
67
61
 
68
62
  if (settings.status !== 'disabled') {
@@ -113,7 +107,7 @@ async function handleGetMailboxSettings(args) {
113
107
 
114
108
  const settings = await callGraphAPI(accessToken, 'GET', endpoint);
115
109
 
116
- let output = [];
110
+ const output = [];
117
111
  output.push('# Mailbox Settings\n');
118
112
 
119
113
  if (section && section !== 'all') {
@@ -287,7 +281,7 @@ async function handleSetAutomaticReplies(args) {
287
281
  'me/mailboxSettings/automaticRepliesSetting'
288
282
  );
289
283
 
290
- let output = [];
284
+ const output = [];
291
285
  output.push('Automatic replies updated!\n');
292
286
  output.push(formatAutomaticReplies(updated));
293
287
 
@@ -413,17 +407,11 @@ async function handleSetWorkingHours(args) {
413
407
  );
414
408
 
415
409
  // Build update
410
+ const padTime = (t) =>
411
+ t.length === 5 ? `${t}:00.0000000` : `${t}.0000000`;
416
412
  const workingHours = {
417
- startTime: startTime
418
- ? startTime.length === 5
419
- ? startTime + ':00.0000000'
420
- : startTime + '.0000000'
421
- : current.startTime,
422
- endTime: endTime
423
- ? endTime.length === 5
424
- ? endTime + ':00.0000000'
425
- : endTime + '.0000000'
426
- : current.endTime,
413
+ startTime: startTime ? padTime(startTime) : current.startTime,
414
+ endTime: endTime ? padTime(endTime) : current.endTime,
427
415
  daysOfWeek: daysOfWeek
428
416
  ? daysOfWeek.map((d) => d.toLowerCase())
429
417
  : current.daysOfWeek,
@@ -442,7 +430,7 @@ async function handleSetWorkingHours(args) {
442
430
  'me/mailboxSettings/workingHours'
443
431
  );
444
432
 
445
- let output = [];
433
+ const output = [];
446
434
  output.push('Working hours updated!\n');
447
435
  output.push(formatWorkingHours(updated));
448
436
 
@@ -491,7 +479,7 @@ async function handleGetAutomaticReplies() {
491
479
  'me/mailboxSettings/automaticRepliesSetting'
492
480
  );
493
481
 
494
- let output = [];
482
+ const output = [];
495
483
  output.push('# Automatic Replies\n');
496
484
  output.push(formatAutomaticReplies(settings));
497
485
 
@@ -533,7 +521,7 @@ async function handleGetWorkingHours() {
533
521
  'me/mailboxSettings/workingHours'
534
522
  );
535
523
 
536
- let output = [];
524
+ const output = [];
537
525
  output.push('# Working Hours\n');
538
526
  output.push(formatWorkingHours(settings));
539
527
 
@@ -107,6 +107,26 @@ const FIELD_PRESETS = {
107
107
  'changeKey',
108
108
  ],
109
109
 
110
+ /**
111
+ * Draft fields for viewing/editing drafts
112
+ * Use case: Draft creation, update, listing
113
+ */
114
+ draft: [
115
+ 'id',
116
+ 'subject',
117
+ 'from',
118
+ 'toRecipients',
119
+ 'ccRecipients',
120
+ 'bccRecipients',
121
+ 'body',
122
+ 'bodyPreview',
123
+ 'lastModifiedDateTime',
124
+ 'isDraft',
125
+ 'importance',
126
+ 'hasAttachments',
127
+ 'conversationId',
128
+ ],
129
+
110
130
  /**
111
131
  * Search result fields (optimized for relevance display)
112
132
  * Use case: Search results with context
@@ -7,6 +7,7 @@ const mockData = require('./mock-data');
7
7
 
8
8
  /**
9
9
  * Makes a request to the Microsoft Graph API
10
+ * In test mode (USE_TEST_MODE=true), routes to mock data instead of the real API.
10
11
  * @param {string} accessToken - The access token for authentication
11
12
  * @param {string} method - HTTP method (GET, POST, etc.)
12
13
  * @param {string} path - API endpoint path
@@ -14,6 +15,8 @@ const mockData = require('./mock-data');
14
15
  * @param {object} queryParams - Query parameters
15
16
  * @param {object} extraHeaders - Additional headers (e.g. Prefer for immutable IDs)
16
17
  * @returns {Promise<object>} - The API response
18
+ * @throws {Error} 'UNAUTHORIZED' if the server returns HTTP 401 (token expired or invalid)
19
+ * @throws {Error} If the HTTP status is outside 2xx, or if JSON parsing or network fails
17
20
  */
18
21
  async function callGraphAPI(
19
22
  accessToken,
@@ -69,7 +72,7 @@ async function callGraphAPI(
69
72
  }
70
73
 
71
74
  if (queryString) {
72
- queryString = '?' + queryString;
75
+ queryString = `?${queryString}`;
73
76
  }
74
77
  }
75
78
 
@@ -153,6 +156,8 @@ async function callGraphAPI(
153
156
  * @param {object} queryParams - Initial query parameters
154
157
  * @param {number} maxCount - Maximum number of items to retrieve (0 = all)
155
158
  * @returns {Promise<object>} - Combined API response with all items
159
+ * @throws {Error} If method is not 'GET'
160
+ * @throws {Error} If any page request fails for any other reason
156
161
  */
157
162
  async function callGraphAPIPaginated(
158
163
  accessToken,
@@ -268,9 +273,12 @@ async function callGraphAPIBatch(accessToken, requests) {
268
273
 
269
274
  /**
270
275
  * Calls Graph API to get raw MIME content (for email export)
276
+ * In test mode (USE_TEST_MODE=true), returns mock MIME content instead of calling the real API.
271
277
  * @param {string} accessToken - The access token for authentication
272
278
  * @param {string} emailId - The email ID to export
273
279
  * @returns {Promise<string>} - Raw MIME content as string
280
+ * @throws {Error} 'UNAUTHORIZED' if the server returns HTTP 401 (token expired or invalid)
281
+ * @throws {Error} If the HTTP status is outside 2xx or a network error occurs
274
282
  */
275
283
  async function callGraphAPIRaw(accessToken, emailId) {
276
284
  // Test mode: return mock MIME content
@@ -187,7 +187,7 @@ function formatEmailListAsTable(emails, folder, meta = {}) {
187
187
  const date = formatDateShort(email.receivedDateTime);
188
188
  const status = email.isRead ? '📖' : '📬';
189
189
  const subject = truncateText(email.subject || '(no subject)', 40);
190
- const shortId = email.id.substring(0, 20) + '...';
190
+ const shortId = `${email.id.substring(0, 20)}...`;
191
191
 
192
192
  output += `| ${i + 1} | ${status} | ${date} | ${truncateText(from, 20)} | ${subject} | \`${shortId}\` |\n`;
193
193
  });
@@ -237,10 +237,12 @@ function formatEmailContent(
237
237
 
238
238
  if (verbosity === VERBOSITY.FULL) {
239
239
  output += `\n**ID:** \`${email.id}\``;
240
- if (email.conversationId)
240
+ if (email.conversationId) {
241
241
  output += `\n**Conversation ID:** \`${email.conversationId}\``;
242
- if (email.internetMessageId)
242
+ }
243
+ if (email.internetMessageId) {
243
244
  output += `\n**Message-ID:** \`${email.internetMessageId}\``;
245
+ }
244
246
  }
245
247
 
246
248
  output += '\n\n---\n\n';
@@ -457,7 +459,7 @@ function formatRecipients(recipients) {
457
459
  function truncateText(text, maxLength) {
458
460
  if (!text) return '';
459
461
  if (text.length <= maxLength) return text;
460
- return text.substring(0, maxLength - 3) + '...';
462
+ return `${text.substring(0, maxLength - 3)}...`;
461
463
  }
462
464
 
463
465
  /**
@@ -490,10 +492,10 @@ function escapeCSV(value) {
490
492
  const isFormula = formulaChars.some((ch) => str.startsWith(ch));
491
493
 
492
494
  if (isFormula) {
493
- return '"' + "'" + str.replace(/"/g, '""') + '"';
495
+ return `"` + `'${str.replace(/"/g, '""')}"`;
494
496
  }
495
497
  if (needsQuoting) {
496
- return '"' + str.replace(/"/g, '""') + '"';
498
+ return `"${str.replace(/"/g, '""')}"`;
497
499
  }
498
500
  return str;
499
501
  }