Build Mailient
KINDAreplaces $29/mosaves $348/yrback to the verdict
A single-mailbox triage tool whose hard problems are a sync that stays connected and resumes correctly after every kind of interruption, a recorded boundary describing exactly what left the machine for the model, and reply headers that make a draft thread properly in the recipient's client.
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 worker.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - accountfree with most providers
Why This build reads and sends over the protocols directly, and a provider that has turned them off makes the whole design impossible before you start.
Get it Enable two-factor authentication and create an application password in the provider's security settings.
Verify
A client connects, lists the inbox, and sends one message to yourself - API keypay per call; a busy personal inbox is small change with the ceiling in place
Why Classification and drafting both need validated JSON, and this is also the only component that costs money or sees your mail.
Get it Create a key and set a provider-side spend limit in addition to the ceiling in this build.
Verify
One thread classifies into the schema without a repair attempt - have readyfree to a few pounds a month
Why A sync that runs only while a laptop is open is a sync that misses the overnight mail, which is exactly the mail triage is for.
Get it A small always-on box on your own network, or a laptop you genuinely never close.
Verify
Mail received overnight is present in the morning - decidefree
Why Deciding what leaves the machine after you have already sent a month of mail to a provider is not a decision, it is a discovery.
Get it Write the patterns and the byte budget into configuration and review the rendered preview for ten real threads.
Verify
The preview for ten threads contains nothing you did not intend to send
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- accounts(id, host, username, last_uid_seen, mailbox_identity_marker, idle_restarted_at) · the identity marker is stored so a server-side reset forces a full resync instead of silent gaps - messages(id, account_id, uid, message_id_header, thread_key, from_addr, to_addrs, subject, sent_at, body_path, headers_path, fetched_at) - threads(id, thread_key, subject, last_message_at, message_count, category, summary, model_id, prompt_version, classified_at) - model_calls(id, thread_id, sent_bytes, sent_hash, redactions_json, model_id, prompt_version, input_tokens, output_tokens, cost_minor, at) · what left the machine, how much of it, and what was stripped first - rules(id, match_kind, match_value, action, priority, created_at) · deterministic overrides applied before any model call is considered - drafts(id, thread_id, in_reply_to, references_header, body, created_at, edited_at, sent_at) · sent_at stays null until you click - redactions(pattern, label, added_on) · what is removed from a body before it is sent anywhere - Nothing in this schema can set sent_at except the send handler behind an explicit click. There is no queue, no scheduler and no retry that could send on your behalf.
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 |
|---|---|---|---|
IMAP_HOST | required | imap.example.com | Reading mail. Port and TLS settings alongside it. |
IMAP_USER | required | me@example.com | The single mailbox this instance mirrors. |
IMAP_PASSsecret | required | redacted-app-password | An application password, never your account password, and never logged. |
SMTP_HOST | required | smtp.example.com | Sending, only ever from the click handler. |
LLM_API_KEYsecret | required | sk-...redacted | Classification and drafting. Set a provider-side limit too. |
LLM_MONTHLY_CEILING | required | 4.00 | Classification stops here. Rules, search and manual triage keep working. |
PAYLOAD_BYTE_BUDGET | required | 12000 | Maximum bytes of thread text sent per call. Truncation is recorded. |
BODY_RETENTION_DAYS | required | 90 | Mirrored bodies are deleted after this; threads, summaries and call records survive. |
The build, in order
A sync that survives the night
Mail keeps arriving in the local mirror across dropped connections, restarts and a server-side mailbox reset.
Otherwise the sync stops without an error anybody sees.
Files
src/mail/idle.tstests/idle-timer.test.tsInterrupt it three ways in the test, not once.
Files
src/mail/sync.tstests/resume.test.tsA stale high-water mark against a reset mailbox means silent gaps.
Files
src/mail/identity.ts
done when · tick each as it passeswatch out- An idle connection left open forever looks perfect for three days and then stops delivering anything, because RFC 2177 says a server with an inactivity timeout may log the client off and advises clients to terminate and re-issue the idle command at least every 29 minutes, so your triage dies quietly at the exact moment you stopped watching it. Re-issue on a timer under that interval, and treat a connection with no server traffic for longer than the interval as dead rather than as idle.
What leaves the machine, written down
Every model call has a stored record of exactly what was sent, what was stripped, and what it cost.
Two functions drift, and the preview stops being true.
Files
src/model/payload.tstests/payload-preview.test.tsMost of a reply chain is somebody else's mail.
Files
src/model/strip.tstests/strip.test.tsWritten first, so a crash still leaves the record.
Files
src/model/call.ts
done when · tick each as it passeswatch out- Sending the full HTML body means you are shipping tracking pixels, forwarded chains from other people and a long quoted history to a third party, and paying per token for markup, so the first month's bill and the first privacy question arrive together. Strip to text, cut the quoted trail at the first attribution line, and record the byte count so the cost and the exposure are both visible before you agree to them.
Rules first, model second
Cheap deterministic rules classify most of the mailbox before anything is sent anywhere.
The cheapest classification is the one you wrote yourself.
Files
src/triage/rules.tstests/rules-first.test.tsA thread is not the same thread after a reply arrives.
Files
src/triage/cache.tstests/cache-invalidation.test.tsA wrong category should be traceable in one click.
Files
src/app/threads/[id].tsx
done when · tick each as it passeswatch out- Caching a classification on the thread identifier alone means a thread that gains a new message keeps yesterday's category and yesterday's summary, so the urgent reply sits under the newsletter heading and the whole tool loses trust in one afternoon. Key the cache on the thread together with its message count, and display which rule or call produced the result so a wrong answer is traceable.
Drafts that thread, and only send when clicked
A reply arrives in the recipient's client inside the original conversation, and nothing is ever sent automatically.
The subject line is not what threads a conversation.
Files
src/mail/reply-headers.tstests/threading.test.tsA half-parsed draft is worse than no draft.
Files
src/model/draft.tstests/draft-schema.test.tsOne code path, and a test that no other path exists.
Files
src/routes/send.tstests/no-auto-send.test.ts
done when · tick each as it passeswatch out- Setting only a subject line with Re in front of it and skipping the reply headers produces a message that opens a brand new conversation in the recipient's client, which reads as a reply to nothing and is the single most common way a hand-rolled mail tool embarrasses its author. Copy the message identifier into the in-reply-to field and append it to the references chain, and test the result by sending to a real client rather than by reading the code.
What it costs, what it keeps, and why it stays personalproduct builder
Spending has a ceiling, the mirror has a retention policy, and the reasons this stays a single-user local tool are written down.
The tool should get dumber, not stop.
Files
src/model/cost.tstests/cost-ceiling.test.tsThe summary stays useful long after the body stops mattering.
Files
src/jobs/retention.tsThis is a mailbox, not a service.
Files
src/server.tstests/bind-loopback.test.ts
done when · tick each as it passeswatch out- Planning to add hosted provider sign-in later means discovering that the scopes which read mail are classed as restricted, that using them in a published application requires app verification, and that storing or transmitting that data on servers pulls in a security assessment, all of which is a project rather than a phase. Build for one mailbox with an application password, bind to the loopback address, and write down in the README what publishing would actually cost.
That is the whole plan for Mailient. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Hosted provider sign-in and the verification that comes with it.
- A mobile application and push notifications.
- Multiple accounts.
- Anything that sends without a click.
- A mobile app and real push notifications, which is where most email actually gets read
- Managed OAuth with Google and Microsoft, including the review process and token refresh pain
- Someone else absorbing provider API changes, rate limits and broken threading
- Polish: keyboard shortcuts, fast search, snooze, undo send, offline behavior
- Any sending reputation work if the product handles outbound at scale
- Full-text search over the mirrored bodies, which is cheap while they are still on disk
- A weekly report of what the rules caught versus what the model caught, which is how you write better rules
- A per-sender byte budget, so the newsletters that arrive as enormous HTML stop costing what they cost
Need the files? The project pack on the verdict page hands your agent the whole brief · more email.