parcel AGENTS.md
17 kB · 289 lines
Markdown
at main

parcel #

A mail client for Gmail and JMAP (Fastmail) on the Linux desktop, in Zig 0.17.1, on the siv GUI toolkit (Wayland only).

Toolchain #

  • Zig 0.17.1 exactly. bs refuses any other release.
  • bs compiles development builds. It takes zig build's commands and flags and needs upstream zig 0.17.1 on PATH for the std library and for C sources. Release builds use upstream zig build -Doptimize=ReleaseFast. Where bs is not installed, as for someone building from the README, the recipes use upstream zig.
  • In an orb, .agents/setup installs zig, bs (built from a pinned commit), ziglint, ast-grep with the baskingspot house rules, oxfmt, and siv's system libraries. Elsewhere nix develop provides the same tools. Keep the pins in .agents/setup, flake.nix, and flake.lock in step.

Commands #

The justfile is the interface. just --list shows every recipe.

just build          # zig-out/bin/parcel
just install        # a release build, desktop entry, and icon in ~/.local
just release [BUMP] # release origin/main: patch, minor, major, or X.Y.Z
just fetch          # install the published build, no compiler
just run            # on the Wayland display
just test           # siv's headless backend; no display needed
just lint           # zig fmt, ziglint, baskingspot-lint, shellcheck, shfmt, nix fmt, oxfmt, just --fmt
just fmt            # format what lint checks
just ci             # build, test, and lint
just proto NAME     # run a mailbox-view prototype
just shots [NAME]   # render prototypes to zig-out/shots
just zig=zig test   # any recipe with upstream Zig instead of bs

Releasing #

There is no CI. After a push, just release BUMP releases origin/main from an orb. It lists the commits since the last tag, then commits release: X.Y.Z, setting build.zig.zon's version, which parcel --version reports; runs just ci on it; builds it; uploads the build; pushes the commit to main and its tag vX.Y.Z in one atomic push; and attaches the build to the tag, which Tangled's Tags page lists. A release cut short finishes when run again; one that finds main moved pushes nothing. just fetch installs the newest, once its download matches the published blob's CID and parcel --version names it.

Versions follow semver strictly from the start: 0.x has no exemption, so what breaks bumps the major even now. The bump is the highest any commit since the last tag needs:

  • major: what makes a user act or lose something. A key, command, or parcel argument removed or doing something else; state, settings, drafts, or sign-ins a new build cannot read (a cache it rebuilds on its own does not count); an account kind dropped; a higher floor for the system it runs on (glibc, CPU, libraries).
  • minor: something new, with nothing old breaking. A key, command, view, setting, or account kind added; a format the old build's data still reads into.
  • patch: fixes alone, adding no behaviour.

Release only when asked. Choose the bump from the commits the recipe lists, read for what changed for the user rather than from their subjects, and say which you chose and why. When it is between two, take the higher. just release X.Y.Z names a version outright; never bump build.zig.zon by hand.

Releasing needs RELEASE_APP_PASSWORD, an app password for rockorager.dev, which orbs get from the project's secrets. The build is for x86-64-v3 (AVX2), not the orb's CPU, whose AVX-512 laptops lack, and may link no glibc symbol newer than 2.38, which just release checks.

