Skip to content

Troubleshooting

SkimMail docs edited this page Sep 18, 2026 · 7 revisions

English · Tiếng Việt · 中文

Troubleshooting

Real failure modes, each as symptom → cause → fix. Nothing on this page is hypothetical — every entry comes from an actual bug report, a changelog entry, or a deliberate breaking change that has a right way to respond to it.

journalctl -u skimmail shows nothing

Symptom. You run journalctl -u skimmail | grep 'claim code' right after installing the apt package and get no output at all — no error, just nothing.

Cause. skimmail.service runs as a dedicated, unprivileged skimmail system user, not as you. Without root, journalctl hides other users' units by default and, instead of the code, prints a hint that you need more privilege — which is easy to miss if you're specifically grepping for 'claim code' and the hint line doesn't contain those words.

Fix. Add sudo:

sudo journalctl -u skimmail | grep 'claim code'

This was reported often enough against 1.9.1 that every copy of this instruction shipped by the project — the apt post-install banner, /etc/default/skimmail, and the setup wizard itself — now says sudo journalctl explicitly. If you're following an older guide or a cached page that doesn't, add sudo anyway.

skimmail claim-code can't find your instance

Symptom. Running the claim-code command by hand — say, after losing the code from the log — fails with a permission error, or (more alarmingly) succeeds and prints a code the running server refuses when you paste it in.

Cause. /etc/default/skimmail is a systemd EnvironmentFile: systemd reads it and hands those variables to the process only when systemd itself starts the unit. It is never read by an interactively-invoked shell command — so sudo -u skimmail skimmail claim-code, run directly at a prompt, has no DATA_DIR and falls back to the built-in default of ./data, resolved against whatever directory your shell happened to be in. Depending on permissions there, that either fails outright or — the more confusing outcome — quietly creates a throwaway database there and prints a perfectly well-formed code for an instance that isn't the one actually running.

Fix. Always pass --data-dir explicitly for apt installs:

sudo -u skimmail skimmail --data-dir /var/lib/skimmail claim-code --rotate

Since 1.9.1 the command also refuses outright to run against a directory that holds no master key, so a wrong path now fails with a clear message instead of the silent-wrong-database outcome described above.

A mistyped subcommand used to start a second server

Symptom (1.9.0 and earlier). You type skimmail claimcode (missing the hyphen) or some other near-miss. Nothing obviously wrong happens at first — then the real server, which was already running, dies with a port-in-use error, and a new, unfamiliar claim code shows up in the log.

Cause. Before 1.9.1, unknown subcommands fell through every if in the command dispatcher with no final else — so a typo didn't produce an error, it fell all the way through to "no command given" and booted a second, full SkimMail server against whatever ./data resolved to from wherever you typed the command. That second instance minted its own claim code, logged it, and then failed to bind the port the real server already held.

Fix. Upgrade to 1.9.1 or later, where an unrecognised subcommand prints usage and exits with a non-zero status instead of falling through:

$ skimmail claimcode
skimmail: unknown command "claimcode"
usage: skimmail [--data-dir <path>] [command]
...

If you're still on 1.9.0, the practical mitigation is simply to type subcommands carefully and check systemctl status skimmail (or docker ps) after running one, so a stray second process is obvious immediately rather than discovered later.

MySQL install fails inside the first migration

Symptom. You point the first-run setup wizard (or bootstrap.env) at a MySQL server. The Test button on the database step passes — the connection opens fine — but the instance then fails to come up, with an error from somewhere inside the database driver that doesn't mention migrations, schemas, or anything you configured.

Cause. SkimMail's schema migrations ship as files that each contain several SQL statements, and the MySQL driver needs the DSN parameter multiStatements=true to execute more than one statement per call. The Test button only opens a connection and pings it — it never runs a migration — so a DSN missing that parameter passes Test and then fails the moment the real migration runs.

Fix. As of 1.9.1, SkimMail sets multiStatements=true on every MySQL DSN itself, whether or not you included it, so this should no longer happen at all on a current release. If you're troubleshooting a MySQL install specifically on 1.9.0, add the parameter to your DSN by hand:

mysql://user:pass@tcp(host:3306)/skimmail?multiStatements=true

