Skill: cedarscy/gmail-cleaner (version 1.0.0)

--- name: gmail-cleaner description: Clean and organize Gmail accounts in bulk. Use when asked to clean Gmail, remove spam, trash newsletters/promotional emails, bulk-delete emails by sender, create labels, set up auto-filters, or restore emails from trash. Handles single or multiple Gmail accounts via OAuth token files. Works with any Gmail account using the Gmail API. --- # Gmail Cleaner Bulk Gmail cleanup using the Gmail API. Processes 1000 messages per API call. ## Prerequisites - `google-api-python-client`, `google-auth-oauthlib` Python packages (scripts auto-install if missing) - OAuth credentials JSON from Google Cloud Console (Desktop app type) - Token files stored as `.pkl` files per account ## Workflow ### 1. Auth (first time or new account) ```bash python scripts/auth.py --credentials /path/to/credentials.json --token /path/to/token.pkl --scopes settings ``` - `basic` scopes: read/modify/delete messages + labels - `settings` scopes: adds `gmail.settings.basic` (required for creating filters) - Default token path: `~/.openclaw/workspace/gmail_token.pkl` - Default creds path: `~/.openclaw/workspace/gmail_credentials.json` For a second account, specify a different `--token` path (e.g., `gmail_token_work.pkl`). ### 2. Scan (identify what to clean) ```bash python scripts/scan.py --token /path/to/token.pkl --sample 500 ``` Shows inbox counts by category + top 40 senders. Run this first. ### 3. Clean (bulk trash/delete) ```bash # Trash specific senders: python scripts/clean.py --from "[REDACTED_SECRET],[REDACTED_SECRET]" # Trash by Gmail search query: python scripts/clean.py --query "category:promotions older_than:30d" # From a JSON config file (list of {query, label}): python scripts/clean.py --config senders.json # Permanently delete instead of trash: python scripts/clean.py --from "[REDACTED_SECRET]" --delete # Dry run first: python scripts/clean.py --from "[REDACTED_SECRET]" --dry-run ``` ### 4. Deep Clean (comprehensive) ```bash # Full deep clean (4 steps: trash promos → archive old → mark read → purge trash): python scripts/deep_clean.py # Custom age thresholds: python scripts/deep_clean.py --promo-days 7 --archive-days 30 --unread-days 14 # Skip trash purge (keep trash for 30-day auto-delete): python scripts/deep_clean.py --skip-trash-purge ``` ### 5. Organize (labels + filters) ```bash # Apply built-in label set (Business, Banking, Tech, Personal, Trading, Social): python scripts/organize.py # Custom labels/rules/filters from JSON: python scripts/organize.py --config labels.json # Labels only (no filters): python scripts/organize.py --skip-filters ``` ### 6. Restore (rescue emails from trash) ```bash # Restore all emails from a sender + apply a label: python scripts/restore.py --from [REDACTED_SECRET] --label "Harvard Health" # Restore by query: python scripts/restore.py --query "from:apple.com in:trash" --label "Tech/Apple" ``` ## Multiple Accounts Run each script with a different `--token` path per account: ```bash python scripts/scan.py --token ~/.openclaw/workspace/gmail_token_personal.pkl python scripts/scan.py --token ~/.openclaw/workspace/gmail_token_work.pkl python scripts/deep_clean.py --token ~/.openclaw/workspace/gmail_token_work.pkl ``` ## Common Patterns **Full cleanup for one account:** ```bash python scripts/auth.py --scopes settings python scripts/scan.py # identify top senders python scripts/clean.py --from "..." # trash specific senders python scripts/deep_clean.py # clean categories python scripts/organize.py # create labels + filters ``` **Rescue important emails caught in bulk delete:** ```bash python scripts/restore.py --from [REDACTED_SECRET] --label "Important" ``` **Senders config file format** for `clean.py --config`: ```json [ {"query": "from:[REDACTED_SECRET]", "label": "Temu"}, {"query": "category:promotions older_than:7d", "label": "Old Promos"} ] ``` ## Notes - `batchModify` moves to TRASH — Gmail auto-purges after 30 days - `batchDelete` is permanent and irreversible — always dry-run first - Gmail filter creation requires `gmail.settings.basic` scope — re-auth with `--scopes settings` if filters fail with 403 - `scan.py` samples N messages; large inboxes may need `--sample 2000` for accuracy - Credentials JSON comes from Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 → Desktop → Download JSON