Layout #

  • src/main.zig opens the window on a Mailbox and shows a Desk at once; nothing waits on the network before the window. Its logFn writes each log line to stderr and keeps warnings, errors, and the sessions' info lines in $XDG_STATE_HOME/parcel/log too (src/LogFile.zig, mode 0600, moved to log.1 past 1 MB), since a desktop launcher sends stderr nowhere. A panic can come through it, so it takes no locks and uses system calls, not Io.
  • src/Accounts.zig holds a Desk per account in the one window, one shown at a time; each account keeps its state (src/Settings.zig's account) in $XDG_STATE_HOME/parcel/accounts/NAME, and runs its own Session. What parcel kept before accounts moves to accounts/Gmail on the first run (migrate in src/main.zig).
  • src/Mailbox.zig is the local model of labels and threads, keyed by Gmail's ids. src/Gmail.zig fills it from Gmail's REST API once src/oauth.zig has signed in (parcel login); sample mail (src/sample.zig) fills it otherwise.
  • src/Jmap.zig is JMAP (RFC 8620, 8621) for Fastmail and other servers: requests, and replies made into the mailbox's labels and threads. Mailboxes with a role take the system labels' places, the rest are user labels, and Archive is no label. src/JmapSession.zig is a signed-in JMAP account, doing what Session, Sync, Pager, and Drafts do for Gmail: it reads the mail whole, then Email/changes and Thread/changes, from the states kept in the cache, whenever the server's event stream (Http.listen) says something changed, and every minute while the stream is down. It signs in with a token, or with OAuth's grant (jmap-oauth), refreshing the access token before it runs out. After each load it walks down every message, newest first, keeping what search needs of those not kept yet in src/Corpus.zig; once it has been all the way down, a walk ends at the first page it has all of. An account is JMAP when its directory has a jmap-token or a jmap-oauth.
  • src/AddAccount.zig is the Add account page, a card over every desk: Gmail, Fastmail, or another JMAP server, a name, and a sign-in, which it saves in a new account's directory before src/Accounts.zig adds the account to the window, last, in the samples' place if they showed. It shows by itself while there is no account, and from the palette. src/SignIn.zig signs in on the event loop: Gmail's OAuth, a JMAP token tried on the session resource, or a JMAP server's OAuth under the profile for open public clients (src/oauth.zig): the session resource's metadata names the authorization server (else its origin is tried), parcel registers itself there, and the browser comes back to a loopback port siv watches. parcel login still signs in from a terminal, and is how an account signs in again.
  • src/Session.zig is a signed-in run: it refreshes the token, brings the desk's mail up to date, and saves it (src/cache.zig, up to 100,000 threads in a flat binary file) so the next run opens on it and asks Gmail's history only what changed. The desk's changes src/Sync.zig sends back as the user makes them, one request per thread at a time. src/Backfill.zig fills in older mail one request at a time, two fifths of the quota at most, and only a minute after the user's last request; where it got to is saved with the mail, and the threads its walk never passed leave the mail when it ends. Once it has, src/Harvest.zig keeps every message for search, in the same two fifths: down messages.list from the top each run, reading whole each message the corpus lacks, or its thread when it lacks more than one. Mind Gmail's quota, 6,000 units a minute per user: a threads.get costs 40 (120 whole), a threads.list page of up to 500 ids 10, a messages.get whole 60, a messages.list page 5.
  • src/Corpus.zig keeps each message for search: who is on it, its date, size, List-Id, Delivered-To, and files, and its text apart from what it quotes. The records are appended to corpus in the account's directory (mode 0600), never rewritten, and read off the app's thread as the session starts; a record cut short by a crash is cut off. Labels and flags, which change, are not kept there, nor are drafts; a message the server destroys gets a record saying it is gone (Gmail's history names those deleted).
  • src/Index.zig is parcel's own search engine: each word and the messages it is in, by field. Most of it is src/Segment.zig, a file (index, mode 0600) beside the corpus that is mapped into memory, not read; the messages the corpus gained since it was written are indexed in memory as the corpus loads, numbered after its messages. Past 5,000 of those, or with no segment, the load writes the segment again from the whole corpus, a buffer at a time. A word matches the words it starts; threads rank by field (subject, sender, recipients, files, text, quoted), a whole word twice a word's start, then by date. Once the corpus's walk has kept every message of the last 30 days (it notes how far down it has, reached), a search the index can answer is answered by it, reading the threads not loaded; one naming labels or flags (label:, is:, in:anywhere) still goes to the server. Until the walk has gone all the way down, the desk says how far back the index searched, and F asks the server instead.
  • src/Notifier.zig tells the desktop of new mail in an account's Inbox while the window is not looked at, and takes it back once read; its Archive button archives what it names without raising the window. Each backend's session owns one.
  • src/Http.zig is libcurl's multi interface on siv's event loop: curl's sockets are siv fd watches and its timer a siv timer, so requests never block the UI. Only the CLI (parcel login) uses its blocking fetch. baskingspot/http and tls are 0.16-only so far.
  • src/Desk.zig is the mailbox view, grown from the list prototype: one list of threads chosen by label filters that stack (AND), the command palette, search, and the reader. src/Settings.zig keeps its filters between runs. Search matches the loaded mail as it is typed, with Gmail's operators (src/Query.zig) against each thread's notes, who is on each message and when. Once typing pauses the server is asked too, and what it finds joins the list: Gmail (threads.list?q=) after a second and three characters, reading ten threads it lacks; JMAP after 0.4 s and two. Enter takes the search, asking at once, and goes to the list, where i or Esc goes back to the search's words and q ends it. In the field, the start of an operator is suggested at the word, and right after one the people (src/Contacts.zig), labels, or values it takes; Tab or Enter takes one. A bare word is never completed. Scrolled near its end, the list asks for more of what it shows, labels or search, through src/Pager.zig: pages of ids, then the threads not loaded, 25 at a time (about 1,000 units).
  • src/Reader.zig shows an open thread's messages, each body in siv's document view as untrusted HTML with remote images blocked. It reads them through a fetcher: Gmail's whole thread (120 quota units) once the selection rests, or made-up ones from src/sample.zig. A file opened (a click, or the palette) is read through it too (20 units), saved to the downloads directory, and opened with xdg-open. Images from the web load as a thread opens, except in Spam or once the palette turns that off (src/Images.zig, shared by every account), then only after i; through src/Http.zig with no account's authorization. Senders' photos come from Gravatar the same way, where images may load, and are kept in $XDG_CACHE_HOME/parcel/avatars (src/Avatars.zig). The messages zoom as a browser zooms a page (Ctrl+=, Ctrl+-, Ctrl+0, a pinch, or Ctrl+wheel), their scroll node scaling them and laying them out at its width over the scale; one scale (src/Zoom.zig) serves every account and is saved as the app quits.
  • src/cards.zig reads what Gmail shows beside a body: a go-to action from schema.org JSON-LD and an invitation's event (text/calendar). The reader draws them, with the List-Unsubscribe button.
  • src/Compose.zig writes mail: c a new message, r and a a reply to the open thread's newest message, and a message's Reply buttons a reply to it, over a quote of it. f and Forward forward one whole, in its thread, with its files, which the reader reads (20 units each). Both start with the account's signature, made text, from Gmail's send-as settings or the JMAP identity. The body is siv's rich text editor, with Vim's keys (normal mode's : opens the palette with the draft's commands; :q asks whether to save, send, or discard it) and a toolbar: marks, links, quotes, and lists, kept as Portable Text and sent as text, and as HTML too once formatted, in multipart/alternative (src/portable_text.zig, src/mime.zig); a draft read back comes back from its text. Typing in To or Cc suggests people from src/Contacts.zig under the field. Files chosen in the desktop's file chooser portal (Ctrl+Shift+A) go with it as multipart/mixed, 25 MB in all at most, shown as chips that remove them.
  • src/Drafts.zig keeps compose's draft in Gmail's drafts, two seconds after typing pauses and as the card closes: a create (10 quota units), then updates (15), one request out at a time. It sends the draft (drafts.send, 100) and deletes one discarded (10). Messages go through Gmail's upload root, which takes up to 35 MB. The draft's id is saved, so the next run reads it back (60), and its files (20 each), for c to open.
  • src/Contacts.zig keeps the people in the From, To, and Cc of the mail read, ranked by how often and how recently the user wrote to them or heard from them, for compose to suggest. Gmail's thread reads feed it, or sample mail. Parcel has no access to Google Contacts: that needs another scope and another sign-in.
  • data/ holds the desktop entry and the icon (SVG), named for the window's app_id, dev.rockorager.parcel; just install puts them in share/ with the binary.
  • site/ is parcel.rockorager.dev: the home page, the privacy policy Google's verification links to, and a changelog and RSS feed that python3 site/build.py makes from the release tags into zig-out/site. It is the Cloudflare Pages project parcel, deployed by hand after each release. Its copy is written in Tim's voice: plain, first person, and specific.
  • src/kit.zig holds UI helpers. It began as a copy of src/proto/kit.zig, as Settings.zig did; a module cannot import outside its directory.

Prototypes #

src/proto/ holds designs for the mailbox view, kept as they were when list was chosen. Each is a file exporting a Prototype and listed in src/proto/all.zig. They share sample mail (sample.zig), an in-memory mailbox (Store.zig), and helpers (kit.zig).

  • Every action has a key and a click: a prototype handles keys in onKey, and kit.hintBar draws keycaps that call it when clicked.
  • just proto NAME runs one on Wayland. just shots renders each one's shots, key sequences walked in order, headless to zig-out/shots/*.png; look at them after a visual change. The tests walk the same keys.
  • What a prototype keeps between runs goes through Settings.zig, files in $XDG_STATE_HOME/parcel. Tests and shots pass none, so they start clean.
  • A prototype's own tests run only if src/proto/main.zig references its file; the smoke test in all.zig covers the rest.

Writing Zig #

  • Load the zig-017 skill (.agents/skills/zig-017, vendored from baskingspot/skills at 6888cc2) before writing Zig, and follow its style. Check APIs against the pinned toolchain's std (zig env), not memory.
  • Prefer baskingspot packages (https://baskingspot.dev) over other dependencies and over hand-written code. Many declare Zig 0.16 only; check that one builds on 0.17 before you depend on it, as the skill describes.
  • A stateful type is its own file named for it. Tests sit at the bottom of the file they cover, and src/main.zig references each file so they run.
  • baskingspot-lint runs the org's ast-grep rules. Fix a finding rather than suppress it, unless the rule's own note names the exception.

siv #

  • The program imports the siv-static module, which links libsiv.a.
  • siv's Zig API is generated. Read it at .zig-cache/o/*/install/share/siv/siv.zig after a build, and siv's guide at zig-pkg/siv-*/docs/guide/.
  • UI tests create the app on the headless backend with no system fonts, icons, or motion, settle with siv.testSettle, and find nodes the way a screen reader does (siv.testFind by role and accessible name), not by the tree's shape.
  • The build installs siv's NOTICES, the licences of what libsiv compiles in, to share/doc/parcel/NOTICES. A new Zig dependency of parcel's own needs its licence there too.

Commits #

One-line subject, area: summary, such as ui: show the message list. A body says why when the subject cannot.