@littlebearapps/outlook-assistant 3.8.2 → 3.9.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.
package/README.md CHANGED
@@ -39,7 +39,7 @@ Outlook Assistant connects AI assistants to your Microsoft Outlook account throu
39
39
  - 📅 **Manage your calendar** — view upcoming events, schedule meetings with attendees, decline or cancel invitations
40
40
  - 📦 **Export emails** — save individual messages to Markdown, EML, JSON, or CSV; export full conversation threads to MBOX or HTML; bulk-export search results in one call
41
41
  - 🔍 **Investigate email headers** — full raw header access (DKIM, SPF, DMARC, delivery chain, X-Mailer, X-Originating-IP) for phishing investigation and compliance review
42
- - 🗂️ **Organise your inbox** — create folders, set up inbox rules, colour-code with categories, manage Focused Inbox — all work together for complete inbox automation
42
+ - 🗂️ **Organise your inbox** — create nested folders (addressable by path), set up inbox rules, colour-code with categories, manage Focused Inbox — all work together for complete inbox automation
43
43
  - 🔄 **Track inbox changes** — delta sync detects new, modified, and deleted emails since your last check, with tokens for incremental polling
44
44
  - 👥 **Manage contacts** — search your contact book and organisational directory, create and update contact records
45
45
  - ⚙️ **Configure settings** — set out-of-office auto-replies, working hours, and time zone
@@ -67,7 +67,7 @@ Outlook Assistant connects AI assistants to your Microsoft Outlook account throu
67
67
  | **Contacts** | 2 | `manage-contact` (list/search/get/create/update/delete), `search-people` |
68
68
  | **Categories** | 3 | `manage-category` (CRUD), `apply-category`, `manage-focused-inbox` |
69
69
  | **Settings** | 1 | `mailbox-settings` (get/set auto-replies/set working hours) |
70
- | **Folder** | 1 | `folders` (list/create/move/stats/delete) |
70
+ | **Folder** | 1 | `folders` (list/create/move/stats/delete) — nested folders addressable by path (`Parent/Child`) or ID |
71
71
  | **Rules** | 1 | `manage-rules` (list/create/update/reorder/delete) |
72
72
  | **Advanced** | 2 | `access-shared-mailbox`, `find-meeting-rooms` |
73
73
  | **Auth** | 1 | `auth` (status/authenticate/about) |
@@ -112,7 +112,7 @@ Outlook Assistant works with both personal and work/school Microsoft accounts, b
112
112
  ### What Makes This Different
113
113
 
114
114
  - **Progressive search** — on accounts where Microsoft's `$search` API is limited, Outlook Assistant automatically falls back through up to 4 search strategies to find your emails. Most Graph API wrappers fail silently; this one adapts.
115
- - **Email forensics** — raw header access for DKIM, SPF, DMARC, delivery chain, X-Mailer, X-Originating-IP, and spam scores. Returns the full data so you can investigate phishing, audit compliance, or trace delivery issues. (Auto-verdict is on the v3.8.0 roadmap; today the data is surfaced and analysed in-conversation.)
115
+ - **Email forensics** — raw header access for DKIM, SPF, DMARC, delivery chain, X-Mailer, X-Originating-IP, and spam scores. Returns the full data so you can investigate phishing, audit compliance, or trace delivery issues. (Auto-verdict is on the roadmap; today the data is surfaced and analysed in-conversation.)
116
116
  - **Delta sync** — incremental inbox monitoring returns only what changed since your last check, with tokens for continuous polling. Designed for agent workflows that need to watch a mailbox.
117
117
  - **Batch operations** — flag, move, export, or categorise multiple emails in a single call. Search-driven export lets you batch-export results without collecting IDs manually.
118
118
  - **Pre-send intelligence** — check recipients for out-of-office, full mailbox, delivery restrictions, and moderation status before sending — no other Outlook MCP server offers this.
@@ -497,7 +497,7 @@ USE_TEST_MODE=true npm start
497
497
  | [Getting Started](docs/how-to/getting-started/connect-outlook-to-claude.md) | Install, configure, and authenticate — start here |
