Skip to content

Configuration ​

Everything is set through environment variables (prefix KATHA_). A copyable template lives in .env.example.

Required ​

VariableDescription
KATHA_BASE_URLPublic URL the magic links point at (e.g. https://katha.example.com).
KATHA_HMAC_SECRETSecret that signs the magic-link tokens. Generate with openssl rand -hex 32.
KATHA_SMTP_HOSTSMTP server host.
KATHA_SMTP_USERSMTP username.
KATHA_SMTP_PASSSMTP password.
KATHA_SMTP_SENDERThe From: address on outgoing mail.
KATHA_SMTP_SENDER_NAMEThe display name beside that address (default katha) — what an inbox shows in its sender column. Set it empty for the bare address. Non-ASCII is encoded per RFC 2047, so an emoji works. Optional, and listed here rather than above because it needs nothing from your relay: same envelope sender, same auth.
KATHA_SMTP_PLAINTEXTtrue speaks plain SMTP with no TLS, for a local mail catcher such as Mailpit (see scripts/dev.sh). katha refuses to start with it unless KATHA_SMTP_HOST is localhost, 127.0.0.1 or ::1. Default false: implicit TLS.
KATHA_KAVITA_DBPath to Kavita's SQLite database (opened read-only).
KATHA_COVERS_DIRPath to Kavita's covers directory.
KATHA_DBPath to katha's own SQLite database (created if absent).

Optional ​

VariableDefaultDescription
KATHA_BIND0.0.0.0:8792Address the HTTP server binds to.
KATHA_SMTP_PORT465SMTP port (465 = implicit TLS).
KATHA_LIBRARY_ID1Which Kavita library to recommend from.
KATHA_LIBRARY_IDS(= KATHA_LIBRARY_ID)Every library katha offers readers picks from, comma-separated. Each reader's pool is this list intersected with the libraries Kavita grants them (its own AppUserLibrary ACL, read live and never copied), minus any they switch off on /me/collections. Leave unset for a single-library instance: behaviour and UI are unchanged.
KATHA_LIBRARY_LANGS(unset)What language each collection is in — <library_id>:<fr|en>, comma-separated (e.g. 2:fr,1:en). Operator-declared rather than derived from Kavita's per-series Language, which is dirty in practice. Shown on /me/collections; and for a reader who has stated no language of their own it decides which language katha writes to them in, ahead of KATHA_DEFAULT_LANGUAGE. A reader whose collections disagree falls through to the default rather than being handed a guess.
KATHA_KAVITA_URL(unset)Public URL of your Kavita. Adds a "browse the library" link to the reader page and the email footer, deep-linked to KATHA_LIBRARY_ID. Left unset, the link is omitted rather than broken.
KATHA_TOKEN_TTL_DAYS30How long a magic link stays valid.
KATHA_ON_DEMAND_COOLDOWN_MINS60Minimum minutes between a reader's on-demand "send me one now" picks (floored at 1).
KATHA_LOGIN_COOLDOWN_MINS15Minutes before the same address may request another login link (floored at 1).
KATHA_LOGIN_MAX_PER_IP_HOUR10Login requests allowed from one client IP per hour (floored at 1). Together with the cooldown above, the only abuse controls on the public login form.
KATHA_SESSION_TTL_DAYS90How long a signed-in browser session lives. Absolute — never renewed by use, the same die-on-schedule rule the emailed links follow (floored at 1).
KATHA_CATEGORIES_PATHcategories.tomlThe operator-curated fold of raw Kavita genre strings into reader-facing taste categories. A missing file disables categories; a malformed one fails at startup. Grow it with katha categories unmapped.
KATHA_METRICS_BIND0.0.0.0:9715Address the Prometheus exposition (/metrics) binds to — a separate listener from KATHA_BIND.
KATHA_METRICS_TEXTFILE_DIR(unset)Directory for the node_exporter textfile collector. run-due is a one-shot command that exits before Prometheus could scrape it, so it writes its counters here instead (katha_rundue.prom), every series labelled source="rundue". Left unset, the export is disabled.
KATHA_OPERATOR_EMAIL(unset)The operator's address. The Kavita user with this email is the admin and gets the announcement composer at /admin/announce; a reader's collection ask is mailed here.
KATHA_DOCS_URL(unset)Adds a documentation link to the landing page. Left unset or blank, the link is omitted.
KATHA_ADJACENCY_PATHadjacency.tomlAuthor aliases and the optional [map] override over computed neighbours (see Author neighbours).
KATHA_DEFAULT_FREQUENCYweeklyDefault cadence: weekly | fortnightly | monthly | quarterly.
KATHA_DEFAULT_BOOK_COUNT1Suggestions per email.
KATHA_DEFAULT_LANGUAGEfrDefault email language: fr | en.
KATHA_LANGUAGE_FROM_KAVITAfalseSeed an unset reader's language from their Kavita locale instead of the default.
KATHA_DRY_RUNfalseRehearse run-due: render each email, then discard it. No mail is sent and no suggestion is recorded, so no reader's cadence clock moves.

Running katha behind a proxy. KATHA_BIND defaults to 0.0.0.0:8792 — katha listens on every interface and does nothing on its own to confine that. The per-IP login limit reads X-Real-IP, so it only means what it claims to mean when katha is reachable exclusively through a reverse proxy that overwrites that header with the real client address — a replace, not an append, since an appending proxy still leaves an attacker-supplied value in front for katha to read. Expose katha directly and X-Real-IP is whatever the client sends, which defeats the per-IP limit entirely. Requests that arrive with no such header at all share one bucket, so the cap degrades to a global one rather than disappearing.

Do not put /metrics behind your public vhost. katha serves it on a second listener precisely so it never shares a port with reader-facing routes. Bind KATHA_METRICS_BIND to an interface Prometheus can reach and the internet cannot; the endpoint has no authentication.

Removed: author requests ​

Author requests and acquisition were removed: chat (livrarr-mcp and seerr-mcp) takes requests now. The watch and requests commands are gone, and the old variables (KATHA_REQUESTS_ENABLED, KATHA_ACQUISITION, KATHA_LIVRARR_*, KATHA_PROWLARR_*, KATHA_MAM_* and the rest) are ignored if still set. On first start, katha drops the request tables from its database.

Choosing the language behavior ​

A reader's language is resolved in this order:

  1. Their explicit choice on the settings page — always wins.
  2. Their Kavita locale — only if KATHA_LANGUAGE_FROM_KAVITA=true.
  3. The instance default KATHA_DEFAULT_LANGUAGE.

If all your readers should get one language regardless of their Kavita UI locale, leave KATHA_LANGUAGE_FROM_KAVITA=false and set KATHA_DEFAULT_LANGUAGE. To mirror each reader's Kavita locale instead, set it to true.

Author neighbours ​

Once a reader has had every book by an author they like, katha widens to a neighbouring author ("you enjoyed X, so here's the related Y") before falling back to the rest of the library.

Computed neighbours ​

katha works out neighbours itself, from the genres your Kavita library already holds. It never makes a network call to do it. Each author's genres are pooled across their books. Spellings that fold or alias to one author count as one profile. Authors are then compared so that a rare genre two authors share counts for much more than a common one. Each author keeps up to five neighbours, best match first.

The genres come from each book's metadata as Kavita imported it, so the result is only as good as that tagging:

  • An author whose books carry no genre gets no computed neighbour. Their readers widen straight to the rest of the library, as before.
  • Terms that say nothing about taste are ignored: "fiction", "general", "novel", "literature", "ebook", "audiobook", "non lu" and the like. An author tagged only with those counts as having no genres.
  • A genre that every tagged author carries matches nobody.

The neighbours are recomputed on every run from the current library, so an import or a retag takes effect on the next email with nothing to restart.

The [map] override ​

adjacency.toml's [map] table is an optional override for where the genres say too little or the wrong thing:

toml
[map]
"Ursula K. Le Guin" = ["Adrian Tchaikovsky", "N. K. Jemisin"]
"Fredrik Backman" = ["Gabrielle Zevin", "Matt Haig"]

An author's [map] entries come first, in the order written, and their computed neighbours follow. An author listed in both places appears once, in the [map] position. Authors named in the map who aren't in your library are skipped, so aspirational entries are harmless.

To see what katha will do, use katha neighbours show "<author>" and katha neighbours missing (see CLI).

Author spellings ​

Imports often leave one writer under several spellings. katha compares authors by a folded key — case, accents, punctuation and word order are ignored, so Dostoïevski, Fiodor and fiodor dostoievski are one author. For variants that fold can't merge (different transliterations, or a bare surname versus a full name), add an [aliases] table to the same file:

toml
[aliases]
"Fiodor Dostoïevski" = ["Dostoïevski", "Fyodor Dostoevsky", "Fyodor Dostoyevsky"]

Each key is the canonical author, each value the spellings that mean the same writer. This affects matching only — same-author picks, adjacency and reader author preferences — and readers still see each book's author exactly as Kavita stores it. A spelling claimed by two authors stops katha at startup with an error.

Taste categories ​

categories.toml folds Kavita's raw genre strings into the few categories readers like or exclude on their tastes page. Each key is a category, each value the raw strings it gathers, matched case-insensitively. katha categories unmapped lists the raw strings no category gathers yet, most used first.

A key is the category's identity: readers' choices are stored under it, so renaming a key loses every choice made on it. The key is also what French readers see. A [labels.<lang>] table sets what readers of another language see instead, and a category it leaves out shows its key:

toml
[categories]
"Horreur" = ["Horror"]
"Polar & thriller" = ["policier", "Thriller", "Mystery"]

[labels.en]
"Horreur" = "Horror"
"Polar & thriller" = "Crime & thrillers"

A label for a key that isn't a category, in a language katha doesn't speak, or left blank stops katha at startup with an error. katha categories unmapped also names any category with no English label.

CLI ​

CommandWhat it does
serveRun the HTTP server (magic links + /health).
run-due [--only-user <id>]Send to every reader who is due now; --only-user narrows to one.
preview [--reader-id <id>] [--lang fr|en] [--send <address>]Render a sample email to preview.html, or --send it. With --reader-id, picks come from the real pipeline; without it, a built-in sample stands in.
announce-test --reader-id <id> [--lang fr|en]Mail one reader a draft announcement from stdin (first line the subject, the rest the body), marked [test], at the address Kavita holds for them. Writes nothing, so the real send still reaches them. Its footer link is real: clicking it pauses that reader.
announce-draftStore an announcement from stdin for the operator to review: the French copy, a line of =====, the English copy, each its subject line and then its body. Refuses a book: line no offered library holds, mails nobody, and prints the link that opens the rendered emails and the send button on /admin/announce.
healthProbe the running server's /health; exit 0 if healthy.
categories unmappedList the raw Kavita genres no category in categories.toml maps yet.
neighbours show <author>Print an author's neighbours in the order katha tries them, each marked manual (from [map]) or computed with its score and the genres it shares. Any spelling of the author works.
neighbours missingList the authors katha cannot widen from, each marked no genres (fix by tagging their books) or no match (fix with a [map] entry), then a count.

Observability ​

serve is scraped on KATHA_METRICS_BIND. run-due exports through KATHA_METRICS_TEXTFILE_DIR.

Released under the MIT License.