path: scripts/auth.py
content: """
Gmail OAuth authentication helper.
Creates/refreshes a token file for use by other gmail-cleaner scripts.

Usage:
  python auth.py                                   # use defaults
  python auth.py --credentials creds.json --token token.pkl
  python auth.py --credentials creds.json --token token.pkl --scopes settings

Scopes:
  basic    (default) - read/modify/delete messages + labels
  settings           - adds gmail.settings.basic (required for creating filters)
  all                - all scopes
"""
import sys, os, pickle, argparse
sys.stdout.reconfigure(encoding='utf-8')

SCOPE_SETS = {
    'basic': [
        'https://www.googleapis.com/auth/gmail.modify',
        'https://www.googleapis.com/auth/gmail.readonly',
    ],
    'settings': [
        'https://www.googleapis.com/auth/gmail.modify',
        'https://www.googleapis.com/auth/gmail.readonly',
        'https://www.googleapis.com/auth/gmail.settings.basic',
    ],
    'all': [
        'https://www.googleapis.com/auth/gmail.modify',
        'https://www.googleapis.com/auth/gmail.readonly',
        'https://www.googleapis.com/auth/gmail.settings.basic',
        'https://www.googleapis.com/auth/gmail.labels',
    ],
}

DEFAULT_CREDS = os.path.join(os.path.expanduser('~'), '.openclaw', 'workspace', 'gmail_credentials.json')
DEFAULT_TOKEN = os.path.join(os.path.expanduser('~'), '.openclaw', 'workspace', 'gmail_token.pkl')

parser = argparse.ArgumentParser(description='Gmail OAuth authentication')
parser.add_argument('--credentials', default=DEFAULT_CREDS, help='Path to OAuth credentials JSON from Google Cloud Console')
parser.add_argument('--token', default=DEFAULT_TOKEN, help='Where to save/load the token')
parser.add_argument('--scopes', default='settings', choices=['basic', 'settings', 'all'], help='Scope set to request')
args = parser.parse_args()

try:
    from google_auth_oauthlib.flow import InstalledAppFlow
    from google.auth.transport.requests import Request
except ImportError:
    print("Installing required packages...")
    os.system(f"{sys.executable} -m pip install google-auth-oauthlib google-auth-httplib2 google-api-python-client -q")
    from google_auth_oauthlib.flow import InstalledAppFlow
    from google.auth.transport.requests import Request

scopes = SCOPE_SETS[args.scopes]
creds = None

# Try to load existing token
if os.path.exists(args.token):
    with open(args.token, 'rb') as f:
        creds = pickle.load(f)

# Refresh if expired
if creds and creds.expired and creds.refresh_token:
    print("Refreshing existing token...")
    creds.refresh(Request())
elif not creds or not creds.valid:
    if not os.path.exists(args.credentials):
        print(f"ERROR: Credentials file not found: {args.credentials}")
        print("Download it from Google Cloud Console > APIs & Services > Credentials > OAuth 2.0 Client IDs > Desktop app > Download JSON")
        sys.exit(1)
    print(f"Starting OAuth flow for scopes: {args.scopes}")
    print("A browser window will open. Sign in and grant permissions.")
    flow = InstalledAppFlow.from_client_secrets_file(args.credentials, scopes)
    creds = flow.run_local_server(port=0)

# Save token
os.makedirs(os.path.dirname(args.token), exist_ok=True)
with open(args.token, 'wb') as f:
    pickle.dump(creds, f)

print(f"\n✅ Token saved to: {args.token}")
print(f"   Scopes: {args.scopes}")
print(f"   Valid: {creds.valid}")
print(f"   Account: {getattr(creds, 'id_token', {}).get('email', 'unknown') if hasattr(creds, 'id_token') else 'check above'}")


path: scripts/clean.py
content: """
Gmail bulk cleaner — trash emails matching sender addresses or search queries.
Processes 1000 messages per API call (Gmail batchModify limit).

Usage:
  # Trash emails from specific senders:
  python clean.py --from "[REDACTED_SECRET],[REDACTED_SECRET]"

  # Trash using a Gmail search query:
  python clean.py --query "category:promotions older_than:30d"

  # Trash multiple queries from a JSON config file:
  python clean.py --config senders.json

  # Permanently delete instead of trash:
  python clean.py --from "[REDACTED_SECRET]" --delete

  # Dry run (count only, don't modify):
  python clean.py --from "[REDACTED_SECRET]" --dry-run

Config JSON format:
  [
    {"query": "from:[REDACTED_SECRET]", "label": "Spam Sender"},
    {"query": "category:promotions older_than:30d", "label": "Old Promotions"}
  ]
"""
import sys, os, pickle, argparse, json, time
sys.stdout.reconfigure(encoding='utf-8')

