@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/advanced/index.js CHANGED
@@ -26,6 +26,8 @@ const {
26
26
  zonedParts,
27
27
  zonedWallTimeToUtcMs,
28
28
  } = require('../utils/datetime');
29
+ const { toolMetadata } = require('../utils/risk-classes');
30
+ const { toolError, authRequiredError } = require('../utils/tool-error');
29
31
 
30
32
  /**
31
33
  * Format an email for display (simplified)
@@ -78,14 +80,9 @@ async function handleAccessSharedMailbox(args) {
78
80
  const sharedMailbox = args.sharedMailbox || args.email;
79
81
 
80
82
  if (!sharedMailbox) {
81
- return {
82
- content: [
83
- {
84
- type: 'text',
85
- text: "Shared mailbox email address is required (e.g., 'shared@company.com').",
86
- },
87
- ],
88
- };
83
+ return toolError(
84
+ "Shared mailbox email address is required (e.g., 'shared@company.com')."
85
+ );
89
86
  }
90
87
 
91
88
  // Validate up front, through the same helper every other tool uses. The
@@ -96,17 +93,12 @@ async function handleAccessSharedMailbox(args) {
96
93
  try {
97
94
  mailboxPrefix = validateMailboxPrefix(sharedMailbox);
98
95
  } catch (error) {
99
- return { content: [{ type: 'text', text: error.message }] };
96
+ return toolError(error.message);
100
97
  }
101
98
  if (mailboxPrefix === 'me') {
102
- return {
103
- content: [
104
- {
105
- type: 'text',
106
- text: 'access-shared-mailbox reads another mailbox — pass its email address. To read your own mailbox, use `search-emails`.',
107
- },
108
- ],
109
- };
99
+ return toolError(
100
+ 'access-shared-mailbox reads another mailbox — pass its email address. To read your own mailbox, use `search-emails`.'
101
+ );
110
102
  }
111
103
 
112
104
  // listFolders mode: enumerate the shared mailbox's folder tree so callers
@@ -115,9 +107,7 @@ async function handleAccessSharedMailbox(args) {
115
107
 
116
108
  if (listFolders) {
117
109
  if (!sharedEnabled) {
118
- return {
119
- content: [{ type: 'text', text: SHARED_MAILBOX_DISABLED_MESSAGE }],
120
- };
110
+ return toolError(SHARED_MAILBOX_DISABLED_MESSAGE);
121
111
  }
122
112
  return handleListSharedMailboxFolders(sharedMailbox, args);
123
113
  }
@@ -151,18 +141,12 @@ async function handleAccessSharedMailbox(args) {
151
141
  if (!/not found|ambiguous/i.test(resolveError.message)) {
152
142
  throw resolveError;
153
143
  }
154
- return {
155
- content: [
156
- {
157
- type: 'text',
158
- text:
159
- `${resolveError.message}\n\n` +
160
- `Searched in ${sharedMailbox}. List its folders first to get exact names/IDs:\n` +
161
- '- `access-shared-mailbox` with `listFolders: true`, or\n' +
162
- `- \`folders\` tool with \`action: list\`, \`sharedMailbox: "${sharedMailbox}"\``,
163
- },
164
- ],
165
- };
144
+ return toolError(
145
+ `${resolveError.message}\n\n` +
146
+ `Searched in ${sharedMailbox}. List its folders first to get exact names/IDs:\n` +
147
+ '- `access-shared-mailbox` with `listFolders: true`, or\n' +
148
+ `- \`folders\` tool with \`action: list\`, \`sharedMailbox: "${sharedMailbox}"\``
149
+ );
166
150
  }
167
151
  }
168
152
 
@@ -244,49 +228,25 @@ async function handleAccessSharedMailbox(args) {
244
228
  };
245
229
  } catch (error) {
246
230
  if (error.message === 'Authentication required') {
247
- return {
248
- content: [
249
- {
250
- type: 'text',
251
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
252
- },
253
- ],
254
- };
231
+ return authRequiredError();
255
232
  }
256
233
 
257
234
  if (
258
235
  error.message.includes('Access is denied') ||
259
236
  error.message.includes('403')
260
237
  ) {
261
- return {
262
- content: [
263
- {
264
- type: 'text',
265
- text: `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect${sharedEnabled ? '' : ENABLE_SHARED_HINT}`,
266
- },
267
- ],
268
- };
238
+ return toolError(
239
+ `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect${sharedEnabled ? '' : ENABLE_SHARED_HINT}`
240
+ );
269
241
  }
270
242
 
271
243
  if (error.message.includes('not found') || error.message.includes('404')) {
272
- return {
273
- content: [
274
- {
275
- type: 'text',
276
- text: `Shared mailbox "${sharedMailbox}" not found. Please verify the email address.`,
277
- },
278
- ],
279
- };
244
+ return toolError(
245
+ `Shared mailbox "${sharedMailbox}" not found. Please verify the email address.`
246
+ );
280
247
  }
281
248
 
282
- return {
283
- content: [
284
- {
285
- type: 'text',
286
- text: `Error accessing shared mailbox: ${error.message}`,
287
- },
288
- ],
289
- };
249
+ return toolError(`Error accessing shared mailbox: ${error.message}`);
290
250
  }
291
251
  }
292
252
 
@@ -379,38 +339,19 @@ async function handleListSharedMailboxFolders(sharedMailbox, args) {
379
339
  };
380
340
  } catch (error) {
381
341
  if (error.message === 'Authentication required') {
382
- return {
383
- content: [
384
- {
385
- type: 'text',
386
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
387
- },
388
- ],
389
- };
342
+ return authRequiredError();
390
343
  }
391
344
 
392
345
  if (
393
346
  error.message.includes('Access is denied') ||
394
347
  error.message.includes('403')
395
348
  ) {
396
- return {
397
- content: [
398
- {
399
- type: 'text',
400
- text: `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have delegate access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect`,
401
- },
402
- ],
403
- };
349
+ return toolError(
350
+ `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have delegate access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect`
351
+ );
404
352
  }
405
353
 
406
- return {
407
- content: [
408
- {
409
- type: 'text',
410
- text: `Error listing shared mailbox folders: ${error.message}`,
411
- },
412
- ],
413
- };
354
+ return toolError(`Error listing shared mailbox folders: ${error.message}`);
414
355
  }
415
356
  }
416
357
 
@@ -474,14 +415,7 @@ async function handleSetMessageFlag(args) {
474
415
  const ids = messageIds || (messageId ? [messageId] : []);
475
416
 
476
417
  if (ids.length === 0) {
477
- return {
478
- content: [
479
- {
480
- type: 'text',
481
- text: 'Message ID (messageId) or IDs (messageIds) required.',
482
- },
483
- ],
484
- };
418
+ return toolError('Message ID (messageId) or IDs (messageIds) required.');
485
419
  }
486
420
 
487
421
  // Build flag object. Zoned values (Z/offset) are sent as the same instant in
@@ -568,23 +502,9 @@ async function handleSetMessageFlag(args) {
568
502
  };
569
503
  } catch (error) {
570
504
  if (error.message === 'Authentication required') {
571
- return {
572
- content: [
573
- {
574
- type: 'text',
575
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
576
- },
577
- ],
578
- };
505
+ return authRequiredError();
579
506
  }
580
- return {
581
- content: [
582
- {
583
- type: 'text',
584
- text: `Error setting message flag: ${error.message}`,
585
- },
586
- ],
587
- };
507
+ return toolError(`Error setting message flag: ${error.message}`);
588
508
  }
589
509
  }
590
510
 
@@ -599,14 +519,7 @@ async function handleClearMessageFlag(args) {
599
519
  const ids = messageIds || (messageId ? [messageId] : []);
600
520
 
601
521
  if (ids.length === 0) {
602
- return {
603
- content: [
604
- {
605
- type: 'text',
606
- text: 'Message ID (messageId) or IDs (messageIds) required.',
607
- },
608
- ],
609
- };
522
+ return toolError('Message ID (messageId) or IDs (messageIds) required.');
610
523
  }
611
524
 
612
525
  try {
@@ -671,23 +584,9 @@ async function handleClearMessageFlag(args) {
671
584
  };
672
585
  } catch (error) {
673
586
  if (error.message === 'Authentication required') {
674
- return {
675
- content: [
676
- {
677
- type: 'text',
678
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
679
- },
680
- ],
681
- };
587
+ return authRequiredError();
682
588
  }
683
- return {
684
- content: [
685
- {
686
- type: 'text',
687
- text: `Error clearing message flag: ${error.message}`,
688
- },
689
- ],
690
- };
589
+ return toolError(`Error clearing message flag: ${error.message}`);
691
590
  }
692
591
  }
693
592
 
@@ -735,14 +634,9 @@ async function handleFindMeetingRooms(args) {
735
634
  const explanation = isLikelyPersonal
736
635
  ? 'Meeting room search is M365-only. Personal Outlook.com accounts cannot use this feature — there are no rooms to find. Connect a Microsoft 365 work/school account to enable.'
737
636
  : 'This feature requires:\n- Places.Read.All permission\n- Meeting rooms configured in your organization';
738
- return {
739
- content: [
740
- {
741
- type: 'text',
742
- text: `Unable to find meeting rooms.\n\n**Note**: ${explanation}\n\nError: ${errMsg}`,
743
- },
744
- ],
745
- };
637
+ return toolError(
638
+ `Unable to find meeting rooms.\n\n**Note**: ${explanation}\n\nError: ${errMsg}`
639
+ );
746
640
  }
747
641
  }
748
642
 
@@ -843,23 +737,9 @@ async function handleFindMeetingRooms(args) {
843
737
  };
844
738
  } catch (error) {
845
739
  if (error.message === 'Authentication required') {
846
- return {
847
- content: [
848
- {
849
- type: 'text',
850
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
851
- },
852
- ],
853
- };
740
+ return authRequiredError();
854
741
  }
855
- return {
856
- content: [
857
- {
858
- type: 'text',
859
- text: `Error finding meeting rooms: ${error.message}`,
860
- },
861
- ],
862
- };
742
+ return toolError(`Error finding meeting rooms: ${error.message}`);
863
743
  }
864
744
  }
865
745
 
@@ -868,14 +748,8 @@ const advancedTools = [
868
748
  {
869
749
  name: 'access-shared-mailbox',
870
750
  description:
871
- "List emails — or enumerate folders — from a shared mailbox the signed-in user has been granted access to (read-only). Returns paged messages from the named `sharedMailbox` (or alias `email`) and `folder` (default `inbox`) with id/subject/from/receivedDateTime/preview — same shape as `search-emails` list mode. `folder` accepts a well-known name (inbox, sent, archive…), a custom/localized folder display name (e.g. `Archiv`), a nested folder path (e.g. `Inbox/Vendors/Acme`), or pass a raw `folderId`. Set `listFolders: true` to enumerate the shared mailbox's full folder tree (names, paths, IDs, counts) — use this to discover custom subfolders before reading them. Requires that the shared mailbox has been delegated to the signed-in user in Exchange (admin-configured). Use `outputVerbosity` to control field count and `count` (default 25, max 50) for page size. For full search/filter capability over a shared mailbox, prefer `search-emails` with `sharedMailbox` set. Custom/localized names, nested paths and `listFolders` need the server opt-in setting OUTLOOK_SHARED_MAILBOX (work/school only); without it `folder` must be a well-known name or a folder ID, as before.",
872
- annotations: {
873
- title: 'Shared Mailbox',
874
- readOnlyHint: true,
875
- // openWorldHint: returns shared-mailbox messages authored by external
876
- // senders. (#92)
877
- openWorldHint: true,
878
- },
751
+ "List emails or folders in a shared mailbox the signed-in user can access (read-only). Returns messages from `sharedMailbox` (alias `email`) and `folder` (default `inbox`) with id/subject/from/receivedDateTime/preview, the same shape as `search-emails` list mode. `folder` takes a well-known name (inbox, sent, archive…), a custom/localized display name (e.g. `Archiv`), a nested path (e.g. `Inbox/Vendors/Acme`), or pass a raw `folderId`. `listFolders: true` enumerates the shared mailbox's folder tree (names, paths, IDs, counts), to find custom subfolders before reading them. Needs the mailbox delegated to the signed-in user in Exchange (admin-configured). `outputVerbosity` sets field count and `count` (default 25, max 50) page size. For search and filters over a shared mailbox, use `search-emails` with `sharedMailbox` set. Custom/localized names, nested paths and `listFolders` need the server opt-in setting OUTLOOK_SHARED_MAILBOX (work/school only); without it `folder` must be a well-known name or a folder ID.",
752
+ ...toolMetadata('access-shared-mailbox', 'Shared Mailbox'),
879
753
  inputSchema: {
880
754
  type: 'object',
881
755
  properties: {
@@ -901,7 +775,7 @@ const advancedTools = [
901
775
  listFolders: {
902
776
  type: 'boolean',
903
777
  description:
904
- "Enumerate the shared mailbox's full folder tree (names, paths, IDs, item counts) instead of reading messages.",
778
+ "Enumerate the shared mailbox's full folder tree (names, paths, IDs, item counts) in place of reading messages.",
905
779
  },
906
780
  count: {
907
781
  type: 'number',
@@ -922,11 +796,7 @@ const advancedTools = [
922
796
  name: 'find-meeting-rooms',
923
797
  description:
924
798
  "Discover bookable meeting rooms in the user's organisation via the Graph rooms endpoint (read-only). Returns room resources with displayName, emailAddress, building, floor, capacity, and bookingType — suitable for piping into `create-event` as attendees. Filter by `query` (matches name/email), `building`, `floor`, or minimum `capacity`. Returns empty list on personal accounts (the rooms endpoint is M365-only). Use `outputVerbosity` to control field count.",
925
- annotations: {
926
- title: 'Meeting Rooms',
927
- readOnlyHint: true,
928
- openWorldHint: false,
929
- },
799
+ ...toolMetadata('find-meeting-rooms', 'Meeting Rooms'),
930
800
  inputSchema: {
931
801
  type: 'object',
932
802
  properties: {
@@ -86,4 +86,26 @@ function describeAuthError(input) {
86
86
  );
87
87
  }
88
88
 
89
- module.exports = { getAuthErrorHints, describeAuthError, AUTH_ERROR_HINTS };
89
+ /**
90
+ * Short, PII-free label for the default log level (#278): `label` plus the
91
+ * first AADSTS code (or a network error code) found, never the description,
92
+ * which can carry the user's address.
93
+ * @param {string} label - e.g. 'refresh-failed'
94
+ * @param {Error|string|null|undefined} input
95
+ * @returns {string} - e.g. 'refresh-failed:AADSTS70008'
96
+ */
97
+ function authErrorLogLabel(label, input) {
98
+ const aadsts = toMessage(input).match(/AADSTS\d+/);
99
+ if (aadsts) return `${label}:${aadsts[0]}`;
100
+ const code = input && input.code;
101
+ return typeof code === 'string' && /^[A-Z][A-Z_]{1,30}$/.test(code)
102
+ ? `${label}:${code}`
103
+ : label;
104
+ }
105
+
106
+ module.exports = {
107
+ getAuthErrorHints,
108
+ describeAuthError,
109
+ authErrorLogLabel,
110
+ AUTH_ERROR_HINTS,
111
+ };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Runtime-supplied Azure Application (client) ID.
3
+ *
4
+ * Plugin marketplaces (GitHub Copilot, Cursor — Agent Plugins 1.0) ship a
5
+ * static `mcp.json` with no way to prompt for settings, so users there can't
6
+ * set OUTLOOK_CLIENT_ID. Device-code sign-in only needs the client ID (never
7
+ * the secret), so the `auth` tool accepts it at runtime and it's persisted to
8
+ * `~/.outlook-assistant-config.json`.
9
+ *
10
+ * Precedence: OUTLOOK_CLIENT_ID env → MS_CLIENT_ID env (legacy) → saved file.
11
+ *
12
+ * Deliberately does NOT require ../config (config.js requires this module).
13
+ */
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+
18
+ const CONFIG_FILE_NAME = '.outlook-assistant-config.json';
19
+
20
+ // Azure Application (client) IDs are GUIDs.
21
+ const CLIENT_ID_RE =
22
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
23
+
24
+ /**
25
+ * Path of the persisted config file. Resolved per call (not at load) so a
26
+ * changed HOME — e.g. in tests — is honoured.
27
+ * @returns {string}
28
+ */
29
+ function getConfigPath() {
30
+ const homeDir = process.env.HOME || process.env.USERPROFILE || os.homedir();
31
+ return path.join(homeDir, CONFIG_FILE_NAME);
32
+ }
33
+
34
+ /**
35
+ * @param {unknown} id
36
+ * @returns {boolean} - true when `id` (trimmed) is a GUID
37
+ */
38
+ function isValidClientId(id) {
39
+ return typeof id === 'string' && CLIENT_ID_RE.test(id.trim());
40
+ }
41
+
42
+ /**
43
+ * Read the whole config file. Never throws.
44
+ * @returns {object} - Parsed object, or {} when missing/unreadable/not an object
45
+ */
46
+ function readConfigFile() {
47
+ try {
48
+ const parsed = JSON.parse(fs.readFileSync(getConfigPath(), 'utf8'));
49
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
50
+ ? parsed
51
+ : {};
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The saved client ID, or '' when there is none or it isn't a valid GUID.
59
+ * Never throws.
60
+ * @returns {string}
61
+ */
62
+ function loadSavedClientId() {
63
+ const { clientId } = readConfigFile();
64
+ return isValidClientId(clientId) ? clientId.trim() : '';
65
+ }
66
+
67
+ /**
68
+ * Validate and persist a client ID (mode 0600, temp file + rename), keeping
69
+ * any other keys already in the file.
70
+ * @param {string} id
71
+ * @returns {string} - The normalised (trimmed) ID that was saved
72
+ * @throws {Error} - When `id` isn't a GUID or the write fails
73
+ */
74
+ function saveClientId(id) {
75
+ if (!isValidClientId(id)) {
76
+ throw new Error(
77
+ 'Invalid client ID: expected the Application (client) ID GUID from your Azure app registration.'
78
+ );
79
+ }
80
+ const clientId = id.trim();
81
+ const filePath = getConfigPath();
82
+ const data = { ...readConfigFile(), clientId };
83
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
84
+ try {
85
+ fs.writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, {
86
+ mode: 0o600,
87
+ flag: 'wx',
88
+ });
89
+ fs.renameSync(tmpPath, filePath);
90
+ } catch (error) {
91
+ try {
92
+ fs.unlinkSync(tmpPath);
93
+ } catch {
94
+ // Temp file was never created or is already gone
95
+ }
96
+ throw error;
97
+ }
98
+ return clientId;
99
+ }
100
+
101
+ /**
102
+ * Which environment variable supplies the client ID, if any.
103
+ * @returns {{name: string, value: string}|null}
104
+ */
105
+ function getEnvClientId() {
106
+ // Trimmed, and blank counts as unset: plugin managers pass "" (or stray
107
+ // whitespace) for an unset option, which must not hide a saved ID.
108
+ for (const name of ['OUTLOOK_CLIENT_ID', 'MS_CLIENT_ID']) {
109
+ const value = (process.env[name] || '').trim();
110
+ if (value) return { name, value };
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Effective client ID: env (OUTLOOK_CLIENT_ID, then legacy MS_CLIENT_ID),
117
+ * then the saved file, then ''.
118
+ * @returns {string}
119
+ */
120
+ function resolveClientId() {
121
+ const env = getEnvClientId();
122
+ return env ? env.value : loadSavedClientId();
123
+ }
124
+
125
+ /**
126
+ * @returns {'env'|'saved'|'none'} - Where the effective client ID comes from
127
+ */
128
+ function getClientIdSource() {
129
+ if (getEnvClientId()) return 'env';
130
+ return loadSavedClientId() ? 'saved' : 'none';
131
+ }
132
+
133
+ module.exports = {
134
+ CONFIG_FILE_NAME,
135
+ getConfigPath,
136
+ isValidClientId,
137
+ loadSavedClientId,
138
+ saveClientId,
139
+ getEnvClientId,
140
+ resolveClientId,
141
+ getClientIdSource,
142
+ };
package/auth/index.js CHANGED
@@ -6,9 +6,11 @@ const TokenStorage = require('./token-storage');
6
6
  const config = require('../config');
7
7
  const { authTools, setToolCount } = require('./tools');
8
8
 
9
- // Singleton TokenStorage instance with auto-refresh support
9
+ // Singleton TokenStorage instance with auto-refresh support. No clientId is
10
+ // passed: TokenStorage resolves it on each use (env → saved config file), so
11
+ // an ID saved at runtime via `auth action=authenticate clientId=…` applies
12
+ // without a restart.
10
13
  const tokenStorage = new TokenStorage({
11
- clientId: config.AUTH_CONFIG.clientId,
12
14
  clientSecret: config.AUTH_CONFIG.clientSecret,
13
15
  tokenStorePath: config.AUTH_CONFIG.tokenStorePath,
14
16
  scopes: config.AUTH_CONFIG.scopes,
@@ -4,6 +4,9 @@ const _https = require('https'); // Reserved for future HTTPS support
4
4
  const _fs = require('fs'); // Reserved for future HTTPS support
5
5
  const crypto = require('crypto'); // Added for generating random string
6
6
  const TokenStorage = require('./token-storage'); // Assuming TokenStorage is in the same directory
7
+ const { loadSavedClientId } = require('./client-config');
8
+ const { authErrorLogLabel } = require('./auth-errors');
9
+ const { log } = require('../utils/logger');
7
10
 
8
11
  // HTML templates
9
12
  function escapeHtml(unsafe) {
@@ -54,8 +57,11 @@ const templates = {
54
57
 
55
58
  function createAuthConfig(envPrefix = 'OUTLOOK_') {
56
59
  return {
60
+ // Env first; then the ID saved via `auth action=authenticate clientId=…`.
57
61
  clientId:
58
- process.env[`${envPrefix}CLIENT_ID`] || process.env.MS_CLIENT_ID || '',
62
+ process.env[`${envPrefix}CLIENT_ID`] ||
63
+ process.env.MS_CLIENT_ID ||
64
+ loadSavedClientId(),
59
65
  clientSecret:
60
66
  process.env[`${envPrefix}CLIENT_SECRET`] ||
61
67
  process.env.MS_CLIENT_SECRET ||
@@ -193,7 +199,11 @@ function setupOAuthRoutes(
193
199
  await tokenStorage.exchangeCodeForTokens(code);
194
200
  res.send(templates.authSuccess);
195
201
  } catch (exchangeError) {
196
- console.error('Token exchange error:', exchangeError);
202
+ log.note(
203
+ 'auth',
204
+ authErrorLogLabel('token-exchange-failed', exchangeError)
205
+ );
206
+ log.debug('Token exchange error:', exchangeError);
197
207
  res.status(500).send(templates.tokenExchangeError(exchangeError));
198
208
  }
199
209
  });
@@ -3,6 +3,7 @@
3
3
  */
4
4
  const fs = require('fs');
5
5
  const config = require('../config');
6
+ const { log } = require('../utils/logger');
6
7
 
7
8
  // Global variable to store tokens
8
9
  let cachedTokens = null;
@@ -38,11 +39,13 @@ function loadTokenCache() {
38
39
  cachedTokens = tokens;
39
40
  return tokens;
40
41
  } catch (parseError) {
41
- console.error('Error parsing token file:', parseError.message);
42
+ log.note('auth', 'token-cache-unreadable');
43
+ log.debug('Error parsing token file:', parseError.message);
42
44
  return null;
43
45
  }
44
46
  } catch (error) {
45
- console.error('Error loading token cache:', error.message);
47
+ log.note('auth', 'token-cache-unreadable');
48
+ log.debug('Error loading token cache:', error.message);
46
49
  return null;
47
50
  }
48
51
  }
@@ -64,7 +67,8 @@ function saveTokenCache(tokens) {
64
67
  cachedTokens = tokens;
65
68
  return true;
66
69
  } catch (error) {
67
- console.error('Error saving token cache:', error);
70
+ log.note('auth', 'token-cache-save-failed');
71
+ log.debug('Error saving token cache:', error);
68
72
  return false;
69
73
  }
70
74
  }