@littlebearapps/outlook-assistant 3.12.1 → 3.14.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 (69) hide show
  1. package/.env.example +27 -3
  2. package/README.md +108 -33
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/client-config.js +142 -0
  6. package/auth/index.js +4 -2
  7. package/auth/oauth-server.js +12 -2
  8. package/auth/token-manager.js +7 -3
  9. package/auth/token-storage.js +46 -33
  10. package/auth/tools.js +223 -93
  11. package/calendar/attendees.js +36 -0
  12. package/calendar/cancel.js +9 -25
  13. package/calendar/create.js +42 -48
  14. package/calendar/decline.js +10 -25
  15. package/calendar/delete.js +10 -25
  16. package/calendar/index.js +20 -37
  17. package/calendar/list.js +4 -16
  18. package/calendar/preview.js +335 -0
  19. package/calendar/update.js +42 -86
  20. package/categories/index.js +59 -264
  21. package/config.js +36 -2
  22. package/contacts/index.js +72 -128
  23. package/email/attachments.js +42 -124
  24. package/email/conversations.js +44 -78
  25. package/email/delta.js +10 -34
  26. package/email/draft.js +140 -96
  27. package/email/export.js +141 -110
  28. package/email/folder-utils.js +3 -2
  29. package/email/headers.js +11 -49
  30. package/email/index.js +85 -109
  31. package/email/list.js +4 -17
  32. package/email/mail-tips.js +86 -57
  33. package/email/mark-as-read.js +13 -49
  34. package/email/mime.js +14 -49
  35. package/email/read.js +16 -50
  36. package/email/search.js +46 -86
  37. package/email/send.js +82 -48
  38. package/folder/create.js +6 -25
  39. package/folder/delete.js +117 -38
  40. package/folder/index.js +17 -16
  41. package/folder/list.js +5 -17
  42. package/folder/move.js +13 -42
  43. package/folder/resolve.js +11 -6
  44. package/folder/stats.js +6 -20
  45. package/index.js +23 -45
  46. package/llms-install.md +31 -7
  47. package/llms.txt +19 -10
  48. package/outlook-auth-server.js +10 -3
  49. package/package.json +6 -2
  50. package/request-handler.js +217 -116
  51. package/rules/create.js +27 -70
  52. package/rules/index.js +30 -92
  53. package/rules/list.js +5 -17
  54. package/rules/rule-builder.js +57 -20
  55. package/rules/update.js +26 -60
  56. package/server.js +37 -0
  57. package/settings/index.js +142 -143
  58. package/tools.js +30 -0
  59. package/utils/field-presets.js +4 -2
  60. package/utils/graph-api.js +65 -22
  61. package/utils/logger.js +251 -0
  62. package/utils/mock-data.js +91 -2
  63. package/utils/read-only.js +59 -0
  64. package/utils/response-formatter.js +54 -15
  65. package/utils/risk-classes.js +324 -0
  66. package/utils/safe-write.js +372 -6
  67. package/utils/safety.js +109 -25
  68. package/utils/server-instructions.js +62 -0
  69. package/utils/tool-error.js +33 -0
package/rules/create.js CHANGED
@@ -3,16 +3,17 @@
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
- const { checkRateLimit } = require('../utils/safety');
7
- const { formatRuleDryRunPreview } = require('../utils/safety');
6
+ const { checkRateLimit, formatRuleDryRunPreview } = require('../utils/safety');
8
7
  const { getInboxRules } = require('./list');
9
8
  const {
10
9
  buildConditions,
11
10
  buildActions,
12
11
  buildExceptions,
12
+ checkRuleRecipients,
13
13
  hasAnyCondition,
14
14
  hasAnyAction,
15
15
  } = require('./rule-builder');
16
+ const { toolError, authRequiredError } = require('../utils/tool-error');
16
17
 
