#!/usr/bin/env python3 """Apply Gmail labels for owner mailbox automation. Currently classifies self-sent briefing mails into existing/created Gmail labels such as "브리핑" and "주식". Designed for OpenClaw cron at 01:00 Asia/Seoul. """ from __future__ import annotations import argparse import json import subprocess import sys from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Iterable ACCOUNT = "mini.snowoyh@gmail.com" STATE_DIR = Path(__file__).resolve().parents[1] / "state" LOG_PATH = STATE_DIR / "gmail_label_classify.log" @dataclass(frozen=True) class Rule: name: str label: str query: str remove_labels: tuple[str, ...] = () @dataclass(frozen=True) class CleanupRule: name: str query: str RULES = [ Rule( name="briefing", label="브리핑", query='newer_than:7d subject:("[오전 브리핑]" OR "[오후 브리핑]")', ), Rule( name="stock_analysis", label="종목분석", query='newer_than:30d subject:"[비하이브 종목분석]"', remove_labels=("주식",), ), Rule( name="stock_briefing", label="주식브리핑", query='newer_than:30d subject:"[주식 리포트]"', remove_labels=("주식",), ), ] CLEANUP_RULES = [ CleanupRule( name="test_mail_24h", query=( 'older_than:1d -in:trash ' 'from:mini.snowoyh@gmail.com to:mini.snowoyh@gmail.com ' '(subject:테스트 OR subject:test OR subject:TEST)' ), ), ] def run_gog(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: cmd = ["gog", "gmail", *args, "--account", ACCOUNT, "--no-input"] return subprocess.run(cmd, text=True, capture_output=True, check=check) def list_labels() -> set[str]: cp = run_gog(["labels", "list", "--json"]) data = json.loads(cp.stdout or "{}") labels = data.get("labels") or data.get("Labels") or [] names: set[str] = set() for item in labels: if isinstance(item, dict): name = item.get("name") or item.get("Name") if name: names.add(name) if not names: # Fallback for older gog JSON shapes: parse plain output. cp = run_gog(["labels", "list", "--plain"]) for line in cp.stdout.splitlines()[1:]: parts = line.split("\t") if len(parts) >= 2: names.add(parts[1]) return names def ensure_label(label: str, dry_run: bool) -> None: if label in list_labels(): return if dry_run: print(f"[dry-run] create label: {label}") return run_gog(["labels", "create", label]) def search_threads(query: str) -> list[dict]: cp = run_gog(["search", query, "--max", "100", "--json"]) data = json.loads(cp.stdout or "{}") return data.get("threads") or [] def has_label(thread: dict, label: str) -> bool: labels = thread.get("labels") or [] return label in labels def apply_label(thread_id: str, label: str, dry_run: bool) -> None: if dry_run: print(f"[dry-run] add {label} to thread {thread_id}") return run_gog(["labels", "modify", thread_id, "--add", label]) def remove_label(thread_id: str, label: str, dry_run: bool) -> None: if dry_run: print(f"[dry-run] remove {label} from thread {thread_id}") return run_gog(["labels", "modify", thread_id, "--remove", label]) def log(line: str) -> None: STATE_DIR.mkdir(parents=True, exist_ok=True) now = datetime.now().isoformat(timespec="seconds") with LOG_PATH.open("a", encoding="utf-8") as f: f.write(f"{now} {line}\n") def cleanup_messages(dry_run: bool = False) -> tuple[int, int]: total_seen = 0 total_trashed = 0 for rule in CLEANUP_RULES: threads = search_threads(rule.query) total_seen += len(threads) if dry_run: for thread in threads: print(f"[dry-run] trash {rule.name}: {thread.get('id')} {thread.get('subject')}") elif threads: run_gog(["trash", "--query", rule.query, "--max", "100"]) total_trashed += len(threads) print(f"{rule.name}: seen={len(threads)} trashed={0 if dry_run else len(threads)}") return total_seen, total_trashed def classify(dry_run: bool = False) -> int: total_seen = 0 total_changed = 0 for rule in RULES: ensure_label(rule.label, dry_run) threads = search_threads(rule.query) total_seen += len(threads) changed = 0 for thread in threads: thread_id = thread.get("id") if not thread_id: continue if not has_label(thread, rule.label): apply_label(thread_id, rule.label, dry_run) changed += 1 for old_label in rule.remove_labels: if has_label(thread, old_label): remove_label(thread_id, old_label, dry_run) changed += 1 total_changed += changed print(f"{rule.name}: seen={len(threads)} changed={changed}") cleanup_seen, cleanup_trashed = cleanup_messages(dry_run=dry_run) status = "dry-run" if dry_run else "ok" summary = ( f"status={status} seen={total_seen} labeled={total_changed} " f"cleanup_seen={cleanup_seen} cleanup_trashed={0 if dry_run else cleanup_trashed}" ) print(summary) if not dry_run: log(summary) return 0 def main(argv: Iterable[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--dry-run", action="store_true") args = parser.parse_args(argv) try: return classify(dry_run=args.dry_run) except subprocess.CalledProcessError as e: print(e.stdout, end="", file=sys.stderr) print(e.stderr, end="", file=sys.stderr) return e.returncode or 1 if __name__ == "__main__": raise SystemExit(main())