Build Mailient

KINDAreplaces $29/mosaves $348/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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.

estimated effort weekendthe files for this build are in the project pack

RuntimeNode 22 with Fastify, server-rendered HTML, bound to the loopback addressMailIMAP for reading with SMTP for sending, using an application passwordStorageSQLite for rows with message bodies and raw headers written to diskModelOne chat endpoint, called once per thread, with the redacted input recordedSendingDrafts are stored, never queued, and a click is the only thing that can send one

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. free 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

  3. 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

  4. 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

  5. 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.

VariableNeededExampleWhere the value comes from
IMAP_HOSTrequiredimap.example.comReading mail. Port and TLS settings alongside it.
IMAP_USERrequiredme@example.comThe single mailbox this instance mirrors.
IMAP_PASSsecretrequiredredacted-app-passwordAn application password, never your account password, and never logged.
SMTP_HOSTrequiredsmtp.example.comSending, only ever from the click handler.
LLM_API_KEYsecretrequiredsk-...redactedClassification and drafting. Set a provider-side limit too.
LLM_MONTHLY_CEILINGrequired4.00Classification stops here. Rules, search and manual triage keep working.
PAYLOAD_BYTE_BUDGETrequired12000Maximum bytes of thread text sent per call. Truncation is recorded.
BODY_RETENTION_DAYSrequired90Mirrored bodies are deleted after this; threads, summaries and call records survive.

The build, in order

  1. A sync that survives the night

    Mail keeps arriving in the local mirror across dropped connections, restarts and a server-side mailbox reset.

    1. Otherwise the sync stops without an error anybody sees.

      Files src/mail/idle.tstests/idle-timer.test.ts

    2. Interrupt it three ways in the test, not once.

      Files src/mail/sync.tstests/resume.test.ts

    3. A stale high-water mark against a reset mailbox means silent gaps.

      Files src/mail/identity.ts

    done when · tick each as it passes
    watch 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.
  2. 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.

    1. Two functions drift, and the preview stops being true.

      Files src/model/payload.tstests/payload-preview.test.ts

    2. Most of a reply chain is somebody else's mail.

      Files src/model/strip.tstests/strip.test.ts

    3. Written first, so a crash still leaves the record.

      Files src/model/call.ts

    done when · tick each as it passes
    watch 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.
  3. Rules first, model second

    Cheap deterministic rules classify most of the mailbox before anything is sent anywhere.

    1. The cheapest classification is the one you wrote yourself.

      Files src/triage/rules.tstests/rules-first.test.ts

    2. A thread is not the same thread after a reply arrives.

      Files src/triage/cache.tstests/cache-invalidation.test.ts

    3. A wrong category should be traceable in one click.

      Files src/app/threads/[id].tsx

    done when · tick each as it passes
    watch 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.
  4. 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.

    1. The subject line is not what threads a conversation.

      Files src/mail/reply-headers.tstests/threading.test.ts

    2. A half-parsed draft is worse than no draft.

      Files src/model/draft.tstests/draft-schema.test.ts

    3. One 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 passes
    watch 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 this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more email.