— then upgrade when you can. Separately: MySQL is labelled Experimental in the setup wizard as of 1.9.1. Nothing about it is disabled, but the per-dialect upsert and full-text-search behaviour on MySQL is still called out as unfinished, and full-text search does behave differently there than on SQLite or Postgres.

S3 "Test destination" fails against a healthy bucket

Symptom (1.9.0 only). Settings ▸ Backup is configured with a working S3-compatible bucket — credentials are correct, the bucket exists and is reachable — but pressing Test destination reports failure anyway.

Cause. 1.9.0 added validation of blob storage keys, and the reachability probe used for Test happened to use a literal key name (__skimmail_probe__) that the new validation rule itself rejected for its leading underscore. The probe's Stat call came back with "invalid key" instead of the expected "not found", and the button reported that as a failed test — against a bucket that was never actually unreachable.

Fix. Upgrade to 1.9.1, which uses a dedicated probe key (blob.ProbeKey) chosen to pass the same validation rule it's meant to be tested against. There is nothing to configure differently on your end — the bucket was fine the whole time.

An account shows "Degraded"

Symptom. An account that was working stops syncing and shows Degraded in Settings ▸ Accounts or the Sync Health board, and mail stops arriving for it.

Cause — almost always deliberate, since 1.8.0. If that account (directly, or via its group) is assigned to a proxy, WireGuard tunnel or relay, and that egress is switched off — or deleted, or its configuration breaks — the account does not fall back to a direct connection. It fails closed: the sync attempt errors with something like egress N is disabled, the error is recorded, and the account shows Degraded until the egress comes back or you change what the account uses.

This is a deliberate, breaking behaviour change from 1.8.0. Before it, an account whose proxy/VPN was turned off silently started connecting directly — meaning your mail server would suddenly see the account's traffic coming from your home or server IP instead of the tunnel it was supposed to go through, with nothing telling you that had happened. Degraded is the visible, noisy version of exactly the failure a proxy or tunnel exists to prevent.

Fix. Decide on purpose:

  • If the egress should come back, re-enable it (or fix its configuration) in Settings ▸ Connections — the account resumes automatically.
  • If you actually want this account to connect directly, open the account and set its connection to Direct yourself. SkimMail will never make that choice for you silently.

An image stays broken after you press Show images

Symptom. You press Show images and some or all of the pictures render as the browser's broken-image mark.

Before 1.18.0, this happened for every message, every time, regardless of the causes below. The reading pane's frame was locked down in a way that also stripped your session from every image request it made, so the fetch always failed with "not signed in" before SkimMail's server even tried the sender's image. Fixed in 1.18.0 (SKIMMAIL-209). If you are on an earlier release, upgrading is the fix; the causes below did not matter yet, because nothing ever got past that first failure. Failures are also logged now, with their cause and the image's host (never its full address) — nothing was recorded before this release either.

On 1.18.0 or later, something refused the image, and that mark is deliberate — SkimMail never answers with a blank pixel, because a blank pixel is indistinguishable from a tracking pixel that loaded successfully. The usual candidates are:

  • The format. Only PNG, JPEG, GIF, WebP, BMP and ICO are served. SVG is always refused (it is a document that can carry a script), and AVIF is refused because Go's content sniffer cannot recognise it — allowing it would mean trusting the sender's own label.
  • The size or the time. Over 5 MiB, or slower than 10 seconds.
  • The address is inside your network. Loopback, private, link-local and metadata addresses are refused even when you asked for the image.
  • The egress. The image is fetched through that account's own egress. If that path is down the fetch fails; and if the egress is an HTTP proxy that refuses CONNECT on port 80, plain-http:// images cannot be fetched through it at all.
  • Beyond the 200th image in one message, or an image referenced with a relative address or embedded in the message (cid:) — those are withheld and never shown.

Fix. For a format refusal there is nothing to configure; the image is not coming. For anything else, check that account's connection in Settings ▸ Connections, and read Remote images in mail for the full list of limits.

Disk usage does not drop after upgrading to 1.11.0

Symptom. 1.11.0 says it garbage-collects cached message bodies, but the data directory or the bucket is exactly as large as before.

