If you landed here, you probably typed something like “how do I make OpenClaw auto-unsubscribe from unwanted emails?” into your search bar. Same. I wrote this because the official docs only gloss over it and every GitHub comment tends to re-explain the same four pitfalls. Below is the playbook I now run on two separate inboxes (one Gmail, one Fastmail IMAP) that cuts my weekly promo mail by ~80 %. No Chrome extensions, no paid SaaS—just OpenClaw 0.30.2 running on ClawCloud.

Why use OpenClaw for auto-unsubscribe?

Email services have a built-in “unsubscribe” button, but it’s per-thread and manual. OpenClaw gives you:

  • Batching — process 500+ messages in one sweep.
  • Two modes — click the unsubscribe link in a headless browser, or draft+send a polite “please remove me” email when the link is missing.
  • Scheduling — run daily at 02:00 server time.
  • Memory — keep a small kv-store of domains already handled to avoid loops.
  • Custom guardrails — confirm before touching work-related senders or anything from “@github.com”.

Prereqs: what you need before the setup

  • Node 22+ (OpenClaw now requires node --version → 22.x.x)
  • OpenClaw 0.30.2 or newer: npm i -g openclaw@latest
  • A ClawCloud project (free tier is fine) or a local machine that can stay online for the schedule
  • Gmail OAuth token or IMAP credentials. If you use Gmail, create a “Desktop” OAuth client on Google Cloud → enable Gmail API.
  • Headless Chromium (OpenClaw ships with playwright-chromium 1.44, nothing extra to install)

Quick start: spin up the agent

On ClawCloud the UI does most of the scaffolding, but here’s the raw CLI flow so you know what the UI actually does.

# create a new agent folder locally (even if you later push to Cloud) mkdir claw-unsub && cd claw-unsub npx openclaw init unsub-agent # Answer wizard questions → pick "Email & Comms" cluster # When asked for tools, add: Gmail, Browser, Scheduler, Memory

The init wizard generates three key files:

  • gateway.json – entrypoints & UI descriptors
  • daemon.js – the agent brain
  • secrets.env – never commit this file

Configuring Gmail / IMAP inside OpenClaw

Gmail via Composio

The fastest path is the Composio integration shipped since OpenClaw 0.27. It wraps Google’s quickstart flow and stores a refresh token in your memory.store.

// inside daemon.js (snippet) const gmail = tools.gmail({ oauthToken: process.env.GMAIL_REFRESH_TOKEN, labelScope: 'INBOX', userId: 'me' });

Plain IMAP

// daemon.js (snippet) const imap = tools.imap({ host: "imap.fastmail.com", port: 993, tls: true, user: process.env.IMAP_USER, password: process.env.IMAP_PASS });

Either tool surfaces the same high-level API: listMessages(), getMessage(id), deleteMessage(id), and sendMessage({to,subject,body}).

Teaching OpenClaw to detect subscription emails

Most marketing mail carries a List-Unsubscribe header with either a mailto link, an HTTPS link, or both. Our agent starts by checking that header. If it’s missing, we fall back to brutally simple heuristics: look for “unsubscribe” in the first 5 kB of HTML and plain text.

function isSubscription(msg) { const h = msg.headers; if (h['List-Unsubscribe']) return true; const body = (msg.html || "" + msg.text || "").toLowerCase(); return body.includes('unsubscribe'); }

Edge cases the above misses:

  • GRAYMAIL from GitHub or Jira. We skip those by whitelisting:
const SAFE_DOMAINS = ['github.com', 'atlassian.com']; const senderDomain = msg.from.address.split('@')[1]; if (SAFE_DOMAINS.includes(senderDomain)) return false;

Batch unsubscribe workflow

1. Fetch candidates

const candidates = await gmail.listMessages({ q: 'newer_than:30d in:inbox', max: 400 });

The Gmail search query trims the risk of touching ancient messages where the link may be dead.

