Install
openclaw skills install @joeyang001/gmail-cleanupApproval-gated Gmail cleanup — triage your inbox into spam/info/action lanes, draft replies, and archive or trash only after you approve on Telegram.
openclaw skills install @joeyang001/gmail-cleanupApproval-gated Gmail triage: classify inbox mail into three lanes, propose cleanup and reply drafts, route every decision through Telegram, and execute only what the owner approves. Nothing is sent or trashed automatically.
Built on the gog skill (Google Workspace CLI). Requires the gog skill and a
configured Telegram channel.
scripts/delta-scan.sh — scans mail new since the
last run (watermark-based delta, 24h bootstrap) instead of the newest N, so
nothing is missed between runs. Documented grouped bulk digests and a
day/night cadence example. Scripts still require GOG_KEYRING_PASSWORD from
the environment only.export GOG_KEYRING_PASSWORD='***'
example alongside the secret-manager form. No script changes.GOG_KEYRING_PASSWORD per run from a secret manager; do not persist or commit it.GOG_KEYRING_PASSWORD must be supplied by the caller
(no fallback to ~/.config/gogcli/keyring.pass), and the Workspace CLI is invoked
as gog from PATH only (no GOG_BIN override).cleanup-execute.py now enforces the approval, dry-run,
and send gates in code: dry-run is the default; --apply is refused without an
explicit approval{approved,approvedBy,approvedAt} artifact; send-draft
additionally requires --send and per-action "sendApproved": true and
safety.blockAllSends == false.triage-scan.py — retry + exponential backoff on
Gmail 403 rateLimitExceeded, optional per-request pacing, and tolerance for
malformed/partial API responses (skipped, not fatal).Authenticate gog for the target account — gog auth add you@gmail.com
(confirm with gog auth list).
Provide the keyring password to the run (required). gog needs
GOG_KEYRING_PASSWORD in the environment to unlock its keyring without an
interactive prompt. The scripts require it and never read credential files
themselves. Export it for the run — ideally sourced from a secret manager rather
than typed in plaintext:
export GOG_KEYRING_PASSWORD='<your gog keyring password>' # current session only
# or, sourced from a secret manager:
export GOG_KEYRING_PASSWORD="$(pass show gog-keyring)" # current session only
Do not commit the secret or persist it in shell rc; prefer a secret manager or CI secret store for scheduled runs.
Put gog on PATH. The scripts call gog directly and accept no override.
Configure the Telegram channel in OpenClaw (channels.telegram) with the
owner's numeric user id in an allowlist. Outbound uses openclaw message send.
Create the state dir and config:
mkdir -p state/gmail-cleanup
cp skills/gmail-cleanup/templates/config.example.json state/gmail-cleanup/config.json
# then edit account + notify.target
| Lane | Meaning | Standing action |
|---|---|---|
spam_suspect | missed spam — cold outreach, lookalike/sketchy domains, scam phrasing, no prior thread | trash, on approval only |
info_only | informational, no action needed — receipts, shipping, statements, newsletters, notifications, noreply@, List-Unsubscribe present | mark-read + archive |
action_required | needs the owner — real humans asking something, bills/invoices with deadlines, security/account alerts, invites | keep + draft a reply |
Every lane's action and whether it needs approval are config-driven (see
templates/config.example.json). Defaults match the owner's standing choices:
archive for info-only, trash-on-approval for spam, draft-on-approval for replies.
state/gmail-cleanup/config.json — copy from templates/config.example.json.
Everything the workflow does is data-driven here; no skill edits needed to retune.
account — Gmail address.scope.query — base Gmail search syntax; the scope knob (e.g. in:inbox).
When scripts/delta-scan.sh runs, it replaces this at runtime with
in:inbox after:<lastScanEpoch> so only the delta window is fetched.
scope.extraQueries[] merges additional queries (e.g. category:promotions).
De-duplicated by id.policy.<lane>.action — trash | archive | mark-read | none.policy.<lane>.requiresApproval — true/false.policy.action_required.draftReply / sendRequiresApproval.limits.scanMax — max candidates fetched per run (a safety cap, not the
primary scope). In delta mode this caps a single run; keep it modest (≈50–200).
Per-message get is API-costly, so a cap prevents a runaway backfill.limits.maxActionsPerRun — hard cap on mutations per run.notify.channel / notify.target — where approval requests go.cadence.everyHours — nominal interval (see Cadence).safety.dryRunFirst, safety.blockAllSends — safety gates.GMAIL_CLEANUP_PACE_MS — delay between API requests (default 250).GMAIL_CLEANUP_RETRIES — max retries per request on 403 rateLimitExceeded (default 4).limits.scanMax modest. Gmail's per-user Total Query Cost is a
per-minute budget; per-message get calls are the expensive part.scripts/cleanup-execute.py refuses to mutate anything without an explicit
approval artifact embedded in the action spec:
{
"approval": {
"approved": true,
"approvedBy": "telegram:<owner-id>",
"approvedAt": "2026-10-12T01:00:00Z"
},
"actions": [ { "type": "archive", "id": "<msgId>" } ]
}
--apply.--apply is refused unless approval.approved is boolean true with
non-empty approvedBy and approvedAt (else it exits and logs a REFUSED line).send-draft runs only when --apply and
--send and safety.blockAllSends == false and the action itself has
"sendApproved": true. Otherwise it is skipped and logged.state/gmail-cleanup/approved.json before executing.scripts/delta-scan.sh fetches only mail
new since the last run — it computes in:inbox after:<lastScanEpoch> from
state/gmail-cleanup/watermark.json (first run bootstraps the last
GMAIL_CLEANUP_BOOTSTRAP_HOURS, default 24h) and advances the watermark only on
success. Fallback: scripts/triage-scan.py scans the newest limits.scanMax.
Both write state/gmail-cleanup/latest.json and mutate nothing.action_required — never auto-clean something ambiguous.state/gmail-cleanup/pending.json with the
cleanup batch and any reply drafts, then send a compact, grouped digest to
Telegram via scripts/notify-telegram.sh.approve cleanup, trash ok,
approve drafts 1,2, skip 3, edit 2 <note>). Parse the reply into a
resolved action list and an approval{} block (approvedBy = the owner's
Telegram identity, approvedAt = the reply time) — write both to
state/gmail-cleanup/approved.json. This artifact is the approval.
Bulk is the default for info-only: approve cleanup approves the whole
proposed batch in one action.scripts/cleanup-execute.py <approved.json> runs dry-run first
(default); re-run with --apply to mutate (refused without the approval
artifact). Archive/read for info-only; trash for approved spam; gog gmail drafts reply for drafts. Sending a draft needs --apply --send plus
"sendApproved": true on the action and safety.blockAllSends == false.state/gmail-cleanup/audit.log (timestamp, mode, action, id, result).Keep the Telegram digest short and grouped — a phone screen, not a log:
reply "approve cleanup" to bulk-archive the N info-only items.If the delta window is empty, send nothing (or a single "no new mail" line).
spam_suspect — no prior thread with sender; lookalike/typo domains; Reply-To
mismatched with From; SPF/DKIM/DMARC fail in Authentication-Results; scam
phrasing ("you've won", crypto, pharma, urgency + attachment); unsolicited sales.
info_only — List-Unsubscribe / List-Unsubscribe-Post present; Precedence: bulk; noreply@ / notifications@ / no-reply@ senders; categories
Promotions/Social/Updates/Forums; receipts/statements/shipping; CI/automation
notifications (e.g. GitHub Actions); first name + no question; "this is a
receipt/confirmation/notification".
action_required — from a human (real name, personal or known org domain) in a thread the owner is part of; contains a direct question, request, deadline, or "confirm"/"action required"/"due"; bills/invoices with a due date; security or account alerts that ask to verify/act; calendar invites. Default bucket.
Signals are inputs to judgment, not hard rules. Prefer precision over volume: a wrong trash is worse than a missed cleanup.
cleanup-execute.py, not
merely documented: no --apply without an approval{} artifact, no send without
--apply --send + sendApproved:true + blockAllSends=false.--apply is a separate, explicit step.action_required (surfaced, not cleaned).limits.maxActionsPerRun).safety.blockAllSends is a hard kill-switch for the send step.--gmail-no-send on gog except during an owner-approved send.Run a day/night delta cadence so the owner gets timely pings without overnight noise. Example: every 2h during the day and every 4h overnight —
0 0,4,8,10,12,14,16,18,20,22 * * * # owner's local timezone
Each run is a delta since the previous run (see scripts/delta-scan.sh), so
runs stay small even with high inbox volume. Preferred scheduler: an OpenClaw
cron isolated job with delivery.mode: announce to the Telegram channel,
payload agentTurn invoking this skill's workflow. If no channel is available,
fall back to a heartbeat tasks: entry in HEARTBEAT.md.
state/gmail-cleanup/
config.json # policy (from templates/config.example.json)
watermark.json # lastScanEpoch for delta scanning (written by delta-scan.sh)
latest.json # scan output
pending.json # proposal awaiting approval
approved.json # resolved action list + approval{} artifact
audit.log # append-only action log
scripts/delta-scan.sh — preferred scan. Read-only delta: in:inbox after:<lastScanEpoch>, 24h bootstrap on first run, watermark advanced on
success. Wraps triage-scan.py with a temp config. Credentials from env only.scripts/triage-scan.py — read-only scan → latest.json. Retries with
exponential backoff on Gmail 403 rateLimitExceeded, paces per-message get
calls (GMAIL_CLEANUP_PACE_MS), and skips malformed/partial responses instead of
crashing; reports a skipped count. Credentials from env only.scripts/cleanup-execute.py — dry-run/apply an approved action list → audit.log.
Enforces the approval artifact for --apply, the --send+sendApproved gate for
sends, and backs off on rate limits.scripts/notify-telegram.sh — send a digest to Telegram via openclaw message send.