Skip to content

Mail: read-only mail, end to end (slice 1) - #25

Merged
zyansheep merged 2 commits into
mainfrom
feat/mail-read
Nov 8, 2026
Merged

zyansheep merged 2 commits into
mainfrom
feat/mail-read

Conversation

@zyansheep

Copy link
Copy Markdown
Contributor

Slice 1 of email: read-only mail, end to end. It follows docs/mail-design.md §12.1 (copied into the repo, with §14 recorded as the owner decided). What works and how to run it is in docs/mail.md.

What changed

Hub (packages/hub/src/mail/)

  • The grant. The hub asks dither-accounts for mail at write on all accounts, in a new registration. A hub that registered before asks once, through mailToAsk. MailAccess is off, unreachable, pending, denied or granted. While it is pending or denied, the hub reads self() every 60 s and on each account list, and "Ask again" sends a new request. Mail accounts are added only on the manager's page.
  • Sync. First: mailboxes, then states for Inbox, Sent and All/Archive (taken before listing). Then Inbox's newest 50, then Sent's, then both down to 500.
  • Keep-up. mail.arrived is stored at once. mail.changed queues a coalesced changes pass, and more is followed. Timers are insurance, and a gap, a reconnect, the account coming online and the hub starting each catch up. A 410 relists the mailbox.
  • Moves. A removed message is deleted only after the other followed mailboxes were asked, so an IMAP move keeps the hub's message, its thread and its body. Unknown removed ids are ignored.
  • Threading. The server's thread id first. Then In-Reply-To and References, from summaries now that dither-accounts 515f26d has them, with Message-IDs kept with their brackets everywhere. Then provisional by subject, fixed when references arrive. A message linking two threads merges them.
  • Read state is local only (seen_local), set after 1 s in view. mail.flag is never called.
  • Access ending (the account leaves the list or the grant goes) keeps the mail 24 h, then a sweep deletes it. "Delete now" is DELETE /api/mail/accounts/:id. This is implemented, not left as a seam.
  • Plumbing. The bootstrap gains mail, and there are the mail events of §5. The journal and /api/sync carry mail for a client that comes back. FakeMailBackend serves both the tests and --demo, whose world gains a mail account.

Protocol

  • mail.ts: all of §5's views and events. Fields later slices fill say which slice.
  • mail-iface.ts: a copy of dither-accounts' interface types. accounts-sync --check compares them, and they match master 515f26d.
  • mailId, 'email' added to Network (not to NETWORKS), and its entries in the per-network tables.

Web

  • Mail in the rail, with an unread dot.
  • The list: views Inbox, Unread, Starred and Sent, mailboxes, and an account picker.
  • The reader: messages fold, the quote folds behind ···, links work, and an HTML-only message shows its text part.
  • The Accounts screen gets a Mail group and Settings a set-mail block: status, "Sending is off: ", links to the manager, and "Delete now".
  • Ctrl+K finds Mail and its mailboxes.
  • The snapshot keeps the threads and bodies opened last.
  • Mail's screens are a lazy chunk (13 KB), and mail events move their own mailRev signal.

Hooks for slices 2-7

  • hub/src/mail/routes.ts ROUTERS: a MailRouter(hub) returns routes. It runs once per hub, so a slice also subscribes there.
  • MailHub.on(...): ingested, deleted, opened, read, body, account.
  • hub/src/mail/schema-<name>.sql: a slice's own tables, loaded after schema.sql.
  • MailQueue.gate and onStart: slice 2's budgets.
  • MAIL_LIMITS in hub/src/mail/limits.ts, including syncDays (the horizon).
  • web/src/ui/mail/slots.tsx: BODY_RENDERERS, MESSAGE_ACTIONS, THREAD_ACTIONS and ROW_WRAPPERS (swipes).

How it was verified

  • bun run typecheck and bun run test pass (hub 615, web 198, and the rest).
  • New tests:
    • mail-thread: a table of 22 cases, with and without the §13 headers.
    • mail-store.
    • mail-sync: first-sync order, more, 410, IMAP and Proton-like moves, unknown removed ids, arrived before changes, gap, the manager down and back, limited, access ending, and the delta sync matching a fresh bootstrap.
    • mail-model and mail-text.
  • mail-remote.test.ts passed 7/7 against a throwaway dither-accounts manager with an imap account on its Dovecot and SMTP sink, on ports 18580-18589. It covered the grant flow, first sync with threading from summaries, a body via mail.get, IDLE keep-up, another client's flags, another client's move (same hub message), and a UIDVALIDITY change, which relists. It skips without the checkout or Dovecot.
  • scripts/verify-mail.ts passed 49/49 on a fresh demo hub, at 390 px with touch and on desktop, in dither-dark and dither-light. It found no page errors and no sideways overflow, and checked list → thread → back (button and history), quote folding, read-here after 1 s, and the HTML-only text with no script.
  • verify-perf: the load average was about 6, and times moved up to 40% between runs of the same code, so I alternated branch, origin/main, branch. Renders are the same as main. A cold load at 4× took 3,692 ms on main, and 2,533 ms and 3,777 ms on the branch. The main bundle is +3 KB. DOM nodes are +9 (the rail's Mail entry). I updated only the four exact *.nodes counts in perf-baseline.json, by hand. Notes are in docs/perf.md.
  • verify-phone: one failure, "dragging the dialog down closes it". origin/main fails the same check on this machine, so it is not from this branch.

What I could not verify

  • No real mailbox: there is no mail account in the live manager yet. The live Proton path and mail.send (slice 5) are untested.
  • The grant flow ran on a throwaway manager only. Nothing was approved on the live manager.
  • The page was tested in headless Chromium, not on a phone.

🤖 Generated with Claude Code

zyansheep and others added 2 commits November 7, 2026 19:49
…nts for mail, syncs Inbox and Sent, keeps up through events and mail.changes, threads by Message-ID, and the page lists and reads it

The hub asks the manager for `mail` at write (a hub registered before asks once), follows the
mail accounts it lists, and keeps them in mail tables of its own (hub/src/mail): mailboxes, the
newest 500 of Inbox and Sent, keeping up through mail.arrived, mail.changed and mail.changes, a
relist on 410, and moves that keep the hub's message. Threads come from the server's thread id,
else In-Reply-To and References (now on summaries, dither-accounts 515f26d), else the subject.
The page gets Mail in the rail: views, mailboxes, a reader with quotes folded and text made from
HTML-only mail, read state kept on the hub after a second in view, and a Mail group in Accounts
and Settings. Access that ends keeps the mail a day, with "Delete now".

Later slices plug in through the routes table, MailHub's events, schema-*.sql files and the
page's slots (docs/mail.md). The design is in docs/mail-design.md with the owner's decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…baseline's node counts for the rail's entry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zyansheep
zyansheep enabled auto-merge (squash) November 8, 2026 04:07
@zyansheep
zyansheep merged commit 20562ee into main Nov 8, 2026
1 check passed
@zyansheep
zyansheep deleted the feat/mail-read branch November 8, 2026 04:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant