No description
  • TypeScript 61.4%
  • JavaScript 18.6%
  • HTML 8.9%
  • CSS 7.4%
  • Shell 2.7%
  • Other 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
devnullv0id cdbfcc6e38
Some checks failed
Docker / check (push) Successful in 10m53s
Docker / version (push) Successful in 2s
Docker / build-and-push (push) Failing after 16s
Docker / tag (push) Has been skipped
Docker / release (push) Has been skipped
Test the container check instead of the machine it runs on
The Forgejo runner executes jobs inside a container, so detectContainer()
correctly answered "yes" and the test that asserted "this test host is not a
container" failed — 1216 of 1217 passed, and the one failure was about the
host rather than the code. GitHub's ubuntu-latest is a VM, which is the only
reason it ever passed.

The probes are injectable now, so both directions are covered: the file docker
leaves behind, the one podman leaves, the cgroup fallback naming which marker
it matched, and an ordinary cgroup line that means nothing. The true branch was
never exercised before. What is left of the host-dependent test is the one
invariant that holds anywhere — there is evidence exactly when it says yes.

The buildx gha cache is switched off on Forgejo by a variable. Its runner does
set ACTIONS_CACHE_URL, so this looked like it would work, but the address it
advertises is not reachable from inside the job: reserveCache failed with
ETIMEDOUT. GitHub keeps the cache, since the default when the variable is
unset is the behaviour that was there before.
2026-08-14 18:08:42 +02:00
.githooks Refuse to commit or push a credential 2026-08-12 15:02:29 +02:00
.github/workflows Test the container check instead of the machine it runs on 2026-08-14 18:08:42 +02:00
docker Make calibre an extension, and let the assistant ask for all three 2026-08-13 13:41:42 +02:00
scripts Make the workflows run on either forge 2026-08-14 17:54:44 +02:00
src Test the container check instead of the machine it runs on 2026-08-14 18:08:42 +02:00
static Make calibre an extension, and let the assistant ask for all three 2026-08-13 13:41:42 +02:00
test Test the container check instead of the machine it runs on 2026-08-14 18:08:42 +02:00
.dockerignore Rewrite on Fastify 5 + TypeScript, add optional accounts and SSO 2026-08-06 14:32:00 +02:00
.env.example Make calibre an extension, and let the assistant ask for all three 2026-08-13 13:41:42 +02:00
.gitattributes Pin line endings so the formatter and the checkout agree 2026-08-11 16:38:43 +02:00
.gitignore Publish the extension the README tells people to use 2026-08-12 17:10:49 +02:00
API.md Configure the whole server from the browser [major] 2026-08-12 13:05:14 +02:00
biome.json Take the header's overlays off the header, so they can cover the window 2026-08-09 21:23:39 +02:00
CODE_REVIEW.md Stop the policy upgrading assets to an https this server does not speak 2026-08-11 20:19:29 +02:00
docker-compose.dev.yaml Take every comment out, and keep none of them 2026-08-10 10:09:45 +02:00
docker-compose.yaml Take every comment out, and keep none of them 2026-08-10 10:09:45 +02:00
Dockerfile Make calibre an extension, and let the assistant ask for all three 2026-08-13 13:41:42 +02:00
LICENSE Rewrite on Fastify 5 + TypeScript, add optional accounts and SSO 2026-08-06 14:32:00 +02:00
package-lock.json Send the security headers that were not there at all 2026-08-11 17:24:51 +02:00
package.json Make the log readable and quiet by default 2026-08-12 20:43:42 +02:00
README.md Make calibre an extension, and let the assistant ask for all three 2026-08-13 13:41:42 +02:00
tsconfig.build.json Rewrite on Fastify 5 + TypeScript, add optional accounts and SSO 2026-08-06 14:32:00 +02:00
tsconfig.json Rewrite on Fastify 5 + TypeScript, add optional accounts and SSO 2026-08-06 14:32:00 +02:00
vitest.config.ts Take every comment out, and keep none of them 2026-08-10 10:09:45 +02:00

send2ereader

A self-hostable service for sending ebooks to a Kobo, Kindle or Tolino ereader through the device's built-in browser.

Open the site on your ereader and it shows a short pairing key. Enter that key on your phone or PC, upload an ebook, and a download link appears on the ereader — converted to the right format for that device.

Format support