DEFAULT_TOKEN = os.path.join(os.path.expanduser('~'), '.openclaw', 'workspace', 'gmail_token.pkl')

parser = argparse.ArgumentParser(description='Bulk clean Gmail messages')
parser.add_argument('--token', default=DEFAULT_TOKEN)
parser.add_argument('--from', dest='senders', help='Comma-separated sender email addresses to trash')
parser.add_argument('--query', help='Gmail search query to match messages to trash')
parser.add_argument('--config', help='Path to JSON config file with list of {query, label} objects')
parser.add_argument('--delete', action='store_true', help='Permanently delete (default: move to trash)')
parser.add_argument('--dry-run', action='store_true', help='Count only, do not modify')
parser.add_argument('--max', type=int, default=10000, help='Max messages per query (default: 10000)')
args = parser.parse_args()

try:
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build
except ImportError:
    os.system(f"{sys.executable} -m pip install google-api-python-client google-auth -q")
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build

with open(args.token, 'rb') as f:
    creds = pickle.load(f)
if creds.expired and creds.refresh_token:
    creds.refresh(Request())
    with open(args.token, 'wb') as f:
        pickle.dump(creds, f)

service = build('gmail', 'v1', credentials=creds)

# Build list of (query, label) tuples
tasks = []

if args.config:
    with open(args.config, 'r', encoding='utf-8') as f:
        data = json.load(f)
    for item in data:
        tasks.append((item['query'], item.get('label', item['query'][:50])))

if args.senders:
    for sender in args.senders.split(','):
        sender = sender.strip()
        tasks.append((f"from:{sender}", f"Sender: {sender}"))

if args.query:
    tasks.append((args.query, f"Query: {args.query[:50]}"))

if not tasks:
    print("ERROR: Provide --from, --query, or --config")
    sys.exit(1)

def get_ids(query, max_results=10000):
    ids = []
    page_token = None
    while len(ids) < max_results:
        params = {'userId': 'me', 'q': query, 'maxResults': min(500, max_results - len(ids))}
        if page_token:
            params['pageToken'] = page_token
        try:
            result = service.users().messages().list(**params).execute()
        except Exception as e:
            print(f"  [ERROR] list: {e}")
            break
        msgs = result.get('messages', [])
        ids.extend([m['id'] for m in msgs])
        page_token = result.get('nextPageToken')
        if not page_token or not msgs:
            break
    return ids

def batch_trash(ids):
    total = 0
    for i in range(0, len(ids), 1000):
        chunk = ids[i:i+1000]
        service.users().messages().batchModify(
            userId='me',
            body={'ids': chunk, 'removeLabelIds': ['INBOX'], 'addLabelIds': ['TRASH']}
        ).execute()
        total += len(chunk)
        print(f"    Trashed {total}/{len(ids)}...")
        time.sleep(0.1)
    return total

def batch_delete(ids):
    total = 0
    for i in range(0, len(ids), 1000):
        chunk = ids[i:i+1000]
        service.users().messages().batchDelete(
            userId='me', body={'ids': chunk}
        ).execute()
        total += len(chunk)
        print(f"    Deleted {total}/{len(ids)}...")
        time.sleep(0.1)
    return total

action_verb = "PERMANENTLY DELETE" if args.delete else "TRASH"
print("=" * 60)
print(f"GMAIL BULK CLEANER — {action_verb}")
if args.dry_run:
    print("  *** DRY RUN — no changes will be made ***")
print("=" * 60)

grand_total = 0
for query, label in tasks:
    print(f"\n  [{label}]")
    print(f"  Query: {query}")
    ids = get_ids(query, max_results=args.max)
    if not ids:
        print(f"  Nothing found")
        continue
    print(f"  Found: {len(ids)} messages")
    if args.dry_run:
        grand_total += len(ids)
        continue
    if args.delete:
        n = batch_delete(ids)
    else:
        n = batch_trash(ids)
    grand_total += n

print(f"\n{'='*60}")
if args.dry_run:
    print(f"DRY RUN COMPLETE — would {'delete' if args.delete else 'trash'} {grand_total} messages")
else:
    print(f"DONE — {'deleted' if args.delete else 'trashed'} {grand_total} messages total")
    if not args.delete:
        print("Gmail auto-purges trash after 30 days")
print("=" * 60)


