docs/KB.md

Repository knowledge

User-facing steps for saved views, Contacts imports and reviewed unsubscribe actions are in the README. Product scope is summarized in PRODUCT.md.

Saved search views

Save view appears in the mail search toolbar when a query or list filter is active. It asks only for a name. Commands offers Save current view and separate Open, Rename and Delete actions for each saved view, using the existing keyboard selection and focus behavior. Definitions preserve mailbox, Inbox tab, query, account, Gmail label, read/relevance filters and sort. Opening a view clears reader selection and restores the definition in one navigation visit. A removed account or label disables opening rather than silently broadening the search.

shared/saved-views.ts owns bounded schemas. server/saved-views.ts mounts session/CSRF-protected CRUD under /api/saved-views; savedView records use the existing AES-256-GCM encrypted store. At most 100 views are retained. Names are 1 to 80 single-line characters. Revision checks reject stale renames/deletes. Definitions stay local and search only existing cached mail and local drafts. They never fetch history, change categories or mutate provider mail.

Privacy replaces command names with numbered Saved view labels and hides unfocused search text. The shared private name field reveals its value only while editing. No saved-view definition is written to localStorage. server/saved-views.test.ts checks encryption, restart, exact definitions and guarded CRUD. Saved-view cases in src/navigation.test.tsx exercise Commands, filter restoration, rename/delete and privacy. Native and installed acceptance are separate from these synthetic checks.

Reviewed unsubscribe

Provider MIME parsing stores bounded List-Unsubscribe metadata on full encrypted mail records. It does not fetch destinations or add metadata to list summaries. The hydrated reader exposes Unsubscribe for supported headers and an explanation for rejected headers. Every action first shows the destination host or mailto address and describes its effect. Privacy requires an explicit destination reveal before confirmation and clears that reveal when enabled.

POST /api/messages/:id/unsubscribe inherits session, origin, CSRF, sync and update guards. It requires explicit confirmation and exact agreement with the stored metadata. The service POST uses the RFC 8058 form body, a five-second deadline, public-only DNS results and a pinned connection address. It follows zero redirects and sends no cookies or authorization. A mailto action creates an ordinary encrypted draft from the receiving account without sending. A manual HTTPS action checks the initial destination and returns a browser link; the system browser controls its subsequent requests and redirects.

Headers are sender-supplied, with no DKIM authentication claim. Unknown request outcomes are explained and never retried automatically. This does not manage subscriptions or change local mail/category state. Details and limits are in ADR 0099. Synthetic parsing, service and mounted reader checks live in server/unsubscribe.test.ts and src/unsubscribe.test.tsx. Real unsubscribe delivery, provider-header availability and installed browser handoff require separate acceptance.

Phone pairing, passkeys and browser key bootstrap

server/phone-passkeys.ts mounts only the public /auth/pair, bundled client and named pairing/WebAuthn POST routes before session authentication. Gate Host/Origin checks apply first; remote POSTs require the exact verified origin. Host management uses the normal authenticated API and CSRF guard plus exact localhost Host/Origin checks.

Two-minute invitations, claims and challenges stay in bounded memory. Invite tokens occur only in QR URL fragments; src/phone-auth.ts clears the fragment before requests. A Secure HttpOnly claim cookie binds host-approved registration. SimpleWebAuthn verifies exact origin, RP ID, challenge, user presence and user verification. Public credentials are encrypted phoneCredential records. Cookie-bound login challenges are one-use and expire after a minute. Counter updates and session issuance recheck credential existence after verification. Revocation and cancellation win against in-flight verification. Route lifetime changes invalidate invitations, ceremonies and remote sessions even if the same origin is restored. Passkeys may sync across physical devices; removal revokes the credential rather than identifying one piece of hardware.

The gate keeps a bounded session map. Password login rotates password sessions; a passkey rotates its own prior session and preserves the host session. Recovery passwords are localhost-only. Lock removes the requesting session, removal locks all sessions using that passkey, and restart clears all sessions.

npm run build:web builds the frontend and its public WebAuthn client without Electron packaging. scripts/start-browser.mjs optionally provisions the data key using a 15-second child process and private stdout pipe. The child uses @napi-rs/keyring, pins Linux to persistent Secret Service, serializes bootstrap under a profile lock, checks existing markers/database, and verifies OS-store readback before returning the key. No plaintext or volatile fallback is allowed. browser-keyring.json contains identity metadata only. Existing external-key profiles are not migrated. Desktop key protection is unchanged.

Synthetic proof lives in server/phone-passkeys.test.ts (real signed WebAuthn fixtures), server/browser-keyring.test.ts, src/phone-pairing.test.tsx, and the browser gate/session tests. Native passkey and OS credential-store acceptance need independent runtime proof. See ADR 0082.

Private phone access

server/phone-access.ts runs the host Tailscale CLI with bounded execFile, no shell and an environment excluding mail secrets. It stores pending/ready ownership in the encrypted phoneAccess record. Settings management requires the authenticated loopback-origin session, exact Host and Origin, CSRF and explicit confirmation. Desktop reports unsupported; phone browsers may read status but cannot mutate the route.

Setup selects unused HTTPS ports 8444 through 8454, checks exact target and route preservation, rejects Funnel, and probes TLS with the browser-gate response marker before admission. Renewal runs every 30 seconds, with a 60-second lease and TLS recheck. Failed checks revoke admission. Expired proof invalidates retained remote sessions before any renewal. Startup verifies saved routes before admission. Disable revokes first and removes only matching owned configuration. Partial cleanup retains pending ownership and a visible error. Concurrent external Serve writers are outside the adapter's lock; avoid manual route changes during setup or removal. The adapter checks before/after snapshots and refuses cleanup when ownership is no longer exact.

MAIL_WEB_PORT selects the loopback backend port independently of the frontend APP_URL. OAuth stores the trusted initiating origin in single-use consent state and binds callback exchange and redirects to it. Phone cookies use Secure, HttpOnly, Strict and the __Host- prefix. MCP retains its own loopback boundary.

Focused synthetic checks: server/phone-access.test.ts, server/browser-access.test.ts, src/phone-access.test.tsx. See ADR 0081 and phone setup. Live Tailscale, provider OAuth and phone interaction require separate proof.

Local browser access gate

Browser startup imports server/browser-preflight.ts before storage. It requires MAIL_WEB_PASSWORD, externally provisioned MAIL_DATA_KEY and isolated MAIL_DATA_DIR, with canonical path checks and desktop/legacy marker refusal. server/browser-access.ts protects APIs and assets with bounded in-memory sessions, HttpOnly Strict cookie, exact host/origin checks and five attempts per minute. Sessions expire after 30 minutes of request inactivity or eight hours total. Password login rotates password sessions; passkey login rotates that credential's session. Explicit Lock at /auth ends the requesting session; restart ends all sessions. Background polling counts as activity. Existing CSRF and MCP authorization remain in place; only the Google return callback bypasses the browser session and still validates its single-use consent state and separate cookie.

See browser setup and ADR 0080. Focused synthetic proof: server/browser-access.test.ts. Vite proxies /auth and denies private files. The development endpoint is not a packaged browser distribution. Native, interactive browser, real-provider and installed acceptance are separate.

Standalone Android client

android/ is an independent Java/Android IMAP and SMTP client. It does not run the Electron renderer or Node mail service. The native client keeps a bounded offline inbox, accounts, a draft and uncertain-send state in one atomic Keystore-backed encrypted snapshot. It never shares or migrates desktop data. Build commands, limits and proof boundaries are in Android. The architecture is recorded in ADR 0079.

Source release version

The source package and both lockfile version fields are 0.2.5. Dependencies and release workflows are unchanged. The automatic main-push lane chooses the next unused patch above both source and stable tags, so this source selects 0.2.6 unless a higher stable tag exists. Manual publication of 0.2.5 requires an immutable matching tag at the approved source commit and separate publication authority. Source version metadata does not establish installer acceptance.

Responsiveness and composition

Gmail and Calendar share server/google-api.ts, which imports their specific installed Google API modules. Tests mock this same facade; no other API clients are loaded at service startup. Account sync captures account-scoped summaries with syncMailCache and reads individual full records on demand. Providers stream batches without retaining a second completed-mailbox array when the service opts into streaming. Missing mail is archived only after a successful complete listing. New-mail classification and notifications retain IDs and hydrate current records, preserving account and message identity checks.

The 25,000-message flag-batching case in server/sync-recovery.test.ts seeds mail and its encrypted summary companions through putBatch before installing or resetting operation counters. Raw mail-only fixtures also trigger legacy summary repair and do not isolate flag reconciliation. Workload size, sync transaction/event counts and zero provider body reads remain asserted.

Compose leaves toolbar Search and Privacy reachable and keeps underlying mail/work panes inert. Search can change the cached view without replacing the unsent draft. Reply context and editor share the working area side by side. Other dialogs remain modal. Appearance, shortcuts, notification preferences, privacy, account data and compose preferences use their existing storage; there is no preference migration or reset.

Local contacts (ADR 0084) are encrypted, account-scoped store records served from /api/contacts. Recent recipients are recorded in sendMail only after the provider accepts a send. RecipientInput completes the address after the last comma, so one component serves single-address and comma-separated fields. Contacts opens from the Workspace sidebar and Commands, outside Settings. Its Saved contacts, Recent recipients and Import contacts sections reuse the Settings rail, grouped forms and narrow-screen section selector. Sidebar account filters apply to the contact lists; Find contacts filters names and addresses locally. Imported entries use the same saved-contact records and recipient suggestions. No account migration is needed.

POST /api/contacts/import/preview validates an account-bound UTF-8 CSV or vCard without writing. POST /api/contacts/import reparses the submitted file and saves all new entries in one transaction. Both inherit session, origin, CSRF and update guards. Files are bounded to 512 KiB and 5000 addresses; saved contacts remain limited to 1000 per account. Duplicate addresses are case-insensitive within the destination account and never overwrite existing names. Invalid and repeated addresses are counted as skipped. Only names and addresses are retained; no raw file, filename, photo, link, formula or provider credential is stored. An over-limit import writes nothing. See contact imports and ADR 0098.

Plain unquoted search words of 4 to 32 letters allow one insertion, deletion, substitution or adjacent transposition within a word. Quoted phrases, field terms, exclusions, addresses, account filters and invalid recognized operators retain exact semantics. Typo matching stays local to the existing cached search.

The sidebar footer and collapsed coverage bar are 42px tall. Today status-tab counts and Refresh/Add task share one row. See ADR 0069.

Floating Settings

Settings uses the shared Dialog above the mounted mail, Drafts or work pane. The background frame and update control are inert. Settings retains all eleven sections, grouped as Connections, Mail, Preferences and Advanced, with separately scrolling rail and content; the grouped rail moves above the content in narrow panes. Shortcut fields remain labeled capture inputs, organized as Navigation, Mail actions and App. They use two columns when the Settings pane is at least 820px wide and one column below that width. Reset interaction defaults stays beside the shortcut heading, with fixed system shortcuts in a disclosure. Preference validation and save behavior are unchanged. Its fixed header contains Back, Forward and Close. Stacked Settings rows remain single-column flex containers. Only horizontal rows wrap their controls; wrapping a stacked row can inflate the theme grid and leave excessive empty space below each preview and label. Opening Settings focuses Close so hidden private fields remain hidden. The Linux local-key warning mounts only in the active Settings or mail surface, reading its session collapse preference when moving between them.

Settings history preserves the existing reader and validates its account and message on historical restore. Other menu and reader history retains its existing behavior. Search and Privacy shortcuts work from the Settings shell; Search closes Settings and focuses All cached. Editors, shortcut-capture fields and nested dialogs retain their keyboard ownership. Commands stays blocked until Settings closes. Compose navigation still flushes the draft before opening Settings; unsaved compose preferences and pending-save locks remain in App state across closing and section changes.

See ADR 0077. Mounted source tests cover focus, history, guards and retained edits. They do not prove installed native layout or input.

Region focus cycle

F6 and Shift+F6 move keyboard focus through the visible window regions in a fixed order: sidebar, search toolbar, mail list and the reader beside it. The order lives in src/region-focus.ts, where nextRegion picks the next available region and regionOf classifies the focused element. src/App.tsx resolves each region's focus target: the current view in the sidebar, the search input, the selected row in the list and the opened email in the reader. A closed sidebar and a closed or full-width reader are skipped; in full-width reading the .message article inside the list pane is the list target. The .list and .reader-scroll containers carry tabIndex={-1} so an empty region still lands somewhere visible.

Dialogs, the composer and the command palette are outside the cycle, and F6 does nothing while focus is inside them or inside any field other than the search box. No desktop change was needed: the macOS menu uses stock roles, the native context menus pass accelerators as display-only text (registerAccelerator: false), and the only before-input-event handling is the Linux Ctrl+Q quit, so no native accelerator captures F6. Mounted coverage is in src/navigation.test.tsx; it does not prove native input on Windows, macOS or Linux builds.

Shared application styling

The working layout uses a 208px main rail and names each view once in the persistent toolbar. Inbox categories and Drafts account tabs follow it without another title row. Work action and cached-mail bars keep their existing controls; Settings keeps its section heading. Category navigation sits outside the list column. Card heights and the list virtualizer share the tokens in styles/tokens.css; account metadata stays inside those measured rows.

Today status navigation is local display state. Open groups are separated by deadline and waiting status; completed and cancelled work stays available. Projects keeps decisions in its context pane and preserves decision draft state when the source reader opens. Calendar month and day agenda use separate columns, falling back to one column for selected details or narrow space. The Agent review context shows counts and the existing Settings destination. All operations still use the original handlers and uncertainty guards.

Mail-row action strips appear only for fine-pointer hover. The virtualizer reserves the message row height alone; narrow and touch lists do not add a second strip. Selection and focus preserve their row treatment without exposing controls. Keyboard context menus and reader actions are unchanged. See ADR 0067.

Dialog backdrops and surfaces explicitly use -webkit-app-region: no-drag. This excludes their full bounds from native drag regions on pane headers behind them, including the composer account selector, Discard and Close.

The current renderer adds a view breadcrumb to the main toolbar and account tabs to Drafts. Those tabs use the existing navigation account filter. Their counts are hidden when a list read fails. Recipient-first draft rows keep account and saved-time context. Calendar places Add task in its view toolbar. The composer reuses native file selection in a compact attachment toolbar.

With native glass enabled, the rail, Calendar headings and compose surface remain transparent over a single tint on the app. Appearance stores glassOpacity as an integer from 0 to 100 in its existing local preference, defaulting missing or invalid stored values to 70. The --glass-opacity token sets the shared surface tint without fading text. The sidebar, app-main and Settings groups add no opaque or duplicate tint. Off and unsupported platforms ignore the level and disable the slider. See ADR 0068. Underlying working views are clipped to the visible list column while composing, preventing mail text from showing behind the editor. Off keeps the approved opaque surfaces. Native material selection and supported-platform checks are unchanged.

src/styles/application.css applies the shared finish after the existing area styles: neutral navigation and headers, draft cards, continuous Calendar cells and consistent Settings and dialog spacing. Primary action colors and depth tokens live in src/styles/tokens.css; appearance applies the selected accent to those controls. Existing light/dark, custom-theme and transparency settings remain in their original layers.

Compose uses the existing Dialog component with compose-dialog and compose-layout classes to present a flush right pane. Its dialog role, keyboard trap and save/send handlers are unchanged. Save status now shares .compose-actions with Send; error recovery stays in the footer. The global scrollbar rules in src/styles/base.css hide indicators without removing scrolling from mail, Settings, work views, dialogs or menus. See the design.

Workflow verification

User journeys maps expected states, failure branches and focused tests. Installed smoke binds startup, UI and responsiveness checks to the packaged candidate. Keep synthetic, installed and real-account outcomes separate. Shared Linux tests and builds use the required bounded runner.

CLI

CLI setup and commands use scripts/emc2.mjs through Node 24 or npm run cli --. The npm bin is emc2, available after optional npm link. The CLI uses the existing authenticated loopback MCP transport and discovers tools before calling them. It does not read the store or app keys. Read is non-mutating; explicit mark-read/archive requests preserve the server result, including V2 pending proposals. No approval or sending command is exposed. Work lists and immediate confirmed creation require the feature-detected V2 work tools and explicit grants. Creation uses caller-owned JSON files with a stable UUID requestId. propose-draft requests review through propose_draft; it never sends. The server exposes 14 tools. Authenticated read-only get_connection_status returns a version 1 contract with capabilities and current permissions: granted tool names and existing permitted account count, without account identities or credentials. The general token reports no grants or accounts. This tool requires no new AgentTool grant and grants no authority. Explicit list/search --view inbox|sent|all requires the advertised view capability. Omitted view keeps all cached mail; Inbox excludes sent, archived and future-snoozed mail, and Sent selects cached sent direction. Filters apply within grants before paging. Summaries include received/sent direction. reply <file> --confirm requires the explicit version 1 proposal-to-draft capability before calling reply_to_email. Approval saves editable Drafts; the CLI never sends or approves. Each operation still checks its explicit grant. See ADR 0070. Run node --test scripts/emc2.test.mjs for the isolated synthetic CLI checks.

Mail-list sorting labels use the existing --reason theme color, including custom themes. Built-in themes use peach in dark mode and rust in light mode.

Simpler agent setup and manual work templates

Projects applies the existing Redact control to the joined account labels in cross-account approval. Mounted synthetic coverage checks visible labels with privacy off, hidden known names and addresses in DOM text and attributes with privacy on, retained source selection and approval, fresh approval after source changes, and the exact account/source payload after explicit save. Privacy and selection changes do not save a project. Shared account-label formatting and private editors are unchanged.

The Projects regression retains the original source checkbox elements by source identity before selection, checks that they stay connected and checked as expected, and toggles the retained in-b source. It does not assume checkbox order because selected sources render first. Linux synthetic validation on 2026-10-18 passed the targeted regression, all 79 tests in the six focused renderer files, and all 787 tests in the full serial suite, with no failures, skips, cancellations or todos. These checks do not prove native interaction or live-provider behavior.

Work coverage account labels use the existing Redact control so known private names stay out of DOM text and attributes while privacy is enabled. Coverage status and explicit fetch controls remain available. Shared account-label formatting and focus-to-reveal private editors are unchanged.

Agent Reviews also applies Redact to allowed-account labels and each existing grant's joined Accounts summary. Mounted synthetic coverage checks both paths with a known private custom name, absence from DOM text and attributes, and checkbox selection before and after enabling privacy. Permission payloads and BYOK freshness and recovery guards are unchanged.

Agent Reviews offers an explicit Reply assistant preset with only read_email and propose_draft. The user chooses accounts and clicks Create agent permission. Provider setup shows provider and API key first, with preset models under closed Advanced controls. Custom HTTPS endpoint and model remain visible. Saving reports local encrypted storage and an untested connection; selecting an email and preparing a draft remain separate explicit actions. Credential reveal, revocation, OpenCode setup and advanced tools remain available. This flow never enables MCP or sending automatically.

New commitment forms offer editable reply, task and waiting templates. Choosing one fills the action, status and an honest manual-tracking reason without saving. Only the selected cached email can supply a subject. Existing items keep their values and history. The forms explain due dates, local reminders, source emails and supporting emails; each work section explains its purpose. These renderer changes do not establish native appearance or live-provider behavior.

MCP endpoint copying

Settings, MCP copies the endpoint through a native desktop action. The action accepts only the active window's main frame at the renderer origin and writes the fixed local MCP endpoint. It accepts no text from the renderer and exposes no clipboard reads. Success is reported after the native write completes. Browser permission checks remain denied; the optional web target continues to use its browser clipboard API.

The mounted Copy-button regression covers a denied browser API, native failure reporting and web fallback. Desktop and preload tests cover sender validation and the fixed endpoint payload.

Each scoped grant on Agent permissions also offers Set up OpenCode. On the desktop app, setup-opencode validates the grant credential (64 lowercase hex), derives the OpenCode user directory in the main process and edits only mcp.emc2 through desktop/opencode.ts with jsonc-parser: it stages private files, keeps a private backup and never guesses between two config files. It refuses an ambiguous or unsafe location and reports codes only, never a path or credential. The renderer enables MCP on the same click through the existing settings API. The copy-opencode-config clipboard action and the fixed-endpoint fragment stay for the Advanced: manual setup fallback: it composes the same emc2 entry for the fixed loopback endpoint, validates the credential before writing and keeps the on-screen preview and notices free of the credential. The browser target writes the identical shared fragment through its clipboard API. No non-local origin, project config or automatic external write is involved.

Agent review provider keys

shared/agent-provider.ts contains the sanitized provider settings shape and OpenAI/DeepSeek presets. server/agent-provider.ts mounts authenticated settings routes at /api/agent/grants/:id/provider: GET returns settings or null, PUT saves bounded fields with an optional new key and the current revision (null for a first save), and DELETE requires the saved revision. Keys remain in the existing encrypted record store under agentProvider, separate from grants. Each successful replacement gets a new opaque revision. Grant revocation atomically removes both grant and provider settings.

POST /api/agent/grants/:id/provider/prepare accepts sourceMailId, provider revision and optional instruction. It requires cached received mail in the grant's account scope with both read_email and propose_draft. The request sends only bounded plain-text selected-email context. A provider reply becomes the body of a pending draft proposal, with locally derived recipient, subject and threading fields. Existing review creates the local draft; sending and MCP settings are unchanged. The existing approval path still requires MCP on.

The source fingerprint, preview, current account, grant and provider revision are rechecked after the external request. A per-grant guard rejects concurrent preparation. HTTPS destinations reject credentials, query, fragment, local names and private literal hosts; public DNS resolution is not pinned. Fetch refuses redirects and retries, times out after 30 seconds, and bounds response bytes and reply length. Provider errors and response bodies never become error diagnostics. No API key is returned in settings or proposal responses.

src/work/AgentProvider.tsx provides the compact disclosure before optional OpenCode setup. It reuses the bounded SourceSelect and privacy controls, clears a successfully saved key, preserves failed input and ignores stale async results. Preparation establishes a hold at dispatch, including across workspace disable and re-enable changes. Only successful history reconciliation after the request settles can clear the hold. Stale results still cannot report success. useWorkspace.refreshFresh waits for any existing workspace read, then requests current history through the normal refresh and mutation-lock checks. BYOK uses this callback directly, so proposal reconciliation does not reload grants/MCP or toggle permission availability. Ordinary workspace reads still coalesce; unknown mutations still require explicit saved-work review. Refresh action history must succeed before another provider request can start; users are prompted to inspect existing proposals. Editing the instruction does not clear this hold. The provider request is never retried automatically. ADR 0045 records the limits.

Mounted src/workbench.test.tsx regressions hold an old workspace response across acknowledged and stored-but-lost preparation, then require a new read before releasing the hold. They also cover failed fresh reads, visible proposal readback, explicit later preparation, ordinary coalescing and mutation recovery. Linux validation on 2026-10-18 passed the 63 focused Today/dialog/Workbench/ provider tests, the final 25-test Workbench preservation check, the strict production build, all 774 tests with file concurrency 1 and the unchanged 120000 ms file timeout, and the isolated MCP handshake/access check. No tests were skipped. Native, visual, installation and live-provider behavior remain unverified.

Confirmed-work experiment

The local experiment opens on Today. Commitments are explicit user records, independent of read flags, replies and archive state. Each deliverable has its own status, source and evidence history. Projects collect tasks, emails and decisions for a job. Compact navigation shows each project name once, followed by tasks, emails and decisions. Email rows show sender, subject, account, direction and date; the reader holds the full content. Selecting a row opens the existing reader inside Projects. Add task prefills the project and a cached source. Saving new work returns to the list and closes any previously opened email so its actions cannot target the wrong source. Edit project / add emails changes the linked sources; combining accounts still requires approval of their exact set. Decisions and supporting emails stay behind an optional disclosure. Project switching stays disabled while a save is pending or unresolved, keeping failed decision text and supporting-email selections until recovery is reviewed.

shared/workspace.ts defines the records; server/workspace.ts validates and stores them through the encrypted record store. src/work/Workbench.tsx owns the action, project and agent-review screens. App owns src/work/useWorkspace.ts, one in-memory snapshot shared by those screens, mail quick tracking and the sidebar due count. GET /api/workspace is separate from the existing mail-state response. ADR 0042 records the decision and acceptance boundary.

