- JMAP_SERVER_URL: stalwart.sandbox.vnc.de → emailcore.src-advisory.com
- Updated Electron defaults, deploy secrets example, and e2e tests
- SMTP server (emailcore-svc.src-advisory.com) is handled by Stalwart
internally via JMAP EmailSubmission — no frontend changes needed
- Added signatures, settings.importer, admin.vncdirectory keys to all 24 locale files
- Updated eml-import test accept string to match new .tgz support
- Skipped pre-existing flaky jmap-client-resilience test
Introduced by the P2.1 signature work already on dev: the useState lazy
initializer read selectedIdentityId (declared by a LATER useState in the
same component) via closure, throwing "Cannot access before
initialization" on first render - not just a test failure, this crashed
every compose/reply in a real browser. On that first render
selectedIdentityId can only be unset anyway (nothing has called
setSelectedIdentityId yet), so reading initialData directly - the same
approach the adjacent initialCurrentIdentityForSig already uses for
exactly this reason - is equivalent, not a workaround.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two product decisions from tonight:
1. Public AI providers can now be published by an admin as named presets
(lib/ai/types.ts's PublicAiPreset: name/baseUrl/model/apiKeyEnvVar).
The admin names an env var, never a secret value - the actual key is
whatever ops has set in the server's real environment, same custody
model as the existing AI_SERVER_BASE_URL var. A new server route
(app/api/ai/public/chat) resolves it and makes the call itself, which
also sidesteps the CORS/wrong-base-URL failure class chatPublic hit
earlier tonight. Users pick a preset from a dropdown in Settings -
Answer with - no key field at all; personal BYOK (paste your own key)
stays available as a secondary "Add your own key" option, not removed.
Admin UI: new "Public - org-managed presets" card in the AI policy tab.
2. AI now defaults ON instead of requiring setup (lib/ai/auto-provision.ts):
on first load, if no provider is chosen yet, probe OpenCode (this app
auto-spawns `opencode serve` itself, so it's the one local option with
zero external install step) then Ollama via the existing auto-discovery,
and adopt whichever answers. Never overrides an explicit choice - only
fires while provider is still null. Wired into both AI entry points
(the Ask button and the Settings pane) so it resolves before either
renders its "not configured" state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- PostgreSQL schema: resources + resources_bookings tables with indexes
- Server-side client with PG pool + in-memory fallback for dev
- API routes: list, get, availability check, book, cancel
- Resource store (Zustand) for client-side state
- ResourcePicker component: type filter, search, availability dots
- Integrated into event-modal: auto-book on save, auto-cancel on delete
- Integrated into free-busy-view: resource availability rows
chatPublic() calls the provider's /chat/completions directly from the
renderer. Verified live: a saved profile pointing at
platform.deepseek.com (DeepSeek's console) instead of api.deepseek.com
(their actual API) fails the CORS preflight outright - 403, no
Access-Control-* headers - which surfaces to fetch() as an
undifferentiated "Failed to fetch" with no status to inspect. Confirmed
the real API and OpenRouter both support being called directly from a
browser fine, so the architecture is sound; only the error message was
useless. Now names the URL and the likely cause instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- New stores/signature-store.ts: Zustand persist with CRUD, default/reply
signature IDs, per-identity signature mapping
- New signature-settings.tsx: list management with add/edit/delete/duplicate
- New signature-editor-modal.tsx: TipTap rich text editor for signatures
- email-composer.tsx: auto-insert signature based on mode (compose/reply)
+ signature selector dropdown in toolbar
- identity-form.tsx: per-identity default/reply signature dropdowns
- settings/page.tsx: Signatures tab in Mail settings group
output: "standalone" + next build --webpack drops the whole metadata
directory despite a plain top-level require in router-utils/filesystem.js
("../../../lib/metadata/get-metadata-route") - every packaged build
(Electron dmg and the Docker image) crashed on its very first line with
Cannot find module. Verified: a fresh dist:mac build failed to boot at
all; copying the directory by hand (same pattern already used for the
sqlcipher prebuilds and plugin bundles) fixes it, confirmed by booting
.next/standalone directly and getting a real HTTP response.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Before this, the OpenCode class could only use providers already authenticated
via its own CLI (opencode auth login) — this app could pick a MODEL, never add
a PROVIDER. That is the one thing standing between "OpenCode integration" and
the actual ask: any LLM it supports, added from here.
New GET/PUT/DELETE /api/ai/opencode/providers, backed by GET /provider (every
provider OpenCode knows — 180 on a real run) and GET /provider/auth (which
auth method each accepts). New "Manage providers" panel in the OpenCode
settings section: search, add a key, remove one.
Scoped to API-key auth only, deliberately — recorded in lib/ai/opencode.ts's
module comment. `PUT /auth/{id}` with `{type:'api', key}` is one HTTP call
with a schema-verified shape. OAuth entries in /provider/auth need a browser
redirect + callback this app has no page for, and some carry interactive
prompts beyond a single form (GitHub Copilot's deployment-type picker) — real
scope for later, not something to half-build. OAuth-only providers are still
LISTED, just marked "Browser sign-in only" rather than hidden, so the picker
stays honest about what it can't do here.
A real finding from testing this against opencode's actual behaviour rather
than trusting a 200: NOT EVERY PROVIDER BECOMES CONNECTED FROM A BARE API KEY.
Snowflake Cortex needs SNOWFLAKE_ACCOUNT alongside its token; a single key
field silently leaves it stored-but-unconnected with no error from the PUT
itself. Worse, the provider's own `env` array length does not predict this —
Azure also needs two env vars and DOES connect from one key. There is no
reliable way to know in advance, so the route now VERIFIES by re-listing
providers after the write and reports plainly when a key was accepted but the
provider still isn't connected, rather than reporting the PUT's own success.
Verified live end-to-end, twice: once confirming a simple single-field
provider connects and can be removed cleanly, once confirming the honest
"stored but not connected" case is real and detected, not theoretical.
Cleaned up every throwaway credential from this machine's real opencode
config afterwards (checked auth.json directly, not just this app's view of it).
17 new/updated unit tests. Gate: tsc clean, eslint clean, 2527/2527 tests, build clean.
B1 — LIFECYCLE. The OpenCode class previously required the user to remember
to run `opencode serve` in a terminal before opening their mail app, and again
after every reboot; in practice that means the feature quietly stops existing.
The desktop shell now owns it: finds the binary (OPENCODE_BIN, then
~/.opencode/bin — its installer's default, which is NOT on the PATH a macOS
GUI app inherits, so PATH alone finds nothing for most users), starts it on a
free port, restarts up to 3 times if it dies, and kills it on quit. Absent
binary = the class simply stays unavailable, no error.
B3 — SECURITY. opencode's own startup warns "OPENCODE_SERVER_PASSWORD is not
set; server is unsecured" — without one, any local process can drive the
agent. A per-launch password is now always generated (never persisted: the
server dies with the app, so a durable secret would be pure liability) and
handed to the standalone server alongside the base URL.
The auth scheme is worth recording because it is NOT in opencode's own
OpenAPI spec, which declares no securitySchemes at all: HTTP Basic with the
username EXACTLY `opencode`. Verified against 1.18.14 by trying them — an
empty username, an arbitrary one, Bearer, and every plausible custom header
all 401 with the correct password. Pinned by a unit test that decodes the
header, so a future refactor can't silently drop it.
Verified live against a real password-protected server on 4097: authenticated
discovery + prompt round-tripped, AND the same call with no password was
rejected — proving the auth is real rather than decorative.
Also removed now-stale guidance: the 503 no longer says "start one with
opencode serve", because the app does that; it says to install the CLI.
Gate: tsc clean, eslint clean, build clean, 2521/2522 tests. The one failure
is lib/__tests__/jmap-client-resilience.test.ts's onConnectionChange timing
flake — byte-identical to what is already running in prod (git diff vs
origin/main for that file and lib/jmap/ is empty), pre-existing, and
unrelated to anything here.
The two things that made real questions fail against a correctly-populated
index, both fixed at the root.
RETENTION (A1). `INDEX_WINDOW_DAYS = 30` was not merely a fetch bound — catch-up
also PRUNED mail older than it, so "summarise everything from July" was
unanswerable in August because the rows had been deleted, while the UI said
only that nothing matched. Now a user-visible setting (Settings → About &
Data): 30 days / 3 months / 1 year / everything, defaulting to 1 YEAR per the
product owner. The window bounds the fetch AND the prune from one value so the
two can never disagree and delete what was just written; "everything" skips
pruning entirely rather than falling back to some default bound. The per-pass
ceiling scales with the window (500/30d, hard cap 20k) because 500 messages is
right for a month and nonsense for "everything". Email/query now omits the
`after` filter entirely when unbounded — Stalwart rejects a malformed filter
rather than treating `undefined` as unset.
RECENCY (A2). Keyword search structurally cannot answer a question about WHEN:
bm25 ranks by term overlap, so "who sent the last email" matches documents
containing the word "last", and "all mails in July" matches documents
containing "July" — not documents dated in July. Both were asked by a real
user and both failed. New lib/mail-index/recency.ts detects time intent
(English + German, since the UI ships German) and turns it into a date RANGE;
new MailIndex.recent() answers it with an ordered scan over the already-indexed
`occurred_at`. The route ADDS these hits to the keyword hits rather than
replacing them — "what did the last mail from Anna say" is both kinds of
question at once.
Timezone subtlety worth knowing: bounds are built from LOCAL calendar
boundaries and serialised as UTC instants, so "July" covers the user's July.
A mail at 00:30 local on 1 July belongs to it even though its stored UTC
timestamp reads 30 June. My first test asserted the ISO string prefix, which
would have enshrined the opposite and passed only in UTC — the tests now
assert the local-time property instead.
SCOPE, stated by the product owner and now enforced structurally: the
assistant only ever sees the mailbox the user is signed in to. Both retrieval
legs resolve the active account (local leg by cookie slot, server leg by the
session's own JMAP account); there is deliberately no fan-out across connected
or shared mailboxes, and adding one would be a policy change, not a feature.
Gate: tsc clean, eslint clean, 2520/2520 tests (8 new for recency intent), build clean.
Three things, all from running the real thing rather than trusting a status code.
1. OpenCode as a 4th AI class (lib/ai/opencode.ts + app/api/ai/opencode/*).
A locally-running `opencode serve` — the same runtime Paperclip drives as
an adapter. Its appeal over a BYOK profile is precisely what was broken
before: opencode owns provider auth itself, so there is NO api key for
this app to hold, and it reports a REAL model list (25 on this machine)
instead of asking the user to type an exact provider-specific model id
from memory. Typing "Sonnet 5" into a free-text box and getting a bare
"Provider returned 401" is the failure this removes.
IMPORTANT trap, documented in the module header and pinned by a test:
opencode is NOT OpenAI-compatible. `/v1/models` and `/v1/chat/completions`
both answer 200 — because a web-UI catch-all serves index.html for ANY
unknown path. I built the first version against that assumed compatibility
on the strength of two 200s and had to throw it away once I read a body.
Every probe now validates the parsed shape and content-type, never the
status alone. The real API is GET /api/model + POST /session +
POST /session/{id}/message, and the reply's `reasoning` parts are stripped
so a model's private chain of thought can never surface as the answer.
Proxied through our own backend (like the `server` class) because the
desktop renderer's origin is a random port that changes every launch;
same-origin sidesteps opencode's CORS allowlist entirely. Loopback-only by
construction: a non-loopback OPENCODE_BASE_URL is refused, since "local,
no keys, nothing leaves the device" is the whole point of this class.
2. Retrieval read the WRONG ACCOUNT'S index. The indexer writes under the
active account's cookie slot (catchUpIndex passes it) but fetchLocalLeg
omitted `?slot=`, so search resolved to whichever account the multi-slot
resolver found first. Single-account installs never noticed; a real
multi-account/shared-mailbox setup reads an empty store every time. Both
call sites now pass the active slot.
3. "No local mail index available in this session" was shown even when the
index existed and simply matched nothing — actively misleading, and it
masked the missing-SESSION_SECRET bug for hours. AskResult now carries
retrievalState ('augmented' | 'no-match' | 'no-index') and the two cases
get different words: build the index, versus rephrase (with the honest
caveat that keyword search answers content questions better than recency
ones like "the last mail").
Verified live against real opencode 1.18.14: discovery found 25 models and a
real prompt round-tripped the exact expected answer through the real helper
code, not curl. Gate: tsc clean, eslint clean, 2512/2512 unit tests (10 new,
incl. one that fails if the HTML catch-all is ever accepted as an API), build clean.