Terminal mail provider design#
Developer reference for the implemented provider, MIME, credential and cache contracts. Start with development; normal account connection belongs in setup. Initial API research: 2026-10-05; current source contracts and cited Google OAuth/scope policy reviewed 2026-10-06. Current source includes changes newer than the published 0.2.3 checkpoint; release qualification identifies the exact candidate separately. Historical targeted results retain their own dates and artifacts.
Developer qualification uses synthetic fixtures and isolated cache/credentials. These tests do not authorize production sending, contacts changes or new consent. The bar retains its separate read-only grant, 30-row snapshots and allocation contract. A dedicated mailbox is a developer live-write qualification requirement, not a normal-user setup prerequisite.
HEY reference#
The referenced product is the official basecamp/hey-cli, linked from HEY's own agent guide and CLI/TUI page. It provides the same mail operations to terminal users and agents. The useful precedent here is shared operations for listing, searching, reading threads, composing, replying, drafts and contacts; calendar browsing, todos and journal features are outside this task.
HEY's CLI reference informed the initial design research: explicit accounts, structured output, recipient previews and shared human/agent mail operations. Omagma uses the same executor behind its TUI and JSON CLI. A send preview exposes account, sender, recipients, subject and threading. Tokens stay out of argv/output, authentication does not scrape browser cookies, and operations choose one account rather than an all-account default. These are implemented Omagma choices; the HEY links are design references, not feature-parity promises or a statement about its current feature set.
Retained bar boundary#
src/providers/gmail.zigverifies the Gmail profile against the selected account for every job, reads at most 30 inbox envelopes, retries one authentication failure, and leaves the unread count unknown when only the count request fails. It does not fetch body MIME parts or retain mail on disk.src/oauth.zig's bar entry point requests exactlygmail.readonlyand rejects a returned scope outside that set. The terminal authorization policy reuses PKCE/state/loopback/deadline machinery through its separate complete capability set; the bar validator stays strict.src/keyring.zigisolates bar tokens with service and account attributes. Terminal grants use a distinct namespace and account/client/scope identity; they never replace the bar token. Secrets use bounded pipes, never command-line arguments, logs, cache or JSON output.src/http_client.zig's bar entry point restricts HTTPS destinations to Gmail/OAuth and retains its 32 KiB form/512 KiB response policy. The implemented terminal JSON path has separate bounds and adds People, without widening the bar wrapper. Both reject redirects/compression and join cancellation.src/config.zigsupplies up to three configured accounts and browser profiles to both clients, plus optional sender name/signature. Terminal cache/UI options and grant registry remain separate. Message/contact IDs are always paired with account identity; identical IDs in two accounts stay distinct.
Capability grants and credentials#
Use the following least-permission sets for explicitly authorized terminal capabilities. Google classifies gmail.readonly as restricted and gmail.send as sensitive. Reading, including body and send-as alias discovery, does not require gmail.modify. Sending does not require deleting or changing mailbox labels. Gmail scopes, send-as aliases.
| Capability | Google permission |
|---|---|
| Mail read/search/body/cache | https://www.googleapis.com/auth/gmail.readonly |
| Compose/reply/send and invitation reply by mail | Above plus https://www.googleapis.com/auth/gmail.send |
| Archive, reversible trash, restore and applying/removing existing labels | https://www.googleapis.com/auth/gmail.modify instead of readonly/send |
| Google address book read | Add https://www.googleapis.com/auth/contacts.readonly |
| Google address book create/update | Add https://www.googleapis.com/auth/contacts in place of contacts.readonly |
| Local draft/address book changes in fixture mode | No Google permission |
Google explicitly says installed applications do not support incremental authorization. A later capability change needs a complete requested scope set and a separate consent flow; it must not rely on incremental web-app behavior. Preserve granted scopes with the terminal credential identity, require the operation's capability locally, and treat API permission denial as authoritative. Continue checking the Gmail profile identity before persisting a new token. Google installed-app OAuth guide.
The implemented terminal Secret Service namespace is io.github.technologylab_ai.omagma.terminal, with exact account, Google OAuth registration client ID and a SHA-256 identity of the canonical requested scope set. terminal-grants.json stores non-secret capability/client metadata; refresh tokens remain in Secret Service. The registry is private user configuration, not a public artifact. Broader terminal access requires a distinct Google OAuth registration, with a different client ID from the registration configured for the bar: a different keyring label alone does not isolate Google's grant for the same account/client. Creating another OAuth registration in the same Google project does not itself grant scopes; the project's existing testing/verification requirements still apply to consent. Never look up a write token as fallback for the bar. auth.status reports metadata without printing secrets. auth.revoke clears the selected account's terminal credential and local grant metadata; it does not claim to revoke Google's entire OAuth grant. Synthetic qualification performs no real consent.
Shared operation boundary#
The shared types, terminal cache/core and provider serve both CLI and TUI. Pure mail modules own bounded recipients, MIME/header conversion and invitation processing. The provider returns caller-owned bounded data, never borrowed scratch storage that survives a job. Optional progress callbacks borrow only during the synchronous worker call; the TUI copies bounded immutable previews rather than retaining those slices. CLI JSON-lines output remains one response frame per request, with no unsolicited progress text.
The common operations are list(account, query, cursor, limit), read(account, message_id), thread(account, thread_id), replyPlan(account, message_id, reply_all), send(account, draft), contacts list/search/create/update, and rsvp(account, message_id, status). Each list response includes an explicit continuation cursor and complete/partial state. Each mutation response says which backend acted; a synthetic send is never described as delivered to Google. TUI state, cached state and credentials cannot change an operation's account implicitly.
Implemented mail types separate heap-owned incoming participants from the outgoing To/Cc/Bcc envelope. Incoming parsing allows 1,024 participants per address header, 16 KiB per address header and 32 KiB of aggregate headers, with a practical 254-byte address cap and 256-byte RFC 2047 decoded display-name cap. Incoming local parts may exceed 64 bytes; outgoing SMTP validation retains its 64-byte local-part limit and 32-recipient cap. Both use the existing ASCII address grammar, without SMTPUTF8 or expanded obsolete forms. Reply-all removes self aliases and duplicates before checking the final outgoing cap; overflow is a named error, never silent truncation. Compose fields retain explicit reply headers, and invitations retain UID, organizer, attendee, sequence and optional recurrence identity.
Gmail retrieval and MIME#
messages.list returns IDs and thread IDs; body/envelope details need messages.get. Google's documented maximum is 500 IDs per page; Omagma caps its own request at 100 and stops at the cache/job budget. Provider nextPageToken is carried explicitly and percent-encoded. A read uses format=full, walks the MIME payload.parts tree, and reads base64url data; a part whose body has attachmentId needs the attachment endpoint. Message listing, message resource, part bodies.
A snippet is never the full body. Prefer a non-attachment text/plain body; use a bounded HTML-to-text conversion for HTML-only mail, preserving paragraph breaks and link destinations. Decode UTF-8 and common ASCII/Latin-1/Windows-1252 content explicitly; unsupported encodings are visible errors. Preserve MIME attachment metadata without automatically downloading arbitrary attachments. Limit nesting, parts, headers, decoded bytes and response bytes independently. Strip terminal escape/control sequences from displayed bodies, including bidi controls, while preserving safe text and newlines. HTML is data and never executes or loads remote resources.
Threads contain actual messages ordered chronologically. Cached threads advertise partial:true when only retained bodies are available. A provider thread above the 100-message cap is refused with ThreadTooLarge; remote thread continuation is not implemented. Transport/body byte caps remain independent, and reaching a bound never produces a falsely complete thread. Thread retrieval.
Reply recipients, threading and sending#
Reply uses Reply-To when present, otherwise From. Reply-all adds original To and Cc, deduplicates mailboxes, excludes the verified account and known aliases, and never derives recipients from Bcc. Quoted display-name commas and comments must not split a mailbox incorrectly. Reject CR/LF/NUL and malformed addresses in outgoing fields before generating headers. Do not infer aliases by removing dots or plus tags; use explicit verified aliases.
Generate an RFC 5322 message with CRLF, a fresh Message-ID, correct From/To/Cc/Bcc, Date, Subject, MIME-Version and UTF-8 body. Use RFC 2047 encoded words for non-ASCII header text and base64 MIME transfer encoding with bounded line lengths. Gmail accepts a base64url RFC message in the JSON raw field and delivers to the envelope header recipients. Sending guide, send endpoint, message format, MIME body encoding.
Markdown drafts keep exact bodyText source and an explicit bodyFormat;
legacy and omitted new CLI formats are plain. draft.preview and send prepare
the same bounded semantic plain text and escaped inline-CSS HTML. Markdown
replies/forwards escape the literal original before quoting it. Raw HTML and
remote image syntax cannot introduce active content. Code fences use bounded
native lexical highlighting without execution or network-loaded grammars.
Markdown MIME uses a plain/HTML alternative, with the HTML and approved tiny PNG in a related part; genuine user attachments wrap that structure in mixed MIME. The logo has a fixed 16 KiB internal allowance, consumes no user attachment slot and still counts toward the serialized wire limit. Its Content-ID is unique to the submitted RFC Message-ID. It introduces no remote image request. Preview and provider preparation use the same renderer; fixture checks do not establish appearance or delivery in a real receiving email client.
To attach a reply to an existing Gmail thread, supply threadId, compliant In-Reply-To/References and a matching subject. Preserve the original Message-ID and reference chain with bounded validation. Missing/invalid source identifiers must not produce a false threading claim. Gmail thread rules.
Application choice: never automatically retry a non-idempotent send after a timeout or connection loss that might follow submission. Return an outcome-unknown result and retain the draft plus generated Message-ID for reconciliation. A user can explicitly decide to retry after inspecting the result. Preview and fixture modes never call the real send endpoint.
Cache beyond 30 messages#
The terminal cache is separate from bar snapshots. Implemented defaults are the newest 2,000 envelope records and 256 MiB of cache data per account; configurable hard limits are 10,000 records and 1 GiB. API pages are at most 100 and TUI windows 32, with one active bounded network job. Missing recent bodies are filled up to a default head of 32, explicitly configurable from 0 to 64; reads remain on demand beyond it. The fixture gate uses at least 96 messages per account and persists more than 30 across process restarts. These are application limits, not Google API limits.
Use owner-only directories/files, an account partition based on a validated local identity rather than mail-derived path text, versioned records, capped individual files, atomic replacement and explicit ownership/symlink checks. Store the account identity in records and verify it on every read. Evict old cache bodies by a bounded policy; do not evict unsent drafts or ambiguous submissions as ordinary cache. Validate paths and lengths before reading, count disk bytes and files, and reject oversized/corrupt entries. Cache clearing applies to one selected account and never clears unrelated credentials. Mail cache is private local user data and must never enter repository fixtures or public diagnostics. Encryption at rest is a separate product decision; file permissions alone do not provide it.
Address book#
People API connections provides personal contacts with names, emailAddresses and metadata. Both contacts.readonly and contacts grant reading. Page/sync tokens must keep the original query parameters; sync tokens expire after seven days and deletion is reported in metadata. A successful write's returned contact is the immediate authoritative result; incremental sync does not provide immediate read-after-write. Connections API.
Create requires contacts; update replaces the explicitly selected fields and requires the CONTACT source metadata/etag. Omagma serializes mutations per account, reads the current CONTACT source before PATCH and refuses stale versions. A failedPrecondition returns a conflict while preserving the attempted form; automatic conflict merging is not implemented. Keep resourceName, CONTACT source ID/etag and provider account together. Only contact read/search/create/update is supported, not deletion or directory/profile editing. Create contact, update contact.
Local completion should search the selected account's cache; selecting a contact copies a validated mailbox into the draft. Google search needs an empty-query warmup and a read mask; it is optional when the local cache already has the address book. Contact search. The fixture implementation tests create/update and stale etag behavior without contacting Google.
Invitation replies without a calendar UI#
Parse a text/calendar REQUEST from the message. An iTIP REPLY keeps the original UID, ORGANIZER, SEQUENCE and recurrence instance, identifies only the responding ATTENDEE, sets PARTSTAT to ACCEPTED/TENTATIVE/DECLINED, and writes a current UTC DTSTAMP. Do not increment SEQUENCE for a reply. Fold generated content lines correctly, reject ambiguous multiple events/methods/identities, and reject an invitation where the selected account or explicit alias is not an attendee. iTIP reply rules, iCalendar content lines.
Send it as an iMIP MIME message to the organizer with text/calendar; method=REPLY and matching VCALENDAR METHOD. This uses Gmail's send permission and supports organizers outside Google. The result means an RSVP email was submitted; it does not independently establish the attendee's Google Calendar state. iMIP transport.
After a confirmed accepted or tentative email RSVP, a separately authorized Calendar continuation saves a supported event in the same account’s primary calendar (or an explicitly chosen writable calendar). Complete UID and organizer lookup prevents duplicate imports; an existing event receives an ETag-guarded participant response with attendeesOmitted=true, preserving other attendees and organizer fields. Imports set the chosen response directly and updates use sendUpdates=none. The returned event must prove the exact attendee response before the Calendar receipt becomes applied. Recurrence, unsafe timezone interpretations, ambiguity and stale organizer versions remain explicit limitations. Mail and Calendar receipts remain independent: a failed or uncertain Calendar save never resends the email. Explicit status reconciliation can resume the Calendar continuation from its durable receipt. Event participant responses, event patch.
Developer fixture gates and live qualification#
Exercise account-scoped pagination/cache restart, complete body retrieval, HTML-only text, common charsets, nested MIME and external body references; recipient parsing, self-alias exclusion, duplicate handling and header injection; compose/reply/reply-all preview and generated MIME; contacts create/update/conflict; accepted/tentative/declined and recurring invitation replies; oversized/corrupt cache, revoked capabilities, interrupted/ambiguous send and account mismatch. Assertions use independent literal RFC messages and invitation fields. Live-write qualification needs a separately authorized disposable mailbox; production accounts are not development fixtures. Dated evidence identifies which live-read checks ran. Ordinary users authorize their own requested capabilities through setup rather than running these gates.
Implemented interfaces and bounded policy#
gmail_decode.normalize(value, allocator, externalBodies) produces the shared types.Message. FULL payloads resolve external textual bodies from the supplied attachment-response map; unopened ordinary external files retain descriptors without fetching their content. RFC 2047 display names decode after recipient parsing, so an encoded comma remains part of one name; malformed display encodings fall back to sanitized literal text in subjects, names and filenames, while address/header-injection and size checks stay strict. Oversized names fail instead of silently truncating. METADATA responses may omit body, MIME type and empty labels. Gmail's internal timestamp must be between zero and 253402300799999 milliseconds, through the last millisecond of year 9999. Supported text charsets are UTF-8, US-ASCII, ISO-8859-1, ISO-8859-15 and Windows-1252; other body charsets return UnsupportedCharset.
Gmail inline text can understate its delivered byte count or omit a leading UTF-8 byte order mark while still counting those three bytes. The latter exception is restricted to inline UTF-8/base64 text leaves; actual decoded-byte budgets still apply, and files retain their integrity checks. Conflicting calendar parts leave the mail and files readable without enabling an ambiguous RSVP. Cached refusals carry a decoder revision: older failures are retried once on a writable cache open, and repeated refusals remain final. Read-only cache access never migrates an index. Restart older running clients after installing an updated decoder.
terminal/gmail.execute(io, allocator, config, account, cmd, request) verifies an enabled configured account, exact credential/grant identity and Gmail profile before dispatch. Each operation owns one HTTP client. Ordinary jobs and uploads have a 30-second total deadline; explicit incoming file downloads have a 180-second total deadline. JSON requests retain joined 10-second deadlines within the remaining job budget. One explicit HTTP 401 permits one refresh/retry without resetting that budget; send timeouts, disconnects, server errors and malformed successful receipts yield an unknown outcome rather than an automatic send retry. Listing fetches at most 100 envelopes using METADATA; bodies and attachments are retrieved on demand. Thread reads reject more than 100 messages explicitly and sort successful results chronologically.
Live contact writes invalidate and persist the local contacts cache before dispatch. After the provider validates an applied receipt, the executor returns that contact directly; it performs no additional cache decoding, cache allocation or disk write. The next contacts read refreshes the cache. If encoding a successful live mutation's final response fails, the executor reports UnknownOutcome rather than implying the write failed; that error survives even when the error JSON cannot be allocated. Ordinary read and fixture response allocation failures remain OutOfMemory. Unknown send journals and their recovery drafts remain available for reconciliation and are never automatically resubmitted.
dispatchAuthorized accepts an injected transport for synthetic API tests. Mail send receives draft and operationId; its deterministic RFC Message-ID binds account plus operation ID so the root's durable operation journal can retain an ambiguous submission. Calendar sends use invitation.reply and the root's reviewed draft plus preparedCalendar. The provider verifies REPLY, attendee, status and organizer destination, preserves the reviewed calendar bytes, and uses the verified responding send-as alias when applicable. Supplied VTIMEZONE components are preserved within a 32 KiB bound. No Calendar API endpoint is used.
Contacts upsert accepts expectedEtag, falling back to the contact DTO's etag. It reads the current CONTACT source, rejects a mismatch before PATCH, and carries that source metadata to Google's compare-and-set check. Only names and emailAddresses appear in the update field mask. Google's documented 400 failedPrecondition is a contact conflict. People update contract.
terminal/auth.run(io, allocator, config, environment, request) handles auth.status, auth.authorize and local auth.revoke. Its default registry is $XDG_CONFIG_HOME/omagma/terminal-grants.json or $HOME/.config/omagma/terminal-grants.json; optional grantFile and clientFile fields select explicit files. Capability names are mail-read, mail-send, mail-modify, contacts-read, contacts-write and calendar-rsvp. Mail read is always required. Modify uses gmail.modify in place of gmail.readonly/send; contacts-write uses contacts in place of contacts.readonly. Local capability gates still prevent actions that were not requested. RSVP-only grants do not enable ordinary compose/send. Bar authorization remains exactly gmail.readonly and cannot store a broader scoped credential.
Registry writes use atomic 0600 replacement. Reads reject final symlinks, nonregular files, group/other permissions and files larger than 64 KiB; they recheck the opened descriptor before bounded streaming parsing. The metadata contains client paths and capability choices even though tokens remain in Secret Service. Existing live configuration is not changed by validation.
Google HTTP JSON request/response storage is caller-owned and capped at 3 MiB. Decoded normalized message DTOs can be larger because they contain both HTML and its plain fallback; those CLI/TUI results remain within the 64 MiB terminal heap budget. The TUI accepts response frames up to 32 MiB. The bar's 32 KiB form/512 KiB response policy remains intact. Decoded MIME bodies are capped at 2 MiB, complete received RFC messages at 3 MiB, MIME nesting at 16, parts at 128, parsed incoming file/resource descriptors at 49, outgoing recipients at 32, and calendar data at 128 KiB.
Ordinary outgoing attachments allow 16 files and 25 MiB combined decoded bytes; retained original inline resources allow 32 and a separate 2 MiB. Small file DTOs use base64url bytes; larger files use immutable account-scoped handles. The encoder preserves binary octets and emits RFC 2231 UTF-8 filename continuations. Larger sends spool MIME privately, then stream a bounded multipart upload with its thread metadata. Complete outgoing MIME is limited to 35 MiB and the outer HTTP upload stream to 36 MiB. Received file saves decode incrementally into private, atomic, no-clobber destinations, with a 50 MiB decoded file bound. The attachment download wire budget is 69,970,604 bytes: padded base64 for 50 MiB plus 64 KiB of JSON framing. Cached attachment handles also obey account disk quotas. Explicit JSON requests still fit 3 MiB and text bodies stay within 2 MiB; unsupported sizes fail rather than truncating. The fixed HTTP workspace remains 8 MiB, the terminal heap remains capped at 64 MiB, and no terminal buffer is added to the bar's static reservation.
Incoming address lists use caller-owned heap slices capped at 1,024 participants per header, with no fixed stack array added. Address-header parsing is bounded at 16 KiB; MIME headers allow at most 256 fields within 32 KiB per entity/part header block, and individual header values are at most 8 KiB. Mailbox addresses remain bounded at 254 bytes and decoded display names at 256 bytes. Encoded display names are parsed structurally before RFC 2047 decoding, so raw encoded words may exceed the decoded-name limit without failing prematurely. Overflow is explicit; no list or identity is silently truncated. The parser supports its existing quoted phrases, comments, groups and empty groups; SMTPUTF8 addresses and unsupported charsets remain explicit errors, and complete obsolete RFC 5322 grammar is not claimed.
Incoming header syntax permits a local part longer than 64 bytes within the address/header bounds. RFC 5322 section 3.4.1 defines header local-part syntax; the 64-octet transport limit comes from RFC 5321 section 4.5.3.1.1. Outgoing validation retains that limit and the total 32-recipient envelope cap. An ordinary reply can select a valid sender from mail addressed to more than 32 people. Reply-all applies self/alias removal and deduplication before the outgoing cap, then refuses excess recipients. A selected Reply-To address outside the outgoing limits fails before a draft or provider submission; reading the message grants no sending exception.
User label names resolve through the read-only labels.list projection to Google's opaque IDs before label mutation or filtering. Known system labels use canonical IDs directly. Unknown or ambiguous names fail before a mutation. Message assignment and custom-label collection management are separate operations. Collection create/rename/delete use mail-modify, explicit operation identities and stable label IDs; system definitions are protected and deletion requires the reviewed current name. Deleting a definition removes its cached memberships while preserving messages and bodies. Cached mail/contact state is invalidated before live mutation so a lost response cannot leave an authoritative old local state.
Local-development operation additions#
The current source adds bounded mail.batch/mail.undo, mail.prefetch,
mail.forward, accounts.identities, and draft.recovery-save to the same
account-scoped executor. The agent CLI guide records request
fields and one-shot syntax. These additions after published v0.2.3 are source-tree
behavior, not a claim about that immutable binary.
Batch triage accepts at most 100 unique IDs and returns one outcome per ID. At most 16 private undo receipts are retained per account. Before each mutation, the journal records the actual prior label delta; undo changes only touched labels, preserving unrelated subsequent state. Unknown outcomes are not retried automatically, and batching never includes sending mail.
Forwarding creates a local draft in the chosen original-content mode and copies existing ordinary attachments under the 16-file/25 MiB decoded and 35 MiB complete MIME limits. Large files use immutable account handles and streaming; retained original inline resources have a separate 32-resource/2 MiB cap. A refused or failed attachment aborts explicitly rather than silently dropping files. Send-as discovery reads Gmail's configured identities; only the primary or verified aliases from that same account are accepted, with live alias verification repeated at actual submission. Provider HTML signatures convert to bounded plaintext (8 KiB); configured primary signatures (4 KiB) and sender names (256 bytes) override the primary defaults. This does not create or modify Google aliases.
Recovery drafts store exactly five raw composer fields, with empty typed recipient arrays, under existing private draft quotas. Index previews strip raw fields/body/attachment bytes. Submission refuses recovery fields until a normal validated draft update clears them. Newly optional From/recovery fields are omitted when null from stored canonical draft JSON, preserving legacy uncertain-send payload identities.
Authorization interface reference#
Normal users should follow full TUI/CLI permissions. These commands document the developer/API interface, not a required verification workflow. Keep the existing bar config and its OAuth registration unchanged. For broader terminal permission, create a second Google OAuth registration in the existing Google project and keep its downloaded installed JSON outside the repository. Enable People API in that project only if contacts are wanted. Configure the chosen address, enabled state and Chrome profile in the existing Omagma config. Later authorization opens that profile and checks the returned Gmail identity before saving a terminal credential.
omagma terminal-auth status --account ACCOUNT --config CONFIG
omagma terminal-auth authorize --account ACCOUNT --config CONFIG \
--client-file TERMINAL_OAUTH_JSON \
--capabilities mail-read,mail-send,mail-modify,contacts-read,contacts-write,calendar-rsvp
omagma terminal-auth revoke --account ACCOUNT --config CONFIG
Use --grant-file FILE to choose a separate registry, and pass the same option to later cli, agent, mail, contacts, invitations or tui sessions. JSONL requests can explicitly carry grantFile; auth authorization additionally carries clientFile and a string array capabilities. The default grant path is the config directory stated above. Read-only terminal use can fall back to the existing bar credential when no terminal grant exists. status shows configured grant metadata rather than proving current connectivity; revoke clears local terminal access and leaves the bar credential alone. --fixtures permits only synthetic auth status, with no consent or credential mutation.
Developer live-write acceptance uses a separately authorized test account: People creation/concurrent-edit rejection, verified sending identities, successful/uncertain send reconciliation and external-organizer RSVP delivery. Wider page/MIME/thread compatibility remains an ongoing live-read qualification boundary; recorded release smoke checks do not exhaust those variants. Fixture success does not establish Google grant policy, actual recipient delivery, Calendar state or successful provider-side idempotency.
Historical initial targeted gates (2026-10-05) used exact Zig 0.17.0, static musl and baseline x86_64 under a coordinated host reservation:
zig test src/terminal_codec_tests.zig -Odebug -target x86_64-linux-musl -mcpu baseline -lc -static
zig test src/terminal_codec_tests.zig -Osafe -target x86_64-linux-musl -mcpu baseline -lc -static
zig test src/terminal_provider_tests.zig -Odebug -target x86_64-linux-musl -mcpu baseline -lc -static
zig test src/terminal_provider_tests.zig -Osafe -target x86_64-linux-musl -mcpu baseline -lc -static
At the recorded targeted gate, codec tests passed 13/13 (including 288 synthetic FULL messages and malformed-limit inputs); provider/auth tests passed 34/34 in both modes. Subsequent full builds exercised additional parser, timezone, attachment, date and provider tests; final receipts remain in the versioned terminal verification records. These targeted runs measured correctness, not process memory or real Google interoperability. No browser consent, Secret Service mutation, live send, contact update, label change or Calendar write was performed by these tests.
Terminal wire qualification uses a test-only loopback POST method with a fixed synthetic bearer, the same JSON request path and 3 MiB terminal limits. probe-terminal-http sends a deterministic 128 KiB JSON payload; probe-terminal-http-oversize supplies more than 3 MiB and must fail before any TCP connection. The independent wire harness passed all seven cases in the recorded initial terminal Debug and Safe runs with Zig 0.17.0 on Linux x86_64/static musl: exact POST/header/body bytes, a response larger than the bar cap, redirect refusal, declared and chunked response caps, joined 10-second cancellation for absent headers and continuously arriving body bytes, and zero accepted TCP connections for an oversized outgoing request. Those checks used no real credentials or provider endpoints and establish wire behavior separately from semantic mocks. Production destination policy remains HTTPS Gmail/OAuth, adding People only through the terminal method.
Cache-first reads and incremental refresh#
The native Session retains at most three verified access tokens between jobs. Every lookup revalidates the current account, grant, client and credential revision; refreshes verify the Gmail profile before publishing a token. An explicit TUI open, reply or forward cancels and joins an active refresh before starting the selected foreground request. Row navigation alone keeps the refresh running, and active writes are not preempted. Completed cache commits survive interruption while the previous history checkpoint remains available for replay.
This transport increment covers token reuse and foreground priority. HTTP clients still have command-scoped lifetimes, connections use keep_alive=false, and metadata requests remain sequential. A stable worker-owned HTTP client and Gmail multipart read batching are deferred. No measured speed improvement is claimed.
Client.callCached and agent requests with cacheOnly:true support mail list/search/read/thread, cache stats/refresh status, cached labels/identities and cached contacts without OAuth or provider requests. The complete accepted command list is enforced in core.zig; missing read capability still rejects cached contacts. They use short shared cache locks and caller-owned result JSON. Cached mail.read returns a known final body refusal as a successful metadata-only message with bodyCached:false, a nonempty bodyCacheError, and no plain-text or HTML body. An absent message or uncached body without such a refusal returns CacheMiss; cached threads explicitly return partial:true. Default Inbox listing can reuse schema-1 cached envelopes immediately, even before a history checkpoint exists. Cached mail.list with an arbitrary Gmail query requires saved account/query/label provenance; local mail.search instead evaluates its own bounded cache-query grammar. Lists report cached, cacheReady, partial, stale, viewIncomplete, per-view lastSyncAt in epoch milliseconds, bodyCached and any stable bodyCacheError. Cursors bind account, query, label and generation; cache cursors use C, remote continuation cursors use L. An anchor ID can locate the selected message's cached page after new mail shifts the ordering. A saved view whose IDs were evicted advertises incompleteness; an online provider page remains usable even when those older messages are outside the retained newest set.
mail.refresh holds no cache lock over provider work. One in-place network session owns tokens, the HTTP allocator and joined cancellation for the bounded 30-second job. Bootstrap projects a global recent metadata head independently of the requested folder/search (TUI 32; API at most 100), then caches full visible messages one at a time using separate arenas and short commits. Existing full bodies are reused. Warm refresh uses saved Gmail history: no-change history performs no mailbox list, metadata or body fetch; known label-only changes fetch MINIMAL data and preserve immutable body files and hashes. Added/unknown IDs fetch metadata and eligible uncached visible bodies. Deletions remove metadata and body files. Searches may need a bounded new ID projection to preserve Google's query semantics; simple saved folders derive their cached membership from authoritative labels. Google synchronization guide, history.list contract.
History traversal is bounded at eight pages, 256 typed events and 100 unique changed IDs. A missing or expired checkpoint, or an over-budget history, uses bounded recent resync. Resync keeps separate account-retention IDs and requested-view IDs. The global recent projection includes Spam/Trash and is never filtered by the requested query or folder; a second scoped ID projection reuses global metadata and fetches only distinct extras within the total 100-metadata cap. Thus a 40-row request uses at most 80 metadata fetches, while the hard cap remains 100. IDs lacking authoritative metadata cannot survive under the advanced checkpoint, and all other saved views are discarded so their next refresh must project membership again. Uncached scoped IDs are advertised through viewIncomplete; they never redefine the whole account cache. The bootstrap checkpoint is captured before enumeration, so later changes are replayable; final history checkpoints advance only after all planned updates and eligible body commits complete. Interrupted jobs leave partial cached progress with the old checkpoint, and retries skip already cached immutable bodies. Retained IDs preserve full bytes on resync. Unsupported or oversized bodies have explicit cache-refusal status instead of a false offline-body claim.
Retention is newest-first by received timestamp with an ID tie-break, capped by the configured count (default 2,000 per account) and the existing 256 MiB per-account byte guard. Both count and byte pressure evict whole old-tail mail entries and body files while preserving drafts, contacts, sent fixture outbox and operation receipts. A byte-pressure cutoff prevents queued older downloads or pagination from recreating holes beneath an evicted tail; cache clear or a larger byte budget resets it. Lower limits are enforced by short writable cache opens before provider work, including offline refresh attempts. Cache-only reads do not rewrite the index.
Synthetic fixture refresh supports sync.historyId, API-shaped sync.historyPages, sync.expired, and optional leaf-name sync.fixtureHold / sync.fixtureEntered files under the fictional fixture root. The entered marker precedes a cancellation-aware wait while the hold file exists. This proves cached list/read/thread usability before background completion. cache.stats exposes committed sync-call, list-call, history-page, metadata and full-body counters. These controls and receipts require no real credentials or endpoints.
Fixture provider label mutations live in protected account-specific provider records, separate from evictable mail entries. Up to 1,024 IDs are retained, each with at most 64 labels of 256 bytes and a bounded source history checkpoint; the existing private index size and disk quota apply. Cache clear, old-tail eviction and restart preserve this synthetic remote state, just as they preserve the fixture sent outbox. All fixture read/list/thread/refresh source projections apply it; fixture files remain read-only. Newer typed history for the same ID replaces a local override, deletion tombstones prevent stale-source resurrection, and unrelated history leaves other mock mutations intact. An explicit newer expired/full snapshot reconciles records authoritatively when bounded typed history is unavailable. These records are restricted to the fixture namespace and can never affect live Gmail or credential lookup.
Progress and cached-window ownership#
Client.callWithProgress wraps one owned executor call with an optional
ProgressSink; ordinary CLI frames remain unchanged. Metadata progress counts
actual returned IDs and completed gets, and body progress counts missing eligible
bodies, not the Gmail Inbox estimate or requested maximum. Each FetchRow is
borrowed only during its callback. The TUI copies at most 32 immutable previews
and bounded excerpts into the active job mailbox, checks account/request
identity, and never exposes provisional IDs as interactive committed rows.
The callback stores atomics and may wake the existing event queue; it does not
allocate or perform network/storage work. Owner shutdown cancels and joins
workers before releasing this storage.
Cached lists and cache searches accept mutually exclusive beforeMessageId or
afterMessageId selectors with cacheOnly:true. They exclude that boundary and
return up to the requested limit of immediate filtered predecessors/successors.
The optional millisecond boundaryReceivedAt permits a missing/evicted boundary
to choose the nearest retained timestamp/ID-ordered window, explicitly marked
boundaryFallback:true. Missing boundaries without a usable identity/time fail
with CacheBoundaryGone; windows never silently fetch an old server page.
Responses expose hasMoreCachedBefore/hasMoreCachedAfter, and this navigation
works after restart without a UI cursor stack. Opaque cursor modes remain
account/query/label scoped; body-aware search cursors additionally bind hashes.
Fixture-only sync.fixtureProgress can hold an actual completed metadata/body
checkpoint under the fictional fixture root. Its validated hold/entered leaf
names use a finite, cancellation-aware wait. Independent PTYs inspect actual
fractions and copied arrivals; these controls never apply to live credentials.
Background foundation and large-cache bounds#
Automatic mail.refresh requests set auto:true and intervalSeconds:300, default to Inbox/32, force the bar's read-only Google OAuth registration and Secret Service namespace with barGrantOnly:true, and use non-unlocking automatic lookup. They never load a wider terminal grant. Manual refresh bypasses age coalescing, while every request shares the same private per-account refresh.lock lease. The lease is a zero-byte 0600 regular file, opened nonblocking with NOFOLLOW/CLOEXEC/NOCTTY, and held independently of the short index locks. Kernel descriptor lifetime releases a crashed owner. cache.refresh-status returns only refreshInProgress without parsing the index. Refresh responses expose coalesced, refreshed and refreshInProgress. Cached reads and contacts remain available while another owner refreshes.
The automatic freshness window uses the matching Inbox view's lastSyncStartedAt, after a successful checkpoint, and is at most half the requested interval: 150 seconds for a five-minute timer. Future timestamps and stale views require refresh. A quota-resident partial subset can coalesce after all retained visible bodies were processed or explicitly refused. metadataPolicy and diskPolicy persist the account's limits; cached and automatic jobs adopt them. Manual explicit limit flags update policy, including explicit resets to defaults. This keeps the timer from refilling a cache the user deliberately reduced.
Each refresh store phase and each body validation/download/commit owns a separate arena. Entry storage grows amortized and reuses capacity after tail eviction; it does not copy the whole 2,000-entry array for every inserted message. Body-only attachments neither change metadata generations nor resurrect evicted/deleted IDs. Already valid immutable bodies are reused when labels change. Missing or same-account hash-damaged bodies are cache misses and may be refetched; wrong account/message/schema remains a hard integrity error. Displayed body availability checks actual files, and refresh validates existing bodies one at a time rather than trusting stale byte counters. Strict old-plus-new disk quota accounting remains. Oversized writes fail before destructive eviction, and replacement at a protected old tail does not evict newer mail around it. Message-scoped malformed MIME has an explicit refusal code and does not stall the mailbox checkpoint forever; transport, authentication, cancellation, allocation and cache failures still abort.
Gmail inline plain/HTML leaves tolerate understated body.size metadata after complete base64url decoding: actual bytes may exceed the declared count, including a zero count. This is a narrow compatibility policy; Google's documented field still describes an exact byte count. It applies only with no external attachment, no filename or attachment disposition, and no child parts (an empty array counts as a leaf). Bodies shorter than their declaration, empty data with a positive declaration, invalid data, negative/oversized declarations and all existing actual-byte limits remain refused. External, attached, calendar and container bodies keep exact size checks; raw MIME and outgoing attachments retain their rules. Actual decoded bytes govern the unchanged 2 MiB leaf and plain/HTML limits and 3 MiB combined decoded limit, with account identity and cached SHA checks preserved. Google MessagePartBody contract.
Cache search is separate from saved Gmail view provenance: mail.search with cacheOnly:true searches retained metadata and already downloaded plaintext with bounded AND/quoted/negative terms and metadata/body predicates. It returns K-mode account/query/label/generation cursors, searchMode:"cache", searchScope:"metadata-and-cached-bodies", bounded searchMatches, highlightTerm, matchedCachedCount, and partial:true. Body-reading cursors additionally bind retained body hashes, since prefetch residency can change matches without changing metadata generation. Each body is reclaimed before inspecting the next entry, and each iteration checks cancellation; no missing body is downloaded for search. Default server search remains Google's query language. Cached contacts require contacts-read, use local name/email matching, and expose cacheReady so downloaded-empty differs from not loaded; neither cached API requests OAuth nor starts a second network worker.
Historical 0.2.2 diagnostics#
The observations below retain their original targeted artifacts and acceptance classifications. They explain cache/MIME implementation decisions; they are not measurements of the later local TUI changes.
The independent maximum-cache Debug receipt is tests/results/tui-cache-max-bounds-debug-2.json: 2,000 metadata records averaging 1,528 bytes plus 100 added and 10 deleted messages completed in 13.2716 seconds. It retained exactly 2,000 newest IDs, fetched 100 new metadata/full bodies without relisting, validated 102 body references and retained hashes, and found no orphan files. Capped heap peak was 41,953,544 bytes with zero rejected allocations. Whole-process observed HWM was 47,956 KiB, a separate measurement from the 64 MiB application heap and 16 MiB fixed reservation. This targeted Debug result precedes final merged Debug/Safe release qualification.
The maximum-cache workload exposed unnecessary index processing on native ARM Debug: every short body phase reparsed and heap-sorted an already ordered metadata vector, while each metadata insertion sorted the whole vector again. The cache now borrows unescaped JSON strings from its caller-owned immutable index buffer, checks existing order in linear time with a fallback for legacy unordered indexes, and inserts or repositions only the changed row. Independent lifetime/order tests cover escaped values, long references, protected outbox/provider state, timestamp and ID ties, retained body hashes and lower-count tail eviction. The 30-second deadline, workload, allocation/disk limits, index lock boundaries, per-body durable saves and checkpoint rules remain unchanged; this is application cache work, with performance measurements qualified separately.
An exact 2 MiB HTML fixture also exposed overlapping normalization and JSON scratch. Cache prefetch now carries an authorized typed message into the body commit, avoiding an internal Message/JSON/Message round-trip. The legacy converter and sanitizer reserve bounded output once, and the already sanitized HTML fallback is reused; output bytes and body limits are unchanged. The targeted Debug diagnostic tests/results/html-large-typed-resource-debug-4.json preserves the complete tail with a 56,024,724-byte heap peak and zero rejected allocations. It keeps the 100-cycle warmup but uses one measured cycle and a one-second quiet interval (acceptanceRun:false), so it does not replace final Debug/Safe resource qualification. Parser/layout complexity falls back to the complete existing plain text, independently of provider body refusal.
Targeted static-musl Zig 0.17.0 Debug checks also passed the six native background cases (tests/results/tui-background-foundation-debug-2.json) and twelve cache cases (tests/results/tui-foundation-cache-regression-debug-2.json). The background receipt covers quiet recent-work coalescing, concurrent native/CLI/TUI ownership with usable cached bodies, cancellation/restart, finite special-file refusal, 150-second freshness boundaries and persisted policy, and read-only capability gates. The cache receipt includes expired/missing-history narrow-view isolation and cached reader navigation before held provider work completes. These synthetic checks used no real credentials, systemd service, desktop installation or production endpoints; full merged Debug/Safe qualification remains separate.