V2 adds dueDate (YYYY-MM-DD) and optional followUpAt (epoch milliseconds) to the same encrypted commitment and history records. Legacy dueAt dates retain their UTC calendar day; new deadline saves also keep dueAt for compatibility. A patch can change either deadline field, never both at once. Follow-up changes use the same optimistic revision check and do not alter the deadline or status. Omitted evidence fields preserve existing links during partial updates. src/work/Calendar.tsx renders month and agenda views from open commitments in the selected account scope, with an overdue list. Open work without a deadline or follow-up appears on today's date as "No deadline" until it is scheduled or closed. Today keeps the selected work and its email in the right pane whenever the window is at least 980 px wide, regardless of the mail reading-pane setting. Today updates its in-app follow-up notice once per minute. There is no background reminder service or external calendar integration. ADR 0043 records these limits.

Today and Projects source and conversation links reuse App's opened-message state and Reader while retaining the work screen, account scope and mail filters. Explicit opening still marks mail read. The work list stays mounted across reading and close. A compact task bar sits above the email; its reason, sources and history are collapsed under Details. Source removal shows an unavailable state. src/work/QuickTrack.tsx creates or patches one explicit item, with a shared mutation lock and captured revision. Quick patches omit deadline, project and evidence fields. Unknown saves require refresh and review; acknowledging recovery and explicitly choosing New preserve the entered title, status and reminder. Selecting a saved item loads its fields. Acknowledged saves cannot be blindly repeated after refresh failure. Quick tracking uses cached source/evidence aliases for work text, with visible status/reminder controls and no saved-item chooser for a fresh email. The opened Today source accepts Reply, Archive, Snooze and Teach shortcuts while list traversal stays disabled. Archive does not complete a commitment. The full editor remains available for dates, evidence and explicit completion.

shared/follow-up-presets.ts uses local calendar days for tomorrow and next Monday at 9am. Today at 5pm is offered only while future, and save recomputes the chosen preset. Workspace writes refresh the shared snapshot. A one-minute timer and window focus refresh both the snapshot and due-time comparison across views. The sidebar counts open, due follow-ups within the account filter, including zero; failed or pending reads show an unavailable count instead of stale totals. Cached rows stay labeled, and editing waits for a successful refresh. No private work data is persisted in browser storage. See ADR 0046. Dialog initial focus skips PrivateField inputs and textareas while privacy is enabled, using their data-private-field marker, and skips disabled controls. It prefers another enabled field, then an enabled button. Explicit field focus reveals private text for editing; blur hides it again. Outside privacy mode, ordinary initial field focus remains available. The keyboard trap and focus restoration code are unchanged.

The commitment-editor privacy regression moves focus to the enabled recovery control after an unknown save. Editing and retry stay blocked until a successful refresh and explicit review acknowledgement. The test checks retained title and reason, empty DOM values and defaults while hidden, deliberate focus to reveal each field after recovery, and no duplicate save. The shared-dialog fixture uses the installed happy-dom because jsdom 27.4.0 misorders its grouped control selector.

Synthetic Linux validation on 2026-10-18 passed all 774 tests with test-file concurrency 1, unchanged timeouts and no skips. The strict build, isolated MCP and diff checks passed. An earlier default-concurrency run timed out in the 3,000-row navigation test; isolated navigation and the serial full suite passed. Native, visual and live-provider acceptance remain unverified.

Commitment editing uses the shared dialog and app navigation guard. Source selection shows five recent emails; optional search shows up to ten matches plus selected evidence. Missing sources stay explicit. The chosen source seeds a new commitment's evidence; editing can add other same-account messages.

Sent imports fetch at most 100 messages per request (the interface requests 50). Gmail conversation expansion allows at most 100 messages. IMAP Sent uses one server-declared Sent mailbox and mailbox-qualified IDs; mutations and content recovery for those Sent records are unavailable. Re-import Sent to recover its evidence. Neither a successful import nor an empty action list proves complete mailbox coverage.

New Teach rules are account scoped. Editing a rule discards pending preview responses as well as the displayed preview. Previews show deterministic matches and must still match at save time. Future-only rules exclude the current cached message IDs, including after later sync. Scoped rules take precedence over legacy sender rules. The question condition checks punctuation, not intent.

MCP remains off by default. The legacy global token grants no account access. Each agent needs explicit account and tool grants. Mail-action tools prepare proposals; separately granted create_project and track_commitment save local single-account work immediately without per-item approval. Approval rechecks the digest, current conversation, current grant and the MCP switch at execution. Approving a reply proposal saves an encrypted local draft and opens it in the composer; agents never send (ADR 0053). Only the user's Send delivers it, with the normal send, Sent-copy and unknown-send handling. The Settings sending switch is removed; the stored allowSending field is inert.

Local creation grants are absent from existing grants, default grants and the Reply assistant preset. list_projects and list_commitments require their own grants and an explicit account. Their responses omit mixed-account projects, linked commitments and unavailable or foreign evidence, including historical commitment evidence. They return cached project and commitment records only; closed commitments remain visible when their evidence is permitted.

MCP creation reuses the HTTP workspace creation validation, date normalization, encrypted records and initial commitment history. track_commitment accepts only needs-reply, needs-work and waiting. It cannot change existing work. Projects and evidence must belong to one selected granted account. Deadline and follow-up fields retain the existing Today/Calendar behavior. Each create requires a caller UUID requestId; its record ID derives from the current agent, tool and UUID. A replay returns the saved record only if revision is still 1, normalized input matches exactly, and all source/account/grant checks still pass. Otherwise it fails without writing. Never use a new UUID to retry an uncertain create. Revocation or missing evidence also denies readback. There is no new store kind, provider action or automatic approval path. See ADR 0047.

Outgoing attachments allow ten files, at most 512 KiB each and 1 MiB total. Received files beyond those limits retain unavailable metadata. Only successfully cached parts consume the import byte/file budget, so an oversized part does not exclude later small files. Large skipped files say "Large attachment not downloaded"; this is distinct from a message parse failure. The composer shows selected file counts and exact decoded bytes before selection/send, accounting for base64 padding when validating an additional selection. Stored bytes and compose drafts are encrypted. Downloads require an explicit user action; files never open automatically. The reader offers explicit Download controls for all available file types, including spreadsheets and archives. Preview is limited to PDFs (canvas pages through a lazy-loaded local PDF.js worker) and PNG, JPEG, GIF, WebP, BMP and AVIF images. HTML, SVG and Office files remain download-only. Preview activation fetches only the authenticated local attachment endpoint; PDF links and actions are not exposed. Decoder fallbacks, CMaps and fonts ship locally through scripts/pdf-assets.ts; PDF.js uses JavaScript decoders so the renderer keeps its existing script execution policy. Enabling privacy clears previews and pending results; a new explicit preview may expose personal information in pixels. Image preview URLs are revoked on hide, privacy changes and unmount. Unavailable or oversized imports keep their existing metadata and explanation. Synthetic coverage is in src/attachments.test.tsx, src/attachment-selection.test.tsx, server/attachment-import.test.ts and server/workspace.test.ts; installed file-saving and PDF appearance require separate acceptance. Disconnect removes cached mail, attachment bytes, scoped rules and agent access. Confirmed commitments and project notes remain, with unavailable source evidence, so disconnect does not erase work.

Contribution guides

Issue forms capture the problem, expected result, scope, acceptance criteria, verification plan and related work. Bug reports also identify the version, platform, app surface, installation method and provider. Unknown details stay explicit; optional evidence records observed results separately from planned checks. Public submissions exclude private data and route vulnerabilities to SECURITY.md.

Agents must read AGENTS.md for project rules and SKILLS.md for task-specific references. CONTRIBUTING.md covers ownership, duplicate searches, issue and PR templates and closure reasons, fix links, git.cafe State, verification, rebase-before-merge and delivery. The PR template keeps the problem, result, before/after evidence and merge risk visible, with verification, ownership and review in collapsed blocks. PR stacks list every layer and merge from the bottom up with explicit parent/dependency records and updated verification and independent review. The canonical GitHub target is collection-of-things/aggregate-mail; git.cafe equivalents use separate IDs. Completion requires verified merged acceptance, closure reasons and reciprocal fixing-PR/issue links on both hosts, with state readback rather than assumed sync. Before push/file and merge, fetch/rebase the latest target. Verification receipts identify the exact base, head, tree, diff and relevant inputs; combined stacks require the full synthetic suite and strict build. Repository commit and PR procedures override generic personal skill defaults within user and safety limits. Follow the guide before starting or resuming work. See ADR 0007 for the coordination rationale.

Sidebar branch integration

PR #48 is rebased onto main at 2b722c7, following CONTRIBUTING.md. The combined desktop startup keeps the macOS key migration and the sidebar's appearance controls. Its synthetic Electron adapter keeps startup-error reporting alongside native-theme and IPC-handler support. The release version and test timeouts come from main. The sidebar ADRs use 0022–0026 so the accepted Keychain migration remains ADR 0021; renderer links follow the component split.

Verification on Linux x64 with Node 24, using the rebased application tree at 7899ef1: npm test passed all 448 tests; npm run build passed strict TypeScript and production bundling; node scripts/check-mcp.mjs passed with an empty temporary store. All 133 local Markdown link targets and the complete diff's whitespace check passed. The isolated npm run check:desktop exited 1 with "System credential encryption is unavailable. Your mail store was not opened." Native startup, Windows/macOS behavior, live providers and downloaded app acceptance remain unverified by this integration check.

Custom themes

Settings, Appearance includes a device-local library of up to 32 custom themes. The theme model validates names, identifiers and complete light/dark palettes using Zod. The editor generates a palette from background/accent colors or edits 12 individual color roles. It previews through App state and the existing appearance application path, including the native scheme and caption colors. Closing, Cancel and Escape clear the preview; saving returns to the user's Mode. Existing themes open in Advanced to avoid regenerating hand-tuned colors. Built-in themes and their accent choices remain available.

Theme create/edit, import and delete dialogs report their open state to App's shared navigation and shortcut guards. Back/Forward cannot unmount a dialog or discard its edits; Cmd/Ctrl+K cannot stack Commands over it. Closing clears the guard and restores navigation and commands.

emc2.appearance.v1 now stores customThemes together with the selected theme, mode and existing appearance preferences in one localStorage write. Old saves without a library still load. A missing or invalid selected custom theme falls back to Black/White; invalid libraries are ignored. Storage failure preserves the prior saved value and keeps an editor draft available for retry. Duplicate and import assign fresh local IDs, and duplicate names receive a numbered suffix. Deletion requires confirmation and selects Black/White.

Import accepts files or pasted JSON in the Emc² version 1 format: format: "emc2-theme", version: 1, name, and palettes.dark/palettes.light. Each palette contains canvas, surface, raised, text, secondaryText, mutedText, accent, link, border, danger, reason and veil. Only opaque #RGB/#RRGGBB colors are accepted; imports are limited to 64 KiB and reject unknown fields. Export includes both palettes without a device ID. This adapts T3 Code's library/editor/preview concepts; it does not import T3 Code or VS Code files. No theme CSS, remote resources, dependencies, provider operations or mail-content parsing changes are introduced. See ADR 0036.

Synthetic tests cover file validation, library limits, persistence/restart, resolved palettes, preview/cancel, editor save failures, editing, duplication, rename conflicts, confirmed deletion and export/import. Native rendering and file dialogs require separate acceptance; the product's live-browser-testing restriction remains in force.

Client appearance and read actions

Formatted mail removes sub-6px and unitless-zero font sizes, line heights, heights and max-heights from text-bearing elements so compressed preview text remains readable without collapsing its wrapper. Image styles retain small max-height caps, including in blocked, unsupported, private-source and failed-image placeholders. Inline image status adds no prose to the sender layout. Synthetic parser and renderer tests cover these cases. Installed rendering and remote image delivery are not verified here.

With transparency enabled, src/styles/tokens.css puts one shared --pane-glass tint on .app, covering the full window. Its persisted glassOpacity is 0 to 100 percent, defaulting to 70 in both schemes. See ADR 0068. The toolbar, mail list, reader, Settings, work and Drafts panes stay transparent to avoid stacking tints. Sticky date headings, the agent review tabs and Today mail actions use a 60% surface tint with 12px blur; scrolling panes themselves gain no CSS blur. Secondary text brightens for built-in and custom themes; text opacity and opaque privacy masks remain intact. The data-glass gate keeps Off and unsupported platforms opaque. This shared layer replaces the separate pane tints recorded in ADR 0035. Native appearance, contrast over desktop content and movement smoothness still need target-platform verification.

Windows offers Off, Subtle (Mica) and Glass (Acrylic); macOS offers Off and Glass using native vibrancy. Glass is the default and may cost more while the window moves. Open menus/notices use 12px CSS blur and dialogs 16px. src/mail-state.ts reuses unchanged snapshot records; stable privacy contexts and memoized text/avatars reduce selection and refresh work. Changed content, recipient/privacy data, images and record order remain observable. Desktop cache-change IPC drives visible windows, with catch-up on show/restore; hidden windows avoid cache-notification redraws. The desktop's duplicate renderer sync and progress-poll timers are disabled. See ADR 0023 for the matched synthetic benchmark and native limits. Run npx tsx scripts/benchmark-mail-ui.tsx to repeat the 1,800-message React/DOM benchmark without saved accounts or browser automation.

The renderer uses macOS-style chrome in light or dark on every desktop platform: a themed window frame and sidebar, near-neutral mail and reader panes, grouped Settings controls and rounded menus/dialogs. src/styles/tokens.css defines every color, surface and radius token for both schemes. Pane and menu transitions are brief opacity/transform animations; there are no looping effects or wheel-event handlers. Scroll containers use native smooth programmatic scrolling and contained overscroll. New panes reset instantly, and reduced-motion preferences disable smooth scrolling and shorten animations.

The mail sidebar places Drafts directly below New message. A Settings gear and sync status share one fixed footer row. The theme picker grows with its previews and labels; the fixed 48px stacked-row height applies only to mail rows, so accent swatches stay below the theme choices.

Mail date headings stick at top: 0. Rows run edge to edge without pane gutters, row insets, corner radii or vertical margins; text has 18px horizontal padding and separators remain inset. Comfortable row heights are 48px, 34px and 28px for high, medium and low relevance; compact rows are 24px. Turning off Size rows by importance gives every row the medium layout and height. The 150px sender column and 8px subject gap also set the 158px two-line preview indent. Side-by-side cards use the same full-width washes and 18px horizontal padding, with 58px low and 78px medium/high heights, or 52px in compact density. All row heights scale with text size.

Right-click mail rows or opened messages to use Mark as read without opening the message. Already-read mail disables that item. Explicit read and read-on-open both use markMailRead in src/mail-ui.ts, coalesce pending requests and call the existing read endpoint independently of other UI busy state. Requests wait behind server sync; failures keep unread state and can be retried from the menu or by reopening mail. Other context-menu provider writes remain disabled during sync.

The visible mail-action group offers the same read, reply, archive and snooze paths for selected and opened mail. Archive is the displayed name for the existing done shortcut ID, preserving saved bindings. Trash is a sibling icon button inside each .mail-row, revealed by hover or focus within the row; it targets that row without selecting, opening or marking mail read. Non-hover devices keep the icon visible. Opened mail retains Trash in its context menu. Archived IMAP mail without Message-ID disables the icon with an accessible description and tooltip. Pending reads have a visible status. A successful read invalidates earlier state snapshots and updates only the matching message/account/provider identity before refreshing the inbox. Refresh failure reports the already-confirmed outcome separately. Back/Forward controls stay in the mail and Settings headers, alongside keyboard shortcuts and mouse side buttons.

The viewport window retains the complete filtered array, bounds mounted rows with overscan, keeps selected/focused rows reachable and restores reader-return selection without focusing or snapping during ordinary scrolling. Both wide rows and side-by-side cards share CSS geometry tokens, including margins and date headers. Synthetic DOM limits do not prove native latency.

npm test runs files serially, retaining all fixture sizes, assertions and deadlines. Navigation fixtures register teardown with the test runner so cancellation still unmounts the root and releases the DOM, timers and globals. Calendar write tests close SQLite before removing their temporary directory, as required on Windows.

Row Trash actions use visibility for hover/focus disclosure. The shared disabled-button opacity cannot reveal an idle action over its timestamp while sync disables the buttons. Devices without hover retain visible actions and timestamp spacing.

Move to Trash uses the existing authenticated message action route and serialized mail-work queue. Gmail uses messages.trash. IMAP requires native MOVE support (including active IMAP4rev2) and exactly one server-declared Trash folder; it does not use ImapFlow's COPY/Deleted/EXPUNGE fallback. Inbox actions validate UIDVALIDITY and UID, plus Message-ID when available. Folder checks compare raw server flags case-insensitively, reject mixed-case duplicate declarations and exclude name-based guesses. Archived mail is resolved through a unique server-declared archive folder and an exact Message-ID match. The encrypted cache entry is removed only after provider success. Confirmed IMAP moves remain successful if connection cleanup throws. Missing folders, unsupported MOVE and unsafe identities fail without a mailbox mutation. See ADR 0017.

The public-feedback report records 13 inspected reports, their age/resolution limits and proposed UX priorities. It does not establish complaint prevalence, native behavior or live-provider verification.

Account selection and cached-mail controls

Multiple IMAP accounts use the existing account store and provider setup. Optional account names are stored in the existing encrypted account records. Settings Rename uses the authenticated, CSRF-protected name-only PATCH route, which trims, limits names to 80 characters and rejects control characters. Clearing a name restores the email label. Sync and reconnect preserve the latest label without changing credentials or connection identity. Privacy keeps custom account labels visible in Sidebar and Settings while masking addresses inside them. The unfocused rename input stays hidden; focus allows editing. Save confirms the name without requiring privacy to be disabled. See ADR 0033 and its current overrides in ADR 0034.

The sidebar Add account button reopens that setup, and account rows select all accounts or one account. Privacy mode uses custom labels with masked addresses, falling back to account numbers and provider names for unnamed accounts. Raw errors stay private. Choosing an account also resets its Gmail label filter; composing uses the selected account as From.

src/mail-ui.ts filters the cached messages by view, account, Gmail label, search, unread status and relevance, then applies newest, oldest or relevance ordering with date and message-ID ties. These controls do not fetch additional provider history. src/navigation.ts keeps menu state in window memory. Back/Forward restores Settings, views, account and filter/search contexts to a list. Back from an opened reader returns to its list and Immediate Forward restores the latest reader only if its message and account still exist. A second Back continues menu traversal. Selecting, opening, closing or traversing email does not create menu entries; a new reader action clears the transient return. Explicit control changes close the reader; automatic read-on-open updates retain its message. Reset clears all controls within the current view. Reader j/k navigation temporarily includes the opened message as a sorting anchor when automatic read removes it from the filtered results; the rendered Unread list still excludes read mail.

The complete interface from PR #48, source commit 6cd5122b2ff2d9b748242c385eba12691f23992c, is integrated into the newer local candidate. src/shell/Sidebar.tsx and src/styles/sidebar.css implement its full-height source list and Settings navigation. Its themes, category tabs, compact toolbar, reading pane, native materials and Gmail revision polling are retained. Newer Keychain, updater, account-label, reader-return and windowing corrections take precedence where source behavior differs. See ADR 0031.

Text-size shortcut dispatch precedes the closed-filter combobox guard, preserving editable-field and dialog ownership. src/MailListWindow.tsx uses density/relevance size tokens shared with CSS, date offsets, resize observation and bounded row mounts. Synthetic component tests reach all 3,000 messages and select within 25,624; mounted App tests cover tail reachability, reader return, filters, sorting and focus. These prove DOM bounds, not native latency.

src/mail/MailList.tsx shows receiving-account and existing high-importance sorting-status labels separately in All accounts. src/App.tsx hides the account column for a selected account. Labels reuse accountLabel, including duplicate names, privacy redaction and unavailable-account fallback; row accessible names include the permitted account and status. Account dots match sidebar order and repeat after four accounts. Narrow rows put metadata below the sender. The compact narrow height is 72px in both CSS tokens and windowing fallbacks. Sender avatars omit provider overlays. Status visibility follows message importance, independent of the Size rows by importance preference; only May need labels take the existing reason colour.

Synthetic mounted-App tests cover combined controls, empty-result reset, privacy-safe account labels, account-specific compose, setup entry, keyboard ownership, read-on-open, history, section folding and collapse focus. They do not prove actual account connections or native appearance.

Encrypted local draft API

server/drafts.ts stores local compose/reply snapshots as encrypted draft records using the existing data key. Draft recovery never invokes a provider. The renderer uses these routes for autosave and explicit recovery. See ADR 0037 for persistence and ADR 0038 for caller coordination.

All draft routes are under /api/accounts/:accountId/drafts and inherit the Host, origin, desktop-session, CSRF and Cache-Control: no-store guards. The routes allow writes during sync. Updater quiescence rejects their writes with 503; reads remain available. Synchronous transactions finish before a quiesce can begin, so they cannot leave admitted asynchronous draft work.

Method and suffixRequestResult
GET /No bodyAt most 100 summaries, newest first with ID ties
POST /Draft content201 with a new server-generated UUID, revision 1 and full snapshot
GET /:idNo bodyFull snapshot
PUT /:idDraft content plus current revisionFull snapshot with revision increased by one
DELETE /:idCurrent revision, plus confirmUnknown: true when needed{ ok: true }

Draft content requires a to array and subject and body strings, including empty or unfinished values. Optional recipient arrays are cc and bcc. Each recipient array has at most 100 entries. To text, including separating commas, is bounded to 10,000 UTF-16 code units; Cc/Bcc entries to 320 each. Legacy encrypted drafts with a single To string normalize on read without rewriting the record. Optional fields include inReplyTo, threadId, references and sendUnknown. Limits are 998 for subject and reply/reference strings, 100 for thread ID, 100 references and 1,000,000 for body. The existing 2 MB HTTP JSON limit also applies. Unknown body fields and invalid revisions are rejected with 400. Sending retains its separate, stricter validation. PUT replaces the content snapshot; omitted optional reply fields are removed. Omitted sendUnknown preserves a stored true value.

Every snapshot includes id, accountId, revision, updatedAt and a boolean sendUnknown. Summaries contain those fields plus to and subject; bodies and reply metadata require GET by ID. Missing accounts/drafts and another account's draft IDs return 404. Revision conflicts, capacity exhaustion, clearing a true uncertainty flag or deleting it without confirmation return 409. Creation refuses draft 101 without eviction. Account removal atomically purges that account's drafts, including uncertain drafts, alongside its cached mail and labels. Reconnecting an account does not remove its drafts. Settings, Accounts warns about permanent draft deletion, including uncertain sends, before Disconnect. Declining the confirmation leaves them intact.

src/compose/draft-session.ts serializes writes per draft and retains local edits until a save is acknowledged. Create once, then use only the returned ID and latest acknowledged revision for PUT. A lost create response pauses saves; Retry never repeats POST. Save separate copy requires confirmation and retains the possible original. A lost PUT response requires GET and exact revision and snapshot reconciliation before another save. A conflict never adopts a newer revision to overwrite it. Explicit deletion stops queued autosaves. PUT never creates a missing record, so delayed saves cannot restore a deleted ID.

Before queueing, the renderer freezes editing, drains autosaves and waits for acknowledgement of the exact snapshot. An unacknowledged save blocks Send. The user Send queues that revision in the encrypted outbox described under Send outcomes. Pending has no provider attempt. The service persists sendUnknown: true on the locked source draft only when submission starts. A committed sent receipt, including a Sent-copy warning, proves provider acceptance only and removes the exact source revision. A lost queue acknowledgement is reconciled through outbox status; an unavailable status read retains conservative uncertainty in the composer. Crash recovery never sends. Never infer an unsent message from a recovered draft or retry because copying to Sent failed. Resending an uncertain draft requires explicit duplicate-risk confirmation. Editing cannot clear the marker; manual discard is confirmed.