Cause. Two different reasons, depending on your storage engine:

  • On S3, nothing is ever deleted automatically. The one-time pass on the first start of 1.11.0 counts objects that no message accounts for and stops there, because that bucket may also hold your backup archives. The startup log line ends with needs_purge=true.
  • On the filesystem, orphans are deleted by that same one-time pass, but the size ceiling is enforced lazily: a pass rides an ordinary request after roughly 64 MiB of new bodies have been cached. An idle instance therefore stays over the ceiling until somebody opens some mail.

Fix. On S3, and only once you are sure the bucket holds nothing but SkimMail's cache, run the manual purge: Settings ▸ Security ▸ Message body cache ▸ Purge unaccounted (owner only). The button exists from 1.11.1; on 1.11.0 the same thing is API-only:

curl -sS -b /tmp/skim.cookies -X POST http://localhost:8080/api/settings/cache/purge

On the filesystem, read the startup log for the reconciled body cache line before assuming nothing happened. Details for both: Message body cache.

Cached bodies turned out not to be encrypted

Symptom. You set BLOB_ENCRYPT on a 1.11.0 instance, believed at-rest encryption of message bodies was on, and it was not.

Cause. 1.11.0 compared the value against the exact word true. Anything else — 1, yes, on, TRUE — was read as off, so confirming the setting was the act that disabled it. 1.11.1 reversed the rule: only false, 0, no and off disable it now, and every other value leaves encryption on.

Fix. Upgrade to 1.11.1 or later. That fixes new writes, but nothing is rewritten in place, so bodies cached during the affected period stay unencrypted until they leave the cache. To force them out, lower Size limit or Keep for in Settings ▸ Security ▸ Message body cache, save, then restore the values you want — the evicted bodies are re-fetched from the mail server and cached again, sealed this time. Nothing is lost either way: an evicted body is a cache miss, not a deleted message. See Message body cache.

On a multi-user instance, Reset groups wiped everybody's groups

Symptom. Somebody pressed Reset groups and every user on the instance lost their groups, not just the person who pressed it. Related, from the same era: one person's snooze or pin landing on another person's message, a push-notification device vanishing from someone else's account, and split-inbox counts that were too high because they counted mail belonging to everyone.

Cause. Five operations shipped in 1.10.0 were gated by role but not by owner. The role check answered "may this person call this route"; nothing checked "whose rows does this statement touch". ResetGroups was the worst of them, because its statements carried no owner predicate at all.

Fix. Upgrade to 1.14.0. 1.12.0 fixed those five; 1.13.0 fixed six more of the same shape, including search returning every account's subjects and senders to anyone who could sign in. Each carries the predicate now, and each has a test that fails if it is removed. There is no configuration change and no workaround on an older release — if more than one person signs in to your instance, this upgrade is not optional. Single-user instances were never affected, because there was no second user to reach across to. Groups already deleted are not recoverable except from a backup. See Users and roles.

1.13.0 does not boot after the upgrade (duplicate key in senders)

Symptom. The upgrade installs fine and then SkimMail never comes up. This is not a slow start or a degraded feature: the process exits during startup, before it opens a port. Under systemd the unit goes to failed (systemctl status skimmail); under Docker the container exits and your restart policy puts it in a loop. Nothing answers on /healthz because nothing is listening.

The last line in the log names migration 0016_sender_user_backfill.sql and a duplicate-key failure on the senders table:

open store err="run migrations: store: migration 0016_sender_user_backfill.sql: UNIQUE constraint failed: senders.user_id, senders.email"

That wording is SQLite's. The same failure on PostgreSQL reads duplicate key value violates unique constraint "senders_pkey", and on MySQL Error 1062: Duplicate entry '…' for key 'PRIMARY'. All three are the same problem; the release ships that migration identically to every engine.

Restarting does not help, and it does not make things worse. A migration is only recorded as applied after its transaction commits, so every start re-runs 0016, hits the same collision, rolls back and exits again. You can restart as many times as you like: the database stays exactly as 1.12.0 left it.

