Build Spike

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

estimated effort multi-daythe files for this build are in the project pack

RuntimeA desktop application with a local database and exactly one accountProtocolIMAP synced by UID, using only the extensions the server advertisesStoreSQLite holding a cache of what the server has, rebuildable from emptyRenderingSanitised HTML in a sandboxed view with remote content off until askedSendingThe provider's own submission service with the credential you already holdSecretsThe operating system keychain, never the database and never a log

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

  2. free

    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

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

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

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

VariableNeededExampleWhere the value comes from
ACCOUNT_ADDRESSrequiredyou@example.comThe single account. Adding a second is a different project.
IMAP_HOSTrequiredimap.example.com:993Always over TLS. Extensions are discovered, not assumed.
SUBMISSION_HOSTrequiredsmtp.example.com:587The provider's submission service. Never a mail server of your own.
OAUTH_CLIENT_IDoptionalyour-own-client-idYour own client, used where the provider requires OAuth rather than an app password.
OAUTH_CLIENT_SECRETsecretoptionalredactedKept in the keychain. The environment holds a reference, not the value.
DATABASE_PATHrequired~/.local/share/mailreader/cache.dbThe cache. Rebuildable from empty, which is the point.
SYNC_WINDOW_DAYSrequired365How far back to cache. Older mail is fetched on demand.
ALLOW_REMOTE_CONTENTrequiredfalseOff by default. Loading it is a read receipt for the sender.

The build, in order

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

    1. It is two columns and it is the difference between a cache and a corruption.

      Files src/sync/folders.tstests/uidvalidity.test.ts

    2. Sequence numbers are reassigned under you during the session.

      Files src/sync/fetch.ts

    3. If the cache can always be rebuilt, no sync bug can be fatal.

      Files tests/resync.test.ts

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

    A conversation is built from message identifiers, with subject matching as a last resort.

    1. The headers exist precisely so you do not have to guess.

      Files src/threading.tstests/threading.test.ts

    2. A time window and shared participants, or not at all.

      Files src/threading.ts

    3. Threads that reshuffle on every sync are threads nobody trusts.

      Files tests/threading.test.ts

    done when · tick each as it passes
    watch 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.
  3. Rendering mail that is not on your side

    A message body cannot run anything, and opening it tells nobody.

    1. Mail from 2004 is still mail and still arrives.

      Files src/mime.tstests/charset.test.ts

    2. A denylist is a list of the attacks you thought of.

      Files src/sanitise.tstests/sanitise.test.ts

    3. Then 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 passes
    watch 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.
  4. The conversational view, with the original always one click away

    A thread reads like a chat without hiding anything the mail actually said.

    1. Expand automatically when the remainder is too short to be the message.

      Files src/quoting.tstests/quoting.test.ts

    2. A reader that lies about what is unread is worse than a list.

      Files src/sync/flags.ts

    3. Mailing lists exist and they are where naive rendering dies.

      Files tests/perf.test.ts

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