path: scripts/deep_clean.py
content: """
Gmail deep cleaner — comprehensive inbox cleanup in 4 steps:
  1. Trash all promotions, social, forums (by age)
  2. Archive old read emails from inbox
  3. Mark old unread as read
  4. Permanently purge existing trash

Usage:
  python deep_clean.py
  python deep_clean.py --token /path/to/token.pkl
  python deep_clean.py --promo-days 7 --archive-days 30 --unread-days 14
  python deep_clean.py --skip-trash-purge
  python deep_clean.py --dry-run
"""
import sys, os, pickle, argparse, time
sys.stdout.reconfigure(encoding='utf-8')

DEFAULT_TOKEN = os.path.join(os.path.expanduser('~'), '.openclaw', 'workspace', 'gmail_token.pkl')

parser = argparse.ArgumentParser(description='Deep clean Gmail inbox')
parser.add_argument('--token', default=DEFAULT_TOKEN)
parser.add_argument('--promo-days', type=int, default=0,    help='Trash promotions older than N days (0=all, default:0)')
parser.add_argument('--archive-days', type=int, default=60, help='Archive read inbox emails older than N days (default:60)')
parser.add_argument('--unread-days', type=int, default=30,  help='Mark old unread as read after N days (default:30)')
parser.add_argument('--skip-archive', action='store_true',  help='Skip step 2 (archive old read)')
parser.add_argument('--skip-mark-read', action='store_true',help='Skip step 3 (mark old unread as read)')
parser.add_argument('--skip-trash-purge', action='store_true', help='Skip step 4 (purge existing trash)')
parser.add_argument('--dry-run', action='store_true',       help='Count only, no changes')
parser.add_argument('--max', type=int, default=10000,       help='Max messages per query')
args = parser.parse_args()

try:
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build
except ImportError:
    os.system(f"{sys.executable} -m pip install google-api-python-client google-auth -q")
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build

with open(args.token, 'rb') as f:
    creds = pickle.load(f)
if creds.expired and creds.refresh_token:
    creds.refresh(Request())
    with open(args.token, 'wb') as f:
        pickle.dump(creds, f)

service = build('gmail', 'v1', credentials=creds)

def get_ids(query, max_results=10000):
    ids = []
    page_token = None
    while len(ids) < max_results:
        params = {'userId': 'me', 'q': query, 'maxResults': min(500, max_results - len(ids))}
        if page_token:
            params['pageToken'] = page_token
        try:
            r = service.users().messages().list(**params).execute()
        except Exception as e:
            print(f"  [ERROR] {e}"); break
        msgs = r.get('messages', [])
        ids.extend([m['id'] for m in msgs])
        page_token = r.get('nextPageToken')
        if not page_token or not msgs:
            break
    return ids

def batch_op(ids, op, label):
    total = 0
    for i in range(0, len(ids), 1000):
        chunk = ids[i:i+1000]
        try:
            if op == 'trash':
                service.users().messages().batchModify(userId='me', body={
                    'ids': chunk, 'removeLabelIds': ['INBOX'], 'addLabelIds': ['TRASH']
                }).execute()
            elif op == 'archive':
                service.users().messages().batchModify(userId='me', body={
                    'ids': chunk, 'removeLabelIds': ['INBOX']
                }).execute()
            elif op == 'read':
                service.users().messages().batchModify(userId='me', body={
                    'ids': chunk, 'removeLabelIds': ['UNREAD']
                }).execute()
            elif op == 'delete':
                service.users().messages().batchDelete(userId='me', body={'ids': chunk}).execute()
            total += len(chunk)
            print(f"  [{label}] {op} {total}/{len(ids)}")
            time.sleep(0.1)
        except Exception as e:
            print(f"  [ERROR] {e}"); time.sleep(2)
    return total

promo_age = f"older_than:{args.promo_days}d" if args.promo_days > 0 else ""

STEP1_QUERIES = [
    (f"category:promotions {promo_age}".strip(),          "Promotions"),
    ("category:social older_than:30d",                    "Social >30d"),
    ("category:forums older_than:30d",                    "Forums >30d"),
    ("unsubscribe older_than:90d -from:linkedin.com -from:google.com -from:microsoft.com -from:apple.com -from:amazon.com -from:paypal.com -from:stripe.com", "Newsletter >90d"),
]

STEP2_QUERIES = [
    (f"in:inbox is:read older_than:{args.archive_days}d -label:important -is:starred", f"Read inbox >{args.archive_days}d"),
    ("in:inbox category:updates is:read older_than:14d",  "Updates read >14d"),
    ("in:inbox category:social is:read older_than:14d",   "Social read >14d"),
]

print("=" * 60)
print("GMAIL DEEP CLEANER")
if args.dry_run:
    print("  *** DRY RUN — no changes ***")
