v0.7.0 — AI proxy, webhook accounts, and production bug fixes
AI: the provider key no longer leaves the server
Chat requests now go to a new POST /v1/ai/llm/chat/completions proxy, authenticated with the session token the client already holds. The server attaches the provider key on the way out, and GET /v1/ai/config reports proxied: true with a same-origin base URL instead of returning a key.
This replaces the LiteLLM per-user key provisioning layer — client, user store, budgets, and the /v1/ai/key/* routes — which existed only to scope keys we now never expose at all. Existing deployments migrate by setting LLM_API_KEY and dropping LITELLM_MASTER_KEY; no per-user state needs migrating. The client's model is ignored in favour of the server-configured one, so a stolen session can't retarget your billing.
Default preset is now deepseek (deepseek-v4-flash). Because it bills hidden reasoning tokens against max_tokens, the proxy raises a client budget below 800 — a 20-token budget returns HTTP 200 with empty content, which surfaced as "the AI returned nothing".
Webhook conversion accounts
WEBHOOK_ACCOUNTS lists mailboxes whose incoming mail is POSTed to a webhook (envelope plus full RFC822 source, base64) and then deleted, turning a mailbox into a feed for a downstream system.
A message is deleted only after the webhook answers 2xx. Anything else leaves it in place and retries — 1m, 5m, 15m, 1h, 3h, 6h, 12h, then daily up to WEBHOOK_MAX_ATTEMPTS (default 14) — after which it stops and leaves the message for a human rather than dropping it. Attempt state is persisted and keyed by (address, uidvalidity, uid), so restarts don't reset the backoff and a reused UID isn't mistaken for the old message. An optional per-account secret signs the body with HMAC-SHA256.
Fixes
Credentials were being written to logs. Request logging included the full header bag, and Basic is the primary auth scheme — so live mailbox passwords and session tokens went to stdout and into Docker's logs. authorization, proxy-authorization, and cookie headers are now redacted.
The image proxy retried failures forever. An oversized image returned a retryable 502 and nothing recorded the failure, so the same >1 MB image was re-downloaded from origin every ~30 seconds for as long as a tab stayed open. Failures are now cached for 6 hours and oversized images return a terminal 413. The reader is also cancelled when bailing out (undici kept buffering the rest of the body), and successful responses carry cache-control.
A background timer could kill the process. Several timers do synchronous sqlite work; an escaping rejection took the whole server down and failed every in-flight request at once — the likely source of "Failed to fetch" bursts. Added unhandledRejection/uncaughtException handlers and a catch around the push poll.
SSRF filter gaps in the image proxy: bracketed IPv6 ([::1]) and integer-form IPv4 (2130706433) bypassed the private-address check.
Unhelpful errors: undici's AggregateError has an empty message, so every connection failure reported fetch failed; the real causes are now unwrapped. A mid-body socket reset in /v1/ai/web-search no longer escapes as a bare 500.
Full suite: 289 tests, 288 passing, 1 skipped.