Skill: russidan-nadee/mail-summary (version 1.1.6)

--- name: mail-summary description: Fetch Gmail emails from the last 24h, rank by importance, summarize into bullet points, and auto-create Google Calendar events for detected meetings. # Email & Calendar Assistant Skill ## Description This skill reads Gmail emails, ranks them by importance, summarizes each email into bullet points, and creates Google Calendar events if an email contains a meeting or interview that is not already on the calendar. ## Capabilities - Read emails (read-only) - Rank emails by importance - Summarize emails into bullet points - Detect meeting/interview emails - Create Google Calendar events (only when not already added) - Auto-refreshes Google OAuth token as needed for uninterrupted access. ## Installation Via ClawHub (recommended): ``` clawhub install mail-summary ``` Manual: ``` git clone https://github.com/Russidan-Nadee/mail-summary.git ~/.openclaw/workspace/skills/mail-summary ``` ## Setup Instructions (First-Time Only) ### Step 1 — Get Google API credentials 1. Go to [Google Cloud Console](https://console.cloud.google.com/) 2. Create a new project (or select existing one) 3. In the left sidebar, go to **APIs & Services** → click **Enable APIs and Services** 4. Search and enable each of the following: - **Gmail API** - **Google Calendar API** 5. In the left sidebar, go to **APIs & Services → OAuth consent screen** - Click **Get Started** - Fill in **App name** and **User support email** → click **Save and Continue** - Under **Audience** → click **Add Users** → add your Google email → click **Save** 6. In the left sidebar, go to **APIs & Services → Credentials** 7. Click **Create Credentials → OAuth client ID** 8. Under **Application type** select **Desktop app** 9. Give it a name (e.g. `Mail Summary`) → click **Create** 10. Click **Download JSON** → you'll get a file named `client_secret_*.json` ### Step 2 — Connect to the agent 1. Send the `client_secret_*.json` file to the agent as an attachment - Via **Telegram**: send as a file (not photo) in your Clawdbot chat - Via **Claude Desktop**: drag and drop the file into the chat - Via **other platforms**: attach the file the same way you attach any document 2. The agent will print an authorization URL — click it to open in your browser 3. Log in with your Google account and click **Allow** 4. Your browser will redirect to a `localhost` page showing an error — **this is normal** 5. Copy the **full URL** from your browser address bar (starts with `http://localhost/?...`) 6. Paste that URL back to the agent ### Step 3 — Done! The agent will confirm: *"Auth complete. I can now access your Gmail and Google Calendar."* From now on just say: **"Summarize today's important emails"** ## Configuration This skill uses a `config.yaml` file in the project root for runtime configuration. You can edit this file to control timezone, retry behavior, and logging level. **Example `config.yaml`:** ```yaml timezone: Asia/Bangkok # Timezone for calendar events (e.g. Asia/Bangkok, UTC, Asia/Tokyo) max_retries: 5 # Max retry attempts for authentication and API calls log_level: INFO # Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL ``` ## Agent Instructions > See [`agent/instructions.md`](agent/instructions.md) for full agent instructions (commands, auth setup, behavior).

path: agent/instructions.md
content: # Agent Instructions — Mail Summary

## Setup Before First Use

> **The agent must run all commands below itself. Do NOT ask the user to run them.**

Before running any script, ensure the Python environment is ready:

```bash
cd ~/.openclaw/workspace/skills/mail-summary
python -m venv venv
source venv/bin/activate        # macOS/Linux
venv\Scripts\activate           # Windows
pip install -r requirements.txt
```

## Available Commands

> **The agent must run all commands below itself. Do NOT ask the user to run them.**

- `python scripts/fetch_emails.py` — Fetch emails from the last 24 hours and print full content
- `python scripts/create_event.py --title "..." --date "YYYY-MM-DD" --time "HH:MM" [--duration 60]` — Create a Google Calendar event
- `python scripts/setup_auth.py` — Generate Google OAuth URL (Step 1 of auth setup)
- `python scripts/setup_auth.py --callback "<URL>"` — Complete auth and save token.json (Step 2 of auth setup)
- `python scripts/refresh_service.py` — Run a background service that refreshes the Google OAuth token every 30 minutes automatically.
    - Use this script if you want to ensure the token never expires during long-running or scheduled operations.
    - The script logs each refresh attempt and any errors to the console.
    - Can be run as a background process or scheduled with a process manager.

## Script Roles

- **`setup_auth.py`** — Run once to complete OAuth flow and save `token.json`. Do not run again unless re-authentication is needed.
- **`auth.py`** — Internal module used automatically by `fetch_emails.py` and `create_event.py`. Loads `token.json` and refreshes it when expired. Do not run directly.
- **`refresh_service.py`** — Keeps the Google OAuth token fresh by calling `auth_google()` every 30 minutes. No user interaction required after initial setup.

