Releases: jr551/mailcow-rest-api
Release list
v0.11.0 — webhook payloads carry attachment bytes
The payload listed attachments but not their contents. A receiver told an invoice exists can't do anything with it, and digging the part back out of the base64 raw source means reimplementing a MIME parser at the other end — the work this payload exists to avoid.
Attachments now carry their bytes alongside the existing metadata:
"attachments": [{
"id": "2",
"filename": "invoice.pdf",
"contentType": "application/pdf",
"size": 84213,
"included": true,
"encoding": "base64",
"content": "JVBERi0xLjQK…"
}]Bounded on purpose. Base64 adds a third on top, and a mailbox accepting 25 MB of attachments would otherwise produce ~33 MB of JSON per message. Defaults are 10 MB per attachment (WEBHOOK_MAX_ATTACHMENT_BYTES) and 20 MB per message (WEBHOOK_MAX_ATTACHMENTS_TOTAL_BYTES); WEBHOOK_INCLUDE_ATTACHMENTS=false turns them off entirely.
Anything over a cap is still listed, with included: false and an omittedReason — silence would be indistinguishable from a message that had no attachments, which is exactly the kind of gap that goes unnoticed for months.
345 tests, 344 passing, 1 skipped.
v0.10.1 — webhook payloads carry a readable body
Webhook payloads carried the message only as base64 raw.data. That's useless to the two things that actually receive these — an LLM can't reliably decode base64, and a script shouldn't have to — and it inflated every payload by a third, so a 100 KB mail arrived as 133 KB of unreadable characters.
Found by wiring the forwarder to a real agent rather than a test endpoint: it could read the subject and sender but not the body, so it correctly logged no-action on everything it was sent. The forwarding worked; the payload was the problem.
Payloads now include:
textandhtml— the decoded bodies, reusing the same MIME walk the message routes already useattachments— filename, content type, size, dispositionraw— still present, for receivers that would rather parse MIME themselves
342 tests, 341 passing, 1 skipped.
v0.10.0 — keep secrets out of prompts, seal the AI cache per user
Privacy release. The AI features send your mail to a third-party model; this is about what they're allowed to take with them.
Credentials are stripped before prompts leave the server
Mail is full of secrets nobody thought of as secrets — a reset mail quoting the temporary password, a colleague pasting an API key, a signup confirmation with a one-time code. Summarising or scanning that mail shipped it verbatim to whoever runs the model, and none of those features need the secret to do their job.
Outbound prompts are now scrubbed with deterministic patterns — no model involved, so it can't be talked out of it: vendor key formats, JWTs, private key blocks, password: …-style assignments, credentials embedded in URLs, and card numbers validated with Luhn so order references survive.
Deliberately biased toward missing an unusual secret over mangling ordinary prose. "Please reset your password before Friday" is left exactly alone. A redactor that eats real text gets switched off, and then it protects nothing. LLM_SCRUB_SECRETS=false opts out.
Verified against production: asking the model to echo back a password and an API key returns my password is [redacted] and the key is [redacted:api-key].
The AI cache is sealed per user
Cached completions quote your own mail back at you, so the cache is as sensitive as the mailbox. Rows are now encrypted with a key derived from the server key and the user's own password — ai-cache.db is inert without both, and the server can only read a row while that user is authenticated.
The password is bound into the lookup as well, so a row written under an old password is never even a candidate rather than being found and failing to open. Rows written before this still read back.
Optional decoy requests
LLM_DECOY_COUNT (0–5, default 0) sends variants of each prompt with names, addresses and numbers randomised, so the provider's stored transcript is mostly conversations that never happened. Fire-and-forget — it cannot delay or alter your answer.
Honestly stated, because the trade-off is real: it multiplies token spend by (1 + count), and it does not hide that the account uses the service, when, or roughly how much text.
Also
has:attachment now matches attachments (#14) — it was mapped to an IMAP keyword Dovecot never sets, so it returned zero results everywhere. Decided from MIME structure using the same function that draws the paperclip, with attachment flags cached as you browse: cold 27s → warm 0.4s on a 2900-message folder.
Mailbox passwords sealed at rest, every sqlite file kept on the persistent volume, Drive buckets provisioned with CORS, and a clear error when mailcow has Sieve disabled for a mailbox — see v0.9.x.
341 tests, 340 passing, 1 skipped.
v0.9.3 — has:attachment without the minute-long scan
Performance follow-up to v0.9.2, which made has:attachment correct but slow — 34s on a 3000-message folder.
Measured against production rather than guessed: the IMAP searches are trivial (120ms to list all UIDs, 54ms for the multipart/ pre-filter). The entire cost is fetching bodyStructure, at roughly 23ms per message — over a minute for the whole folder.
The list view already fetches bodyStructure for every page it renders, so the answer is now recorded as it goes and the search only fetches messages nobody has looked at yet. In practice a browsed mailbox answers immediately. UIDs are immutable within a uidvalidity, so an entry never needs revisiting, and the flags are dropped alongside the rest of the folder cache on invalidation.
324 tests, 323 passing, 1 skipped.
v0.9.2 — has:attachment actually matches attachments
Fixes webmail #14.
The search UI advertises has:attachment. It was translated to the IMAP keyword $HasAttachment, which Dovecot never sets — so the advertised query returned zero results in every folder, including for messages the list was visibly marking with a paperclip.
"Has an attachment" isn't expressible as an IMAP SEARCH key at all. It's now decided from the message's MIME structure using countAttachments() — the same function that renders the indicator — so the query and the icon cannot disagree.
To keep it cheap, a HEADER Content-Type "multipart/" search narrows server-side first (every message with an attachment is multipart), and the precise pass fetches only bodyStructure for the plausible candidates rather than the whole folder.
Inline-image-only mail stays excluded, and combining tokens (from:bob has:attachment) still applies both.
324 tests, 323 passing, 1 skipped.
v0.9.1 — seal pre-existing plaintext credentials on startup
Follow-up to v0.9.0. Apply this if you deployed v0.9.0.
v0.9.0 encrypted new writes and deliberately read older rows as-is, so upgrading didn't sign anyone out. The gap: a session row is only rewritten when the session is recreated, so real passwords stayed readable in cache.db until every pre-upgrade session expired — up to the 30-day session lifetime.
On the reference deployment that was 16 of 19 rows still in plaintext after the v0.9.0 deploy, with a working password legible in the first few bytes.
Existing rows are now sealed at startup in a single transaction, for both sessions and tracking-pixel senders. Sessions keep working across the migration, and the operator gets a log line saying how many rows were converted.
321 tests, 320 passing, 1 skipped.
v0.9.0 — credentials encrypted at rest, Drive actually reachable
Mailbox passwords are no longer stored in the clear
The server keeps live IMAP credentials for two reasons: sessions renew a bearer token without re-prompting, and the open-tracking pixel sends its notification as the original sender long after the request that created it. Both were plaintext columns, so any copy of the sqlite files — a backup, a volume snapshot, a stray docker cp — was a set of working passwords for every active user.
They're now sealed with AES-256-GCM. A fresh nonce per value, so the database doesn't leak which users share a password, and a tampered row fails to decrypt rather than decrypting to something the tamperer chose.
Set CREDENTIAL_ENCRYPTION_KEY (openssl rand -hex 32). Without it the server generates a key beside the databases and warns you — that keeps an upgrade zero-config, but a backup of the data directory then carries the key next to the ciphertext it protects, which is not the same thing as protection.
Upgrading is safe. Rows written before this have no prefix and are read back unchanged, so nobody is signed out; they're replaced with ciphertext on the next write. A session that can't be decrypted is dropped rather than handed to IMAP with a password we can't vouch for.
Drive was unreachable for almost every user
Buckets were provisioned with no CORS rules at all. The webmail's Drive is a direct browser-to-S3 client, so a bucket without them can never be opened: the preflight is refused and every operation surfaces as a bare "Failed to fetch". On the reference deployment exactly one bucket had rules — added by hand — which is why the feature looked like it worked.
createBucket now attaches rules from DRIVE_CORS_ORIGINS, and an existing bucket found without any gets topped up the next time it's provisioned.
"ManageSieve auth failed" now says what's wrong
mailcow gates ManageSieve per mailbox (attributes.sieve_access). Dovecot answers a disabled mailbox with a bare Authentication failed, which became a 502 ManageSieve auth failed — an operator staring at that has no way to know the password was fine and a checkbox in mailcow is the actual answer. The server now checks the attribute and says so, with the path to the setting.
Worth recording why this was investigated: production was logging these failures, but they turned out to be a service account with sieve deliberately disabled, not a bug. Every human mailbox on the deployment has it enabled and mail rules work fine.
320 tests, 319 passing, 1 skipped.
v0.8.1 — keep every sqlite file on the persistent volume
Patch on top of v0.8.0. Recommended for anyone running v0.8.0.
The AI cache shipped ephemeral
Its default path was a bare ./data/ai-cache.db. The deployed compose file lives outside this repo and only sets the paths it already knew about, so the new file landed in the container's working directory and was destroyed on the next docker compose up — silently, because sqlite just recreates it. A 12-hour cache that resets on every deploy isn't much of a cache.
Caught by checking the volume after deploying, not by reading the code.
imap-cache.db turned out to be worse: hardcoded to a relative path with no env override at all, so it has been discarded on every deploy since it was introduced — 3.7 MB of warm cache at the time of writing — no matter how the operator configured things.
Fix
Data file paths now default to the directory of CACHE_PATH. Landing on the persistent volume is what you get for free; putting a file somewhere else is what you opt into. Explicit env overrides still win, and IMAP_CACHE_PATH is now a real knob.
Covered by tests asserting that every sqlite path sits beside cache.db by default, that an override still wins, and that a relative CACHE_PATH keeps the rest alongside it.
308 tests, 307 passing, 1 skipped.
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.
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.