Skip to content

Commit bdf9624

Browse files
committed
Add Mailstrom: a Gmail bulk-cleanup PWA for iPhone
Groups a Gmail mailbox by sender, then allows trashing/archiving a sender's entire history in one action, unsubscribing where the sender offers it, and creating a Gmail filter so future mail is handled automatically. Built as a static, backend-free PWA. It talks to the Gmail REST API directly from the browser via Google Identity Services' implicit token flow, so no server ever holds a token to the user's mailbox. Tokens live in sessionStorage and die with the tab; the service worker caches the app shell only, never API responses. Scanning is the expensive part, so it is batched and incremental: - the Gmail batch endpoint folds 100 header reads into one request, avoiding a bearer token per message on cellular, and falls back to concurrent individual requests if batching fails - message metadata is cached in IndexedDB per mailbox, so a rescan only fetches unseen IDs and evicts ones that disappeared - requests are paced to the 250 units/sec quota and back off on 429/5xx, honouring Retry-After Destructive actions use Gmail's TRASH label rather than permanent delete, so everything remains recoverable for 30 days, and each one requires an explicit confirmation. Tests: 70 unit tests over the parsing, grouping, batch-response and filter-construction logic; 23 end-to-end tests driving the real UI at iPhone viewport against a stubbed Gmail, including the batch fallback, expired sessions, the IndexedDB cache, and iPhone-specific checks for tap target size, input font size and manifest installability. Note: this app is unrelated to openboxxx's DJ tooling and shares no code with it. It is self-contained in mailstrom/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HKB7qMrxUHuFwyiBU5YNw1
1 parent ed4ad16 commit bdf9624

41 files changed

Lines changed: 7241 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/mailstrom.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: mailstrom
2+
3+
on:
4+
push:
5+
paths:
6+
- 'mailstrom/**'
7+
- '.github/workflows/mailstrom.yml'
8+
pull_request:
9+
paths:
10+
- 'mailstrom/**'
11+
- '.github/workflows/mailstrom.yml'
12+
13+
defaults:
14+
run:
15+
working-directory: mailstrom
16+
17+
jobs:
18+
check:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
23+
- uses: actions/setup-node@v4
24+
with:
25+
node-version: 22
26+
cache: npm
27+
cache-dependency-path: mailstrom/package-lock.json
28+
29+
- run: npm ci
30+
31+
- run: npm run lint
32+
33+
- run: npm test
34+
35+
- run: npm run build
36+
37+
# Only Chromium is needed; the e2e project pins that browser.
38+
- run: npx playwright install --with-deps chromium
39+
40+
- run: npm run test:e2e
41+
42+
- uses: actions/upload-artifact@v4
43+
if: failure()
44+
with:
45+
name: playwright-report
46+
path: mailstrom/playwright-report/
47+
retention-days: 7