17
18
  /**
18
19
  * Create rule handler
@@ -22,55 +23,31 @@ const {
22
23
  async function handleCreateRule(args) {
23
24
  const { name, isEnabled = true, sequence, dryRun } = args;
24
25
 
25
- // Rate limit rule creation
26
- const rateLimitError = checkRateLimit('manage-rules');
27
- if (rateLimitError) return rateLimitError;
28
-
29
26
  // Validate sequence parameter
30
27
  if (sequence !== undefined && (isNaN(sequence) || sequence < 1)) {
31
- return {
32
- content: [
33
- {
34
- type: 'text',
35
- text: 'Sequence must be a positive number greater than zero.',
36
- },
37
- ],
38
- };
28
+ return toolError('Sequence must be a positive number greater than zero.');
39
29
  }
40
30
 
41
31
  if (!name) {
42
- return {
43
- content: [
44
- {
45
- type: 'text',
46
- text: 'Rule name is required.',
47
- },
48
- ],
49
- };
32
+ return toolError('Rule name is required.');
50
33
  }
51
34
 
52
35
  if (!hasAnyCondition(args)) {
53
- return {
54
- content: [
55
- {
56
- type: 'text',
57
- text: 'At least one condition is required. Available conditions: fromAddresses, containsSubject, bodyContains, bodyOrSubjectContains, senderContains, recipientContains, sentToAddresses, hasAttachments, importance, sensitivity, sentToMe, sentOnlyToMe, sentCcMe, isAutomaticReply.',
58
- },
59
- ],
60
- };
36
+ return toolError(
37
+ 'At least one condition is required. Available conditions: fromAddresses, containsSubject, bodyContains, bodyOrSubjectContains, senderContains, recipientContains, sentToAddresses, hasAttachments, importance, sensitivity, sentToMe, sentOnlyToMe, sentCcMe, isAutomaticReply.'
38
+ );
61
39
  }
62
40
 
63
41
  if (!hasAnyAction(args)) {
64
- return {
65
- content: [
66
- {
67
- type: 'text',
68
- text: 'At least one action is required. Available actions: moveToFolder, copyToFolder, markAsRead, markImportance, forwardTo, redirectTo, assignCategories, stopProcessingRules, deleteMessage.',
69
- },
70
- ],
71
- };
42
+ return toolError(
43
+ 'At least one action is required. Available actions: moveToFolder, copyToFolder, markAsRead, markImportance, forwardTo, redirectTo, assignCategories, stopProcessingRules, deleteMessage.'
44
+ );
72
45
  }
73
46
 
47
+ // Refuse the whole rule if the allowlist blocks any forwarding (#273)
48
+ const recipientError = checkRuleRecipients(args, { dryRun });
49
+ if (recipientError) return recipientError;
50
+
74
51
  try {
75
52
  const accessToken = await ensureAuthenticated();
76
53
 
@@ -87,14 +64,9 @@ async function handleCreateRule(args) {
87
64
  // Check for fatal warnings (folder not found = no valid action)
88
65
  const folderNotFound = actWarnings.some((w) => w.includes('not found'));
89
66
  if (folderNotFound && Object.keys(actions).length === 0) {
90
- return {
91
- content: [
92
- {
93
- type: 'text',
94
- text: actWarnings.filter((w) => w.includes('not found')).join('\n'),
95
- },
96
- ],
97
- };
67
+ return toolError(
68
+ actWarnings.filter((w) => w.includes('not found')).join('\n')
69
+ );
98
70
  }
99
71
 
100
72
  // Determine sequence
@@ -141,6 +113,10 @@ async function handleCreateRule(args) {
141
113
  };
142
114
  }
143
115
 
116
+ // Rate limit only real writes, so a dry run never uses up a slot (#273)
117
+ const rateLimitError = checkRateLimit('manage-rules');
118
+ if (rateLimitError) return rateLimitError;
119
+
144
120
  // Create the rule
145
121
  const response = await callGraphAPI(
146
122
  accessToken,
@@ -167,34 +143,15 @@ async function handleCreateRule(args) {
167
143
  };
168
144
  }
169
145
 
170
- return {
171
- content: [
172
- {
173
- type: 'text',
174
- text: "Failed to create rule. The server didn't return a rule ID.",
175
- },
176
- ],
177
- };
146
+ return toolError(
147
+ "Failed to create rule. The server didn't return a rule ID."
148
+ );
178
149
  } catch (error) {
179
150
  if (error.message === 'Authentication required') {
180
- return {
181
- content: [
182
- {
183
- type: 'text',
184
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
185
- },
186
- ],
187
- };
151
+ return authRequiredError();
188
152
  }
189
153
 
190
- return {
191
- content: [
192
- {
193
- type: 'text',
194
- text: `Error creating rule: ${error.message}`,
195
- },
196
- ],
197
- };
154
+ return toolError(`Error creating rule: ${error.message}`);
198
155
  }
199
156
  }
200
157
 
package/rules/index.js CHANGED
@@ -7,6 +7,8 @@ const handleUpdateRule = require('./update');
7
7
  const { callGraphAPI } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
9
  const { checkRateLimit } = require('../utils/safety');
10
+ const { toolMetadata } = require('../utils/risk-classes');
11
+ const { toolError, authRequiredError } = require('../utils/tool-error');
10
12
 
11
13
  /**
12
14
  * Delete rule handler
@@ -16,19 +18,8 @@ const { checkRateLimit } = require('../utils/safety');
16
18
  async function handleDeleteRule(args) {
17
19
  const { ruleName, ruleId } = args;
18
20
 
19
- // Rate limit
20
- const rateLimitError = checkRateLimit('manage-rules');
21
- if (rateLimitError) return rateLimitError;
22
-
23
21
  if (!ruleName && !ruleId) {
24
- return {
25
- content: [
26
- {
27
- type: 'text',
28
- text: 'Either ruleName or ruleId is required.',
29
- },
30
- ],
31
- };
22
+ return toolError('Either ruleName or ruleId is required.');
32
23
  }
33
24
 
34
25
  try {
@@ -42,19 +33,17 @@ async function handleDeleteRule(args) {
42
33
  const rules = await getInboxRules(accessToken);
43
34
  const rule = rules.find((r) => r.displayName === ruleName);
44
35
  if (!rule) {
45
- return {
46
- content: [
47
- {
48
- type: 'text',
49
- text: `Rule with name "${ruleName}" not found.`,
50
- },
51
- ],
52
- };
36
+ return toolError(`Rule with name "${ruleName}" not found.`);
53
37
  }
54
38
  resolvedId = rule.id;
55
39
  displayName = ruleName;
56
40
  }
57
41
 
42
+ // Checked only once there is a rule to delete, so a bad call doesn't
43
+ // use up a slot (#279)
44
+ const rateLimitError = checkRateLimit('manage-rules');
45
+ if (rateLimitError) return rateLimitError;
46
+
58
47
  await callGraphAPI(
59
48
  accessToken,
60
49
  'DELETE',
@@ -71,23 +60,9 @@ async function handleDeleteRule(args) {
71
60
  };
72
61
  } catch (error) {
73
62
  if (error.message === 'Authentication required') {
74
- return {
75
- content: [
76
- {
77
- type: 'text',
78
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
79
- },
80
- ],
81
- };
63
+ return authRequiredError();
82
64
  }
83
- return {
84
- content: [
85
- {
86
- type: 'text',
87
- text: `Error deleting rule: ${error.message}`,
88
- },
89
- ],
90
- };
65
+ return toolError(`Error deleting rule: ${error.message}`);
91
66
  }
92
67
  }
93
68
 
@@ -100,25 +75,15 @@ async function handleEditRuleSequence(args) {
100
75
  const { ruleName, sequence } = args;
101
76
 
102
77
  if (!ruleName) {
103
- return {
104
- content: [
105
- {
106
- type: 'text',
107
- text: 'Rule name is required. Please specify the exact name of an existing rule.',
108
- },
109
- ],
110
- };
78
+ return toolError(
79
+ 'Rule name is required. Please specify the exact name of an existing rule.'
80
+ );
111
81
  }
112
82
 
113
83
  if (!sequence || isNaN(sequence) || sequence < 1) {
114
- return {
115
- content: [
116
- {
117
- type: 'text',
118
- text: 'A positive sequence number is required. Lower numbers run first (higher priority).',
119
- },
120
- ],
121
- };
84
+ return toolError(
85
+ 'A positive sequence number is required. Lower numbers run first (higher priority).'
86
+ );
122
87
  }
123
88
 
124
89
  try {
@@ -127,16 +92,13 @@ async function handleEditRuleSequence(args) {
127
92
 
128
93
  const rule = rules.find((r) => r.displayName === ruleName);
129
94
  if (!rule) {
130
- return {
131
- content: [
132
- {
133
- type: 'text',
134
- text: `Rule with name "${ruleName}" not found.`,
135
- },
136
- ],
137
- };
95
+ return toolError(`Rule with name "${ruleName}" not found.`);
138
96
  }
139
97
 
98
+ // Reordering is a write, so it shares the manage-rules limit (#273)
99
+ const rateLimitError = checkRateLimit('manage-rules');
100
+ if (rateLimitError) return rateLimitError;
101
+
140
102
  await callGraphAPI(
141
103
  accessToken,
142
104
  'PATCH',
@@ -154,24 +116,10 @@ async function handleEditRuleSequence(args) {
154
116
  };
155
117
  } catch (error) {
156
118
  if (error.message === 'Authentication required') {
157
- return {
158
- content: [
159
- {
160
- type: 'text',
161
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
162
- },
163
- ],
164
- };
119
+ return authRequiredError();
165
120
  }
166
121
 
167
- return {
168
- content: [
169
- {
170
- type: 'text',
171
- text: `Error updating rule sequence: ${error.message}`,
172
- },
173
- ],
174
- };
122
+ return toolError(`Error updating rule sequence: ${error.message}`);
175
123
  }
176
124
  }
177
125
 
@@ -180,13 +128,8 @@ const rulesTools = [
180
128
  {
181
129
  name: 'manage-rules',
182
130
  description:
183
- 'Server-side inbox rule CRUD (destructive: covers `delete`; supports `dryRun` on create/update for preview). Rules run on the Exchange server regardless of which client is open. action=`list` (default) returns rules with id/name/sequence — pass `includeDetails: true` to expand conditions/actions/exceptions. action=`create` builds a new rule from condition params (12 supported: fromAddresses, containsSubject, bodyContains, hasAttachments, importance, sentTo, sensitivity, etc.), action params (9 supported: moveToFolder/copyToFolder — folder name, nested path like `Triage/Delete`, or ID — forwardTo, redirectTo, assignCategories, markAsRead, delete, etc.), and optional `except*` exceptions. action=`update` patches the named fields by `ruleId`. action=`reorder` changes execution priority via `sequence` (lower = earlier). action=`delete` removes a rule. Recipient allowlist applies to forwardTo/redirectTo. `permanentDelete` action is intentionally omitted (too dangerous for AI use — use the Outlook UI). Subject to session rate limits (`OUTLOOK_MAX_MANAGE_RULES_PER_SESSION`).',
184
- annotations: {
185
- title: 'Inbox Rules',
186
- readOnlyHint: false,
187
- destructiveHint: true,
188
- openWorldHint: false,
189
- },
131
+ 'Server-side inbox rules (destructive: covers `delete`; `dryRun` previews create/update). Rules run on the Exchange server whichever client is open. action=`list` (default) returns rules with id/name/sequence; `includeDetails: true` adds conditions/actions/exceptions. action=`create` builds a rule from condition params (12, e.g. fromAddresses, containsSubject, bodyContains, hasAttachments, importance, sentTo, sensitivity), action params (9, e.g. moveToFolder/copyToFolder — folder name, nested path like `Triage/Delete`, or ID — forwardTo, redirectTo, assignCategories, markAsRead, delete) and optional `except*` exceptions. action=`update` patches fields by `ruleId` or `ruleName`. action=`reorder` changes priority via `sequence` (lower runs first). action=`delete` removes a rule. The recipient allowlist applies to forwardTo/redirectTo. There is deliberately no `permanentDelete` action (too dangerous for AI use; use the Outlook UI). Subject to session rate limits (`OUTLOOK_MAX_MANAGE_RULES_PER_SESSION`).',
132
+ ...toolMetadata('manage-rules', 'Inbox Rules'),
190
133
  inputSchema: {
191
134
  type: 'object',
192
135
  properties: {
@@ -217,7 +160,7 @@ const rulesTools = [
217
160
  dryRun: {
218
161
  type: 'boolean',
219
162
  description:
220
- 'Preview rule without creating/updating (action=create, action=update)',
163
+ 'Preview only (action=create or update): shows the rule without creating or changing it. Other actions refuse dryRun and change nothing. Default false.',
221
164
  },
222
165
  isEnabled: {
223
166
  type: 'boolean',
@@ -404,14 +347,9 @@ const rulesTools = [
404
347
  case 'list':
405
348
  return handleListRules(args);
406
349
  default:
407
- return {
408
- content: [
409
- {
410
- type: 'text',
411
- text: `Unknown action '${action}'. Valid actions: list, create, update, reorder, delete.`,
412
- },
413
- ],
414
- };
350
+ return toolError(
351
+ `Unknown action '${action}'. Valid actions: list, create, update, reorder, delete.`
352
+ );
415
353
  }
416
354
  },
417
355
  },
package/rules/list.js CHANGED
@@ -3,6 +3,8 @@
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
+ const { toolError, authRequiredError } = require('../utils/tool-error');
7
+ const { log } = require('../utils/logger');
6
8
 
7
9
  /**
8
10
  * List rules handler
@@ -27,24 +29,10 @@ async function handleListRules(args) {
27
29
  };
28
30
  } catch (error) {
29
31
  if (error.message === 'Authentication required') {
30
- return {
31
- content: [
32
- {
33
- type: 'text',
34
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
35
- },
36
- ],
37
- };
32
+ return authRequiredError();
38
33
  }
39
34
 
40
- return {
41
- content: [
42
- {
43
- type: 'text',
44
- text: `Error listing rules: ${error.message}`,
45
- },
46
- ],
47
- };
35
+ return toolError(`Error listing rules: ${error.message}`);
48
36
  }
49
37
  }
50
38
 
@@ -64,7 +52,7 @@ async function getInboxRules(accessToken) {
64
52
 
65
53
  return response.value || [];
66
54
  } catch (error) {
67
- console.error(`Error getting inbox rules: ${error.message}`);
55
+ log.debug(`Error getting inbox rules: ${error.message}`);
68
56
  throw error;
69
57
  }
70
58
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
6
6
  const { checkRecipientAllowlist } = require('../utils/safety');
7
+ const { toolError } = require('../utils/tool-error');
7
8
 
8
9
  const VALID_IMPORTANCE = ['low', 'normal', 'high'];
9
10
  const VALID_SENSITIVITY = ['normal', 'personal', 'private', 'confidential'];
@@ -197,38 +198,35 @@ async function buildActions(args, accessToken) {
197
198
  }
198
199
  }
199
200
 
200
- // Recipient-based actions (comma-separated emails → recipient objects)
201
+ // Recipient-based actions (comma-separated emails → recipient objects).
202
+ // create/update refuse blocked recipients up front (checkRuleRecipients);
203
+ // this is the backstop, so a blocked forward is never silently dropped.
201
204
  if (args.forwardTo) {
202
205
  const recipients = formatRecipientObjects(args.forwardTo);
203
206
  if (recipients.length > 0) {
204
- // Check allowlist if configured
205
- const allowlistError = checkRecipientAllowlist(recipients);
206
- if (allowlistError) {
207
- warnings.push(
208
- `Forward recipients blocked by allowlist: ${args.forwardTo}`
209
- );
210
- } else {
211
- actions.forwardTo = recipients;
212
- warnings.push(
213
- `This rule will forward all matching emails to: ${args.forwardTo}. Verify these addresses are correct.`
207
+ if (checkRecipientAllowlist(recipients)) {
208
+ throw new Error(
209
+ `forwardTo recipients blocked by OUTLOOK_ALLOWED_RECIPIENTS: ${args.forwardTo}`
214
210
  );
215
211
  }
212
+ actions.forwardTo = recipients;
213
+ warnings.push(
214
+ `This rule will forward all matching emails to: ${args.forwardTo}. Verify these addresses are correct.`
215
+ );
216
216
  }
217
217
  }
218
218
  if (args.redirectTo) {
219
219
  const recipients = formatRecipientObjects(args.redirectTo);
220
220
  if (recipients.length > 0) {
221
- const allowlistError = checkRecipientAllowlist(recipients);
222
- if (allowlistError) {
223
- warnings.push(
224
- `Redirect recipients blocked by allowlist: ${args.redirectTo}`
225
- );
226
- } else {
227
- actions.redirectTo = recipients;
228
- warnings.push(
229
- `This rule will redirect all matching emails to: ${args.redirectTo}. The original sender will appear as the sender.`
221
+ if (checkRecipientAllowlist(recipients)) {
222
+ throw new Error(
223
+ `redirectTo recipients blocked by OUTLOOK_ALLOWED_RECIPIENTS: ${args.redirectTo}`
230
224
  );
231
225
  }
226
+ actions.redirectTo = recipients;
227
+ warnings.push(
228
+ `This rule will redirect all matching emails to: ${args.redirectTo}. The original sender will appear as the sender.`
229
+ );
232
230
  }
233
231
  }
234
232
 
@@ -240,6 +238,44 @@ async function buildActions(args, accessToken) {
240
238
  return { actions, warnings };
241
239
  }
242
240
 
241
+ /** Rule actions that send matching mail to other people. */
242
+ const RECIPIENT_ACTIONS = ['forwardTo', 'redirectTo'];
243
+
244
+ /**
245
+ * Refuse a rule whose forwardTo/redirectTo includes an address that
246
+ * OUTLOOK_ALLOWED_RECIPIENTS blocks. The whole rule (or update) is refused,
247
+ * never saved with the blocked forwarding dropped, and a dry run reports the
248
+ * refusal too. Needs no Graph call, so it runs before sign-in. (#273)
249
+ * @param {object} args - Tool arguments
250
+ * @param {{operation?: 'create'|'update', dryRun?: boolean}} [options]
251
+ * @returns {object|null} - toolError naming the blocked addresses, or null
252
+ */
253
+ function checkRuleRecipients(args, { operation = 'create', dryRun } = {}) {
254
+ const blocked = [];
255
+ for (const param of RECIPIENT_ACTIONS) {
256
+ const addresses = formatRecipientObjects(args[param])
257
+ .filter((recipient) => checkRecipientAllowlist([recipient]))
258
+ .map((recipient) => recipient.emailAddress.address);
259
+ if (addresses.length > 0) {
260
+ blocked.push(`${param}: ${addresses.join(', ')}`);
261
+ }
262
+ }
263
+ if (blocked.length === 0) return null;
264
+
265
+ const subject = operation === 'update' ? 'Rule update' : 'Rule';
266
+ const lead = dryRun
267
+ ? `DRY RUN — ${subject.toLowerCase()} would be refused`
268
+ : `${subject} refused`;
269
+ const outcome = operation === 'update' ? 'changed' : 'created';
270
+ return toolError(
271
+ `${lead}: OUTLOOK_ALLOWED_RECIPIENTS does not allow ${blocked.join('; ')}. Nothing was ${outcome}; a rule is refused whole rather than saved without the blocked forwarding.`,
272
+ {
273
+ nextStep:
274
+ 'Remove the blocked addresses from forwardTo/redirectTo and retry, or ask the user to add them to OUTLOOK_ALLOWED_RECIPIENTS in the server configuration and restart the server.',
275
+ }
276
+ );
277
+ }
278
+
243
279
  /**
244
280
  * Build a Graph API exceptions object from except* prefixed parameters.
245
281
  * @param {object} args - Tool arguments
@@ -324,6 +360,7 @@ module.exports = {
324
360
  buildConditions,
325
361
  buildActions,
326
362
  buildExceptions,
363
+ checkRuleRecipients,
327
364
  hasAnyCondition,
328
365
  hasAnyAction,
329
366
  };
package/rules/update.js CHANGED
@@ -9,7 +9,9 @@ const {
9
9
  buildConditions,
10
10
  buildActions,
11
11
  buildExceptions,
12
+ checkRuleRecipients,
12
13
  } = require('./rule-builder');
14
+ const { toolError, authRequiredError } = require('../utils/tool-error');
13
15
 
14
16
  /**
15
17
  * Update rule handler — modify an existing rule by name or ID.
@@ -20,32 +22,23 @@ const {
20
22
  async function handleUpdateRule(args) {
21
23
  const { ruleName, ruleId, name, isEnabled, sequence, dryRun } = args;
22
24
 
23
- // Rate limit
24
- const rateLimitError = checkRateLimit('manage-rules');
25
- if (rateLimitError) return rateLimitError;
26
-
27
25
  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
- };
26
+ return toolError(
27
+ 'Either ruleName or ruleId is required to identify the rule to update.'
28
+ );
36
29
  }
37
30
 
38
31
  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
- };
32
+ return toolError('Sequence must be a positive number greater than zero.');
47
33
  }
48
34
 
35
+ // Refuse the whole update if the allowlist blocks any forwarding (#273)
36
+ const recipientError = checkRuleRecipients(args, {
37
+ operation: 'update',
38
+ dryRun,
39
+ });
40
+ if (recipientError) return recipientError;
41
+
49
42
  try {
50
43
  const accessToken = await ensureAuthenticated();
51
44
 
@@ -63,14 +56,7 @@ async function handleUpdateRule(args) {
63
56
  }
64
57
 
65
58
  if (!currentRule) {
66
- return {
67
- content: [
68
- {
69
- type: 'text',
70
- text: `Rule "${ruleName || ruleId}" not found.`,
71
- },
72
- ],
73
- };
59
+ return toolError(`Rule "${ruleName || ruleId}" not found.`);
74
60
  }
75
61
 
76
62
  // Build PATCH payload — only include fields the user provided
@@ -107,14 +93,9 @@ async function handleUpdateRule(args) {
107
93
  // Check for fatal folder-not-found
108
94
  const folderNotFound = actWarnings.some((w) => w.includes('not found'));
109
95
  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
- };
96
+ return toolError(
97
+ actWarnings.filter((w) => w.includes('not found')).join('\n')
98
+ );
118
99
  }
119
100
 
120
101
  // Build exceptions if any except* param was provided
@@ -126,14 +107,9 @@ async function handleUpdateRule(args) {
126
107
 
127
108
  // Check that something is actually being changed
128
109
  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
- };
110
+ return toolError(
111
+ `No changes specified for rule "${currentRule.displayName}". Provide at least one parameter to update (name, isEnabled, sequence, conditions, actions, or exceptions).`
112
+ );
137
113
  }
138
114
 
139
115
  // Dry-run: show before/after preview
@@ -153,6 +129,10 @@ async function handleUpdateRule(args) {
153
129
  };
154
130
  }
155
131
 
132
+ // Rate limit only real writes, so a dry run never uses up a slot (#273)
133
+ const rateLimitError = checkRateLimit('manage-rules');
134
+ if (rateLimitError) return rateLimitError;
135
+
156
136
  // Execute PATCH
157
137
  await callGraphAPI(
158
138
  accessToken,
@@ -197,24 +177,10 @@ async function handleUpdateRule(args) {
197
177
  };
198
178
  } catch (error) {
199
179
  if (error.message === 'Authentication required') {
200
- return {
201
- content: [
202
- {
203
- type: 'text',
204
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
205
- },
206
- ],
207
- };
180
+ return authRequiredError();
208
181
  }
209
182
 
210
- return {
211
- content: [
212
- {
213
- type: 'text',
214
- text: `Error updating rule: ${error.message}`,
215
- },
216
- ],
217
- };
183
+ return toolError(`Error updating rule: ${error.message}`);
218
184
  }
219
185
  }
220
186