Talking My Way Out of a SaaS
My inbox has the same problem everyone’s inbox has: real mail buried under a rising tide of newsletters, marketing blasts, and “digest” mail I never asked to be digested. The obvious fix — filter out anything containing the word “unsubscribe” — is also a terrible one. Receipts, shipping notices, security alerts, and bank statements are all legally required to carry an unsubscribe/manage-preferences link too. Filter on that text and you bury the mail that actually matters right alongside the mail that doesn’t.
I’ve been leaning on Claude more and more for side projects like this, and I’ve learned not to open with “build me a thing.” I open a conversation and think out loud instead. This one started with no code at all — just describing the mess and asking what shape a fix could even take.
“Just use Gmail’s filters”
The obvious answer, before any of this, is that Gmail already has filters built in. No new code, no Apps Script, no problem to solve. That’s true right up until you’ve actually tried to build something nontrivial out of them. Have you? It’s awkward and cryptic — a UI built for “if from contains X, do Y,” not for header-based scoring, not for “classify this once and remember the decision,” not for anything that has to reason about a message instead of just pattern-matching it. I already had a pile of hand-built filters from years of doing exactly this by hand, and the honest state of that pile was: I couldn’t tell you anymore which ones still made sense. That pile of filters wasn’t a solution I’d overlooked. It was the mess I was trying to get out from under.
Option one: make it a SaaS
The first idea either of us reached for was the biggest one: a real product. Sign up, connect your Gmail, get a dashboard, subscription tiers, the works. It’s the default shape my brain jumps to for “tool that solves a problem I clearly share with other people.”
Talking it through is what killed it, not the idea itself. Anything that inspects
gmail.settings or gmail.modify on someone else’s account needs Google’s OAuth app
verification, and for those scopes specifically, that means a CASA security assessment — an
outside audit, a real cost, a real delay, before a single other user could touch it. Add a billing
layer and multi-tenant infrastructure on top and I wasn’t looking at “a version of my tool” anymore.
I was looking at a different piece of software that happened to share a classifier with it. Worth
knowing, maybe, if this ever proves itself. Not worth building on day one, when I don’t even know
yet if the classification is any good.
Option two: a browser extension
Next idea: skip the backend entirely, run it client-side as a Chrome extension watching the Gmail tab. No server to host, no OAuth client of my own to register.
That one didn’t survive contact with Gmail’s actual feature set either. Google already ships
Workspace Add-ons — a first-party way to put a sidebar next to any open message, CardService UI
and all, with none of the plumbing a Chrome extension drags in: no separate manifest, no content
scripts fighting Gmail’s DOM, no OAuth client to stand up and maintain. Everything an extension
would buy me, the platform already had, minus the parts I’d have had to build and keep working
myself.
What was left
Once a SaaS and an extension were both off the table, what remained was smaller than either: a Google Apps Script project, container-bound to a single Sheet, running under my own Google account. No separate OAuth client, no store listing, nothing distributed to anyone else’s account — which means the entire verification requirement that killed option one simply doesn’t apply. It runs as me, on a timer, and the Sheet is both the control surface and the audit trail.
It’s the least impressive-sounding architecture of the three. It’s also the only one I could actually ship that week.
Building the thing we’d decided on
With the shape settled, the conversation shifted from “what should this be” to “how does
classification actually work.” The signal we landed on was the List-Unsubscribe header (RFC
8058) — mail clients already use it to separate bulk mail from transactional mail, and unlike body
text, it can’t be spoofed by an innocent word choice in a receipt. It’s not perfect alone, though —
Nextdoor digests turned out to have no List-Unsubscribe header at all, which sent us back to add
weaker corroborating signals: a no-reply-style sender, Precedence: bulk, other RFC 2369
List-* headers, mass-mailer fingerprints like X-SFMC-* (Salesforce Marketing Cloud). One strong
signal is enough on its own; otherwise it takes two weak ones agreeing, so a no-reply 2FA code
doesn’t get swept away with the actual noise.
The path a message actually takes
Every incoming message gets tagged Unscanned the instant it arrives — that part is a plain
native Gmail filter, no script involved, so nothing can slip in ahead of it. Every ten minutes, the
script works through whatever’s sitting in Unscanned and scores it against the header signals
above. Clean mail just has the tag removed and goes on with its life in the Inbox, no trace left
behind. Mail that fails the check gets the Unscanned tag swapped for Suspect and gets archived
out of the Inbox — held, not hidden, since it’s still sitting right there in All Mail.
From there it’s on me. I open a message tagged Suspect, open the panel on the right that the
add-on puts next to it, and pick Allow, Block, or File. Allow clears the sender and puts the
message — and every other Suspect message from them — back in the Inbox. Block and File both do
their work through Gmail’s own filters rather than anything the script has to keep enforcing:
picking one creates or updates a real Gmail filter for that sender, skip-the-inbox plus a label, so
every future message from them is routed straight there by Gmail itself — the script just has to
recognize the sender is already decided and leave it alone. File just points that filter at a label
of my choosing instead of Blocked. I’d assumed Allow and
Block would be the whole model here. GitHub notifications broke that within the first day of real
use — I don’t want them in my Inbox, but I don’t want them blocked either, I want them organized.
They also carry no List-Unsubscribe header at all, so they’d never even reach Suspect on their
own. That’s why the panel isn’t gated to flagged mail — it offers all three options on any open
message, because the classifier missing a sender entirely turned out to be a normal case, not an
edge one.
Bugs worth remembering
A few things went sideways in ways I’ll probably repeat if I don’t write them down. The GitHub
Actions deploy step corrupted a credential secret by interpolating it directly into a quoted shell
string — GitHub substitutes secrets as raw text before bash ever parses the line, so the JSON’s own
embedded quotes broke it. Passing the secret through env: instead fixed it. Separately, adding a
new OAuth scope to the manifest doesn’t take effect on the next script push — it requires manually
re-running Test Deployment ▸ Install in the Apps Script editor, a step I discovered by hitting the
same permissions error twice before it stuck.
The part still deliberately unbuilt
The SaaS question didn’t fully go away — it just moved to the end of the conversation instead of the start. Once the tool was live and working, the same three options came back up: sell the template as-is, gate copies behind a license and a small backend, or go full multi-tenant with its own OAuth client and billing. The recommendation I landed on with Claude was to not decide yet — let classification run against real mail for a few weeks first, because accuracy is the actual risk here, not the business model. Deciding a monetization strategy for a filter that might still misfile someone’s bank statement would be solving the wrong problem first.
There’s a migration tool for my existing hand-built filters, fully designed and still unwritten, for the same reason. Some things are more honest half-built and clearly labeled that way than finished and premature.
This post was drafted with the help of Claude.