498
498
  | [Azure Setup Guide](docs/guides/azure-setup.md) | Azure account creation, app registration, permissions, and secrets |
499
499
  | [How-To Guides](docs/how-to/index.md) | 29 practical guides for email, calendar, contacts, and settings |
500
- | [Roadmap](ROADMAP.md) | Active milestones (v3.7.5, v3.8.0, v3.9.0) and recent releases |
500
+ | [Roadmap](ROADMAP.md) | Active milestones (v3.7.5, v3.8.x, v3.10.0+) and recent releases |
501
501
  | [Troubleshooting & FAQ](docs/how-to/getting-started/verify-your-connection.md#common-connection-problems) | Common problems, re-authentication, and frequently asked questions |
502
502
  | [Tools Reference](docs/quickrefs/tools-reference.md) | All 22 tools with parameters |
503
503
  | [AI Agent Guide](docs/how-to/ai-agents/using-outlook-assistant-in-agents.md) | Tool selection and workflow patterns for AI agents |
@@ -506,7 +506,7 @@ Full documentation: [docs/](docs/README.md)
506
506
 
507
507
  ## Known Limitations
508
508
 
509
- - **Personal account search**: Free-text `query` and `kqlQuery` rely on Microsoft's `$search` API, which has limited support on personal Outlook.com accounts. Outlook Assistant mitigates this with progressive search fallback (trying OData filters automatically), but for the most direct results, use structured filters (`from`, `subject`, `to`, `receivedAfter`).
509
+ - **Personal account search**: Free-text `query` and the raw `searchExpression` (formerly `kqlQuery`) rely on Microsoft's `$search` API, which has limited support on personal Outlook.com accounts — field-scoped raw `$search` (e.g. `subject:"…"`) may return nothing there. `query` mitigates this with progressive fallback (OData filters, then client-side), so for reliable personal-account search prefer structured filters (`from`, `subject`, `to`, `receivedAfter`) or `query`. Cross-folder search (`searchAllFolders: true`) returns a superset of inbox-only results.
510
510
  - **Focused Inbox**: Only available on work/school Microsoft 365 accounts.
511
511
  - **Shared mailboxes**: Require `Mail.Read.Shared` permission and a work/school account.
512
512
  - **Meeting room search**: Requires `Place.Read.All` permission with admin consent (work/school accounts only).
package/advanced/index.js CHANGED
@@ -611,7 +611,9 @@ const advancedTools = [
611
611
  annotations: {
612
612
  title: 'Shared Mailbox',
613
613
  readOnlyHint: true,
614
- openWorldHint: false,
614
+ // openWorldHint: returns shared-mailbox messages authored by external
615
+ // senders. (#92)
616
+ openWorldHint: true,
615
617
  },
616
618
  inputSchema: {
617
619
  type: 'object',
package/calendar/index.js CHANGED
@@ -13,7 +13,7 @@ const calendarTools = [
13
13
  {
14
14
  name: 'list-events',
15
15
  description:
16
- 'List upcoming calendar events for the signed-in user (read-only). Returns an array of events with id, subject, start/end, attendees, location, organiser, and webLink. Use `count` (default 10, max 50) to control page size; this tool does not filter — use the Outlook UI or specific date ranges via Graph for filtered queries. Times are returned in the configured timezone (default Australia/Melbourne; override with `OUTLOOK_DEFAULT_TIMEZONE`).',
16
+ 'List upcoming calendar events for the signed-in user (read-only). Returns an array of events with id, subject, start/end, attendees, location, organiser, and webLink. Use `count` (default 10, max 50) to control page size; this tool does not filter — use the Outlook UI or specific date ranges via Graph for filtered queries. Each start/end is returned as a canonical UTC ISO-8601 instant (e.g. `2026-04-02T22:00:00.000Z`) followed by a labelled local rendering in the configured display timezone (default Australia/Melbourne; override with `OUTLOOK_DEFAULT_TIMEZONE`) — the UTC value is authoritative, so consumers never have to guess the zone.',
17
17
  annotations: {
18
18
  title: 'List Calendar Events',
19
19
  readOnlyHint: true,
package/calendar/list.js CHANGED
@@ -5,6 +5,84 @@ const config = require('../config');
5
5
  const { callGraphAPI } = require('../utils/graph-api');
6
6
  const { ensureAuthenticated } = require('../auth');
7
7
 
8
+ /**
9
+ * Normalise a Graph dateTimeTimeZone value to a canonical UTC ISO-8601 string
10
+ * (e.g. "2026-04-02T22:00:00.000Z"). This is the authoritative, unambiguous
11
+ * value returned to consumers so an AI client never has to guess the zone. (#118)
12
+ *
13
+ * We request events in UTC (Prefer: outlook.timezone="UTC"), so Graph returns
14
+ * zone-less dateTimes tagged timeZone:"UTC". We still validate defensively
15
+ * rather than blindly appending "Z":
16
+ * - values already ending in "Z" or carrying an explicit numeric offset are
17
+ * parsed as-is (never double-suffixed);
18
+ * - zone-less values are treated as UTC ONLY when the accompanying timeZone is
19
+ * UTC (or absent); any other zone throws rather than emit a wrong instant.
20
+ *
21
+ * @param {{dateTime: string, timeZone?: string}} dtz - Graph start/end value
22
+ * @returns {string} Canonical UTC ISO-8601 string
23
+ * @throws {Error} if missing/unparseable, or zone-less in a non-UTC zone
24
+ */
25
+ function toUtcIso(dtz) {
26
+ if (!dtz || !dtz.dateTime) {
27
+ throw new Error('event time missing dateTime');
28
+ }
29
+ const raw = String(dtz.dateTime).trim();
30
+ const hasZ = /[zZ]$/.test(raw);
31
+ const hasOffset = /[+-]\d{2}:?\d{2}$/.test(raw);
32
+
33
+ let iso;
34
+ if (hasZ || hasOffset) {
35
+ // Already zone-aware — parse as-is, never double-suffix.
36
+ iso = raw;
37
+ } else {
38
+ // Zone-less. Safe to treat as UTC only when Graph says so.
39
+ const zone = (dtz.timeZone || 'UTC').toUpperCase();
40
+ if (zone !== 'UTC') {
41
+ throw new Error(
42
+ `cannot normalise zone-less time in non-UTC zone "${dtz.timeZone}"`
43
+ );
44
+ }
45
+ iso = `${raw}Z`;
46
+ }
47
+
48
+ const parsed = new Date(iso);
49
+ if (Number.isNaN(parsed.getTime())) {
50
+ throw new Error(`invalid event dateTime "${dtz.dateTime}"`);
51
+ }
52
+ return parsed.toISOString();
53
+ }
54
+
55
+ /**
56
+ * Render a UTC ISO instant as a human-friendly local time explicitly labelled
57
+ * with its zone offset (e.g. "3 Apr 2026, 9:00 am GMT+11:00"), so it can never
58
+ * be mistaken for UTC or another zone. Returns '' if unrenderable.
59
+ *
60
+ * NB: explicit component options are used deliberately — combining
61
+ * `dateStyle`/`timeStyle` with `timeZoneName` throws `Invalid option` in Intl.
62
+ *
63
+ * @param {string} utcIso - Canonical UTC ISO-8601 instant
64
+ * @param {string} tz - IANA display timezone
65
+ * @returns {string}
66
+ */
67
+ function formatLocal(utcIso, tz) {
68
+ const d = new Date(utcIso);
69
+ if (Number.isNaN(d.getTime())) return '';
70
+ try {
71
+ return d.toLocaleString('en-AU', {
72
+ timeZone: tz,
73
+ year: 'numeric',
74
+ month: 'short',
75
+ day: 'numeric',
76
+ hour: 'numeric',
77
+ minute: '2-digit',
78
+ hour12: true,
79
+ timeZoneName: 'longOffset',
80
+ });
81
+ } catch (_e) {
82
+ return '';
83
+ }
84
+ }
85
+
8
86
  /**
9
87
  * List events handler
10
88
  * @param {object} args - Tool arguments
@@ -28,13 +106,16 @@ async function handleListEvents(args) {
28
106
  $select: config.CALENDAR_SELECT_FIELDS,
29
107
  };
30
108
 
31
- // Make API call
109
+ // Make API call. Force UTC so Graph's returned instants are unambiguous
110
+ // regardless of mailbox/server timezone; we then emit canonical UTC ISO
111
+ // plus a labelled local rendering. (#118)
32
112
  const response = await callGraphAPI(
33
113
  accessToken,
34
114
  'GET',
35
115
  endpoint,
36
116
  null,
37
- queryParams
117
+ queryParams,
118
+ { Prefer: 'outlook.timezone="UTC"' }
38
119
  );
39
120
 
40
121
  if (!response.value || response.value.length === 0) {
@@ -48,29 +129,35 @@ async function handleListEvents(args) {
48
129
  };
49
130
  }
50
131
 
51
- // Format results
132
+ // Format results. Each Start/End is the canonical UTC ISO-8601 instant
133
+ // (unambiguous for machine consumers), followed by a labelled local
134
+ // rendering in the configured display timezone for human readers. (#118)
52
135
  const tz = config.DEFAULT_TIMEZONE;
53
136
  const eventList = response.value
54
137
  .map((event, index) => {
55
- const startDt = event.start.dateTime.endsWith('Z')
56
- ? event.start.dateTime
57
- : `${event.start.dateTime}Z`;
58
- const endDt = event.end.dateTime.endsWith('Z')
59
- ? event.end.dateTime
60
- : `${event.end.dateTime}Z`;
61
- const startDate = new Date(startDt).toLocaleString('en-AU', {
62
- timeZone: tz,
63
- dateStyle: 'medium',
64
- timeStyle: 'short',
65
- });
66
- const endDate = new Date(endDt).toLocaleString('en-AU', {
67
- timeZone: tz,
68
- dateStyle: 'medium',
69
- timeStyle: 'short',
70
- });
71
- const location = event.location.displayName || 'No location';
72
-
73
- return `${index + 1}. ${event.subject} - Location: ${location}\nStart: ${startDate}\nEnd: ${endDate}\nSummary: ${event.bodyPreview}\nID: ${event.id}\n`;
138
+ // Authoritative machine-readable instants. Fall back to the raw Graph
139
+ // value if normalisation fails so one odd event can't break the list.
140
+ let startUtc;
141
+ let endUtc;
142
+ try {
143
+ startUtc = toUtcIso(event.start);
144
+ } catch (_e) {
145
+ startUtc = event.start?.dateTime || 'unknown';
146
+ }
147
+ try {
148
+ endUtc = toUtcIso(event.end);
149
+ } catch (_e) {
150
+ endUtc = event.end?.dateTime || 'unknown';
151
+ }
152
+
153
+ const startLocal = formatLocal(startUtc, tz);
154
+ const endLocal = formatLocal(endUtc, tz);
155
+ const startStr = startLocal ? `${startUtc} (${startLocal})` : startUtc;
156
+ const endStr = endLocal ? `${endUtc} (${endLocal})` : endUtc;
157
+
158
+ const location = event.location?.displayName || 'No location';
159
+
160
+ return `${index + 1}. ${event.subject} - Location: ${location}\nStart: ${startStr}\nEnd: ${endStr}\nSummary: ${event.bodyPreview}\nID: ${event.id}\n`;
74
161
  })
75
162
  .join('\n');
76
163
 
@@ -105,4 +192,9 @@ async function handleListEvents(args) {
105
192
  }
106
193
  }
107
194
 
195
+ // Primary export stays the handler (default import used by calendar/index.js
196
+ // and tests). Helpers are attached for unit testing without changing callers.
197
+ handleListEvents.toUtcIso = toUtcIso;
198
+ handleListEvents.formatLocal = formatLocal;
199
+
108
200
  module.exports = handleListEvents;
@@ -867,7 +867,8 @@ const categoriesTools = [
867
867
  },
868
868
  categoryId: {
869
869
  type: 'string',
870
- description: 'DEPRECATED: alias for `id`. Will be removed in v3.8.0.',
870
+ description:
871
+ 'DEPRECATED: alias for `id`. Will be removed in a future release.',
871
872
  },
872
873
  },
873
874
  additionalProperties: false,
package/contacts/index.js CHANGED
@@ -805,7 +805,9 @@ const contactsTools = [
805
805
  annotations: {
806
806
  title: 'People Search',
807
807
  readOnlyHint: true,
808
- openWorldHint: false,
808
+ // openWorldHint: returns directory/people data for external contacts
809
+ // (org directory + inferred from recent comms). (#92)
810
+ openWorldHint: true,
809
811
  },
810
812
  inputSchema: {
811
813
  type: 'object',
@@ -2,6 +2,7 @@
2
2
  * Email folder utilities
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
+ const { resolveFolder } = require('../folder/resolve');
5
6
 
6
7
  /**
7
8
  * Cache of folder information to reduce API calls
@@ -65,20 +66,19 @@ async function resolveFolderPath(accessToken, folderName) {
65
66
  }
66
67
 
67
68
  try {
68
- // Try to find the folder by name
69
- const folderId = await getFolderIdByName(accessToken, folderName);
70
- if (folderId) {
71
- const path = `me/mailFolders/${folderId}/messages`;
72
- console.error(`Resolved folder "${folderName}" to path: ${path}`);
73
- return path;
74
- }
75
-
76
- // If not found, throw error instead of silently falling back
77
- throw new Error(
78
- `Folder "${folderName}" not found. Use the folders tool (action=list) to see available folders.`
79
- );
69
+ // Path-aware resolution: supports nested folders ("Parent/Child"),
70
+ // case-insensitive names, and reports ambiguity — the same shared resolver
71
+ // the `folders` tool uses, so search can now scope to nested folders. (#216)
72
+ const resolved = await resolveFolder(accessToken, { name: folderName });
73
+ const path = `me/mailFolders/${resolved.id}/messages`;
74
+ console.error(`Resolved folder "${folderName}" to path: ${path}`);
75
+ return path;
80
76
  } catch (error) {
81
- if (error.message.includes('not found')) {
77
+ // Surface not-found / ambiguity messages verbatim; wrap anything else.
78
+ if (
79
+ error.message.includes('not found') ||
80
+ error.message.includes('ambiguous')
81
+ ) {
82
82
  throw error;
83
83
  }
84
84
  throw new Error(
package/email/index.js CHANGED
@@ -33,11 +33,13 @@ const emailTools = [
33
33
  {
34
34
  name: 'search-emails',
35
35
  description:
36
- 'Search, list, delta-sync, or thread-group emails — six modes selected by parameters (read-only). With no params: lists recent emails in `folder` (default `inbox`). With `query`/`from`/`to`/`subject`/date filters: full search (combines via OData filter). With `kqlQuery`: raw Keyword Query Language for advanced server-side search. With `deltaMode: true`: returns current state plus a `deltaToken`; pass the token back on the next call for incremental changes only — ideal for inbox monitoring. With `groupByConversation: true`: returns conversation threads. With `conversationId`: returns all messages in a single thread. With `internetMessageId`: looks up a message by its RFC Message-ID header. Personal Outlook.com accounts have limited `$search` support — this tool falls back through OData filters / boolean filters / recent listing automatically, but structured filters (`from`/`subject`/`receivedAfter`/`hasAttachments`/`unreadOnly`) return cleaner results. Returns paged messages with id/subject/from/receivedDateTime/preview by default; use `outputVerbosity` to expand.',
36
+ 'Search, list, delta-sync, or thread-group emails — six modes selected by parameters (read-only). With no params: lists recent emails in `folder` (default `inbox`). With `query`/`from`/`to`/`subject`/date filters: full search (combines via OData filter). With `searchExpression` (deprecated alias `kqlQuery`): a raw Microsoft Graph `$search` expression for advanced server-side search. With `deltaMode: true`: returns current state plus a `deltaToken`; pass the token back on the next call for incremental changes only — ideal for inbox monitoring. With `groupByConversation: true`: returns conversation threads. With `conversationId`: returns all messages in a single thread. With `internetMessageId`: looks up a message by its RFC Message-ID header. Personal Outlook.com accounts have limited `$search` support — this tool falls back through OData filters / boolean filters / recent listing automatically, but structured filters (`from`/`subject`/`receivedAfter`/`hasAttachments`/`unreadOnly`) return cleaner results. Returns paged messages with id/subject/from/receivedDateTime/preview by default; use `outputVerbosity` to expand.',
37
37
  annotations: {
38
38
  title: 'Search Emails',
39
39
  readOnlyHint: true,
40
- openWorldHint: false,
40
+ // openWorldHint: output includes email content authored by external
41
+ // senders (bodies/previews/threads) — may contain prompt-injection. (#92)
42
+ openWorldHint: true,
41
43
  },
42
44
  inputSchema: {
43
45
  type: 'object',
@@ -68,10 +70,15 @@ const emailTools = [
68
70
  type: 'string',
69
71
  description: 'Search query text. Omit for list mode.',
70
72
  },
73
+ searchExpression: {
74
+ type: 'string',
75
+ description:
76
+ 'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases; a single bare token is auto-quoted. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: on personal Outlook.com accounts field-scoped `$search` is best-effort and may return nothing — prefer `query` there (it has progressive fallback).',
77
+ },
71
78
  kqlQuery: {
72
79
  type: 'string',
73
80
  description:
74
- 'Raw KQL (Keyword Query Language) query for advanced search. Bypasses other search params.',
81
+ 'DEPRECATED alias for `searchExpression` (this was never full KQL — it is a Graph `$search` expression). Prefer `searchExpression`.',
75
82
  },
76
83
  folder: {
77
84
  type: 'string',
@@ -160,6 +167,7 @@ const emailTools = [
160
167
  // If any search params provided, use search handler
161
168
  if (
162
169
  args.query ||
170
+ args.searchExpression ||
163
171
  args.kqlQuery ||
164
172
  args.from ||
165
173
  args.to ||
@@ -183,7 +191,8 @@ const emailTools = [
183
191
  annotations: {
184
192
  title: 'Read Email',
185
193
  readOnlyHint: true,
186
- openWorldHint: false,
194
+ // openWorldHint: returns full message body from external senders. (#92)
195
+ openWorldHint: true,
187
196
  },
188
197
  inputSchema: {
189
198
  type: 'object',
@@ -460,7 +469,9 @@ const emailTools = [
460
469
  title: 'Attachments',
461
470
  readOnlyHint: false,
462
471
  destructiveHint: false,
463
- openWorldHint: false,
472
+ // openWorldHint: action=view returns attachment content supplied by
473
+ // external senders. (#92)
474
+ openWorldHint: true,
464
475
  },
465
476
  inputSchema: {
466
477
  type: 'object',
@@ -486,7 +497,7 @@ const emailTools = [
486
497
  savePath: {
487
498
  type: 'string',
488
499
  description:
489
- 'DEPRECATED alias for `outputDir`. Will be removed in v3.8.0.',
500
+ 'DEPRECATED alias for `outputDir`. Will be removed in a future release.',
490
501
  },
491
502
  },
492
503
  additionalProperties: false,
@@ -521,7 +532,9 @@ const emailTools = [
521
532
  title: 'Export Emails',
522
533
  readOnlyHint: false,
523
534
  destructiveHint: false,
524
- openWorldHint: false,
535
+ // openWorldHint: exports full message/MIME/conversation content from
536
+ // external senders. (#92)
537
+ openWorldHint: true,
525
538
  },
526
539
  inputSchema: {
527
540
  type: 'object',