Build Clean Email
KINDAback to the verdict
A mailbox cleanup tool built around the fact that its own actions are the risk. Bulk operations are previewed and reversible, rules are simulated before being enabled, and unsubscribing prefers the mechanism that does not tell a spammer you exist.
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 grouping queries.
Get it Docker or your package manager. open ↗
Verify
psql connects and groups a hundred thousand rows quickly - accountfree
Why Grouping, rotating sender addresses and the scale problems only appear in a mailbox with years of accumulated mail.
Get it Your own account, or a test account you have deliberately filled. Read the provider's scope documentation before designing the consent screen.
Verify
An account with at least fifty thousand messages syncs metadata - have readyfree
Why This build's own actions are the risk it carries, and testing bulk operations against a real mailbox without a backup is how you lose correspondence.
Get it Export the mailbox through the provider's own export before running anything destructive.
Verify
A complete export exists and has been checked for size and message count - decidefree
Why It is the window in which a mistake is recoverable, and it is enforced in code, so it belongs before the first bulk action.
Get it Decide how long moved mail is kept before actual deletion, and whether actual deletion happens at all.
Verify
A written note states the period and whether permanent deletion is ever performed
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) - messages(id, account_id, provider_id, sender, list_id, subject, received_at, size_bytes, thread_id) · metadata only - groups(id, account_id, kind, key, message_count, oldest_at, newest_at) · kind is sender, list or subject_pattern - actions(id, account_id, kind, group_id, matched_count, previewed_count, executed_at, undone_at) · kind is move, label or unsubscribe - moved(action_id, provider_id, from_folder, to_folder, at) · exactly what moved where, so undo is real - rules(id, account_id, condition_json, action, enabled, simulated_count, simulated_at) - unsubscribes(id, group_id, method, attempted_at, outcome, safe) · method is header or link, and link is unsafe by default
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/mailclean | Message metadata. Senders and subjects reveal a great deal even without bodies. |
STORE_MESSAGE_BODIES | required | never | Present as a constant so the answer is visible. There is no other value. |
HOLDING_LABEL | required | Cleanup Holding | Where bulk actions move mail. Nothing is deleted. |
HOLDING_RETENTION_DAYS | required | 60 | The window in which a mistake is recoverable. |
RULE_VOLUME_CAP_PER_DAY | required | 500 | How much one rule may act on before pausing. Bounds a mistake. |
FOLLOW_UNSUBSCRIBE_LINKS | required | never_automatically | Clicking a link in unsolicited mail confirms a person is there. |
The build, in order
Seeing the mailbox without reading it
You can understand a large mailbox from metadata alone.
And document what it grants. Broad access is a liability even unused.
Files
src/oauth.tsSender, subject, size, dates. Assert no bodies in the suite.
Files
src/sync.tstests/no-bodies.test.tsHundreds of thousands of messages. A restart from zero is unacceptable.
Files
src/sync.ts
done when · tick each as it passeswatch out- Requesting full read access because it is the simplest scope means your application holds the ability to read every message the user has ever received, which is a liability far exceeding what cleanup requires. Request what the operations need, store metadata only, and assert in the suite that no body text is persisted.
Grouping so cleanup is possible at all
A hundred thousand messages become a few hundred decisions.
It is the field designed for exactly this and it survives address rotation.
Files
src/group.tsSo one newsletter is one decision rather than fifty.
Files
src/group.tsSize is what motivates cleanup. Range is what makes it safe.
Files
src/groups.ts
done when · tick each as it passeswatch out- Grouping by exact sender address fragments a single newsletter into fifty groups when the sender rotates its envelope address, which is common, and the user gives up. Use the list identifier where present and fall back to a normalised sending domain, so one newsletter is one decision.
Bulk actions you can take back
Every destructive action is previewed and reversible.
The exact number, and messages the user can look at.
Files
src/preview.tsDeletion is the risk. Retention makes it reversible for a while.
Files
src/actions.tsSo undo restores rather than piling everything in one folder.
Files
src/moved.tstests/undo.test.ts
done when · tick each as it passeswatch out- Recording only that an action ran, without the per-message origin, makes undo a best guess that puts everything in one folder, which is not a restoration and is sometimes worse than the original mistake. Record the source folder per message, so undo is exact.
Rules, simulated before they run
A rule cannot surprise you after it is enabled.
The count against real mail is the only useful preview.
Files
src/simulate.tsAn edited rule is a new rule.
Files
src/rules.tsSo a mistake is bounded rather than open ended.
Files
src/rules.ts
done when · tick each as it passeswatch out- A rule enabled without simulation runs against every future message and the user finds out months later that something important has been filed away since April. Simulate against history, show the count, and cap the volume a single rule may act on so a mistake is bounded.
Unsubscribing without making it worseproduct builder
You stop receiving mail without confirming you exist to a spammer.
Machine to machine. It does not tell the sender a person is there.
Files
src/unsubscribe.tsWith an explanation. The user should decide, knowing the trade.
Files
src/unsubscribe.tsMail arriving afterwards is the only real test.
Files
src/verify.ts
done when · tick each as it passeswatch out- Clicking an unsubscribe link in unsolicited mail confirms to the sender that a real person opened it, which reliably produces more mail rather than less. Use the header mechanism, which is machine-to-machine and does not signal engagement, and where only a link exists, say what the trade is instead of deciding for the user.
That is the whole plan for Clean Email. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Reading or analysing message content.
- Broad provider coverage.
- Spam classification.
- Permanent deletion as a primary action.
- sender reputation, deliverability, and suppression handling
- provider-grade deliverability
- push reliability across devices
- team routing and shared inbox rules
- abuse response and security operations
- Attachment-focused cleanup, since size is usually what prompts this
- A scheduled review of what rules did last month, which is where trust is maintained
- Sender reputation from your own behaviour, so groups you always read are never suggested
Need the files? The project pack on the verdict page hands your agent the whole brief · more email.