Skip to content

v0.8.0 — reasoning fix, completion cache, proxy-trust hardening

Choose a tag to compare

@jr551 jr551 released this 01 Aug 09:47

Reasoning models no longer swallow the answer

Measured against the live DeepSeek API rather than inferred: reasoning_effort: low produced more hidden thinking than sending nothing at all, and at max_tokens: 40 the model spent the entire budget deliberating and returned finish_reason=length with empty content. That is what "the AI returned nothing" was. With reasoning_effort: none the same request answers correctly, and it works cleanly with tool calls and JSON mode.

none is now the default (LLM_REASONING_EFFORT re-enables it), which replaces the blunt max_tokens floor added in v0.7.0 — that floor now only applies when reasoning is actually on.

12-hour completion cache

Chat completions are cached in SQLite for 12h, keyed by a stable hash of the request so key order doesn't cause misses. Entries are scoped per user: the reply usually quotes the caller's own mail back at them, so a byte-identical request from another mailbox is a miss, not a hit. Only responses that actually carry content or tool calls are stored, so a provider error can't be pinned for half a day. Streaming bypasses the cache. Tunable via AI_CACHE_TTL_MS / AI_CACHE_MAX_ENTRIES, disable with AI_CACHE_ENABLED=false.

TRUST_PROXY=true was a footgun

It meant "believe any client's X-Forwarded-For", making req.ip attacker-controlled. A forged 127.0.0.1 matches both the rate limiter's allowList and the IP allowlist's loopback rules — and that limiter is the only brake on credential stuffing, since every failed auth triggers a real Dovecot login.

Verified against production that it is not currently exploitable: the front nginx overwrites the header. But that is the proxy protecting the app rather than the app protecting itself. The default is now to trust only loopback/private hops, and true maps to the same thing instead of trusting everyone.

Missing mailboxes returned 502

Dovecot phrases it as Mailbox doesn't exist; the pattern only matched does not exist. The settings-sync folder is created lazily, so a brand-new user's first sign-in produced a Bad Gateway and a blocking "Server error" dialog. Now a 404.

Other hardening

  • Sieve injection: CR/LF are stripped from rule names and match values. A newline closed the quoted string (or comment) and let a user write arbitrary Sieve into their own active script, sidestepping the ownership checks on envelope-to/discard rules. Field lengths are capped.
  • ELEVENLABS_API_KEY is no longer returned to clients. Voice runs on the browser's own SpeechSynthesis and only needs a configured flag; previously one compromised mailbox password exfiltrated the operator's key.
  • Telemetry log is bounded. The POST endpoint is public and append-only; the log now rotates at 16 MB and /recent reads a bounded tail instead of loading the whole file into memory.
  • IPv4-mapped IPv6 (::ffff:127.0.0.1) parsed to a different address entirely and failed closed, 403-ing legitimate loopback clients. Now normalized.
  • push-sender closes its SQLite handle on stop.

A finding that turned out not to be real

An earlier review reported that pooled IMAP connections were released mid-stream during downloads, risking truncated attachments. Measured over a real socket, Fastify already keeps the connection checked out until the body is written — release lands a few ms after the stream ends. No fix was applied; regression tests were added instead to keep it that way. Worth noting that a test built on app.inject() passes either way, because inject buffers the body — the tests deliberately use a real socket.

305 tests, 304 passing, 1 skipped.