Device Input Sent as Converter In the image
Kobo EPUB .kepub.epub kepubify yes
Kobo KEPUB, PDF, CBZ, CBR, MOBI, TXT, HTML unchanged yes
Kindle EPUB, CBZ, CBR, TXT, HTML .azw3 (default) calibre ebook-convert the calibre extension
Kindle EPUB .mobi (opt-in, pre-2015 devices) calibre ebook-convert the calibre extension
Kindle MOBI, AZW3, KFX, PDF unchanged yes
Any PDF cropped PDF (opt-in) pdfCropMargins the pdfcrop extension
Tolino / other anything supported unchanged yes

A freshly pulled image sends EPUB, makes a KEPUB for a Kobo, and repairs the layout on the way. Everything else is fetched onto your machine when you ask for it — see Extensions — because it is the difference between a 110MB pull and a 1.3GB one, and most servers never use all of it. Nothing is hidden while it is missing: the Convert page greys the format and says which install would bring it back.

Files sent to a Kindle have their names stripped of special characters — a limitation of the Kindle browser. Uploads are validated by magic bytes, not just by extension.

Converters are probed at startup; if one is missing, its option is disabled in the UI and the file is sent unconverted rather than failing.

EPUB layout fix

Adobe's RMSDK renderer — the engine in Kobo, Tolino and PocketBook readers — clips full-page images at the edges, pushes tall ones off the bottom, and stretches covers that calibre wrote with preserveAspectRatio="none". Every EPUB this service delivers is repaired for those defects by default, using the engine from calibre-epub-layout-fix.

That project ships as a calibre GUI plugin, but its engine (fixer.py) is deliberately free of calibre and Qt imports, so the Docker build extracts that one module and runs it under plain python3 — no plugin registration, no GUI, no calibre startup cost. The build tracks the latest release; pass --build-arg EPUB_LAYOUT_FIX_REF=v0.1.0 to pin a tag instead.

It applies to every input format whose result is an EPUB, and runs before kepubify so the Kobo package wraps the repaired book:

.kfx → calibre → .epub → layout fix → kepubify → .kepub.epub

It is skipped for Kindle output, because AZW3/MOBI are not EPUB and KF8 does not share the defect. The option is a checkbox in the form (default on, LAYOUT_FIX_DEFAULT=false to flip the server default), and the step is optional: if the engine fails, the failure is logged and the unrepaired book is still delivered rather than failing the upload.

Choosing a target

The upload form has a single Convert for switch — Auto, Kobo, Kindle, Don't convert — and exactly one is ever active. Kobo and Kindle are mutually exclusive by construction: a .kepub.epub carries Kobo-specific markup that a Kindle cannot read, and an AZW3 is meaningless to a Kobo, so there is never a reason to produce both.

Auto is the default and uses whichever device generated the key. Type a key and the form asks the server (GET /key/:key) what it is paired with, then says so — "Auto: paired with a Kobo". The other three are manual overrides for when user-agent detection gets it wrong, or when you want the file left alone.

Tolino is not folded into Kobo. Both read EPUB directly, but only Kobo understands KEPUB, so a Tolino resolves to no conversion.

KFX

.kfx and .kfx-zip uploads are always accepted, and a .kfx is sent to a Kindle as-is — that is already the device's native format, so no conversion is involved.

Reading KFX (converting it into something a Kobo or Tolino can open, or unwrapping the .kfx-zip container that no device can open directly) needs calibre's third-party KFX Input plugin, and that is in the image. The build reads its MobileRead thread and takes whatever attachment is current, because the forum gives every reupload a new id and a pinned one would quietly go stale. Override either plugin with a URL of your own if you would rather:

docker build --build-arg KFX_INPUT_PLUGIN_URL=https://…/KFX_Input.zip -t send2ereader .

If the thread cannot be read at build time the image is built without that plugin rather than failing, and the feature is refused at runtime the way it always was.

With the plugin present, .kfx/.kfx-zip convert to AZW3/MOBI for a Kindle and to EPUB for everything else. Without it, both are passed through untouched. The server detects this at startup (calibre-customize --list-plugins) and /healthz reports it as tools.kfxInput.

Writing KFX needs two things, and the image can only ship one of them. calibre's KFX Output plugin is in the image — the build resolves the current attachment from its MobileRead thread, so it tracks the author's releases rather than pinning a version that goes stale. What the image cannot carry is Amazon's Kindle Previewer: it is a Windows program and not ours to redistribute.

So the Convert page refuses KFX until a Previewer is present, and says so. It is not enough for the plugin to be installed — a plugin with nothing behind it would offer KFX and then fail at the conversion, which is worse than refusing it. AZW3 is the best format this image can produce on its own, and every Kindle since 2011 reads it.