Cause. That migration adopts sender rows that were saved without an owner — the muted and VIP senders 1.13.0 rescues — by moving them onto the first user. The senders table is keyed on (owner, address), and it is possible to hold two rows for the same address: one written without an owner (a mute or a VIP set between 1.10.0 and 1.12.0) and one written with one (an image decision, or an unsubscribe URL cached when you used the one-click unsubscribe button, both of which always recorded the owner). Moving the first onto the second collides.

The most likely way to have both is the most ordinary pair of actions there is: you unsubscribed from a newsletter and muted the sender, or you chose Always / Never from this sender for someone you also muted.

Fix — upgrade to 1.14.0. The corrected migration ships there, and it repairs the situation on its own: it merges the stranded flags into the row that already exists, deletes the leftovers, and only then adopts the rest. Install 1.14.0 and start the service. A stuck instance completes the migration on that first start and no mute or VIP is lost. Nothing needs cleaning up beforehand, because nothing was ever half-applied.

If you cannot upgrade right now, rolling back to 1.12.0 gets you running again immediately — reinstall the previous package, or start the previous container tag, and the instance boots as it did before.

Everything below is for two situations only: checking before you upgrade whether this will hit you at all, and repairing by hand if you must stay on 1.13.0. On 1.14.0 you need none of it.

To see whether you are affected — ideally before you upgrade — ask the database directly. This is read-only and safe on a running instance:

SELECT email FROM senders WHERE user_id = 0 AND email IN (
  SELECT email FROM senders WHERE user_id = (SELECT COALESCE(MIN(id), 1) FROM users));

Any address it prints is a collision; no output means this will not happen to you.

If you would rather repair it and go on, take a backup first (Settings ▸ Backup, or stop the service and copy DATA_DIR), then fold the stranded flags onto the row that already exists and delete the leftovers:

UPDATE senders SET
  muted = CASE WHEN email IN (SELECT email FROM (SELECT email FROM senders WHERE user_id = 0 AND muted = 1) t) THEN 1 ELSE muted END,
  vip   = CASE WHEN email IN (SELECT email FROM (SELECT email FROM senders WHERE user_id = 0 AND vip   = 1) t) THEN 1 ELSE vip   END
WHERE user_id = (SELECT COALESCE(MIN(id), 1) FROM users);

DELETE FROM senders WHERE user_id = 0 AND email IN (
  SELECT email FROM (SELECT email FROM senders WHERE user_id = (SELECT COALESCE(MIN(id), 1) FROM users)) t);

Both keep every mute, VIP, image decision and unsubscribe URL you had. Start 1.13.0 afterwards and the migration finds nothing left to collide with.

How far these two statements are verified: the detection query and the repair were executed against all three engines — SQLite, PostgreSQL 16 and MySQL 8.4, the last two on real servers, not simulated. In each case the detection named the same colliding address, the repair applied cleanly, and the 1.13.0 migration then completed instead of failing, leaving every mute, VIP, image decision and unsubscribe URL intact and other users' rows untouched.

The portability is deliberate: the inner SELECT … FROM (…) t wrapper is there because MySQL refuses to read a table it is updating or deleting from directly, and the alias t is there because PostgreSQL requires one.

Take the backup anyway. This is your mail, the statements are being run by hand, and a verified statement is not the same thing as a verified typist.

Senders you do not recognise appear in Settings ▸ Security after upgrading

Symptom. You upgrade a multi-user instance to 1.13.0, open Settings ▸ Security, and the new muted/VIP list contains addresses you never muted.

Cause. Before 1.13.0, muting was recorded against no one in particular. The upgrade has to give those rows to somebody or the mutes stop working, and the original owner was never written down — so they all go to the first user on the instance. If that is you, you have inherited everybody's mutes.

Fix. Remove the ones that are not yours, from that same panel: each row has an unmute / un-VIP button. Nothing else is affected — a mute hides mail, it never deleted any.

On a single-user install this cannot happen: the first user is you, and they were your mutes all along.

Every Gmail or Outlook account needs reconnecting after upgrading to 1.18.0

Symptom. After upgrading, mailboxes that were syncing fine under a Gmail or Outlook sign-in start failing, or a re-authentication screen appears where none was expected.