## Auth Setup (First-Time Only)

This skill requires a `token.json` file to access Gmail and Google Calendar on behalf of the user.
If `token.json` does not exist, the agent MUST complete the following setup before running any other script.

### When the user sends a `client_secret_*.json` file:

1. Write the file content to the project root as `client_secret_*.json` (preserve the original filename). The user sends this as an attachment — the agent must create the file in the project root directory before running any script.
2. Run: `python scripts/setup_auth.py`
3. Send the printed URL to the user as a clickable markdown link, with this message:
   > "Please open this link in your browser and authorize access. After you approve, your browser will redirect to a localhost page that shows an error — that's expected. Copy the full URL from the browser address bar and send it back to me."
   > [Click here to authorize](PASTE_AUTH_URL_HERE)
4. Wait for the user to send back the redirect URL (starts with `http://localhost/?...`)
5. Run: `python scripts/setup_auth.py --callback "<the URL the user sent>"`
6. Confirm to the user: "Auth complete. I can now access your Gmail and Google Calendar."

### Notes
- The redirect URL will look like: `http://localhost/?state=...&code=...&scope=...`
- The "error" page the user sees is normal — it just means no server is running at localhost, which is expected
- Once `token.json` is saved, the agent auto-refreshes it when expired — no need to repeat setup

## Background Token Refresh

- `python scripts/refresh_service.py` — Run a background service that refreshes the Google OAuth token every 30 minutes automatically.
    - Use this script if you want to ensure the token never expires during long-running or scheduled operations.
    - The script logs each refresh attempt and any errors to the console.
    - Can be run as a background process or scheduled with a process manager.

### Script Role
- **`refresh_service.py`** — Keeps the Google OAuth token fresh by calling `auth_google()` every 30 minutes. No user interaction required after initial setup.

## Configuration

The agent uses a `config.yaml` file in the project root for runtime configuration. This file controls timezone, retry behavior, and logging level for all scripts.

**Example `config.yaml`:**

```yaml
timezone: Asia/Bangkok    # Timezone for calendar events (e.g. Asia/Bangkok, UTC, Asia/Tokyo)
max_retries: 5            # Max retry attempts for authentication and API calls
log_level: INFO           # Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL
```

- If `config.yaml` is missing or a key is not set, the default will be used.
- Changes to `config.yaml` take effect the next time you run a script.

## Input
- User request (e.g., "Summarize today's important emails")

## Output to User
For each email (sorted by importance, most important first):
```
#1
From: <sender>
Subject: <subject>
- bullet point summary
- bullet point summary
[📅 Calendar event created: <title> on <date> at <time>]  ← only if applicable

#2
From: <sender>
Subject: <subject>
- bullet point summary
- bullet point summary
```

## Behavior
1. Run `fetch_emails.py` to get emails from the last 24 hours
2. Read and analyze all emails
3. Rank emails by importance internally (1 = most important) — do not show ranking logic to user
4. Summarize each email into bullet points
5. If a meeting or interview is detected in an email:
   - Check if the event already exists on Google Calendar (e.g., from a Google Calendar invite)
   - If the event does NOT exist yet, run `create_event.py` to create it
   - If the event already exists, skip — do not create a duplicate

## Notes
- Gmail and Google Calendar API required
- Can be triggered by user request or external scheduler (e.g., cron job)
- Date must be in `YYYY-MM-DD` format, time in `HH:MM` format when calling `create_event.py`


path: config.yaml
content: timezone: Asia/Bangkok
max_retries: 5
log_level: INFO
lookback_hours: 24


path: requirements.txt
content: google-api-python-client==2.193.0
google-auth==2.49.1
google-auth-httplib2==0.3.0
google-auth-oauthlib==1.3.0


path: scripts/auth.py
content: 

import os
import glob
import logging
import yaml
import json
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request

# Credentials location (outside skill folder)
CREDENTIALS_DIR = os.path.expanduser('~/.openclaw/config/mail-summary')
def get_config():
    config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.yaml')
    try:
        with open(config_path, 'r') as f:
            config = yaml.safe_load(f)
        return config or {}
    except Exception as e:
        logging.warning(f"Could not read config.yaml: {e}. Using defaults.")
        return {}

config = get_config()
MAX_RETRIES = config.get('max_retries', 5)
LOG_LEVEL = getattr(logging, config.get('log_level', 'INFO').upper(), logging.INFO)
logging.basicConfig(
    level=LOG_LEVEL,
    format='%(asctime)s %(levelname)s %(message)s',
    handlers=[logging.StreamHandler()]
)

