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 descriptorsdaemon.js– the agent brainsecrets.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 switch —
process.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.logso 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.