Drafts sits directly below New message in the sidebar and lists account-scoped summaries; opening one fetches its body without sending. Privacy mode omits private summary subjects and recipients. Close, Escape and Keep draft wait for the latest save. Discard is separately confirmed. Save errors keep text open and protect unload. Saved text survives ordinary close; the native prompt warns before closing with unsaved changes or an uncertain send. Changing From retains a saved original and confirms a copy in the selected account. The existing renderer updater drain also waits for draft actions and the current save. MCP send callers remain unchanged and do not use this draft lifecycle. Unacknowledged edits cannot be guaranteed. SQLite uses WAL with explicit FULL synchronization; acknowledged saves survive the tested process restart/crash with the same key. This does not prove device power-loss behavior or disk survival. Native interactive close/crash behavior requires separate installed-app acceptance.

Focused synthetic service and subprocess verification:

sh
1node --import tsx --test --test-timeout=30000 server/drafts.test.ts
2node --import tsx --test --test-timeout=30000 src/draft-session.test.tsx src/draft-recovery.test.tsx src/compose-send.test.tsx src/update-lifecycle.test.tsx
3npm run build

The tests inspect DB/WAL bytes for synthetic draft plaintext, reopen in fresh processes, hard-kill a writer after committed saves/deletion and verify recovery with the original key. They cover account isolation/removal, capacity, strict validation, authentication, conflicting saves/deletes, sticky uncertainty and sync/updater guards. They use temporary stores and no real accounts or sends. Mounted synthetic renderer tests use the authenticated HTTP routes and encrypted store to cover rapid edits, pending-save close protection, remount recovery, lost create responses, failed updates, explicit copies, account switching, private summaries, send preparation and frozen fields, persistent uncertainty, confirmed resend, accepted-send cleanup, Keep and Discard. These use a simulated DOM and fake sync/send boundaries. Full-suite and isolated desktop verification remain separate checks.

Browser target session

Without MAIL_DESKTOP_TOKEN (npm run dev:web, npm run start:web), /api requires the emc2_browser_session cookie. The server prints APP_URL/api/session?token=… at startup; opening it sets the HttpOnly, SameSite=Strict, Path=/api cookie and redirects to /. The secret changes on every launch. Only the Google OAuth launch entry and callback bypass the session check; starting consent requires an authenticated session and CSRF token. Covered by server/browser-session.test.ts; see ADR 0085.

Desktop targets

App updates

Packaged macOS builds use desktop/mac-installer-update.ts, following ADR 0080. The ordinary action reads only the canonical repository's latest stable release, selects exactly one DMG for the running process architecture (including x64 under Rosetta), and requires its exact filename, positive bounded size and one SHA256 entry in the same release's SHA256SUMS.txt. Foreign URLs, duplicates and malformed checksums fail closed. Windows/Linux keep their separate manifest/SHA512 route and fallback.

The binary adapter creates a private directory under Downloads, downloads with /usr/bin/curl using HTTPS-only redirects, a size ceiling and deadline, verifies the regular nonsymlink file's exact size/SHA256 and applies quarantine. It mounts the image read-only without Finder, copies its sole expected app into a private sibling workspace beside the writable installed app, transfers image quarantine explicitly and verifies bundle identity/version, the thin Mach-O architecture, strict deep signature and complete bundle digest. It uses no lipo or developer tools. Installed and workspace directories must be canonical; bundle symlinks must resolve inside the bundle. When the running bundle cannot be replaced, the adapter checks only the system and current user's Applications folders for Emc².app. It accepts exactly one canonical, writable bundle with the running app's complete digest. It retains the actual running executable for the helper, which verifies that same digest before readiness. The selected installation is rechecked before handoff and again after parent exit, before moving the old app. Missing, changed, ambiguous or noncanonical counterparts fail closed. This does not discover the original translocation path or change quarantine. See ADR 0103. The owned mount is detached with an independent cleanup signal even after cancellation. A still-mounted image is never recursively removed.

After existing mail/send drain and normal draft/unknown-send close confirmation, the adapter rechecks the image and stage and arms a bundled helper using the running Electron executable with ELECTRON_RUN_AS_NODE=1. No external Node is needed. The helper validates its private IPC request and stage, waits for the original process to exit, renames the old app to previous.app, atomically promotes the stage, rechecks bytes/quarantine and uses normal LaunchServices open -n. It never removes quarantine, changes Gatekeeper or touches Keychain. Downloaded-app trust prompts and rejection remain macOS decisions. A successful open dispatch is not app-startup or installed-update proof.

Known failures before launch dispatch restore the original app when needed and show it in Finder with a bounded native recovery notice. Failed or uncertain dispatch keeps both copies, reveals recovery files and never downgrades data that may already have been opened. The private workspace includes a local result receipt. On a later update, an already-started app may remove exactly its prior successful workspace only when canonical installed path, version, architecture, complete bundle digest and explicit-profile binding match that receipt. Failed, uncertain, malformed or unmatched recovery workspaces remain untouched and block another staging attempt. Downloads are removed after handoff; previous-app backups remain until this verified later cleanup. Source-building and its helper remain unchanged maintainer tooling under historical ADR 0042. Source-builder fixtures inject only the free-space probe so CI host capacity does not select a failure path accidentally. Boundary checks cover the 8 GiB build and 1 GiB replacement limits; production uses the real filesystem probe.

Source selection requires both release metadata and its manifest to pass validation. If the preferred manifest cannot be read or validated, the check tries the original repository once with the same source, version, architecture, size and SHA512 checks. If both sources fail, the final source's failure keeps the existing error or unavailable state; no updater is created.

desktop/updates.ts checks the latest stable release once at normal startup and on explicit request. Releases publish from collection-of-things/aggregate-mail from v0.2.2. The check reads that repository first and falls back to screen-devs/aggregate-mail when it has no usable newer release, and every release must link only to its own repository's downloads. Development builds and Linux processes outside an AppImage show an unsupported state without requesting a release. Compatible Windows NSIS and Linux AppImage candidates require a strictly higher stable version, the running process architecture, one unambiguous installer, matching manifest version and size, and a canonical SHA512 digest. CI-suffixed asset names under a stable tag are rejected. Metadata reads and downloads have size and time bounds.

electron-updater is pinned to 6.8.11. Its provider uses the validated candidate; the renderer cannot supply a URL, path or executable arguments. IPC accepts only the current window's main frame at the application origin. Automatic download, install on ordinary quit, prereleases, downgrades, web installers and differential downloads are disabled. Packaged updater configuration supplies the library cache location and any configured Windows publisher check. The app preserves that signature check; SHA512 verification establishes file integrity against the selected release metadata, not an independent publisher identity.

The update icon at the top right of the window opens dismissible details without issuing a command. Its no-drag region follows the draggable pane headers in DOM order so native title-bar hit testing delivers clicks to the icon. It stays outside the inert mail frame and remains usable during installation. Capture-phase Escape closes details and restores icon focus without closing Search or the reader. Right-side window controls retain their corner placement. The details panel's Download, install and restart action downloads, verifies the cached regular nonsymlink file, pauses admission of mail work, drains queued sync/mutations and active renderer/MCP sends, and waits for renderer send results and draft saves. Native close still honors draft and unknown-send protection. Keep editing resumes mail without invoking the installer. Accepted close is followed by another file hash check and the platform library handoff. Failures before handoff resume mail and reopen a closed window. The macOS binary route stages and replaces the verified app, retains the old copy and requests normal LaunchServices restart. A normal quit never installs a downloaded update.

The download stream checks the candidate's byte limit before hashing or file output, even without Content-Length. An oversized chunk stops the stream and aborts the request; cancellation also aborts a request still waiting for headers. SHA512 and configured Windows publisher checks remain active.

Cache validation uses the candidate's canonical filename and ignores filenames in cached JSON. It rejects oversized or symlinked files before reading, bounds hashing to the expected size, and checks file identity, size and modification time afterward. It rehashes files already used by the running instance. A hash mismatch falls back to the normal library downloader; cancellation or timeout stops hashing without that fallback. The verification stream closes its handle and cancels application-level reads, but cannot preempt an OS filesystem call already in progress. The adapter uses the protected download hook and the pinned library's private cache initializer; dependency upgrades must recheck this integration with the actual-library regressions.

Before the library replaces an AppImage, the app copies the installed image and any existing destination into a temporary directory beside the installed image. A replacement or launch failure restores their names, bytes and permissions before restoring the window and resuming mail. Failed handoffs do not quit or retry automatically. Successful handoff or rollback removes the backup directory; failed rollback retains any remaining backup copies for recovery. This protects handled failures, not power loss or a process crash during replacement.

Release v0.1.9 lacks compatible updater manifests and carries CI-suffixed installers. New release builds use the checked-out stable tag, GitHub-safe Emc2- installer filenames and the Linux builder's x86_64 suffix for x64 processes. CI generates latest.yml and latest-linux.yml, then runs node --import tsx scripts/check-release-assets.ts windows-x64 or linux-x64 to validate manifest identity, size and SHA512 against the installer with the updater's own helpers. Both release workflows publish these manifests and include them in SHA256SUMS.txt. Explicit GitHub publisher configuration generates metadata even with --publish never; CI packaging does not publish. See ADR 0039. macOS updates use the verified DMG replacement described above, preserving ad-hoc signing. Published output and native two-version updates require separate verification. See ADR 0019.

Synthetic tests exercise candidate rejection, pinned-library options and Windows launch arguments, checksum changes, duplicate commands, download bounds, authenticated IPC, actual service drain/resume, mounted notice behavior and the renderer's unknown-send unload guard. Tests of the pinned AppImage library use temporary files and synthetic process adapters to check replacement/launch failure, filename preservation and destination collisions. The Electron download adapter receives synthetic streams to check headerless overflow, exact size, checksum failures and preheader cancellation. Actual library download tests cover unsafe cache files, cache growth, valid reuse, changed hashes, untrusted metadata paths, and stalled-read timeout, cancellation and stop. The Windows publisher callback test is synthetic. Startup checks disable update traffic and cannot prove download/install/restart. Native installation requires separate disposable two-version fixtures on each supported platform. The macOS source adapter and helper have their own focused tests; native download, installation, renderer/data continuity and cleanup must be recorded separately. An update into a release predating the adapter proves only one cycle.

The source-adapter test fixture waits for each synthetic helper to exit before removing its workspace, since Windows locks a running process's working directory. These helpers ignore standard streams; IPC disconnect can omit Node's close event.

Background running and notifications

The V2 notification controls and exact-target flow are described in ADR 0050. Native delivery, audible playback and hidden-window sound require separate OS acceptance.

Closing the desktop window hides it while the mail service and five-minute main-process sync keep running. macOS has no status-bar icon: Dock activation and a second launch restore the window, and the native application menu quits. Windows and Linux retain tray Open and Quit controls. Linux resizes its icon to 22 pixels; Windows keeps it unchanged. desktop/main.test.ts checks native menu roles, synthetic hide/reopen activation and platform tray selection. These source tests do not prove actual Dock or desktop-panel interaction. Linux Ctrl+Q uses normal application quit and retains the unsaved-draft, unsaved-event and unknown-send close warning; Keep editing cancels it. Additional modifiers and repeated key events do not invoke it.

startServer accepts an onNewMail callback and emits new-mail with the count and sanitized accepted-message details from a completed sync. The desktop main process binds each native notice to that exact cached email. The renderer keeps generic notices for legacy count-only state snapshots. The first sync of a mailbox, which imports the cache, and a sync that failed for an account report no arrivals. StoredAccount.hasSynced records the first successful sync for that decision. See ADR 0016.

IMAP inbox events

Gmail API accounts now use GmailWatch alongside the IMAP watcher. Each account checks only the historyId profile field every 15 seconds after its previous poll/sync finishes. Unchanged markers do not trigger mail/label listings or renderer events. Changed markers request the existing serialized sync path; a failed or partial sync never advances the marker. Requests/token refresh are bounded at ten seconds with no SDK retry. Failures back off from 30 seconds to a five-minute ceiling. OAuth connection/replacement, disconnect, updater quiescence/resume and server shutdown reconcile or stop the watches. Markers are memory-only, and the five-minute full sync remains a recovery path. Synthetic revision/provider/service tests cover timing, overlap, identity, arrivals/notification callbacks and lifecycle without live accounts.

server/imap-watch.ts opens one read-only INBOX watch per IMAP account, including Gmail IMAP. It reuses the protected provider connection setup and ImapFlow's idle handling. Exists, flags and expunge events coalesce over 100 ms before requesting an account-specific sync. A watcher never fetches mail, so sync fetch responses cannot trigger its own events. Opening or reconnecting also requests sync to recover changes missed while disconnected. Gmail API accounts use the 15-second revision polling described above.

The existing scheduler keeps at most two account syncs active and serializes mail actions. Events during an active account sync request one pending follow-up. Watch identity includes account, provider, email and credentials; replacement/removal stops the old connection and prevents stale sync batches from overwriting current data. Disconnects allow five automatic reconnect attempts with exponential delays; manual or periodic sync can start a new recovery window. Server close removes watcher listeners and timers and closes its clients.

The first complete import enables later arrival notifications. A partial first import saves fetched mail but keeps that notification boundary closed. Existing caches without a stored boundary retain migration compatibility. See ADR 0018. Synthetic service tests cover events through cache refresh and the existing new-mail callback, account isolation, bursts, read serialization, partial failures, credential replacement/removal and shutdown. They do not prove live-provider event behavior or native notification delivery.

Linux local-key fallback

When safeStorage encryption is unavailable on Linux, a fresh profile stores its database key in mail/key.local (0600) and the renderer shows a persistent warning (MAIL_CREDENTIAL_FALLBACK=linux-local). Fallback profiles keep the local key even if a keyring appears later. Profiles with key.protected never fall back; they stop with a keyring/reset message. macOS and Windows still stop. See ADR 0027. The warning uses its own styles in mail and Settings, separate from the update icon and its dismissible details.

Protected mail keys on macOS

Electron selects its macOS Keychain item from the application name before startup. Moving Local State preserves Windows protection but does not carry the old macOS Aggregate Mail identity into Emc². Desktop startup pins the Emc² profile path, acquires its singleton, recovers a default legacy profile when needed, then selects identity before readiness. A second instance cannot inspect or migrate keys. Unconfirmed original wrappers retain the one legacy-identity relaunch from ADR 0015.

After recovering a legacy key, the migration launches the same application in an early helper mode with a fresh private temporary profile. The dispatcher keeps ordinary startup out of that helper. It selects Emc² before readiness, wraps the same canonical 32-byte key and verifies decryption equality. One bounded request and authenticated response travel over private pipes; the parent requires complete frames, normal EOF and a successful child exit before committing. The parent holds the profile lock throughout and applies a five-minute deadline. Handled quit and fatal paths terminate the owned child and observe its exit before cleanup. A hard parent termination cannot guarantee cleanup during a native prompt.

Only the parent writes mail/key.current.json, using a private temporary file, file flush, rename, directory flush and readback. This versioned record binds the current wrapper to the retained key.protected digest. Later macOS starts prefer that record and select Emc² even if a legacy retry argument remains. Malformed records, a missing or changed original wrapper, invalid keys and migration failures stop before mail startup. They never trigger a replacement key or a fallback from a committed current record. The database is not reencrypted, and the original wrapper and identity metadata remain unchanged. Fresh/current profiles keep their existing key path; Windows does not use this macOS migration.

An older application can still use the retained original wrapper and metadata. Do not delete Keychain items or reset a profile to recover from an error. Moving aside a corrupt current record requires explicit recovery authority with all copies stopped; retain the record as evidence. See ADR 0021.

Use --user-data-dir=<nonempty path> before -- for isolated checks. Explicit profiles skip both saved-profile migration and development .data imports; invalid profile switches stop before the default profile is accessed. The startup regression tests use synthetic profiles and do not establish access to a user's saved Keychain item. Run node --import tsx --test desktop/main.test.ts desktop/migrate.test.ts desktop/keychain.test.ts for the connected bootstrap, restart, protocol, preservation and failure cases. Then run npm test and npm run build for the application change. Fixtures that require POSIX permissions, ownership or directory flushing report explicit skips on Windows. Portable record/parser/framing checks, child-protocol failure checks, synthetic Windows startup and Local State recovery stay enabled. The skip selection itself does not prove Windows execution.

A temporary application profile does not isolate named macOS Keychain services. Native migration verification needs an explicitly authorized disposable macOS account/Keychain and reviewed synthetic fixture setup. Use the existing Mac packaging and isolated-startup recipes below only within that authority. Bind the candidate's bundle metadata and signature, observe both prompt labels, verify the same key and synthetic mail across migration/restart/reinstall, exercise denial and concurrent launch, and confirm all owned processes exit. Record results separately for each tested architecture and Windows startup. productName already says Emc²; source checks alone do not prove the requesting app label, native item label, saved-account continuity or downloaded-app trust. The first migration can still request legacy access, and local ad-hoc signing does not promise prompt suppression after reinstall.

Emc² shares its React renderer, Electron main process and bundled mail service across Windows and macOS. npm run package builds Windows NSIS; npm run package:mac builds macOS DMG and ZIP files on a Mac. Package names include the macOS architecture. See installation and storage, product scope and ADR 0001.

The macOS source-installation route uses npm run package:mac:dir on the target Mac with Git, Node 24 or newer and Xcode Command Line Tools. It creates release/mac-arm64/Emc².app on Apple silicon or release/mac/Emc².app on Intel. The installed app includes its runtime. See README for setup.

scripts/sign-mac.cjs runs after packaging and replaces inherited signatures using the locked @electron/osx-sign dependency already supplied by electron-builder. It sets identity: "-", identityValidation: false, disables provisioning-profile discovery and automatic entitlement rewriting, and uses the library's default Electron entitlements without hardened runtime or a timestamp. The builder's own mac.identity stays null: in the locked 26.17.0 implementation, "-" can select a certificate containing a hyphen. The hook avoids that lookup. There are no configured Electron fuse mutations; signing must follow those mutations if they are added later. Windows and Linux skip the hook. DMG signing and notarization remain disabled. Regression checks exercise the actual signing library with synthetic certificate data and mocked native commands, verify ad-hoc-only arguments, and check failure propagation. See ADR 0011.

The published 0.1.4 arm64 DMG matches its release checksum but its app fails strict signature verification with code has no resources but signature indicates they must be present. Existing release assets remain unchanged. The release workflow publishes installers for version tags. Local packaging does not publish or prove downloaded-app Gatekeeper acceptance.

Ad-hoc signing repairs bundle integrity but does not satisfy Apple's public distribution requirements. Source installation builds and runs on the user's Mac and needs no Apple Developer account. Public binary distribution with normal first-open confirmation requires a project-authorized Developer ID Application certificate, hardened runtime, secure timestamp, Apple notarization and a stapled ticket. The publisher can be an organization or an authorized maintainer's team; users installing the app need no developer account. See Apple's notarization requirements, organization enrollment and Electron's release guidance.

Account setup

The empty inbox opens the account connection dialog directly. Settings uses the same connection path. Known providers supply their server settings; users enter an email address and provider-issued app password. Only custom IMAP requires manual hosts and ports. Connection errors stay in the dialog so users can correct their input.

Google sign-in completion dismisses the provider chooser only. It preserves another provider's open form and any pending connection check. A pending check blocks duplicate submissions and form dismissal; a failed check leaves the entered details available for correction and retry.

src/onboarding.test.tsx mounts the React forms in a synthetic DOM with the test-only happy-dom dependency. It exercises the desktop callback, pending submissions, retained input, inline errors and retry without a browser or accounts.

The server onboarding fixture stops its HTTP server and calls closeStore() before removing the temporary directory. Windows requires the SQLite handle to be closed before its files can be deleted. The application does not call this test cleanup helper.

Gmail supports two separate transports: gmail uses the existing Gmail API OAuth flow; gmail-imap uses imap.gmail.com:993 and smtp.gmail.com:465 over TLS. The latter accepts personal Gmail and Workspace addresses when Google permits app passwords. It does not require a Google Cloud project. Google recommends OAuth, and administrators or account security policy can prevent app-password access. See Google's requirements.

Account creation validates incoming and outgoing access before writing the encrypted account record. Reconnecting cannot silently change between Google transports or mix their cached message IDs. Gmail IMAP sends rely on Google's Sent copy and do not append a second copy. Other providers retain the existing Sent-copy behavior, including the warning after successful delivery when copying to Sent fails.

Source builds still require OAuth client configuration for Google browser sign-in. No shared client, cloud relay or provider-registration automation is included. See connection instructions and ADR 0008. Synthetic tests do not prove live provider eligibility, login, mail delivery or installed desktop behavior.

Verification

Mailbox sync

The sync service allows validated IMAP registration and Google OAuth callbacks during a pending sync. Each connection schedules its account immediately. Up to two accounts run at once; an additional account waits for a slot. Overlapping sync requests share that run. Replacing an active account's credentials queues one subsequent run with its current credentials. An old run cannot restore old credentials or recreate a removed account. Mail actions, account removal, configuration and Teach keep their existing sync guards.

Settings, Retry connection calls POST /api/accounts/:id/reconnect with no account data. The route reads the encrypted saved credentials server-side and calls requestSync([id]); it does not validate SMTP or replace credentials. Desktop-session, Host, origin and CSRF checks still apply. Unknown IDs return 404 and account data returns 400. Accepted requests return 202 with only the target account's queued/running status and prior failure. Duplicate requests share work; an account that has finished can retry while peers remain active. Its previous error remains until that account finishes. Authentication rejection requires Google sign-in or an app-password replacement through the existing connection form. No new automatic provider retry is added.

Provider listing saves fetched batches before later pages or AI sorting finish. New mail first uses local sorting, then receives the configured AI classification. Cached sorting and snooze state are retained. Only a successful complete inbox listing marks absent cached messages archived. A partial failure retains cached mail and fetched batches, sorts new fetched mail, and reports an account error. AI failures keep local sorting and return a warning. Sync failures use fixed messages for authentication, access, transient connection/provider, message-read and unknown failures. Stored details contain only an operation, allowlisted code and optional numeric HTTP status; raw exceptions, response bodies and credentials are excluded. IMAP error emissions reject the pending listing operation and close its connection. Known IMAP connection and greeting timeouts normalize to ETIMEDOUT; NoConnection and EConnectionClosed normalize to CONNECTION_CLOSED. These failures remain transient, including disconnects during message reads. Throwing or false logout produces a separate cleanup warning and preserves the primary listing result or failure.

Mail batches skip deeply equal cached records and write changed records in one SQLite transaction using the existing encryption. A failed transaction rolls back the whole batch. Only committed fresh mail enters AI sorting, so sorting cannot restore a failed batch. Earlier committed batches still receive AI sorting after a later batch fails. The renderer reloads state during sync, including once per second while pending. Reload notifications share one active read and one pending refresh. A failed active read still services a refresh requested during that read; failure alone does not schedule a retry. The service owns connection-triggered sync so connection callbacks do not start duplicate runs.

An opened message is selected by ID from the full current cache, so background classification cannot switch the reader or reply to another message. Closing the reader restores selection within the filtered list. If the opened message is removed, it has no reply target. The selection tests cover category changes with remaining or empty lists, removal and closed-reader selection. Run them with node --import tsx --test src/mail-selection.test.tsx. They check the message used by the reader and reply logic; they do not mount the UI or execute a reply.

Gmail reuses cached bodies and refreshes unread state with paginated inbox and unread-ID lists instead of one metadata request per cached message. Fresh raw reads run in batches of ten and publish before later groups in the same page settle. Cached-only batches publish once per inbox page. Gmail reads and token refresh have a 30-second request timeout that aborts transport. Read batches settle every in-flight request before releasing their account slot. IMAP drains UID/flag enumeration before fetching missing bodies by UID, with no cached-body download. Before reconciling absent cache entries, IMAP requires unique UIDs and flags, an enumeration matching EXISTS, stable UIDVALIDITY/UIDNEXT, no observed mailbox change and every requested body UID exactly once. Missing bodies report a message-read failure. Incomplete snapshots retain cached mail. Neither provider caps the inbox or drops older mail.

Run the synthetic service and provider regressions with:

sh
1node --import tsx --test server/sync.test.ts server/providers.test.ts server/sync-recovery.test.ts src/sync-reload.test.tsx src/mail-selection.test.tsx

