diff --git a/SKILL.md b/SKILL.md index a5aa8fe..95afd24 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,81 +1,77 @@ --- name: microsoft-graph -description: Microsoft Graph API access for Mikkel's Outlook and OneDrive (mikpar@outlook.dk). Use for email, calendar, and file operations via Graph API. Triggers on Outlook, OneDrive, or Microsoft 365 tasks. +description: Microsoft Graph API access for Mikkel's Outlook (mikpar@outlook.dk) and OneDrive. Use for reading/writing emails, calendar, and files. Triggers on Outlook, OneDrive, Microsoft 365 tasks. --- # Microsoft Graph Skill -Microsoft Graph API client for Mikkel's personal Microsoft account (mikpar@outlook.dk). +Access to Mikkel's Microsoft 365 account (mikpar@outlook.dk) via Graph API. + +## Capabilities + +- šŸ“§ **Mail** — Read, send, organize emails +- šŸ“ **OneDrive** — Read/write files +- šŸ“… **Calendar** — Read events (if permitted) +- šŸ‘¤ **Profile** — User info + +## Authentication + +Uses OAuth 2.0 device code flow with delegated permissions. + +**Credentials:** +- **Account:** mikpar@outlook.dk +- **Client ID:** 14d82eec-204b-4c2f-b7e8-296a70dab67e +- **Tenant:** consumers +- **Token file:** `~/.config/microsoft-graph-tokens.json` ## Quick Start ```bash -# Authenticate (first time / token expired) -python3 scripts/graph_client.py auth +# List mail folders +python3 ~/.openclaw/skills/microsoft-graph/scripts/graph_api.py folders -# List recent emails -python3 scripts/graph_client.py mail list --limit 10 +# List emails in Inbox +python3 ~/.openclaw/skills/microsoft-graph/scripts/graph_api.py emails --folder Indbakke --limit 10 -# Read specific email -python3 scripts/graph_client.py mail read --id - -# Send email -python3 scripts/graph_client.py mail send --to "to@example.com" --subject "Test" --body "Hello" +# Get email by ID +python3 ~/.openclaw/skills/microsoft-graph/scripts/graph_api.py email --id # List OneDrive root -python3 scripts/graph_client.py drive list +python3 ~/.openclaw/skills/microsoft-graph/scripts/graph_api.py onedrive -# List folder contents -python3 scripts/graph_client.py drive list --path "Documents/Parlobyg" - -# Download file -python3 scripts/graph_client.py drive download --path "file.pdf" --output ./file.pdf +# Refresh token (when expired) +python3 ~/.openclaw/skills/microsoft-graph/scripts/graph_api.py refresh ``` -## Credentials +## Token Refresh -- **Account:** mikpar@outlook.dk -- **Client ID:** 96861287-203e-4d84-ad3b-379d63ba5eb2 -- **Tenant ID:** 3a22994f-512f-447e-b8fa-19bbc849bd0c -- **Client Secret:** kiB8Q~TmhWDKHRdLqBi_RqRIIAjTkljyL6xE0aHU +Tokens expire after ~1 hour. Use refresh command or re-authenticate: -## Authentication +```bash +python3 ~/.openclaw/skills/microsoft-graph/scripts/auth.py +``` -Uses **device code flow** for personal Microsoft accounts: -1. Run `python3 scripts/graph_client.py auth` -2. Open the URL shown (https://microsoft.com/devicelogin) -3. Enter the code displayed -4. Sign in with mikpar@outlook.dk -5. Token is saved to `~/.config/microsoft-graph/token.json` +## Folder Structure (mikpar@outlook.dk) -Token auto-refreshes. Re-auth only needed if refresh token expires (~90 days). - -## Capabilities - -### Outlook Email -- šŸ“§ List/search inbox -- šŸ“– Read messages -- āœ‰ļø Send emails -- šŸ“ Create drafts -- šŸ“ Manage folders - -### OneDrive -- šŸ“‚ Browse folders -- šŸ” Search files -- ā¬‡ļø Download files -- ā¬†ļø Upload files -- šŸ”— Create sharing links - -### Calendar (if needed) -- šŸ“… List events -- āž• Create events -- šŸ”” Check availability - -## API Reference - -- Base URL: `https://graph.microsoft.com/v1.0` -- Scopes: `Mail.Read Mail.Send Files.ReadWrite User.Read offline_access` +``` +šŸ“„ Indbakke +ā”œā”€ Aftaler og bookinger (99) +ā”œā”€ CHRISTIAN - ADMINISTRATION (1915) +ā”œā”€ JOAKIM - PROJEKT (3) +ā”œā”€ MESTER MANUALEN (8) +ā”œā”€ MICHELLE - SUPPORT (2813) +ā”œā”€ Online Handel (1022) +ā”œā”€ ERHVERV +│ ā”œā”€ PARLO BYG ApS +│ └─ Parlo Holding ApS +ā”œā”€ ROUND TABLE +│ ā”œā”€ Frederik Stenild +│ └─ Lasse Olsen +└─ PRIVAT (+ subfolders) +``` ## References -- [Graph API Docs](references/graph-api.md) +- [Graph API Reference](references/graph-api.md) +- [Mail API](https://learn.microsoft.com/en-us/graph/api/resources/mail-api-overview) +- [OneDrive API](https://learn.microsoft.com/en-us/graph/api/resources/onedrive) diff --git a/references/graph-api.md b/references/graph-api.md index ce670b9..b87ccc8 100644 --- a/references/graph-api.md +++ b/references/graph-api.md @@ -1,31 +1,31 @@ # Microsoft Graph API Reference ## Base URL -`https://graph.microsoft.com/v1.0` +``` +https://graph.microsoft.com/v1.0 +``` ## Authentication +Bearer token in Authorization header: +``` +Authorization: Bearer {access_token} +``` -Uses OAuth 2.0 device code flow for personal Microsoft accounts. +## Mail Endpoints -### Endpoints -- Device code: `https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode` -- Token: `https://login.microsoftonline.com/consumers/oauth2/v2.0/token` - -### Scopes -- `Mail.Read` — Read emails -- `Mail.Send` — Send emails -- `Files.ReadWrite` — OneDrive access -- `User.Read` — User profile -- `offline_access` — Refresh tokens - -## Mail API +### List folders +``` +GET /me/mailFolders +GET /me/mailFolders/{id}/childFolders +``` ### List messages ``` -GET /me/mailFolders/inbox/messages?$top=10&$orderby=receivedDateTime desc +GET /me/mailFolders/{folder_id}/messages +GET /me/messages?$filter=... ``` -### Read message +### Get message ``` GET /me/messages/{id} ``` @@ -35,24 +35,26 @@ GET /me/messages/{id} POST /me/sendMail { "message": { - "subject": "Subject", - "body": {"contentType": "Text", "content": "Body"}, - "toRecipients": [{"emailAddress": {"address": "to@example.com"}}] + "subject": "...", + "body": {"contentType": "Text", "content": "..."}, + "toRecipients": [{"emailAddress": {"address": "..."}}] } } ``` -### Search messages +### Move message ``` -GET /me/messages?$search="from:sender@example.com" +POST /me/messages/{id}/move +{"destinationId": "{folder_id}"} ``` -### List folders +### Copy message ``` -GET /me/mailFolders +POST /me/messages/{id}/copy +{"destinationId": "{folder_id}"} ``` -## OneDrive API +## OneDrive Endpoints ### List root ``` @@ -64,58 +66,25 @@ GET /me/drive/root/children GET /me/drive/root:/{path}:/children ``` -### Get file metadata +### Get file content ``` -GET /me/drive/root:/{path} +GET /me/drive/items/{item_id}/content ``` -### Download file -Get the `@microsoft.graph.downloadUrl` from file metadata. - -### Upload small file (<4MB) +### Upload file ``` PUT /me/drive/root:/{path}:/content -Content-Type: application/octet-stream - - ``` -### Create folder -``` -POST /me/drive/root/children -{ - "name": "New Folder", - "folder": {} -} -``` +## Useful Query Parameters -## Calendar API - -### List events -``` -GET /me/events?$top=10&$orderby=start/dateTime -``` - -### Create event -``` -POST /me/events -{ - "subject": "Meeting", - "start": {"dateTime": "2026-03-24T10:00:00", "timeZone": "Europe/Copenhagen"}, - "end": {"dateTime": "2026-03-24T11:00:00", "timeZone": "Europe/Copenhagen"} -} -``` - -## Error Handling - -Common errors: -- `401 Unauthorized` — Token expired, need to refresh or re-auth -- `403 Forbidden` — Missing permissions -- `404 NotFound` — Resource doesn't exist -- `429 TooManyRequests` — Rate limited, back off +- `$top=N` - Limit results +- `$skip=N` - Pagination offset +- `$select=field1,field2` - Select specific fields +- `$filter=field eq 'value'` - Filter results +- `$orderby=field desc` - Sort results ## Rate Limits -- Per-app: ~10,000 requests / 10 minutes -- Per-mailbox: ~10,000 requests / 10 minutes -- Throttled responses include `Retry-After` header +- 10,000 requests per 10 minutes per app +- Throttled requests return 429 Too Many Requests diff --git a/scripts/auth.py b/scripts/auth.py new file mode 100755 index 0000000..6ab3881 --- /dev/null +++ b/scripts/auth.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Microsoft Graph OAuth authentication (device code flow).""" + +import msal +import json +import os + +TOKEN_FILE = os.path.expanduser("~/.config/microsoft-graph-tokens.json") +CLIENT_ID = "14d82eec-204b-4c2f-b7e8-296a70dab67e" +SCOPES = [ + "https://graph.microsoft.com/Mail.Read", + "https://graph.microsoft.com/Mail.ReadWrite", + "https://graph.microsoft.com/Mail.Send", + "https://graph.microsoft.com/Files.Read", + "https://graph.microsoft.com/Files.ReadWrite", + "https://graph.microsoft.com/User.Read" +] + +def main(): + app = msal.PublicClientApplication(CLIENT_ID, authority="https://login.microsoftonline.com/consumers") + + flow = app.initiate_device_flow(scopes=SCOPES) + + if "user_code" not in flow: + print(f"āŒ Failed to create device flow: {flow}") + return + + print("\nšŸ” Microsoft Sign-In Required\n") + print(f"1. Go to: {flow['verification_uri']}") + print(f"2. Enter code: {flow['user_code']}") + print("3. Sign in with your Microsoft account\n") + print("Waiting for authorization...") + + result = app.acquire_token_by_device_flow(flow) + + if "access_token" in result: + tokens = { + "access_token": result["access_token"], + "refresh_token": result.get("refresh_token"), + "id_token": result.get("id_token"), + "expires_in": result.get("expires_in"), + "client_id": CLIENT_ID, + "scopes": SCOPES, + "account": result.get("id_token_claims", {}).get("preferred_username", "unknown") + } + + os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True) + with open(TOKEN_FILE, "w") as f: + json.dump(tokens, f, indent=2) + + print(f"\nāœ… Authorization successful!") + print(f"Account: {tokens['account']}") + print(f"Token saved to: {TOKEN_FILE}") + else: + print(f"\nāŒ Error: {result.get('error')}") + print(f"Description: {result.get('error_description')}") + +if __name__ == "__main__": + main() diff --git a/scripts/graph_api.py b/scripts/graph_api.py new file mode 100755 index 0000000..47e81f2 --- /dev/null +++ b/scripts/graph_api.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Microsoft Graph API client for Outlook and OneDrive.""" + +import argparse +import json +import os +import sys +import requests + +TOKEN_FILE = os.path.expanduser("~/.config/microsoft-graph-tokens.json") +GRAPH_BASE = "https://graph.microsoft.com/v1.0" + +def load_tokens(): + """Load access tokens from file.""" + if not os.path.exists(TOKEN_FILE): + print(f"āŒ Token file not found: {TOKEN_FILE}") + print("Run: python3 ~/.openclaw/skills/microsoft-graph/scripts/auth.py") + sys.exit(1) + with open(TOKEN_FILE, "r") as f: + return json.load(f) + +def get_headers(): + """Get authorization headers.""" + tokens = load_tokens() + return {"Authorization": f"Bearer {tokens['access_token']}"} + +def api_get(endpoint, params=None): + """Make GET request to Graph API.""" + url = f"{GRAPH_BASE}{endpoint}" + response = requests.get(url, headers=get_headers(), params=params) + if response.status_code == 401: + print("āŒ Token expired. Run: python3 ~/.openclaw/skills/microsoft-graph/scripts/graph_api.py refresh") + sys.exit(1) + return response.json() + +def list_folders(parent_id=None): + """List mail folders.""" + if parent_id: + data = api_get(f"/me/mailFolders/{parent_id}/childFolders", {"$top": 100}) + else: + data = api_get("/me/mailFolders", {"$top": 100}) + + folders = data.get("value", []) + for f in folders: + name = f["displayName"] + count = f["totalItemCount"] + children = f["childFolderCount"] + print(f"šŸ“ {name} ({count} items, {children} subfolders) [id: {f['id'][:20]}...]") + return folders + +def list_emails(folder_name="Inbox", limit=10): + """List emails in a folder.""" + # Get folder ID + data = api_get("/me/mailFolders", {"$top": 100}) + folder_id = None + for f in data.get("value", []): + if f["displayName"].lower() == folder_name.lower(): + folder_id = f["id"] + break + + if not folder_id: + print(f"āŒ Folder not found: {folder_name}") + return + + # Get messages + messages = api_get(f"/me/mailFolders/{folder_id}/messages", { + "$top": limit, + "$select": "id,subject,from,receivedDateTime,isRead", + "$orderby": "receivedDateTime desc" + }) + + for msg in messages.get("value", []): + read = "āœ“" if msg["isRead"] else "ā—‹" + sender = msg.get("from", {}).get("emailAddress", {}).get("address", "?") + date = msg["receivedDateTime"][:10] + subject = msg.get("subject", "(no subject)")[:50] + print(f"{read} [{date}] {sender}: {subject}") + + return messages.get("value", []) + +def get_email(message_id): + """Get full email content.""" + msg = api_get(f"/me/messages/{message_id}") + print(f"Subject: {msg.get('subject')}") + print(f"From: {msg.get('from', {}).get('emailAddress', {}).get('address')}") + print(f"Date: {msg.get('receivedDateTime')}") + print(f"\n{msg.get('body', {}).get('content', '')[:2000]}") + return msg + +def list_onedrive(path="/"): + """List OneDrive files.""" + if path == "/": + data = api_get("/me/drive/root/children", {"$top": 50}) + else: + data = api_get(f"/me/drive/root:/{path}:/children", {"$top": 50}) + + items = data.get("value", []) + for item in items: + icon = "šŸ“" if "folder" in item else "šŸ“„" + size = item.get("size", 0) + name = item["name"] + print(f"{icon} {name} ({size} bytes)") + return items + +def refresh_token(): + """Refresh the access token.""" + import msal + + tokens = load_tokens() + client_id = tokens.get("client_id", "14d82eec-204b-4c2f-b7e8-296a70dab67e") + + app = msal.PublicClientApplication(client_id, authority="https://login.microsoftonline.com/consumers") + + # Try to refresh + accounts = app.get_accounts() + if accounts: + result = app.acquire_token_silent(tokens.get("scopes", []), account=accounts[0]) + if result and "access_token" in result: + tokens["access_token"] = result["access_token"] + with open(TOKEN_FILE, "w") as f: + json.dump(tokens, f, indent=2) + print("āœ… Token refreshed!") + return + + # Need re-auth + print("āŒ Cannot refresh. Please re-authenticate:") + print("python3 ~/.openclaw/skills/microsoft-graph/scripts/auth.py") + +def main(): + parser = argparse.ArgumentParser(description="Microsoft Graph API client") + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # folders + folders_p = subparsers.add_parser("folders", help="List mail folders") + folders_p.add_argument("--parent", help="Parent folder ID") + + # emails + emails_p = subparsers.add_parser("emails", help="List emails") + emails_p.add_argument("--folder", default="Indbakke", help="Folder name") + emails_p.add_argument("--limit", type=int, default=10, help="Number of emails") + + # email + email_p = subparsers.add_parser("email", help="Get email content") + email_p.add_argument("--id", required=True, help="Message ID") + + # onedrive + od_p = subparsers.add_parser("onedrive", help="List OneDrive files") + od_p.add_argument("--path", default="/", help="Path") + + # refresh + subparsers.add_parser("refresh", help="Refresh access token") + + args = parser.parse_args() + + if args.command == "folders": + list_folders(args.parent) + elif args.command == "emails": + list_emails(args.folder, args.limit) + elif args.command == "email": + get_email(args.id) + elif args.command == "onedrive": + list_onedrive(args.path) + elif args.command == "refresh": + refresh_token() + else: + parser.print_help() + +if __name__ == "__main__": + main()