Cause. Not a bug — a deliberate, breaking change. Earlier releases asked Google and Microsoft for permissions meant for their web APIs, which the mail servers themselves reject; 1.18.0 corrects the scopes to the ones each provider documents for IMAP. A sign-in granted under the old scopes cannot be upgraded in place.

Fix. Reconnect each Gmail or Outlook account once — Settings ▸ Accounts ▸ that account ▸ Re-authenticate. It takes you back to the provider's consent screen and updates the account you already have, rather than creating a duplicate. If the account had already been auto-stopped by repeated failures, reconnecting also resumes it — before 1.18.0 (SKIMMAIL-201), storing a fresh sign-in did not clear that stop, and the account looked untouched after an apparently successful sign-in.

Signing in with Google completed, but the mailbox never synced

Symptom. Add Account ▸ Google (or Microsoft) finished normally, an account appeared in your list, and every sync since has failed — often with something like "provider not configured".

Cause (before 1.18.0, SKIMMAIL-198). OAuth credentials entered through Settings ▸ About ▸ Setup health ▸ OAuth, or set with skimmail config set, reached the running server but never reached the background sync daemon, which had cached its own copy of the configuration at boot and never refreshed it. The sign-in itself genuinely worked; syncing with it did not. This is the most likely explanation if Gmail sign-in has never worked on your instance — it only ever worked when the client id and secret came from environment variables, which the daemon does read at boot.

Fix. Upgrade to 1.18.0. No other action is needed for this part; the daemon now picks up configuration changes as they are saved.

Saving one setting in Settings ▸ About silently cleared others

Symptom. You saved an OAuth client ID (or ran skimmail config set), and afterward your public URL, base path, trusted origins, or "trust proxy headers" were empty or off — settings you had not touched in that request.

Cause (before 1.18.0, SKIMMAIL-199). A save that supplied only some fields was applied as though every field the request did not mention should be reset, rather than left alone. It was invisible wherever those values came from the environment instead, because Plane 1 always wins there regardless of what the database holds — see Configuration.

Fix. Upgrade to 1.18.0 and re-enter whatever was cleared. Saving one field now leaves every other field exactly as it was.

A failed Gmail or Outlook sign-in showed no error at all

Symptom (before 1.18.0). You pressed Continue with Google or Continue with Microsoft, the provider's own screen refused the sign-in — you pressed Cancel, the code had expired, your account limit was reached, or one of several other reasons — and SkimMail came back looking exactly as you had left it. No account appeared, and nothing on screen said anything had happened.

Cause. The server already knew exactly why: the callback handler classifies eight distinct outcomes and sends the reason back on the return URL as ?oauth_error=<reason>. Nothing in the interface ever read that parameter.

Fixed in 1.18.0 (SKIMMAIL-206). Seven reasons now show as their own sentence: you pressed Cancel or an administrator on that account blocks the app; the sign-in expired or was already used; the provider sent back no authorization code; the provider refused the exchange; the provider would not say which address the mailbox is; your plan's account limit was reached; or — a different case from the rest, because retrying will not help — the sign-in succeeded and the account still could not be saved, in which case check the server's storage and its log. Anything else, including a code invented by the provider or crafted into a link someone sent you, is shown with that code printed alongside it so you can search for it — but only when it looks like a short code rather than a sentence, so a link cannot be built to make words appear as though SkimMail wrote them itself.

A Gmail or Outlook account you connected is missing

Symptom. You added a mailbox with Sign in with Google or Sign in with Microsoft, the consent screen completed successfully — and the account is not in your account list. Two people connecting the same mailbox saw the second sign-in replace the first.

Cause. Between 1.10.0 and 1.12.0 the OAuth path saved the account without recording an owner. The account list only shows accounts you own, so it showed nothing; the background sync daemon runs outside any user's scope, so it kept fetching that mailbox's mail into a row nobody could see.

Fix. Upgrade to 1.14.0 and connect the mailbox again — the consent flow now records the owner, and the account appears. Two people connecting the same mailbox get one account each.

What the upgrade does not do is adopt the accounts already created that way. There is no migration for them: the invisible row stays, keeps syncing, and keeps counting against your tier's account limit — so if Accounts says you are at the limit while showing fewer mailboxes than that, this is why. Removing it needs direct database access in 1.13.0; there is no in-product way to reach a row nobody owns.