print("=" * 60)

grand_trash = grand_archive = 0

# STEP 1: Trash promotions + newsletters
print(f"\n── STEP 1: Trash Promotions & Newsletters ──")
for q, lbl in STEP1_QUERIES:
    ids = get_ids(q, args.max)
    if not ids:
        print(f"  [0] {lbl}")
        continue
    print(f"  [{len(ids)}] {lbl}")
    if not args.dry_run:
        grand_trash += batch_op(ids, 'trash', lbl)
    else:
        grand_trash += len(ids)

# STEP 2: Archive old read emails
if not args.skip_archive:
    print(f"\n── STEP 2: Archive Old Read Emails ──")
    for q, lbl in STEP2_QUERIES:
        ids = get_ids(q, args.max)
        if not ids:
            print(f"  [0] {lbl}")
            continue
        print(f"  [{len(ids)}] {lbl}")
        if not args.dry_run:
            grand_archive += batch_op(ids, 'archive', lbl)
        else:
            grand_archive += len(ids)

# STEP 3: Mark old unread as read
if not args.skip_mark_read:
    print(f"\n── STEP 3: Mark Old Unread as Read (>{args.unread_days}d) ──")
    ids = get_ids(f"in:inbox is:unread older_than:{args.unread_days}d -label:important -is:starred", args.max)
    if ids:
        print(f"  [{len(ids)}] Old unread")
        if not args.dry_run:
            batch_op(ids, 'read', 'Old unread')
    else:
        print("  [0] Nothing found")

# STEP 4: Purge trash
if not args.skip_trash_purge:
    print(f"\n── STEP 4: Purge Existing Trash ──")
    ids = get_ids("in:trash older_than:1d", args.max)
    if ids:
        print(f"  [{len(ids)}] In trash")
        if not args.dry_run:
            batch_op(ids, 'delete', 'Trash purge')
    else:
        print("  [0] Trash already empty")

print(f"\n{'='*60}")
print(f"{'DRY RUN' if args.dry_run else 'DONE'}")
print(f"  Trashed:  {grand_trash}")
print(f"  Archived: {grand_archive}")
print("=" * 60)


path: scripts/organize.py
content: """
Gmail organizer — creates a label hierarchy, applies labels to existing emails,
and optionally creates auto-filter rules for future emails.

Usage:
  python organize.py                         # use built-in label set
  python organize.py --config labels.json   # use custom label config
  python organize.py --skip-filters         # labels only, no filters
  python organize.py --dry-run

Custom config JSON format:
{
  "labels": [
    {"name": "Business",         "color": "green"},
    {"name": "Business/Solar",   "color": "green"}
  ],
  "rules": [
    {"query": "from:cedarscy.com", "label": "Business/Solar"},
    {"query": "from:revolut.com",  "label": "Banking"}
  ],
  "filters": [
    {"from": "revolut.com", "label": "Banking"},
    {"from": "apple.com",   "label": "Tech"}
  ]
}

Colors: blue, navy, green, teal, red, purple, orange, yellow, pink
"""
import sys, os, pickle, argparse, json, time
sys.stdout.reconfigure(encoding='utf-8')

DEFAULT_TOKEN = os.path.join(os.path.expanduser('~'), '.openclaw', 'workspace', 'gmail_token.pkl')

COLORS = {
    'blue':   ('#4a86e8', '#ffffff'),
    'navy':   ('#0d3472', '#ffffff'),
    'green':  ('#16a766', '#ffffff'),
    'teal':   ('#149e60', '#ffffff'),
    'red':    ('#fb4c2f', '#ffffff'),
    'purple': ('#8e63ce', '#ffffff'),
    'orange': ('#e07e00', '#ffffff'),
    'yellow': ('#f2c960', '#000000'),
    'pink':   ('#f691b2', '#000000'),
}

# ── Built-in defaults (override with --config) ──────────────────────────────
DEFAULT_LABELS = [
    ("Business",                "green"),
    ("Business/Finance",        "navy"),
    ("Banking",                 "navy"),
    ("Tech",                    "purple"),
    ("Tech/Apple",              "purple"),
    ("Tech/Google",             "blue"),
    ("Tech/Microsoft",          "blue"),
    ("Personal",                "pink"),
    ("Personal/Shopping",       "pink"),
    ("Trading",                 "pink"),
    ("Social",                  "red"),
    ("Social/LinkedIn",         "navy"),
]