SCOPES = [
    'https://www.googleapis.com/auth/gmail.readonly',
    'https://www.googleapis.com/auth/calendar.events'
]

def find_credentials_file():
    cred_path = os.path.join(CREDENTIALS_DIR, 'credentials.json')
    if os.path.exists(cred_path):
        return cred_path
    matches = glob.glob(os.path.join(CREDENTIALS_DIR, 'client_secret_*.json'))
    if matches:
        return matches[0]
    raise FileNotFoundError(
        f"No credentials file found in {CREDENTIALS_DIR}. "
        "Please place credentials.json or client_secret_*.json there."
    )

def save_checkpoint(state, filename=".auth_checkpoint.json"):
    try:
        with open(filename, "w") as f:
            json.dump(state, f)
        logging.info(f"Checkpoint saved to {filename}")
    except Exception as e:
        logging.error(f"Failed to save checkpoint: {e}")

def auth_google():
    retry_count = 0
    last_error = None
    token_path = os.path.join(CREDENTIALS_DIR, 'token.json')
    while retry_count < MAX_RETRIES:
        try:
            if not os.path.exists(token_path):
                raise FileNotFoundError(
                    f"token.json not found in {CREDENTIALS_DIR}. Please complete the auth setup first:\n"
                    "  Step 1: python scripts/setup_auth.py\n"
                    "          → Send the printed URL to the user to open in their browser\n"
                    "  Step 2: python scripts/setup_auth.py --callback \"<redirect URL from browser>\"\n"
                    "          → This saves token.json"
                )

            creds = Credentials.from_authorized_user_file(token_path, SCOPES)

            if not creds.valid:
                if creds.expired and creds.refresh_token:
                    creds.refresh(Request())
                    with open(token_path, 'w') as [REDACTED_SECRET]))
                else:
                    raise ValueError(
                        f"token.json is invalid or expired without a refresh token in {CREDENTIALS_DIR}.\n"
                        "Please re-run the auth setup:\n"
                        "  Step 1: python scripts/setup_auth.py\n"
                        "  Step 2: python scripts/setup_auth.py --callback \"<redirect URL from browser>\""
                    )

            return creds
        except Exception as e:
            retry_count += 1
            last_error = e
            logging.warning(f"auth_google() failed (attempt {retry_count}/{MAX_RETRIES}): {e}")
            if retry_count >= MAX_RETRIES:
                state = {"error": str(e), "retry_count": retry_count}
                save_checkpoint(state)
                raise StopIteration(f"Max retries ({MAX_RETRIES}) reached: {e}")


path: scripts/create_event.py
content: 
import argparse
import sys
import os
import logging
import yaml
from datetime import datetime, timedelta
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

sys.path.insert(0, os.path.dirname(__file__))
from auth import auth_google

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(message)s',
    handlers=[logging.StreamHandler()]
)

def get_config():
    config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.yaml')
    try:
        with open(config_path, 'r') as f:
            config = yaml.safe_load(f)
        return config or {}
    except Exception as e:
        logging.warning(f"Could not read config.yaml: {e}. Using defaults.")
        return {}

config = get_config()
TIMEZONE = config.get('timezone', 'Asia/Bangkok')
LOG_LEVEL = getattr(logging, config.get('log_level', 'INFO').upper(), logging.INFO)
logging.basicConfig(
    level=LOG_LEVEL,
    format='%(asctime)s %(levelname)s %(message)s',
    handlers=[logging.StreamHandler()]
)

def create_event(service, title, date, time, duration=60):
    if duration <= 0:
        logging.error(f"duration must be a positive number of minutes, got {duration}")
        raise ValueError(f"duration must be a positive number of minutes, got {duration}")
    start_dt = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M")
    end_dt = start_dt + timedelta(minutes=duration)
    event = {
        'summary': title,
        'start': {'dateTime': start_dt.isoformat(), 'timeZone': TIMEZONE},
        'end': {'dateTime': end_dt.isoformat(), 'timeZone': TIMEZONE},
    }

    try:
        result = service.events().insert(calendarId='primary', body=event).execute()
        logging.info(f"Event created: {result.get('htmlLink')}")
        print(f"Event created: {result.get('htmlLink')}")
    except HttpError as error:
        logging.error(f'An error occurred: {error}')

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Create a Google Calendar event')
    parser.add_argument('--title', required=True, help='Event title')
    parser.add_argument('--date', required=True, help='Date in YYYY-MM-DD format')
    parser.add_argument('--time', required=True, help='Time in HH:MM format')
    parser.add_argument('--duration', type=int, default=60, help='Duration in minutes (default: 60)')
    args = parser.parse_args()

    creds = auth_google()
    service = build('calendar', 'v3', credentials=creds)
    create_event(service, args.title, args.date, args.time, args.duration)