An operator who wants KFX turns it on from the admin page, under Converters: it installs Wine and fetches the Previewer from Amazon on your machine, at your instruction, showing each stage as it goes, and the server stays up throughout. EXTENSIONS: kfx in the compose file does the same before the server starts.

It is not free: 356MB downloaded once, a 2.6GB Wine prefix kept on the data volume, 1.7GB of Wine packages in the container, and about 920MB of memory while a KFX conversion runs — which takes a minute or two per book, because a Windows program renders it. The page says all of that before the button, and can remove the lot again. The server then offers KFX for real, because it checks that both the plugin and a Previewer are there (tools.kfxOutput on /healthz) rather than being told. See Extensions.

How to run

services:
  send2ereader:
    image: ghcr.io/devnullv0id/send2ereader:latest
    container_name: send2ereader
    restart: unless-stopped
    ports:
      - 3001:3001
    volumes:
      - uploads:/data/uploads

volumes:
  uploads:
docker compose up -d

Images are published for linux/amd64 and linux/arm64, and the service listens on port 3001.

Tag What it is
latest The current build from master.
legacy The last build of the original Express app, kept pinned. Pull this if you want the app as it was before the rewrite; it will not receive updates.

The image is around 110MB. calibre is not in it — it is an extension, installed on the data volume when you ask for it, and it brings the Qt and Mesa libraries it needs with it. That was measured rather than assumed: with those libraries moved aside every format still converted except PDF, which is the one path that reaches Qt WebEngine.

Installing all three extensions costs roughly 700MB on the volume, plus about 2.6GB more if you want KFX.

Build the image yourself

git clone https://github.com/devnullv0id/send2ereader.git
cd send2ereader
docker compose build     # uncomment the `build:` block in docker-compose.yaml first
docker compose up -d

On your host OS

  1. Install Node.js 22 or newer.
  2. Install the converters and make sure they are on PATH:
  3. Install dependencies, build and start:
npm ci
npm run build
npm start

Then open http://localhost:3001.

Accounts (optional)

Sending a book never needs an account. The key flow above works for anyone who can reach the server, and that does not change. Accounts exist only to manage registered ereaders.

They are on with no configuration at all. The secret that signs a session is generated on first boot and written next to the database as session.key, mode 0600; keep that file, because losing it signs everyone out and makes stored Kobo tokens and two-factor secrets unreadable. Set it yourself if you would rather — which is the right answer when several instances share a database:

cp .env.example .env
openssl rand -base64 32          # put the result in SESSION_SECRET

To run the bare key-transfer app instead, with no sign-in, no library and no admin page, set ACCOUNTS=false. It is set in the environment and nowhere else, because turning accounts off also removes the page you would turn them back on from.

The first account to register claims the server and becomes its owner. After that local registration is closed unless you set ALLOW_SIGNUP=true; SSO, once configured, is the intended route for anyone else.

Use a real address — it is the only way back in if you forget the password. Unless SMTP_ENABLED is on, the confirmation and reset links are written to the server log instead of being e-mailed, so a self-hoster without a mail server can still complete the flow:

docker logs send2ereader | grep /auth/verify

You can sign in without confirming, but registering an ereader requires a confirmed address.

Passwords are hashed with scrypt (node:crypto, N=2¹⁶, ~130 ms per hash) and the login, register and reset endpoints are rate limited, because that cost is per attempt.

Configuration

Settings come from environment variables. For anything other than a throwaway run, put them in a .env file next to docker-compose.yaml:

cp .env.example .env