DEFAULT_RULES = [
    ("from:apple.com OR from:icloud.com",                           "Tech/Apple"),
    ("from:accounts.google.com OR from:google.com",                 "Tech/Google"),
    ("from:microsoft.com OR from:onedrive.com OR from:outlook.com", "Tech/Microsoft"),
    ("from:revolut.com OR from:paypal.com OR from:stripe.com",      "Banking"),
    ("from:amazon.com -from:[REDACTED_SECRET]",                 "Personal/Shopping"),
    ("from:linkedin.com",                                           "Social/LinkedIn"),
]

DEFAULT_FILTERS = [
    ("revolut.com",              "Banking"),
    ("apple.com OR icloud.com",  "Tech/Apple"),
    ("google.com",               "Tech/Google"),
    ("microsoft.com",            "Tech/Microsoft"),
    ("linkedin.com",             "Social/LinkedIn"),
]
# ─────────────────────────────────────────────────────────────────────────────

parser = argparse.ArgumentParser(description='Organize Gmail with labels and filters')
parser.add_argument('--token', default=DEFAULT_TOKEN)
parser.add_argument('--config', help='Path to custom labels/rules/filters JSON config')
parser.add_argument('--skip-filters', action='store_true', help='Skip creating Gmail auto-filters')
parser.add_argument('--skip-rules', action='store_true',   help='Skip applying labels to existing emails')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()

try:
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build
except ImportError:
    os.system(f"{sys.executable} -m pip install google-api-python-client google-auth -q")
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build

with open(args.token, 'rb') as f:
    creds = pickle.load(f)
if creds.expired and creds.refresh_token:
    creds.refresh(Request())
    with open(args.token, 'wb') as f:
        pickle.dump(creds, f)

service = build('gmail', 'v1', credentials=creds)

# Load config
if args.config:
    with open(args.config, 'r', encoding='utf-8') as f:
        cfg = json.load(f)
    label_defs = [(l['name'], l.get('color', 'blue')) for l in cfg.get('labels', [])]
    rules      = [(r['query'], r['label']) for r in cfg.get('rules', [])]
    filter_defs = [(f['from'], f['label']) for f in cfg.get('filters', [])]
else:
    label_defs  = DEFAULT_LABELS
    rules       = DEFAULT_RULES
    filter_defs = DEFAULT_FILTERS

print("=" * 55)
print("GMAIL ORGANIZER")
if args.dry_run:
    print("  *** DRY RUN ***")
print("=" * 55)

# STEP 1: Create labels
print("\n── STEP 1: Create Labels ──")
existing = service.users().labels().list(userId='me').execute().get('labels', [])
existing_names = {l['name']: l['id'] for l in existing}
label_ids = dict(existing_names)

for name, color in label_defs:
    if name in existing_names:
        print(f"  [skip] {name}")
        continue
    if args.dry_run:
        print(f"  [would create] {name}")
        continue
    bg, fg = COLORS.get(color, COLORS['blue'])
    try:
        r = service.users().labels().create(userId='me', body={
            'name': name,
            'labelListVisibility': 'labelShow',
            'messageListVisibility': 'show',
            'color': {'backgroundColor': bg, 'textColor': fg}
        }).execute()
        label_ids[name] = r['id']
        print(f"  [✓] {name}")
    except Exception as e:
        print(f"  [!] {name}: {e}")
    time.sleep(0.1)

# STEP 2: Apply labels to existing emails
if not args.skip_rules:
    print("\n── STEP 2: Apply Labels to Existing Emails ──")
    grand = 0
    for query, label in rules:
        lid = label_ids.get(label)
        if not lid:
            print(f"  [!] No label ID for: {label}")
            continue
        ids = []
        page_token = None
        while True:
            params = {'userId': 'me', 'q': query + ' -in:trash -in:spam', 'maxResults': 500}
            if page_token:
                params['pageToken'] = page_token
            try:
                r = service.users().messages().list(**params).execute()
            except:
                break
            msgs = r.get('messages', [])
            ids.extend([m['id'] for m in msgs])
            page_token = r.get('nextPageToken')
            if not page_token or not msgs:
                break

        if not ids:
            print(f"  [0]  {label}")
            continue
        if args.dry_run:
            print(f"  [{len(ids)}]  {label} (dry run)")
            grand += len(ids)
            continue

        for i in range(0, len(ids), 1000):
            service.users().messages().batchModify(
                userId='me', body={'ids': ids[i:i+1000], 'addLabelIds': [lid]}
            ).execute()
            time.sleep(0.1)
        grand += len(ids)
        print(f"  [{len(ids)}]  {label}")

    print(f"\n  Total emails labeled: {grand}")