path: scripts/fetch_emails.py
content: import re
import html
import unicodedata
import base64
import sys
import os
import logging
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from auth import auth_google, get_config

MAX_EMAILS = 50
MAX_BODY_LENGTH = 500

config = get_config()
LOG_LEVEL = getattr(logging, config.get('log_level', 'INFO').upper(), logging.INFO)
logging.basicConfig(
    level=LOG_LEVEL,
    format='%(asctime)s %(levelname)s %(message)s',
    handlers=[logging.StreamHandler()]
)

def clean_text(text):
    text = html.unescape(text)
    text = ''.join(c for c in text if unicodedata.category(c) != 'Cf')
    text = re.sub(r'\s+', ' ', text).strip()
    return text

def extract_body(payload):
    if payload.get('mimeType') == 'text/plain':
        data = payload.get('body', {}).get('data', '')
        if data:
            return base64.urlsafe_b64decode(data).decode('utf-8', errors='ignore')
    for part in payload.get('parts', []):
        result = extract_body(part)
        if result:
            return result
    return ''

def _parse_date(date_str):
    """Parse RFC 2822 date string; return epoch-aware datetime or datetime.min on failure."""
    if not date_str:
        return datetime.min.replace(tzinfo=timezone.utc)
    try:
        return parsedate_to_datetime(date_str)
    except Exception:
        return datetime.min.replace(tzinfo=timezone.utc)

def fetch_emails(service, max_results=MAX_EMAILS):
    try:
        emails = []
        next_page_token = None
        logging.info("Fetching emails from Gmail API...")

        lookback_hours = int(config.get('lookback_hours', 24))
        lookback_str = f"newer_than:{lookback_hours}h"
        while len(emails) < max_results:
            batch_size = min(max_results - len(emails), 100)
            results = service.users().messages().list(
                userId='me',
                q=lookback_str,
                maxResults=batch_size,
                pageToken=next_page_token
            ).execute()

            messages = results.get('messages', [])
            logging.info(f"Fetched {len(messages)} message headers.")
            for msg in messages:
                msg_data = service.users().messages().get(userId='me', id=msg['id'], format='full').execute()
                headers = msg_data['payload']['headers']
                subject = next((h['value'] for h in headers if h['name'] == 'Subject'), "")
                sender = next((h['value'] for h in headers if h['name'] == 'From'), "")
                date = next((h['value'] for h in headers if h['name'] == 'Date'), "")
                body = clean_text(extract_body(msg_data['payload']))
                if not body:
                    body = clean_text(msg_data.get('snippet', ''))
                if len(body) > MAX_BODY_LENGTH:
                    body = body[:MAX_BODY_LENGTH] + '...'
                emails.append({'subject': subject, 'from': sender, 'date': date, 'body': body})
                logging.info(f"Email: from={sender}, subject={subject}, date={date}")

            next_page_token = results.get('nextPageToken')
            if not next_page_token:
                break

        logging.info(f"Total emails fetched: {len(emails)}")
        return emails

    except HttpError as error:
        logging.error(f'An error occurred: {error}')
        return []

if __name__ == '__main__':
    creds = auth_google()
    service = build('gmail', 'v1', credentials=creds)
    emails = fetch_emails(service)
    emails.sort(key=lambda e: _parse_date(e['date']), reverse=True)
    print(f"Total emails: {len(emails)}")
    print("=" * 60)
    for email in emails:
        print(f"From: {email['from']}")
        print(f"Date: {email['date']}")
        print(f"Subject: {email['subject']}")
        print(f"Body:\n{email['body']}")
        print("=" * 60)


path: scripts/refresh_service.py
content: import time
import logging
import os
from datetime import datetime
from scripts.auth import auth_google

REFRESH_INTERVAL = 30 * 60  # ทุก 30 นาที
LOCK_FILE = 'refresh_service.lock'

logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s')

class LockFile:
    def __init__(self, path):
        self.path = path
        self.locked = False

    def acquire(self):
        if os.path.exists(self.path):
            # Check if the lock is stale (process died)
            try:
                with open(self.path, 'r') as f:
                    pid = int(f.read())
                # If process is not running, remove stale lock
                if not self._pid_exists(pid):
                    os.remove(self.path)
                else:
                    return False
            except Exception:
                return False
        with open(self.path, 'w') as f:
            f.write(str(os.getpid()))
        self.locked = True
        return True

    def release(self):
        if self.locked and os.path.exists(self.path):
            try:
                os.remove(self.path)
            except Exception:
                pass
        self.locked = False

    def _pid_exists(self, pid):
        # Windows version
        import psutil
        return psutil.pid_exists(pid)