The service tests cover registration during pending sync, the mocked Google callback, credential continuity, coalescing, progressive visibility, configured AI completion, cache preservation, retry and deleted accounts. The background AI-failure case waits for the service's completion notification after AI starts and asserts one provider run. It does not issue another sync as a wait, which could arrive late and clear the expected warning. The provider tests check cached work, pagination, progressive batches, IMAP command order and actual transport abort against a synthetic loopback endpoint. They assert the configured 30-second timeout and trigger it only after the request arrives, avoiding a race with connection setup. Each phase must make one request, report the expected safe timeout error and close the connection within one second. A negative control keeps the transport open after the caller times out and must fail that closure check. These tests do not establish live-provider behavior or the cause of a historical CI failure. The recovery tests exercise the actual loopback service with synthetic OAuth transport and ImapFlow command/emitter fixtures. They cover safe failures, guarded saved-credential reconnect, duplicate clicks, retry while a peer runs, incomplete listings, four library throttle attempts, cleanup outcomes, transaction rollback and unchanged/changed cache operation counts. They also block a later Gmail download group and check that earlier fresh mail is already in state. The reload tests check the exported coalescing seam, including a queued refresh after rejection; they do not mount Settings. These checks use synthetic accounts and mail. They do not prove real Gmail or Workspace authentication, real mailbox latency, native UI behavior, deployment or release readiness.

Bounded cached-mail reloads

server/store.ts retains mail summaries in memory, including account routing, attachment metadata, a content version and the server-computed Inbox tab. It retains no full-body ciphertext index or normalized search documents. Encrypted mailSummary companion records use the existing codec and key, with source and rule digests. Source and companion writes commit atomically; removing mail removes its companion. Cold initialization reads source ciphertext to verify its digest and decrypts the smaller companion when valid. Missing or stale companions are rebuilt in bounded transactions with changed-source checks. Committed writes during initialization are reconciled before publication. Legacy source mail and installation keys are preserved. Companions add database space in exchange for less repeated body decoding.

Mail revisions and removal history remain volatile. Transactions publish them only after COMMIT; rollback retains the prior view. External SQLite writes invalidate the index and generation through data_version. Rule changes also invalidate summaries so body-conditioned Inbox tabs are recomputed. History retains at most 4,096 distinct changed IDs before advancing its floor.

GET /api/state returns summaries for all retained mail views. A valid mailCursor returns changed summaries and explicit removedMailIds; an unchanged cursor returns no messages. Invalid, future, expired and restarted cursors return a full summary snapshot. Response serialization still uses backpressure and bounded yields; disconnect cancellation discards that request's work without cancelling shared cold initialization.

The state route uses server/state-response.ts for batched serialization and negotiated gzip on large snapshots. Small responses and clients excluding gzip retain plain JSON. The existing writer keeps status, including CSRF, in a separate uncompressed gzip member from mail metadata. Authentication, no-store, snapshot boundaries, cancellation and backpressure still apply.

The cached conversation GET route uses readConversationMail to select related messages from summaries and hydrate only those bodies in one SQLite snapshot. It passes that captured view to the existing conversation builder, preserving account boundaries, ordering and coverage gaps. Opening does not synchronously decrypt the whole mailbox. Explicit provider expansion and synchronous approval fingerprint operations retain their existing contracts.

server/loading-performance.test.ts exercises both real authenticated HTTP routes, requiring equivalent compressed state JSON, small plain deltas and related-only body decoding. Existing conversation snapshot, privacy, reader and state-transport tests cover the reused helpers. Synthetic HTTP timings do not prove browser paint, native interaction or provider latency; cold summary initialization and metadata membership scans still scale with mailbox size.

GET /api/messages/:id/body requires exact accountId and contentVersion query values and reads only the local encrypted record. Missing or wrong-account mail returns 404; a changed version returns 409. The renderer holds content for its current opening or reply source, drops it on identity/version changes, and rejects stale responses. Closing and reopening does not retain a body cache. Image consent and privacy rendering remain tied to each opening.

GET /api/messages/search?query=... uses the shared search compiler and a read-only SQLite snapshot. It decrypts one mail at a time, yields every 32 records, honours cancellation and returns matching account/message/version identities without bodies. Renderer lists and source pickers use those results with their existing view filters. Search errors do not imply an empty mailbox.

Internal readMail, mailChanges, direct record reads and body-sensitive MCP, Teach, rule, proposal and fingerprint operations retain full-mail contracts. Some of those callers still materialize a complete request-local mailbox. Summaries and search result IDs still scale with message count. A single large legacy record can cost more than a normal batch; source benchmarks do not prove installed-app latency or Electron/GPU memory use.

Focused checks include server/on-demand-mail.test.ts, src/on-demand-mail.test.tsx, server/mail-materialization.test.ts, src/mail-state.test.tsx, search, reply recovery and workspace regressions. Native and real-provider acceptance remain separate from synthetic checks.

Cached-mail tools

shared/mail-search.ts, re-exported by src/mail-search.ts, defines the query semantics. Renderer search uses the authenticated local service. New queries start globally; deliberate account, mailbox, Gmail-label, read and relevance choices narrow the results. An empty query creates no body search strings. Recognized invalid operators, dates and unmatched quotes return a generic error without echoing the query. Unknown prefixes remain literal text. Dates use UTC start-of-day boundaries.

Search normalizes canonical Unicode and whitespace and folds Latin accents for ordinary text and account display names. Terms containing an email address keep their accents; account IDs and addresses keep their existing exact alias matching. Non-Latin combining marks remain significant. Phrases match within one field, while separate terms can match different fields. Search keeps a process-local LRU of searchable strings, bounded to 256 entries and 4 MiB of accounted strings. Empty and flag/date-only queries skip body search strings. Plain unquoted words of four to 32 letters tolerate one insertion, deletion, substitution or adjacent transposition. Phrases, addresses, field terms and exclusions remain exact. There is no persisted plaintext index.

Provider search beyond the cache

POST /api/accounts/:id/search in server/provider-search.ts runs one explicit older-mail search for a single account; it is never automatic. providerSearchPlan in shared/mail-search.ts maps the cached-search syntax to provider terms, providers.*.search in server/providers.ts runs Gmail paging or read-only IMAP UID searches, src/provider-search.ts formats the coverage line and src/mail/ProviderSearch.tsx owns the button, cancel and status. Each run examines at most 100 matches (providerSearchLimit). Gmail pages users.messages.list then reads uncached ids as raw; IMAP searches the declared archive mailbox and Sent, or All Mail for Gmail IMAP, under a read-only lock with UID SEARCH, ENVELOPE and BODY.PEEK, keeping the newest 100 UIDs in mailbox order. INBOX is skipped because sync caches it completely. Cached matches count toward examined without a download. Imports use the same encrypted record shape as sync and never overwrite existing records or change mailbox flags. Cancelling aborts requests and the connection and imports nothing. The coverage line names the searched scope and remainder, and account:, is:pinned and excluded body: terms are applied locally after import. See ADR 0081.

src/dialogs/CommandPalette.tsx consumes existing action callbacks from App. The modal uses the shared Dialog focus trap; the underlying frame is inert. The updater closes the palette before installation. The fixed Command/Control+K binding and list Up/Down behavior leave saved j/k preferences intact. Commands carry a typed id, a group (selected-email actions or navigation) and optional aliases; exact label matches rank first, then alias matches, then substrings. Disabled commands show their reason beside the label and announce it through aria-description; selected-email actions stay listed, disabled, with Select an email first when no mail is selected. The Commands button and entry shortcuts show the platform's real modifier through commandModifier()/commandsChord() in src/interaction.ts.

shared/mail-features.ts defines compose preferences. Authenticated GET/PUT /api/compose-preferences reads/writes one encrypted compose record. PUT validates bounded templates, unique IDs and signatures for connected account IDs. GET /api/state includes the preferences; unchanged values keep renderer identity stable. Account deletion removes its saved signature. A successful account reload also removes disconnected-account signatures from retained Settings edits, preserving templates and connected-account signatures. This adds a record kind without changing the mail key, database schema or encryption format.

App owns the compose-preference save lock and pending state. Leaving or reopening Settings cannot start a second save while the first is pending. Success updates the saved baseline; failure keeps the editable draft and reports the error even after navigation. Mounted deferred-response regressions in src/navigation.test.tsx cover both outcomes and the next save.

pin and unpin message actions run through the existing mail queue and never call a provider mutation. Pins live in the encrypted mail record, survive provider refresh, and clear with the cached mail. They do not reorder results or change Inbox categories.

Find pinned mail is a cached is:pinned search, not a separate state. The Filters popover and Commands both call App's findPinned, which either keeps the current view or switches to All cached. Both keep the selected account, unlike typing a new search, which widens to all accounts. All cached is the only scope that reaches pins after Archive, Done or Snooze move them out of Inbox. A mounted synthetic test in src/navigation.test.tsx covers both scopes, account retention, the help text and the Commands entries.

The service increments an in-memory unreadArrivals count only for arrivals already accepted by its existing sync notification rules. App suppresses its initial snapshot and reports newly accepted arrival sequences on later reloads. The service retains a bounded in-memory journal of exact IDs and sanitized details, pruning removed mail while preserving surviving sequence numbers and the cumulative count. Legacy count-only snapshots use generic notices. Repeated state reads do not repeat a notice; the service counter resets on restart. Each observed arrival batch gets a distinct event ID so equal-sized batches keep separate notices. src/ui/Toasts.tsx retains queued outcomes, renders four at a time, masks text with Redact and pauses informational expiry during hover/focus. Errors and delivery warnings remain until dismissed. This supplements native notifications; it does not introduce another sync loop.

Settings, Notifications customizes floating in-app arrivals and action confirmations independently. Position can be Bottom right, Bottom center or Top right; duration can be 3, 6 or 10 seconds or Until dismissed. Defaults enable both categories at Bottom right for six seconds. Preview works with both categories disabled, and reset restores defaults. Validated display preferences use the emc2.notifications.v1 localStorage key. Duration changes apply to future notices; queued and visible notices retain their original duration. Storage failure keeps the current selection in memory and reports the save failure. Errors and uncertain-send warnings cannot be disabled or timed out by these choices. Independent desktop delivery, optional sender/subject/preview details, exact-email opening and system/off/custom audio extend these preferences. All details default off and remain generic in privacy mode. A native chooser imports regular WAV/MP3/Ogg files up to 2 MiB, storing bytes without the source path or name. Playback stops after 30 seconds. Reset removes imported audio. See ADR 0040 and ADR 0050.

Snooze confirms the selected local return date and time. Return to inbox, Archive and Trash also produce success confirmations. Success follows the confirmed action, before refreshing the cache. A subsequent refresh failure reports the confirmed result with a separate refresh warning, rather than suggesting the action failed or should be repeated. Mounted synthetic action and notification tests cover these outcomes; physical input, native appearance and live-provider delivery are separate proof.

Focused source checks are node --import tsx --test server/mail-features.test.ts src/mail-search.test.tsx src/command-palette.test.tsx src/compose-preferences.test.tsx src/toasts.test.tsx src/mail-actions.test.tsx src/mail-state.test.tsx. They cover synthetic HTTP/storage, restart persistence, component controls and mounted App selection/pin/arrival behavior. They do not prove native physical input, provider delivery or installed-app behavior. Full application checks remain npm test and npm run build.

Desktop startup

Activation and second-instance callbacks check that the native window still exists. The closed event clears only that window's reference so quit-time activation cannot call a destroyed Electron object. The desktop main-process tests cover callbacks both before and after the closed event.

Run npm test and npm run build before packaging. The desktop startup check opens the encrypted database, loads the renderer, requires the desktop token for API access, checks native menu roles on macOS and closes the window to confirm the hidden service survives and the platform tray policy holds. Use a temporary user-data directory so checks cannot open a maintainer's accounts:

sh
1check_dir=$(mktemp -d)
2npm run check:desktop -- --user-data-dir="$check_dir"

For a packaged Apple silicon build, use the logged-in macOS desktop session. Quit any running copy first, since the mail service uses a fixed port:

sh
1check_dir=$(mktemp -d)
2mkdir "$check_dir/profile"
3open -n -W "release/mac-arm64/Emc².app" \
4 --stdout "$check_dir/stdout.log" --stderr "$check_dir/stderr.log" \
5 --args --check-startup --user-data-dir="$check_dir/profile"
6cat "$check_dir/stdout.log" "$check_dir/stderr.log"

Require a startup: passed JSON result and process exit, then remove only the temporary directory created for that check. An open exit alone is insufficient. Intel builds use release/mac/. The explicit profile argument prevents migration or import of saved accounts into the check. Verify the packaged app with codesign --verify --deep --strict --verbose=2 "release/mac-arm64/Emc².app" before startup. These checks do not drive the UI, authenticate real accounts or send mail. A direct SSH executable launch can lack Keychain access even when desktop launch works. Keychain availability is required; an access failure is a blocker, not a reason to replace encryption.

The repository has no scripts/documentation.py; documentation checks currently require link and diff review.

Send outcomes

Compose retains the draft after an untyped queue failure, including transport loss, an unreadable response or HTTP 400. Review the Outbox to reconcile a possible committed queue entry before sending again. HTTP 400 alone does not establish rejection: the API uses that status for provider exceptions too. The draft shows "Send status unknown" and possible delivery. Editing fields does not clear this status. All form submissions share a duplicate-risk confirmation; canceling it makes no send request. Native form validation blocks invalid input before submission. The in-flight send lock prevents overlapping submissions before the busy state renders.

After SMTP accepts the message, Sent-copy and connection cleanup failures become SentCopyError warnings. An IMAP append or logout result of false also produces a warning. The shared mail operation retains { ok: true, warning }, and the outbox persists its sent receipt with that warning. Queue acknowledgement clears compose before refreshing the inbox; a refresh failure does not resubmit. Agent approvals only create drafts. These outcomes confirm provider acceptance, not recipient delivery.

Focused synthetic regressions:

sh
1node --import tsx --test src/compose-send.test.tsx server/send-provider.test.ts

Renderer regressions execute the compose submission function, form event handlers and rendered status text. A loopback HTTP server records a submission and drops its response, then verifies canceled and confirmed resends. Tests cover uncertainty through edits, shared pointer/keyboard form handling, validation, close prompts, overlapping submissions and refresh failure. Provider regressions run the real provider and shared send operation with synthetic SMTP and IMAP adapters. They cover accepted SMTP, Sent-copy and cleanup warnings, invalid input and SMTP failure without an IMAP connection. These tests do not drive a native window, verify browser event dispatch or prove real mail delivery.

User Send now commits an encrypted local outbox entry before any provider call (ADR 0092). The composer's Undo delay control offers Off, 5, 10 (default) or 30 seconds. Pending mail has a countdown and an Undo button. Ctrl/Cmd+Z undoes the latest pending send outside editors and dialogs; Tab reaches the same action. Undo restores the exact source draft, including Bcc, markup and attachments. There is no recall after provider acceptance. Emc² must be running to send; this is not a background service.

POST /api/send accepts { payload, sourceDraftId, sourceRevision }, where payload passes sendSchema and matches that saved draft. It returns HTTP 202 with { queued: true, entry }, not provider acceptance. The encrypted record keeps the opaque payload, original draft, stable attempt UUID, account, source revision, creation/update times and deadline. Repeating an identical source revision returns its existing entry. Source draft editing/deletion is blocked until the outbox is resolved. Legacy string-To records normalize read-only at admission without saving or changing the frozen revision. GET /api/outbox returns status metadata only. POST /api/outbox/:id/edit restores the draft and removes the entry. A delayed Undo response leaves any newer composer open and keeps the restored draft saved; POST /api/outbox/:id/send explicitly submits pending mail; DELETE /api/outbox/:id discards it, requiring confirmUnknown: true for unknown outcomes. Sent entries can only be dismissed, never resent.

The committed states are pending → submitting → sent, failed or unknown. Only explicit protocol rejection establishes failed; network loss and other ambiguous errors remain unknown. A Sent-copy warning is sent and never retried. Submitting is persisted before provider execution. Startup holds all recovered pending entries for Send now, Edit or Discard, and converts submitting to unknown. Missed deadlines after sleep are held for review. Unknown Edit retains the duplicate-risk warning; user confirmation is required before another Send. There is no automatic retry. Source drafts are removed only with a committed sent receipt. Records remain until explicit dismissal, Edit or Discard, bounded to 100 per account without eviction. Account removal refuses during submission and otherwise purges local entries with that account.

Quit holds pending entries before asking whether to keep the app open or quit with mail saved for review. It drains active submissions through their receipt. An update holds pending mail and drains active submissions before the existing renderer/native close flow. Cancellation leaves pending mail held; it does not restart a countdown. Update integrity checks are unchanged. Agent approvals still only create drafts under ADR 0053. Scheduled sends extend these records as described below. Outbox attachments remain inline encrypted snapshots under the existing send limits. No uploaded-reference contract or attachment lifecycle is introduced.

Focused synthetic checks: server/outbox.test.ts, src/outbox.test.tsx, src/outbox-recovery.test.tsx, server/update-drain.test.ts and src/update-lifecycle.test.tsx, run serially through the bounded wrapper in CONTRIBUTING.md. Native quit/update interaction, installed recovery and real provider delivery require separate acceptance. Sources: outbox, compose, providers, shared send operation and interface behavior.

The provider fixture calls closeStore before deleting its temporary database directory. Windows cannot remove the directory while SQLite holds its files open.

Public contribution boundary

Keep credentials, personal contact details, absolute machine paths, private hostnames, session records and mailbox contents out of source, commits, PRs and release attachments. Use synthetic test data and portable commands. Preserve the project's existing identity. Review commit author and committer metadata before pushing. Ignore rules and package exclusions are preventive controls; scan the final source diff and package contents too. A pattern scan cannot prove the absence of all sensitive information.

CI

CI runs automatically on non-draft pull requests to main (opened, reopened, ready for review and later pushes) and on manual dispatch. Changes touching only Markdown or docs/ are skipped. Every job runs on GitHub-hosted runners: a budget gate and tsc --noEmit on ubuntu-latest capped at 2 and 8 minutes, and npm test on ubuntu-latest capped at 15 minutes. The repository is public, so these runs are not billed and the gate passes. If it becomes private, or a visibility lookup fails (unknown), the gate skips CI jobs after 150 executed runs in a calendar month; draft-PR runs that skip every job are not counted. Only a repository known to be private also disables the workflow until a maintainer re-enables it (ADR 0105). Build installers and release runs on pushes to main (next patch version, tagged and published after all four installer jobs pass) and on manual dispatch of an existing stable tag. Docs-only pushes, [skip release] and Release commits are skipped. Checks, Linux and publishing use ubuntu-latest; Windows and macOS also use GitHub-hosted runners; tests run once, on Linux. See ADR 0064 and ADR 0065. Merge groups, other branch pushes, tag pushes and schedules start no workflow. Automatic patch release, the earlier CI and CodeQL workflow files and the Dependabot configuration are removed. Dependency review and auto-merge helpers remain absent. See ADR 0063; ADRs 0061 and 0062 remain historical decisions.

A manual dispatch requires an existing stable v<major>.<minor>.<patch> tag resolving to the run commit. The build-version script also requires the tag to match the committed package version. An ordinary branch selection cannot pass this release gate. Only an explicitly authorized release should dispatch it. No workflow creates a version commit; only the push lane creates a patch tag.

  • Checks verifies the selected tag and runs checksum-pinned actionlint 1.7.12 before the platform jobs.
  • Test and package (<platform>) uses the existing shared step list for checkout, Node setup, cached Electron downloads, npm ci, serial tests, strict build, installer creation and artifact upload. Windows and macOS also run an isolated production-startup check. Linux rejects an unsuccessful Checks dependency before checkout; publication requires every build job.
  • Publish downloads all installers with digest mismatch rejection. The asset preparation gate requires the four expected nonempty regular installer files and both updater manifests, rejects symlinks and unexpected files, and generates deterministic SHA-256 checksums. Per-platform Windows/Linux version, filename, size and SHA512 validation remains before upload. Publication verifies the existing tag.
PlatformArchitectureInstallerRunner
Windowsx64NSIS .exewindows-2025
macOSApple silicon arm64.dmgmacos-15
macOSIntel x64.dmgmacos-15-intel, x64 Node
Linuxx64.AppImageubuntu-latest

Build jobs read Node 24.22.1 from .nvmrc; package.json requires >=24.22.1 <25. The app uses Electron's bundled Node. Actions retain their full release-commit pins and require runners supporting their Node 24 runtime. macOS jobs use Bash without startup profiles, and x64 jobs fail unless Node reports x64.

Manual runs for the same ref serialize without cancelling publication. Platform jobs keep a 30-minute limit on Linux and 45 minutes on Windows and macOS, and artifacts expire after one day. Signing certificate discovery stays disabled, builds use --publish never, and only the publisher has repository write permission. Windows installers are unsigned; macOS apps use local ad-hoc signatures and are not notarized. Build or startup success does not establish installation, real-account use or delivery.

The build-version script retains stable-tag version validation and platform bundle build metadata. Push releases select a version with release-version.mjs, then pass it to each build as EMC2_RELEASE_VERSION. Both scripts use the same selection: reuse the highest stable tag above the committed package version that resolves to HEAD, including annotated tags. Otherwise, choose the next patch above both the source version and all stable tags. Tags at other commits never authorize reuse. Stamping also requires EMC2_BUILD_REF to match HEAD before writing package or lockfile versions.

Retry an automatic release through its original push workflow run. After tag creation succeeds and publication fails, a full-run retry selects the same version; a build-job retry validates the retained Checks output against that source tag. Later tags at other commits do not change this selection. Native build metadata still records the current run number and attempt. Manual dispatch still requires the tag to match the committed package version, so it rejects automatic tags whose source retains an older version.

node --test scripts/set-build-version.test.mjs covers both retry paths with lightweight and annotated tags in temporary synthetic Git repositories, before and after a higher tag appears at another commit. It also checks wrong-source rejection before writes, tags at other commits, strict manual matching and package/lockfile/native metadata stamping. These checks do not prove a hosted release, publication recovery or downloaded installer behavior.

There is no automatic PR or merge-queue commit-email job. Keep the local scripts/check-commit-emails.mjs origin/main..HEAD gate for authors, committers and co-author trailers, and mask any rejected address. Review dependency advisories, licenses and upgrades manually. .github/release.yml lists pull requests directly under What's Changed, without category groups, and leaves out Dependabot. It configures release notes and is not a workflow.

Actions enablement, required checks, merge queues and tag protections are repository settings, outside these files. This source deletion changes none of them. A maintainer must reconcile requirements for removed CI before merging. Do not synthesize passing statuses or treat a manual release of a different commit as PR validation. See CONTRIBUTING.

No workflows, tests, builds or lint were executed for this deletion. Source inspection establishes the intended configuration only. Hosted/manual release behavior remains unverified for the new workflow.

Email links

Plain-text fallback bodies stay text. Explicit HTTP and HTTPS URLs become links when the message opens. The renderer preserves query strings and balanced parentheses, and leaves surrounding prose punctuation and curly quotes outside the link. Bare domains and mailto links remain text. The bounded formatted reader described below now supports a safe subset of sender layout. Remote body images require per-message activation, as described below.

The existing mailparser dependency converts HTML-only email links to text containing their destinations. No mailbox resync or storage migration is needed for URLs already in cached bodies.

Address privacy mode masks URLs containing literal or percent-encoded email addresses and omits their anchors. Turn address privacy off to open these links. Personal-information hiding also covers known connected-recipient names and suppresses private destinations.

The desktop navigation policy allows HTTP and HTTPS destinations outside the mail service origin. On the mail service origin, only the Google OAuth entry route may open externally. The renderer gets that route's single-use launch link from the authenticated POST /api/oauth/google/start; the entry route refuses requests without a pending launch, and the service keeps at most eight pending launches (two minutes) and eight pending states (ten minutes). Local API routes and other URL schemes stay blocked.

