v0.8.0 — reasoning fix, completion cache, proxy-trust hardening
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_KEYis no longer returned to clients. Voice runs on the browser's own SpeechSynthesis and only needs aconfiguredflag; 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
/recentreads 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.