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 suffix | Request | Result |
|---|---|---|
GET / | No body | At most 100 summaries, newest first with ID ties |
POST / | Draft content | 201 with a new server-generated UUID, revision 1 and full snapshot |
GET /:id | No body | Full snapshot |
PUT /:id | Draft content plus current revision | Full snapshot with revision increased by one |
DELETE /:id | Current 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:
| 1 | node --import tsx --test --test-timeout=30000 server/drafts.test.ts |
| 2 | node --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 |
| 3 | npm 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:
| 1 | node --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:
| 1 | check_dir=$(mktemp -d) |
| 2 | npm 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:
| 1 | check_dir=$(mktemp -d) |
| 2 | mkdir "$check_dir/profile" |
| 3 | open -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" |
| 6 | cat "$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:
| 1 | node --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.
| Platform | Architecture | Installer | Runner |
|---|---|---|---|
| Windows | x64 | NSIS .exe | windows-2025 |
| macOS | Apple silicon arm64 | .dmg | macos-15 |
| macOS | Intel x64 | .dmg | macos-15-intel, x64 Node |
| Linux | x64 | .AppImage | ubuntu-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:
| Before | Recommended after | Why |
|---|---|---|
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 path | Request | Result |
|---|---|---|
GET /api/calendar/events?from&to | ISO instants, at most 62 days apart | { events, google } local and cached Google events overlapping the range, by start |
POST /api/calendar/events | Event fields, optional UUID id | 201 new event; 200 for an identical replay of the same ID; 409 for different details |
PATCH /api/calendar/events/:id | Event fields plus current revision | Event with revision + 1; 409 when stale or synced |
DELETE /api/calendar/events/:id | Current 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 path | Request | Result |
|---|---|---|
GET /api/calendar/work-sessions?commitmentId | Exact parent ID | Nonremoved sessions, availability and unavailable-parent/source/account reason; independent of Calendar month |
GET /api/calendar/work-sessions/:id | Saved session ID | Saved state, including removed tombstones, for recovery |
POST /api/calendar/work-sessions | UUID id, commitmentId, commitmentRevision, offset ISO start/end, Boolean busy, IANA timeZone | 201 new; 200 canonical replay returning current state; 409 UUID collision or unavailable/stale scheduling state |
PATCH /api/calendar/work-sessions/:id | Current session revision, commitmentRevision, start, end, busy, timeZone | Move/edit a planned session only; immutable bindings; increment session revision |
POST /api/calendar/work-sessions/:id/finish | Current revision | Planned to finished; parent unchanged |
DELETE /api/calendar/work-sessions/:id | Current revision | Retain 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 path | Request | Result |
|---|---|---|
GET /api/calendar/events?from&to | Same bounded window as local events | Cached 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 IDs | Local 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 path | Request | Result |
|---|---|---|
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/:id | Cached incoming mail ID | Source 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?eventId | Exact event ID | Saved 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 path | Request | Result |
|---|---|---|
GET /api/calendar/offers?sourceMailId | Cached source ID | Source fingerprint, saved settings, Google catalog/consent state and same-conversation offers |
PUT /api/calendar/availability | AvailabilitySettings | Validated encrypted settings |
POST /api/calendar/offers/check | UUID id, source ID/fingerprint, inclusive local firstDay/lastDay, explicit google selections | Saved slots and request identity; identical replay returns the saved offer |
POST /api/calendar/offers/:id/draft | Source ID/fingerprint, selected slot indexes | Ordinary encrypted reply draft, bound to source/account/thread |
POST /api/calendar/offers/:id/book | Source ID/fingerprint, agreed slot index | One 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.
| Messages | Cold Node RSS before / after, MiB | Cold snapshot before / after, ms | Closed-WAL database before / after, bytes |
|---|---|---|---|
| 100 | 66.08 / 69.48 | 5.03 / 14.98 | 901,120 / 1,011,712 |
| 1,000 | 99.57 / 85.45 | 36.77 / 89.76 | 8,916,992 / 9,973,760 |
| 5,000 | 224.14 / 119.86 | 139.14 / 396.53 | 44,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:
| Messages | Process | Baseline | Candidate |
|---|---|---|---|
| 1,000 | Main | 242.26 | 227.26 |
| 1,000 | Renderer | 120.22 | 110.74 |
| 5,000 | Main | 380.25 | 212.12 |
| 5,000 | Renderer | 166.19 | 112.82 |
| 5,000 | GPU | 82.09 | 70.78 |
| 5,000 | Utility | 52.55 | 45.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.
| Provider | Emc² encoded-message cap | Additional remote preflight |
|---|---|---|
| Gmail API | 35 MiB raw MIME | Measure before streaming base64url JSON |
Gmail SMTP (gmail-imap) | 35 MiB MIME | Submission connection's advertised SIZE |
| iCloud SMTP | 35 MiB MIME | Submission connection's advertised SIZE |
| Fastmail SMTP | 35 MiB MIME | Submission connection's advertised SIZE |
| Yahoo SMTP | 35 MiB MIME | Submission connection's advertised SIZE |
| Custom IMAP/SMTP | 35 MiB MIME | Submission 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.