if __name__ == "__main__":
    try:
        import psutil
    except ImportError:
        print("psutil not found. Please install with: pip install psutil")
        exit(1)

    lock = LockFile(LOCK_FILE)
    if not lock.acquire():
        logging.warning("Another refresh_service.py process is already running. Exiting.")
        exit(0)
    try:
        while True:
            try:
                creds = auth_google()  # refresh ถ้าจำเป็น
                logging.info(f"Token refreshed at {datetime.now()}")
            except Exception as e:
                logging.error(f"Refresh failed: {e}")
            time.sleep(REFRESH_INTERVAL)
    finally:
        lock.release()


path: scripts/setup_auth.py
content: """
Auth setup - 2 steps:

Step 1: Get auth URL
  python scripts/setup_auth.py
  → prints URL for user to open in browser

Step 2: Complete auth with callback URL
  python scripts/setup_auth.py --callback "http://localhost/?code=..."
  → saves token.json
"""
import sys
import os
import json
import base64
import hashlib
import secrets
import argparse
import urllib.parse
import logging

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from auth import SCOPES, find_credentials_file, CREDENTIALS_DIR
from google_auth_oauthlib.flow import InstalledAppFlow

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(message)s',
    handlers=[logging.StreamHandler()]
)

def generate_pkce():
    code_verifier = secrets.token_urlsafe(32)
    code_challenge = base64.urlsafe_b64encode(
        hashlib.sha256(code_verifier.encode()).digest()
    ).rstrip(b'=').decode()
    return code_verifier, code_challenge

def get_auth_url():
    creds_file = find_credentials_file()
    flow = InstalledAppFlow.from_client_secrets_file(creds_file, SCOPES)
    flow.redirect_uri = 'http://localhost'

    code_verifier, code_challenge = generate_pkce()
    auth_url, _ = flow.authorization_url(
        prompt='consent',
        access_type='offline',
        code_challenge=code_challenge,
        code_challenge_method='S256'
    )

    with open('.auth_state.json', 'w') as f:
        json.dump({'code_verifier': code_verifier}, f)

    logging.info(f"Generated auth URL: {auth_url}")
    print(auth_url)

def complete_auth(callback_url):
    creds_file = find_credentials_file()
    flow = InstalledAppFlow.from_client_secrets_file(creds_file, SCOPES)
    flow.redirect_uri = 'http://localhost'

    parsed = urllib.parse.urlparse(callback_url)
    params = urllib.parse.parse_qs(parsed.query)
    code = params.get('code', [None])[0]
    if not code:
        logging.error("No authorization code found in URL.")
        sys.exit(1)

    code_verifier = None
    if os.path.exists('.auth_state.json'):
        with open('.auth_state.json') as f:
            state_data = json.load(f)
        code_verifier = state_data.get('code_verifier')
        os.remove('.auth_state.json')

    try:
        flow.fetch_token(code=code, code_verifier=code_verifier)
        token_path = os.path.join(CREDENTIALS_DIR, 'token.json')
        os.makedirs(CREDENTIALS_DIR, exist_ok=True)
        with open(token_path, 'w') as f:
            f.write(flow.credentials.to_json())
        logging.info("Auth complete. token.json saved.")
        print("Auth complete. token.json saved.")
        # Run refresh_service.py automatically after auth complete, only if not already running
        import subprocess
        import psutil
        script_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'refresh_service.py')
        lock_file = os.path.join(os.path.dirname(script_path), 'refresh_service.lock')
        def is_refresh_running():
            if not os.path.exists(lock_file):
                return False
            try:
                with open(lock_file, 'r') as f:
                    pid = int(f.read())
                return psutil.pid_exists(pid)
            except Exception:
                return False
        if is_refresh_running():
            print("refresh_service.py is already running. Skipping auto-run.")
        else:
            try:
                result = subprocess.run([sys.executable, script_path], check=True, capture_output=True, text=True)
                print("\n[refresh_service.py output]\n" + result.stdout)
            except subprocess.CalledProcessError as e:
                print(f"[refresh_service.py error]: {e.stderr}")
    except Exception as e:
        logging.error(f"Failed to fetch token: {e}")
        sys.exit(1)

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--callback', help='Callback URL from browser after authorization')
    args = parser.parse_args()

    if args.callback:
        complete_auth(args.callback)
    else:
        get_auth_url()


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