@littlebearapps/outlook-assistant 3.5.1 → 3.6.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.
package/email/search.js CHANGED
@@ -152,33 +152,47 @@ async function progressiveSearch(
152
152
  }
153
153
  }
154
154
 
155
- // 1. Try combined search (most specific)
156
- try {
157
- const params = buildSearchParams(
158
- searchTerms,
159
- filterTerms,
160
- Math.min(50, maxCount),
161
- selectFields
162
- );
163
- console.error('Attempting combined search with params:', params);
164
- searchAttempts.push('combined-search');
155
+ // Check if we have any actual search terms (not just boolean filters)
156
+ const hasSearchTerms =
157
+ searchTerms.query ||
158
+ searchTerms.from ||
159
+ searchTerms.to ||
160
+ searchTerms.subject;
161
+
162
+ // 1. Try combined search (most specific) — skip if only boolean filters
163
+ if (
164
+ !hasSearchTerms &&
165
+ (filterTerms.hasAttachments === true || filterTerms.unreadOnly === true)
166
+ ) {
167
+ // Skip directly to boolean-only filter (step 3) — combined search is redundant
168
+ console.error('Only boolean filters provided, skipping combined search');
169
+ } else
170
+ try {
171
+ const params = buildSearchParams(
172
+ searchTerms,
173
+ filterTerms,
174
+ Math.min(50, maxCount),
175
+ selectFields
176
+ );
177
+ console.error('Attempting combined search with params:', params);
178
+ searchAttempts.push('combined-search');
165
179
 
166
- const response = await callGraphAPIPaginated(
167
- accessToken,
168
- 'GET',
169
- endpoint,
170
- params,
171
- maxCount
172
- );
173
- if (response.value && response.value.length > 0) {
174
- console.error(
175
- `Combined search successful: found ${response.value.length} results`
180
+ const response = await callGraphAPIPaginated(
181
+ accessToken,
182
+ 'GET',
183
+ endpoint,
184
+ params,
185
+ maxCount
176
186
  );
177
- return response;
187
+ if (response.value && response.value.length > 0) {
188
+ console.error(
189
+ `Combined search successful: found ${response.value.length} results`
190
+ );
191
+ return response;
192
+ }
193
+ } catch (error) {
194
+ console.error(`Combined search failed: ${error.message}`);
178
195
  }
179
- } catch (error) {
180
- console.error(`Combined search failed: ${error.message}`);
181
- }
182
196
 
183
197
  // 2. Try each search term individually, starting with most specific
184
198
  const searchPriority = ['from', 'to', 'subject', 'query'];
@@ -200,23 +214,16 @@ async function progressiveSearch(
200
214
  // $search for free-text query only.
201
215
  // NOTE: $filter and $orderby cannot be used together on mailbox - Graph API limitation
202
216
  if (term === 'from') {
203
- if (searchTerms[term].includes('@')) {
204
- simplifiedParams.$filter = `from/emailAddress/address eq '${searchTerms[term]}'`;
205
- } else {
206
- simplifiedParams.$filter = `contains(from/emailAddress/name, '${searchTerms[term]}')`;
207
- }
217
+ simplifiedParams.$filter = buildFromFilter(searchTerms[term]);
208
218
  } else if (term === 'to') {
209
- if (searchTerms[term].includes('@')) {
210
- simplifiedParams.$filter = `toRecipients/any(r: r/emailAddress/address eq '${searchTerms[term]}')`;
211
- } else {
212
- simplifiedParams.$filter = `toRecipients/any(r: contains(r/emailAddress/name, '${searchTerms[term]}'))`;
213
- }
219
+ simplifiedParams.$filter = buildToFilter(searchTerms[term]);
214
220
  } else if (term === 'subject') {
215
221
  // Use $filter with contains() — $search silently fails on personal MS accounts
216
222
  simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
217
223
  } else if (term === 'query') {
218
- simplifiedParams.$orderby = 'receivedDateTime desc';
219
- simplifiedParams.$search = `"${searchTerms[term]}"`;
224
+ // On personal accounts, $search fails with 503. Use $filter with
225
+ // contains(subject) as a best-effort fallback for free-text queries.
226
+ simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
220
227
  }
221
228
 
222
229
  // Add boolean filters if applicable
@@ -241,21 +248,29 @@ async function progressiveSearch(
241
248
  }
242
249
  }
243
250
 
244
- // 3. Try with only boolean filters
245
- if (filterTerms.hasAttachments === true || filterTerms.unreadOnly === true) {
251
+ // 3. Try with only boolean filters (also date range filters)
252
+ const hasBooleanFilters =
253
+ filterTerms.hasAttachments === true || filterTerms.unreadOnly === true;
254
+ const hasDateFilters =
255
+ filterTerms.receivedAfter || filterTerms.receivedBefore;
256
+ if (hasBooleanFilters || hasDateFilters) {
246
257
  try {
247
- console.error('Attempting search with only boolean filters');
258
+ console.error('Attempting search with only boolean/date filters');
248
259
  searchAttempts.push('boolean-filters-only');
249
260
 
250
261
  const filterOnlyParams = {
251
262
  $top: Math.min(50, maxCount),
252
263
  $select: selectFields,
253
- $orderby: 'receivedDateTime desc',
254
264
  };
255
265
 
256
- // Add the boolean filters
266
+ // Add the boolean + date filters
257
267
  addBooleanFilters(filterOnlyParams, filterTerms);
258
268
 
269
+ // Only add $orderby if no $filter (they can conflict on personal accounts)
270
+ if (!filterOnlyParams.$filter) {
271
+ filterOnlyParams.$orderby = 'receivedDateTime desc';
272
+ }
273
+
259
274
  const response = await callGraphAPIPaginated(
260
275
  accessToken,
261
276
  'GET',
@@ -269,6 +284,29 @@ async function progressiveSearch(
269
284
  return response;
270
285
  } catch (error) {
271
286
  console.error(`Boolean filter search failed: ${error.message}`);
287
+ // Retry without $orderby if it was the issue
288
+ if (error.message && error.message.includes('InefficientFilter')) {
289
+ try {
290
+ console.error('Retrying boolean filters without $orderby');
291
+ const retryParams = {
292
+ $top: Math.min(50, maxCount),
293
+ $select: selectFields,
294
+ };
295
+ addBooleanFilters(retryParams, filterTerms);
296
+ const response = await callGraphAPIPaginated(
297
+ accessToken,
298
+ 'GET',
299
+ endpoint,
300
+ retryParams,
301
+ maxCount
302
+ );
303
+ return response;
304
+ } catch (retryError) {
305
+ console.error(
306
+ `Boolean filter retry also failed: ${retryError.message}`
307
+ );
308
+ }
309
+ }
272
310
  }
273
311
  }
274
312
 
@@ -304,6 +342,54 @@ async function progressiveSearch(
304
342
  return response;
305
343
  }
306
344
 
345
+ /**
346
+ * Detect if a value is a domain-only filter (e.g. "@souliv.com.au" or "souliv.com.au")
347
+ * vs a full email address (e.g. "user@souliv.com.au") vs a display name (e.g. "Billie")
348
+ * @param {string} val - The from/to filter value
349
+ * @returns {'domain'|'email'|'name'} - The type of filter
350
+ */
351
+ function classifyEmailFilter(val) {
352
+ if (val.startsWith('@')) return 'domain';
353
+ // Has dots but no @ — likely a domain like "souliv.com.au"
354
+ if (!val.includes('@') && val.includes('.')) return 'domain';
355
+ if (val.includes('@')) return 'email';
356
+ return 'name';
357
+ }
358
+
359
+ /**
360
+ * Build a $filter condition for a from field value
361
+ * @param {string} val - The from filter value
362
+ * @returns {string} - OData $filter condition
363
+ */
364
+ function buildFromFilter(val) {
365
+ const type = classifyEmailFilter(val);
366
+ if (type === 'domain') {
367
+ // Use contains() — endswith() not supported on personal accounts
368
+ const domain = val.startsWith('@') ? val : `@${val}`;
369
+ return `contains(from/emailAddress/address, '${domain.substring(1)}')`;
370
+ } else if (type === 'email') {
371
+ return `from/emailAddress/address eq '${val}'`;
372
+ }
373
+ return `contains(from/emailAddress/name, '${val}')`;
374
+ }
375
+
376
+ /**
377
+ * Build a $filter condition for a to field value
378
+ * @param {string} val - The to filter value
379
+ * @returns {string} - OData $filter condition
380
+ */
381
+ function buildToFilter(val) {
382
+ const type = classifyEmailFilter(val);
383
+ if (type === 'domain') {
384
+ const domain = val.startsWith('@') ? val : `@${val}`;
385
+ // Use contains() — endswith() not supported on personal accounts
386
+ return `toRecipients/any(r: contains(r/emailAddress/address, '${domain.substring(1)}'))`;
387
+ } else if (type === 'email') {
388
+ return `toRecipients/any(r: r/emailAddress/address eq '${val}')`;
389
+ }
390
+ return `toRecipients/any(r: contains(r/emailAddress/name, '${val}'))`;
391
+ }
392
+
307
393
  /**
308
394
  * Build search parameters from search terms and filter terms
309
395
  * Uses $filter for email addresses (more reliable than $search)
@@ -342,28 +428,12 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
342
428
  // NOTE: $filter on email addresses is incompatible with $orderby - Graph API limitation
343
429
  if (searchTerms.from) {
344
430
  usesEmailFilter = true;
345
- if (searchTerms.from.includes('@')) {
346
- filterConditions.push(
347
- `from/emailAddress/address eq '${searchTerms.from}'`
348
- );
349
- } else {
350
- filterConditions.push(
351
- `contains(from/emailAddress/name, '${searchTerms.from}')`
352
- );
353
- }
431
+ filterConditions.push(buildFromFilter(searchTerms.from));
354
432
  }
355
433
 
356
434
  if (searchTerms.to) {
357
435
  usesEmailFilter = true;
358
- if (searchTerms.to.includes('@')) {
359
- filterConditions.push(
360
- `toRecipients/any(r: r/emailAddress/address eq '${searchTerms.to}')`
361
- );
362
- } else {
363
- filterConditions.push(
364
- `toRecipients/any(r: contains(r/emailAddress/name, '${searchTerms.to}'))`
365
- );
366
- }
436
+ filterConditions.push(buildToFilter(searchTerms.to));
367
437
  }
368
438
 
369
439
  // Add boolean filters (these ARE compatible with $orderby)
@@ -625,4 +695,7 @@ async function handleSearchByMessageId(args) {
625
695
  module.exports = {
626
696
  handleSearchEmails,
627
697
  handleSearchByMessageId,
698
+ buildFromFilter,
699
+ buildToFilter,
700
+ classifyEmailFilter,
628
701
  };
package/email/send.js CHANGED
@@ -101,49 +101,7 @@ async function handleSendEmail(args) {
101
101
  const allowlistError = checkRecipientAllowlist(allRecipients);
102
102
  if (allowlistError) return allowlistError;
103
103
 
104
- // Pre-send mail tips check
105
- if (checkRecipients) {
106
- const allAddresses = allRecipients.map((r) => r.emailAddress.address);
107
- const tipsResult = await handleGetMailTips({
108
- recipients: allAddresses,
109
- });
110
-
111
- // If there are warnings, prepend them to the response
112
- if (tipsResult._meta?.warningCount > 0 && dryRun) {
113
- // In dry-run mode, include mail tips in the preview
114
- const tipsText = tipsResult.content[0]?.text || '';
115
- const preview = formatDryRunPreview({
116
- message: {
117
- subject,
118
- body: {
119
- contentType:
120
- /<(html|div|p|h[1-6]|br|table|ul|ol|li|span|a\s|img|strong|em|b|i)\b/i.test(
121
- body
122
- )
123
- ? 'html'
124
- : 'text',
125
- content: body,
126
- },
127
- toRecipients,
128
- ccRecipients: ccRecipients.length > 0 ? ccRecipients : undefined,
129
- bccRecipients: bccRecipients.length > 0 ? bccRecipients : undefined,
130
- importance,
131
- },
132
- saveToSentItems,
133
- });
134
- return {
135
- content: [
136
- {
137
- type: 'text',
138
- text: tipsText + '\n\n---\n\n' + preview.content[0].text,
139
- },
140
- ],
141
- _meta: { mailTips: tipsResult._meta },
142
- };
143
- }
144
- }
145
-
146
- // Prepare email object
104
+ // Prepare email object (needed by both dryRun and actual send)
147
105
  const emailObject = {
148
106
  message: {
149
107
  subject,
@@ -164,6 +122,37 @@ async function handleSendEmail(args) {
164
122
  saveToSentItems,
165
123
  };
166
124
 
125
+ // Pre-send mail tips check
126
+ if (checkRecipients) {
127
+ const allAddresses = allRecipients.map((r) => r.emailAddress.address);
128
+ const tipsResult = await handleGetMailTips({
129
+ recipients: allAddresses,
130
+ });
131
+
132
+ const tipsText = tipsResult.content[0]?.text || '';
133
+
134
+ // In dry-run mode, always include mail tips in the preview
135
+ if (dryRun) {
136
+ const preview = formatDryRunPreview(emailObject);
137
+ return {
138
+ content: [
139
+ {
140
+ type: 'text',
141
+ text: tipsText + '\n\n---\n\n' + preview.content[0].text,
142
+ },
143
+ ],
144
+ _meta: { mailTips: tipsResult._meta },
145
+ };
146
+ }
147
+
148
+ // In send mode, warn if there are issues but proceed
149
+ if (tipsResult._meta?.warningCount > 0) {
150
+ // Store tips to prepend to send response
151
+ emailObject._mailTipsText = tipsText;
152
+ emailObject._mailTipsMeta = tipsResult._meta;
153
+ }
154
+ }
155
+
167
156
  // Dry-run mode: return preview without sending
168
157
  if (dryRun) {
169
158
  return formatDryRunPreview(emailObject);
package/index.js CHANGED
@@ -12,7 +12,7 @@ const {
12
12
  const config = require('./config');
13
13
 
14
14
  // Import module tools
15
- const { authTools } = require('./auth');
15
+ const { authTools, setToolCount } = require('./auth');
16
16
  const { calendarTools } = require('./calendar');
17
17
  const { emailTools } = require('./email');
18
18
  const { folderTools } = require('./folder');
@@ -39,6 +39,9 @@ const TOOLS = [
39
39
  ...advancedTools,
40
40
  ];
41
41
 
42
+ // Set dynamic tool count for auth about handler
43
+ setToolCount(TOOLS.length);
44
+
42
45
  // Create server with tools capabilities
43
46
  const server = new Server(
44
47
  { name: config.SERVER_NAME, version: config.SERVER_VERSION },
package/llms.txt CHANGED
@@ -11,12 +11,12 @@ Built by [Little Bear Apps](https://littlebearapps.com).
11
11
  - **License**: MIT
12
12
  - **Node.js**: >= 18.0.0
13
13
  - **Authentication**: OAuth 2.0 with Microsoft Graph API (requires Azure app registration)
14
- - **Tools**: 21 tools across 9 modules (reduced from 55 for optimal AI performance)
14
+ - **Tools**: 22 tools across 9 modules (reduced from 55 for optimal AI performance)
15
15
 
16
16
  ## Why Outlook Assistant?
17
17
 
18
18
  - Read, search, send, and export emails directly from Claude instead of switching apps
19
- - 7 email tools covering search, conversations, attachments, bulk export, and pre-send mail tips
19
+ - 8 email tools covering search, conversations, attachments, bulk export, pre-send mail tips, and draft management
20
20
  - Manage calendar events, contacts, rules, categories, and mailbox settings in one place
21
21
  - Export to multiple formats: MIME/EML, MBOX, Markdown, JSON, HTML
22
22
 
@@ -31,9 +31,9 @@ Built by [Little Bear Apps](https://littlebearapps.com).
31
31
 
32
32
  ## Safety & Token Efficiency
33
33
 
34
- - **MCP safety annotations** on all 21 tools — AI clients auto-approve reads and prompt for destructive operations
34
+ - **MCP safety annotations** on all 22 tools — AI clients auto-approve reads and prompt for destructive operations
35
35
  - **Send-email protections**: pre-send mail tips, dry-run preview, session rate limiting, recipient allowlist
36
- - **Token-optimised**: 21 tools instead of 55 saves ~11,000 tokens per turn (~64% reduction), improving AI accuracy and context efficiency
36
+ - **Token-optimised**: 22 tools instead of 55 saves ~11,000 tokens per turn (~64% reduction), improving AI accuracy and context efficiency
37
37
  - These safeguards reduce risk but are not foolproof — always review actions before approving
38
38
 
39
39
  ## Quick Start
@@ -58,7 +58,7 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
58
58
  ## Tool Categories
59
59
 
60
60
  - **Authentication (1 tool)**: `auth` — OAuth flow, status, about
61
- - **Email (7 tools)**: `search-emails`, `read-email`, `send-email`, `update-email`, `attachments`, `export`, `get-mail-tips`
61
+ - **Email (8 tools)**: `search-emails`, `read-email`, `send-email`, `draft`, `update-email`, `attachments`, `export`, `get-mail-tips`
62
62
  - **Calendar (3 tools)**: `list-events`, `create-event`, `manage-event`
63
63
  - **Contacts (2 tools)**: `manage-contact`, `search-people`
64
64
  - **Folders (1 tool)**: `folders` — list, create, move, stats
@@ -70,7 +70,7 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
70
70
  ## Documentation
71
71
 
72
72
  - [README](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/README.md): Full documentation including setup, Azure configuration, and usage
73
- - [Tools Reference](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/quickrefs/tools-reference.md): All 21 tools with parameters and safety annotations
73
+ - [Tools Reference](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/quickrefs/tools-reference.md): All 22 tools with parameters and safety annotations
74
74
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
75
75
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
76
76
  - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@littlebearapps/outlook-assistant",
3
- "version": "3.5.1",
3
+ "version": "3.6.0",
4
4
  "mcpName": "io.github.littlebearapps/outlook-assistant",
5
- "description": "Outlook Assistant — MCP server with 21 tools for email, calendar, contacts, and settings via Microsoft Graph API",
5
+ "description": "Outlook Assistant — MCP server with 22 tools for email, calendar, contacts, and settings via Microsoft Graph API",
6
6
  "main": "index.js",
7
7
  "bin": {
8
8
  "outlook-assistant": "./index.js"
@@ -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,
@@ -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