@littlebearapps/outlook-assistant 3.11.2 → 3.12.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.
Files changed (49) hide show
  1. package/.env.example +20 -0
  2. package/README.md +51 -28
  3. package/advanced/index.js +319 -46
  4. package/auth/device-code.js +100 -3
  5. package/auth/token-storage.js +44 -2
  6. package/auth/tools.js +196 -14
  7. package/calendar/attendees.js +101 -0
  8. package/calendar/cancel.js +5 -4
  9. package/calendar/create.js +15 -4
  10. package/calendar/decline.js +10 -5
  11. package/calendar/index.js +51 -10
  12. package/calendar/list.js +146 -3
  13. package/calendar/update.js +65 -33
  14. package/categories/index.js +17 -3
  15. package/config.js +103 -17
  16. package/contacts/index.js +2 -1
  17. package/email/attachments.js +19 -37
  18. package/email/conversations.js +180 -91
  19. package/email/delta.js +123 -13
  20. package/email/draft.js +66 -9
  21. package/email/export.js +113 -77
  22. package/email/folder-utils.js +29 -129
  23. package/email/headers.js +5 -1
  24. package/email/index.js +76 -19
  25. package/email/list.js +8 -1
  26. package/email/mark-as-read.js +3 -1
  27. package/email/mime.js +4 -1
  28. package/email/read.js +5 -1
  29. package/email/search.js +23 -9
  30. package/folder/create.js +11 -4
  31. package/folder/delete.js +9 -1
  32. package/folder/index.js +11 -1
  33. package/folder/list.js +61 -27
  34. package/folder/move.js +32 -7
  35. package/folder/resolve.js +65 -25
  36. package/folder/stats.js +11 -5
  37. package/index.js +9 -1
  38. package/llms-install.md +28 -9
  39. package/llms.txt +13 -9
  40. package/package.json +3 -3
  41. package/rules/index.js +3 -3
  42. package/rules/rule-builder.js +61 -16
  43. package/utils/datetime.js +170 -0
  44. package/utils/graph-api.js +390 -211
  45. package/utils/mailbox.js +77 -0
  46. package/utils/mock-data.js +3 -0
  47. package/utils/odata-helpers.js +24 -0
  48. package/utils/safe-write.js +151 -0
  49. package/calendar/accept.js +0 -72
