A personal Gmail subscription unsubscriber. It scans your inbox for the
List-Unsubscribe / List-Unsubscribe-Post headers (RFC 2369 / RFC 8058) that
mailing senders attach to messages, groups matches by sender, and classifies each
one so you can unsubscribe with as few clicks as possible wherever the sender's own
headers actually support it:
- One-click — sender supports RFC 8058's single-POST unsubscribe, no redirects.
- Mailto — sender expects an unsubscribe email; sent for you via the Gmail API.
- Link only — sender only offers a web page; you'll need to open it yourself.
- Not supported — no
List-Unsubscribeheader at all; deprioritized for v1.
Runs entirely locally, against your own Gmail account, using your own Google Cloud OAuth credentials — bring your own client secrets, nothing is hosted centrally.
The app requests two OAuth scopes:
gmail.metadata— headers only, never message bodies. This is what scanning uses, and it's the reason scans can't do server-side date filtering (see below).gmail.send— narrowly needed to send the unsubscribe email for mailto senders. It can send mail as you but still cannot read any message content.
-
Create a project in Google Cloud Console.
-
Enable the Gmail API for it.
-
Configure the OAuth consent screen in Testing mode and add your own Google account as a test user — this avoids Google's app-verification review, since this is a single-user personal tool.
-
Create an OAuth client of type Desktop app, download the JSON, and save it as
credentials/client_secret.json.Note: in Testing mode, refresh tokens can expire after ~7 days of inactivity. If the app later fails to start complaining about an invalid token, just re-run step 2 below.
python scripts/authorize.pyOpens your browser for Google's consent screen once, then writes
credentials/token.json. Neither credentials file is ever committed (see
.gitignore).
Native install, no Docker:
brew install postgresql@14
brew services start postgresql@14
createuser unsub
createdb unsub -O unsub
postgresql@14is deprecated upstream and Homebrew will disable the formula around 2026-11-12 — fine for now, but worth moving to a newer major version (e.g.postgresql@16) before then. Any 14.x+ install works identically for this app; nothing here is version-specific.
Copy .env.example to .env and adjust DATABASE_URL if needed.
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
uvicorn app.main:app --reloadThe app creates its own tables on startup (Base.metadata.create_all() — no
migration tool; see the plan notes if you want to add Alembic later). Visit
http://localhost:8000.
pytesttest_classify.py/test_grouping.py/test_gmail_scanner.py are pure/mocked and
always run. test_scan_service.py/test_unsubscribe_service.py need a real
Postgres reachable at TEST_DATABASE_URL (defaults to
postgresql+psycopg://unsub@localhost:5432/unsub_test) and skip cleanly if one
isn't available.
The gmail.metadata scope forbids the q search parameter on messages.list, and
messages.list carries no date information at all — only messages.get returns a
message's internalDate. So a scan's date-range filter (default: past 3 months,
configurable in the UI) doesn't reduce how many messages get fetched — it only
changes which fetched messages get kept. Every scan walks the full INBOX message
list. This is simple and always correct, but on a mailbox with years of history, the
first scan may take a while.