@chrischall/office-outlook-mcp 0.0.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/.claude-plugin/marketplace.json +27 -0
- package/.claude-plugin/plugin.json +16 -0
- package/.mcp.json +11 -0
- package/README.md +106 -0
- package/dist/auth-fetchproxy.js +166 -0
- package/dist/bundle.js +46233 -0
- package/dist/client.js +247 -0
- package/dist/index.js +36 -0
- package/dist/token-cache.js +68 -0
- package/dist/tools/_confirm.js +27 -0
- package/dist/tools/calendar.js +72 -0
- package/dist/tools/directory.js +73 -0
- package/dist/tools/healthcheck.js +45 -0
- package/dist/tools/mail.js +130 -0
- package/dist/tools/writes.js +165 -0
- package/dist/version.js +13 -0
- package/dist/view.js +109 -0
- package/mint.yaml +79 -0
- package/package.json +62 -0
- package/server.json +63 -0
- package/skills/outlook-fpx/SKILL.md +82 -0
- package/skills/outlook-fpx/references/api-recipes.md +163 -0
- package/skills/outlook-fpx/references/outlook-env.sh +159 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: outlook-fpx
|
|
3
|
+
description: Read and act on the user's Outlook / Microsoft 365 mailbox — mail, folders, calendar, contacts, tasks — via the Outlook REST API, using a bearer token lifted from their signed-in browser tab. Use when asked about their Outlook email, calendar or meetings.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Outlook (Microsoft 365) access
|
|
7
|
+
|
|
8
|
+
Talks to `https://outlook.office.com/api/v2.0` with a bearer token captured
|
|
9
|
+
from the signed-in Outlook Web tab. **The browser is needed only to mint the
|
|
10
|
+
token** (~25h lifetime) — every actual request is a plain server-side `curl`.
|
|
11
|
+
|
|
12
|
+
## Setup (once)
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm i -g @fetchproxy/cli # provides `fpx`
|
|
16
|
+
fpx profile add outlook --domain cloud.microsoft --domain office.com \
|
|
17
|
+
--domain microsoft.com --domain office365.com
|
|
18
|
+
fpx profile declare outlook \
|
|
19
|
+
--capture-header authorization@outlook.cloud.microsoft \
|
|
20
|
+
--capture-header authorization@outlook.office.com \
|
|
21
|
+
--allow-download
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Declare **all** scopes before the first pairing — widening them later forces the
|
|
25
|
+
user to re-approve. The first `fpx` call prints a 6-digit pair code to approve in
|
|
26
|
+
the Transporter extension popup; the grant then persists.
|
|
27
|
+
|
|
28
|
+
Requires the **Transporter** Chrome extension, and the extension and CLI must be
|
|
29
|
+
on the **same major version** — a 3.x extension against a 2.x CLI fails by
|
|
30
|
+
binding the port and silently never connecting.
|
|
31
|
+
|
|
32
|
+
## Use
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
source references/outlook-env.sh
|
|
36
|
+
outlook_token_refresh # once per ~25h; needs an open, signed-in Outlook tab
|
|
37
|
+
outlook_get '/me' | jq .
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- `outlook_get <path> [curl args]` — GET, body on stdout
|
|
41
|
+
- `outlook_get_text <path>` — same plus `Prefer: outlook.body-content-type="text"`; **use this for anything you will read** (measured 9.4× smaller than HTML)
|
|
42
|
+
- `outlook_post <path> <json>` / `outlook_patch <path> <json>`
|
|
43
|
+
- `outlook_token_expiry` — prints time remaining, never the token
|
|
44
|
+
|
|
45
|
+
Paths are relative to `/api/v2.0`. Exit code **4** means non-2xx; a **401**
|
|
46
|
+
prints `run: outlook_token_refresh`.
|
|
47
|
+
|
|
48
|
+
Common starting points — full verified set, including calendar, contacts, tasks
|
|
49
|
+
and write payloads, in **`references/api-recipes.md`**:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
outlook_get '/me/mailfolders/inbox?$select=UnreadItemCount,TotalItemCount'
|
|
53
|
+
outlook_get '/me/mailfolders/inbox/messages?$top=20&$orderby=ReceivedDateTime%20desc&$select=Id,Subject,From,ReceivedDateTime,IsRead'
|
|
54
|
+
outlook_get '/me/calendarview?startDateTime=2026-09-20T00:00:00Z&endDateTime=2026-09-27T00:00:00Z&$select=Subject,Start,End,Location'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Rules that bite
|
|
58
|
+
|
|
59
|
+
- **Fields are PascalCase** (`ReceivedDateTime`, `IsRead`). This is the Outlook
|
|
60
|
+
REST API, *not* Graph — a camelCase Graph snippet silently returns nothing.
|
|
61
|
+
- **Always pass `$select`.** The default projection is ~40 fields per message
|
|
62
|
+
including the full body.
|
|
63
|
+
- **`$search` and `$filter` cannot be combined.**
|
|
64
|
+
- **`/me/events` does not expand recurring series** — use `/me/calendarview`
|
|
65
|
+
with a start/end window for "what's on my schedule".
|
|
66
|
+
- **`Prefer: outlook.timezone` takes Windows zone names** (`Eastern Standard
|
|
67
|
+
Time`), not IANA (`America/New_York`).
|
|
68
|
+
- **`@odata.nextLink` is an absolute URL** — pass it to `curl -K ~/.outlook-fpx/curlrc`
|
|
69
|
+
directly, not to `outlook_get`, which would prefix the base again.
|
|
70
|
+
- **`/owa/0/service.svc` is not this API** and returns 500. Use `/api/v2.0`.
|
|
71
|
+
- **A 2xx is not proof a write persisted** — re-read the resource to confirm.
|
|
72
|
+
- Never name a shell local `path` or `status`: in zsh `path` is tied to `PATH`
|
|
73
|
+
(assigning it wipes `PATH` mid-function) and `status` is read-only. Both are
|
|
74
|
+
harmless in bash, so this only breaks for zsh users.
|
|
75
|
+
|
|
76
|
+
## Privacy
|
|
77
|
+
|
|
78
|
+
This reads the user's real mailbox. Surface only what was asked for; do not dump
|
|
79
|
+
message bodies, recipients or attachments into the transcript incidentally. The
|
|
80
|
+
token lives in `~/.outlook-fpx/curlrc` (0600, its own directory — deliberately
|
|
81
|
+
not shared with any MCP's state) and is passed to `curl` via `-K` so it never
|
|
82
|
+
appears in `argv` or `ps` output.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Outlook REST v2.0 — verified request shapes
|
|
2
|
+
|
|
3
|
+
Every endpoint and header below was executed live against a real TransUnion
|
|
4
|
+
M365 mailbox on 2026-09-20 and returned the status shown. Nothing here is
|
|
5
|
+
inferred from documentation.
|
|
6
|
+
|
|
7
|
+
Base: `https://outlook.office.com/api/v2.0`
|
|
8
|
+
Auth: `Authorization: Bearer <token>` (see SKILL.md for capture)
|
|
9
|
+
|
|
10
|
+
> **Fields are PascalCase.** This is the Outlook REST API, not Microsoft Graph.
|
|
11
|
+
> `ReceivedDateTime`, `IsRead`, `BodyPreview` — not `receivedDateTime`. Copying a
|
|
12
|
+
> Graph snippet here silently returns nothing useful.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Identity
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
outlook_get '/me' | jq '{EmailAddress, DisplayName, Alias, MailboxGuid}'
|
|
20
|
+
```
|
|
21
|
+
Returns `Id, EmailAddress, DisplayName, Alias, MailboxGuid`. Cheapest liveness check.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Mail
|
|
26
|
+
|
|
27
|
+
### Folders
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
outlook_get '/me/mailfolders?$top=50' | jq -r '.value[] | "\(.UnreadItemCount)/\(.TotalItemCount)\t\(.DisplayName)"'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Item keys: `Id, DisplayName, ParentFolderId, ChildFolderCount, UnreadItemCount,
|
|
34
|
+
TotalItemCount, SizeInBytes, IsHidden`.
|
|
35
|
+
|
|
36
|
+
Well-known names usable in place of an id — all verified present:
|
|
37
|
+
`inbox`, `drafts`, `sentitems`, `deleteditems`, `archive`, `junkemail`,
|
|
38
|
+
`outbox`, `clutter`.
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
outlook_get '/me/mailfolders/inbox?$select=UnreadItemCount,TotalItemCount' | jq .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Listing messages
|
|
45
|
+
|
|
46
|
+
Always pass `$select` — the default projection returns ~40 fields per message
|
|
47
|
+
including full `Body`, which is enormous on a 13k-message folder.
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
outlook_get '/me/mailfolders/inbox/messages?$top=20&$orderby=ReceivedDateTime%20desc&$select=Id,Subject,From,ReceivedDateTime,IsRead,HasAttachments' \
|
|
51
|
+
| jq -r '.value[] | "\(.ReceivedDateTime) \(if .IsRead then " " else "•" end) \(.From.EmailAddress.Name // "?")\t\(.Subject)"'
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Unread only:
|
|
55
|
+
```sh
|
|
56
|
+
outlook_get '/me/mailfolders/inbox/messages?$filter=IsRead%20eq%20false&$top=25&$select=Id,Subject,From,ReceivedDateTime'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Full-text search (`$search` and `$filter` cannot be combined):
|
|
60
|
+
```sh
|
|
61
|
+
outlook_get '/me/messages?$search=%22quarterly%20report%22&$top=10&$select=Id,Subject,From,ReceivedDateTime'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Verified query options: `$top`, `$skip`, `$select`, `$filter`, `$orderby`,
|
|
65
|
+
`$search`, `$count`. Paging: follow `@odata.nextLink` (absolute URL — pass it to
|
|
66
|
+
`curl` directly, not through `outlook_get`, which prefixes the base).
|
|
67
|
+
|
|
68
|
+
### Reading one message
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
outlook_get_text "/me/messages/$ID" | jq '{Subject, From, ReceivedDateTime, Body: .Body.Content}'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`outlook_get_text` sends `Prefer: outlook.body-content-type="text"`. Measured on
|
|
75
|
+
a real message: **7,299 bytes HTML → 775 bytes text (9.4×)**; across a 5-message
|
|
76
|
+
collection 37,638 → 12,828 bytes. Use it for anything an agent will read.
|
|
77
|
+
|
|
78
|
+
Without the header the API returns whatever the message was authored in, so a
|
|
79
|
+
collection comes back with a *mix* of `HTML` and `Text` — the `Prefer` header
|
|
80
|
+
normalises all of them to `Text`.
|
|
81
|
+
|
|
82
|
+
### Attachments
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
outlook_get "/me/messages/$ID/attachments?\$select=Id,Name,Size,ContentType" \
|
|
86
|
+
| jq -r '.value[] | "\(.Size)\t\(.ContentType)\t\(.Name)"'
|
|
87
|
+
```
|
|
88
|
+
Item keys: `@odata.type, @odata.mediaContentType, Id, Name, ContentType, Size`.
|
|
89
|
+
Content bytes come back base64 in `ContentBytes` when `$select` does not exclude
|
|
90
|
+
it; omit `$select` to get it, and expect the response to be ~1.37× the file size.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Calendar
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
# events in a window — expands recurrences, which /me/events does NOT
|
|
98
|
+
outlook_get '/me/calendarview?startDateTime=2026-09-20T00:00:00Z&endDateTime=2026-09-27T00:00:00Z&$select=Subject,Start,End,Location,Organizer&$orderby=Start/DateTime' \
|
|
99
|
+
-H 'Prefer: outlook.timezone="Eastern Standard Time"' \
|
|
100
|
+
| jq -r '.value[] | "\(.Start.DateTime) \(.Subject)"'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`Prefer: outlook.timezone="<Windows tz name>"` is verified — `Start.TimeZone`
|
|
104
|
+
comes back as the requested zone instead of UTC. Note it takes **Windows**
|
|
105
|
+
zone names (`Eastern Standard Time`), not IANA (`America/New_York`).
|
|
106
|
+
|
|
107
|
+
`/me/calendars` lists calendars; `/me/events` lists events **without** expanding
|
|
108
|
+
recurring series, so prefer `/me/calendarview` for "what's on my schedule".
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Contacts, tasks, people
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
outlook_get '/me/contacts?$top=20&$select=DisplayName,EmailAddresses,CompanyName'
|
|
116
|
+
outlook_get '/me/tasks?$top=20&$select=Subject,Status,DueDateTime'
|
|
117
|
+
outlook_get '/me/people?$top=20&$select=DisplayName,ScoredEmailAddresses' # relevance-ranked
|
|
118
|
+
outlook_get '/me/MailboxSettings' # tz, working hours, auto-reply
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Writes
|
|
124
|
+
|
|
125
|
+
All four routes were confirmed to exist and be authorised by POSTing a
|
|
126
|
+
deliberately invalid body and receiving **400** (`UnableToDeserializePostBody` /
|
|
127
|
+
`RequestBodyRead`) rather than 401/403/404. **The payloads below are shapes, not
|
|
128
|
+
executed calls** — no mail was sent and nothing was mutated during the build.
|
|
129
|
+
Verify the body of any write by re-reading the resource afterwards; a 2xx is not
|
|
130
|
+
proof it persisted.
|
|
131
|
+
|
|
132
|
+
```sh
|
|
133
|
+
# send mail
|
|
134
|
+
outlook_post '/me/sendmail' '{
|
|
135
|
+
"Message": {
|
|
136
|
+
"Subject": "…",
|
|
137
|
+
"Body": {"ContentType": "Text", "Content": "…"},
|
|
138
|
+
"ToRecipients": [{"EmailAddress": {"Address": "someone@example.com"}}]
|
|
139
|
+
},
|
|
140
|
+
"SaveToSentItems": true
|
|
141
|
+
}'
|
|
142
|
+
|
|
143
|
+
# mark read / move / create draft / create event
|
|
144
|
+
outlook_patch "/me/messages/$ID" '{"IsRead": true}'
|
|
145
|
+
outlook_post "/me/messages/$ID/move" '{"DestinationId": "archive"}'
|
|
146
|
+
outlook_post '/me/messages' '{"Subject":"…","Body":{"ContentType":"Text","Content":"…"},"ToRecipients":[…]}'
|
|
147
|
+
outlook_post '/me/events' '{"Subject":"…","Start":{"DateTime":"2026-09-22T15:00:00","TimeZone":"Eastern Standard Time"},"End":{…}}'
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Send only to `@example.com` when testing.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Errors
|
|
155
|
+
|
|
156
|
+
| Signal | Meaning |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `401`, **empty body**, `WWW-Authenticate: Bearer client_id="00000002-0000-0ff1-ce00-000000000000"` | token expired or revoked → `outlook_token_refresh` |
|
|
159
|
+
| `400` + `error.code` (`UnableToDeserializePostBody`, `RequestBodyRead`) | route exists, payload is wrong |
|
|
160
|
+
| `404` | wrong id or path — *not* an auth problem |
|
|
161
|
+
| `500` on `/owa/0/service.svc` | that is the OWA internal endpoint; it is **not** part of this API. Use `/api/v2.0`. |
|
|
162
|
+
|
|
163
|
+
`outlook_get` returns rc `4` for any non-2xx and prints the status to stderr.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Outlook access helpers — source this file, then use outlook_get / outlook_post.
|
|
3
|
+
#
|
|
4
|
+
# source references/outlook-env.sh
|
|
5
|
+
# outlook_token_refresh # once per ~25h (browser needed only here)
|
|
6
|
+
# outlook_get '/me/messages?$top=5&$select=Subject,From' | jq .
|
|
7
|
+
#
|
|
8
|
+
# Works under bash and zsh. Data on stdout, diagnostics on stderr.
|
|
9
|
+
# The bearer token never appears in argv (curl reads it from a 0600 config file).
|
|
10
|
+
|
|
11
|
+
OUTLOOK_FPX_DIR="${OUTLOOK_FPX_DIR:-$HOME/.outlook-fpx}"
|
|
12
|
+
OUTLOOK_FPX_PROFILE="${OUTLOOK_FPX_PROFILE:-outlook}"
|
|
13
|
+
OUTLOOK_API="${OUTLOOK_API:-https://outlook.office.com/api/v2.0}"
|
|
14
|
+
# Host whose outgoing Authorization header we snapshot. New Outlook Web is on
|
|
15
|
+
# outlook.cloud.microsoft; older tenants still serve outlook.office.com.
|
|
16
|
+
OUTLOOK_CAPTURE_HOST="${OUTLOOK_CAPTURE_HOST:-outlook.cloud.microsoft}"
|
|
17
|
+
|
|
18
|
+
_outlook_curlrc() { printf '%s/curlrc' "$OUTLOOK_FPX_DIR"; }
|
|
19
|
+
|
|
20
|
+
# Reload an open Outlook tab so the page re-issues API calls while we capture.
|
|
21
|
+
# macOS/Chrome only; a no-op elsewhere (the capture still works if the tab is
|
|
22
|
+
# active on its own, it is just less reliable).
|
|
23
|
+
_outlook_poke_tab() {
|
|
24
|
+
command -v osascript >/dev/null 2>&1 || return 0
|
|
25
|
+
osascript - "$OUTLOOK_CAPTURE_HOST" <<'APPLESCRIPT' >/dev/null 2>&1 || true
|
|
26
|
+
on run argv
|
|
27
|
+
set theHost to item 1 of argv
|
|
28
|
+
tell application "Google Chrome"
|
|
29
|
+
repeat with w in windows
|
|
30
|
+
repeat with t in tabs of w
|
|
31
|
+
if (URL of t) contains theHost then
|
|
32
|
+
tell t to reload
|
|
33
|
+
return "ok"
|
|
34
|
+
end if
|
|
35
|
+
end repeat
|
|
36
|
+
end repeat
|
|
37
|
+
end tell
|
|
38
|
+
return "none"
|
|
39
|
+
end run
|
|
40
|
+
APPLESCRIPT
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Capture a fresh bearer token from the signed-in browser tab.
|
|
44
|
+
outlook_token_refresh() {
|
|
45
|
+
local timeout="${1:-55}" tmp rc token dir
|
|
46
|
+
dir="$OUTLOOK_FPX_DIR"
|
|
47
|
+
mkdir -p "$dir" || return 1
|
|
48
|
+
chmod 700 "$dir" 2>/dev/null
|
|
49
|
+
|
|
50
|
+
command -v fpx >/dev/null 2>&1 || {
|
|
51
|
+
echo "fpx not found — npm i -g @fetchproxy/cli" >&2; return 1; }
|
|
52
|
+
|
|
53
|
+
tmp="$(mktemp "${TMPDIR:-/tmp}/outlook-cap.XXXXXX")" || return 1
|
|
54
|
+
|
|
55
|
+
echo "capturing token from ${OUTLOOK_CAPTURE_HOST} (reloading your Outlook tab)…" >&2
|
|
56
|
+
fpx capture "authorization@${OUTLOOK_CAPTURE_HOST}" \
|
|
57
|
+
--capture-timeout "$timeout" -p "$OUTLOOK_FPX_PROFILE" >"$tmp" 2>"$tmp.err" &
|
|
58
|
+
local cappid=$!
|
|
59
|
+
sleep 3
|
|
60
|
+
_outlook_poke_tab
|
|
61
|
+
wait "$cappid"; rc=$?
|
|
62
|
+
|
|
63
|
+
if [ "$rc" -ne 0 ] || [ ! -s "$tmp" ]; then
|
|
64
|
+
echo "capture failed (exit $rc). Is an Outlook tab open and signed in?" >&2
|
|
65
|
+
sed -n '1,3p' "$tmp.err" >&2 2>/dev/null
|
|
66
|
+
rm -f "$tmp" "$tmp.err"; return 1
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
token="$(OUTLOOK_CAP_FILE="$tmp" OUTLOOK_CAP_HOST="$OUTLOOK_CAPTURE_HOST" python3 -c '
|
|
70
|
+
import json, os, sys
|
|
71
|
+
d = json.load(open(os.environ["OUTLOOK_CAP_FILE"]))
|
|
72
|
+
v = d.get("authorization@" + os.environ["OUTLOOK_CAP_HOST"])
|
|
73
|
+
if not v:
|
|
74
|
+
vals = [x for x in d.values() if isinstance(x, str) and x.strip()]
|
|
75
|
+
v = vals[0] if vals else ""
|
|
76
|
+
sys.stdout.write(v.strip())
|
|
77
|
+
')"
|
|
78
|
+
rm -f "$tmp" "$tmp.err"
|
|
79
|
+
|
|
80
|
+
case "$token" in
|
|
81
|
+
Bearer\ *) : ;;
|
|
82
|
+
"") echo "no authorization header captured — reload Outlook and retry" >&2; return 1 ;;
|
|
83
|
+
*) token="Bearer $token" ;;
|
|
84
|
+
esac
|
|
85
|
+
|
|
86
|
+
local rc_file; rc_file="$(_outlook_curlrc)"
|
|
87
|
+
umask 077
|
|
88
|
+
printf 'header = "Authorization: %s"\n' "$token" > "$rc_file" || return 1
|
|
89
|
+
chmod 600 "$rc_file" 2>/dev/null
|
|
90
|
+
echo "token stored in $rc_file ($(outlook_token_expiry))" >&2
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
# Print the token's expiry (non-secret claim) without revealing the token.
|
|
94
|
+
outlook_token_expiry() {
|
|
95
|
+
local rc_file; rc_file="$(_outlook_curlrc)"
|
|
96
|
+
[ -f "$rc_file" ] || { echo "no token"; return 1; }
|
|
97
|
+
OUTLOOK_RC="$rc_file" python3 -c '
|
|
98
|
+
import base64, json, os, re, sys, datetime
|
|
99
|
+
txt = open(os.environ["OUTLOOK_RC"]).read()
|
|
100
|
+
m = re.search(r"Authorization: Bearer ([A-Za-z0-9._-]+)", txt)
|
|
101
|
+
if not m: print("unparseable"); sys.exit(1)
|
|
102
|
+
p = m.group(1).split(".")
|
|
103
|
+
if len(p) < 2: print("not a jwt"); sys.exit(1)
|
|
104
|
+
s = p[1] + "=" * (-len(p[1]) % 4)
|
|
105
|
+
try:
|
|
106
|
+
exp = json.loads(base64.urlsafe_b64decode(s)).get("exp")
|
|
107
|
+
except Exception:
|
|
108
|
+
print("undecodable"); sys.exit(1)
|
|
109
|
+
if not exp: print("no exp"); sys.exit(1)
|
|
110
|
+
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
|
|
111
|
+
left = int(exp - now)
|
|
112
|
+
when = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
|
|
113
|
+
print(f"expires {when}" + (f", {left//3600}h{(left%3600)//60}m left" if left > 0 else ", EXPIRED"))
|
|
114
|
+
'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# NOTE: never name a local `path` or `status` here. In zsh `path` is tied to the
|
|
118
|
+
# PATH array (assigning it wipes PATH for the rest of the function, so even
|
|
119
|
+
# `mktemp` stops resolving) and `status` is read-only. Both are harmless under
|
|
120
|
+
# bash, so this only shows up when the file is sourced from a zsh shell.
|
|
121
|
+
_outlook_request() {
|
|
122
|
+
local method="$1" req_path="$2"; shift 2
|
|
123
|
+
local rc_file; rc_file="$(_outlook_curlrc)"
|
|
124
|
+
[ -f "$rc_file" ] || { echo "no token — run: outlook_token_refresh" >&2; return 1; }
|
|
125
|
+
|
|
126
|
+
local http_status tmp
|
|
127
|
+
tmp="$(mktemp "${TMPDIR:-/tmp}/outlook-resp.XXXXXX")" || return 1
|
|
128
|
+
# Args in an array so nothing is re-split; token stays in the -K file.
|
|
129
|
+
local -a args
|
|
130
|
+
args=(-s -K "$rc_file" -X "$method" -H 'Accept: application/json'
|
|
131
|
+
-o "$tmp" -w '%{http_code}' --max-time 60)
|
|
132
|
+
http_status="$(curl "${args[@]}" "$@" "${OUTLOOK_API}${req_path}")" || {
|
|
133
|
+
echo "curl failed" >&2; rm -f "$tmp"; return 1; }
|
|
134
|
+
|
|
135
|
+
case "$http_status" in
|
|
136
|
+
2*) cat "$tmp"; rm -f "$tmp"; return 0 ;;
|
|
137
|
+
401) echo "401 — token expired or revoked. Run: outlook_token_refresh" >&2
|
|
138
|
+
rm -f "$tmp"; return 4 ;;
|
|
139
|
+
*) echo "HTTP $http_status" >&2; sed -n '1,5p' "$tmp" >&2; rm -f "$tmp"; return 4 ;;
|
|
140
|
+
esac
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# outlook_get '<path>' [extra curl args...]
|
|
144
|
+
outlook_get() { local p="$1"; shift; _outlook_request GET "$p" "$@"; }
|
|
145
|
+
|
|
146
|
+
# outlook_post '<path>' '<json>' [extra curl args...]
|
|
147
|
+
outlook_post() {
|
|
148
|
+
local p="$1" body="$2"; shift 2
|
|
149
|
+
_outlook_request POST "$p" -H 'Content-Type: application/json' -d "$body" "$@"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# outlook_patch '<path>' '<json>' [extra curl args...]
|
|
153
|
+
outlook_patch() {
|
|
154
|
+
local p="$1" body="$2"; shift 2
|
|
155
|
+
_outlook_request PATCH "$p" -H 'Content-Type: application/json' -d "$body" "$@"
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# Plain-text bodies instead of HTML — ~3-9x smaller. Use for anything you read.
|
|
159
|
+
outlook_get_text() { local p="$1"; shift; outlook_get "$p" -H 'Prefer: outlook.body-content-type="text"' "$@"; }
|