# STEP 3: Create filters
if not args.skip_filters:
    print("\n── STEP 3: Create Auto-Filters ──")
    print("  Note: Requires gmail.settings.basic scope (run auth.py --scopes settings)")
    created = failed = 0
    for from_q, label in filter_defs:
        lid = label_ids.get(label)
        if not lid:
            print(f"  [!] No label ID for: {label}")
            failed += 1
            continue
        if args.dry_run:
            print(f"  [would create] {from_q} → {label}")
            created += 1
            continue
        try:
            service.users().settings().filters().create(
                userId='me',
                body={
                    'criteria': {'from': from_q},
                    'action': {'addLabelIds': [lid]}
                }
            ).execute()
            print(f"  [✓] {from_q[:40]} → {label}")
            created += 1
        except Exception as e:
            print(f"  [!] {from_q[:40]}: {str(e)[:60]}")
            failed += 1
        time.sleep(0.1)
    print(f"\n  Filters: {created} created, {failed} failed")

print(f"\n{'='*55}")
print("DONE")
print("=" * 55)


path: scripts/restore.py
content: """
Gmail restore — move emails from trash back to a label (skip inbox).
Useful for rescuing emails that were bulk-trashed by mistake.

Usage:
  # Restore all emails from a sender, apply a label:
  python restore.py --from [REDACTED_SECRET] --label "Harvard Health"

  # Restore using any Gmail query:
  python restore.py --query "from:apple.com in:trash" --label "Tech/Apple"

  # Create the label if it doesn't exist, with a color:
  python restore.py --from [REDACTED_SECRET] --label "My Label" --color blue

  # Dry run:
  python restore.py --from [REDACTED_SECRET] --label "My Label" --dry-run

Available colors: blue, green, red, purple, orange, yellow, teal, navy
"""
import sys, os, pickle, argparse
sys.stdout.reconfigure(encoding='utf-8')

DEFAULT_TOKEN = os.path.join(os.path.expanduser('~'), '.openclaw', 'workspace', 'gmail_token.pkl')

COLORS = {
    'blue':   ('#4a86e8', '#ffffff'),
    'navy':   ('#1c4587', '#ffffff'),
    'green':  ('#16a766', '#ffffff'),
    'red':    ('#fb4c2f', '#ffffff'),
    'purple': ('#8e63ce', '#ffffff'),
    'orange': ('#e07e00', '#ffffff'),
    'yellow': ('#f2c960', '#000000'),
    'teal':   ('#149e60', '#ffffff'),
}

parser = argparse.ArgumentParser(description='Restore Gmail emails from trash')
parser.add_argument('--token', default=DEFAULT_TOKEN)
parser.add_argument('--from', dest='sender', help='Sender email address to restore')
parser.add_argument('--query', help='Gmail search query (will auto-add "in:trash")')
parser.add_argument('--label', required=True, help='Label to apply to restored emails')
parser.add_argument('--color', default='blue', choices=list(COLORS.keys()), help='Label color if creating new (default: blue)')
parser.add_argument('--skip-inbox', action='store_true', default=True, help='Keep restored emails out of inbox (default: True)')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()

try:
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build
except ImportError:
    os.system(f"{sys.executable} -m pip install google-api-python-client google-auth -q")
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build

with open(args.token, 'rb') as f:
    creds = pickle.load(f)
if creds.expired and creds.refresh_token:
    creds.refresh(Request())
    with open(args.token, 'wb') as f:
        pickle.dump(creds, f)

service = build('gmail', 'v1', credentials=creds)

# Get or create label
existing = service.users().labels().list(userId='me').execute().get('labels', [])
label_id = None
for l in existing:
    if l['name'] == args.label:
        label_id = l['id']
        print(f"✅ Label exists: {args.label} (id: {label_id})")
        break

if not label_id and not args.dry_run:
    bg, fg = COLORS[args.color]
    result = service.users().labels().create(userId='me', body={
        'name': args.label,
        'labelListVisibility': 'labelShow',
        'messageListVisibility': 'show',
        'color': {'backgroundColor': bg, 'textColor': fg}
    }).execute()
    label_id = result['id']
    print(f"✅ Created label: {args.label} (id: {label_id})")

# Build query
if args.sender:
    query = f"from:{args.sender} in:trash"
elif args.query:
    query = args.query if 'in:trash' in args.query else args.query + ' in:trash'
else:
    print("ERROR: Provide --from or --query")
    sys.exit(1)