2. For each, decide the strategy

for (const id of candidates) { const msg = await gmail.getMessage(id); if (!isSubscription(msg)) continue; const unsub = parseUnsubHeader(msg.headers['List-Unsubscribe']); if (unsub.link) await clickUnsubscribe(unsub.link, id); else if (unsub.mailto) await draftEmailUnsub(unsub.mailto, msg); else await flagForReview(id); }

3. Clicking unsubscribe with the Browser tool

async function clickUnsubscribe(url, msgId) { const page = await tools.browser.newPage({headless: true}); await page.goto(url, {waitUntil: 'networkidle'}); // Some senders require an extra confirm button try { await page.click('text=/unsubscribe|confirm/i', {timeout: 5000}); } catch {} await page.close(); await gmail.deleteMessage(msgId); }

Two guardrails baked in:

  • Domain allow-list: we only allow browser clicks on HTTPS hosts that match /mailchimp|sendgrid|amazonaws|convertkit/.
  • Timeout: 8 s hard cut. If the page hangs, abort and leave the email unread for manual follow-up.

4. Drafting an unsubscribe email

async function draftEmailUnsub(address, origMsg) { await gmail.sendMessage({ to: address, subject: 'unsubscribe', body: `Hi, please remove ${origMsg.to.address} from your list. Thanks.` }); await gmail.deleteMessage(origMsg.id); }

No send, no body preview if you prefer timid mode: set DRY_RUN=1 in secrets.env and the function will only log what it would send.

Safety guardrails you should enable

Some horror stories on the issue tracker involve agents unsubscribing company mailing lists because the sender used Mailchimp. My current checklist:

  • Dry-run switchprocess.env.DRY_RUN === '1' wraps every destructive call.
  • Max per run — cap at 150 deletions/day while you monitor.
  • Sender ban-list — regex match against @.*(github|slack|jira)\.com$.
  • Audit log — append JSON lines to logs/unsub.log so you can revert by message-ID if needed.

Scheduling the task on ClawCloud

Inside the ClawCloud UI → Agent → Schedules → New:

  • CRON: 0 2 * * *
  • Task: node daemon.js --job unsubscribe
  • Memory: Shared (keeps the list of handled domains)

Locally you can piggy-back on systemd:

[Unit] Description=OpenClaw Unsubscribe Job [Service] Type=simple ExecStart=/usr/bin/node /home/me/claw-unsub/daemon.js --job unsubscribe Environment=NODE_ENV=production [Timer] OnCalendar=*-*-* 02:00:00 Persistent=true

Filtering future junk on arrival

You can also auto-label or delete future promos at receive time instead of running batch jobs. Add a real-time webhook in gateway.json:

{ "webhooks": [ { "name": "gmail.newMessage", "handler": "./daemon.js#onNewMessage" } ] } export async function onNewMessage(msg) { if (!isSubscription(msg)) return; if (alreadySeenDomain(msg)) return; // memory check await gmail.addLabel(msg.id, 'PROMO'); }

Works great for catching cold outreach before it hits your phone.

Observability & rollback

Two lines save you when something goes sideways:

// inside daemon.js top-level agent.on('action', (evt) => fs.appendFileSync('logs/unsub.log', JSON.stringify(evt) + '\n')); agent.on('error', console.error);

If you ever need to restore, the log lines have Gmail message IDs. Re-fetch and move back to INBOX:

const ids = fs.readFileSync('logs/unsub.log','utf8') .split('\n') .filter(Boolean) .map(l => JSON.parse(l).messageId); for (const id of ids) await gmail.move(id, 'INBOX');

Next step: tighten the loop

Spend one week in dry-run mode, tail the logs, tweak the allow/deny lists. When you’re convinced nothing critical is being nuked, flip DRY_RUN=0, keep the max deletions/day cap, and let OpenClaw earn its keep. If you hit an edge case, open an issue on GitHub—chances are someone has already written a patch.