commit b777ac65a1752cb6572533e8f03df85c64905e87 Author: Parlomachine Date: Mon Mar 23 20:04:29 2026 +0000 Initial microsoft-graph skill - Outlook and OneDrive via Graph API diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..a5aa8fe --- /dev/null +++ b/SKILL.md @@ -0,0 +1,81 @@ +--- +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. +--- + +# Microsoft Graph Skill + +Microsoft Graph API client for Mikkel's personal Microsoft account (mikpar@outlook.dk). + +## Quick Start + +```bash +# Authenticate (first time / token expired) +python3 scripts/graph_client.py auth + +# List recent emails +python3 scripts/graph_client.py mail list --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" + +# List OneDrive root +python3 scripts/graph_client.py drive list + +# 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 +``` + +## Credentials + +- **Account:** mikpar@outlook.dk +- **Client ID:** 96861287-203e-4d84-ad3b-379d63ba5eb2 +- **Tenant ID:** 3a22994f-512f-447e-b8fa-19bbc849bd0c +- **Client Secret:** kiB8Q~TmhWDKHRdLqBi_RqRIIAjTkljyL6xE0aHU + +## Authentication + +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` + +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` + +## References + +- [Graph API Docs](references/graph-api.md) diff --git a/references/graph-api.md b/references/graph-api.md new file mode 100644 index 0000000..ce670b9 --- /dev/null +++ b/references/graph-api.md @@ -0,0 +1,121 @@ +# Microsoft Graph API Reference + +## Base URL +`https://graph.microsoft.com/v1.0` + +## Authentication + +Uses OAuth 2.0 device code flow for personal Microsoft accounts. + +### 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 messages +``` +GET /me/mailFolders/inbox/messages?$top=10&$orderby=receivedDateTime desc +``` + +### Read message +``` +GET /me/messages/{id} +``` + +### Send message +``` +POST /me/sendMail +{ + "message": { + "subject": "Subject", + "body": {"contentType": "Text", "content": "Body"}, + "toRecipients": [{"emailAddress": {"address": "to@example.com"}}] + } +} +``` + +### Search messages +``` +GET /me/messages?$search="from:sender@example.com" +``` + +### List folders +``` +GET /me/mailFolders +``` + +## OneDrive API + +### List root +``` +GET /me/drive/root/children +``` + +### List folder +``` +GET /me/drive/root:/{path}:/children +``` + +### Get file metadata +``` +GET /me/drive/root:/{path} +``` + +### Download file +Get the `@microsoft.graph.downloadUrl` from file metadata. + +### Upload small file (<4MB) +``` +PUT /me/drive/root:/{path}:/content +Content-Type: application/octet-stream + + +``` + +### Create folder +``` +POST /me/drive/root/children +{ + "name": "New Folder", + "folder": {} +} +``` + +## 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 + +## Rate Limits + +- Per-app: ~10,000 requests / 10 minutes +- Per-mailbox: ~10,000 requests / 10 minutes +- Throttled responses include `Retry-After` header diff --git a/scripts/graph_client.py b/scripts/graph_client.py new file mode 100755 index 0000000..4460db0 --- /dev/null +++ b/scripts/graph_client.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +""" +Microsoft Graph API Client for Mikkel (mikpar@outlook.dk) + +Usage: + python3 graph_client.py auth # Authenticate + python3 graph_client.py mail list --limit 10 # List emails + python3 graph_client.py mail read --id # Read email + python3 graph_client.py mail send --to x@y.com --subject "Hi" --body "Hello" + python3 graph_client.py drive list # List OneDrive root + python3 graph_client.py drive list --path "Documents" # List folder + python3 graph_client.py drive download --path "file.pdf" --output ./file.pdf +""" + +import argparse +import json +import os +import sys +import time +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode + +# Configuration +CLIENT_ID = "96861287-203e-4d84-ad3b-379d63ba5eb2" +TENANT_ID = "3a22994f-512f-447e-b8fa-19bbc849bd0c" +CLIENT_SECRET = "kiB8Q~TmhWDKHRdLqBi_RqRIIAjTkljyL6xE0aHU" +SCOPES = "Mail.Read Mail.Send Files.ReadWrite User.Read offline_access" + +# Use 'consumers' endpoint for personal Microsoft accounts +AUTH_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0" +GRAPH_URL = "https://graph.microsoft.com/v1.0" + +TOKEN_FILE = os.path.expanduser("~/.config/microsoft-graph/token.json") + + +def save_token(token_data): + """Save token to file.""" + os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True) + token_data["saved_at"] = int(time.time()) + with open(TOKEN_FILE, "w") as f: + json.dump(token_data, f, indent=2) + + +def load_token(): + """Load token from file.""" + if not os.path.exists(TOKEN_FILE): + return None + with open(TOKEN_FILE, "r") as f: + return json.load(f) + + +def refresh_token(refresh_token_str): + """Refresh the access token.""" + data = urlencode({ + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "refresh_token": refresh_token_str, + "grant_type": "refresh_token", + "scope": SCOPES, + }).encode() + + req = Request(f"{AUTH_URL}/token", data=data, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + + try: + with urlopen(req, timeout=30) as resp: + token_data = json.loads(resp.read().decode()) + save_token(token_data) + return token_data + except HTTPError as e: + print(f"Token refresh failed: {e.code}") + return None + + +def get_access_token(): + """Get valid access token, refreshing if needed.""" + token_data = load_token() + if not token_data: + print("No token found. Run 'auth' command first.") + sys.exit(1) + + # Check if token is expired (with 5 min buffer) + saved_at = token_data.get("saved_at", 0) + expires_in = token_data.get("expires_in", 3600) + if time.time() > saved_at + expires_in - 300: + print("Token expired, refreshing...") + token_data = refresh_token(token_data.get("refresh_token")) + if not token_data: + print("Token refresh failed. Run 'auth' command.") + sys.exit(1) + + return token_data["access_token"] + + +def device_code_auth(): + """Authenticate using device code flow.""" + # Request device code + data = urlencode({ + "client_id": CLIENT_ID, + "scope": SCOPES, + }).encode() + + req = Request(f"{AUTH_URL}/devicecode", data=data, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + + try: + with urlopen(req, timeout=30) as resp: + device_data = json.loads(resp.read().decode()) + except HTTPError as e: + return {"error": f"Device code request failed: {e.code}"} + + print("\n" + "=" * 50) + print("šŸ“± AUTHENTICATION REQUIRED") + print("=" * 50) + print(f"\n1. Open: {device_data['verification_uri']}") + print(f"2. Enter code: {device_data['user_code']}") + print(f"3. Sign in with mikpar@outlook.dk") + print("\nWaiting for authorization...") + + # Poll for token + interval = device_data.get("interval", 5) + expires_in = device_data.get("expires_in", 900) + start_time = time.time() + + while time.time() - start_time < expires_in: + time.sleep(interval) + + poll_data = urlencode({ + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "device_code": device_data["device_code"], + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }).encode() + + poll_req = Request(f"{AUTH_URL}/token", data=poll_data, method="POST") + poll_req.add_header("Content-Type", "application/x-www-form-urlencoded") + + try: + with urlopen(poll_req, timeout=30) as resp: + token_data = json.loads(resp.read().decode()) + save_token(token_data) + print("\nāœ… Authentication successful! Token saved.") + return {"status": "authenticated"} + except HTTPError as e: + error_body = json.loads(e.read().decode()) + error_code = error_body.get("error") + if error_code == "authorization_pending": + continue + elif error_code == "slow_down": + interval += 5 + continue + else: + return {"error": f"Auth failed: {error_body.get('error_description')}"} + + return {"error": "Authentication timed out"} + + +def graph_request(endpoint, method="GET", data=None, headers=None): + """Make a Graph API request.""" + token = get_access_token() + url = f"{GRAPH_URL}{endpoint}" + + req_headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + if headers: + req_headers.update(headers) + + req = Request(url, method=method, headers=req_headers) + if data: + req.data = json.dumps(data).encode() + + try: + with urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + except HTTPError as e: + error_body = e.read().decode() + try: + return {"error": json.loads(error_body)} + except: + return {"error": f"HTTP {e.code}: {error_body[:200]}"} + + +def list_mail(limit=10, folder="inbox"): + """List emails.""" + endpoint = f"/me/mailFolders/{folder}/messages?$top={limit}&$orderby=receivedDateTime desc" + endpoint += "&$select=id,subject,from,receivedDateTime,isRead,bodyPreview" + result = graph_request(endpoint) + + if "error" in result: + return result + + messages = [] + for msg in result.get("value", []): + messages.append({ + "id": msg["id"], + "subject": msg.get("subject", "(no subject)"), + "from": msg.get("from", {}).get("emailAddress", {}).get("address", "unknown"), + "date": msg.get("receivedDateTime"), + "read": msg.get("isRead", False), + "preview": msg.get("bodyPreview", "")[:100] + }) + + return {"count": len(messages), "messages": messages} + + +def read_mail(msg_id): + """Read a specific email.""" + endpoint = f"/me/messages/{msg_id}" + result = graph_request(endpoint) + + if "error" in result: + return result + + return { + "id": result["id"], + "subject": result.get("subject"), + "from": result.get("from", {}).get("emailAddress", {}), + "to": [r.get("emailAddress", {}) for r in result.get("toRecipients", [])], + "date": result.get("receivedDateTime"), + "body": result.get("body", {}).get("content", "")[:5000] + } + + +def send_mail(to, subject, body, cc=None): + """Send an email.""" + message = { + "message": { + "subject": subject, + "body": { + "contentType": "Text", + "content": body + }, + "toRecipients": [{"emailAddress": {"address": to}}] + } + } + + if cc: + message["message"]["ccRecipients"] = [{"emailAddress": {"address": cc}}] + + result = graph_request("/me/sendMail", method="POST", data=message) + + if result is None: + return {"status": "sent", "to": to, "subject": subject} + return result + + +def list_drive(path=None): + """List OneDrive contents.""" + if path: + endpoint = f"/me/drive/root:/{path}:/children" + else: + endpoint = "/me/drive/root/children" + + endpoint += "?$select=id,name,size,folder,file,lastModifiedDateTime" + result = graph_request(endpoint) + + if "error" in result: + return result + + items = [] + for item in result.get("value", []): + items.append({ + "id": item["id"], + "name": item["name"], + "type": "folder" if "folder" in item else "file", + "size": item.get("size"), + "modified": item.get("lastModifiedDateTime") + }) + + return {"count": len(items), "items": items} + + +def download_file(path, output): + """Download a file from OneDrive.""" + # Get download URL + endpoint = f"/me/drive/root:/{path}" + result = graph_request(endpoint) + + if "error" in result: + return result + + download_url = result.get("@microsoft.graph.downloadUrl") + if not download_url: + return {"error": "No download URL available"} + + # Download file + try: + req = Request(download_url) + with urlopen(req, timeout=60) as resp: + with open(output, "wb") as f: + f.write(resp.read()) + return {"status": "downloaded", "path": path, "output": output, "size": os.path.getsize(output)} + except Exception as e: + return {"error": str(e)} + + +def main(): + parser = argparse.ArgumentParser(description="Microsoft Graph API Client") + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # Auth + subparsers.add_parser("auth", help="Authenticate with device code flow") + + # Mail commands + mail_parser = subparsers.add_parser("mail", help="Email operations") + mail_sub = mail_parser.add_subparsers(dest="mail_cmd") + + mail_list = mail_sub.add_parser("list", help="List emails") + mail_list.add_argument("--limit", type=int, default=10) + mail_list.add_argument("--folder", default="inbox") + + mail_read = mail_sub.add_parser("read", help="Read email") + mail_read.add_argument("--id", required=True) + + mail_send = mail_sub.add_parser("send", help="Send email") + mail_send.add_argument("--to", required=True) + mail_send.add_argument("--subject", required=True) + mail_send.add_argument("--body", required=True) + mail_send.add_argument("--cc") + + # Drive commands + drive_parser = subparsers.add_parser("drive", help="OneDrive operations") + drive_sub = drive_parser.add_subparsers(dest="drive_cmd") + + drive_list = drive_sub.add_parser("list", help="List files/folders") + drive_list.add_argument("--path") + + drive_dl = drive_sub.add_parser("download", help="Download file") + drive_dl.add_argument("--path", required=True) + drive_dl.add_argument("--output", required=True) + + args = parser.parse_args() + + if args.command == "auth": + result = device_code_auth() + elif args.command == "mail": + if args.mail_cmd == "list": + result = list_mail(args.limit, args.folder) + elif args.mail_cmd == "read": + result = read_mail(args.id) + elif args.mail_cmd == "send": + result = send_mail(args.to, args.subject, args.body, args.cc) + else: + mail_parser.print_help() + sys.exit(1) + elif args.command == "drive": + if args.drive_cmd == "list": + result = list_drive(args.path) + elif args.drive_cmd == "download": + result = download_file(args.path, args.output) + else: + drive_parser.print_help() + sys.exit(1) + else: + parser.print_help() + sys.exit(1) + + print(json.dumps(result, indent=2, ensure_ascii=False)) + + +if __name__ == "__main__": + main()