Build Spike
KINDAback to the verdict
A single-account mail reader whose hard problems are keeping a local cache that knows when the server has renumbered a mailbox, threading from headers rather than subjects, and rendering hostile HTML without leaking that you opened it.
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 application and the local database.
Get it nodejs.org, plus the desktop framework of your choice. open ↗
Verify
A window opens and the local database file is created - accountfree
Why Everything in this build is tested against a real server's behaviour, and providers differ in which extensions they advertise.
Get it Enable IMAP and create an application password or an OAuth client of your own.
Verify
A client can log in, list folders and read the folder validity value - have readyfree
Why Validity changes, expunges and double sends have to be tested by actually doing them, and not on the account holding your bank mail.
Get it A second account on the same provider, seeded with a few hundred messages including a long list thread.
Verify
You can expunge and resync it without caring about the outcome - have readyfree
Why Character sets, nested MIME, inline replies and tracking pixels are the actual work, and you cannot write the tests without examples.
Get it Export a varied folder to maildir, or collect public mailing list archives.
Verify
The corpus includes a legacy charset, a nested multipart, an inline reply and a remote image - decidefree
Why How much mail is cached, what the subject fallback window is, and which export format you commit to, are all things tests assert.
Get it Write down the sync window, the threading fallback rule and the export format.
Verify
All three appear in the configuration and in the README
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- accounts(id, address, provider, auth_kind, token_ref, imap_host, submission_host, state, last_error) - folders(id, account_id, name, uidvalidity, uidnext, highest_modseq, last_synced_at) · uidvalidity is stored because it is the only thing that tells you the cache is void - messages(id, folder_id, uid, message_id_header, in_reply_to, references_json, from_addr, to_json, subject, date_utc, internal_date, size, flags_json, body_ref) - bodies(ref, mime_structure_json, text_plain, text_html_sanitised, sha256) · the sanitised form is what the view ever sees - attachments(message_id, part_id, filename, mime, bytes, stored_path, opened_at) - threads(id, root_message_id, subject_normalised, participants_json, last_activity_at) - outbox(id, account_id, in_reply_to, state, attempts, queued_at, sent_at, last_error) · a message is queued locally and sent once, because a double send is visible to another human
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 |
|---|---|---|---|
ACCOUNT_ADDRESS | required | you@example.com | The single account. Adding a second is a different project. |
IMAP_HOST | required | imap.example.com:993 | Always over TLS. Extensions are discovered, not assumed. |
SUBMISSION_HOST | required | smtp.example.com:587 | The provider's submission service. Never a mail server of your own. |
OAUTH_CLIENT_ID | optional | your-own-client-id | Your own client, used where the provider requires OAuth rather than an app password. |
OAUTH_CLIENT_SECRETsecret | optional | redacted | Kept in the keychain. The environment holds a reference, not the value. |
DATABASE_PATH | required | ~/.local/share/mailreader/cache.db | The cache. Rebuildable from empty, which is the point. |
SYNC_WINDOW_DAYS | required | 365 | How far back to cache. Older mail is fetched on demand. |
ALLOW_REMOTE_CONTENT | required | false | Off by default. Loading it is a read receipt for the sender. |
The build, in order
A cache that knows when it has gone stale
Sync by UID, and throw the folder away the moment the server says its numbering changed.
It is two columns and it is the difference between a cache and a corruption.
Files
src/sync/folders.tstests/uidvalidity.test.tsSequence numbers are reassigned under you during the session.
Files
src/sync/fetch.tsIf the cache can always be rebuilt, no sync bug can be fatal.
Files
tests/resync.test.ts
done when · tick each as it passeswatch out- Caching messages by their sequence number, or keeping UIDs across a validity change, attaches the wrong body to the wrong header after an expunge or a server-side mailbox rebuild, and the result is not a crash but a reader showing one person's message under another person's subject. Key on the pair of folder validity and UID, and treat a changed validity as a reason to drop the folder cache rather than to reconcile it.
Threads assembled from headers
A conversation is built from message identifiers, with subject matching as a last resort.
The headers exist precisely so you do not have to guess.
Files
src/threading.tstests/threading.test.tsA time window and shared participants, or not at all.
Files
src/threading.tsThreads that reshuffle on every sync are threads nobody trusts.
Files
tests/threading.test.ts
done when · tick each as it passeswatch out- Threading on the normalised subject merges three years of separate 'Meeting notes' messages from different people into one conversation, and mail sent from a list or a helpdesk turns into a single unreadable thread. Use the headers first, and gate the subject fallback on a time window and shared participants.
Rendering mail that is not on your side
A message body cannot run anything, and opening it tells nobody.
Mail from 2004 is still mail and still arrives.
Files
src/mime.tstests/charset.test.tsA denylist is a list of the attacks you thought of.
Files
src/sanitise.tstests/sanitise.test.tsThen let the person load it deliberately, per message or per sender.
Files
src/render.tsxtests/no-beacon.test.ts
done when · tick each as it passeswatch out- Loading remote images by default turns every open into a read receipt for whoever sent it, including the sender who bought your address, and one of those images is a one-pixel file whose URL contains your address. Block remote content until the person asks, per message, and say what was blocked.
The conversational view, with the original always one click away
A thread reads like a chat without hiding anything the mail actually said.
Expand automatically when the remainder is too short to be the message.
Files
src/quoting.tstests/quoting.test.tsA reader that lies about what is unread is worse than a list.
Files
src/sync/flags.tsMailing lists exist and they are where naive rendering dies.
Files
tests/perf.test.ts
done when · tick each as it passeswatch out- Trimming quoted text aggressively deletes the entire content of a reply from someone who answers inline or writes above a forward with the substance below, so the conversation shows an empty bubble where the answer was. Collapse rather than discard, expand automatically when what remains is trivially short, and keep the full body reachable.
Sending once, staying authenticated, and being able to leaveproduct builder
A reply goes out exactly once, a dead token is obvious, and the mail can walk out in a standard format.
The same protection a scheduler needs, for the same reason.
Files
src/send.tstests/exactly-once.test.tsA client that silently stops syncing looks like a quiet week.
Files
src/auth.tsOpen it in another client before calling the export done.
Files
src/export.tstests/export.test.ts
done when · tick each as it passeswatch out- Retrying a send after a timeout delivers the same reply twice to a person who will notice, because the submission was accepted and the acknowledgement was lost. Lock the row before the network call, and resolve an unknown outcome by searching the sent folder for the message identifier rather than by sending again.
That is the whole plan for Spike. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Your own outbound mail server.
- Several accounts and unified search.
- Shared inboxes and team routing.
- Chat, meetings and shared notes.
- sender reputation, deliverability, and suppression handling
- provider-grade deliverability
- push reliability across devices
- team routing and shared inbox rules
- abuse response and security operations
- Server-side search where the provider offers it, falling back to the local index
- Rules that file or flag on arrival, running locally over the same threading model
- A second account, once a full resync of the first has matched the server for a month
Need the files? The project pack on the verdict page hands your agent the whole brief · more email.