@littlebearapps/outlook-assistant 3.12.0 → 3.13.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/.env.example +8 -0
- package/README.md +58 -18
- package/advanced/index.js +80 -36
- package/auth/client-config.js +142 -0
- package/auth/index.js +4 -2
- package/auth/oauth-server.js +5 -1
- package/auth/token-storage.js +29 -14
- package/auth/tools.js +179 -18
- package/calendar/attendees.js +101 -0
- package/calendar/cancel.js +5 -4
- package/calendar/create.js +15 -4
- package/calendar/decline.js +10 -5
- package/calendar/index.js +33 -10
- package/calendar/list.js +10 -19
- package/calendar/update.js +65 -33
- package/config.js +37 -1
- package/contacts/index.js +2 -1
- package/email/attachments.js +7 -35
- package/email/conversations.js +155 -88
- package/email/delta.js +29 -9
- package/email/draft.js +66 -9
- package/email/export.js +5 -79
- package/email/folder-utils.js +0 -123
- package/email/index.js +12 -10
- package/email/search.js +9 -4
- package/folder/index.js +1 -1
- package/folder/resolve.js +3 -2
- package/index.js +13 -3
- package/llms-install.md +10 -4
- package/llms.txt +7 -7
- package/package.json +3 -2
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +324 -218
- package/utils/mock-data.js +3 -0
- package/utils/odata-helpers.js +24 -0
- package/utils/safe-write.js +151 -0
- package/calendar/accept.js +0 -72
package/llms-install.md
CHANGED
|
@@ -11,8 +11,7 @@ Add to your MCP client configuration:
|
|
|
11
11
|
"command": "npx",
|
|
12
12
|
"args": ["-y", "@littlebearapps/outlook-assistant"],
|
|
13
13
|
"env": {
|
|
14
|
-
"OUTLOOK_CLIENT_ID": "<user-must-provide>"
|
|
15
|
-
"OUTLOOK_CLIENT_SECRET": "<user-must-provide>"
|
|
14
|
+
"OUTLOOK_CLIENT_ID": "<user-must-provide>"
|
|
16
15
|
}
|
|
17
16
|
}
|
|
18
17
|
}
|
|
@@ -36,7 +35,10 @@ Users must create an Azure app registration to get credentials:
|
|
|
36
35
|
6. Click "Register"
|
|
37
36
|
7. Copy the **Application (client) ID** → this is `OUTLOOK_CLIENT_ID`
|
|
38
37
|
|
|
39
|
-
|
|
38
|
+
If the client ID is left out of the config, the `auth` tool asks for it at sign-in (`auth action=authenticate clientId=<id>`) and saves it to `~/.outlook-assistant-config.json`.
|
|
39
|
+
|
|
40
|
+
### Create a client secret (browser flow only):
|
|
41
|
+
The default device-code sign-in doesn't need a secret; skip this unless you'll use `method=browser`, and then add `OUTLOOK_CLIENT_SECRET` to the `env` block.
|
|
40
42
|
1. Go to "Certificates & secrets" → "New client secret"
|
|
41
43
|
2. Add a description, select expiration, click "Add"
|
|
42
44
|
3. **Copy the Value immediately** (not the Secret ID) → this is `OUTLOOK_CLIENT_SECRET`
|
|
@@ -69,9 +71,13 @@ Add these to the same `env` block if needed:
|
|
|
69
71
|
|----------|---------|
|
|
70
72
|
| `OUTLOOK_AUTH_AUDIENCE` | `consumers` for Azure apps registered as personal-accounts-only (fixes `AADSTS9002331`); `organizations` or a tenant GUID for work-only apps. Default `common` |
|
|
71
73
|
| `OUTLOOK_DEFAULT_TIMEZONE` | IANA timezone for calendar times (default `Australia/Melbourne`) |
|
|
72
|
-
| `OUTLOOK_MAX_EMAILS_PER_SESSION` |
|
|
74
|
+
| `OUTLOOK_MAX_EMAILS_PER_SESSION` | Default per-session cap for `send-email`, `draft` and `manage-rules` (override one tool with `OUTLOOK_MAX_<TOOL>_PER_SESSION`, e.g. `OUTLOOK_MAX_SEND_EMAIL_PER_SESSION`) |
|
|
73
75
|
| `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of recipient domains/addresses |
|
|
74
76
|
| `OUTLOOK_SHARED_MAILBOX` | Opt-in shared-mailbox support, work/school accounts only: `read` or `true` (read and organise). Also add `Mail.Read.Shared` (and `Mail.ReadWrite.Shared` for `true`) in Azure, restart, then run `auth` with `action=authenticate` and `force=true` |
|
|
77
|
+
| `OUTLOOK_SEARCH_SCAN_LIMIT` | Messages scanned by the local search fallback on personal accounts (default 500, max 5000) |
|
|
78
|
+
| `OUTLOOK_REQUEST_TIMEOUT_MS` | Per-attempt Graph inactivity timeout in milliseconds (default 60000); not an overall deadline |
|
|
79
|
+
|
|
80
|
+
Run `npx @littlebearapps/outlook-assistant --help` for the full list of environment variables.
|
|
75
81
|
|
|
76
82
|
## Configuration Files by Client
|
|
77
83
|
|
package/llms.txt
CHANGED
|
@@ -36,7 +36,8 @@ Built by [Little Bear Apps](https://littlebearapps.com).
|
|
|
36
36
|
- **MCP safety annotations** on all 22 tools — AI clients auto-approve reads and prompt for destructive operations
|
|
37
37
|
- **Send-email protections**: pre-send mail tips, dry-run preview, session rate limiting, recipient allowlist
|
|
38
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
|
|
39
|
+
- **Input and file hardening**: IDs with `.`/`..` path segments refused, the access token only ever sent to `graph.microsoft.com`, attachment downloads and exports (including conversation exports) confined to the output directory without overwriting, and a partly written file removed if a write fails
|
|
40
|
+
- **Throttling-aware Graph client**: `429` (and `503`/`504` for non-POST requests) retried honouring `Retry-After`, a per-attempt inactivity timeout (`OUTLOOK_REQUEST_TIMEOUT_MS`, default 60000 ms) and at most 4 requests in flight, so bulk operations neither hang nor throttle themselves
|
|
40
41
|
- **Shared mailboxes off by default**: `.Shared` scopes are requested only when `OUTLOOK_SHARED_MAILBOX` is set (`read` keeps shared access read-only)
|
|
41
42
|
- **Token-optimised**: 22 tools instead of 55 saves ~11,000 tokens per turn (~64% reduction), improving AI accuracy and context efficiency
|
|
42
43
|
- These safeguards reduce risk but are not foolproof — always review actions before approving
|
|
@@ -50,15 +51,14 @@ Built by [Little Bear Apps](https://littlebearapps.com).
|
|
|
50
51
|
"command": "npx",
|
|
51
52
|
"args": ["@littlebearapps/outlook-assistant"],
|
|
52
53
|
"env": {
|
|
53
|
-
"OUTLOOK_CLIENT_ID": "your-application-client-id"
|
|
54
|
-
"OUTLOOK_CLIENT_SECRET": "your-client-secret-VALUE"
|
|
54
|
+
"OUTLOOK_CLIENT_ID": "your-application-client-id"
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
Requires an Azure app registration with Microsoft Graph delegated permissions. See README for full setup.
|
|
61
|
+
`OUTLOOK_CLIENT_SECRET` is only needed for the browser sign-in flow; the default device-code flow uses the client ID alone. Claude Code users can instead install the plugin: `claude plugin marketplace add littlebearapps/outlook-assistant`, then `claude plugin install outlook-assistant@littlebearapps`. Requires an Azure app registration with Microsoft Graph delegated permissions. See README for full setup.
|
|
62
62
|
|
|
63
63
|
## Tool Categories
|
|
64
64
|
|
|
@@ -83,6 +83,6 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
|
|
|
83
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/>)
|
|
84
84
|
- [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
|
|
85
85
|
- [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
|
|
86
|
-
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.
|
|
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, token handling, and MCP safety controls
|
|
86
|
+
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.13.0 — marketplace plugin bundle for Claude Code, GitHub Copilot and Cursor; Azure client ID can be given at sign-in and saved locally; client secret documented as browser-flow only)
|
|
87
|
+
- [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.12.x tool description audit, the patch-release fix queue, 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.
|
|
3
|
+
"version": "3.13.0",
|
|
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",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"format": "prettier --write .",
|
|
19
19
|
"format:check": "prettier --check .",
|
|
20
20
|
"prepare": "husky || true",
|
|
21
|
-
"version": "node -
|
|
21
|
+
"version": "node scripts/sync-version.js && git add server.json plugins/outlook-assistant",
|
|
22
|
+
"version:check": "node scripts/sync-version.js --check"
|
|
22
23
|
},
|
|
23
24
|
"lint-staged": {
|
|
24
25
|
"*.js": [
|
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
|
|
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
|
|
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',
|
package/rules/rule-builder.js
CHANGED
|
@@ -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 {
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
};
|