print(f"\nSearching: {query}")
ids = []
page_token = None
while True:
    params = {'userId': 'me', 'q': query, 'maxResults': 500}
    if page_token:
        params['pageToken'] = page_token
    result = service.users().messages().list(**params).execute()
    msgs = result.get('messages', [])
    if not msgs:
        break
    ids.extend([m['id'] for m in msgs])
    page_token = result.get('nextPageToken')
    if not page_token:
        break

print(f"Found: {len(ids)} messages in trash")

if args.dry_run:
    print(f"DRY RUN — would restore {len(ids)} emails to label '{args.label}'")
    sys.exit(0)

if not ids:
    print("Nothing to restore.")
    sys.exit(0)

# Restore: remove TRASH, apply label, optionally skip INBOX
restore_body = {
    'ids': [],
    'removeLabelIds': ['TRASH'],
    'addLabelIds': [label_id] if label_id else []
}

total = 0
for i in range(0, len(ids), 1000):
    chunk = ids[i:i+1000]
    body = {
        'ids': chunk,
        'removeLabelIds': ['TRASH'],
        'addLabelIds': [label_id] if label_id else []
    }
    service.users().messages().batchModify(userId='me', body=body).execute()
    total += len(chunk)
    print(f"  Restored {total}/{len(ids)}...")

print(f"\n✅ Restored {total} emails to label '{args.label}' (skipping inbox)")


path: scripts/scan.py
content: """
Gmail inbox scanner — shows stats and top senders.
Use this FIRST to identify what to clean.

Usage:
  python scan.py
  python scan.py --token /path/to/token.pkl --sample 500
"""
import sys, os, pickle, argparse
sys.stdout.reconfigure(encoding='utf-8')

from collections import Counter

DEFAULT_TOKEN = os.path.join(os.path.expanduser('~'), '.openclaw', 'workspace', 'gmail_token.pkl')

parser = argparse.ArgumentParser(description='Scan Gmail inbox and show top senders')
parser.add_argument('--token', default=DEFAULT_TOKEN, help='Path to token pickle file')
parser.add_argument('--sample', type=int, default=500, help='Number of messages to sample for sender analysis (default: 500)')
parser.add_argument('--top', type=int, default=40, help='Number of top senders to show (default: 40)')
args = parser.parse_args()

try:
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build
except ImportError:
    os.system(f"{sys.executable} -m pip install google-api-python-client google-auth -q")
    from google.auth.transport.requests import Request
    from googleapiclient.discovery import build

with open(args.token, 'rb') as f:
    creds = pickle.load(f)
if creds.expired and creds.refresh_token:
    creds.refresh(Request())
    with open(args.token, 'wb') as f:
        pickle.dump(creds, f)

service = build('gmail', 'v1', credentials=creds)

print("=" * 60)
print("GMAIL INBOX SCANNER")
print("=" * 60)

# Quick stats
for label, q in [
    ('INBOX total',     'in:inbox'),
    ('INBOX unread',    'in:inbox is:unread'),
    ('Promotions',      'category:promotions -in:trash'),
    ('Social',          'category:social -in:trash'),
    ('Updates',         'category:updates -in:trash'),
    ('Forums',          'category:forums -in:trash'),
    ('Spam',            'in:spam'),
    ('Trash',           'in:trash'),
]:
    r = service.users().messages().list(userId='me', q=q, maxResults=1).execute()
    print(f"  {label:<20} ~{r.get('resultSizeEstimate', 0):>6,}")

print(f"\nSampling {args.sample} messages for sender analysis...")
result = service.users().messages().list(userId='me', q='-in:trash -in:spam', maxResults=args.sample).execute()
msgs = result.get('messages', [])

senders = Counter()
domains = Counter()
for m in msgs:
    detail = service.users().messages().get(
        userId='me', id=m['id'], format='metadata', metadataHeaders=['From']
    ).execute()
    for h in detail.get('payload', {}).get('headers', []):
        if h['name'] == 'From':
            addr = h['value']
            senders[addr] += 1
            # Extract domain
            if '@' in addr:
                domain = addr.split('@')[-1].rstrip('>')
                domains[domain] += 1
            break

print(f"\n{'TOP SENDERS':}")
print("-" * 60)
for s, c in senders.most_common(args.top):
    print(f"  {c:4d}  {s}")

print(f"\n{'TOP DOMAINS':}")
print("-" * 60)
for d, c in domains.most_common(20):
    print(f"  {c:4d}  {d}")

print("\n" + "=" * 60)
print("TIP: Use clean.py --from '[REDACTED_SECRET]' to trash these")
print("     Use deep_clean.py for bulk category cleanup")
print("=" * 60)


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