Verification for source revision dbe8b870e3ec1140c1385d49118fcbf8f5b917a9: seven renderer tests and the production build passed. Earlier navigation and server test results apply because their inputs are unchanged. T3 Code browser checks used synthetic mail and covered mouse/keyboard link activation, address privacy, escaped HTML and the exact destination of a URL surrounded by curly quotes. This does not prove native Windows browser handoff, real mailbox operations or release readiness.

Sources: message rendering, navigation policy, mail parsing, design.

Email reader and opt-in images

Back and Forward

The navigation hook keeps visited menus, views, accounts, Gmail label filters, search and sort controls in memory. App records explicit changes to those controls and Settings. Opening, closing, selecting or traversing mail does not add a visit or clear Forward. Consecutive search edits share one visit. Back from an open reader returns to the list; immediate Forward reopens the latest reader only while its account and message still exist. Otherwise Back and Forward restore the menu or list. A new menu visit after Back removes the forward branch. Missing account filters return to All. Drafts and dialogs block traversal. Escape retains the existing close-email, close-dialog and draft-confirmation behavior. Closing or changing the reader clears remote-image consent.

On Windows and Linux, Electron main forwards the documented app-command values browser-backward and browser-forward through the preload bridge. The renderer suppresses DOM side-button events on those platforms so one gesture cannot traverse twice. Elsewhere, DOM mouse button 3 means Back and button 4 means Forward; only mouseup traverses, while mousedown and auxclick suppress defaults.

Synthetic mounted App tests exercise traversal, menu-only history, filter/search restoration, forward retention during mail operations, forward truncation after menu changes, consent reset, guards, source selection and listener cleanup. The preload test checks direction validation and unsubscribe behavior. Run npm test, npm run build and the isolated npm run check:desktop recipe above for desktop changes. DOM and bridge tests do not prove physical mouse delivery, native Windows/macOS behavior or visual layout. Installed-app proof remains separate.

The reader splits blank-line runs, including whitespace-only lines, into one paragraph break. It preserves single line breaks, indentation, text links and address masking. This changes display only; cached body strings are unchanged. The body uses 15px text at 1.45 line height within a 65ch column. Sender details and the date wrap within the header.

Show images is a native button. Before activation the reader creates no body image elements and makes no image-metadata request. Activation shows loading, then responsive images, a failure message or a no-supported-images message. Hide images removes them. Message changes and reader closure clear consent; late responses cannot reveal images in a different message.

New MIME parsing records at most 50 unique image URLs and 500 characters of alt text per image. Only explicit HTTPS sources with public-looking DNS names, no credentials and the default port are accepted. HTTP, local hostnames, IP literals, relative URLs, data URLs and CID sources are excluded. The renderer checks sources again. Images use referrerPolicy="no-referrer"; the server never fetches an image URL. The formatted reader stores bounded HTML in encrypted mail JSON and constructs allowed React elements. It discards scripts, active elements, sender stylesheets, event handlers and resource-loading CSS. See the formatted-content decision below. The renderer CSP permits HTTPS in img-src while retaining the existing script, connection and sandbox restrictions.

Loading remote images can expose the reader's IP address and send cookies to the image host. URL filtering does not verify DNS resolution or redirect chains. Consent therefore applies only to the current reader opening and is never saved as a preference. Show images works while personal text is hidden. Enabling privacy clears earlier consent; a new click can show supported images while known private text, alt descriptions and captions stay masked. Formatted image descriptions are redacted before the 500 UTF-16-unit display limit. Gallery metadata may already be truncated by the provider; descriptions of 500 or more units use a generic Image N label while privacy is enabled, in both alt text and loaded captions. Short descriptions keep their existing masking and explicit caption reveal. Sources containing detected private information remain blocked in privacy mode. Image pixels may contain personal information and are not redacted. See ADR 0020. Embedded CID raster images now use the consent-controlled attachment path described in ADR 0088; they do not use remote image URL validation.

Older cached mail lacks image metadata. The authenticated, CSRF-protected POST /api/messages/:id/images retrieves only the requested message, writes optional image metadata to its existing encrypted JSON record and leaves its body and current mailbox state intact. Later requests reuse that metadata, including an empty list. Gmail retrieves raw MIME by the stable Gmail ID; its cached inbox sync refreshes unread status and label membership using lists, without downloading cached bodies. IMAP validates INBOX UIDVALIDITY and fetches that UID without setting Seen. It also checks Message-ID when one is cached. Older archived IMAP messages fail before connecting because the stored UID belongs to the former INBOX. Newly parsed images remain usable after archive. No bulk cache refill or database migration is needed.

Read and archive operations reread the cached message after the provider responds. Image metadata saved during that wait survives the action, and a message removed during the wait is not recreated. Deferred-provider tests cover both completion orders between image caching and mail actions.

Automated synthetic scenarios cover paragraph normalization, existing links and masking, MIME extraction, URL rejection, cached Gmail metadata sync, targeted Gmail retrieval, IMAP identity failures, API authentication/origin/CSRF gates, cache reuse, and preservation of text and concurrent archive/read state. Mounted DOM scenarios cover blocked entry, activation, loading, image failure, provider failure and manual retry, no images, and switching during a pending request. Run npm test and npm run build. DOM tests do not load remote images or prove native keyboard activation, visual layout, live providers or installed desktop behavior. Browser testing remains prohibited by PRODUCT.md.

Sources: reader, styles, shared image policy, providers, API, image decision.

Formatted content, privacy and interaction settings

ADR 0013 supersedes the plain-text-only part of ADR 0009 for the owner's approved formatted reader. Mail.html is optional: undefined means old cache, null means no supported HTML. The existing encrypted JSON store retains at most 256,000 UTF-16 code units. POST /api/messages/:id/content recovers one old record through the same authenticated, origin/CSRF-checked provider path as image metadata. It refuses unsafe old IMAP identities, rereads current records and preserves concurrent read/archive/image state. HTML recovery never fetches sender assets or changes provider read state.

The content parser allows a bounded element, attribute and inline-style subset. It rejects scripts, forms, frames, SVG, sender stylesheets, fonts, event handlers, srcset, resource CSS, positioning and transforms. Lengths accept px, pt, em, rem, ex and ch (ex and ch count as 8px); margins also accept auto; border shorthands are read in any word order and normalized to width style color. The renderer applies HTML align the way browsers do (auto margins for centered tables, left/right floats for aligned tables and images, -webkit- text alignment elsewhere), renders <center> as a centered block, and drops sender margin, padding and border on blockquote so the app's quote style applies. Node, depth and dimension limits bound work and layout. Bounded sender min-width is supported. Formatted content uses break-word wrapping to preserve whole-word table widths; oversized tables scroll inside the content boundary. FormattedMail constructs React elements; it never injects sender HTML. Table layout, heading hierarchy, emphasis and safe HTTP(S) links remain supported. The renderer maps sender text, backgrounds and borders to app colors, and links use the app's blue. This includes legacy color attributes and validated border shorthand. Image pixels and privacy bars remain unchanged. See ADR 0014. Missing or rejected content uses cached plain text. Embedded raster CID attachments resolve only against a unique attachment on the opened message after Show images. The renderer fetches the authenticated attachment bytes, enforces the existing 512 KiB file limit and revokes its blob URL when hidden or closed. SVG, arbitrary data URLs and unmatched CID references remain unsupported. Older embedded-image imports recover missing Content-ID metadata through the existing content route. See ADR 0088.

The reader loads missing formatting on open, shows cached text during loading or failure and offers one manual retry. Show images sits above the body. No body image element exists until activation; consent resets on closure, message change or privacy activation. Delayed content and image responses cannot reveal another message. Formatted images stay inline without a duplicate gallery. Privacy ranges use the full text and map back to the original text leaves with UTF-16 offsets. This keeps supported sender formatting and image/caption order while masking tokens split across tags or images. Split segments share the whole source token as their reveal key; revealing a full name does not reveal a separate surname. Privacy mode suppresses private link and image destinations while allowing explicit display of other supported images. Scrollbar indicators are hidden by CSS without changing scrollable overflow or input handling.

Replies use the safe formatted reader for the original message. Reopened drafts require exactly one cached source matching the draft account and inReplyTo message ID; a missing or ambiguous match shows no original. Authenticated single-message recovery can load missing HTML, while remote images require explicit per-opening consent. Rejected or unavailable HTML retains the cached body or preview. Privacy uses the source recipients and clears image consent. The outgoing draft and source mail are unchanged. See ADR 0074.

Privacy matching learns names from optional account display names and the current To/Cc display names tied to connected account addresses. It covers full names, first names and surnames, including one-letter Unicode components, with Unicode-aware boundaries, case/whitespace variation and URL encoding. The opened message's dictionary also protects Gmail label filter options and the label editor. A sender avatar uses a neutral dot when its displayed initial matches a known private name component; ordinary company initials stay visible. Sender-markup splits cannot expose a known name. Hidden bars have no private children, title or accessibility text. Explicit reveal applies to the clicked token; re-enabling privacy clears it. Unknown names that appear only in a body cannot be identified. A name that is also an ordinary word can hide that word while privacy is on. Editable compose fields retain their focus-to-reveal behavior.

Inbox tabs are derived on the client from Teach rules, personal-mail patterns, Gmail CATEGORY_* labels, the sorter's reason and sender/subject patterns; nothing new is stored, and the server's important/other category still drives MCP, relevance and Teach. The selected tab is part of the in-memory navigation location. The sidebar open state is stored as sidebar (open or closed) in localStorage, beside window-controls. The removed sender panel left an unused sender-panel key, which is ignored. A persistent toolbar contains search and Commands above the pane headers, plus the Filters and privacy buttons while the mail list shows; each pane's secondary controls share its header's title line. A closed sidebar stays mounted with inert so it can animate. App decides which panes are on screen with useMedia (the sidebar needs 760px, the reader beside the list 980px). With Side by side selected, the mail list keeps its column even before opening an email and after closing it; the adjacent reader shows opening guidance until mail is opened. F6 skips this empty reader until an email is open. Full width and windows below 980px use one column. The renderer gives the window controls to the header in each corner through PaneHeader; without matchMedia (the renderer tests) every pane counts as present. Appearance stores mode (system, light or dark; dark by default), theme, accent, transparency (off, subtle or glass; an earlier boolean glass is migrated), reading layout (split or full) and sizeByImportance (on by default; off gives every row the medium layout) under emc2.appearance.v1. It applies them as data-scheme, data-theme, --accent (a deeper shade in light mode) and data-glass on the document root; System follows prefers-color-scheme. The preload exposes desktop.materials, true on macOS and on Windows build 22621 or later; desktop.setMaterial, which sends window-material (none, mica or acrylic), accepted only from the app window and only on Windows; and desktop.setScheme, which sends window-scheme and sets nativeTheme.themeSource so materials, native menus and System follow the mode. The main process starts dark until the renderer reports its mode. ADR 0022 records the window material decision and ADR 0025 the modes and reading layout.

