-
Notifications
You must be signed in to change notification settings - Fork 0
Feature Message body cache
English · Tiếng Việt · 中文
SkimMail keeps the full body of a message on disk the first time you open it, so the second open does not wait on IMAP. Since 1.11.0 that cache is encrypted at rest by default, has a size ceiling and an age limit, and is finally deleted when the mail it belongs to is deleted. In 1.11.0 you configure it with three environment variables — the Settings panel for it is built, but not visible in this release.
- The parsed body (HTML and text) of a message, written the first time that message is opened, and by the background prefetch described below.
- It lives in the blob store: a directory under
DATA_DIRby default, or your S3 bucket if you switched the storage engine. - Subjects, senders, dates, flags, snippets and the search index live in the database, not here, and are not affected by anything on this page.
- Attachments are not cached. They are fetched from the mail server every time you open one.
The cache exists because a miss is expensive in a way no storage backend can fix: it is a synchronous IMAP round-trip pulling a whole message inside an HTTP request, roughly a thousand times the cost of reading the body back from disk.
The cache is always on. Three environment variables control it:
| Variable | What it does | Default |
|---|---|---|
CACHE_MAX_SIZE_MB |
Evict cached bodies above this total; 0 = no ceiling |
2048 |
BODY_TTL_DAYS |
Drop cached bodies older than this; 0 = never expire |
90 |
BLOB_ENCRYPT |
Encrypt newly written bodies at rest | true |
Two of them behave differently from the third, and it matters:
-
CACHE_MAX_SIZE_MBandBODY_TTL_DAYSare Plane 2 settings: the environment provides the initial default, and a value saved from the screen — or throughPUT /api/settings/cache— is stored in the database and wins from then on. Since 1.11.1 that screen is Settings ▸ Security ▸ Message body cache. On 1.11.0 the same panel shipped inside the hidden Storage tab, so the environment really was the whole story there. -
BLOB_ENCRYPTis boot-only. No UI, no API, noskimmail config; it is read from the environment at startup and nowhere else. It is on unless you explicitly turn it off: onlyfalse,0,noandoffdisable it (case-insensitive, surrounding spaces ignored). Every other value —1,TRUE,yes,on, or a typo — leaves encryption on, because a flag that defaults to on has to fail in the safe direction.
1.11.0 got that last rule backwards. It compared the value against the exact word
true, soBLOB_ENCRYPT=1,=yes,=onand=TRUEall read as off — encryption stopped at the exact moment an operator believed they had just confirmed it. Fixed in 1.11.1. Bodies cached while it was off are not rewritten in place; they stay unencrypted until they leave the cache. Lowering the size or age limit in Settings ▸ Security evicts them, and they come back encrypted.
See Configuration for what "Plane 2" means and where these sit in the full environment reference.
Settings ▸ Security ▸ Message body cache, from 1.11.1. It shows four things and offers two actions:
| Row | What it means |
|---|---|
| Cached bodies | how many are stored, and how many bytes that is |
| Awaiting deletion | bodies already condemned but not yet removed from the store; only shown when there are some |
| Background prefetch | on/off, instance-wide. Since 1.16.0 — see "Prefetching new mail" below |
| Size limit (MB) |
0 = no ceiling. Above it, the least recently read go first |
| Keep for (days) |
0 = never expire |
Save applies the new limits immediately, so the usage figures move as soon as it returns. Purge unaccounted is the S3 cleanup described further down.
Every route behind this panel is owner-only, including the read — so for an operator or a viewer the panel simply does not appear, rather than appearing and refusing. That is deliberate: a control you would only be refused is worse than no control.
With BLOB_ENCRYPT=true (the default), every newly written body is sealed with
AES-256-GCM. The key is derived from your master key, so:
- There is nothing new to back up, lose or rotate. No second secret exists. Losing the master key already leaves the database unreadable, so this adds no way to lose data that you did not already have.
- Upgrading needs no migration and no downtime. Each blob says whether it is sealed, so a store can hold bodies written by 1.10.0 and bodies written by 1.11.0 at the same time. Reads handle both.
- Turning encryption off does not strand the existing cache. Reads always decrypt; the switch only decides how new bodies are written.
- If the key is ever wrong, a body reads as a cache miss and is fetched again from the mail server. The product degrades into being slow, never into losing mail.
What this does not cover: the database. Subjects, addresses, snippets and the search index are plaintext on disk. For those, encrypt the volume — see Security.
A backup archive never carries this key. Bodies go into the archive decrypted (the archive has its own encryption) and are re-sealed with the target instance's own key on restore, so an archive stays restorable on a machine that has never seen this instance's master key.
- When the cache is over
CACHE_MAX_SIZE_MB, the least recently read bodies go first, down to 90% of the ceiling rather than exactly to it — the cache should not spend its life one message away from full. -
BODY_TTL_DAYSis measured from when the body was cached, not from when it was last read: a body read every day is exactly as stale as one nobody has opened. - The age limit is not only housekeeping. A mail server that resets UIDVALIDITY can hand the same UID to a different message, and the cache is keyed by account, mailbox and UID — so without an age limit a stale body could be shown under a new message indefinitely. The TTL bounds that to N days.
- There is no background timer. A pass runs when roughly 64 MiB of new bodies have accumulated, and immediately when new limits are saved through the API. Deletion is batched (500 blobs per pass, plus 50 on each sync poll), so a pass never stalls somebody's request; whatever is left over is picked up by the next one.
- Eviction costs you a re-fetch and nothing else. The cache never holds the only copy of your mail.
Before 1.11.0, deleting an account cleared its mail from the database and left every cached body on disk for ever — invisible, unrecoverable, and still counted against the Community storage limit. As of 1.11.0 those bytes are removed with the account. Moving a message to another mailbox retires the old body too, since the destination gives it a new UID.
With the filesystem blob store, cached bodies live under DATA_DIR and
therefore count toward your tier's storage limit. With the S3 engine they live
in the bucket and do not.
An instance upgrading to 1.11.0 has a store full of bodies and no index of them. On the first start, SkimMail derives which blobs should exist from your messages, puts those on the books, and treats the rest as unaccounted. It runs once, in the background, so it never delays the listening port, and it logs:
reconciled body cache store=fs indexed=1843 bytes=284127744 extra=12 deleted=12 needs_purge=false
The two storage engines are treated differently on purpose:
- Filesystem — the blob directory is SkimMail's alone, so unaccounted blobs really are orphans. They are deleted.
-
S3 — the bucket may also be your backup destination. Unaccounted
objects are counted and left alone; the log line says
needs_purge=true. Nothing is deleted, because deleting the wrong object there could destroy the only copy of your mail.
On S3 the gap also closes by itself: a body gets indexed the next time it is read, so an instance in normal use converges without touching the bucket.
If you are certain the bucket holds nothing but SkimMail's cache, there is a manual purge: Settings ▸ Security ▸ Message body cache ▸ Purge unaccounted. It reports how many stored objects it deleted.
The same thing over the API, for a script:
curl -sS -b /tmp/skim.cookies -X POST http://localhost:8080/api/settings/cache/purgeBoth are owner-only. See Remote images in mail for how to obtain the session cookie.
After a sync brings new mail, SkimMail warms up to 10 of the newest bodies in the background, so the first click is instant instead of waiting on IMAP.
Three refusals matter more than the feature itself:
- Only when somebody is connected. With no browser open there is no first open to make faster, and on a metered connection the trade is just a bill.
- Never for an account whose sync has been auto-stopped. Speculative work is the last thing a struggling mailbox needs.
- Never counted as a sync failure. A prefetch that fails is dropped silently, because letting optional background work drive the consecutive-failure counter could auto-stop a mailbox that works perfectly.
It also stops when the cache is already at its ceiling — filling a full cache would evict something a human read in order to store something nobody asked for. Fetches are spaced 250 ms apart so a provider does not see a burst.
Since 1.16.0 there is a switch: Settings ▸ Security ▸ Message body
cache, right beside the size and age limits — because it is the same
question, how much of your mail ends up on this disk and how it got there.
It is on by default, and it stays on across an upgrade: the reason it was
worth turning off (the \Seen-marking leak below) is already fixed, and
quietly changing a performance default under people would be its own kind of
surprise. With it off, background prefetch does no work at all — no targets
are computed, no cache is measured, no connection is opened — but opening a
message still fetches its body on demand, exactly like any other cache miss.
The switch is owner-only, the same as the rest of this panel.
Up to and including 1.14.0, every message SkimMail fetched was requested with
FETCH BODY[...], and that form implicitly sets the\Seenflag on the mail server (RFC 3501 §6.4.5).BODY.PEEK[...]is the same fetch without the side effect, and nothing in SkimMail used it.Because prefetch runs in the background over the newest messages, mail nobody had opened was being marked read on the real server — visible in webmail and on the phone, not just here. The next sync then read that state back, so everything inside SkimMail stayed self-consistent and nothing looked wrong. Downloading an attachment and using one-click unsubscribe did the same.
Upgrade to 1.15.0. All three fetch paths now peek.
Mail already marked read this way cannot be repaired. It is indistinguishable from mail you genuinely read, so there is nothing for a migration to undo. The fix stops it from here; it cannot reach backwards.
- It is not an archive. An evicted body is re-fetched from the mail server; if the message is gone from the server, it is gone.
- It has no per-account or per-user limits. The ceiling and the age limit are instance-wide.
- It does not cache attachments.
- It does not show you which messages are cached. The panel reports a count and a total size, and the purge acts on unaccounted objects; there is no per-message list and no way to pin one body.
- Configuration — the environment reference and the three configuration planes
-
Security — encryption at rest, and what a stolen
DATA_DIRexposes - Troubleshooting — disk usage that does not drop after an upgrade
- Users and roles — why the cache endpoints are owner-only
SkimMail · skimmail@base101.app · 2026-09-15 · commit 767741a