.env.example documents every variable with its default — a test fails if a new one is added to the code without appearing there. The file is read by the app itself (via Node's built-in process.loadEnvFile, no dependency) and by Docker Compose, and it is gitignored and excluded from the image so secrets are not committed or baked in.

Real environment variables always win over .env, so compose environment: entries and docker run -e override the file rather than being silently ignored. Point ENV_FILE somewhere else to load a different file.

Everything is optional; defaults are shown.

Variable Default Description
HOST 0.0.0.0 Listen address
HTTP_PORT 3001 Port the server listens on
HTTP_ADDR 0.0.0.0 Address the server listens on
LOG_LEVEL info pino log level
TRUST_PROXY false Honour X-Forwarded-* behind a reverse proxy
UPLOAD_DIR ./uploads Where uploads are stored while a key is alive
CLEAN_UPLOAD_DIR_ON_BOOT true Wipe leftovers at startup
EXPIRE_SECONDS 30 Idle TTL — a key dies this long after the ereader stops polling
MAX_EXPIRE_SECONDS 600 Hard TTL — never extended, however active the key is
MAX_FILE_SIZE 838860800 Upload limit in bytes (800 MB)
KEY_LENGTH 4 Pairing key length
CONVERSION_TIMEOUT_MS 600000 Wall-clock cap for one conversion
CALIBRE_OUTPUT_PROFILE kindle_pw3 calibre --output-profile for Kindle targets; set empty to omit
LAYOUT_FIX_DEFAULT true Default state of the "Fix EPUB layout" checkbox
ACCOUNTS true Accounts, sign-in and the admin page. Off is the bare key flow
SESSION_SECRET (generated) Signs sessions and encrypts stored tokens. Unset generates one beside the database
PROTOCOL http Scheme people reach the server on — https behind a proxy
DOMAIN (unset) Host people reach the server on. Builds every link the server hands out; falls back to HTTP_ADDR:HTTP_PORT
ALLOW_SIGNUP false Allow local registration beyond the owner
DB_PATH /data/db/send2ereader.db Accounts database; created on first boot
SMTP_ENABLED false Send mail. While off, links go to the server log
SMTP_HOST / SMTP_PORT (unset) / 587 465 uses implicit TLS, 587 and 25 use STARTTLS
SMTP_USERNAME / SMTP_PASSWORD (unset) Omit both for an unauthenticated relay
SMTP_FROM_EMAIL / SMTP_FROM_NAME (unset) / send2ereader Sender. Defaults to SMTP_USERNAME when that is an address; set it only when the two differ
SMTP_TLS true Turn off only for a relay on localhost
SMTP_TIMEOUT_SECONDS 30 Connection, greeting and socket timeout
OIDC_ENABLED false Offer single sign-on alongside local accounts
OIDC_CONFIG_URL (unset) Discovery document or issuer URL
OIDC_CLIENT_ID / OIDC_CLIENT_SECRET (unset) Omit the secret for a public client
OIDC_ADMIN_GROUP (unset) Group that grants administrator rights
KEPUBIFY_BIN / EBOOK_CONVERT_BIN / PDFCROPMARGINS_BIN / CALIBRE_CUSTOMIZE_BIN / EPUB_LAYOUT_FIX_BIN binary name Override converter paths
EXTENSIONS / EXTENSION_PACKAGES (unset) Installed at container start; pipe separated. See below

Extensions

Three converters are left out of the image and installed on demand. Two of them are simply large; the third, Amazon's Kindle Previewer, is not ours to redistribute at all.

id What it adds Needs Roughly
calibre MOBI, AZW3, PDF, TXT, HTMLZ, and reading a KFX 600MB, a few minutes
pdfcrop Trimming the white margins off a PDF 90MB, a minute
kfx Writing KFX, the format a modern Kindle prefers calibre 2.6GB, up to twenty minutes

There are two ways in, and they install the same scripts from the same image.

From the browser, at Admin → Converters, while the server keeps running. Each stage is shown as it happens, the installer's own output is streamed under it, and the same page removes any of them again. The first-run assistant asks the same question as its fifth step and queues whatever was ticked.

Before the server starts, by naming them in the compose file:

environment:
  EXTENSIONS: calibre|pdfcrop|kfx
  EXTENSION_PACKAGES: fonts-noto-cjk|poppler-utils

Both are pipe-separated. What was asked for either way is remembered in /data/extensions/enabled, so a container recreated against the same volume puts it back — and since everything lands under /data, that is a relink rather than another download. A name with a slash in it is still treated as an OCI image to unpack, which is how a third-party extension is added.

At start the entrypoint installs EXTENSION_PACKAGES, stages each extension it was asked for, runs them in dependency order, and only then drops to the node user and starts the server. One that fails is logged and skipped rather than stopping the container. docker/extensions has the details.

HTTP API

A summary. API.md has the full reference — request and response shapes, status codes, and the rules each endpoint enforces.

Route Purpose
GET / Upload form, or the receive page when the user-agent is an ereader
GET /send, GET /receive Force either page
POST /generate Issue a pairing key (plain text). Rate limited.
GET /key/:key Which device a key is paired with, so the form can preselect a target
GET /convert Convert a book without sending it anywhere
POST /convert multipart: file, format, and the fixes. Returns a one-shot download link.
GET /convert/:id/:filename Collect the result. Serving it deletes it.
GET /api/convert/targets?from= Which formats are reachable from a source, and why the rest are not
GET /login, /register, /settings Account pages. Only exist when accounts are enabled
POST /auth/register, /auth/login, /auth/logout Local accounts
POST /auth/password Change a password from Settings, with the current one
GET /auth/verify?token=, POST /auth/reset E-mail confirmation and password reset
POST /api/devices/:id/token Rotate a device's sync token. The device and its queue survive.
DELETE /api/waiting/:id Cancel a queued book, deleting the file now
GET /api/waiting/:id/download Collect a queued book in the browser. It stays queued.
GET /auth/status Whether accounts are on, claimed, and who is signed in
GET /status/:key Poll for the attached file and urls. Requires the issuing user-agent.
POST /upload multipart: key, file, url, and the conversion checkboxes. Returns JSON.
GET /download/:filename?key= Download. Requires the issuing user-agent. Supports ranges.
DELETE /file/:key Detach and delete the stored file
GET /healthz Liveness, key count, converter availability

Backups

The admin page has a Backup panel. It hands you one .tar.gz holding the database — taken with SQLite's own VACUUM INTO, so a running server cannot be caught half-written — and every book kept in the library. The archive is shaped like the data directory:

db/send2ereader.db
library/<account>/<book>

Putting one back is deliberately not a button: unpacking over a running server would race whatever it is doing. Stop it, unpack, start it.

docker compose down
tar -xzf send2ereader-2026-08-12-09-30-00.tar.gz -C /your/data
docker compose up -d

Two things are not in it, because neither belongs to the server: your .env, and the generated session.key beside the database. Losing that key signs everyone out and makes stored Kobo tokens and two-factor secrets unreadable — everything else comes back regardless.

Privacy

Uploads live only as long as the pairing key: they are deleted when the key expires (about 30 seconds after the ereader stops polling), when a new file replaces them, when the file is deleted from the ereader page, and on server shutdown. Nothing is persisted between restarts.

Keys are generated with a CSPRNG, and both /status and /download require the same user-agent that created the key.

Development

Two loops, and the difference matters because Docker is what actually ships.

Fast loop — iterate on code. Reloads on save, but uses whatever converters are installed on your machine, so behaviour can differ from production:

npm ci
npm run dev          # tsx watch on http://localhost:3001
npm test             # vitest
npm run lint         # biome
npm run typecheck    # tsc --noEmit
npm run scan:secrets # refuse credentials in the history

npm ci also points git at .githooks, which refuses to commit anything shaped like a credential and refuses to push a range containing one — because a file deleted in a later commit is still in the history you push, and .gitignore never applies to a path that is already tracked. CI runs the same scan over the whole history, where --no-verify cannot reach it.

Real loop — verify the artifact. Same Dockerfile, same converters, same non-root user as the published image. Run this before trusting a change:

docker compose -f docker-compose.dev.yaml up --build

static/ is bind-mounted read-only in the dev compose file, so page edits show up on reload without a rebuild; server changes need --build. /healthz reports which converters the container actually found — worth checking, since a converter missing on your host but present in the image (or the reverse) is the usual reason the two loops disagree.

The server is Fastify 5 on TypeScript in src/. The pages in static/ split into two worlds with deliberately different constraints, and code must not cross between them:

  • download.html + style.css + common.js run on the ereader, in WebKit builds from the early 2010s. No CSS custom properties, flexbox, grid or rem; no const/arrow functions/fetch/Promise. Pure black-on-white with 3px borders, because e-ink dithers greys and shadows into noise, and it stays readable down to 380px wide. test/static.test.ts enforces all of that, including that the design system never reaches this page.
  • Everything else runs on a phone or desktop and is a hand port of the design in UI/, which is a git-ignored handoff rather than source. Two rules hold there: no style= attribute in any page, and no HTML built from strings — repeated rows clone a <template>. The only value a script may write into a style is a bare number on a custom property, --prog. test/page.test.ts enforces each of those, and also that every class in the markup resolves to a rule.

Where the prototype's markup and its companion styles.css disagree, the markup wins; each correction carries a comment saying so. Controls the design draws that this server has no endpoint for stay on the page, disabled, marked data-unbacked with the reason — and a test names every one, so "temporarily inert" cannot quietly become permanent.

Mail is the one exception to the no-inline-style rule, because mail clients give no choice: src/mail/template.ts is table layout with every rule on the element and the palette resolved to literal hex.

Credits

Maintained by devnullv0id. Inspired by send2ereader by djazz, which this started as before being rewritten.

License

MIT — see LICENSE.