package/llms.txt CHANGED
@@ -9,7 +9,7 @@ Built by [Little Bear Apps](https://littlebearapps.com).
9
9
  - **Package**: `@littlebearapps/outlook-assistant` on npm
10
10
  - **Install**: `npm install -g @littlebearapps/outlook-assistant` or `npx @littlebearapps/outlook-assistant`
11
11
  - **License**: MIT
12
- - **Node.js**: >= 18.0.0
12
+ - **Node.js**: >= 18.18.0 (development tooling: >= 22.22.1)
13
13
  - **Authentication**: OAuth 2.0 with Microsoft Graph API (requires Azure app registration)
14
14
  - **Tools**: 22 tools across 9 modules (reduced from 55 for optimal AI performance)
15
15
 
@@ -17,8 +17,9 @@ Built by [Little Bear Apps](https://littlebearapps.com).
17
17
 
18
18
  - Read, search, send, and export emails directly from Claude instead of switching apps
19
19
  - 8 email tools covering search, conversations, attachments, bulk export, pre-send mail tips, and draft management
20
- - Manage calendar events, contacts, rules, categories, and mailbox settings in one place
21
- - Export to multiple formats: MIME/EML, MBOX, Markdown, JSON, HTML
20
+ - Manage calendar events (upcoming by default, or past and named events via `startAfter`/`startBefore`/`subject`), contacts, rules, categories, and mailbox settings in one place
21
+ - Export to multiple formats: MIME/EML, MBOX, Markdown, JSON, HTML, CSV
22
+ - Opt-in shared-mailbox read and organise support (`sharedMailbox`, work/school accounts, `OUTLOOK_SHARED_MAILBOX`); sending from a shared mailbox is not supported
22
23
 
23
24
  ## Key Differentiators
24
25
 
@@ -35,6 +36,8 @@ Built by [Little Bear Apps](https://littlebearapps.com).
35
36
  - **MCP safety annotations** on all 22 tools — AI clients auto-approve reads and prompt for destructive operations
36
37
  - **Send-email protections**: pre-send mail tips, dry-run preview, session rate limiting, recipient allowlist
37
38
  - **Rule protections**: dry-run preview on create/update, rate limiting, recipient allowlist on forward/redirect, no permanent-delete action
39
+ - **Input and file hardening**: IDs with `.`/`..` path segments refused, the access token only ever sent to `graph.microsoft.com`, attachment downloads and exports confined to the output directory without overwriting
40
+ - **Shared mailboxes off by default**: `.Shared` scopes are requested only when `OUTLOOK_SHARED_MAILBOX` is set (`read` keeps shared access read-only)
38
41
  - **Token-optimised**: 22 tools instead of 55 saves ~11,000 tokens per turn (~64% reduction), improving AI accuracy and context efficiency
39
42
  - These safeguards reduce risk but are not foolproof — always review actions before approving
40
43
 
@@ -59,15 +62,15 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
59
62
 
60
63
  ## Tool Categories
61
64
 
62
- - **Authentication (1 tool)**: `auth` — OAuth flow, status, about
65
+ - **Authentication (1 tool)**: `auth` — status, authenticate (device code by default), device-code-complete, about (version, granted scopes, shared-mailbox status)
63
66
  - **Email (8 tools)**: `search-emails`, `read-email`, `send-email`, `draft`, `update-email`, `attachments`, `export`, `get-mail-tips`
64
- - **Calendar (3 tools)**: `list-events`, `create-event`, `manage-event`
67
+ - **Calendar (3 tools)**: `list-events` (`startAfter`, `startBefore`, `subject` filters), `create-event`, `manage-event` (update, decline, cancel, delete)
65
68
  - **Contacts (2 tools)**: `manage-contact`, `search-people`
66
69
  - **Folders (1 tool)**: `folders` — list, create, move, stats, delete; folders addressable by nested path (`Parent/Child`) or ID
67
70
  - **Rules (1 tool)**: `manage-rules` — list, create, update, reorder, delete
68
71
  - **Categories (3 tools)**: `manage-category`, `apply-category`, `manage-focused-inbox`
69
72
  - **Settings (1 tool)**: `mailbox-settings` — get, set auto-replies, set working hours
70
- - **Advanced (2 tools)**: `access-shared-mailbox`, `find-meeting-rooms`
73
+ - **Advanced (2 tools)**: `access-shared-mailbox` (messages, `listFolders`, `folderId`, nested folder paths), `find-meeting-rooms`
71
74
 
72
75
  ## Documentation
73
76
 
@@ -76,9 +79,10 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
76
79
  - [Connect Outlook to Claude](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/connect-outlook-to-claude.md): Step-by-step setup guide for Claude Desktop / Claude Code
77
80
  - [Verify Your Connection](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/verify-your-connection.md): Test and troubleshoot the connection after installation
78
81
  - [Azure Setup](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/guides/azure-setup.md): Azure app registration and API permissions walkthrough
82
+ - [Troubleshooting](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/troubleshooting.md): Known errors and fixes — auth, search, export, shared mailboxes
79
83
  - [FAQ](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/faq/faq.md): Frequently asked questions — install, accounts, permissions, tokens, send safety, updates, uninstall (also at <https://littlebearapps.com/help/outlook-assistant/faq/>)
80
84
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
81
85
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
82
- - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.11.2 — security release: attachment downloads confined to `outputDir` (GHSA-755c-c45g-69rv) and the access token only ever sent to Microsoft Graph (GHSA-mqfm-wfjq-jxq2), HTML-to-text entity double-decoding fixed, `npm audit` at 0. Preceded by v3.11.1 — search and export correctness: a search term combined with a date or boolean filter was silently overwritten, so the request carried only the date window and the whole window came back reported as a filtered result; batch export named files `<date>_<subject>`, so a same-day reply chain overwrote itself on disk while the summary reported `Failed 0` — filenames now carry the time, collisions get a numeric suffix instead of clobbering, and a manifest maps each requested ID to the file actually written; a truncated local scan is now disclosed when it matched, not only when it returned nothing; `query` versus `searchExpression` and the 500-message `to` scan cap documented. Preceded by v3.11.0 — fixes & polish: `--version`/`--help` CLI flags (#68), `AADSTS7000215` explaining the Secret ID vs Secret Value mistake via one shared AADSTS hint table (#69), token-refresh round trip covered end to end (#72), and all 17 development-dependency advisories cleared; and v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217), two-filter searches no longer returning the single-filter superset (#229), `from`/`to` filter values OData-escaped (#230), no-results guidance derived from what was actually attempted (#231))
83
- - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.11.2 tool description audit, v3.8.x carry-over, v3.12.0+ new Graph APIs)
84
- - [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy, token handling, and MCP safety controls
86
+ - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.12.1 — Graph reliability and correctness fixes: throttling retries, a request inactivity timeout and a 4-request concurrency cap (#244), complete delta paging (#254), a draft-only guard on `draft` update/send/delete (#246), nested folder paths in `manage-rules` (#248), flag dates honouring `Z` and offsets (#247), attendee types kept on `manage-event update` (#249), no "via API" text on decline/cancel (#242), escaped `$search` phrases (#251), and conversation read/export working on personal accounts. Preceded by v3.12.0 — opt-in shared-mailbox read and organise support via `sharedMailbox` and `OUTLOOK_SHARED_MAILBOX` (#228), `list-events` `startAfter`/`startBefore`/`subject` filters (#193), token refresh requesting the granted scopes plus `offline_access` (#241), and two security fixes: dot segments in resource paths rejected, `export` writes confined to the output directory. Preceded by v3.11.2 — security release: attachment downloads confined to `outputDir` (GHSA-755c-c45g-69rv) and the access token only ever sent to Microsoft Graph (GHSA-mqfm-wfjq-jxq2); v3.11.1 — search and export correctness (filters combined with dates no longer silently dropped, batch export no longer overwrites same-day reply chains); v3.11.0 — `--version`/`--help` CLI flags (#68) and the `AADSTS7000215` Secret ID vs Value explanation (#69); v3.10.0 — search correctness (#217, #229, #230, #231))
87
+ - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.12.x tool description audit, v3.8.x carry-over, v3.13.0+ new Graph APIs)
88
+ - [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy (report vulnerabilities privately via GitHub private vulnerability reporting; acknowledged within 7 days), token handling, and MCP safety controls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/outlook-assistant",
3
- "version": "3.11.2",
3
+ "version": "3.12.1",
4
4
  "mcpName": "io.github.littlebearapps/outlook-assistant",
5
5
  "description": "Outlook Assistant — MCP server with 22 tools for email, calendar, contacts, and settings via Microsoft Graph API",
6
6
  "main": "index.js",
@@ -84,12 +84,12 @@
84
84
  "@commitlint/cli": "^20.4.3",
85
85
  "@commitlint/config-conventional": "^20.4.3",
86
86
  "@eslint/js": "^10.0.1",
87
- "@modelcontextprotocol/inspector": "^0.21.1",
87
+ "@modelcontextprotocol/inspector": "^2.8.0",
88
88
  "eslint": "^10.0.2",
89
89
  "globals": "^17.4.0",
90
90
  "husky": "^9.1.7",
91
91
  "jest": "^30.2.0",
92
- "lint-staged": "^16.3.2",
92
+ "lint-staged": "^17.6.0",
93
93
  "prettier": "^3.8.1",
94
94
  "supertest": "^7.2.2"
95
95
  },
package/rules/index.js CHANGED
@@ -180,7 +180,7 @@ const rulesTools = [
180
180
  {
181
181
  name: 'manage-rules',
182
182
  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, 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`).',
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
184
  annotations: {
185
185
  title: 'Inbox Rules',
186
186
  readOnlyHint: false,
@@ -304,12 +304,12 @@ const rulesTools = [
304
304
  moveToFolder: {
305
305
  type: 'string',
306
306
  description:
307
- 'Folder name to move matching emails to (action=create/update)',
307
+ 'Folder to move matching emails to: a name, a nested path like `Triage/Delete`, a well-known name (e.g. `archive`), or a folder ID (action=create/update)',
308
308
  },
309
309
  copyToFolder: {
310
310
  type: 'string',
311
311
  description:
312
- 'Folder name to copy matching emails to (action=create/update)',
312
+ 'Folder to copy matching emails to: a name, a nested path like `Projects/Backup`, a well-known name, or a folder ID (action=create/update)',
313
313
  },
314
314
  markAsRead: {
315
315
  type: 'boolean',
@@ -2,7 +2,7 @@
2
2
  * Shared rule builder utilities for creating and updating mail rules.
3
3
  * Converts flat MCP tool parameters into Microsoft Graph API rule objects.
4
4
  */
5
- const { getFolderIdByName } = require('../email/folder-utils');
5
+ const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
6
6
  const { checkRecipientAllowlist } = require('../utils/safety');
7
7
 
8
8
  const VALID_IMPORTANCE = ['low', 'normal', 'high'];
@@ -104,6 +104,49 @@ function buildConditions(args) {
104
104
  return { conditions, warnings };
105
105
  }
106
106
 
107
+ /**
108
+ * Resolve a rule's target folder (ID, well-known alias, nested path such as
109
+ * "Triage/Delete", or bare name) to a folder ID with the shared resolver. (#248)
110
+ * Not-found and ambiguous folders become a warning containing "not found", which
111
+ * create/update treat as fatal when no other action is left; any other failure
112
+ * (auth, network, Graph outage) is rethrown rather than misreported.
113
+ * @param {string} accessToken - Graph API access token
114
+ * @param {string} folder - Folder ID, alias, path, or name
115
+ * @param {string} label - Warning prefix, e.g. "Target folder"
116
+ * @returns {Promise<{ folderId?: string, warning?: string }>}
117
+ */
118
+ async function resolveRuleFolder(accessToken, folder, label) {
119
+ const spec = looksLikeFolderId(folder)
120
+ ? { id: folder.trim() }
121
+ : { name: folder };
122
+ try {
123
+ const resolved = await resolveFolder(accessToken, spec);
124
+ return { folderId: resolved.id };
125
+ } catch (error) {
126
+ const message = error.message || '';
127
+ if (message.includes('ambiguous')) {
128
+ return {
129
+ warning: `${label} "${folder}" not found as a single folder. ${message}`,
130
+ };
131
+ }
132
+ if (
133
+ message.includes('not found') ||
134
+ message.startsWith('Invalid folder path') ||
135
+ /status (400|404):/.test(message)
136
+ ) {
137
+ // The resolver's reason follows (an empty path segment, Graph's 400 text);
138
+ // its own not-found message already carries the action=list guidance.
139
+ const hint = message.includes('action=list')
140
+ ? ''
141
+ : ' Use `folders` action=list to see folders (with IDs and full paths), or pass a path like "Parent/Child" or a folderId.';
142
+ return {
143
+ warning: `${label} "${folder}" not found. ${message}${hint}`,
144
+ };
145
+ }
146
+ throw error;
147
+ }
148
+ }
149
+
107
150
  /**
108
151
  * Build a Graph API actions object from flat tool parameters.
109
152
  * Async because folder resolution requires API calls.
@@ -115,21 +158,23 @@ async function buildActions(args, accessToken) {
115
158
  const actions = {};
116
159
  const warnings = [];
117
160
 
118
- // Folder-based actions (name → ID resolution)
119
- if (args.moveToFolder) {
120
- const folderId = await getFolderIdByName(accessToken, args.moveToFolder);
121
- if (!folderId) {
122
- warnings.push(`Target folder "${args.moveToFolder}" not found.`);
123
- } else {
124
- actions.moveToFolder = folderId;
125
- }
126
- }
127
- if (args.copyToFolder) {
128
- const folderId = await getFolderIdByName(accessToken, args.copyToFolder);
129
- if (!folderId) {
130
- warnings.push(`Copy-to folder "${args.copyToFolder}" not found.`);
131
- } else {
132
- actions.copyToFolder = folderId;
161
+ // Folder-based actions (ID, alias, nested path or name → ID)
162
+ const folderActions = [
163
+ ['moveToFolder', 'Target folder'],
164
+ ['copyToFolder', 'Copy-to folder'],
165
+ ];
166
+ for (const [param, label] of folderActions) {
167
+ if (args[param]) {
168
+ const { folderId, warning } = await resolveRuleFolder(
169
+ accessToken,
170
+ args[param],
171
+ label
172
+ );
173
+ if (folderId) {
174
+ actions[param] = folderId;
175
+ } else {
176
+ warnings.push(warning);
177
+ }
133
178
  }
134
179
  }
135
180
 
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Shared ISO 8601 date-time parsing and timezone conversion.
3
+ *
4
+ * Nothing here reads the server's local timezone: zoned values are converted
5
+ * with their own `Z`/offset, and wall-clock values are read in an explicit IANA
6
+ * zone through Intl, so results are the same on every machine.
7
+ */
8
+ const config = require('../config');
9
+
10
+ // An ISO 8601 instant with an explicit zone: `Z` or a ±hh:mm offset. A
11
+ // zone-less value would be read in the server's local timezone by Date.parse,
12
+ // so results would differ between machines; date-only values are rejected for
13
+ // the same reason.
14
+ const ISO_INSTANT =
15
+ /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/;
16
+
17
+ // A wall-clock date-time with no zone, e.g. 2026-03-01T09:00 or
18
+ // 2026-03-01T09:00:00.5. Callers decide which zone it is read in.
19
+ const WALL_TIME =
20
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?$/;
21
+
22
+ const MIN_YEAR = 1900;
23
+
24
+ /**
25
+ * Error raised for a date-time argument that can't be read, so handlers can
26
+ * report it as a tool error before touching the network.
27
+ */
28
+ class InvalidDateTimeError extends Error {}
29
+
30
+ /** True when year/month/day name a real calendar day (no 30 Feb roll-over). */
31
+ function isRealDate(year, month, day) {
32
+ return (
33
+ year >= MIN_YEAR &&
34
+ month >= 1 &&
35
+ month <= 12 &&
36
+ new Date(Date.UTC(year, month - 1, day)).getUTCDate() === day
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Parse an ISO 8601 instant that carries `Z` or a ±hh:mm offset.
42
+ * @returns {number} epoch milliseconds, or NaN when the value isn't one
43
+ */
44
+ function parseIsoInstant(value) {
45
+ const s = typeof value === 'string' ? value.trim().replace(/z$/, 'Z') : '';
46
+ const m = ISO_INSTANT.exec(s);
47
+ if (!m || !isRealDate(Number(m[1]), Number(m[2]), Number(m[3]))) {
48
+ return NaN;
49
+ }
50
+ return Date.parse(s);
51
+ }
52
+
53
+ /**
54
+ * Parse a zone-less wall-clock date-time.
55
+ * @returns {number[]|null} [year, month, day, hour, minute, second], or null
56
+ */
57
+ function parseWallTime(value) {
58
+ const m = typeof value === 'string' ? WALL_TIME.exec(value.trim()) : null;
59
+ if (!m) return null;
60
+ const [year, month, day, hour, minute] = m.slice(1, 6).map(Number);
61
+ const second = m[6] === undefined ? 0 : Number(m[6]);
62
+ if (
63
+ !isRealDate(year, month, day) ||
64
+ hour > 23 ||
65
+ minute > 59 ||
66
+ second > 59
67
+ ) {
68
+ return null;
69
+ }
70
+ return [year, month, day, hour, minute, second];
71
+ }
72
+
73
+ const formatters = new Map();
74
+
75
+ /**
76
+ * Wall-clock date and time of an instant in an IANA zone.
77
+ * @returns {{date: string, time: string, wallMs: number}|null} `date` is
78
+ * YYYY-MM-DD, `time` HH:mm:ss, `wallMs` the wall-clock fields read as UTC;
79
+ * null when the zone is unknown to Intl
80
+ */
81
+ function zonedParts(ms, timeZone) {
82
+ let fmt = formatters.get(timeZone);
83
+ if (!fmt) {
84
+ try {
85
+ fmt = new Intl.DateTimeFormat('en-US', {
86
+ timeZone,
87
+ hourCycle: 'h23',
88
+ year: 'numeric',
89
+ month: '2-digit',
90
+ day: '2-digit',
91
+ hour: '2-digit',
92
+ minute: '2-digit',
93
+ second: '2-digit',
94
+ });
95
+ } catch (_e) {
96
+ return null;
97
+ }
98
+ formatters.set(timeZone, fmt);
99
+ }
100
+ const p = {};
101
+ for (const { type, value } of fmt.formatToParts(new Date(ms))) {
102
+ p[type] = value;
103
+ }
104
+ const date = `${p.year}-${p.month}-${p.day}`;
105
+ const time = `${p.hour}:${p.minute}:${p.second}`;
106
+ return {
107
+ date,
108
+ time,
109
+ wallMs: Date.parse(`${date}T${time}Z`),
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Convert a wall-clock date-time in an IANA zone to an instant.
115
+ * @returns {number} epoch milliseconds, or NaN for a bad value or zone
116
+ */
117
+ function zonedWallTimeToUtcMs(wall, timeZone) {
118
+ const f = parseWallTime(wall);
119
+ if (!f) return NaN;
120
+ const guess = Date.UTC(f[0], f[1] - 1, f[2], f[3], f[4], f[5]);
121
+ const offsetAt = (t) => {
122
+ const parts = zonedParts(t, timeZone);
123
+ return parts ? parts.wallMs - t : NaN;
124
+ };
125
+ const first = offsetAt(guess);
126
+ if (Number.isNaN(first)) return NaN;
127
+ // Re-check the offset at the candidate instant, in case a DST change falls
128
+ // between the naive guess and the real instant.
129
+ const second = offsetAt(guess - first);
130
+ return guess - (second === first ? first : second);
131
+ }
132
+
133
+ /**
134
+ * Turn an ISO 8601 date-time into a Graph dateTimeTimeZone envelope.
135
+ *
136
+ * - With `Z` or a ±hh:mm offset: the same instant, sent in UTC (Graph wants
137
+ * the dateTime without a trailing Z).
138
+ * - Without a zone: sent unchanged, read in `timeZone` (default
139
+ * DEFAULT_TIMEZONE).
140
+ *
141
+ * @throws {InvalidDateTimeError} for anything else (date-only, impossible
142
+ * dates, free text)
143
+ */
144
+ function toGraphDateTimeTimeZone(
145
+ value,
146
+ paramName,
147
+ timeZone = config.DEFAULT_TIMEZONE
148
+ ) {
149
+ const instant = parseIsoInstant(value);
150
+ if (!Number.isNaN(instant)) {
151
+ return {
152
+ dateTime: new Date(instant).toISOString().replace(/(?:\.000)?Z$/, ''),
153
+ timeZone: 'UTC',
154
+ };
155
+ }
156
+ if (parseWallTime(value)) {
157
+ return { dateTime: value.trim(), timeZone };
158
+ }
159
+ throw new InvalidDateTimeError(
160
+ `Invalid ${paramName}: expected an ISO 8601 date-time with a time, e.g. "2026-03-01T09:00:00Z" (UTC), "2026-03-01T09:00:00+10:00" (offset) or "2026-03-01T09:00:00" (read in ${timeZone}) (got ${JSON.stringify(String(value).slice(0, 40))}).`
161
+ );
162
+ }
163
+
164
+ module.exports = {
165
+ InvalidDateTimeError,
166
+ parseIsoInstant,
167
+ zonedParts,
168
+ zonedWallTimeToUtcMs,
169
+ toGraphDateTimeTimeZone,
170
+ };