Window chrome outside macOS (ADR 0026): the main process reads window.json ({"controls":"mac"|"windows"}) from the app data folder when it creates the window; anything unreadable means the drawn lights. Windows style creates the window with titleBarOverlay and passes --emc2-window-controls=windows to the preload, which exposes desktop.windowControls. window-controls (from the current window main frame at the app origin only) saves the choice and, if it changed, closes through the native draft guard and, only after accepted closure, opens a new window at the old bounds; the renderer refuses while a draft is open or sending. window-overlay sets the caption colors (validated #rrggbb) from the renderer's --surface and --text. Mail rows call desktop.mailMenu, which sends mail-menu to ipcMain.handle; window-chrome checks item ids, labels, enabled flags, display-only accelerators and position, and the main process pops a native menu and returns the chosen id (or null after the menu closes). Without the bridge the drawn menu is used. Folded sidebar sections are stored as sidebar-folded in localStorage.

The renderer is split by area: App.tsx holds state, handlers and pane layout (the reader keyboard test parses its markRead, openMail and keydown handler declarations, so they stay there); src/mail/ holds the list, reader, filters and tabs; src/shell/ the sidebar, pane headers and update notice; src/settings/, src/dialogs/ and src/compose/ their screens; src/ui/ shared controls, icons, privacy bars and window controls. style.css imports one stylesheet per area from src/styles/ in cascade order, starting with the tokens; the stylesheet test reads them through those imports.

Interaction preferences store opening mode, mouse-history enablement and validated shortcuts under emc2.interaction.v1 in localStorage. New or reset preferences use Gmail defaults for supported actions: O/Enter opens, S pins locally, B snoozes, A replies all, Shift+I marks read and U returns to the list. G then I/T/D/A/S/B goes to Inbox/Sent/Drafts/All cached/Pinned/Snoozed across accounts, clearing mail filters. Settings, Keyboard and mouse records either single bindings or two-letter sequences for every registry action. Duplicate bindings, ambiguous sequence prefixes, editing and system keys are rejected. Sequences expire after 1.5 seconds, clear on focus/blur or blocked events, and consume an invalid printable second key without running a mail action. Editors and dialogs retain ownership. Older complete preferences retain all saved keys; new actions receive unused bindings if their defaults conflict. Reset applies Gmail defaults. Mouse side buttons can be disabled without changing toolbar or keyboard history. Right-click and hover/focus row actions retain existing behavior. Help, Commands and context menus use the saved bindings. Browser Mac detection uses the platform when the Electron bridge is absent. Storage failures keep the session choice and display a notice. See ADR 0086.

Tests use synthetic messages and accounts. The mounted App path covers Settings changes, custom-key dispatch, double/single-click transitions, read counts, history keys, recipient names in lists/headers/formatted content/attributes, image placement and privacy. Provider/API tests cover authentication, identity and cache races. Run npm test and npm run build. Native rendering, physical input, live providers and downloaded-app acceptance need separate proof. Browser testing remains prohibited by PRODUCT.md.

UI review, 2026-10-09

The reader follow-up caps hidden-address bars at 0.9em high and one line, with the existing container-width limit. A synthetic CSS comparison at 728px and 360px confirmed that normal and long bars fit inside their line boxes without horizontal overflow. This checks isolated layout, not the installed application.

Baseline: b04679ac3e6fa62e60acd37dab1d03c1d3f2f981. Scope: inbox, open message, address privacy, and Settings provider dropdown. Method: source inspection and T3 Code browser interaction using synthetic mail at 1280 x 800 and 900 x 600. The Windows application and real mailbox operations were not exercised.

Guidance: Nielsen usability heuristics and Web Interface Guidelines, retrieved during this review. This is a focused review, not a full WCAG audit.

These findings remain outside the link repair:

BeforeRecommended afterWhy
Compose, reply, archive, snooze and Teach have keyboard shortcuts but no visible action controls. The shortcut list itself requires ?.Add visible access to primary mail actions and shortcut help.Major: pointer users cannot discover or invoke core actions. See App keyboard handler.
Address reveal uses a clickable span with no keyboard focus or key handler.Provide keyboard access to each address reveal control, with valid semantics in both text and mail rows.Major: keyboard users cannot reveal one address while keeping the others hidden. See Address.
The provider listbox references a label ID that does not exist. Focus stays on the button, while the active option is declared on the unfocused list.Connect the label and expose the active option through the focused control.Major: assistive technology cannot reliably identify the list or current option. See Dropdown.
Faint text uses #5a5a5a on #0a0a0a, about 2.87:1 contrast.Raise contrast for small status, date and context labels to at least 4.5:1.Minor: these labels are hard to read. See --text-4 in src/styles/tokens.css.
The compose textarea removes its focus outline without a visible replacement.Add a visible keyboard focus indicator.Major: keyboard users cannot see when the message editor has focus. See .compose-form textarea:focus-visible in src/styles/dialogs.css. Source finding; not tested in the browser.

The hierarchy, restrained colors, button focus outlines and reduced-motion rule support the intended dense inbox. The reviewed message and Settings layouts did not overflow at 900px. This review does not claim measured improvements in usability.

Working checkout

Create a new worktree and feature branch for each new Emc² task. Bind the T3 Code thread to that worktree before task work. Preserve the main checkout. See ADR 0005, based on the owner's instruction from 2026-10-09.

Showcase downloads

The showcase website displays a combined download total for Windows, macOS and Linux installers from all published GitHub releases. Its public query follows pagination and caches complete totals for ten minutes, retaining the last count on failed requests. It adds no desktop telemetry. npm test includes scripts/website-downloads.test.mjs; the website build is separate: cd website followed by npx lakebed build --target claimed. Hosted outbound requests require a claimed deployment. Browser and live deployment verification remain separate from these checks.

Application rename

Version 0.1.4 publishes the application as Emc². desktop/migrate.ts moves mail, settings.env and Local State from the pre-rename app-data folder into the new one on first launch and writes .renamed-to-emc2 so the step never repeats. desktop/migrate.test.ts covers the carry, the single run, the no-overwrite case and the two no-op cases; run it with npm test. New installers are named Emc2-<version>-<os>-<arch>.<ext>; Linux x64 uses x86_64. The product name remains Emc². The Windows application id stays dev.screen.aggregate-mail, and the local MCP server is named emc2.

Gmail labels, density and read-on-open

Gmail sync caches its label catalog as encrypted label records, keyed by account and label ID, and stores labelIds on mail. Existing mail without that optional field remains readable and gets labels on its next inbox sync. Disconnect removes the account's label cache. The filter is account-scoped and applies to the current view of cached mail. Sync still fetches Gmail's inbox; it does not backfill the remote archive. Label creation, renaming and deletion remain in Gmail.

Edit labels on an opened Gmail message to replace its known custom labels. The server validates every requested label against that account's cached catalog and reads current provider metadata before modifying it. System labels and unknown labels are preserved. Gmail's existing gmail.modify permission is sufficient. Sources: label listing and message modification.

Ctrl/Cmd+Plus, Equals and numpad Plus increase text size; Ctrl/Cmd+Minus and numpad Minus decrease it. Ctrl/Cmd+0 resets the size; D changes density. Editing fields and dialogs keep their normal key behavior. Opening unread mail uses the shared provider read operation independently of the UI busy gate. This includes reader navigation and cached archived Gmail mail. Repeated opens coalesce while a request is pending. Read failures leave the unread flag intact and reopening retries. An opened message stays visible if marking it read removes it from the Unread label filter. An explicit number-key change to a different account clears the reader and selection; selecting the same account leaves them intact. Archived IMAP messages are located by a Message-ID search in the provider archive instead of reusing their old inbox UID. Because IMAP header search matches substrings, the sole candidate must also have an exactly matching parsed Message-ID before its read flag changes. Missing, ambiguous or mismatched candidates report an error without changing remote flags or local unread state. Sync and message mutations run in order on the server so stale sync metadata cannot undo a read. Other writes still return a sync conflict instead of bypassing the existing boundary.

server/gmail.test.ts uses mocked Gmail API methods with a temporary encrypted store to cover catalog refresh, message label sync, account isolation, custom label edits, provider failures, sync/read ordering, idempotent reads and disconnect cleanup. server/imap-read.test.ts covers inbox UID validation and exact archive Message-ID verification without a connection, including substring false positives and preservation of cached unread state on failure. The store holds SQLite open until process exit, so these tests leave synthetic files in the system temporary directory for the host or CI runner to clean up. src/mail-ui.test.tsx covers key recognition, account-scoped filters, label rendering/privacy and duplicate/retry read requests. src/reader-keyboard.test.tsx executes the App keyboard and open handlers with synthetic state to cover reader navigation, account changes, coalescing, retry and input guards. These tests do not mount the interface, authenticate real accounts or drive a browser.

Snooze retains its message-specific privacy context. Rejected formatted trees and wrappers without readable text or supported images use the same readable text and consent-controlled gallery. Supported image-only mail keeps its inline image presentation. Image status styles do not override body paragraph sizing.

Filters stay outside scrolling mail content. Compact reader up/down buttons reuse the filtered order and Unread anchor; traversal restores reader focus and resets image consent. See ADR 0032. Synthetic mounted tests cover these paths and the selected sidebar folding/collapse behavior. Native layout and physical input remain separate checks.

Encryption research

Database and E2E encryption research traces current record-value AES-256-GCM protection, SQLite metadata exposure, OS key protection and Linux's missing explicit basic_text rejection. It compares a SQLCipher binding spike with the current store and compares OpenPGP/PGP-MIME with S/MIME recipient support. No encryption or migration implementation was added. No standard guarantees all recipient clients; packaged platform tests and actual cross-client encrypted exchanges remain required before compatibility claims.

Saved-draft count

The Drafts sidebar count totals saved drafts across all accounts, matching the Drafts list. /api/state uses a SQLite row count without decrypting or returning draft content. Successful draft creation and deletion use the existing desktop mail-change event to refresh it; ordinary autosave edits do not trigger extra refreshes. Unacknowledged sends keep their saved draft in the count. The badge is hidden until the state response supplies a count, then shows zero when empty.

V2 persistent navigation and Settings

Custom-theme library, editor, import and delete actions use the shared button class for their visible fill, border and spacing. The obsolete btn class has no styles and must not be used for these controls.

Primary sidebar destinations use distinct outline glyphs. Projects uses a folder, Agent review a CPU, Contacts people and All cached stacked mail. Inbox retains its tray and Drafts its envelope. Icons inherit currentColor, render at 16px and stay hidden from assistive technology and keyboard focus. src/navigation.test.tsx checks the rendered destinations for glyph uniqueness and this SVG contract.

The sidebar remains visible in Settings, with its Settings action at the bottom. SettingsPage owns a ten-section rail and per-section explanations. Section IDs are unchanged. Visible Segmented controls replace Settings dropdowns and BYOK provider selection. Arrows, Home and End select enabled choices; one choice is in the tab order. Notification values, configuration saves and preference persistence reuse their existing handlers.

SearchToolbar uses App’s existing search state. A new nonempty query opens All cached across all accounts and clears inherited mail filters. Continued typing preserves deliberate narrowing. The command palette uses the same navigation and action guards. Agent sections keep their forms mounted, so switching to Review queue or Activity retains unsaved setup. Presets do not create grants, enable MCP or grant immediate creation. Existing grants are not expanded. See ADR 0048.

V2 visual correction

The V2 sidebar uses separate Workspace and Mail groups at 196px, with Settings anchored at the bottom. Agent review keeps existing assistant cards above a collapsible add form. Permissions, BYOK and external connection setup stay in each card; hiding a section preserves its state and approval guards. Projects use direct selection buttons. Row and selected-email actions show text when space permits and retain accessible labels when the narrow layout uses icons. The existing notification queue now shows type icons and headings plus Dismiss all. Generic new-mail notices use the unread count as their single heading with a mail icon. Notices with enabled details keep a New email heading above the privacy-filtered text. Open email is an accent text action with a chevron; a separate top-right close button is labeled Dismiss notification. Notices use an opaque themed surface, compact spacing and visible keyboard focus outlines. Dismissing changes only local notice visibility. Error expiry, privacy, preference filtering and hover/focus pauses are unchanged. The stack scrolls within the viewport at bottom-right, bottom-center and top-right anchors, with internal padding for shadows. Notice slots and the count line retain their full height so multiline text and Dismiss controls remain reachable as text size grows. Arrival and dismissal slot animations remain enabled unless reduced motion is set.

See ADR 0049 for the V2 visual correction.

V2 workflow corrections

Assistant setup is under Settings, AI and agents. The sidebar Agent review view contains proposals and activity. Access and approval checks are unchanged. Edit access on an assistant card changes its accounts and tools in place (PATCH /api/agent/grants/:id); the credential is kept. MCP re-reads the grant on every call, and approval rechecks pending proposals against the current grant. Back/Forward share the persistent search toolbar. There is no sender panel (ADR 0056).

Drafts is a full list with account filtering and plain-text search over sender, recipients, account, subject and a 200-character body preview. Complete content is retrieved only when a draft opens. Summaries include attachment counts, not file contents. Privacy masks draft details; uncertain-send warnings stay visible. An initial load failure reports an unavailable count; retained rows are labeled cached rather than implying that saved drafts disappeared.

Mail lists fill the working area until a message opens. Flat direct action buttons appear on hovered, selected or focused rows. Read, Pin, Reply, Archive, Snooze (Return to inbox for snoozed mail) and Trash reuse the handlers and guards in mailMenuActions. More actions retains the full menu. Narrow lists and devices without hover show a scrollable strip below each message; windowing reserves its 36-pixel height. Controls use theme tokens without bevels or shadows. The reader omits confirmed-empty attachments and image controls; unknown metadata and load failures retain recovery controls. Conversation evidence is an inline stack with explicit per-message action selection. Single-item quick tracking shows the deliverable once, with an explicit action to add another.

Settings resets its scroll container on section/history changes. App retains unsaved compose preferences across sections and Settings close for the current session. Save failures retain those edits; a successful save clears only the submitted draft. Closing the application still discards unsaved Settings edits.

Snooze uses an anchored menu that re-clamps when its size changes, including when the custom-time form opens. Only opening the menu focuses its first action, except for an already-snoozed email, whose form opens on the current return time and takes focus instead. Presets carry their full local date and time. Re-clamping preserves input focus. Oversized menus scroll within the viewport. An opened snoozed email's return line offers Return to inbox and Change time beside the local time, reusing the same handlers and Escape focus restoration. Calendar combines confirmed deadlines and follow-ups with active cached-email snoozes in local time. No duplicate event is persisted. Cleared, expired, sent and archived snoozes are excluded.

Ctrl/Cmd +, - and 0 resize or reset text. D still changes density. Page Up/Down moves through the complete filtered email list outside editors and dialogs. Reply context uses the safe formatted reader with cached text fallback and explicit image consent. File drops and file selection share validation.

Notification Preview obeys delivery toggles and shows enabled options. It uses synthetic sample text, honors privacy and cannot open a real message. See ADR 0052.

Emc² calendar events

shared/calendar.ts defines CalendarEvent, eventDays and eventTime. server/calendar.ts stores events as encrypted calendarEvent records and mounts authenticated routes under the usual Host, origin, desktop-session and CSRF checks:

Method and pathRequestResult
GET /api/calendar/events?from&toISO instants, at most 62 days apart{ events, google } local and cached Google events overlapping the range, by start
POST /api/calendar/eventsEvent fields, optional UUID id201 new event; 200 for an identical replay of the same ID; 409 for different details
PATCH /api/calendar/events/:idEvent fields plus current revisionEvent with revision + 1; 409 when stale or synced
DELETE /api/calendar/events/:idCurrent revision{ ok: true }; 409 when stale or synced

Fields are title (1–300), start/end, allDay, optional accountId, location (300), description (5,000), meetLink (https only, 2,000), attendees (≤50 addresses, deduplicated case-insensitively keeping the first spelling) and sourceMailId. Timed events use ISO instants with an offset. All-day events use YYYY-MM-DD with an exclusive end. Events last at most 14 days. A source email must exist and belong to the event's account; an edit may keep an unchanged source that has left the cache only with its original account. A source always requires a matching account, including when No account is chosen. The editor's Unlink source email control clears the source in the form. Saving omits sourceMailId to unlink it before changing accounts; Cancel keeps the saved source. An omitted source or Meet link is cleared rather than inherited by a full-form edit. Later invitation drafts use the saved link, including its removal. Invited events cannot change accounts, even after unlinking a source. Unknown fields are rejected. Calendar writes are allowed during mail sync because they touch no mailbox; updater quiescence still rejects them with 503.

src/work/events.ts converts form fields (local dates and times, inclusive all-day last day) to the API shape and rejects skipped daylight-saving times instead of shifting them. meetingFields builds the Schedule meeting prefill: the subject without Re:/Fwd: prefixes, the sender, To and Cc without any connected account address, the email's account and source. useCalendarEvents loads the one or two exact local month windows intersecting the visible range, serially. It refreshes on window focus and Refresh and serializes writes, refreshing after each one. Account/range changes discard obsolete reads and clear prior results. New events keep one UUID per form, so retrying an uncertain save cannot create a copy.

Calendar day-list and agenda rows stretch their text across the row instead of centering each line. Event dialogs keep the heading and close control outside the scrolling form. The renderer document title is Emc², matching the native window title.

Invitation and cancellation draft controls stay disabled until the local event has a currently connected account. Deleting a local event uses the shared confirmation dialog, sends no cancellation, and retains the captured revision across a failed request. Cancel and Escape restore focus to the trigger, or a Calendar control if a confirmed refresh removed it after response loss; pending deletion blocks dismissal and app navigation.

The shared EventForm disables all editable controls while either caller saves. Cancel, dialog close and app navigation remain guarded during the request. A failed save unlocks the retained inputs; retry keeps the form's event UUID. sameEventFields compares every field with the form's initial values and reports the result to App's existing beforeunload guard. Quitting with unsaved event changes therefore shows the same native prompt as a composer draft, while an untouched form never blocks quit. This covers New event, Edit event and Schedule meeting.

src/work/Calendar.tsx merges events with deadlines, follow-ups, undated open work and active snoozes. src/work/EventPanel.tsx holds the shared event form, the right-hand detail pane and the Schedule meeting dialog. In the App, Calendar opens snoozed mail and sources in its own reading pane (inlineWorkView, shared with Today and Projects) and enables the Reply, Archive, Snooze and Teach shortcuts there. Saving local events sends no invitations. Separate explicit notification and reviewed draft actions are described under Phase 3 below. See ADR 0054 for the phase boundaries.

src/work/calendar-layout.ts calculates inclusive Day, Monday-based Week and month/Agenda ranges, local-midnight boundaries, elapsed-time clipping and stable overlap lanes. DST days can have 23, 25 or fractional hours; each column shows local clock/UTC-offset ticks and pads only outside its day. Date-only/all-day work stays separate from timed spans. Follow-ups and snoozes are zero-duration points; full textual buttons keep short events and point markers reachable. Navigation changes no event or commitment and invokes no provider operation.

The Day/Week timeline keeps its midnight tick above the outside-day shading. The outside-day notice reserves a separate text line below that tick when daylight-saving changes produce days of different elapsed lengths.

Cross-month reads preserve full source/account/calendar/event identity. Local copies prefer revision, then updatedAt; Google copies prefer updatedAt because imported revisions remain 1. Equal-version conflicts choose a canonical field ordering independent of response order and keep a stale warning. Coverage is retained per month. Failed or missing month coverage cannot become a fresh full range. Google controls retain one explicit Sync for each exact month window.

Focused source checks are src/calendar-layout.test.tsx, src/calendar.test.tsx, src/workbench.test.tsx and src/google-calendar.test.tsx, plus the strict build. They use synthetic data. Installed, native, visual-layout and real-provider acceptance require separate evidence.

Calendar retains the originating button for events, commitments and snoozed mail. Workbench restores focus after the destination renders: the event or commitment pane after source-reader return, or the originating row after Back/Escape. src/workbench.test.tsx exercises these paths through the mounted App. Its narrow fixture applies the current CSS hiding selector and models refusal to focus a display:none ancestor. This synthetic check does not prove native layout or focus.

Linked local work sessions

CalendarEvent.workSession stores a commitment ID, Busy Boolean, planned/finished/ removed status and the SHA256 of the canonical initial request. Account, source and title are captured from the parent on creation. Existing encrypted calendarEvent storage needs no schema migration or plaintext index. Events without metadata retain their ordinary busy meaning. Malformed metadata blocks scheduling and availability rather than becoming Free.

server/calendar.ts mounts these authenticated routes:

Method and pathRequestResult
GET /api/calendar/work-sessions?commitmentIdExact parent IDNonremoved sessions, availability and unavailable-parent/source/account reason; independent of Calendar month
GET /api/calendar/work-sessions/:idSaved session IDSaved state, including removed tombstones, for recovery
POST /api/calendar/work-sessionsUUID id, commitmentId, commitmentRevision, offset ISO start/end, Boolean busy, IANA timeZone201 new; 200 canonical replay returning current state; 409 UUID collision or unavailable/stale scheduling state
PATCH /api/calendar/work-sessions/:idCurrent session revision, commitmentRevision, start, end, busy, timeZoneMove/edit a planned session only; immutable bindings; increment session revision
POST /api/calendar/work-sessions/:id/finishCurrent revisionPlanned to finished; parent unchanged
DELETE /api/calendar/work-sessions/:idCurrent revisionRetain encrypted removed tombstone; hide from normal feeds/lists

Malformed input returns 400; absent saved ID returns 404. Stale revisions, conflicts and unavailable scheduling state return 409. Writes retain update quiescence checks. New scheduling requires a current open parent, connected account and same-account source inside the write transaction. Session duration stays within the existing 14-day maximum. The device form rejects DST gaps and folds; the API receives explicit offset instants and a validated IANA zone.

server/calendar-busy.ts supplies shared local busy periods for sessions and offers. Planned Busy sessions block; Free, finished and removed sessions do not. Busy edits exclude only their own event. Touching edges are permitted. Google calendars are excluded from session conflict checks. Offered-time booking still performs its own fresh local and explicitly included Google checks. Ordinary calendar PATCH/DELETE, invitation preparation and Google creation reject session IDs before side effects.

Exact create replay returns current saved state without resetting later edits, finish or removal. Removed is terminal, including finish at the tombstone's current revision. Parent closure or account/source loss blocks scheduling and moves but permits local finish/removal using the saved session revision. No session operation updates the parent or mail, sends or prepares mail, or reads providers. Explicit source opening retains its existing separate behavior.

src/work/WorkSessions.tsx keeps request/recovery state mounted at Workbench level. Pending/unknown inputs freeze until a same-ID GET and explicit review. Acknowledged writes with failed refresh say saved, refresh unavailable and block resubmission. A genuine remount reads saved sessions; it never automatically recreates an unknown operation. There is no private localStorage persistence. The rendered session heading receives focus when the dialog closes with focus on the document body after Finish or Remove removes its Today trigger. Direct success and explicit recovery acknowledgement use the same fallback; a connected trigger retains normal dialog focus restoration. src/styles/work.css gives .session-dialog vertical scrolling within the shared dialog height limit, including explanatory text and form actions. Its .session-form .form-actions row wraps at the available width.

Focused synthetic coverage is server/work-sessions.test.ts and src/work-sessions.test.tsx, plus the calendar, offers, event-panel, workbench and invitation checks in the journey map. Mounted JSDOM checks use independently seeded edit, finish and remove scenarios. The edit targets the create request's retained UUID and 13:00 interval while a 15:00 session sorts first. Finish and Remove check focus after both direct and recovered outcomes. These checks cover control behavior; they do not prove viewport geometry, native focus, theme rendering or installed behavior. See ADR 0076.

Optional Google Calendar read sync

Gmail OAuth accounts can explicitly start consent with POST /api/oauth/google/start and { "calendar": true, "accountId": "<id>" }, which returns the single-use launch link to open. Consent requests only calendar.events, calendar.calendarlist.readonly and, for Phase 4, calendar.freebusy in addition to Gmail, with include_granted_scopes. The server binds the chosen account to OAuth state and rejects a different signed-in identity. Every successful callback, including ordinary Gmail reconnect, recomputes calendar event access and a separate free/busy proof from tokens.scope; missing or partial scope fails closed. Event access alone never proves free/busy permission. Each configured Gmail account keeps a Reconnect Google Calendar link even while event access is Connected, so missing free/busy consent has a direct recovery path.

server/google-calendar.ts uses the installed googleapis client. Encrypted googleCalendar records hold the account's access proof, calendar list, selected IDs, sanitized error code and a generation used to reject obsolete fetches. No scope string or token is returned to the renderer. googleCalendarWindow records hold each exact normalized from/to window, sanitized events, last successful sync time and explicit stale/error state. Each account retains at most six windows. Successful fetches replace the whole window, including empty results, so deletions disappear. Failed or incomplete fetches retain the old window and mark it stale. Reconnect, selection changes and account removal invalidate pending results. Disconnecting Gmail clears its Google records and windows while preserving Emc² events.

Method and pathRequestResult
GET /api/calendar/events?from&toSame bounded window as local eventsCached events plus google account states; no Google request
POST /api/calendar/google/:accountId/sync{ from, to }Explicit provider read; { ok }; inspect refreshed google status even when HTTP succeeds
PATCH /api/calendar/google/:accountId{ selectedIds }, at most 20 known calendar IDsLocal selection only; invalidates old windows; Sync is still explicit

The initial Sync reads the calendar catalog and uses up to 20 of Google's selected/primary calendars; later choices stay in Emc². Catalog reads allow five pages of 100; events allow ten pages of 250 per selected calendar, at most 5,000 total events. Repeated page tokens and exceeded limits fail without replacing the cache. Requests allow 10 seconds and 2 MiB per response with retries disabled; one 60-second abort signal also covers token refresh and all pages. Read sync makes no event write, invitation, automatic sync or free/busy call.

Provider text is parsed to plain text with a 20,000-character input limit, then bounded to title/calendar name 300, location 300, notes 5,000, person name 200 and email 320 characters. Attendees are limited to 50; IDs and pagination tokens to 1,024. Meeting and event links allow HTTPS only, at most 2,000 characters, without embedded credentials. Only known error codes reach storage/UI; raw provider errors are neither displayed nor logged. Google events keep accountId, calendarId and remoteId separately from their hashed display ID. Phase 3 uses these exact identities for explicit remote writes described below.

The Google Calendar disclosure shows per-account Not connected, Needs consent, Connected and Calendar API disabled states. API-disabled help tells users to enable Google Calendar API in their own Cloud project and reconnect. Ordinary Refresh, focus and month navigation read only encrypted cache. Sync is a separate explicit button. Stale cached events stay visible and labeled. Remote events share month/day/agenda and the right pane, use the privacy controls and keep read-only event fields. Explicit Phase 3 RSVP is separate. Long events are expanded only within the displayed month.

Focused synthetic checks are server/google-calendar.test.ts, server/calendar.test.ts, src/calendar.test.tsx and src/workbench.test.tsx, run serially through the wrapper in CONTRIBUTING. They cover mocked OAuth and provider calls, scope loss, account isolation, pagination, encrypted replacement, stale failures, selection invalidation, local-event preservation and mounted UI. They do not prove native consent handoff, installed behavior, provider access or live-account sync.

src/google-calendar.test.tsx checks that connected accounts retain their account-bound re-consent links. This is synthetic markup proof, not native OAuth.

Calendar invitations and explicit Google notifications (phase 3)

server/ical.ts parses and generates one bounded calendar event without fetching remote content. Limits are 128 KiB, 4,096 physical lines, 32,768 bytes per unfolded line, 2,048 unfolds and 50 unique attendees. It accepts UTC, all-day exclusive ends and unambiguous modern IANA time zones. Recurrence, embedded VTIMEZONE, floating, ambiguous and nonexistent times remain unsupported. Calendar MIME is cached with mail and rendered as plain text; unsupported messages expose no RSVP.

Method and pathRequestResult
POST /api/calendar/events/:id/invitation{ revision, method: "REQUEST" or "CANCEL" }Encrypted reviewed draft, replayed for the same event/revision/method
GET /api/calendar/invitations/:idCached incoming mail IDSource fingerprint, eligibility and optional unique current Google match
POST /api/calendar/invitations/:id/reply{ fingerprint, response }Local event plus reviewed own-attendee REPLY draft; response is ACCEPTED, TENTATIVE or DECLINED
POST /api/calendar/invitations/:id/apply{ fingerprint }Explicit local CANCEL or REPLY application, bound to identity and sequence
POST /api/calendar/invitations/:id/google-rsvp{ fingerprint, response, requestId, reconcile? }Source-bound Google operation; same uppercase responses as local RSVP
POST /api/calendar/events/:id/google{ revision, reconcile? }Explicit Google insert and attendee notification operation
POST /api/calendar/google/:accountId/rsvp{ eventId, response, requestId, reconcile? }Event-pane own-attendee RSVP; accepted, tentative or declined
GET /api/calendar/operations?eventIdExact event IDSaved Google status, request/source identity and sanitized result for review

Local draft preparation saves event, draft and replay record atomically. Stable UID and advancing sequence protect update/cancellation ordering. The composer fixes calendar details and recipients while allowing body edits. Ordinary Send resolves the encrypted draft by ID/revision and rechecks event and source before generating MIME. Preparation, agents and reads never send. Unknown-send and Sent-copy warnings use the existing compose lifecycle. Draft validation reuses the structured codec schema so MCP tool discovery can serialize it. REPLY validation identifies its sole responder; Send separately checks that attendee against the receiving account. Ordinary recovered content omits invitation when absent, retaining the existing exact content shape.

Google insert uses deterministic provider and conference request IDs, sendUpdates=all and conferenceDataVersion=1. It retains the local event and saves its sanitized remote result in calendarOperation. Review created Google event opens the shared event details, including the Meet link if returned, even with no matching synced window. This does not create a synthetic sync window or claim that the separate local event tracks subsequent Google edits. The reviewed result is bound to the local source event's ID; selecting a different event in the same pane restores that event's details and actions. Read-only insert reconciliation uses the saved operation's original account, remote ID, marker and retained payload, even after the local title or time changes. It preserves the submitted revision and never inserts again. A new submission with changed fields or a reassigned account is rejected; account identity and consent checks still apply before recovery.

Reader RSVP posts to the invitation endpoint with its fetched source fingerprint. The service validates organizer/sender, receiving account's exact attendee, sequence and one non-stale cached Google match. It checks fresh provider account, UID, organizer, sequence, complete attendee list and one exact self attendee; the patch changes only that response and carries If-Match with the fresh etag. Source validation runs again after provider reads. No aliases are inferred.

Before patching, the encrypted operation stores request ID, response and source fingerprint. HTTP response loss retains that request in the mounted controls; remount recovers server-saved unknown operations. Reconcile performs a provider read only and never inserts or patches. Changed source/identity, missing cache or an unconfirmed result can keep an operation unresolved. Reopen the matching source and review status; never blindly repeat a notification. Provider writes use 10-second requests, 2 MiB response limits and disabled retries. Raw provider errors are not returned to the UI.

Focused synthetic checks: server/invitations.test.ts, server/calendar-writes.test.ts, server/invitation-google.test.ts, src/invitations.test.tsx and src/google-invitations.test.tsx, plus the existing calendar, draft, mail and keyboard checks in the journey map. They cover encrypted local HTTP persistence, provider fakes, response loss and mounted paths. They do not prove native, installed, OAuth, provider delivery or real-account behavior.

Phase 4 can reuse event/account/calendar/provider IDs, invitation UID/sequence, source fingerprints, encrypted reviewed drafts and read-only unknown-operation reconciliation. Availability helpers are separate and unchanged. Offer times, free/busy integration, automatic booking and Calendar MCP sending are not part of Phase 3.

Offer times (phase 4)

The renderer shows saved preferences above an Edit preferences disclosure. Coverage remains outside it and names the selected accounts/calendars through privacy controls, or states that every Google calendar is excluded. Results group by the saved IANA zone's dates while preserving original slot indexes and the ten selection limit. shared/offers.ts formats both endpoint dates, times and numeric UTC offsets, including DST and date rollover, for both the preview and reply body. An optional recipient IANA zone is manually chosen, validated with Intl and used only for display. It resets when the source email changes; it never changes saved instants, check inputs or reply text.

Date-range, included-source and acknowledged availability saves clear the active preview and selection. Slot checkboxes leave the preview intact. New checks clear previous results before starting, including failed checks. Unconfirmed requests freeze their exact identity, source fingerprint and selected indexes. Only an explicitly rejected check permits changing its request. Acknowledged draft preparation is separate from ordinary composer Send. A missing draft supplies no delivery proof. Preparing offers never books; booking still requires fresh server validation and an explicit Book agreed time action.

server/offers.ts stores availability and checked offers in encrypted availability and calendarOffer records. The reader's Offer times form saves working weekdays, same-day hours, meeting length, buffer and a chosen IANA zone. Checking requires an explicit local-only choice or selected per-account Google calendars. Local-only excludes Google and states that limit. Opening mail or resuming a saved check makes no provider request.

Method and pathRequestResult
GET /api/calendar/offers?sourceMailIdCached source IDSource fingerprint, saved settings, Google catalog/consent state and same-conversation offers
PUT /api/calendar/availabilityAvailabilitySettingsValidated encrypted settings
POST /api/calendar/offers/checkUUID id, source ID/fingerprint, inclusive local firstDay/lastDay, explicit google selectionsSaved slots and request identity; identical replay returns the saved offer
POST /api/calendar/offers/:id/draftSource ID/fingerprint, selected slot indexesOrdinary encrypted reply draft, bound to source/account/thread
POST /api/calendar/offers/:id/bookSource ID/fingerprint, agreed slot indexOne local event after fresh validation; identical replay returns its saved event

A range covers at most 31 local dates and returns at most 200 future slots. Meeting length is 5–480 minutes and buffer is 0–120 minutes. Select at most ten Google accounts, twenty calendars per account and fifty calendars total. Each selected account needs fresh matching Google identity, free/busy consent and complete data for every included calendar. Requests expand the entire window by the buffer on both ends. Returned timeMin and timeMax must identify those exact instants; missing, invalid, truncated or mismatched bounds reject the result. Requests have a 10-second timeout, disabled retries and a 2 MiB response limit; a shared 60-second abort also covers identity and token refresh. Busy periods are bounded to 10,000. Any failed read blocks results, never implies free time.

All active, non-declined local events across accounts contribute busy time. All-day ends stay exclusive and convert using the chosen zone, including DST and date-line offsets. The unchanged availability helper rejects ambiguous or nonexistent slots. Up to ten selected times become a plain reviewed reply with the zone and a warning that times are not reserved. No public booking page, automatic send, reply interpretation or Google write is added.

Rejected checks can explicitly change their inputs. Unconfirmed checks retain the UUID and exact input for recovery; persisted checked offers without drafts can resume after remount. Unconfirmed draft preparation freezes the selection for an identical retry. Saved draft/event identities are reused; sent or removed drafts and removed booked events are never recreated. At most 500 offers persist. Account removal clears its offers. Availability changes require a new check.

Before booking, revalidate source, conversation, account, settings, Google identity and all required busy reads, then check current local events and save the event and replay identity atomically. Booking is explicit and local. The existing Phase 3 controls separately prepare invitations or notify through Google. Event details open in the reader; Calendar retains Edit and Delete.

Focused synthetic checks are server/offers.test.ts, src/offers.test.tsx, server/google-calendar.test.ts and src/availability.test.tsx, plus affected calendar, draft, provider, mail and keyboard regressions in the journey map. They cover HTTP persistence, response loss, remount recovery, replay safety, privacy and synthetic narrow/focus behavior. They do not prove installed layout, physical input, real consent, provider access or delivery.

Status-first startup

Authenticated /api/bootstrap returns status without initializing the mail index or reading mail/draft bodies. It shares account/configuration status fields with /api/state, excluding mail and cursor/arrival fields. The client starts both requests together and sets CSRF before status readiness. Full mail and its cursor remain a single complete snapshot. Unknown mail counts stay hidden; mail lists and source-dependent workspace details remain loading until that snapshot arrives. Workspace reads may prefetch after status. A bootstrap error falls back to full state. Full-read failures offer Retry loading mail; auth failures reset renderer readiness/cursor/CSRF, without discarding drafts or altering compose sessions. Unmount aborts startup reads. Desktop mail events retain the existing reload path. Bootstrap does not replace browser focus/visibility refresh. See ADR 0087.

Focused checks: server/bootstrap.test.ts and src/bootstrap.test.tsx, serially through the bounded runner. For matched 34,000-message synthetic measurements, run node --import tsx scripts/benchmark-bootstrap.ts through the bounded runner with 3072 MiB and a 420-second deadline. It reports process-cold API status and full-mail availability separately. It does not prove browser paint, real-provider behavior, installed performance or an immediate complete mail list.

Encrypted connection settings and storage compaction

server/config.ts stores managed Google OAuth, OpenAI and Typesafe settings in the encrypted config record using the existing installation key. Startup commits and reads back migrated values before replacing the legacy environment file with its unmanaged entries. Launch-environment values retain precedence. A failed cleanup retries later; no plaintext backup is created. Earlier copies and backups are not securely erased. Existing databases with missing or invalid keys fail closed. See ADR 0057.

Plaintext-cleanup validation compares the parser's own key/value entries. It accepts ordinary and null-prototype dictionaries, including Node 26 output, without changing encryption readback, launch precedence or cleanup atomicity. Regression cases exercise both parser shapes, empty and missing files, special property names, multiline settings and failure recovery.

Authenticated POST /api/storage/compact requires the existing desktop token, origin and CSRF checks and a strict {"confirm":true} JSON body. It pauses admission, drains admitted mail/provider work and checkpoints WAL before conservative orphan attachment cleanup and SQLite VACUUM. All retained mail views supply exact attachment references. Unknown record kinds or incomplete metadata skip deletion; corrupt encrypted records abort cleanup. Legacy drafts remain inline. New uploads use encrypted chunk records and immutable references. Busy readers fail before deletion. Cleanup and vacuum are separate transactions, so a failed vacuum can follow successful orphan removal. Surviving ciphertext and installation keys are not rewritten by vacuum. The route resumes mail work after success or failure.

The service also owns an automatic five-minute maintenance probe. It requires no queued or active admitted mail work and 60 seconds since the last completion. Automatic vacuum requires at least 16 MiB of free pages and a free-page ratio of at least 20 percent, rechecked after cleanup. Attachment inventory is due independently every 24 hours; its timestamp is encrypted and survives restart. Daily cleanup does not force vacuum on a healthy database. Successful attempts wait six hours. Failures back off from 30 minutes to six hours. Attempt cooldowns are process-local. Updates and shutdown defer maintenance; failed drains resume ordinary admission. Closing the timer cannot cancel an active synchronous vacuum. Explicit Calendar sync participates in this activity and drain boundary. Automatic failures and conservative cleanup skips emit one allowlisted diagnostic per attempt; raw errors and mail data are excluded. Healthy attempts remain quiet, and a diagnostic reporter failure does not change backoff.

Regression cases are in server/config.test.ts and server/storage-maintenance.test.ts. Native, power-loss and installed acceptance remain separate from synthetic source verification.

Default explicit compaction now skips vacuum when the post-cleanup SQLite freelist is empty. Internal vacuum: true callers retain forced partial-page repacking. Automatic maintenance thresholds are unchanged. Summary reads copy the envelope and mutable metadata children; an unchanged valid cursor avoids scanning the complete metadata map. Neither change rewrites mail or changes the storage format or API contract. See ADR 0086.

server/startup-storage.test.ts checks metadata isolation and healthy compaction alongside account-scoped search, cursors, ciphertext, keys and draft preservation. For matched synthetic startup measurements, run node --import tsx scripts/benchmark-web-startup.ts through the bounded runner with 3072 MiB and a 420-second deadline. It owns and removes a fresh temporary profile and defaults to 34,000 messages across four accounts. Its HTTP timing measures a warm server snapshot on loopback; cold metadata initialization, serialization and compaction are reported separately. Keep runtime receipts outside the repository. It does not measure browser paint or network latency.

server/state-response.ts is a separate, unmounted transport candidate. It negotiates gzip above 16 KiB, keeps tiny deltas plain, preserves decoded JSON, and uses a separate stored status member so CSRF never enters the compressed message dictionary. server/state-response.test.ts exercises negotiation, decoded equality, auth ordering, disconnects and serialization failures using an ephemeral synthetic HTTP service. Route integration and browser/proxy acceptance remain separate work. scripts/benchmark-state-transport.ts measures wire bytes, loopback time, decode time and process CPU on synthetic envelopes; run it through the bounded runner with 2048 MiB and a 120-second deadline.

server/mime-limits.ts caps raw messages at 8 MiB, Gmail responses at 12 MiB, MIME header size at 64 KiB and parser children at 128. Two parsers may run, with a bounded waiting queue. Decoded content validation follows parsing; it does not replace the raw transport and parser bounds. Oversized messages report an explicit failure while previous cached mail remains available. Image downloads use incremental byte limits. Decorative memory caches have entry/byte budgets and expiry; disposable logo files are trimmed to 256 entries and 16 MiB. Resource limits do not change mail retention. Attachment upload and reference limits are described below.

Search documents use a 256-entry LRU cache with a 4 MiB accounted-string budget. The budget charges two bytes per UTF-16 unit for identity and raw, canonical and folded searchable strings. Oversized documents remain uncached. Entries retain no full mail objects, HTML or attachments. This bounds retained logical payload, not V8 heap or transient normalization allocations.

Storage and memory measurements

Task #92 measured pristine e166227 against the initial security/storage layer 7227d06 on Linux x64 with Node 24.22.1, Electron 44.7.0 and electron-builder 26.17.0. Each size used one paired run, baseline first, with identical synthetic mail. The first mail-data call was the default snapshot: full mail before, summaries after. Candidate runs created missing encrypted summary companions during that cold call.

MessagesCold Node RSS before / after, MiBCold snapshot before / after, msClosed-WAL database before / after, bytes
10066.08 / 69.485.03 / 14.98901,120 / 1,011,712
1,00099.57 / 85.4536.77 / 89.768,916,992 / 9,973,760
5,000224.14 / 119.86139.14 / 396.5344,544,000 / 49,831,936

At 5,000 messages, snapshot JSON fell from 32,682,613 to 2,307,613 bytes. Exact search returned identical result identities. After deleting alternate messages, candidate compaction reduced the database from 49,831,936 to 24,924,160 bytes, with a zero-byte WAL and no free pages. Surviving source ciphertext and keys remained unchanged. This reclaims deleted pages and does not compress live mail. Summary companions increase live storage, and first initialization is slower. The 100-message cold RSS also increased. Node RSS does not measure Electron renderer, GPU or whole-app memory.

Complete unpublished Linux x64 packages used identical production dependencies. Unpacked regular-file bytes were 376,723,623 before and 376,759,262 after. app.asar grew from 80,415,980 to 80,451,619 bytes; each delta is 35,639 bytes. Both archives contained all 215 production dependency versions. The candidate passed sandbox-enabled packaged startup using a fresh synthetic profile and the documented Linux local-key fallback. These totals are not compressed installer size or installed disk allocation.

The candidate passed 1,016 tests, strict build and MCP checks. Synthetic active vacuum interruption and reopen preserved integrity, ciphertext, keys and uncertain drafts. Native macOS/Windows, installed acceptance, real providers, whole-app RAM and power loss remain unverified. These single synthetic runs support the stated observations, not a general performance guarantee.

Privacy scanning allocations

Ordinary text without a percent sign uses direct UTF-16 offsets. Text containing percent signs retains the existing decoder and source-offset mapping. This avoids per-character mapping pairs without caching bodies or changing privacy ranges. Focused privacy, formatting, image-consent and reader checks passed 90 tests with the strict build. In controlled ordinary-text fixtures, mapping pairs fell from 246,000 and 150,045 to zero; median scan times changed from 12.776 to 1.183 ms and 9.667 to 1.204 ms. The percent-encoded control retained its mapping allocations. These synthetic Node allocation and CPU observations do not establish lower Electron or installed macOS memory.

Combined stack verification

The integrated automatic-maintenance and privacy candidate a69cbea passed 1,032 tests across 110 files, a fresh strict build, MCP checks, sandboxed source startup, complete unpublished Linux packaging and sandboxed packaged startup. All eight checks returned both payload and Fleet exit zero with active memory, zero-swap and deadline limits and cleanup captured. Independent source review resolved both Calendar-drain and maintenance-diagnostic findings without new actionable findings.

The final complete unpacked package contains all 215 production package versions and 351 dependency edges. Its logical regular-file size is 376,764,296 bytes, 40,673 bytes above the verified e166227 baseline. app.asar is 80,456,653 bytes. These replace the earlier layer's package totals for the integrated candidate; source-only privacy timing is still a separate experiment.

A separate passive Electron comparison did not produce an accepted paired result. The candidate completed its three-minute startup/idle and native hide/show sequence, but Fleet returned exit 125 at cleanup confirmation. Later empty-scope observations do not clear that failed containment result. Those Linux runs establish no whole-app or per-process RAM reduction. The comparison used normal Today state in an external default-app wrapper, not installed macOS interaction. It did not exercise mail-list navigation or the five-minute automatic maintenance probe. Native, installed, real-provider and power-loss acceptance remain unverified.

Native Mac memory validation

Native arm64 builds of e166227 and 57a300c used Node 26.12.1, npm 11.20.0, Electron 44.7.0 and Xcode 27.1. Both passed strict builds, unpublished packaging, deep strict ad-hoc signature checks, dependency closure, packaged startup and native Keychain protection. No installed app was replaced.

Two valid paired runs used 1,000 synthetic messages; three used 5,000. Each ran the unchanged production ASAR through the same external default-app wrapper, with 120 seconds idle, five native hide/show cycles and final idle to 180 seconds. Readiness required the exact snapshot count, Inbox badge, Today view, completed sync and zero body fetches. External provider traffic was blocked. The viewport and appearance were matched. The normal Today view had zero mail rows, so these runs measure retained state rather than mail-list interaction.

Median final RSS across valid paired runs, in MiB:

MessagesProcessBaselineCandidate
1,000Main242.26227.26
1,000Renderer120.22110.74
5,000Main380.25212.12
5,000Renderer166.19112.82
5,000GPU82.0970.78
5,000Utility52.5545.61

The sum of measured process physical footprints fell by 24.96%, 27.62% and 23.07% in the three 5,000-message pairs, a median decrease of 24.96%. This is repeatable within this synthetic larger-cache workload. The 1,000-message physical-footprint changes were inconsistent at -1.82% and +7.77%; no consistent overall saving is established for that size. RSS includes shared pages and must not be interpreted as unique physical memory.

Every accepted arm passed payload exit, native supervisor cleanup, mail ciphertext, protected-key, identity and stable-account preservation checks. An interrupted arm was excluded. No completed passing pair was rerun. The original installed bundle was unchanged and reopened normally after each batch. All owned process groups and temporary LaunchAgents were removed.

The wrapper reports app.isPackaged=false; packaged startup is separate proof. Interactive reads, long-running sync, the five-minute automatic-maintenance probe and exact installed-build performance equivalence remain unverified. These observations do not establish a general RAM guarantee or explain every large installed-profile footprint.

Native loaded-mail and automatic timer checks

A later native arm64 check used the same unchanged production ASAR with 5,000 synthetic cached messages and 3,333 Inbox messages. Ten reader cycles kept 13 mounted mail rows and passed exact body/version and search checks. Privacy image-consent reset and reply-draft keep/recovery passed without sending. These were candidate-only functional runs, not a new paired RAM comparison.

The unchanged five-minute timer ran after about 302 seconds without a manual compaction request or clock override. The synthetic database went from 29,576 pages, including 18,240 free pages, to 12,174 pages with none free, at 4,096 bytes per page. It reclaimed 71,278,592 bytes. All 5,000 mail ciphertexts, three live attachment records, protected key and identity, and an uncertain draft at revision 7 with its inline attachment survived. Only the seeded orphan was removed, and the inventory timestamp was persisted encrypted.

Post-maintenance local body and search requests and the Drafts list passed. Reopening the draft in that same native run remains unverified because the owned test window lost focus. All owned processes were cleaned up and the unchanged installed app was reopened. These synthetic checks do not prove real-provider behavior, power-loss durability or installed-candidate interaction.

Reader offer-context requests validate the source, then filter saved offers by account and case-insensitive recipient before constructing a conversation. If none match, they skip the full-mailbox scan. HTTP regressions count encrypted mail reads and retain source validation, conversation filtering, offer ordering and booked-event coverage. This removes avoidable work; its whole-app RAM effect requires a separate matched runtime comparison.

Optional outbound rich text

ADR 0070 defines the outbound subset. The composer starts with plain text. Applying formatting stores optional DraftContent.html beside its matching plain-text body in the existing encrypted draft record. No schema migration or settings reset is required. Draft snapshots, equality, recovery, separate copies and uncertainty markers retain both alternatives. Calendar send validation compares both against the reviewed saved snapshot.

shared/compose-content.ts owns outbound serialization, independently of the inbound renderer. Canonical HTML allows div/p/br, strong/em/u, safe HTTP(S) or mailto links, ul/ol/li and blockquote. It strips unsupported wrappers, active subtrees and all attributes except validated href, with limits of 256,000 code units, 4,000 parser events and depth 48. The draft/send boundary rejects noncanonical HTML or mismatched text instead of silently normalizing a saved snapshot. Plain alternatives include link destinations, list markers and quotation prefixes. Resource-loading markup and CSS never leave the composer.

Formatting uses the Electron editing commands to preserve native undo; support is checked before leaving plain mode. Selection is retained across toolbar activation. Paste inserts plain text, and drop does not insert HTML. Programmatic templates/signature changes retain unchanged leading rich content. A partially changed structural node uses its plain text instead of inventing formatting. Native physical editing and recipient-client rendering require separate proof.

Both Gmail and SMTP pass text and optional HTML to MailComposer. SMTP Sent copies retain the exact transmitted bytes. Gmail API and gmail-imap retain their existing no-manual-append behavior. Delivery acceptance followed by a Sent-copy failure remains a success with a warning. MCP replies and agent proposal contracts remain plain text. Tables and embedded images are excluded.

The shared footer track is at least 42px high, with 6px clearance around the 30px Send button, matching New Message. Sidebar, collapsed coverage and compose actions align to this track. Compose observes only its action row, adds 12px, and applies the height to its app instance; unmount disconnects the observer and removes the temporary override. Recovery controls stay above this row. Original-message scrolling stops above the footer. A pointer-transparent app-wide divider aligns visible columns during composition. Sender identity is shown in From; no duplicate sender/format footer row. In Drafts, Delete draft and Close share the account-tabs row and remain inside the compose keyboard cycle. Compact fields are 34px before separators.

New Message and Send use a 140ms transform/overlay-opacity press response. The glow is transient, does not change layout, and is suppressed for disabled controls. Reduced motion removes the movement and transitions. MCP and agent proposals explicitly use strict plain-only schemas; widening the UI draft/send HTML schema does not widen these separate tool boundaries.

Coverage footers span the full pane width. Their horizontal inset cancels Calendar content padding at each breakpoint; Today/Projects already use zero content inset. Expanded details keep 12px inner padding and existing controls.

Draft rows place recipient/account, subject/preview and time in compact columns. Narrow lists reflow without truncating uncertain-send warnings. Refresh shares the account-tabs row; the healthy saved-count banner is visually hidden, while loading, cached/unavailable and open failures remain visible. Account/draft identity, privacy and saved data are unchanged. See ADR 0077.

Saved draft rows offer a direct Delete draft action on pointer hover or keyboard focus, always visible on touch. Confirm before deletion, including the existing unknown-send warning. Use the exact account, draft ID and saved revision; remove the row only after confirmed success. Retain failed rows with refresh guidance, refresh the sidebar count and return focus to the next row or Refresh. Rows reserve delete-button clearance at every width and pointer type so dates and attachment counts remain visible. Account labels and colour dots match the inbox; privacy-safe custom names remain visible while addresses stay hidden.

Workspace proposal refresh memory

Workspace and proposal reads select the union of source conversations using cached envelope summaries, then hydrate only related full mail records through one existing SQLite WAL snapshot. Selection does not yield before establishing the snapshot. Thread and Message-ID reference indexes remain separate and account-scoped. Full-record fingerprints, source-change warnings and synchronous approval/execution revalidation retain their prior fields and ordering. Missing sources remain changed; external writes invalidate metadata before selection.

A matched synthetic HTTP workload with 3,000 large cached messages, one proposal and one related message decoded 3,000 bodies before and one afterward. One run measured 22,218ms elapsed / 20,548ms CPU / 528.4MB peak heap before, versus 20.0ms / 29.4ms / 47.2MB afterward. Process max RSS was about 753MiB versus 231MiB. These are workload-specific observations, not startup guarantees or proof of every native memory spike. Settings, encryption and stored records are unchanged. See ADR 0072.

Compose navigation and input ownership

The nonmodal compose backdrop is pointer-transparent, while the composer itself accepts pointer input and covered mail/work panes remain inert. Sidebar and exposed toolbar controls share its explicit Tab cycle. Back/Forward, sidebar view/account/Settings changes, New message, Add account, Commands and Filters first drain attachments and flush the current draft using the existing shared lock. Only a successful save queues the requested action. A fresh post-close render consumes it; failed or uncertain saves keep the text open. Forward at a history edge does not close a draft. No automatic send or draft deletion occurs.

Mouse side buttons work over composer fields; native selects/menus retain their own pointer handling. Native history IPC remains the sole side-button source on Windows/Linux. Safe shortcuts work from nonediting compose controls; text fields, dropdown arrows, rich editing and native undo/copy/paste retain ownership. Search and Privacy remain usable without leaving composition. Other modal dialogs keep their existing navigation and focus guards. No saved interaction preferences change. See ADR 0073.

Attachment reads finish before edit freezing; failed reads retain the composer and error. Inert descendants are excluded from the explicit keyboard cycle. Insert link uses a compact inline address field with Add link and Cancel link. Enter applies the validated link without submitting email; Escape cancels only link entry. Selected text, plain alternatives and safe URL rules are retained.

The composer From selector uses the shared themed dropdown: a 30px trigger, compact account rows, arrow/Home/End selection, Enter to confirm and Escape or Tab to dismiss. Private mode omits account addresses. Account switching retains the existing signature and saved-copy behavior.

The composer template picker is a combobox inside the draft. Typing filters templates by name, subject and body; arrows move the highlighted option, Enter or Insert template applies the existing insertion and Escape closes only the picker, returning focus to the message body. Clicking an option also inserts it, and a click outside closes the list. Insertion still appends before an unchanged signature and fills only an empty subject. Privacy mode keeps generic numbered labels and blurs the unfocused field like other compose inputs.

Formatted original messages in replies

New replies and reopened saved drafts use the same ReaderContent allowlist, content recovery and opening-specific image consent as the reading pane. Cached HTML keeps link labels instead of displaying plaintext tracking URLs. Rejected or unavailable HTML uses the original plain text without dropping it. Privacy clears image consent; changing source identity/content remounts the original-message view. Draft content and existing settings are unchanged. See ADR 0074.

Summary-first cached search

The shared search compiler can prove header matches or misses from summaries; queries involving unknown body text remain unresolved. Conjunctions, exclusions, quoted text, account filters and invalid operators retain their exact rules. The server decrypts only unresolved records in the existing coherent WAL scan. Header-only searches need no body reads. Known summary matches remain visible while body searches run and after source-version refresh; unresolved hits still require the exact account/message/content-version response. Empty pending views show a searching state rather than claiming no matches. No index migration, plaintext search storage or preference changes are introduced. See ADR 0075.

Global cached-mail and draft search

The search status names the cached view, account, read state, relevance, label, sort and saved-draft coverage, and announces result counts. Account and label text use existing privacy masking. Empty filtered searches offer an explicit widen action that keeps the query, account and sort, clears only narrowing filters and opens All cached. Back restores the prior scope. Reset filters and sort remains separate. Widening reads cached results only; it never requests provider history. Synthetic coverage: src/mail-search-scope.test.tsx and the search-widening case in src/navigation.test.tsx.

The toolbar owns immediate input text and commits result updates after a 120ms pause. Navigation, disabling and unmount cancel pending input; Clear and Escape cancel it immediately. The search hook reuses equivalent source identities and summary matches. Header-only queries still avoid mail-body decoding. Draft and mail matches use the same encrypted WAL snapshot and query compiler.

New searches include every connected account's imported mail, including Sent, Archive and Snoozed, plus saved local drafts. Coverage remains the local cache, not complete provider history; an explicit older-mail search for one selected account extends it (ADR 0081). Clear and Escape remove the query while retaining the current view and filters; the next nonempty query starts globally again. Back/Forward restores scope and query together. See ADR 0077.

Draft text search includes the complete saved body, account sender identity, To/Cc and subject. Date filters use the last saved time. Read/pin predicates, including negations, and mail-only filters exclude drafts. Draft matches have a separate section and keep their own recovery and uncertain-send controls. Draft-control focus keeps mail shortcuts and traversal from acting on another selected message. Search and Privacy remain global. Search returns summaries without attachment bytes. Superseded responses and stale draft openings must not replace the current search or compose session.

Verification is tracked in task #42. Synthetic results, native interaction, installed-app acceptance and provider coverage are separate evidence.

Automatic browser sync starts only after the full cached-mail snapshot loads. Bootstrap status alone does not start it; a failed initial read keeps it paused until a successful retry. Regression coverage: src/bootstrap.test.tsx. Release authorization: the active release workflow publishes eligible main pushes automatically. Use [skip release] in a PR title to suppress publication from its squash merge when release approval is held. Skipped CI is not passing verification. Browser rendering also checks access before mounting private content and every 10 seconds afterward. Confirmed revocation removes mounted mail and drafts. Temporary network or HTTP failures conceal private content and retry; successful reconnection restores the existing mounted draft. Hidden tabs conceal content and recheck access on return. The access page broadcasts explicit locking to other tabs on the same origin. Checks of session status do not renew request-idle expiry. Browser-session loss can discard unsaved edits; it never sends mail or retries an uncertain send.

Phone browser navigation

Below 760px, the sidebar opens as a drawer above the working pane. The toolbar keeps its toggle visible and puts search on a second row. Choosing a view, account, compose action or Settings closes the drawer. The backdrop and Escape dismiss it; phone drawer state does not replace the saved desktop sidebar preference. Custom themes remain available under Settings > Appearance, with phone-sized color controls and wrapping editor rows. src/navigation.test.tsx covers phone navigation and custom-theme persistence.

Browser pages refresh local state on focus, when becoming visible and every 15 seconds while visible, even with no connected accounts. This reads the existing state endpoint; it does not trigger mailbox writes. Desktop windows retain their event subscriptions.

Startup summary copying and compaction

Summary reads copy their envelopes and mutable metadata children; an unchanged valid cursor avoids scanning the metadata map. Default explicit compaction skips vacuum when the post-cleanup SQLite freelist is empty. Internal vacuum: true retains forced partial-page repacking. Automatic thresholds, encryption, attachment inventory and snapshot/cursor semantics stay unchanged. See ADR 0086. Synthetic regression: server/startup-storage.test.ts. Run scripts/benchmark-web-startup.ts only through the bounded runner with synthetic profiles.

Agent review fills its working pane rather than capping the review grid at 1120px. The existing narrow-screen single-column layout and review controls remain.

Browser API integration authenticates each request with either the desktop per-launch token or the expiring browser gate session. The gate replaces the earlier terminal session link; it does not add a second sign-in step. OAuth start remains authenticated and CSRF-protected, single-use launch/state records remain capped, and the approved browser origin is retained through callback. server/browser-session.test.ts uses a temporary synthetic profile and ephemeral port to verify login, refusal, CSRF, origin and lock behavior without using another service's listener. Phone access uses verified private Tailscale HTTPS with the backend bound only to loopback; a high port alone provides no encryption.

Mobile browser layout

Below 760px, Settings uses a native section picker while desktop retains the section rail. The picker uses the existing section navigation and save guards. Mobile Settings fills the available viewport, descriptions wrap, and form text remains at least 16px. Reply context stacks above the editor and remains independently scrollable. Touch controls, including the primary New Message action, target 44px and respect browser safe areas. Responsive CSS loads after application styles so compact desktop rules cannot override these sizes. The viewport requests content resizing for supported Android keyboards. src/navigation.test.tsx covers the phone picker, drawer and theme persistence with synthetic state; actual keyboard and screen geometry require device verification.

Changelog and release notes follow CONTRIBUTING.md. Versioned entries identify release changes; legacy development notes preserve the earlier unversioned history. The release owner verifies the published body matches the version section before declaring publication complete.

Forward and send recipient contract

POST /api/messages/:id/forward prepares an encrypted draft from the cached message. It copies plain text (or cached preview), From/Date/Subject/To/Cc, and locally available attachments within send limits. Every omitted attachment is listed in the body. Sender HTML, remote resources, original threading and Bcc are not copied. Forward uses a new Fwd: subject and empty To. The reader, Commands menu and F shortcut expose the action. No provider fetch or send occurs. Forward admits one preparation request at a time. Its completion can open only in the originating compose generation while the source and account remain available and installation has not started. A newer composer stays open; the prepared encrypted draft remains available in Drafts without repeating the POST.

The payload inside POST /api/send requires accountId, to (1..100 email addresses), subject and nonempty body. The enclosing queue request also requires the saved sourceDraftId and sourceRevision. Optional cc and bcc each allow 0..100 addresses. Existing optional HTML, attachment, calendar and reply/thread fields remain. Drafts permit incomplete recipients. MCP draft inputs and plain send proposals accept the legacy single-address To form and normalize it to an array. Agent approval still creates a draft, never sends. CLI proposals follow that contract.

SMTP MIME excludes Bcc; the nodemailer envelope includes To, Cc and Bcc. Gmail API raw MIME retains the Bcc header because Gmail derives recipients from MIME. Recipient-visible Bcc stripping is Gmail's responsibility and is unproven by synthetic tests. SMTP Sent copies use the same Bcc-free bytes as submission. Partial SMTP acceptance returns a sent warning without recipient addresses or provider details. Accepted recipients are never retried. See ADR 0083.

Synthetic checks: server/forward.test.ts, server/compose-recipients.test.ts, server/rich-send.test.ts, server/drafts.test.ts, server/workspace.test.ts, src/compose-layout.test.tsx, src/compose-recipients.test.tsx, src/draft-session.test.tsx and src/compose-send.test.tsx. These cover local contracts and recovery. Installed UI, real providers and recipient delivery need separate acceptance. Frozen snapshots and unknown-send recovery retain the ADR 0038 sequence; there is no automatic resend.

Scheduled send

Send later in the composer offers Tomorrow morning, Monday morning and a custom local date/time using the existing follow-up preset helper. Send still freezes and acknowledges the exact draft before queue admission. The optional scheduleAt timestamp on POST /api/send must be in the future and within one year. The encrypted record retains scheduledFor and uses it as dueAt, with the same committed pending/submitting/sent/failed/unknown transitions as Undo send. Schedule replay is idempotent for the original payload, revision and time.

Open Scheduled from Commands or the persistent scheduled-mail notice. It shows times and delivery state without mail content. Edit cancels the pending attempt and restores the exact draft in compose; Cancel schedule saves that draft in Drafts. Reschedule changes only the pending entry's timestamps, preserving its identity, source revision and frozen snapshot. POST /api/outbox/:id/reschedule accepts { at }. Active and terminal attempts cannot be rescheduled. An unknown Edit retains the duplicate-risk warning before another Send. Ctrl/Cmd+Z remains owned by immediate Undo send and never cancels scheduled mail.

Emc² must be running and online to send. All pending mail, including future schedules, is conservatively held for review after restart, sleep or ordinary quit/update drain. A timer gap over two seconds or a backwards clock change holds pending mail. Native suspend/resume events also hold it. When a due schedule sees the operating system offline, it is held; reconnect never rearms it. Review offers explicit Send now, Edit, Cancel schedule or Reschedule. The visible scheduled-mail notice calls out held or unknown outcomes. There is no new background service, cloud credential hosting, automatic retry or recall.

Desktop uses Electron's online signal; the standalone service checks for a non-loopback network interface. These signals do not prove provider reachability. A failed or lost provider response retains the existing conservative unknown classification. Each dispatch rereads the entry and commits submitting before awaiting provider work, so a concurrent due tick and user action cannot admit the same pending entry twice.

ADR 0093 records this extension. Focused synthetic checks are server/scheduled-send.test.ts, src/scheduled-send.test.tsx, server/outbox.test.ts, server/outbox-http.test.ts and desktop/main.test.ts. Real network transitions, OS sleep, installed delivery and native acceptance remain separate proof.

Unread and Inbox restoration

The serialized single-message PATCH route accepts unread and inbox. Gmail adds UNREAD or INBOX. IMAP resolves archived mail by its exact Message-ID before restoring it; UIDPLUS, a unique flagged archive folder and MOVE are required. Gmail over IMAP copies from All Mail instead. Destination identifiers map back to the same local ID during sync. Shift+U and V are editable defaults, preserving U for return-to-list and B for snooze. Commands and message actions use the same eligibility checks. An open reader stays unread after the action. See ADR 0095.

Bulk mail triage

POST /api/messages/batch accepts 1 to 200 unique {id, accountId} items and one existing operation: read, unread, archive, inbox, snooze, pin, unpin or trash. Each item runs sequentially through updateMessage and rechecks its account inside mail serialization. Results contain the same identity and done, failed or unknown status. Exceptions after provider work starts are unknown, never automatically retried. Missing responses become unknown in the renderer.

The list's bulk selection is independent of its opened message. X is editable; existing custom bindings keep priority. Shift+arrows and Shift-click select a range. Platform select-all includes unmounted virtual rows in the current filtered list up to 200. Selection clears on view/filter changes and prunes missing or account-changed mail. Fields, dialogs and opened readers keep their keys. Trash confirms the captured count; results identify affected items with privacy-redacted subjects. See ADR 0089.

Inline cached conversation reader (#54)

Conversation history diagnostics, keyboard guidance and bounded Gmail fetch live in collapsed Conversation details below the stack. Errors remain visible above messages. Message headers separate sender/date from preview and active/unread state; Select for actions stays outside the expansion button. Row hover actions show Mark as read for unread mail or Mark as unread for read mail, with an envelope for the latter. The full context menu retains both guarded operations.

src/mail/ConversationReader.tsx reuses GET /messages/:id/conversation and POST /messages/:id/conversation/expand. Membership comes from the existing account-scoped service model, never from subjects. Order is oldest first with ID tie-breaking. Cache counts and reported gaps do not establish complete history. Gmail fetch remains capped at 100 messages; IMAP has no remote expansion.

Malformed response envelopes or message entries retain the opened mail with an unavailable-evidence notice. Late results from another opening cannot replace the current conversation or trigger its refresh. An expansion failure retains the last valid cache and makes no automatic retry.

Reader.tsx preserves the existing single-message reader for each expanded entry. Only the selected message receives callbacks bound to the App target. Other entries expose their own context menus and Select for actions, which uses the existing opening and mark-read operation. Collapse unmounts content and resets image consent; privacy applies independently to every entry. App forwards conversation menus without changing the selected reader, so its navigation cleanup does not close them. Closed-list menus, bulk selection, custom shortcuts and provider contracts are unchanged.

Focused synthetic coverage is in src/conversation-reader.test.tsx, src/reader-content.test.tsx and src/today-workflow.test.tsx; existing image, formatting, privacy, navigation, snooze and conversation-service checks remain relevant. These checks do not prove browser, native, installed or real-provider behavior. See the reader decision.

When inline conversation reading is combined with Forward, only the selected message receives the callback bound to the current action target. Other expanded messages must first use Select for actions.

Local mail Undo

Single-message Pin/Unpin and Snooze/Return confirmations offer Undo. Commands keeps Undo last action available when confirmations are off and independently of the selected message. Batch, archive, trash and send actions have no Undo. Editor Ctrl/Cmd+Z remains native.

PATCH /api/messages/:id may return undo: {id, accountId, token} for local actions. POST /api/messages/:id/undo accepts {accountId, token}. The signed receipt binds the original identities, prior local field and post-write encrypted row version. Serialization and a SQLite writer transaction check and restore atomically. Changed, replaced, replayed or restart-invalidated receipts return 409 without writing. Any intervening rewrite, including sync or body refresh, can conservatively invalidate Undo. A lost response is not retried; refresh mail before acting again. No provider request is made by Undo. See ADR 0091.

Accessible account warnings

Sidebar account warnings are separate keyboard-reachable buttons. Their names include the privacy-safe account label and fixed status text. Activation opens Settings, Accounts and focuses the affected account row without retrying. Settings names queued/running sync, connection failure and cleanup warning states without rendering raw provider errors or failure codes in text or tooltips. Connected means a connection exists, not that all provider history is cached. The global sync time remains global. Retry connection remains an explicit action. Synthetic coverage: src/account-status.test.tsx and account-warning cases in src/navigation.test.tsx.

Visible persistent mail density

Settings, Appearance exposes Comfortable and Compact spacing beside Reading pane. It shares App's density state with Commands and the configured density shortcut. Explicit changes save emc2.density on this device; startup reads the saved value without writing it. A failed save keeps the session value and shows a notice. Density changes spacing independently of importance sizing and text scale. The virtual list retains existing wide and narrow row-height tokens, including 72px compact cards. Density and text-scale changes scroll the selected row into view without taking focus from Settings or editors. Passive scrolling and mail refreshes retain their existing behavior. Synthetic coverage is in src/list-windowing.test.tsx and the persistent-density case in src/navigation.test.tsx.

Browser build updates

Browser session checks and private-route HTTPS probes allow up to fifteen seconds for a response. Polling does not supersede a running check. Returning to a visible tab invalidates any earlier response and queues fresh verification before showing retained mail. Explicit revocation still removes retained mail immediately; failed checks hide it while preserving mounted drafts. Private-route admission still requires successful verification and the existing sixty-second proof lease. Renewal schedules from that lease rather than a fixed interval, reserving time for CLI checks and the first TLS probe even after a slow previous verification. API origin denials return JSON with a readable error; access-page denials remain text.

npm run build:web stamps the built HTML with a public, deterministic asset identity. Admitted /auth/session responses expose it in X-Emc2-Build with no-store; the renderer compares it with the loaded HTML, including its first status check. The update notice defers reload for active mail editing, Settings, work views, saved-draft lists, hidden tabs, pending writes, attachment downloads and previews, and uncertain outcomes. Bulk action results containing an unknown outcome also suspend notice-triggered refresh for the page session, preserving the per-message outcome for review. Navigation survives only the update reload through a validated, one-use, two-minute tab-local record. No mail bodies or drafts enter that record. Host installation and persistent sign-in remain separate. Synthetic proof: src/browser-update.test.tsx, src/browser-session.test.tsx, the browser-update case in src/navigation.test.tsx, server/browser-access.test.ts and scripts/browser-build.test.mjs.

Phone pairing and access pages

Sign-in and pairing pages use a self-contained stylesheet at /auth/style.css, with light/dark colors, responsive spacing, keyboard focus and touch controls. They load before application assets and retain the restrictive content policy. Remote sign-in explains passkeys and first-time setup; recovery passphrases remain restricted to the configured localhost origin.

The private access page also offers Connect this device. It creates a cookie-bound phone request, visible in localhost Settings. A competing remote browser cannot replace an active request. The host can approve its matching code or cancel it. This entry point grants no access before passkey verification.

Settings > Phone access > Connect phone returns a QR and a direct setup link for the same one-use invitation. Open the link on the target device, or scan from another screen. Both require the matching-code host approval and a user-verified passkey. Privacy hides both invitation forms. Reloading the claimed pairing page resumes through its HttpOnly claim cookie. Unopened invitations expire in two minutes; claimed setup expires in ten minutes. See ADR 0096.

Synthetic regressions: src/phone-auth.test.ts, src/phone-pairing.test.tsx, server/phone-passkeys.test.ts and server/browser-access.test.ts. Native passkey prompts and live phone acceptance require separate confirmation.

Remember paired browsers

Paired browsers use persistent Secure HttpOnly Strict cookies with random 256-bit tokens. Only SHA256 token hashes are saved in the existing encrypted phone-credential record, with up to eight browser hashes per passkey. Trust is bound to that credential and exact verified private HTTPS origin. There is no server idle or total timeout for this trust. A browser restart or host restart retains it; route loss suspends admission until route proof returns. Explicit Lock removes the current browser hash. Removing a credential revokes every browser using it. Local password sessions retain their existing timeouts. Browsers cap cookie lifetime at 400 days, renewed on normal authenticated use. Older paired browsers unlock once with their existing passkey to adopt trusted cookies. No new host pairing is needed. See ADR 0097.

Regression checks include server/browser-trust.test.ts, server/phone-passkeys.test.ts and src/browser-session.test.tsx.

Encrypted workspace backup

Settings, About and storage mounts WorkspaceBackup. The desktop bridge has only workspace-backup-open and workspace-backup-save, with native dialogs, main-frame sender validation and a 128 MiB file cap. Paths stay in Electron. server/workspace-backup.ts mounts POST export, preview and import routes under /api/workspace-backup after the existing API authorization middleware. Request parsing errors are sanitized; the larger body allowance applies only here. KDF operations are serialized and tracked by the update drain. Sync/update availability is checked again after decryption before merging.

The format, exclusions and merge rules are recorded in ADR 0100. Import validates the manifest, requires a single-use preview receipt and writes through store.transaction. Templates/signatures merge within the existing compose record. Uncached flags use encrypted workspaceMailState records and apply once when the exact mail ID is first cached. Account identity hints map typed references to a uniquely matching connected email/provider. Pending/executing proposals become unknown history; no grants or credentials are imported. Calendar and provider cache recovery are outside this format. Reload after import for all views to refresh.

Focused synthetic coverage is in server/workspace-backup.test.ts, desktop/workspace-backup.test.ts and src/workspace-backup.test.tsx. Run these files serially through the bounded wrapper, plus the strict build, preload regression and the isolated desktop startup recipe above. These checks do not establish installed dialog behavior, native portability or live-provider continuity.

Workspace backups reject retained work without connected account identity hints. Preview and import each check deduplicated destination draft and template quotas inside the transaction; overflow imports leave existing work unchanged.

Practical attachment envelope

Outgoing messages allow 20 attachments and 25 MiB of decoded file bytes in total, with a 25 MiB per-file bound. The composer shows exact selected bytes and limits before selection and Send. New selections upload raw bytes serially through POST /api/accounts/:accountId/attachments?filename=...&contentType=.... The route inherits desktop session, loopback, origin, CSRF, sync and update checks. It requires a declared length, limits each request to 25 MiB and 60 seconds, and returns {id, filename, contentType, size}. Other JSON routes stay at 2 MB. Partial uploads are removed on failure or cancellation.

The store encrypts independent 64 KiB chunks in attachment_chunks using the existing installation key. Encrypted upload metadata binds filename, type, size and account. Writes batch at most 1 MiB in a synchronous transaction; no transaction spans network awaits. Stored uploads, including referenced ones, are capped conservatively at 256 MiB per account, 1 GiB globally and 2,000 entries. Admission persists the maximum request reservation until completion, so concurrent backup imports cannot consume an active stream's capacity. These are disk quotas; stream buffers remain bounded. A new upload cannot silently reuse another account's attachment. Changing From requires removing uploaded attachments and choosing them again for that account. Existing inline drafts remain readable and sendable with the original 512 KiB per-file and 1 MiB inline total limits.

Drafts and sendSchema store attachment references inside the ordinary attachments field. Frozen snapshots and encrypted outbox requests therefore keep their bytes without an attachment sidecar or a larger JSON request. Saves and sends verify every reference against immutable stored metadata. Forward reuses downloaded references only within the same account and lists omitted files. Legacy inline attachments still use the existing send path.

Reference lifecycle and outbox integration

cleanupUploads scans one encrypted record at a time, recursively collecting string references from every record kind, including opaque outbox payloads. Drafts, received mail, queued/failed/unknown outbox entries and durable attachmentHold records all retain referenced bytes. In-memory active readers also prevent cleanup. Corrupt ciphertext aborts inventory before deletion. Cleanup removes only unreferenced uploads older than one hour. This grace period protects uploads awaiting the first autosave and separate-copy recovery. Discard, confirmed sends and drained storage maintenance invoke cleanup. Interrupted uploads remove partial chunks immediately; process-crash leftovers expire through normal inventory. Explicit account removal purges that account's uploads and holds with its drafts/outbox, while preserving other accounts' bytes. Legacy attachment inventory keeps its existing fail-closed behavior for unknown kinds and incomplete mail metadata.

sendMail creates an encrypted hold before provider work. Confirmed acceptance, including a Sent-copy warning, releases the hold. Known attachment preparation and SMTP SIZE failures release it because submission did not occur. Other failures retain it indefinitely, including across restart. Do not expire unknown send holds. releaseAttachmentHold(id) is available only for a caller that has explicitly resolved that operation; there is no automatic retry or timeout-based release. A caller integrating resolution must retain any draft/outbox payload until its own terminal-state transaction commits. Removing an outbox record never directly deletes attachment chunks. Referenced bytes survive cancellation until all remaining records release them. Cleanup failure after acceptance is a warning and cannot turn successful delivery into a retryable send failure.

Provider envelopes and received downloads

Referenced sends stream MIME into an encrypted spool, enforce 35 MiB encoded MIME, and replay the exact measured bytes. Gmail's API raw-message cap is 35 MiB; its JSON base64url transport is streamed separately. SMTP servers commonly allow 25 MB, 35 MB or 50 MB encoded messages. Nodemailer's submission connection checks the measured envelope.size against its advertised positive SIZE before MAIL or DATA. Servers without SIZE still use the local 35 MiB cap for referenced messages. MIME base64 line wrapping adds about 37 percent, so decoded file size alone does not guarantee provider acceptance.

ProviderEmc² encoded-message capAdditional remote preflight
Gmail API35 MiB raw MIMEMeasure before streaming base64url JSON
Gmail SMTP (gmail-imap)35 MiB MIMESubmission connection's advertised SIZE
iCloud SMTP35 MiB MIMESubmission connection's advertised SIZE
Fastmail SMTP35 MiB MIMESubmission connection's advertised SIZE
Yahoo SMTP35 MiB MIMESubmission connection's advertised SIZE
Custom IMAP/SMTP35 MiB MIMESubmission connection's advertised SIZE

The lower positive advertised SMTP value wins. Preset names do not establish plan-specific provider acceptance limits. Without SIZE the provider's actual limit is unknown until submission; the local cap still applies. Typical 25 MB SMTP budgets permit less than 25 MiB of files after MIME encoding.

For streamed SMTP messages, Gmail SMTP saves its own Sent copy. Other IMAP accounts append the exact submitted message to Sent after SMTP acceptance, through the same saveSentCopy helper as legacy sends. ImapFlow's APPEND needs one Buffer, so the encrypted spool (bounded by MIME_BYTES, 35 MiB) is read once after acceptance and before it is erased. The copy carries no Bcc header. A copy or cleanup failure is a successful-send warning; never resend because of it.

Automatic import stays at 10 files, 512 KiB each and 1 MiB total. The 8 MiB raw parse bound remains. Oversized Gmail sources recover bounded metadata using full-message fields that exclude body bytes. IMAP listings use RFC822.SIZE and fetch only envelope/bodystructure for oversized messages. The reader separates an unparsed message body from a large attachment not downloaded yet. IMAP bodystructure sizes are estimates until the decoded part is downloaded.

POST /api/messages/:id/attachments/:attachmentId/download performs an explicit provider-part retrieval and caches completed bytes encrypted. The reader offers Cancel download. Requests stop at 25 MiB decoded bytes or 60 seconds; failure removes partial chunks and preserves metadata. Gmail uses users.messages.attachments.get and decodes its response incrementally. IMAP opens the recorded mailbox read-only, verifies UIDVALIDITY and Message-ID, and downloads only the selected UID/body part with bounded PEEK ranges. Archived mail without a recorded safe mailbox identity is refused. Account, message and attachment identities are checked again before provider-part fetch and before caching. No read flags or mailbox state are changed.

The existing attachment GET streams cached bytes to the renderer. The existing browser/desktop download pattern saves the chosen file, with no executable preview or sender HTML. Only small automatically imported PDF/raster files offer in-app previews; explicit large downloads do not automatically render pixels. Cancellation and privacy changes abort the reader request. Native save dialogs, installed interaction and real-provider behavior remain separate acceptance.

Synthetic coverage: server/attachment-envelope.test.ts, server/attachment-import.test.ts, server/attachment-quota.test.ts, server/forward.test.ts, server/drafts.test.ts, server/send-provider.test.ts, server/storage-maintenance.test.ts, src/attachment-selection.test.tsx, src/attachments.test.tsx and src/compose-layout.test.tsx. See ADR 0101.

Backups with attachment references

ADR 0101 extends the original backup decision: backup draft/proposal content includes referenced attachment bytes, never a source-device upload ID. Export verifies account ownership and complete bytes. Preview creates no uploads. Import creates new encrypted uploads and rebinds only the imported content in the same transaction as the workspace merge. Existing records are skipped without allocating duplicate attachment bytes; a failed import rolls back chunks and metadata. Agent history remains inert. Received caches and queued/scheduled outbox actions remain excluded.

The encrypted backup cap is now 128 MiB to fit a practical attachment after both base64 layers. The dedicated backup request parser accepts 132 MiB; ordinary JSON stays at 2 MB. Backup construction is an explicit bounded in-memory file operation. Ordinary upload, send and received-part retrieval remain streamed. The release build stamps package.json before bundling; the manifest records that stamped version. Source builds record the checked-out package version. Regression: server/attachment-backup.test.ts, alongside the backup, envelope, raw selection, outbox and scheduled-send suites. Synthetic proof does not establish installed dialogs, native portability or live-provider behavior.

Refusing busy attachment work before admission is a preflight failure, not unknown delivery. Scheduled sends release their byte holds without contacting the provider; errors after submission keep existing uncertainty handling.

About version and update status

Settings, About and storage displays the bundled package version. Browser sessions also show the first eight characters of their stamped build ID and compare the full ID with the authenticated, uncached host session response. A matching build means current on that host, not necessarily the latest public release. Missing metadata, failed checks and unauthenticated responses cannot claim freshness. A changed build uses the existing guarded browser update notice; Settings must close before refreshing. Desktop sessions read and check the existing update bridge without downloading or installing from Settings.

Stable-release web host updater

The Linux deployment controller is scripts/web-deploy.mjs. The operator keeps a reviewed copy outside the release directories and installs the templates in scripts/systemd/ only after activation approval. The controller selects only the canonical repository's latest published stable release, rejects downgrades and moved tags, stamps isolated package/lock versions using the existing release contract, installs dependencies, runs serial tests and the web build, and probes an isolated encrypted synthetic browser before stopping the live service. Build children use a separate HOME and an allowlisted environment without mail credentials. Resource controls are checked against the actual updater cgroup.

Managed startup validates the public release receipt and browser asset identity, requires profile/configuration paths outside the deployment root, and handles SIGUSR2 through the existing mail drain. The local /auth probe reports only the public deployed revision. Source switching preserves a previous symlink and a pending record. Failed startup restores the previous compatible source; interrupted switching requires explicit recovery. Failed revisions need an explicit retry. Package webDeployment.profile generations must match for automatic source rollback; this does not roll back database contents.

See operator setup and recovery and ADR 0104. Synthetic coverage: scripts/web-deploy.test.mjs, scripts/browser-shutdown.test.mjs and scripts/web-startup.test.mjs. Live service/timer enablement, OS credential-store access and native phone proof remain separate, held operator acceptance.

Isolated sender styles

The formatted reader extracts up to 32,000 bytes of sender style rules from bounded HTML. It keeps at most 128 tag/class/id rules and one level of min/max width media queries. Declarations pass through the existing resource-free mail-style grammar. Attribute/pseudo selectors, external styles and fonts, resource URLs, script, positioning and text-hiding rules remain unsupported. Class and ID selectors become opaque document-local tokens before rendering.

Mail with supported rules uses a blank sandboxed iframe with only same-origin host access. The host portals the existing sanitized React tree into it; it never injects sender HTML or enables scripts, forms, popups or top navigation. The host installs sanitized CSS through the document stylesheet API without relaxing request CSP. The cached/themed fallback remains if style installation is unavailable. Mail without supported styles keeps the existing themed reader.

Cross-leaf text redaction, link checks and remote/CID image consent stay in the existing formatter. Each document is replaced on message, HTML, privacy or image-consent changes. Embedded blob results are bound to exact message and attachment metadata. Resize observers and key listeners are removed on cleanup; reader shortcuts forward only after the document's own controls can consume the event. Synthetic parser and mounted lifecycle checks are in src/sender-css.test.tsx; native layout and physical keyboard acceptance are separate proof. See ADR 0102.

The isolated frame carries the isolated-mail class so the host message body keeps its full available width after the formatted tree moves into the frame. Plain-text bodies retain the 65ch line limit. The mounted sender-style test checks both host width rules after portal creation; native geometry remains separate acceptance.

Isolated sender styles use the host foreground as a low-priority default. Sender font sizes and the body default follow --text-scale, including changes on .app while mail stays open; unhandled Enter on plain content reaches host shortcuts, while interactive controls keep activation.