@littlebearapps/outlook-assistant 3.6.0 → 3.7.1

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,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
 
@@ -72,7 +72,7 @@ async function callGraphAPI(
72
72
  }
73
73
 
74
74
  if (queryString) {
75
- queryString = '?' + queryString;
75
+ queryString = `?${queryString}`;
76
76
  }
77
77
  }
78
78
 
@@ -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
  }
package/utils/safety.js CHANGED
@@ -116,8 +116,121 @@ function formatDryRunPreview(emailObject) {
116
116
  };
117
117
  }
118
118
 
119
+ /**
120
+ * Format a dry-run preview for a mail rule (create or update).
121
+ * @param {object} rule - The composed Graph API rule object
122
+ * @returns {string} - Human-readable rule preview text
123
+ */
124
+ function formatRuleDryRunPreview(rule) {
125
+ const lines = [];
126
+
127
+ lines.push(`Name: ${rule.displayName}`);
128
+ lines.push(`Enabled: ${rule.isEnabled !== false}`);
129
+ if (rule.sequence) lines.push(`Sequence: ${rule.sequence}`);
130
+
131
+ // Conditions
132
+ const cond = rule.conditions || {};
133
+ const condParts = [];
134
+ if (cond.fromAddresses?.length > 0) {
135
+ condParts.push(
136
+ `From: ${cond.fromAddresses.map((a) => a.emailAddress?.address).join(', ')}`
137
+ );
138
+ }
139
+ if (cond.subjectContains?.length > 0) {
140
+ condParts.push(
141
+ `Subject contains (any): "${cond.subjectContains.join('", "')}"`
142
+ );
143
+ }
144
+ if (cond.bodyContains?.length > 0) {
145
+ condParts.push(`Body contains (any): "${cond.bodyContains.join('", "')}"`);
146
+ }
147
+ if (cond.bodyOrSubjectContains?.length > 0) {
148
+ condParts.push(
149
+ `Body or subject contains (any): "${cond.bodyOrSubjectContains.join('", "')}"`
150
+ );
151
+ }
152
+ if (cond.senderContains?.length > 0) {
153
+ condParts.push(
154
+ `Sender contains (any): "${cond.senderContains.join('", "')}"`
155
+ );
156
+ }
157
+ if (cond.recipientContains?.length > 0) {
158
+ condParts.push(
159
+ `Recipient contains (any): "${cond.recipientContains.join('", "')}"`
160
+ );
161
+ }
162
+ if (cond.sentToAddresses?.length > 0) {
163
+ condParts.push(
164
+ `Sent to: ${cond.sentToAddresses.map((a) => a.emailAddress?.address).join(', ')}`
165
+ );
166
+ }
167
+ if (cond.hasAttachment === true) condParts.push('Has attachment');
168
+ if (cond.importance) condParts.push(`Importance: ${cond.importance}`);
169
+ if (cond.sensitivity) condParts.push(`Sensitivity: ${cond.sensitivity}`);
170
+ if (cond.sentToMe === true) condParts.push('Sent to me');
171
+ if (cond.sentOnlyToMe === true) condParts.push('Sent only to me');
172
+ if (cond.sentCcMe === true) condParts.push('I am in CC');
173
+ if (cond.isAutomaticReply === true) condParts.push('Is automatic reply');
174
+ if (condParts.length > 0) {
175
+ lines.push(`Conditions: ${condParts.join('; ')}`);
176
+ }
177
+
178
+ // Actions
179
+ const act = rule.actions || {};
180
+ const actParts = [];
181
+ if (act.moveToFolder) actParts.push(`Move to folder: ${act.moveToFolder}`);
182
+ if (act.copyToFolder) actParts.push(`Copy to folder: ${act.copyToFolder}`);
183
+ if (act.markAsRead === true) actParts.push('Mark as read');
184
+ if (act.markImportance) {
185
+ actParts.push(`Mark importance: ${act.markImportance}`);
186
+ }
187
+ if (act.forwardTo?.length > 0) {
188
+ actParts.push(
189
+ `Forward to: ${act.forwardTo.map((r) => r.emailAddress?.address).join(', ')}`
190
+ );
191
+ }
192
+ if (act.redirectTo?.length > 0) {
193
+ actParts.push(
194
+ `Redirect to: ${act.redirectTo.map((r) => r.emailAddress?.address).join(', ')}`
195
+ );
196
+ }
197
+ if (act.assignCategories?.length > 0) {
198
+ actParts.push(`Assign categories: ${act.assignCategories.join(', ')}`);
199
+ }
200
+ if (act.stopProcessingRules === true) actParts.push('Stop processing rules');
201
+ if (act.delete === true) actParts.push('Delete (move to Deleted Items)');
202
+ if (actParts.length > 0) {
203
+ lines.push(`Actions: ${actParts.join('; ')}`);
204
+ }
205
+
206
+ // Exceptions
207
+ const exc = rule.exceptions || {};
208
+ const excParts = [];
209
+ if (exc.fromAddresses?.length > 0) {
210
+ excParts.push(
211
+ `From: ${exc.fromAddresses.map((a) => a.emailAddress?.address).join(', ')}`
212
+ );
213
+ }
214
+ if (exc.subjectContains?.length > 0) {
215
+ excParts.push(`Subject contains: "${exc.subjectContains.join('", "')}"`);
216
+ }
217
+ if (exc.senderContains?.length > 0) {
218
+ excParts.push(`Sender contains: "${exc.senderContains.join('", "')}"`);
219
+ }
220
+ if (exc.bodyContains?.length > 0) {
221
+ excParts.push(`Body contains: "${exc.bodyContains.join('", "')}"`);
222
+ }
223
+ if (exc.hasAttachment === true) excParts.push('Has attachment');
224
+ if (excParts.length > 0) {
225
+ lines.push(`Exceptions (rule skipped when): ${excParts.join('; ')}`);
226
+ }
227
+
228
+ return lines.join('\n');
229
+ }
230
+
119
231
  module.exports = {
120
232
  checkRateLimit,
121
233
  checkRecipientAllowlist,
122
234
  formatDryRunPreview,
235
+ formatRuleDryRunPreview,
123
236
  };