outnote 0.1.0__tar.gz
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.
- outnote-0.1.0/LICENSE +21 -0
- outnote-0.1.0/PKG-INFO +178 -0
- outnote-0.1.0/README.md +149 -0
- outnote-0.1.0/pyproject.toml +92 -0
- outnote-0.1.0/pyproject.toml.orig +83 -0
- outnote-0.1.0/src/outnote/__init__.py +30 -0
- outnote-0.1.0/src/outnote/_auth.py +151 -0
- outnote-0.1.0/src/outnote/_graph.py +240 -0
outnote-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PianoNic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
outnote-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: outnote
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Headless Microsoft 365 reader - mail, calendar, Teams, OneDrive, SharePoint and OneNote over Graph, no app registration.
|
|
5
|
+
Keywords: microsoft,microsoft-365,microsoft-graph,graph-api,outlook,onenote,onedrive,sharepoint,teams,mail,calendar,headless,no-app-registration,foci,entra,mcp
|
|
6
|
+
Author: PianoNic
|
|
7
|
+
Author-email: PianoNic <contact@pianonic.ch>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
21
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Office/Business
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Dist: ms-entrance>=1.2
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Project-URL: Homepage, https://github.com/PianoNic/OutNote
|
|
27
|
+
Project-URL: Issues, https://github.com/PianoNic/OutNote/issues
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
<p align="center">
|
|
31
|
+
<img src="assets/outnote-icon.svg" width="140" alt="OutNote Logo">
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
<h1 align="center">OutNote</h1>
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
<strong>Headless Microsoft 365 reader - mail, calendar, Teams, OneDrive, SharePoint and OneNote over Graph, without an app registration.</strong>
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<p align="center">
|
|
41
|
+
<a href="https://github.com/PianoNic/OutNote"><img src="https://badgetrack.pianonic.ch/badge?tag=outnote&label=visits&color=7719AA&style=flat" alt="visits"/></a>
|
|
42
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-7719AA" alt="Python 3.10+"/>
|
|
43
|
+
<a href="https://github.com/PianoNic/OutNote/releases"><img src="https://img.shields.io/github/v/release/PianoNic/OutNote?include_prereleases&color=7719AA&label=Latest%20Release" alt="Latest release"/></a>
|
|
44
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-7719AA.svg" alt="MIT license"/></a>
|
|
45
|
+
<a href="https://github.com/PianoNic/Entrance"><img src="https://img.shields.io/badge/built%20on-entrance-7719AA.svg" alt="Built on Entrance"/></a>
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
> [!WARNING]
|
|
49
|
+
> **For educational and research purposes only.** Use it solely against **accounts you own** or with the **explicit authorization** of the account/tenant owner. It drives a real sign-in with real credentials - treat the cookie jar and any tokens like passwords. See the [Disclaimer](#disclaimer) below.
|
|
50
|
+
|
|
51
|
+
## About The Project
|
|
52
|
+
|
|
53
|
+
Every "read my mailbox over Graph" tutorial makes you register an Azure app and get **admin consent** - which a locked-down tenant (a school, a company) simply refuses. OutNote sidesteps that entirely.
|
|
54
|
+
|
|
55
|
+
It signs in with [`entrance`](https://github.com/PianoNic/Entrance) using the pre-consented **OfficeHome** first-party client, which hands back a **FOCI family refresh token**. A tiny broker then trades that token for whichever first-party client's Graph token a given call needs - all without another sign-in. Each of those clients is already consented in the tenant, so there is **nothing to register and nobody to ask**.
|
|
56
|
+
|
|
57
|
+
The result: one headless login (password + TOTP MFA, no browser), then clean Graph reads across your whole Microsoft 365 account. Built to be the base layer for an MCP server.
|
|
58
|
+
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
- 🔓 **No app registration, no admin consent** - rides pre-consented Microsoft first-party clients
|
|
62
|
+
- 🤖 **Fully headless** - password + TOTP MFA over pure HTTP, no browser (via [`entrance`](https://github.com/PianoNic/Entrance))
|
|
63
|
+
- 🧩 **One login, every service** - a FOCI family token is brokered into the right client per call
|
|
64
|
+
- 📬 **Broad coverage** - mail, calendar, Teams chats & channels, channel files, OneDrive, SharePoint, OneNote
|
|
65
|
+
- 🍪 **Silent reuse** - a persisted session jar means password-free reruns
|
|
66
|
+
- 📦 **Tiny** - stdlib `urllib` Graph client, one dependency (`ms-entrance`)
|
|
67
|
+
|
|
68
|
+
## Installation
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install outnote
|
|
72
|
+
# or
|
|
73
|
+
uv add outnote
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Requires Python 3.10+; `ms-entrance` is pulled in automatically.
|
|
77
|
+
|
|
78
|
+
## Quickstart
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from outnote import authenticate
|
|
82
|
+
|
|
83
|
+
note = authenticate(
|
|
84
|
+
"you@school.ch",
|
|
85
|
+
"your-password",
|
|
86
|
+
totp_secret="JBSWY3DPEHPK3PXP", # Base32 TOTP, if the account has MFA
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# mail & calendar
|
|
90
|
+
note.emails(top=5)
|
|
91
|
+
note.events(top=5)
|
|
92
|
+
|
|
93
|
+
# Teams chats
|
|
94
|
+
for chat in note.chats():
|
|
95
|
+
note.chat_messages(chat["id"])
|
|
96
|
+
|
|
97
|
+
# Teams channels + files
|
|
98
|
+
for team in note.teams():
|
|
99
|
+
for ch in note.channels(team["id"]):
|
|
100
|
+
note.channel_messages(team["id"], ch["id"])
|
|
101
|
+
note.channel_files(team["id"], ch["id"]) # files posted in the channel
|
|
102
|
+
|
|
103
|
+
# OneDrive & SharePoint
|
|
104
|
+
note.files() # OneDrive items
|
|
105
|
+
note.download_url(item_id) # direct, pre-authed download link
|
|
106
|
+
note.sites() # SharePoint sites
|
|
107
|
+
|
|
108
|
+
# search across the account
|
|
109
|
+
note.search_emails("invoice")
|
|
110
|
+
note.search_files("budget.xlsx")
|
|
111
|
+
note.search_messages("standup") # Teams chat & channel messages
|
|
112
|
+
note.search_people("Fischer")
|
|
113
|
+
note.search("project kickoff", entity="event") # any entity type
|
|
114
|
+
|
|
115
|
+
# OneNote - list and read page content as HTML
|
|
116
|
+
for nb in note.notebooks():
|
|
117
|
+
for sec in note.sections(nb["id"]):
|
|
118
|
+
for page in note.pages(sec["id"]):
|
|
119
|
+
html = note.page_content(page["id"])
|
|
120
|
+
|
|
121
|
+
note.get("me/drive/root/children") # any Graph endpoint, raw
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The first call saves `.ms_session.json`; every call after that skips the password and MFA.
|
|
125
|
+
|
|
126
|
+
**Already have a Graph token** (MSAL, a browser session, ...)? Skip auth entirely. A single static token only carries its own scopes, so OneNote/SharePoint calls may `403` if it lacks them:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from outnote import OutNote
|
|
130
|
+
|
|
131
|
+
OutNote(access_token).emails()
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## What it does
|
|
135
|
+
|
|
136
|
+
Verified end-to-end against a live locked-down EDU tenant:
|
|
137
|
+
|
|
138
|
+
| Area | Methods | Graph endpoint |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| Mail | `emails()`, `email(id)` | `/me/mailFolders/{f}/messages` |
|
|
141
|
+
| Calendar | `events(start, end)` | `/me/events`, `/me/calendarView` |
|
|
142
|
+
| Teams chats | `chats()`, `chat_messages(id)` | `/me/chats`, `/me/chats/{id}/messages` |
|
|
143
|
+
| Teams channels | `teams()`, `channels(t)`, `channel_messages(t, c)` | `/me/joinedTeams`, `/teams/{t}/channels[/{c}/messages]` |
|
|
144
|
+
| Channel files | `channel_files(t, c)` | `filesFolder` → `/drives/{d}/items/{i}/children` |
|
|
145
|
+
| OneDrive | `files(path)`, `download_url(id[, drive_id])` | `/me/drive/...` |
|
|
146
|
+
| SharePoint | `sites(search)`, `site_drive_items(id)` | `/sites`, `/sites/{id}/drive` |
|
|
147
|
+
| OneNote | `notebooks()`, `sections(nb)`, `pages(sec)`, `page_content(id)` | `/me/onenote/...` |
|
|
148
|
+
| Contacts / Tasks | `contacts()`, `task_lists()`, `tasks(list)` | `/me/contacts`, `/me/todo/lists` |
|
|
149
|
+
| Search | `search_emails(q)`, `search_files(q)`, `search_messages(q)`, `search_people(q)`, `search(q, entity)` | `/search/query` (message, driveItem, chatMessage, event, person, site) |
|
|
150
|
+
| Anything | `get(path)`, `list(path)` | any endpoint, follows `@odata.nextLink` |
|
|
151
|
+
|
|
152
|
+
## How It Works
|
|
153
|
+
|
|
154
|
+
`authenticate` runs a PKCE authorization-code sign-in for the **OfficeHome** client through `entrance` (which drives Microsoft's sign-in - including TOTP MFA - over pure HTTP and intercepts the `code` at the native-client redirect), then redeems it for a **FOCI family refresh token**. The `OutNote` client holds a broker that, per call, refreshes that token into the first-party client whose pre-consented scopes cover the request, caching each token until it expires:
|
|
155
|
+
|
|
156
|
+
| Service | FOCI client | Key scope it carries |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| Mail, Calendar, Chats, Channels, Files, Teams, Contacts, Tasks | Office `d3590ed6-...` | `Mail.ReadWrite`, `Chat.ReadWrite`, `ChannelMessage.Read.All`, `Files.ReadWrite.All`, `Calendars.ReadWrite` |
|
|
159
|
+
| OneNote (read page content) | Teams-classic `1fec8e78-...` | `Notes.ReadWrite.All` |
|
|
160
|
+
| SharePoint sites | OfficeHome `4765445b-...` | `Sites.Read.All` |
|
|
161
|
+
|
|
162
|
+
Which first-party clients are FOCI-family and pre-consented is tenant-dependent; the three above work on a standard Microsoft 365 / EDU tenant.
|
|
163
|
+
|
|
164
|
+
## Caveats
|
|
165
|
+
|
|
166
|
+
- Works against **managed** Entra tenants (`login.microsoftonline.com`) with these first-party clients pre-consented - the common case for Microsoft 365 / EDU.
|
|
167
|
+
- **Push / SMS MFA can't be done headless** - only TOTP (authenticator app). Enroll one and pass its Base32 secret.
|
|
168
|
+
- Microsoft tweaks its login pages and client registrations; if a flow breaks, a user-agent, field name or client ID is usually the culprit.
|
|
169
|
+
|
|
170
|
+
## Disclaimer
|
|
171
|
+
|
|
172
|
+
OutNote is published for **educational and research purposes only** - to document how Microsoft 365's first-party OAuth2 clients and the Graph API behave for a browserless client.
|
|
173
|
+
|
|
174
|
+
Use it only against accounts you own, or with the explicit authorization of the account or tenant owner. You alone are responsible for your use of it and for complying with Microsoft's terms, your identity provider's terms, your institution's acceptable-use policy, and applicable law. The software is provided "as is", without warranty of any kind, and the author accepts no liability for misuse. Not affiliated with or endorsed by Microsoft.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
[MIT](LICENSE) (c) [PianoNic](https://github.com/PianoNic)
|
outnote-0.1.0/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/outnote-icon.svg" width="140" alt="OutNote Logo">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">OutNote</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Headless Microsoft 365 reader - mail, calendar, Teams, OneDrive, SharePoint and OneNote over Graph, without an app registration.</strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://github.com/PianoNic/OutNote"><img src="https://badgetrack.pianonic.ch/badge?tag=outnote&label=visits&color=7719AA&style=flat" alt="visits"/></a>
|
|
13
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-7719AA" alt="Python 3.10+"/>
|
|
14
|
+
<a href="https://github.com/PianoNic/OutNote/releases"><img src="https://img.shields.io/github/v/release/PianoNic/OutNote?include_prereleases&color=7719AA&label=Latest%20Release" alt="Latest release"/></a>
|
|
15
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-7719AA.svg" alt="MIT license"/></a>
|
|
16
|
+
<a href="https://github.com/PianoNic/Entrance"><img src="https://img.shields.io/badge/built%20on-entrance-7719AA.svg" alt="Built on Entrance"/></a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
> [!WARNING]
|
|
20
|
+
> **For educational and research purposes only.** Use it solely against **accounts you own** or with the **explicit authorization** of the account/tenant owner. It drives a real sign-in with real credentials - treat the cookie jar and any tokens like passwords. See the [Disclaimer](#disclaimer) below.
|
|
21
|
+
|
|
22
|
+
## About The Project
|
|
23
|
+
|
|
24
|
+
Every "read my mailbox over Graph" tutorial makes you register an Azure app and get **admin consent** - which a locked-down tenant (a school, a company) simply refuses. OutNote sidesteps that entirely.
|
|
25
|
+
|
|
26
|
+
It signs in with [`entrance`](https://github.com/PianoNic/Entrance) using the pre-consented **OfficeHome** first-party client, which hands back a **FOCI family refresh token**. A tiny broker then trades that token for whichever first-party client's Graph token a given call needs - all without another sign-in. Each of those clients is already consented in the tenant, so there is **nothing to register and nobody to ask**.
|
|
27
|
+
|
|
28
|
+
The result: one headless login (password + TOTP MFA, no browser), then clean Graph reads across your whole Microsoft 365 account. Built to be the base layer for an MCP server.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- 🔓 **No app registration, no admin consent** - rides pre-consented Microsoft first-party clients
|
|
33
|
+
- 🤖 **Fully headless** - password + TOTP MFA over pure HTTP, no browser (via [`entrance`](https://github.com/PianoNic/Entrance))
|
|
34
|
+
- 🧩 **One login, every service** - a FOCI family token is brokered into the right client per call
|
|
35
|
+
- 📬 **Broad coverage** - mail, calendar, Teams chats & channels, channel files, OneDrive, SharePoint, OneNote
|
|
36
|
+
- 🍪 **Silent reuse** - a persisted session jar means password-free reruns
|
|
37
|
+
- 📦 **Tiny** - stdlib `urllib` Graph client, one dependency (`ms-entrance`)
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install outnote
|
|
43
|
+
# or
|
|
44
|
+
uv add outnote
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Requires Python 3.10+; `ms-entrance` is pulled in automatically.
|
|
48
|
+
|
|
49
|
+
## Quickstart
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from outnote import authenticate
|
|
53
|
+
|
|
54
|
+
note = authenticate(
|
|
55
|
+
"you@school.ch",
|
|
56
|
+
"your-password",
|
|
57
|
+
totp_secret="JBSWY3DPEHPK3PXP", # Base32 TOTP, if the account has MFA
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# mail & calendar
|
|
61
|
+
note.emails(top=5)
|
|
62
|
+
note.events(top=5)
|
|
63
|
+
|
|
64
|
+
# Teams chats
|
|
65
|
+
for chat in note.chats():
|
|
66
|
+
note.chat_messages(chat["id"])
|
|
67
|
+
|
|
68
|
+
# Teams channels + files
|
|
69
|
+
for team in note.teams():
|
|
70
|
+
for ch in note.channels(team["id"]):
|
|
71
|
+
note.channel_messages(team["id"], ch["id"])
|
|
72
|
+
note.channel_files(team["id"], ch["id"]) # files posted in the channel
|
|
73
|
+
|
|
74
|
+
# OneDrive & SharePoint
|
|
75
|
+
note.files() # OneDrive items
|
|
76
|
+
note.download_url(item_id) # direct, pre-authed download link
|
|
77
|
+
note.sites() # SharePoint sites
|
|
78
|
+
|
|
79
|
+
# search across the account
|
|
80
|
+
note.search_emails("invoice")
|
|
81
|
+
note.search_files("budget.xlsx")
|
|
82
|
+
note.search_messages("standup") # Teams chat & channel messages
|
|
83
|
+
note.search_people("Fischer")
|
|
84
|
+
note.search("project kickoff", entity="event") # any entity type
|
|
85
|
+
|
|
86
|
+
# OneNote - list and read page content as HTML
|
|
87
|
+
for nb in note.notebooks():
|
|
88
|
+
for sec in note.sections(nb["id"]):
|
|
89
|
+
for page in note.pages(sec["id"]):
|
|
90
|
+
html = note.page_content(page["id"])
|
|
91
|
+
|
|
92
|
+
note.get("me/drive/root/children") # any Graph endpoint, raw
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The first call saves `.ms_session.json`; every call after that skips the password and MFA.
|
|
96
|
+
|
|
97
|
+
**Already have a Graph token** (MSAL, a browser session, ...)? Skip auth entirely. A single static token only carries its own scopes, so OneNote/SharePoint calls may `403` if it lacks them:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from outnote import OutNote
|
|
101
|
+
|
|
102
|
+
OutNote(access_token).emails()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## What it does
|
|
106
|
+
|
|
107
|
+
Verified end-to-end against a live locked-down EDU tenant:
|
|
108
|
+
|
|
109
|
+
| Area | Methods | Graph endpoint |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| Mail | `emails()`, `email(id)` | `/me/mailFolders/{f}/messages` |
|
|
112
|
+
| Calendar | `events(start, end)` | `/me/events`, `/me/calendarView` |
|
|
113
|
+
| Teams chats | `chats()`, `chat_messages(id)` | `/me/chats`, `/me/chats/{id}/messages` |
|
|
114
|
+
| Teams channels | `teams()`, `channels(t)`, `channel_messages(t, c)` | `/me/joinedTeams`, `/teams/{t}/channels[/{c}/messages]` |
|
|
115
|
+
| Channel files | `channel_files(t, c)` | `filesFolder` → `/drives/{d}/items/{i}/children` |
|
|
116
|
+
| OneDrive | `files(path)`, `download_url(id[, drive_id])` | `/me/drive/...` |
|
|
117
|
+
| SharePoint | `sites(search)`, `site_drive_items(id)` | `/sites`, `/sites/{id}/drive` |
|
|
118
|
+
| OneNote | `notebooks()`, `sections(nb)`, `pages(sec)`, `page_content(id)` | `/me/onenote/...` |
|
|
119
|
+
| Contacts / Tasks | `contacts()`, `task_lists()`, `tasks(list)` | `/me/contacts`, `/me/todo/lists` |
|
|
120
|
+
| Search | `search_emails(q)`, `search_files(q)`, `search_messages(q)`, `search_people(q)`, `search(q, entity)` | `/search/query` (message, driveItem, chatMessage, event, person, site) |
|
|
121
|
+
| Anything | `get(path)`, `list(path)` | any endpoint, follows `@odata.nextLink` |
|
|
122
|
+
|
|
123
|
+
## How It Works
|
|
124
|
+
|
|
125
|
+
`authenticate` runs a PKCE authorization-code sign-in for the **OfficeHome** client through `entrance` (which drives Microsoft's sign-in - including TOTP MFA - over pure HTTP and intercepts the `code` at the native-client redirect), then redeems it for a **FOCI family refresh token**. The `OutNote` client holds a broker that, per call, refreshes that token into the first-party client whose pre-consented scopes cover the request, caching each token until it expires:
|
|
126
|
+
|
|
127
|
+
| Service | FOCI client | Key scope it carries |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| Mail, Calendar, Chats, Channels, Files, Teams, Contacts, Tasks | Office `d3590ed6-...` | `Mail.ReadWrite`, `Chat.ReadWrite`, `ChannelMessage.Read.All`, `Files.ReadWrite.All`, `Calendars.ReadWrite` |
|
|
130
|
+
| OneNote (read page content) | Teams-classic `1fec8e78-...` | `Notes.ReadWrite.All` |
|
|
131
|
+
| SharePoint sites | OfficeHome `4765445b-...` | `Sites.Read.All` |
|
|
132
|
+
|
|
133
|
+
Which first-party clients are FOCI-family and pre-consented is tenant-dependent; the three above work on a standard Microsoft 365 / EDU tenant.
|
|
134
|
+
|
|
135
|
+
## Caveats
|
|
136
|
+
|
|
137
|
+
- Works against **managed** Entra tenants (`login.microsoftonline.com`) with these first-party clients pre-consented - the common case for Microsoft 365 / EDU.
|
|
138
|
+
- **Push / SMS MFA can't be done headless** - only TOTP (authenticator app). Enroll one and pass its Base32 secret.
|
|
139
|
+
- Microsoft tweaks its login pages and client registrations; if a flow breaks, a user-agent, field name or client ID is usually the culprit.
|
|
140
|
+
|
|
141
|
+
## Disclaimer
|
|
142
|
+
|
|
143
|
+
OutNote is published for **educational and research purposes only** - to document how Microsoft 365's first-party OAuth2 clients and the Graph API behave for a browserless client.
|
|
144
|
+
|
|
145
|
+
Use it only against accounts you own, or with the explicit authorization of the account or tenant owner. You alone are responsible for your use of it and for complying with Microsoft's terms, your identity provider's terms, your institution's acceptable-use policy, and applicable law. The software is provided "as is", without warranty of any kind, and the author accepts no liability for misuse. Not affiliated with or endorsed by Microsoft.
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
[MIT](LICENSE) (c) [PianoNic](https://github.com/PianoNic)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build >= 0.11.7, <0.12.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "outnote"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Headless Microsoft 365 reader - mail, calendar, Teams, OneDrive, SharePoint and OneNote over Graph, no app registration."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICEN[CS]E*"]
|
|
13
|
+
keywords = [
|
|
14
|
+
"microsoft",
|
|
15
|
+
"microsoft-365",
|
|
16
|
+
"microsoft-graph",
|
|
17
|
+
"graph-api",
|
|
18
|
+
"outlook",
|
|
19
|
+
"onenote",
|
|
20
|
+
"onedrive",
|
|
21
|
+
"sharepoint",
|
|
22
|
+
"teams",
|
|
23
|
+
"mail",
|
|
24
|
+
"calendar",
|
|
25
|
+
"headless",
|
|
26
|
+
"no-app-registration",
|
|
27
|
+
"foci",
|
|
28
|
+
"entra",
|
|
29
|
+
"mcp",
|
|
30
|
+
]
|
|
31
|
+
classifiers = [
|
|
32
|
+
"Development Status :: 4 - Beta",
|
|
33
|
+
"Intended Audience :: Developers",
|
|
34
|
+
"Operating System :: OS Independent",
|
|
35
|
+
"Natural Language :: English",
|
|
36
|
+
"Programming Language :: Python :: 3",
|
|
37
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
38
|
+
"Programming Language :: Python :: 3.10",
|
|
39
|
+
"Programming Language :: Python :: 3.11",
|
|
40
|
+
"Programming Language :: Python :: 3.12",
|
|
41
|
+
"Programming Language :: Python :: 3.13",
|
|
42
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
43
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
44
|
+
"Topic :: Office/Business",
|
|
45
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
46
|
+
]
|
|
47
|
+
dependencies = ["ms-entrance>=1.2"]
|
|
48
|
+
|
|
49
|
+
[[project.authors]]
|
|
50
|
+
name = "PianoNic"
|
|
51
|
+
email = "contact@pianonic.ch"
|
|
52
|
+
|
|
53
|
+
[project.urls]
|
|
54
|
+
Homepage = "https://github.com/PianoNic/OutNote"
|
|
55
|
+
Issues = "https://github.com/PianoNic/OutNote/issues"
|
|
56
|
+
|
|
57
|
+
[dependency-groups]
|
|
58
|
+
dev = [
|
|
59
|
+
"pytest>=8.0",
|
|
60
|
+
"mypy>=1.10",
|
|
61
|
+
"ruff>=0.6",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[tool.uv.build-backend]
|
|
65
|
+
module-name = "outnote"
|
|
66
|
+
module-root = "src"
|
|
67
|
+
|
|
68
|
+
[tool.pytest.ini_options]
|
|
69
|
+
testpaths = ["tests"]
|
|
70
|
+
|
|
71
|
+
[tool.ruff]
|
|
72
|
+
line-length = 100
|
|
73
|
+
target-version = "py310"
|
|
74
|
+
|
|
75
|
+
[tool.ruff.lint]
|
|
76
|
+
select = [
|
|
77
|
+
"E",
|
|
78
|
+
"F",
|
|
79
|
+
"I",
|
|
80
|
+
"UP",
|
|
81
|
+
"B",
|
|
82
|
+
]
|
|
83
|
+
ignore = [
|
|
84
|
+
"E501",
|
|
85
|
+
"E731",
|
|
86
|
+
"B008",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[tool.mypy]
|
|
90
|
+
python_version = "3.10"
|
|
91
|
+
ignore_missing_imports = true
|
|
92
|
+
files = ["src/outnote"]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build >= 0.11.7, <0.12.0"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "outnote"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Headless Microsoft 365 reader - mail, calendar, Teams, OneDrive, SharePoint and OneNote over Graph, no app registration."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "PianoNic", email = "contact@pianonic.ch" },
|
|
13
|
+
]
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICEN[CS]E*"]
|
|
16
|
+
keywords = [
|
|
17
|
+
"microsoft",
|
|
18
|
+
"microsoft-365",
|
|
19
|
+
"microsoft-graph",
|
|
20
|
+
"graph-api",
|
|
21
|
+
"outlook",
|
|
22
|
+
"onenote",
|
|
23
|
+
"onedrive",
|
|
24
|
+
"sharepoint",
|
|
25
|
+
"teams",
|
|
26
|
+
"mail",
|
|
27
|
+
"calendar",
|
|
28
|
+
"headless",
|
|
29
|
+
"no-app-registration",
|
|
30
|
+
"foci",
|
|
31
|
+
"entra",
|
|
32
|
+
"mcp",
|
|
33
|
+
]
|
|
34
|
+
classifiers = [
|
|
35
|
+
"Development Status :: 4 - Beta",
|
|
36
|
+
"Intended Audience :: Developers",
|
|
37
|
+
"Operating System :: OS Independent",
|
|
38
|
+
"Natural Language :: English",
|
|
39
|
+
"Programming Language :: Python :: 3",
|
|
40
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
41
|
+
"Programming Language :: Python :: 3.10",
|
|
42
|
+
"Programming Language :: Python :: 3.11",
|
|
43
|
+
"Programming Language :: Python :: 3.12",
|
|
44
|
+
"Programming Language :: Python :: 3.13",
|
|
45
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
46
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
47
|
+
"Topic :: Office/Business",
|
|
48
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
49
|
+
]
|
|
50
|
+
dependencies = [
|
|
51
|
+
"ms-entrance>=1.2",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[project.urls]
|
|
55
|
+
Homepage = "https://github.com/PianoNic/OutNote"
|
|
56
|
+
Issues = "https://github.com/PianoNic/OutNote/issues"
|
|
57
|
+
|
|
58
|
+
[dependency-groups]
|
|
59
|
+
dev = [
|
|
60
|
+
"pytest>=8.0",
|
|
61
|
+
"mypy>=1.10",
|
|
62
|
+
"ruff>=0.6",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.uv.build-backend]
|
|
66
|
+
module-name = "outnote"
|
|
67
|
+
module-root = "src"
|
|
68
|
+
|
|
69
|
+
[tool.pytest.ini_options]
|
|
70
|
+
testpaths = ["tests"]
|
|
71
|
+
|
|
72
|
+
[tool.ruff]
|
|
73
|
+
line-length = 100
|
|
74
|
+
target-version = "py310"
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint]
|
|
77
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
78
|
+
ignore = ["E501", "E731", "B008"]
|
|
79
|
+
|
|
80
|
+
[tool.mypy]
|
|
81
|
+
python_version = "3.10"
|
|
82
|
+
ignore_missing_imports = true
|
|
83
|
+
files = ["src/outnote"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""OutNote — read Microsoft 365 over Graph, headless, no app registration.
|
|
2
|
+
|
|
3
|
+
from outnote import authenticate
|
|
4
|
+
note = authenticate("you@org.com", "password", totp_secret="BASE32")
|
|
5
|
+
|
|
6
|
+
note.emails() # mail
|
|
7
|
+
note.events() # calendar
|
|
8
|
+
note.chats(); note.chat_messages(id) # Teams chats
|
|
9
|
+
note.teams(); note.channel_messages(t, c) # Teams channels
|
|
10
|
+
note.channel_files(t, c) # files posted in a channel
|
|
11
|
+
note.files() # OneDrive
|
|
12
|
+
note.sites() # SharePoint
|
|
13
|
+
note.notebooks(); note.pages(sec); note.page_content(id) # OneNote
|
|
14
|
+
|
|
15
|
+
One sign-in yields a FOCI family refresh token; a broker mints the right
|
|
16
|
+
first-party client's token per call. Already have a token? `OutNote(token)`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from ._auth import OFFICEHOME, authenticate
|
|
20
|
+
from ._graph import C_OFFICE, C_ONENOTE, C_SITES, GraphError, OutNote
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"OutNote",
|
|
24
|
+
"authenticate",
|
|
25
|
+
"GraphError",
|
|
26
|
+
"OFFICEHOME",
|
|
27
|
+
"C_OFFICE",
|
|
28
|
+
"C_ONENOTE",
|
|
29
|
+
"C_SITES",
|
|
30
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Headless Microsoft 365 sign-in that needs no app registration.
|
|
2
|
+
|
|
3
|
+
One sign-in (via `entrance`) against the pre-consented OfficeHome client yields
|
|
4
|
+
a FOCI **family refresh token**; a broker then mints a Graph token for whichever
|
|
5
|
+
first-party client a given call needs — all without further sign-ins. See the
|
|
6
|
+
README for the token map and the (accounts-you-own) disclaimer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from urllib.parse import urlencode
|
|
15
|
+
from urllib.request import Request, urlopen
|
|
16
|
+
|
|
17
|
+
from ._graph import OutNote
|
|
18
|
+
|
|
19
|
+
_AUTHORIZE = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize"
|
|
20
|
+
_TOKEN = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
|
21
|
+
|
|
22
|
+
# The sign-in client: OfficeHome is pre-consented and issues a FOCI (family=1)
|
|
23
|
+
# refresh token that the broker trades for the other family clients' tokens.
|
|
24
|
+
OFFICEHOME = "4765445b-32c6-49b0-83e6-1d93765276ca"
|
|
25
|
+
# Native-client redirect: redeems server-side (no SPA/Origin dance). entrance
|
|
26
|
+
# intercepts the code here; it is never actually delivered.
|
|
27
|
+
_REDIRECT = "https://login.microsoftonline.com/common/oauth2/nativeclient"
|
|
28
|
+
_SCOPES = "https://graph.microsoft.com/.default offline_access openid profile"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _pkce():
|
|
32
|
+
v = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
|
33
|
+
c = base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b"=").decode()
|
|
34
|
+
return v, c
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _post_token(url, data):
|
|
38
|
+
req = Request(
|
|
39
|
+
url,
|
|
40
|
+
data=urlencode(data).encode(),
|
|
41
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
with urlopen(req) as r: # noqa: S310 - fixed https host
|
|
45
|
+
return json.loads(r.read())
|
|
46
|
+
except Exception as e: # noqa: BLE001 - surface the AADSTS error body
|
|
47
|
+
body = getattr(e, "read", lambda: b"")()
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(body)
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise RuntimeError(f"Token endpoint error: {e}") from None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Broker:
|
|
55
|
+
"""Holds a FOCI family refresh token and mints/caches a Graph access token
|
|
56
|
+
per first-party client on demand. Callable as `broker(client_id) -> token`."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, refresh_token, tenant="common"):
|
|
59
|
+
self._frt = refresh_token
|
|
60
|
+
self._url = _TOKEN.format(tenant=tenant)
|
|
61
|
+
self._cache = {} # client_id -> (access_token, expiry_epoch)
|
|
62
|
+
|
|
63
|
+
def __call__(self, client_id):
|
|
64
|
+
tok, exp = self._cache.get(client_id, (None, 0))
|
|
65
|
+
if tok and exp - 60 > time.time():
|
|
66
|
+
return tok
|
|
67
|
+
r = _post_token(
|
|
68
|
+
self._url,
|
|
69
|
+
{
|
|
70
|
+
"grant_type": "refresh_token",
|
|
71
|
+
"refresh_token": self._frt,
|
|
72
|
+
"client_id": client_id,
|
|
73
|
+
"scope": "https://graph.microsoft.com/.default",
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
if not r.get("access_token"):
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
f"Could not get a token for client {client_id}: {r.get('error_description') or r}"
|
|
79
|
+
)
|
|
80
|
+
self._frt = r.get("refresh_token", self._frt) # FRT rotates on use
|
|
81
|
+
self._cache[client_id] = (r["access_token"], time.time() + r.get("expires_in", 3600))
|
|
82
|
+
return r["access_token"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def authenticate(
|
|
86
|
+
username=None,
|
|
87
|
+
password=None,
|
|
88
|
+
*,
|
|
89
|
+
tenant="common",
|
|
90
|
+
totp_secret=None,
|
|
91
|
+
totp_code=None,
|
|
92
|
+
cookies=".ms_session.json",
|
|
93
|
+
**login_kwargs,
|
|
94
|
+
):
|
|
95
|
+
"""Sign in and return an `OutNote` client wired to a token broker.
|
|
96
|
+
|
|
97
|
+
No app registration or admin consent needed. `username`/`password` are
|
|
98
|
+
optional once `cookies` holds a live session (silent reuse). MFA: pass
|
|
99
|
+
`totp_secret` (Base32) or `totp_code`. Extra kwargs pass through to
|
|
100
|
+
`entrance.login`.
|
|
101
|
+
"""
|
|
102
|
+
from entrance import login # deferred: only the sign-in path needs entrance
|
|
103
|
+
|
|
104
|
+
verifier, challenge = _pkce()
|
|
105
|
+
authorize_url = (
|
|
106
|
+
_AUTHORIZE.format(tenant=tenant)
|
|
107
|
+
+ "?"
|
|
108
|
+
+ urlencode(
|
|
109
|
+
{
|
|
110
|
+
"client_id": OFFICEHOME,
|
|
111
|
+
"response_type": "code",
|
|
112
|
+
"redirect_uri": _REDIRECT,
|
|
113
|
+
"response_mode": "query",
|
|
114
|
+
"scope": _SCOPES,
|
|
115
|
+
"state": secrets.token_urlsafe(16),
|
|
116
|
+
"code_challenge": challenge,
|
|
117
|
+
"code_challenge_method": "S256",
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
res = login(
|
|
123
|
+
authorize_url,
|
|
124
|
+
username=username,
|
|
125
|
+
password=password,
|
|
126
|
+
totp_secret=totp_secret,
|
|
127
|
+
totp_code=totp_code,
|
|
128
|
+
code_verifier=verifier,
|
|
129
|
+
redirect_uri=_REDIRECT,
|
|
130
|
+
cookies=cookies,
|
|
131
|
+
**login_kwargs,
|
|
132
|
+
)
|
|
133
|
+
if not res.get("code"):
|
|
134
|
+
raise RuntimeError("No authorization code intercepted; keys: " + ", ".join(res))
|
|
135
|
+
|
|
136
|
+
tokens = _post_token(
|
|
137
|
+
_TOKEN.format(tenant=tenant),
|
|
138
|
+
{
|
|
139
|
+
"grant_type": "authorization_code",
|
|
140
|
+
"code": res["code"],
|
|
141
|
+
"client_id": OFFICEHOME,
|
|
142
|
+
"redirect_uri": _REDIRECT,
|
|
143
|
+
"code_verifier": verifier,
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
frt = tokens.get("refresh_token")
|
|
147
|
+
if not frt:
|
|
148
|
+
raise RuntimeError(
|
|
149
|
+
f"No refresh token from sign-in: {tokens.get('error_description') or tokens}"
|
|
150
|
+
)
|
|
151
|
+
return OutNote(_Broker(frt, tenant))
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Microsoft Graph client for mail, calendar, chats, channels, files,
|
|
2
|
+
SharePoint and OneNote.
|
|
3
|
+
|
|
4
|
+
Each method routes to the pre-consented first-party client whose token carries
|
|
5
|
+
the scope it needs (see `authenticate` / the README token map). Graph is just
|
|
6
|
+
GET + Bearer + JSON, so this leans on stdlib urllib.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from urllib.error import HTTPError
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
13
|
+
|
|
14
|
+
GRAPH = "https://graph.microsoft.com/v1.0"
|
|
15
|
+
_MAX_PAGES = 50 # ponytail: hard stop so follow-the-nextLink can't loop forever
|
|
16
|
+
|
|
17
|
+
# FOCI family clients, each pre-consented for the scopes that service needs.
|
|
18
|
+
C_OFFICE = "d3590ed6-52b3-4102-aeff-aad2292ab01c" # mail, calendar, chat, channels, files, teams, contacts, tasks
|
|
19
|
+
C_ONENOTE = "1fec8e78-bce4-4aaf-ab1b-5451cc387264" # OneNote (Notes.ReadWrite.All)
|
|
20
|
+
C_SITES = "4765445b-32c6-49b0-83e6-1d93765276ca" # SharePoint sites (Sites.Read.All)
|
|
21
|
+
|
|
22
|
+
# which client's token each unified-search entity type needs
|
|
23
|
+
_SEARCH_CLIENT = {
|
|
24
|
+
"message": C_OFFICE,
|
|
25
|
+
"event": C_OFFICE,
|
|
26
|
+
"driveItem": C_OFFICE,
|
|
27
|
+
"chatMessage": C_OFFICE,
|
|
28
|
+
"person": C_OFFICE,
|
|
29
|
+
"site": C_SITES,
|
|
30
|
+
"listItem": C_SITES,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GraphError(RuntimeError):
|
|
35
|
+
def __init__(self, status, message):
|
|
36
|
+
self.status = status
|
|
37
|
+
super().__init__(f"Graph {status}: {message}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OutNote:
|
|
41
|
+
def __init__(self, token, base=GRAPH):
|
|
42
|
+
"""`token` is either an access-token string (used for every call) or a
|
|
43
|
+
callable `client_id -> access_token` (the broker from `authenticate`,
|
|
44
|
+
which hands each service the right client's token)."""
|
|
45
|
+
if not token:
|
|
46
|
+
raise ValueError("token is required")
|
|
47
|
+
self._tp = token if callable(token) else (lambda _client: token)
|
|
48
|
+
self.token = token if isinstance(token, str) else None
|
|
49
|
+
self.base = base.rstrip("/")
|
|
50
|
+
|
|
51
|
+
# --- transport ----------------------------------------------------------
|
|
52
|
+
def _url(self, path, params=None):
|
|
53
|
+
url = path if path.startswith("http") else self.base + "/" + path.lstrip("/")
|
|
54
|
+
if params:
|
|
55
|
+
q = urlencode({k: v for k, v in params.items() if v is not None})
|
|
56
|
+
if q:
|
|
57
|
+
url += ("&" if "?" in url else "?") + q
|
|
58
|
+
return url
|
|
59
|
+
|
|
60
|
+
def _send(self, req, raw=False):
|
|
61
|
+
try:
|
|
62
|
+
with urlopen(req) as r: # noqa: S310 - fixed https host
|
|
63
|
+
body = r.read()
|
|
64
|
+
except HTTPError as e:
|
|
65
|
+
detail = e.read().decode("utf-8", "replace")
|
|
66
|
+
try:
|
|
67
|
+
detail = json.loads(detail)["error"]["message"]
|
|
68
|
+
except (ValueError, KeyError, TypeError):
|
|
69
|
+
pass
|
|
70
|
+
raise GraphError(e.code, detail) from None
|
|
71
|
+
return body.decode("utf-8") if raw else json.loads(body)
|
|
72
|
+
|
|
73
|
+
def get(self, path, params=None, raw=False, client=C_OFFICE):
|
|
74
|
+
"""One GET against `client`'s token. `raw=True` returns body text
|
|
75
|
+
(OneNote page content is HTML, not JSON)."""
|
|
76
|
+
req = Request(
|
|
77
|
+
self._url(path, params),
|
|
78
|
+
headers={"Authorization": f"Bearer {self._tp(client)}", "Accept": "application/json"},
|
|
79
|
+
)
|
|
80
|
+
return self._send(req, raw)
|
|
81
|
+
|
|
82
|
+
def post(self, path, body, client=C_OFFICE):
|
|
83
|
+
"""One POST of a JSON body against `client`'s token."""
|
|
84
|
+
req = Request(
|
|
85
|
+
self._url(path),
|
|
86
|
+
data=json.dumps(body).encode(),
|
|
87
|
+
headers={
|
|
88
|
+
"Authorization": f"Bearer {self._tp(client)}",
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
"Accept": "application/json",
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
return self._send(req)
|
|
94
|
+
|
|
95
|
+
def list(self, path, params=None, limit=None, client=C_OFFICE):
|
|
96
|
+
"""GET a collection, following @odata.nextLink; returns the combined
|
|
97
|
+
`value` list, capped at `limit` items (and _MAX_PAGES pages)."""
|
|
98
|
+
out = []
|
|
99
|
+
url = self._url(path, params)
|
|
100
|
+
for _ in range(_MAX_PAGES):
|
|
101
|
+
page = self.get(url, client=client)
|
|
102
|
+
out.extend(page.get("value", []))
|
|
103
|
+
if limit and len(out) >= limit:
|
|
104
|
+
return out[:limit]
|
|
105
|
+
url = page.get("@odata.nextLink")
|
|
106
|
+
if not url:
|
|
107
|
+
break
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
# --- mail ---------------------------------------------------------------
|
|
111
|
+
def emails(self, folder="inbox", top=25, search=None, select=None):
|
|
112
|
+
params = {"$top": top, "$select": select}
|
|
113
|
+
if search:
|
|
114
|
+
params["$search"] = f'"{search}"'
|
|
115
|
+
else:
|
|
116
|
+
params["$orderby"] = "receivedDateTime desc"
|
|
117
|
+
return self.list(f"me/mailFolders/{folder}/messages", params, limit=top)
|
|
118
|
+
|
|
119
|
+
def email(self, message_id):
|
|
120
|
+
return self.get(f"me/messages/{message_id}")
|
|
121
|
+
|
|
122
|
+
# --- calendar -----------------------------------------------------------
|
|
123
|
+
def events(self, start=None, end=None, top=25):
|
|
124
|
+
if start and end:
|
|
125
|
+
params = {
|
|
126
|
+
"startDateTime": start,
|
|
127
|
+
"endDateTime": end,
|
|
128
|
+
"$top": top,
|
|
129
|
+
"$orderby": "start/dateTime",
|
|
130
|
+
}
|
|
131
|
+
return self.list("me/calendarView", params, limit=top)
|
|
132
|
+
return self.list("me/events", {"$top": top, "$orderby": "start/dateTime"}, limit=top)
|
|
133
|
+
|
|
134
|
+
# --- teams: chats -------------------------------------------------------
|
|
135
|
+
def chats(self, top=25):
|
|
136
|
+
"""1:1 and group chats."""
|
|
137
|
+
return self.list("me/chats", {"$top": top}, limit=top)
|
|
138
|
+
|
|
139
|
+
def chat_messages(self, chat_id, top=30):
|
|
140
|
+
return self.list(f"me/chats/{chat_id}/messages", {"$top": top}, limit=top)
|
|
141
|
+
|
|
142
|
+
# --- teams: teams & channels --------------------------------------------
|
|
143
|
+
def teams(self):
|
|
144
|
+
return self.list("me/joinedTeams")
|
|
145
|
+
|
|
146
|
+
def channels(self, team_id):
|
|
147
|
+
return self.list(f"teams/{team_id}/channels")
|
|
148
|
+
|
|
149
|
+
def channel_messages(self, team_id, channel_id, top=30):
|
|
150
|
+
return self.list(
|
|
151
|
+
f"teams/{team_id}/channels/{channel_id}/messages", {"$top": top}, limit=top
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def channel_files(self, team_id, channel_id):
|
|
155
|
+
"""Files posted in a channel (the channel's SharePoint folder)."""
|
|
156
|
+
ff = self.get(f"teams/{team_id}/channels/{channel_id}/filesFolder")
|
|
157
|
+
drive, item = ff["parentReference"]["driveId"], ff["id"]
|
|
158
|
+
return self.list(f"drives/{drive}/items/{item}/children")
|
|
159
|
+
|
|
160
|
+
# --- files / onedrive / sharepoint --------------------------------------
|
|
161
|
+
def files(self, path="root", top=100):
|
|
162
|
+
"""OneDrive items. `path` is 'root' or 'root:/Folder/Sub:'."""
|
|
163
|
+
return self.list(f"me/drive/{path}/children", {"$top": top}, limit=top)
|
|
164
|
+
|
|
165
|
+
def download_url(self, item_id, drive_id=None):
|
|
166
|
+
"""A direct, pre-authed download URL for a drive item (works for OneDrive
|
|
167
|
+
and channel files — pass the item's drive_id for the latter)."""
|
|
168
|
+
base = f"drives/{drive_id}/items/{item_id}" if drive_id else f"me/drive/items/{item_id}"
|
|
169
|
+
return self.get(base).get("@microsoft.graph.downloadUrl")
|
|
170
|
+
|
|
171
|
+
def sites(self, search="*"):
|
|
172
|
+
return self.list(f"sites?search={search}", client=C_SITES)
|
|
173
|
+
|
|
174
|
+
def site_drive_items(self, site_id, path="root", top=100):
|
|
175
|
+
return self.list(
|
|
176
|
+
f"sites/{site_id}/drive/{path}/children", {"$top": top}, limit=top, client=C_SITES
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# --- onenote ------------------------------------------------------------
|
|
180
|
+
def notebooks(self):
|
|
181
|
+
return self.list("me/onenote/notebooks", client=C_ONENOTE)
|
|
182
|
+
|
|
183
|
+
def sections(self, notebook_id=None):
|
|
184
|
+
path = (
|
|
185
|
+
f"me/onenote/notebooks/{notebook_id}/sections" if notebook_id else "me/onenote/sections"
|
|
186
|
+
)
|
|
187
|
+
return self.list(path, client=C_ONENOTE)
|
|
188
|
+
|
|
189
|
+
def pages(self, section_id, top=50):
|
|
190
|
+
"""Pages in a section. (List per-section — the all-pages endpoint is
|
|
191
|
+
flaky.)"""
|
|
192
|
+
return self.list(
|
|
193
|
+
f"me/onenote/sections/{section_id}/pages", {"$top": top}, limit=top, client=C_ONENOTE
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def page_content(self, page_id):
|
|
197
|
+
"""A OneNote page's body as HTML (string)."""
|
|
198
|
+
return self.get(f"me/onenote/pages/{page_id}/content", raw=True, client=C_ONENOTE)
|
|
199
|
+
|
|
200
|
+
# --- people / tasks -----------------------------------------------------
|
|
201
|
+
def contacts(self, top=50):
|
|
202
|
+
return self.list("me/contacts", {"$top": top}, limit=top)
|
|
203
|
+
|
|
204
|
+
def task_lists(self):
|
|
205
|
+
return self.list("me/todo/lists")
|
|
206
|
+
|
|
207
|
+
def tasks(self, list_id, top=50):
|
|
208
|
+
return self.list(f"me/todo/lists/{list_id}/tasks", {"$top": top}, limit=top)
|
|
209
|
+
|
|
210
|
+
# --- search (unified Graph /search/query) -------------------------------
|
|
211
|
+
def search(self, query, entity="message", top=25):
|
|
212
|
+
"""Search one entity type across the account and return the matched
|
|
213
|
+
resources. `entity` is one of: 'message' (email), 'driveItem' (files),
|
|
214
|
+
'chatMessage' (Teams chat/channel messages), 'event' (calendar),
|
|
215
|
+
'person', 'site', 'listItem'. (Graph only allows one of these per
|
|
216
|
+
request, so search one kind at a time.)"""
|
|
217
|
+
client = _SEARCH_CLIENT.get(entity, C_OFFICE)
|
|
218
|
+
body = {
|
|
219
|
+
"requests": [{"entityTypes": [entity], "query": {"queryString": query}, "size": top}]
|
|
220
|
+
}
|
|
221
|
+
data = self.post("search/query", body, client=client)
|
|
222
|
+
containers = (data.get("value") or [{}])[0].get("hitsContainers") or []
|
|
223
|
+
hits = containers[0].get("hits", []) if containers else []
|
|
224
|
+
return [h["resource"] for h in hits]
|
|
225
|
+
|
|
226
|
+
def search_emails(self, query, top=25):
|
|
227
|
+
return self.search(query, "message", top)
|
|
228
|
+
|
|
229
|
+
def search_files(self, query, top=25):
|
|
230
|
+
return self.search(query, "driveItem", top)
|
|
231
|
+
|
|
232
|
+
def search_messages(self, query, top=25):
|
|
233
|
+
"""Search Teams chat and channel messages."""
|
|
234
|
+
return self.search(query, "chatMessage", top)
|
|
235
|
+
|
|
236
|
+
def search_people(self, query, top=25):
|
|
237
|
+
return self.search(query, "person", top)
|
|
238
|
+
|
|
239
|
+
def me(self):
|
|
240
|
+
return self.get("me")
|