Skill: jrskerrett/openclaw-msgraph (version 1.0.0)

--- name: msgraph description: Read and manage Microsoft Outlook email (inbox, folders, move messages) and Outlook calendar (list events, create events) via Microsoft Graph API. Use when the user asks to check email, read messages, move emails to folders, see upcoming calendar events, or create calendar events in Outlook/Microsoft 365 personal accounts. Also use for anything involving the user's Outlook inbox or Microsoft calendar. --- # Microsoft Graph Skill Integrates with Microsoft Graph API for Outlook mail and calendar on personal Microsoft accounts (consumers tenant). Uses PKCE auth — no client secret needed. **Skill root:** Skill directory containing `scripts/` and `references/` **Token file:** `~/.openclaw/msgraph-tokens.json` **First-time setup:** [Setup Guide](references/SETUP.md) — Walk through Azure app registration one time For API details, scopes, and endpoint reference: see [API Reference](references/api.md). ## Auth Always check auth status before making API calls. ```bash python scripts/auth.py status # Check if authenticated python scripts/auth.py login # Full PKCE login (opens browser — interactive, needs user) python scripts/auth.py refresh # Force token refresh python scripts/auth.py token # Print current access token ``` **First-time setup:** Run `auth.py login`. A browser window opens for Microsoft login. After login, tokens are stored and auto-refreshed on future calls. **WSL2 note:** The script tries `cmd.exe /c start` to open the browser, then falls back to printing the URL. If the browser opens but the redirect fails, the user may need to paste the full redirect URL — ask them. **On 401 errors:** Token auto-refreshes. If refresh fails, instruct the user to run `python auth.py login`. ## Mail ```bash # List inbox (default 20 messages) python scripts/mail.py inbox python scripts/mail.py inbox --count 50 python scripts/mail.py inbox --folder sentitems # Read a message (also marks it read) python scripts/mail.py read <message_id> # Move a message to a folder (by name or well-known name) python scripts/mail.py move <message_id> archive python scripts/mail.py move <message_id> "Newsletters" # List all folders (shows IDs and unread counts) python scripts/mail.py folders # Search email python scripts/mail.py search "project update" ``` **Folder names:** Well-known names (`inbox`, `drafts`, `sentitems`, `deleteditems`, `junk`, `archive`) work directly. Display names are resolved automatically by listing folders. ## Calendar **Note:** The calendar script is named `cal.py` (not `calendar.py`) to avoid a Python stdlib conflict. ```bash # List upcoming events (default 7 days) python scripts/cal.py list python scripts/cal.py list --days 14 # Get event detail python scripts/cal.py get <event_id> # Create an event python scripts/cal.py create \ --subject "Team Sync" \ --start "2026-03-10T10:00" \ --end "2026-03-10T11:00" \ --tz "America/New_York" \ --location "Zoom" \ --body "Weekly sync call" \ --attendees "[REDACTED_SECRET],[REDACTED_SECRET]" # Delete an event python scripts/cal.py delete <event_id> # List available calendars python scripts/cal.py calendars ``` **Defaults:** `--tz` defaults to `America/New_York`. `--start`/`--end` accept `YYYY-MM-DDTHH:MM`. ## Workflow 1. Run `auth.py status` — if not authenticated, prompt user to run `auth.py login` 2. Call the appropriate script 3. Present results in a readable summary (don't dump raw IDs to the user; use subjects/senders) 4. For move operations, confirm which folder the email went to 5. For event creation, confirm back the created event's subject, time, and ID ## User Email Organization (example — update for your setup) The user's inbox organization system: - **Inbox** — open loops only (anything still needing action) - **Events/** — travel plans, reservations (subfolders per trip/event) - **eReceipts** — purchase receipts and confirmations When processing email unprompted, apply this logic before moving anything. When in doubt, leave in inbox and flag for the user.

path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

## [1.0.0] - 2026-03-03

### Added

- Initial release: Microsoft Graph Skill for OpenClaw
- Email management: inbox listing, read, move, folder browsing, search
- Calendar management: list events, get details, create events, list calendars
- PKCE OAuth authentication with local token storage
- Comprehensive test suite: 114 tests with 82% code coverage
- Full documentation: setup guide, skill guide, API reference
- Python 3.7+ compatibility with no external dependencies

### Features

- ✅ Secure PKCE authentication (no client secret required)
- ✅ Automatic token refresh on expiration
- ✅ Well-known folder shortcuts (inbox, drafts, sentitems, etc.)
- ✅ Custom folder name resolution by display name
- ✅ Rich datetime formatting with timezone support
- ✅ HTML content stripping for readable email display
- ✅ Event creation with attendees and location
- ✅ Calendar timezone support

### Code Quality

- Type hints throughout core modules
- Comprehensive docstrings
- Modular architecture (graph_api.py, utils.py shared modules)
- No code duplication (60+ lines consolidated)
- Full test coverage for HTTP utilities and formatters

### Documentation

- README with quick start guide
- Detailed setup instructions for Azure Portal config
- Skill guide with command reference and workflows
- Troubleshooting section
- API reference for Microsoft Graph endpoints

---

## Format

The rest of this file follows the [keepachangelog.com](https://keepachangelog.com) format:

- **Added** for new features
- **Changed** for changes in existing functionality
- **Deprecated** for soon-to-be removed features
- **Removed** for now removed features
- **Fixed** for any bug fixes
- **Security** for in case of vulnerabilities


path: config.example.ini
content: [msgraph]
client_id = YOUR_CLIENT_ID_HERE
tenant = consumers
scopes = Mail.ReadWrite Calendars.ReadWrite offline_access User.Read
redirect_port = 8765

path: CONTRIBUTING.md
content: # Contributing to OpenClaw Microsoft Graph Skill

Thanks for your interest in contributing! Here's how to get started.

## Development Setup

1. **Clone and navigate:**

   ```bash
   git clone https://github.com/openclaw/msgraph-skill.git
   cd msgraph-skill
   ```

2. **Set up Python environment:**

   ```bash
   python -m venv venv
   source venv/bin/activate  # or: venv\Scripts\activate on Windows
   ```

3. **Install dependencies:**

   ```bash
   pip install -r requirements-test.txt
   ```

4. **Configure for testing:**
   ```bash
   cp config.example.ini config.ini
   # Set a test CLIENT_ID if you want to test auth flows
   ```

## Testing

**Run tests:**

```bash
python -m pytest tests/ -v
```

**With coverage:**

```bash
python -m pytest tests/ --cov=scripts --cov-report=html
```

**Coverage requirements:** Maintain 80%+ overall coverage. Target modules:

- `auth.py`: PKCE flow, token management (64% baseline—browser flow hard to test)
- `cal.py`: Calendar operations (93% baseline)
- `graph_api.py`: HTTP utilities (100% target)
- `mail.py`: Email operations (83% baseline)
- `utils.py`: Formatting helpers (100% target)

## Code Style

- **Type hints:** Add to all function signatures
- **Docstrings:** Include module, function, and class docstrings (Google style)
- **Linting:** No external linters configured; follow PEP 8
- **Line length:** Keep under 100 characters where practical

## Making Changes

1. **Create a feature branch:**

   ```bash
   git checkout -b feature/my-feature
   ```

2. **Make your changes:**
   - Update code in `scripts/`
   - Add/update tests in `tests/`
   - Update docs in `references/` or root

3. **Test thoroughly:**

   ```bash
   python -m pytest tests/ -v --cov=scripts
   ```

4. **Commit with clear messages:**

   ```bash
   git commit -m "feat: add support for new calendar feature"
   ```

5. **Push and create a pull request**

## What to Contribute

### Good First Issues

- Test coverage improvements
- Documentation fixes or clarifications
- Error message improvements
- New test cases for edge cases
- Performance optimizations in HTTP utilities

### Feature Ideas

- Support for additional Microsoft Graph endpoints
- Better error diagnostics
- Folder recursion improvements
- Timezone handling enhancements
- Batch operation support

### Documentation

- Real-world usage examples
- Troubleshooting guides
- Video tutorials or walkthroughs
- API reference improvements

## Pull Request Process

1. **Ensure all tests pass** with 80%+ coverage
2. **Write clear PR description** explaining what and why
3. **Reference any related issues** (e.g., "Fixes #42")
4. **Add tests** for new functionality
5. **Update documentation** if behavior changes
6. **Keep commits clean** (consider squashing if many small commits)

## Reporting Issues

Found a bug? Please include:

- Python version (`python --version`)
- OS (Windows, macOS, Linux)
- Steps to reproduce
- Expected vs actual behavior
- Relevant error messages or logs

## Module Guide

### `scripts/auth.py`

- PKCE OAuth flow implementation
- Token storage/loading from `~/.openclaw/`
- Automatic token refresh
- **Key functions:** `generate_pkce()`, `build_auth_url()`, `exchange_code()`, `get_access_token()`

### `scripts/mail.py`

- Email operations via Graph API
- Folder resolution (well-known names + custom lookups)
- Message reading, moving, searching
- **Key functions:** `cmd_inbox()`, `cmd_read()`, `cmd_move()`, `resolve_folder_id()`

### `scripts/cal.py`

- Calendar operations via Graph API
- Event listing, creation, deletion
- Calendar selection and event details
- **Key functions:** `cmd_list()`, `cmd_create()`, `cmd_get()`, `cmd_delete()`

### `scripts/graph_api.py` (Shared)

- HTTP utilities for Graph API calls
- Request/response handling
- Error handling and logging
- **Key functions:** `graph_get()`, `graph_post()`, `graph_patch()`, `graph_delete()`

### `scripts/utils.py` (Shared)

- DateTime formatting with timezone support
- DateTime parsing (flexible input formats)
- HTML to text conversion (strips tags and excess whitespace)
- **Key functions:** `format_datetime()`, `parse_local_datetime()`, `strip_html()`

## Documentation

Go through the docs before making changes:

- [README.md](README.md) — Quick start and overview
- [SETUP.md](references/SETUP.md) — Azure setup, troubleshooting
- [SKILL.md](skill.md) — Command reference, workflows
- [API.md](references/api.md) — Microsoft Graph endpoints and scopes

## Questions?

- Check existing issues for Q&A
- Review test files for usage examples
- Look at docstrings in source files

Thanks for contributing! 🎉


path: examples/common_workflows.py
content: #!/usr/bin/env python3
"""
Common OpenClaw Microsoft Graph Skill workflows.

These examples show typical patterns for using the msgraph skill
in OpenClaw agents and assistants.
"""

import sys
import json
from pathlib import Path

# Add scripts to path for importing
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))

import auth
import mail
import cal
import graph_api


# ── Example 1: Daily Standup (Email + Calendar) ────────────────────────────

def daily_standup():
    """
    Daily standup routine: check inbox, upcoming events, and summarize.
    
    This is a typical LLM workflow:
    1. Verify auth status
    2. Get inbox summary
    3. Get today's calendar
    4. Combine for context/summary
    """
    print("=== Daily Standup ===\n")
    
    # Check auth
    if not auth.load_tokens():
        print("❌ Not authenticated. Run: python scripts/auth.py login")
        return
    
    print("✓ Authenticated\n")
    
    # Get recent emails
    print("📧 Recent emails:")
    try:
        path = "/me/mailFolders/inbox/messages"
        params = {
            "$select": "id,subject,from,receivedDateTime",
            "$top": "5",
            "$orderby": "receivedDateTime desc"
        }
        result = graph_api.graph_get(path, params)
        for msg in result.get("value", []):
            sender = msg.get("from", {}).get("emailAddress", {}).get("name", "Unknown")
            subject = msg.get("subject", "(no subject)")
            print(f"  • {sender}: {subject}")
    except Exception as e:
        print(f"  Error: {e}")
    
    print()
    
    # Get today's events
    print("📅 Today's events:")
    try:
        path = "/me/calendarview"
        # For real usage, use proper date range
        params = {
            "$select": "subject,start,end,organizer",
            "$orderby": "start/dateTime",
            "$top": "10"
        }
        result = graph_api.graph_get(path, params)
        for event in result.get("value", []):
            subject = event.get("subject", "(no title)")
            start = event.get("start", {}).get("dateTime", "")
            print(f"  • {start[:16]}: {subject}")
    except Exception as e:
        print(f"  Error: {e}")


# ── Example 2: Email Summary with HTML Stripping ────────────────────────────

def email_summary(message_id):
    """
    Get a message and extract clean text content.
    
    Usage: email_summary("<message-id>")
    """
    print(f"📧 Reading message: {message_id}\n")
    
    try:
        # Fetch message
        path = f"/me/messages/{message_id}"
        params = {"$select": "subject,from,body,receivedDateTime"}
        msg = graph_api.graph_get(path, params)
        
        # Display summary
        print(f"From: {msg.get('from', {}).get('emailAddress', {}).get('name', 'Unknown')}")
        print(f"Subject: {msg.get('subject', '(no subject)')}")
        print(f"Date: {msg.get('receivedDateTime', 'Unknown')}")
        print()
        
        # Strip HTML and display content
        from utils import strip_html
        body_content = msg.get("body", {}).get("content", "")
        clean_content = strip_html(body_content)
        print("Content:")
        print(clean_content[:500] + ("..." if len(clean_content) > 500 else ""))
        
    except Exception as e:
        print(f"Error: {e}")


# ── Example 3: Folder Organization ────────────────────────────────────────

def organize_inbox():
    """
    Example: Move messages matching criteria to folders.
    
    Real workflow would:
    1. List inbox messages
    2. Check subject/sender against rules
    3. Resolve folder by name
    4. Move message to folder
    """
    print("📁 Folder organization workflow\n")
    
    try:
        # List folders to show available destinations
        path = "/me/mailFolders"
        params = {"$select": "id,displayName,childFolderCount"}
        folders = graph_api.graph_get(path, params)
        
        print("Available folders:")
        for folder in folders.get("value", []):
            name = folder.get("displayName", "Unknown")
            print(f"  • {name}")
        
        print()
        print("Example: Move receipts to 'Receipts' folder")
        print("  1. Search for messages from receipt-related senders")
        print("  2. Use mail.resolve_folder_id('Receipts') to get folder ID")
        print("  3. Use graph_post() with /move endpoint")
        
    except Exception as e:
        print(f"Error: {e}")


# ── Example 4: Create Calendar Event ───────────────────────────────────────

def create_meeting(subject, attendees, start_time, end_time):
    """
    Create a calendar event with attendees.
    
    Usage: create_meeting(
        "Team Sync",
        ["[REDACTED_SECRET]", "[REDACTED_SECRET]"],
        "2026-03-10T14:00",
        "2026-03-10T15:00"
    )
    """
    print(f"📅 Creating event: {subject}\n")
    
    try:
        from utils import parse_local_datetime
        
        # Parse times
        start_dt = parse_local_datetime(start_time)
        end_dt = parse_local_datetime(end_time)
        
        # Build attendees list
        attendee_list = [
            {
                "emailAddress": {"address": addr, "name": addr.split("@")[0]},
                "type": "required"
            }
            for addr in attendees
        ]
        
        # Create event
        payload = {
            "subject": subject,
            "start": {"dateTime": start_dt.isoformat(), "timeZone": "America/New_York"},
            "end": {"dateTime": end_dt.isoformat(), "timeZone": "America/New_York"},
            "attendees": attendee_list
        }
        
        path = "/me/events"
        result = graph_api.graph_post(path, payload)
        
        print(f"✓ Event created!")
        print(f"  ID: {result.get('id')}")
        print(f"  Link: {result.get('webLink', 'N/A')}")
        
    except Exception as e:
        print(f"Error: {e}")


# ── Example 5: Search and Filter ───────────────────────────────────────────

def search_recent_emails(query, days=7):
    """
    Search for recent emails from specific senders or with keywords.
    
    Usage: search_recent_emails("from:[REDACTED_SECRET]", days=7)
    """
    print(f"🔍 Searching: {query}\n")
    
    try:
        # Build OData filter (simplified—Graph API uses KQL for complex searches)
        params = {
            "$search": f'"{query}"',
            "$select": "subject,from,receivedDateTime",
            "$top": "20",
            "$orderby": "receivedDateTime desc"
        }
        
        path = "/me/messages"
        result = graph_api.graph_get(path, params)
        
        matches = result.get("value", [])
        print(f"Found {len(matches)} messages:\n")
        
        for msg in matches:
            from_name = msg.get("from", {}).get("emailAddress", {}).get("name", "Unknown")
            subject = msg.get("subject", "(no subject)")
            date = msg.get("receivedDateTime", "")[:10]
            print(f"  [{date}] {from_name}: {subject}")
        
    except Exception as e:
        print(f"Error: {e}")


# ── Example 6: Unread Email Count ──────────────────────────────────────────

def unread_summary():
    """
    Quick summary of unread counts by folder.
    Useful for "check my email" type commands.
    """
    print("📨 Unread Summary\n")
    
    try:
        path = "/me/mailFolders"
        params = {"$select": "displayName,unreadItemCount"}
        folders = graph_api.graph_get(path, params)
        
        total_unread = 0
        for folder in folders.get("value", []):
            name = folder.get("displayName", "Unknown")
            unread = folder.get("unreadItemCount", 0)
            if unread > 0:
                print(f"  {name}: {unread} unread")
                total_unread += unread
        
        print(f"\nTotal unread: {total_unread}")
        
    except Exception as e:
        print(f"Error: {e}")


if __name__ == "__main__":
    import argparse
    
    parser = argparse.ArgumentParser(description="OpenClaw msgraph skill examples")
    parser.add_argument("--example", choices=[
        "standup", "summary", "organize", "create", "search", "unread"
    ], help="Which example to run")
    
    args = parser.parse_args()
    
    if args.example == "standup":
        daily_standup()
    elif args.example == "summary":
        # Would need to pass message_id
        print("Usage: python examples.py --example summary")
        print("Then edit the script to add a message ID")
    elif args.example == "organize":
        organize_inbox()
    elif args.example == "create":
        create_meeting(
            "Demo Meeting",
            ["[REDACTED_SECRET]"],
            "2026-03-10T14:00",
            "2026-03-10T15:00"
        )
    elif args.example == "search":
        search_recent_emails("test", days=7)
    elif args.example == "unread":
        unread_summary()
    else:
        print("Examples:")
        print("  python examples.py --example standup     # Daily standup")
        print("  python examples.py --example organize    # Folder organization")
        print("  python examples.py --example search      # Search emails")
        print("  python examples.py --example unread      # Unread summary")
        print("\nOr import and use functions directly in your code:")
        print("  from examples import daily_standup, search_recent_emails")


path: examples/llm_integration.py
content: #!/usr/bin/env python3
"""
Integration example: Using msgraph skill with an LLM (OpenAI, etc).

Shows how to:
1. Fetch email/calendar data
2. Format for LLM context
3. Handle LLM instructions to modify email/calendar
4. Parse and execute resulting commands
"""

import sys
import json
from pathlib import Path

# Add scripts to path
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))

import auth
import mail
import cal
import graph_api
import utils


# ── Utility: Format email for LLM context ──────────────────────────────────

def format_inbox_for_context(max_messages=10):
    """
    Fetch and format inbox for inclusion in LLM prompt context.
    
    Returns a string suitable for passing to LLM:
    "Here are your recent emails:\n
    1. [unread] Alice Smith: Subject (2026-03-03 10:30)
    2. [read] Bob Johnson: Subject (2026-03-02 14:15)
    ..."
    """
    try:
        path = "/me/mailFolders/inbox/messages"
        params = {
            "$select": "id,subject,from,isRead,receivedDateTime",
            "$top": str(max_messages),
            "$orderby": "receivedDateTime desc"
        }
        result = graph_api.graph_get(path, params)
        
        emails = result.get("value", [])
        if not emails:
            return "No unread messages."
        
        formatted = "Recent emails:\n"
        for i, msg in enumerate(emails, 1):
            sender = msg.get("from", {}).get("emailAddress", {}).get("name", "Unknown")
            subject = msg.get("subject", "(no subject)")
            date = msg.get("receivedDateTime", "")
            is_read = msg.get("isRead", False)
            msg_id = msg.get("id", "")
            
            # Format date
            try:
                date_formatted = utils.format_datetime(date)
            except:
                date_formatted = date[:10]
            
            # Indicator
            indicator = "[read]" if is_read else "[UNREAD]"
            
            formatted += f"{i}. {indicator} {sender}: {subject}\n"
            formatted += f"   Date: {date_formatted} | ID: {msg_id[:8]}...\n"
        
        return formatted
        
    except Exception as e:
        return f"Error fetching inbox: {e}"


# ── Utility: Format calendar for LLM context ───────────────────────────────

def format_calendar_for_context(days=7):
    """
    Fetch and format upcoming events for LLM prompt.
    
    Returns formatted string:
    "Upcoming events (next 7 days):
    • 2026-03-03 14:00: Team Sync
    • 2026-03-04 10:00: Client Call
    ..."
    """
    try:
        # In real usage, set proper date range
        path = "/me/calendarview"
        params = {
            "$select": "id,subject,start,end,organizer",
            "$orderby": "start/dateTime",
            "$top": "20"
        }
        result = graph_api.graph_get(path, params)
        
        events = result.get("value", [])
        if not events:
            return f"No events scheduled (next {days} days)."
        
        formatted = f"Upcoming events (next {days} days):\n"
        for event in events[:10]:
            subject = event.get("subject", "(no title)")
            start = event.get("start", {}).get("dateTime", "")
            
            # Format datetime
            try:
                start_formatted = utils.format_datetime(start)
            except:
                start_formatted = start[:16]
            
            formatted += f"• {start_formatted}: {subject}\n"
        
        return formatted
        
    except Exception as e:
        return f"Error fetching calendar: {e}"


# ── LLM Integration Example ────────────────────────────────────────────────

class EmailCalendarAssistant:
    """
    Example LLM-powered assistant that can read and modify email/calendar.
    
    This shows the pattern for integrating with OpenAI, Claude, etc.
    """
    
    def __init__(self):
        """Initialize assistant and check auth."""
        self.[REDACTED_SECRET])
        if not self.token:
            raise RuntimeError("Not authenticated. Run: python scripts/auth.py login")
    
    def get_context_for_llm(self):
        """Build context string to pass to LLM."""
        sections = []
        
        # Email context
        sections.append("## Email")
        sections.append(format_inbox_for_context(5))
        
        # Calendar context
        sections.append("\n## Calendar")
        sections.append(format_calendar_for_context(7))
        
        return "\n".join(sections)
    
    def execute_command(self, command):
        """
        Execute an email/calendar command.
        
        Commands might be like:
        - "move message abc123 to folder Newsletters"
        - "create event on 2026-03-10 at 14:00 titled 'Team Sync'"
        - "search for emails from [REDACTED_SECRET]"
        """
        print(f"Executing: {command}")
        # Implementation would parse and execute the command
        return "Command executed"
    
    def process_llm_response(self, response):
        """
        Parse LLM response that might contain action commands.
        
        LLM might respond with structured actions:
        {'message': '...', 'actions': [{'type': 'move', 'id': '...', 'folder': '...'}]}
        """
        try:
            data = json.loads(response)
            actions = data.get("actions", [])
            
            results = []
            for action in actions:
                action_type = action.get("type")
                
                if action_type == "move":
                    msg_id = action.get("id")
                    folder = action.get("folder")
                    folder_id = mail.resolve_folder_id(folder)
                    result = graph_api.graph_post(
                        f"/me/messages/{msg_id}/move",
                        {"destinationId": folder_id}
                    )
                    results.append(f"✓ Moved message to {folder}")
                
                elif action_type == "create_event":
                    payload = action.get("payload")
                    result = graph_api.graph_post("/me/events", payload)
                    results.append(f"✓ Created event: {payload.get('subject')}")
                
                elif action_type == "search":
                    query = action.get("query")
                    params = {"$search": f'"{query}"', "$top": "10"}
                    result = graph_api.graph_get("/me/messages", params)
                    results.append(f"✓ Found {len(result.get('value', []))} messages")
            
            return results
            
        except json.JSONDecodeError:
            print("Warning: Response not valid JSON")
            return []


# ── Example Usage ──────────────────────────────────────────────────────────

def main():
    """Demonstrate LLM integration pattern."""
    
    print("=== Email/Calendar + LLM Integration Example ===\n")
    
    try:
        assistant = EmailCalendarAssistant()
        
        # 1. Get context for LLM
        print("📋 Building context for LLM...\n")
        context = assistant.get_context_for_llm()
        print(context)
        print("\n" + "="*50 + "\n")
        
        # 2. Example: Show what you'd send to LLM
        llm_prompt = f"""You are an email and calendar assistant. 
Help the user manage their inbox and schedule.

{context}

User request: "Show me my unread emails and upcoming meetings"

Respond with:
1. A summary message
2. If you need to take actions, include a JSON block:
   {{"message": "...", "actions": [...]}}
"""
        print("📨 Example prompt for LLM:")
        print(llm_prompt)
        print("\n" + "="*50 + "\n")
        
        # 3. Example LLM response
        example_response = {
            "message": "You have 3 unread emails and 5 meetings this week.",
            "actions": [
                {
                    "type": "search",
                    "query": "important"
                }
            ]
        }
        
        print("🤖 Example LLM response:")
        print(json.dumps(example_response, indent=2))
        print("\n" + "="*50 + "\n")
        
        # 4. Process response
        print("⚙️  Processing LLM response...")
        results = assistant.process_llm_response(json.dumps(example_response))
        for result in results:
            print(f"  {result}")
        
    except RuntimeError as e:
        print(f"❌ {e}")


if __name__ == "__main__":
    main()


path: examples/README.md
content: # Examples

Real-world usage patterns for the OpenClaw Microsoft Graph skill.

## Quick Links

- **[common_workflows.py](common_workflows.py)** — Email/calendar operations (daily standup, search, create events)
- **[llm_integration.py](llm_integration.py)** — How to integrate with OpenAI, Claude, etc.

## Running Examples

### Prerequisites

```bash
# Set up your Client ID first
cp ../config.example.ini ../config.ini
# Edit config.ini with your Client ID

# Authenticate
python ../scripts/auth.py login
```

### Common Workflows

```bash
python common_workflows.py --example standup     # Daily email + calendar summary
python common_workflows.py --example organize    # Folder organization pattern
python common_workflows.py --example search      # Search emails
python common_workflows.py --example unread      # Unread count by folder
python common_workflows.py --example create      # Create calendar event
```

### LLM Integration

```bash
python llm_integration.py
```

Shows how to:

1. Format email/calendar data for LLM context
2. Send to LLM (OpenAI, Claude, etc)
3. Parse structured responses
4. Execute resulting commands

## Pattern 1: Daily Standup

```python
from common_workflows import daily_standup

daily_standup()
# Outputs: Recent emails + today's events
```

## Pattern 2: Search & Filter

```python
from common_workflows import search_recent_emails

search_recent_emails("from:[REDACTED_SECRET]", days=7)
# Outputs: Matching emails with dates
```

## Pattern 3: Email Summary

```python
from common_workflows import email_summary

email_summary("<message-id>")
# Outputs: Message with HTML stripped and formatted
```

## Pattern 4: LLM Integration

```python
from llm_integration import EmailCalendarAssistant, format_inbox_for_context

# Get formatted context
context = format_inbox_for_context(10)

# Send to LLM
response = llm_client.chat.completions.create(
    messages=[
        {"role": "user", "content": f"Here's my email:\n{context}\nSummarize."}
    ]
)

# Process any actions in response
assistant = EmailCalendarAssistant()
assistant.process_llm_response(response.choices[0].message.content)
```

## Pattern 5: Folder Organization

```python
# List all folders
folders = graph_api.graph_get("/me/mailFolders", {"$select": "displayName"})

# Resolve custom folder by name
folder_id = mail.resolve_folder_id("Newsletters")

# Move a message
graph_api.graph_post(
    f"/me/messages/{msg_id}/move",
    {"destinationId": folder_id}
)
```

## Pattern 6: Create Events with Attendees

```python
from common_workflows import create_meeting

create_meeting(
    subject="Weekly Sync",
    attendees=["[REDACTED_SECRET]", "[REDACTED_SECRET]"],
    start_time="2026-03-10T14:00",
    end_time="2026-03-10T15:00"
)
```

## Error Handling

All examples handle common errors:

- **Not authenticated**: `auth.load_tokens()` returns None
- **API errors**: Caught and printed
- **Invalid input**: Validated before API calls

Example:

```python
try:
    result = graph_api.graph_get(path, params)
except Exception as e:
    print(f"Error: {e}")
```

## Extending Examples

Add your own patterns:

1. **Email processing pipeline**
   - Fetch inbox
   - Apply rules/filters
   - Archive, label, or summarize

2. **Calendar analytics**
   - Count meetings by attendee
   - Find free/busy slots
   - Suggest meeting times

3. **Notification system**
   - Check for unread emails every N minutes
   - Alert on specific senders or keywords
   - Post to Slack, Discord, etc.

4. **AI-powered summarization**
   - Format long emails
   - Create action items from meetings
   - Generate status updates

## Tips

- **Always check auth first**: `if not auth.load_tokens(): ...`
- **Use try/except for API calls** — network issues happen
- **Format results for users** — don't dump raw JSON
- **Respect rate limits** — Group operations if possible
- **Cache when appropriate** — Email subjects don't change every second

## See Also

- [Skill Guide](../skill.md) — Command reference
- [Setup Guide](../references/SETUP.md) — Auth and troubleshooting
- [API Reference](../references/api.md) — Microsoft Graph endpoints


path: pyproject.toml
content: [build-system]
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"

[project]
name = "openclaw-msgraph-skill"
version = "1.0.0"
description = "Microsoft Graph API integration for Outlook & Calendar in OpenClaw"
readme = "README.md"
requires-python = ">=3.7"
license = { text = "MIT" }
authors = [{ name = "OpenClaw Contributors", email = "[REDACTED_SECRET]" }]
keywords = ["outlook", "calendar", "microsoft", "graph-api", "oauth", "email"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.7",
    "Programming Language :: Python :: 3.8",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Topic :: Office/Business",
    "Topic :: Communications :: Email",
]

[project.urls]
Homepage = "https://github.com/openclaw/msgraph-skill"
Repository = "https://github.com/openclaw/msgraph-skill.git"
Issues = "https://github.com/openclaw/msgraph-skill/issues"
Documentation = "https://github.com/openclaw/msgraph-skill/blob/main/README.md"

[project.optional-dependencies]
dev = ["pytest>=7.0.0", "pytest-cov>=4.0.0", "pytest-mock>=3.10.0"]

[tool.setuptools]
packages = ["scripts"]

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
addopts = "--cov=scripts --cov-report=term-missing"
minversion = "6.0"

[tool.coverage.run]
source = ["scripts"]
omit = ["*/tests/*"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise AssertionError",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
    "if TYPE_CHECKING:",
]


path: pytest.ini
content: [pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = 
    --cov=scripts
    --cov-report=term-missing
    --cov-report=html:htmlcov
    --cov-fail-under=80
    --strict-markers
    -v
markers =
    unit: Unit tests
    integration: Integration tests
    slow: Slow running tests


path: README.md
content: # OpenClaw Microsoft Graph Skill

![Tests](https://img.shields.io/badge/tests-114%20passing-brightgreen)
![Coverage](https://img.shields.io/badge/coverage-82%25-green)

Connect your Microsoft 365 personal account (Outlook, Calendar) to OpenClaw via the **Microsoft Graph API** with secure PKCE authentication.

## Features

- 📧 **Email Management**: List inbox, read messages, move to folders, search
- 📅 **Calendar**: View upcoming events, create events with attendees, manage calendars
- 🔐 **Secure Auth**: PKCE flow — no client secret needed, tokens stored locally
- 🚀 **Ready to Use**: One-time browser login, auto token refresh
- ✅ **Well Tested**: 114 tests, 82% code coverage

## Quick Start

### 1. Register Your App (Azure Portal)

1. Go to [Azure Portal](https://portal.azure.com) → **Azure Active Directory** → **App registrations** → **New registration**
2. Name: `OpenClaw` | Account type: **Personal Microsoft accounts only**
3. Go to **Authentication** → **Add a platform** → **Single-page application**
4. Add redirect URI: `http://localhost:8765/`
5. Go to **API permissions** → **Microsoft Graph** → add **Delegated permissions**:
   - `Mail.Read`, `Mail.ReadWrite`
   - `Calendars.Read`, `Calendars.ReadWrite`
   - `User.Read`
6. Copy your **Application (client) ID**

### 2. Configure

Copy `config.example.ini` to `config.ini` and set your Client ID:

```ini
[msgraph]
CLIENT_ID = your-client-id-here
tenant = consumers
scopes = Mail.ReadWrite Calendars.ReadWrite offline_access User.Read
redirect_port = 8765
```

### 3. Authenticate

```bash
python scripts/auth.py login
# Browser opens → Sign in with your Microsoft account → Done!
# Tokens saved to ~/.openclaw/msgraph-tokens.json
```

### 4. Use It

```bash
# Check auth status
python scripts/auth.py status

# List inbox
python scripts/mail.py inbox --count 20

# Read a message
python scripts/mail.py read <message_id>

# Upcoming events (next 7 days)
python scripts/cal.py list

# Create an event
python scripts/cal.py create \
  --subject "Team Meeting" \
  --start "2026-03-10T14:00" \
  --end "2026-03-10T15:00"
```

## Documentation

- **[Detailed Setup Guide](references/SETUP.md)** — Azure app registration, troubleshooting
- **[Skill Guide](skill.md)** — Commands, workflows, folder organization
- **[API Reference](references/api.md)** — Microsoft Graph endpoints, scopes

## Dev & Testing

### Install Test Dependencies

```bash
pip install -r requirements-test.txt
```

### Run Tests

```bash
python -m pytest tests/ -v
python -m pytest tests/ --cov=scripts --cov-report=html
```

**Coverage:** 82% across all modules

- `auth.py`: 64% (interactive browser flow hard to test)
- `cal.py`: 93% (calendar operations)
- `graph_api.py`: **100%** (HTTP utilities)
- `mail.py`: 83% (email operations)
- `utils.py`: **100%** (formatting helpers)

## Architecture

```
scripts/
├── auth.py        # PKCE OAuth, token management
├── mail.py        # Email commands (inbox, read, move, search)
├── cal.py         # Calendar commands (list, create, delete)
├── graph_api.py   # Shared HTTP utilities for Graph API
└── utils.py       # Formatting & parsing helpers
```

## Auth Flow

1. User runs `auth.py login`
2. Script generates PKCE challenge and opens browser
3. User signs in to Microsoft account
4. Browser redirects to `http://localhost:8765/`
5. Script captures authorization code
6. Script exchanges code for access token (with PKCE proof)
7. Refresh token stored locally, access token auto-refreshed

## Error Handling

- **401 Unauthorized**: Token expired → auto-refreshes
- **403 Forbidden**: Missing permissions → check Azure Portal
- **404 Not Found**: Invalid resource ID → verify message/event IDs

## Folder Organization

Well-known folder names work directly: `inbox`, `drafts`, `sentitems`, `deleteditems`, `junk`, `archive`

Custom folder names resolved by display name lookup.

## Requirements

- Python 3.7+
- Microsoft account (personal or work/school depending on Azure app config)
- No external dependencies (uses stdlib: `urllib`, `json`, `datetime`, `hashlib`, `base64`, `configparser`, `argparse`)

## License

MIT — See [LICENSE](LICENSE)

## Contributing

Found a bug? Have a feature request? Open an issue or submit a PR!

## Support

- Check [Troubleshooting](references/SETUP.md#troubleshooting) section
- Review [Skill Guide](skill.md) for workflows
- Check test files for usage examples


path: references/api.md
content: # Microsoft Graph API Reference

## Auth

| Setting | Value |
|---|---|
| Client ID | YOUR_CLIENT_ID_HERE |
| Tenant | consumers (personal Microsoft accounts) |
| Auth URL | `https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize` |
| Token URL | `https://login.microsoftonline.com/consumers/oauth2/v2.0/token` |
| Redirect URI | `http://localhost:8765/callback` |
| Scopes | `Mail.ReadWrite Calendars.ReadWrite offline_access User.Read` |

## Token File

Stored at `~/.openclaw/msgraph-tokens.json` (mode 0600). Contains:
- `access_token` — Bearer token for API calls
- `refresh_token` — Long-lived token for renewal
- `expires_at` — Unix timestamp of access token expiry
- `expires_in` — Seconds until expiry (from last refresh)

## Key Endpoints

### Mail

| Operation | Endpoint |
|---|---|
| List inbox | `GET /me/mailFolders/inbox/messages` |
| List folder | `GET /me/mailFolders/{folderId}/messages` |
| Get message | `GET /me/messages/{id}` |
| Move message | `POST /me/messages/{id}/move` → `{"destinationId": "..."}` |
| List folders | `GET /me/mailFolders` |
| Search | `GET /me/messages?$search="query"` |
| Mark read | `PATCH /me/messages/{id}` → `{"isRead": true}` |

**Well-known folder names** (usable as IDs directly):
`inbox`, `drafts`, `sentitems`, `deleteditems`, `junk`, `archive`, `outbox`

### Calendar

| Operation | Endpoint |
|---|---|
| List events (view) | `GET /me/calendarView?startDateTime=...&endDateTime=...` |
| Get event | `GET /me/events/{id}` |
| Create event | `POST /me/events` |
| List calendars | `GET /me/calendars` |
| List in calendar | `GET /me/calendars/{id}/calendarView` |

**Event create payload example:**
```json
{
  "subject": "Team Sync",
  "start": {"dateTime": "2026-03-10T10:00:00", "timeZone": "America/New_York"},
  "end":   {"dateTime": "2026-03-10T11:00:00", "timeZone": "America/New_York"},
  "location": {"displayName": "Zoom"},
  "body": {"contentType": "text", "content": "Weekly sync"},
  "attendees": [
    {"emailAddress": {"address": "[REDACTED_SECRET]"}, "type": "required"}
  ]
}
```

## Common Query Parameters

| Param | Purpose | Example |
|---|---|---|
| `$top` | Limit results | `$top=20` |
| `$orderby` | Sort | `$orderby=receivedDateTime desc` |
| `$select` | Fields to return | `$select=id,subject,from` |
| `$search` | OData search | `$search="meeting notes"` |
| `$filter` | OData filter | `$filter=isRead eq false` |

## Error Handling

| HTTP Code | Meaning | Action |
|---|---|---|
| 401 | Unauthorized | Re-run `python auth.py login` |
| 403 | Insufficient scope | Re-auth; check scopes |
| 404 | Resource not found | Check ID |
| 429 | Rate limited | Wait and retry |

On 401, `get_access_token()` will attempt refresh automatically. If refresh also fails, it exits with instructions to re-login.

## PKCE Flow Summary

1. Generate `code_verifier` (random 64-char URL-safe string)
2. `code_challenge = BASE64URL(SHA256(code_verifier))` (no padding)
3. Redirect user to auth URL with `code_challenge_method=S256`
4. After login, Microsoft redirects to `redirect_uri?code=...&state=...`
5. Exchange `code` + `code_verifier` for tokens at token URL
6. Store `access_token`, `refresh_token`, `expires_at`


path: requirements-test.txt
content: pytest>=7.0.0
pytest-cov>=4.0.0
pytest-mock>=3.10.0


path: requirements.txt
content: # No external dependencies required
# This skill uses only Python standard library modules:
#   - urllib (HTTP requests)
#   - json (JSON parsing)
#   - datetime (date/time handling)
#   - hashlib (PKCE challenge hashing)
#   - base64 (PKCE encoding)
#   - configparser (config file parsing)
#   - argparse (CLI argument parsing)
#   - http.server (local callback server for OAuth redirect)
#
# For development and testing, see requirements-test.txt


path: scripts/graph_api.py
content: """
Shared Microsoft Graph API utilities.

Provides HTTP methods for Graph API communication with auth token management.
"""

import sys
import json
import urllib.request
import urllib.error
import urllib.parse
from typing import Any, Dict, Optional

import auth

GRAPH_BASE = "https://graph.microsoft.com/v1.0"


def graph_get(path: str, params: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
    """
    GET request to Graph API.
    
    Args:
        path: API endpoint path (e.g., "/me/messages")
        params: Optional query parameters
        
    Returns:
        Parsed JSON response
    """
    [REDACTED_SECRET])
    url = GRAPH_BASE + path
    if params:
        url += "?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
    try:
        with urllib.request.urlopen(req) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        body = e.read().decode()
        print(f"ERROR {e.code}: {body}", file=sys.stderr)
        sys.exit(1)


def graph_post(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
    """
    POST request to Graph API.
    
    Args:
        path: API endpoint path
        payload: Request body as dict
        
    Returns:
        Parsed JSON response, or empty dict if no content
    """
    [REDACTED_SECRET])
    url = GRAPH_BASE + path
    data = json.dumps(payload).encode()
    req = urllib.request.Request(url, data=data, method="POST", headers={
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    })
    try:
        with urllib.request.urlopen(req) as resp:
            content = resp.read()
            return json.loads(content) if content else {}
    except urllib.error.HTTPError as e:
        body = e.read().decode()
        print(f"ERROR {e.code}: {body}", file=sys.stderr)
        sys.exit(1)


def graph_patch(path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
    """
    PATCH request to Graph API.
    
    Args:
        path: API endpoint path
        payload: Request body as dict
        
    Returns:
        Parsed JSON response, or empty dict if no content
    """
    [REDACTED_SECRET])
    url = GRAPH_BASE + path
    data = json.dumps(payload).encode()
    req = urllib.request.Request(url, data=data, method="PATCH", headers={
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    })
    try:
        with urllib.request.urlopen(req) as resp:
            content = resp.read()
            return json.loads(content) if content else {}
    except urllib.error.HTTPError as e:
        body = e.read().decode()
        print(f"ERROR {e.code}: {body}", file=sys.stderr)
        sys.exit(1)


def graph_delete(path: str) -> None:
    """
    DELETE request to Graph API.
    
    Args:
        path: API endpoint path
    """
    [REDACTED_SECRET])
    url = GRAPH_BASE + path
    req = urllib.request.Request(url, method="DELETE",
                                  headers={"Authorization": f"Bearer {token}"})
    try:
        with urllib.request.urlopen(req) as resp:
            pass  # 204 No Content on success
    except urllib.error.HTTPError as e:
        body = e.read().decode()
        print(f"ERROR {e.code}: {body}", file=sys.stderr)
        sys.exit(1)


path: scripts/__init__.py
content: """OpenClaw msgraph skill - Microsoft Graph integration for Outlook."""


path: tests/__init__.py
content: """Tests for OpenCloutlook msgraph skill."""


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)