mailstrom/.gitignore

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
dist
12+
dist-ssr
13+
*.local
14+
15+
# Editor directories and files
16+
.vscode/*
17+
!.vscode/extensions.json
18+
.idea
19+
.DS_Store
20+
*.suo
21+
*.ntvs*
22+
*.njsproj
23+
*.sln
24+
*.sw?
25+
26+
# Playwright
27+
/test-results/
28+
/playwright-report/
29+
/blob-report/
30+
/playwright/.cache/
31+
32+
# Generated review screenshots
33+
/screenshots/

mailstrom/.oxlintrc.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"$schema": "./node_modules/oxlint/configuration_schema.json",
3+
"plugins": ["react", "typescript", "oxc"],
4+
"rules": {
5+
"react/rules-of-hooks": "error",
6+
"react/only-export-components": ["warn", { "allowConstantExport": true }]
7+
}
8+
}

mailstrom/README.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Mailstrom
2+
3+
A bulk Gmail cleanup tool that installs to an iPhone home screen. Groups your mailbox by
4+
sender, then lets you trash or archive a sender's entire history in one tap, unsubscribe
5+
from the ones that offer it, and create a Gmail filter so they never come back.
6+
7+
> **Note on this repository.** `openboxxx` is a DJ software project (Mixxx → rekordbox USB
8+
> export). This app is unrelated to it and shares no code; it lives here only because that's
9+
> where it was asked for. It is entirely self-contained in `mailstrom/` and can be lifted
10+
> into its own repository by copying the directory.
11+
12+
## What it does
13+
14+
- **Group by sender or domain.** Every message is reduced to sender, size, age, unread and
15+
inbox state, then bucketed. Domain mode folds `news@acme.com` and `billing@acme.com`
16+
into one row.
17+
- **Bulk actions.** Trash, archive, or mark-read an entire sender at once, behind a
18+
confirmation step. Trash means Gmail's Trash — recoverable for 30 days. The app never
19+
calls the permanent-delete endpoint.
20+
- **Unsubscribe.** Senders advertising `List-Unsubscribe` are badged. One-click (RFC 8058)
21+
senders get a POST; everyone else hands you their link.
22+
- **Filters.** Create a Gmail filter for a sender — skip inbox, mark read, apply a label, or
23+
send straight to trash. A domain group produces a single filter matching every address.
24+
- **Scopes.** Inbox, all mail, unread, older than a year, large, or newsletters.
25+
26+
## Architecture, and why
27+
28+
**There is no backend.** The app is static files. It talks to the Gmail REST API directly
29+
from the browser using Google Identity Services' implicit token flow.
30+
31+
The trade-off is deliberate. A server would let it hold refresh tokens and run scans in the
32+
background, but it would also mean a machine that holds an access token to your entire
33+
mailbox. There isn't one. Your mail is never transmitted anywhere except between your phone
34+
and Google. The cost you pay: access tokens last about an hour and cannot be silently
35+
refreshed forever, so a long session will occasionally ask you to sign in again.
36+
37+
A few other decisions worth knowing:
38+
39+
- **Tokens live in `sessionStorage`**, not `localStorage`, so they die with the tab rather
40+
than persisting on disk.
41+
- **Scanning is batched and incremental.** Reading headers one message at a time costs one
42+
request — and one ~1 KB bearer token — per message, which on a 30 000-message mailbox is
43+
tens of megabytes of pure overhead on cellular. The Gmail batch endpoint folds 100 reads
44+
into one request. If batching ever fails the app falls back to concurrent individual
45+
requests, so a Google-side change degrades speed rather than breaking the app.
46+
- **Message metadata is cached in IndexedDB**, keyed by mailbox. A rescan only fetches IDs
47+
it has not seen and evicts ones that disappeared, so the expensive full scan happens once.
48+
- **Requests are paced to Gmail's quota** (250 units/sec; `messages.get` costs 5) and back
49+
off on 429/5xx, honouring `Retry-After`.
50+
- **The service worker caches the app shell only** — never API responses. Caching mail in a
51+
store that outlives the tab would undo the point of keeping tokens in `sessionStorage`.
52+
53+
## Setup
54+
55+
You need your own Google OAuth client. This is unavoidable: Gmail's read/modify scopes are
56+
"restricted", and an app cannot ship a shared client ID for them without going through
57+
Google's verification and a security assessment. Your own client, used only by you, needs
58+
none of that — but it does cap you at 100 test users, which is fine for personal use.
59+
60+
It takes about three minutes, once:
61+
62+
1. At [console.cloud.google.com](https://console.cloud.google.com), create a project.
63+
2. **APIs & Services → Library** → enable the **Gmail API**.
64+
3. **OAuth consent screen****External**. Fill in an app name and your email. Add your own
65+
Google account under **Test users**.
66+
4. Add these scopes:
67+
- `https://www.googleapis.com/auth/gmail.modify` — read headers, apply labels, trash
68+
- `https://www.googleapis.com/auth/gmail.settings.basic` — create filters
69+
5. **Credentials****Create credentials****OAuth client ID****Web application**.
70+
Under **Authorised JavaScript origins**, add the exact URL you'll serve the app from
71+
(e.g. `https://mailstrom.you.vercel.app`). No trailing slash.
72+
6. Open the app and paste the client ID into the setup screen.
73+
74+
The client ID is not a secret — browser OAuth clients are public by design. You can also
75+
bake it in at build time with `VITE_GOOGLE_CLIENT_ID` instead of pasting it.
76+
77+
> Google will show an "unverified app" warning on first sign-in. That is expected for a
78+
> personal OAuth client with you as the sole test user.
79+
80+
## Deploying, and installing on the iPhone
81+
82+
The build output is static, so any static host works. It **must** be served over HTTPS —
83+
both Google OAuth and service workers require it. `localhost` is exempt for development.
84+
85+
```bash
86+
npm install
87+
npm run build # → dist/
88+
```
89+
90+
Then deploy `dist/` (Vercel, Netlify, Cloudflare Pages, GitHub Pages, or your own nginx).
91+
Add the deployed origin to your OAuth client's authorised origins.
92+
93+
To install on the phone:
94+
95+
1. Open the URL in **Safari** (not Chrome — only Safari can install to the home screen on iOS).
96+
2. Tap **Share****Add to Home Screen**.
97+
3. Launch it from the icon. It runs full-screen with no browser chrome.
98+
99+
## Development
100+
101+
```bash
102+
npm install
103+
npm run dev # http://localhost:5173, reachable from your phone on the same network
104+
npm test # unit tests (vitest)
105+
npm run test:e2e # end-to-end tests (playwright, iPhone viewport, stubbed Gmail)
106+
npm run lint
107+
npm run build
108+
npm run icons # regenerate PNG icons from public/icons/icon.svg
109+
```
110+
111+
### Tests
112+
113+
70 unit tests cover the pure logic — header parsing (quoted names, unbracketed addresses,
114+
`List-Unsubscribe` shapes), sender grouping and display-name selection, sorting, the
115+
multipart batch-response parser including partial failures, and filter construction.
116+
117+
23 end-to-end tests drive the real UI at iPhone viewport against a stubbed Gmail API,
118+
covering the scan, grouping and sorting, every bulk action and its confirmation, unsubscribe,
119+
filter creation, the IndexedDB cache, expired-session handling, the batch→individual
120+
fallback, plus iPhone-specific checks (no horizontal scroll, 44px tap targets, 16px inputs
121+
so Safari doesn't zoom, and an installable manifest whose icons all resolve).
122+
123+
The e2e suite runs on **Chromium with iPhone 13 metrics**, not WebKit — WebKit isn't
124+
installed in this environment. It verifies layout, tap targets and app logic, none of which
125+
are engine-specific. Real Safari behaviour is worth a check on the device itself.
126+
127+
## Known limits
128+
129+
- **Unsubscribe cannot be confirmed.** A browser sends a cross-origin one-click POST but is
130+
not allowed to read the response, so the app reports the request as *sent*, not
131+
*succeeded*. Rescan in a few days to see whether it took.
132+
- **Filters are not retroactive.** Gmail applies them only to mail that arrives after
133+
creation. Use Trash or Archive for what's already there — the sheet says so.
134+
- **Sessions expire hourly.** A consequence of having no backend; see above.
135+
- **A first scan of a very large mailbox takes minutes** and is quota-bound at roughly 50
136+
messages/second. It is cached afterwards, and it is resumable — partial progress is banked
137+
as it goes, so stopping and restarting doesn't start over.

0 commit comments

Comments
 (0)