Build SaneBox
KINDAback to the verdict
An email triage system built around an asymmetric cost: a false positive that files something important is far worse than a false negative that leaves noise in the inbox, so the classifier, the digest and the reversibility all lean the same way.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the server and the sync workers.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - installfree locally
Why Message metadata at mailbox scale with sender aggregation.
Get it Docker or your package manager. open ↗
Verify
psql connects and aggregates two hundred thousand rows quickly - accountfree
Why Sender profiles are built from your own replies over time, and a fresh account contains none of the signal the whole approach depends on.
Get it Your own account, and read the provider's scope documentation to find the narrowest one that permits moving messages.
Verify
At least three years of history syncs as metadata - have readyfree, and it takes an evening
Why The two error rates cannot be measured without ground truth, and only you know which of your messages mattered.
Get it Take a random sample from your own mailbox and label each as should have stayed or could have been filed. Do not use a public dataset.
Verify
Five hundred labelled messages exist with both classes represented - decidefree
Why The threshold is set from it, and choosing after seeing the results means choosing whichever produces a flattering figure.
Get it Decide how often you could tolerate something important being filed, expressed as a rate, before measuring anything.
Verify
A number is written down and the threshold is derived from it
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- accounts(id, provider, address, scopes_granted, connected_at) - senders(id, account_id, address_normalised, domain, first_seen_at, replied_count, ignored_count, filed_count) - messages(id, account_id, provider_id, sender_id, subject, received_at, thread_id, was_replied) · metadata only - decisions(id, message_id, action, confidence, signals_json, at, reversed_at, reversed_by) - folders(id, account_id, name, kind, digest_schedule) · kind is later, newsletter, seen_it or blocked - digests(id, account_id, period_start, period_end, sent_at, message_count) - A decision records the signals that produced it, so a correction can be traced to the signal that was wrong rather than to a model that cannot be examined.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://localhost:5432/triage | Message metadata and sender behaviour. Revealing even without bodies. |
STORE_MESSAGE_BODIES | required | never | Present as a constant. Sorting a mailbox does not require reading it. |
FILE_CONFIDENCE_THRESHOLD | required | 0.95 | High. Uncertain means leave it in the inbox. |
DIGEST_SCHEDULE | required | daily | Short enough that a mistake is caught within a day. |
DELETE_MESSAGES | required | never | Present as a constant. Nothing is ever deleted. |
FOLLOWUP_DAYS | required | 4 | Before reminding about an unanswered sent message. |
The build, in order
Sorting without reading
Triage works from metadata and no message body is stored.
And document what it grants. Broad access is a liability even unused.
Files
src/oauth.tsAssert it in the suite, including log paths.
Files
src/sync.tstests/no-bodies.test.tsTwo hundred thousand messages is a normal mailbox.
Files
src/sync.ts
done when · tick each as it passeswatch out- Requesting full mailbox read access because it is the simplest scope gives your application the ability to read every message the person has ever received, which is a liability far exceeding what sorting requires and a permission prompt that loses users. Request the narrowest working scope and store metadata only.
Signals from what you actually do
The strongest signal is whether you reply, and it is used first.
It is the only signal that is genuinely about you.
Files
src/signals.tsNot as unimportant. The default must be to leave it alone.
Files
src/signals.tsNewsletters rotate constantly and fragment otherwise.
Files
src/senders.ts
done when · tick each as it passeswatch out- Classifying on sender reputation lists or on subject keywords ignores the only signal that is actually about you, which is whether you have ever replied to this person. Build from your own history first, and treat an unknown sender as unknown rather than as unimportant.
A threshold tuned for the expensive mistake
The classifier is deliberately reluctant to file anything.
Uncertain means leave it alone.
Files
src/classify.tsFive hundred messages you judged yourself. There is no substitute.
Files
eval/labelled.jsonA single accuracy figure hides the expensive one.
Files
src/evaluate.tstests/error-rates.test.ts
done when · tick each as it passeswatch out- Reporting a single accuracy figure hides the only number that matters, which is how often something important was filed, and a classifier at ninety five percent accuracy that is wrong in the expensive direction is far worse than one at eighty five that is not. Measure and report the two error rates separately.
Nothing is hidden and nothing is deleted
Filed messages are visible, reversible and summarised on a schedule.
A folder nobody opens is a deletion with extra steps.
Files
src/folders.tsA mistake caught within a day is recoverable.
Files
src/digest.tsThe correction is the most valuable signal you will get.
Files
src/reversal.ts
done when · tick each as it passeswatch out- Filing messages into a folder nobody opens is functionally the same as deleting them, and the person only discovers the important one when someone asks why they never replied. Send a digest on a schedule short enough that a mistake is caught within a day, and make it skimmable rather than complete.
Reminders and knowing whether it worksproduct builder
You are reminded about what you sent, and you can see the classifier's record.
Cancelled by a reply, which requires no body access.
Files
src/reminders.tsFree ground truth arriving continuously.
Files
src/metrics.tsNo hidden state that only your application understands.
Files
src/disconnect.ts
done when · tick each as it passeswatch out- Measuring accuracy only against the original labelled set means the reported figures stop reflecting reality as your correspondence changes, while your own reversals are a continuous stream of ground truth arriving for free. Compute the ongoing error rates from reversals and show them.
That is the whole plan for SaneBox. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Reading or summarising message content.
- Spam detection.
- Deleting anything.
- Shared or team triage.
- push reliability across devices
- team routing and shared inbox rules
- abuse response and security operations
- sender reputation, deliverability, and suppression handling
- provider-grade deliverability
- Per-sender rules you set explicitly, which beat any classifier for the cases you care about
- A weekly view of who you never reply to, which is genuinely useful and slightly uncomfortable
- Snoozing a message until a stated time, which is the other half of getting an inbox to zero
Need the files? The project pack on the verdict page hands your agent the whole brief · more email.