"Server search runs one mailbox at a time"

Symptom. You press the Server tab under the search box while the unified inbox is open, and instead of results you get a notice and a row of account buttons.

Cause. The server tier opens a connection to a mail server and searches that account's INBOX. Across a unified inbox that would mean one connection per account, so SkimMail does not do it.

Fix. Press one of the account buttons in the notice — it switches to that account and re-runs the same query as a server search. Or select an account in the sidebar first, then search.

This notice is new in 1.13.0. Before it, the Server tab in the unified inbox looked active and quietly returned cache-only results, which is the same thing as answering a different question. See Search.

Move to folder fails, or offers the wrong folders

Symptom. You press Move to folder in the reading pane and the folder list does not look like the folders of the message you are reading — or you pick one and the move fails, and the message comes back into the list.

Cause. The picker lists the folders of the account selected in the sidebar. In the unified inbox, or in the Starred view, the message you have open can belong to a different account, and the folder you chose may not exist on its server. SkimMail deletes its cached copy only after the mail server accepts the move, so a refused move changes nothing.

Fix. Select the message's own account in the sidebar first, then open the message and move it. See Move to folder.

After unlocking, you land on "Your first mailbox" instead of your inbox

Symptom (1.9.0 through 1.16.1). The screen lock came on, and before you typed your passcode the page reloaded — you pressed F5, the browser put the tab to sleep, your laptop woke up, or you just reopened the app. You type your passcode and instead of your inbox you get the first-run "Your first mailbox" screen, on an instance that has plenty of mail. Reloading the page again is the only thing that fixes it.

Cause. The idle lock has been server-enforced since 1.9.0: while a session is locked, every API route but a handful answers 423 Locked, including the one that lists your mailboxes. Before 1.16.2, the client code that loads your account list caught any failure — including that 423 — and wrote it down as "you have zero mailboxes", which is exactly the state that draws the setup screen. The component that shows your mail is deliberately never unmounted (so unlocking doesn't lose your scroll position or an open draft), which is also why nothing ever re-ran that load on its own — only a full page reload started it again, which is why the symptom looked like a display bug rather than a lock problem at all.

The same mistake — a failed request recorded as an empty one rather than a failure — showed up in several other places at once: a failed message load rendered "Inbox Zero", a failed folder list disappeared instead of showing an error, the sessions list in Settings ▸ Security could claim "no active sessions" while you read it from inside one, and the setup hub could report no mailbox connected on an instance that had several. None of these needed the lock specifically to trigger — any dropped connection or 5xx while one of these lists was loading produced the same wrong, quiet answer.

Fix. Upgrade to 1.16.2. The account list — and every other list this affected — now tells "refused" apart from "empty" apart from "still loading", and a 423 from a locked session always routes to the lock overlay rather than an error screen. The moment you unlock, the mailbox list is fetched again automatically; you no longer need to reload the page. 1.16.2 also closed a related gap: a locked session used to lock only the tab that triggered it, so a second open tab kept showing your mail with no overlay at all — see Security for how tabs now stay in sync, and why a boot-time server error (a 5xx) is no longer shown as the login screen. There is no data-loss risk from the old behaviour — reloading always recovered it — and nothing to clean up after upgrading.

Bonus: two more, closely related

Restoring a backup asks for a claim code again. A backup that carries no credential — or restoring onto a fresh target — puts the instance back into first-run state, and a fresh claim code is minted and printed to the log automatically as part of the restore. This is intentional: an older release treated "the code was already spent" as "already claimed", which let anyone finish setup on a freshly-restored instance with no code at all. Read the new code the same way as a first install (see Installation).

Server refuses to start with "database is locked". This means KEY_PROVIDER=passphrase is set but the unlock secret is missing or wrong — check SKIMMAIL_UNLOCK_PASSPHRASE / SKIMMAIL_UNLOCK_PASSPHRASE_FILE, or run skimmail rekey if you need to set a new one. SkimMail refuses to start rather than run without being able to decrypt its own credentials — see Security for what KEY_PROVIDER actually protects.


SkimMail · skimmail@base101.app · 2026-09-18 · commit f525934

Clone this wiki locally