Introduction
KyPost is a self-hosted email ecosystem: a server you run yourself, paired with native apps for Android, iOS, macOS, and Linux — built around local AI sorting, end-to-end encrypted messages that decrypt only on enrolled devices, and themes that follow you everywhere.
The repositories live under the Busness-app GitHub organization.
Software covered
| Application | Description |
|---|---|
kypost-server | Self-hosted IMAP web client with local Ollama keyword labeling, WKD publishing, and OIDC single sign-on |
kypost-android | Android email client backed by a self-hosted KyPost relay |
kypost-for-Mac | Native SwiftUI mail client for macOS and iOS, relay-only |
kypost-Linux | Relay-only email client for KDE Plasma and Plasma Mobile, Qt6/Kirigami |
kypost-server/worker | Cloudflare Worker relay for Android/FCM push |
kypost-server/worker-apns | Cloudflare Worker relay for iOS/APNs push |
How this book is organized
Each chapter covers one system, its installation, and its configuration.
Conventions
- Where wording matters (security warnings, env var defaults), the text keeps the original phrasing or quotes it.
- Env vars, endpoints, and file paths appear verbatim.
Ecosystem Overview
What KyPost is
KyPost is a self-hosted email system. A Go relay server (kypost-server) talks to the user’s IMAP/SMTP mailbox, applies keyword labels with a local Ollama model, and exposes a web UI plus a relay API. Native clients on Android, macOS/iOS, and Linux contain no mail credentials and no direct IMAP/SMTP — they talk only to the relay.
Single-hosted, multi-client
One backend serves all clients:
- Server — multi-user (admin/user), per-user IMAP/SMTP, per-user label lists copied from instance defaults at account creation, per-user tuning prompts and notification preferences, and OpenID Connect single sign-on with directory replication.
- Android — keyword tabs, two-way contact sync, native-push pairing with FCM or UnifiedPush, optional pull mode, large-screen and foldable layouts.
- macOS & iOS — SwiftUI shared codebase, three-pane on macOS, tab layout on iOS, WebKit reader, APNs + 90s polling fallback.
- Linux — Qt6/Kirigami, one Flatpak for KDE Plasma desktop (3-column) and Plasma Mobile (bottom-tab push navigation).
All three clients share the same Go relay backend and the same 15-theme palette.
Core capabilities
| Area | Server | Android | macOS/iOS | Linux |
|---|---|---|---|---|
| Mail reading | IMAP inbox, folder mgmt, drag-drop move | Relay-proxied, no credentials on device | Server folders incl. subfolders, HTML via WebKit | Inbox/detail/composer, WebEngineView, server folders create/rename/delete |
| Compose | Send via SMTP, draft save, attachments 25 MB | To/Cc/Bcc autocomplete from contacts, picker | Compose and send through relay | Reply/reply-all/forward, attachments, drafts, HTML composer |
| Keyword tabs/labels | Polls unread, local LLM classifies, IMAP keywords, per-user allowlist | Tabs from server tab/label fields, tune in Keywords screen | Relay sorts into tabs/labels, visibility in settings | Tabs from the same server fields |
| Filter rules | GUI + raw Sieve, run-now panel | — | — | — |
| Contacts | Address book + groups, dedupe, CSV/vCard, photos, CardDAV server + client | Two-way sync via relay | Two-way sync, extended schema, reconciliation, groups/photos/IM/social/etc. | Synced list/detail, offline queued edits, groups, dedupe |
| PGP | Browser-protected key, WKD/keyserver/Autocrypt discovery and WKD publishing, recipient check, browser-sealed pickup links, in-browser signature verification | QR key exchange; on-device encrypt, sign and decrypt once enrolled | QR pickup link 2 min expiry, fingerprint confirmation, on-device decrypt once enrolled; server-custody accounts can use relay-side encrypt/sign, while browser-protected accounts hand sending off to webmail | QR exchange, camera scan/show, on-device encrypt, sign and decrypt through the user’s own gpg-agent |
| Device enrollment | Seals the key to a device after a 14-character code matches | Keystore-backed, blocked while Hostile Location Protection is on | Secure Enclave, released on user presence | Imported into the user’s GnuPG keyring |
| Cached mail at rest | Encrypted secrets on disk; no decrypted bodies cached | SQLCipher, key in the Keystore | SwiftData store with backup exclusion | SQLCipher, converted in place on upgrade |
| Sign-in | Password + MFA, or OpenID Connect SSO | Per-device credential, no webmail sign-in | Per-device credential | Per-device credential |
| Push | Browser push + native pairing, central Cloudflare relays for FCM/APNs | FCM, UnifiedPush, or App Pull polling; delivery mode push/pull | APNs + 90s polling + background refresh | UnifiedPush 2 tiers + 90s polling |
| MFA | TOTP, recovery codes, push-approval | Push approval via notification + in-app fallback | Approve from notification tap | N/A |
Privacy model
Covered in full by PGP & Encryption and the Security Model: client-held PGP keys, device enrollment onto phones and desktops, WKD/Autocrypt key discovery, the six-state signature trust model, browser-sealed pickup links, protected subjects, generic push notifications by default, and device-level hardening. The 15-theme system is in Theming.
Repository links
- KyPost Server —
https://github.com/Busness-app/KyPost-Server - KyPost for Android —
https://github.com/Busness-app/KyPost-for-Android - KyPost for Mac & iOS —
https://github.com/Busness-app/KyPost-for-Mac - KyPost for Linux —
https://github.com/Busness-app/KyPost-for-Linux
Workspace trust note
These assume self-hosting on hardware you control.
Architecture
This chapter describes the structure of KyPost.
System shape
KyPost uses a relay model. The server talks to IMAP and SMTP. Clients talk only to the server. No client uses IMAP or SMTP directly. One Go relay serves Android, macOS, iOS, and Linux clients.
IMAP/SMTP <---> kypost-server (Go) <---> clients (Android / macOS / iOS / Linux)
|
+---> Ollama (local LLM)
+---> Cloudflare Workers (FCM relay, APNs relay)
+---> Keyservers / WKD (key discovery and publishing)
+---> OpenID Connect provider (optional SSO)
Clients hold no mail credentials and no PGP private key until they are deliberately enrolled; see PGP & Encryption. Each paired device authenticates with its own server-minted secret, revocable one device at a time.
Server processes
There are four processes in one container. supervisord manages them:
- API server:
kypost-server --mode server - Polling daemon:
kypost-server --mode daemon - Ollama service:
ollama serve - One-shot startup pull:
ollama pull <configured model>
The image sets OLLAMA_MODELS=/kypost/ollama-models.
Classification flow
The server polls unread mail and applies IMAP keywords. There are seven steps:
- Fetch unread messages from IMAP (
INBOXby default). - Redact sensitive patterns.
- Build the prompt from sender, subject, body, and tuning context.
- Call Ollama
/api/generate. - Match the output against the allowed labels.
- Apply the IMAP keywords.
- Save the checkpoint and the decision history.
Warning: Labels are a hint, not a security boundary. A sender can influence the keyword on their own message. The allowlist is enforced after the model answers, so a message cannot get a label you did not configure, and it cannot move, delete, or mark other mail. Do not use a label to grant trust or to auto-archive.
Client architectures
Android
One Gradle module (app/), package org.kysecurity.mail, Views and XML layouts
rather than Compose. Dependencies are wired by hand through SingletonGraph.kt.
| Package | Contents |
|---|---|
| (root) | Activities — inbox, email detail, compose, settings, themes, keywords — plus AppNavigation, AppTheme, SingletonGraph |
data/ | Room over SQLCipher: AppDatabase, DAOs and entities for mail and contacts, DataRuntime |
mail/ | MailRepository, MailSource, cursor store and checkpointing |
contacts/ | Contact list, detail, edit, address-book sheet, cursor store |
pgp/ | ClientEncryptedSender, device enrollment (activity, view model, envelope, code), SignerBinding, PgpFingerprint, WebmailTab |
push/ | FCM service, UnifiedPush service and registrar, pull notifications, pairing, MFA approval |
security/ | AppLockManager, LockoutPolicy, PinPolicy, SecurityWipe, DatabaseKey, CredentialCipher, SpkiPinner, Hostile Location settings, attachment ledger |
ui/ | SplitInitializer — activity embedding for master-detail above 800dp |
Large-screen layouts come from resource qualifiers (res/layout-w600dp/) and
res/xml/split_config.xml, not from a separate codebase.
macOS and iOS
One codebase in KyPost/ builds for both platforms:
| Layer | Contents |
|---|---|
App/ | Entry point, scenes, app delegate, DI graph (SingletonGraph), polling scheduler, notification dispatcher |
Data/ | Relay clients (RelayMailSource, sync, push, registration), SwiftData DAOs and entities, Keychain and settings stores |
Domain/ | Models, repositories (mail, keywords, contacts, push), use cases (send, pairing, MFA) |
Presentation/ | Shared SwiftUI screens and view models, macOS-specific root and preferences views |
Style/ | Theme palettes and manager (contract with web theme.ts and Android AppTheme.kt) |
Platforms split at the root view. iOS uses MainTabView (tab layout). macOS uses MacRootView (NavigationSplitView) plus a per-email WindowGroup.
Linux
One codebase targets two surfaces from one Flatpak:
- Linux Desktop — KDE Plasma, 3-column layout
- KDE Mobile — Plasma Mobile, bottom-tab layout
Structure:
core/ libkypostcore: models, SQLite DAOs, stores, relay networking, domain
repositories, theme data. QtCore/QtNetwork/QtSql only.
app/ main.cpp, push/ (KUnifiedPush + KNotifications), platform/ (SecureStore),
pgp/, contacts/, mail/, pairing/, qml/ (MobileRoot, DesktopRoot, pages)
tests/ QtTest, stubbed HttpClient/FakeRelayServer; ctest-driven, plus the QML
suite and tests/guards.tsv, the security guards proven load-bearing
packaging/ flatpak/ (manifest, desktop file, D-Bus service, AppStream), click/ (deferred)
po/ gettext catalogs
scripts/ build-sqlcipher.sh, verify-guards.sh, verify-version.sh
docs/ PARITY.md (authoritative Android-parity matrix), DISTRIBUTION.md,
THREADING.md, SETUP.md, RENAME_NOTES.md
Dependencies
- Server: Docker and Docker Compose required. Go 1.26+ and Node 20+ optional for local dev. Frontend uses React and Vite.
- Android: Firebase project with
google-services.json, FCM enabled. Bouncy Castle for OpenPGP, SQLCipher for the mail database, the UnifiedPush connector, andplay-services-code-scannerfor the pairing QR. - macOS/iOS: Xcode 26, deployment target macOS and iOS 26.5, SwiftData, URLSession, WebKit. One external dependency: GopenPGP, as a SHA-256-pinned binary XCFramework.
- Linux: Qt6 (
qt6-base,qt6-declarative,qt6-webengine,qt6-multimedia,kirigamiKF6,knotificationsKF6,kstatusnotifieritemKF6,kdbusaddonsKF6,ki18nKF6,qtkeychain-qt6,kunifiedpush,zxing-cpp,openssl,argon2,gpgme), CMake, and SQLCipher built from source byscripts/build-sqlcipher.sh.
Server — Overview
The server is a self-hosted IMAP web client. It offers OpenPGP public-key discovery and publishing for email encryption, and sealed private-key protection. In client-protected mode the server cannot read a user’s PGP-encrypted mail. It adds local-AI keyword labels, a web UI, and a relay API for native clients.
What the server does
- Single-container Docker runtime.
supervisordmanages the processes. - Multi-user with two roles. Admins manage users and system settings. Each user connects his own IMAP mailbox.
- IMAP inbox reader with folder management and drag-and-drop moves.
- Keyword labels for unread mail. Each account has its own label list, copied from instance defaults at creation.
- Filter Rules: GUI condition and action builder plus raw Sieve editor. A run-now panel applies rules on demand.
- Compose with SMTP send and IMAP draft save.
- PGP mail encryption. Generate or import a browser-protected key, discover recipient keys over WKD, keyservers and Autocrypt, check them before you send, and seal the key to a paired device so native clients can read encrypted mail. See PGP & Encryption.
- Web Key Directory publishing at
/.well-known/openpgpkey/for DNS-verified domains, so correspondents find your users’ keys without a keyserver. - Send-as aliases, each verified by a DKIM-signed challenge from the alias’s own domain before it can be used.
- Contacts address book with groups, dedupe, bulk delete, CSV and vCard import and export, and photos.
- CardDAV server (
/dav,/.well-known/carddav) and optional CardDAV client to sync an external address book. - Multi-factor authentication: TOTP, one-time recovery codes, push-approval sign-in with number matching.
- Single Sign-On against any standard OpenID Connect provider, with directory replication. KySignOn is a one-click preset; Authentik and Keycloak have their admin-group claims mapped. Authorization code + PKCE, ID tokens verified against the issuer’s JWKS, accounts claimed by the provider’s
suband never by username or email. Admin-configured under Admin → Server → SSO; requiresSERVER_BASE_URL. - CAPTCHA on login: self-hosted proof-of-work by default, also Turnstile or Friendly Captcha, or
CAPTCHA_PROVIDER=none. - Browser push for each user, for all mail or keyword matches only, plus native push pairing.
- Settings panels: Appearance, Mail (IMAP/SMTP, send-as, contact sync, filters), Security, Notifications and Status, Email Labels (per-user prompt tuning), and Admin (runtime, diagnostics, label rules).
- 15 theme presets, shared byte for byte with every native client. See Theming.
Quick facts
- Ports:
5866for web UI and API,11434for Ollama (not exposed by default). - Languages: Go for the API and poller, React and Vite for the frontend, Ollama for classification.
- Container: one image (backend, frontend, Ollama runtime), one compose file,
supervisord.confinside.
Server — Quick Start
Requirements
- Docker and Docker Compose.
- Optional for local dev outside Docker: Go 1.26+, Node 20+, npm.
Steps
-
Clone the repository.
-
Copy the environment file and set
KYPOST_BIND:cp .env.example .envEdit
.envto match your configuration. These choices have significant security implications, so make changes deliberately. The supplied values are sensible starting points, but you must adapt them to your deployment. Read.env.examplefor the complete list and its security notes.TZ=America/New_Yorksets your local time zone, update as needed.SERVER_BASE_URL=sets the address your server is located at.KYPOST_BIND=127.0.0.1publishes the server only on loopback. Use it when an HTTPS proxy such as cloudflared or nginx reaches KyPost on the same host. A proxy running as a container onkypost-netcan instead connect directly tohttp://KyPost-Server:5866without publishing the port.CAPTCHA_PROVIDER=powenables the self-hosted proof-of-work CAPTCHA. Set it tononeto disable CAPTCHA. The default requires TLS except on localhost. -
Create the model cache directory, then build and start the container:
mkdir -p share/ollama/models docker compose up --build -d -
Open the web UI at
http://localhost:5866.Warning, the server serves plain HTTP by default. The session cookie gets the
Secureflag only when the request came over TLS.http://onlocalhostfor one machine is acceptable. For network access, put TLS in front. -
Sign in as
admin. On first start the server writes a generated password tofirst-run-password.txtin the config volume, mode600. Read it, then delete the file:docker compose exec kypost-server cat /kypost/config/first-run-password.txt docker compose exec kypost-server rm /kypost/config/first-run-password.txtTo set your own password, pass
BOOTSTRAP_ADMIN_PASSon first run. You can also passBOOTSTRAP_ADMIN_USER. The password does not appear in container logs. -
Change the password when the UI asks you to. Until you do, the account can reach only the password-change screen.
-
In Config, save IMAP and SMTP settings. Then run IMAP Test.
-
In Tuning, change labels and prompt. Then save.
After setup
- Verify the client IP handling. Sign in and fetch
GET /api/status. The README saysclientIpmust be your own public address andproxyHeadersTrustedmust betrue(orfalsefor direct TLS in KyPost). IfclientIpis a loopback or bridge address, every user shares one lockout key.
Server — Configuration
Common environment variables
This is a practical summary, not the complete configuration contract. Use the
server repository’s .env.example
for every supported setting, its current default, and its security notes.
WEB_PORT(5866)TZ(America/New_York)SECRET_DIR(/kypost/private). Each*_KEY_FILEand*_SECRET_FILEdefault derives from this.OLLAMA_BASE_URL(http://127.0.0.1:11434)OLLAMA_MODEL(nemotron-3-nano:4b). See classifier note below.TUNING_FILE(/kypost/config/TUNING.md)OLLAMA_MODELS_HOST_DIR(./share/ollama/models)IMAP_CONFIG_FILE($SECRET_DIR/imap-config.json)IMAP_CONFIG_KEY_FILE($SECRET_DIR/imap-config.key)TOTP_SECRET_KEY_FILE($SECRET_DIR/totp-secret.key)SERVER_BASE_URL(optional, recommended for pairing; the server embeds this public URL assrvin the QR code and uses it to buildreg)PAIRING_SECRET(optional; HMAC secret for pickup links, PGP QR, pairing tokens; generated on first start atPAIRING_SECRET_FILE; set it only if replicas must share one; useopenssl rand -base64 32; value must be 32 bytes or longer)PAIRING_SECRET_FILE($SECRET_DIR/pairing.key)PUSH_RELAY_URL(optional; base URL of the FCM relay Worker; must behttps://except for loopback)PUSH_RELAY_KEY(per-server API key; set withPUSH_RELAY_URLto enable Android push)APNS_RELAY_URL(optional; base URL of the APNs relay Worker; must behttps://except for loopback)APNS_RELAY_KEY(per-server API key; set withAPNS_RELAY_URLto enable iOS push)CAPTCHA_PROVIDER(optional;pow,turnstile,friendly, ornone; works with 3-strikes/15-minute lockout)CAPTCHA_SITE_KEYandCAPTCHA_SECRET_KEY(required withturnstileorfriendly, not withpow)POW_MAX_NUMBER,POW_SECRET_FILE,POW_SECRET(optional,CAPTCHA_PROVIDER=powonly; see.env.example)KYPOST_BIND(required by Compose;.env.examplestarts at127.0.0.1)TRUSTED_PROXY_CIDRS(optional; narrowly identifies proxies whose forwarded headers may be trusted)TLS_CERT_FILEandTLS_KEY_FILE(optional; required together for direct TLS termination)ALLOW_INSECURE_HTTPandALLOW_INSECURE_SMTP(optional, default off; explicit security downgrade acknowledgements)BOOTSTRAP_ADMIN_USERandBOOTSTRAP_ADMIN_PASS(optional, first run only)
The image also sets OLLAMA_MODELS=/kypost/ollama-models. The classifier default appears in four places: Dockerfile, docker-compose.yml, .env.example, and backend fallback. All use nemotron-3-nano:4b.
TLS options
There are three ways to get TLS. They are not equivalent.
-
Terminate TLS in KyPost. Set
TLS_CERT_FILEandTLS_KEY_FILEto mounted paths (see.env.exampleand the commented volume indocker-compose.yml). The server answers “did this arrive over TLS” from the connection.TRUSTED_PROXY_CIDRSdoes not apply. Renewals apply without a restart. You must set both files or you get a startup error. -
Cloudflare Tunnel.
cloudflaredgives the browser a real HTTPS origin. No TLS config of your own. -
Reverse proxy you run (nginx, Caddy).
Options 2 and 3 need TRUSTED_PROXY_CIDRS set to the proxy address, for example 127.0.0.1/32 or a pinned address on kypost-net (for example 10.89.0.10/32). Name the proxy address narrowly, not a wide range, or a peer in the range can forge X-Forwarded-For. Behind Cloudflare, the server reads CF-Connecting-IP before X-Forwarded-For.
For network layout and DNS details see
docs/Reverse_Proxy_Networking.md.
Choosing a classifier model
Measured on a 60-email benchmark, five repeats, zero run-to-run variance:
| Model | Unambiguous mail | Keyword traps | Prompt injection | RAM resident |
|---|---|---|---|---|
nemotron-3-nano:4b (default) | 100% | 75% | 63% | 2.9 GB |
gemma4:e4b | 100% | 75% | 88% | 8.8 GB |
Speed is not tabulated. It depends on CPU and host load. The same request varied from 13 to 19 seconds on one machine as background load changed. The two models were within 20% under identical conditions. The poller paces at one message every three seconds.
Use gemma4:e4b if the host has 12 GB or more free.
Per-user tuning
- Each user connects his own IMAP mailbox.
- Each account has its own label list, copied from instance defaults at creation.
- Each user has his own tuning prompt and his own decisions page.
- Label rules are instance-wide (Server tab). Email Labels are per-user.
Server — Persistence & Backup
Named volumes
There are four volumes:
kypost_config-> /kypost/configkypost_private-> /kypost/privatekypost_logs-> /kypost/logskypost_state-> /kypost/state
Host bind mount:
${OLLAMA_MODELS_HOST_DIR:-./share/ollama/models}-> /kypost/ollama-models
Ollama models are a cache. You can re-download them.
Compose keeps volumes on up --build. down -v removes them. Check real Docker volume names with docker volume ls | grep kypost. The Compose project prefix applies, for example kypost-server_kypost_config.
Important files
- /kypost/config/config.yaml (global system config)
- /kypost/config/users.json (user accounts and roles)
/kypost/config/users/<userID>/(per-user IMAP credentials, tuning prompt, notification prefs)- /kypost/config/TUNING.md (default tuning for new users)
- /kypost/config/notifications-vapid-private.pem (shared web-push signing key)
- /kypost/private/imap-config.key (master key for stored IMAP credentials)
- /kypost/private/totp-secret.key (master key for stored TOTP secrets)
- /kypost/state/state.db (global state: AI-credits flag)
/kypost/state/users/<userID>/state.db(per-user mailbox checkpoint, processed set, decision history, push subscriptions, paired devices). SQLite runs in WAL mode, sostate.db-walandstate.db-shmsit beside it.
Per-user layout:
/kypost/config/users/<userID>/: encrypted IMAP credentials,tuning.md,config.yaml/kypost/state/users/<userID>/:state.db
Backup
Back up with the container stopped. A live copy of state.db misses data still in the WAL. The four volumes depend on each other. Archive them at different moments and the set never existed together.
Steps:
docker image inspect --format '{{index .RepoDigests 0}}' \
"$(docker compose images -q kypost-server)" > backup-version.txt
cat backup-version.txt
docker compose down
docker run --rm \
-v kypost-server_kypost_config:/v/config:ro \
-v kypost-server_kypost_private:/v/private:ro \
-v kypost-server_kypost_logs:/v/logs:ro \
-v kypost-server_kypost_state:/v/state:ro \
-v "$PWD":/backup \
alpine tar czf /backup/kypost-backup.tar.gz -C /v .
docker compose up -d
Confirm archive:
tar tzf kypost-backup.tar.gz | grep -E 'private/|state/users/' | head
Store the archive and backup-version.txt together, encrypted or in a safe place. The archive holds keys that unwrap IMAP and TOTP secrets. It may also hold wrapped PGP private-key blobs and sealed device-enrollment envelopes, but it does not contain the password-derived wrapping key or a plaintext PGP private key.
Restore
Restore into empty volumes, running the same version the backup came from.
docker compose down -v
docker compose create
docker run --rm \
-v kypost-server_kypost_config:/v/config \
-v kypost-server_kypost_private:/v/private \
-v kypost-server_kypost_logs:/v/logs \
-v kypost-server_kypost_state:/v/state \
-v "$PWD":/backup \
alpine tar xzf /backup/kypost-backup.tar.gz -C /v
printf 'services:\n kypost-server:\n image: %s\n' "$(cat backup-version.txt)" \
> docker-compose.restore.yml
docker compose -f docker-compose.yml -f docker-compose.restore.yml up -d
Do not create volumes by hand with docker volume create. That makes Compose treat them as foreign and triggers first-run bootstrap. For a locally built image with no published digest, use git pull --ff-only && docker compose up --build -d from the commit it came from.
Verify the restore
- Sign in as an existing user.
- Open an encrypted message.
- Confirm a paired device still appears and TOTP still validates.
- Watch one poll tick and confirm the checkpoint advances.
Do these checks before you upgrade to a newer version. A reset checkpoint re-labels and re-notifies everything.
Server — Selected API Endpoints
This page highlights commonly used endpoints; it is not a complete API reference and does not describe every request or response body. The server router is authoritative. Native-client wire contracts are documented in the server repository and must be updated with compatible client changes.
Auth
POST /api/auth/loginGET /api/auth/login-params— per-account salt and work factor so the client can derive the auth secret; answers the same for unknown usersGET /api/auth/captcha-configGET /api/auth/mePOST /api/auth/logoutPOST /api/auth/password
Multi-factor authentication
GET /api/mfa/statusPOST /api/mfa/totp/setupPOST /api/mfa/totp/confirmPOST /api/mfa/totp/disablePOST /api/mfa/recovery-codes/regeneratePUT /api/mfa/push/enabledPOST /api/auth/mfa/totpandPOST /api/auth/mfa/recovery-code(login-time verification)POST /api/auth/mfa/push/poll,POST /api/auth/mfa/push/finish,POST /api/mfa/push/respond(push-approval sign-in)
User management (admin only)
GET|POST /api/usersPUT /api/users/{id}(change role)POST /api/users/{id}/reset-passwordPOST /api/users/{id}/deactivatePOST /api/users/{id}/reactivatePOST /api/users/{id}/clear-mfa
Runtime
GET /api/status— includesclientIpandproxyHeadersTrustedGET /api/healthPOST /api/health/repair(admin)POST /api/admin/mail/poll-now(admin)GET /api/setup(whether initial admin setup finished)GET /pickup/{id}?t=<token>(single-use mobile pickup link)
Config and data
GET|PUT /api/config(GET hidesredaction.patternsfor non-admins; PUT is admin only)GET /api/labelsGET /api/decisions(caller own decisions)GET|PUT /api/tuning(caller own prompt)
IMAP and inbox
GET|POST|DELETE /api/imap/configPOST /api/imap/testGET /api/inbox?limit=500&mailbox=<name>POST /api/inbox/actions(bulk read, archive, spam, delete, move)GET|POST|PUT|DELETE /api/inbox/foldersGET /api/mail/search
POST /api/mail/send— optionalattachments: [{name, mimeType, dataBase64}](25 MB total), optionalencryptandsign; ifencryptis true and a recipient lacks a usable key, the call fails with 409 unlessallowPickupFallbackis setPOST /api/mail/draft(same attachments shape)GET /api/mail/attachments?mailbox=&messageId=(list metadata)GET /api/mail/attachment?mailbox=&messageId=&index=(download one)
Filter Rules (caller own rules)
GET|POST /api/rulesPUT|DELETE /api/rules/{id}POST /api/rules/reorderGET|PUT /api/rules/{id}/sieve(raw Sieve)POST /api/rules/run(run on demand)
PGP
POST /api/pgp/identity/generateandPOST /api/pgp/identity/importGET|DELETE /api/pgp/identityGET /api/pgp/keyserver/lookup(keys.openpgp.org)POST /api/pgp/recipients/check(key status before send)GET /api/pgp/qr/tokenandGET /api/pgp/qr/key(QR key exchange)
Contacts
GET|POST /api/contactsGET|PUT|DELETE /api/contacts/{id}POST /api/contacts/dedupeGET /api/contacts/searchPOST /api/contacts/bulk-deleteGET /api/contacts/exportandPOST /api/contacts/importGET|POST|DELETE /api/contacts/dav-passwordGET|POST|DELETE /api/contacts/carddav-client/configandPOST /api/contacts/carddav-client/syncPOST|GET|DELETE /api/contacts/{id}/photoPOST /api/contacts/{id}/selfGET|POST /api/contacts/sync(mobile two-way sync; pairing token authenticates)
Groups
GET|POST /api/groupsPUT|DELETE /api/groups/{id}
CardDAV server
/.well-known/carddav/dav/...(per-user DAV password authenticates)
Notifications (scoped to signed-in user)
GET|PUT /api/notifications/preferencesGET /api/notifications/vapid-public-keyPOST|DELETE /api/notifications/subscriptionsPOST /api/notifications/testGET /api/notifications/pairingPOST /api/notifications/native/registerGET|DELETE /api/notifications/native/devicesPOST /api/notifications/native/unpair
Logs (admin only)
GET /api/logs?file=<name>.log&lines=<n>GET /api/logs/list
Wire contracts used by mobile clients
The Mac lists the relay endpoints that mobile clients rely on. It points to Mobile_Mail_Relay.md and Mobile_Contact_Sync.md as the reference. The list there:
GET /api/inbox—{tabs, byTab, cursor, delta, removed}GET /api/inbox/folders?parent=— full paths likeINBOX/ReceiptsPOST /api/inbox/actionsPOST /api/mail/send— comma-joined recipientsPOST /api/mail/draftGET /api/pgp/bootstrap—hasIdentity,protectionPOST /api/pgp/recipients/check(contacts-only preflight)GET/POST /api/contacts/sync(cursor-based)GET /api/pgp/qr/tokenandGET /api/pgp/qr/key?t=POST /api/notifications/native/register
What this page does not add
- No schema for bodies or errors beyond the notes above.
- No auth header format beyond pairing-token query params where noted.
- No rate limits or pagination details.
Server — Push Relays
Two Cloudflare Workers deliver native push. The maintainer runs them. Self-hosters use them through a per-server API key. No Firebase or Apple Developer account is needed, and no app recompile is needed.
- Android/FCM:
kypost-server/worker/— Firebase Cloud Messaging - iOS/APNs:
kypost-server/worker-apns/— Apple Push Notification service
self-hosted Go server --Bearer key--> Worker --service account / APNs key--> FCM / APNs --> device
Why a relay exists
The published apps are built against one Firebase project and one Apple bundle ID. Only a holder of those credentials can deliver push to them. The relay holds the one service account and the one APNs auth key. Servers forward push requests to the relay.
The backend never holds Firebase credentials and never reads google-services.json. That file belongs in the mobile project at app/google-services.json and must not be committed.
Endpoints (both workers)
Both workers expose three routes. Anything else returns 404.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /health | none | Liveness and whether configured |
| POST | /register | none | Self-issue a per-server key |
| POST | /send | Bearer key | Deliver one push |
POST /send body
FCM and APNs workers accept the same JSON from the Go backend:
{ "token": "<FCM or APNs token>", "title": "...", "body": "...", "data": { "url": "/read" }, "platform": "android" }
APNs payload example after translation:
{
"aps": { "alert": { "title": "...", "body": "..." }, "sound": "default", "mutable-content": 1 },
"messageId": "msg-123", "senderName": "...", "emailSubject": "...", "Keywords": "..."
}
Responses
200 {"ok":true}on delivery403when the token is claimed by a different active key (token pinning)410 {"stale":true}when the token is no longer registered (Go server removes the device)401for a bad or expired key429when the per-key or daily budget limit is exceeded413when the body exceeds 16 KiB400for a malformed body502 {"error":"push delivery failed"}for upstream errors (deliberately coarse; upstream text stays in operator logs)- Every error body has a
requestIdthat matches theX-Request-Idheader.
Body limits before reaching FCM/APNs:
| Field | Limit | Over limit |
|---|---|---|
| body total | 16 KiB | 413 |
| token | 512 chars | 400 |
| title | 256 chars | clipped |
| body | 1024 chars | clipped |
| data | 16 entries, keys 64 chars, values 1024 chars | 400 or clipped |
| unknown fields | — | ignored |
Titles and bodies are clipped rather than refused. The Go backend treats a dropped notification as worse than a long subject line.
Token pinning
The first key that delivers to a device token claims it. Later sends to that token must come from the same key, or the worker rejects with 403. This stops an open-relay gap that self-registration would otherwise leave.
- A claim releases only if the owning key is revoked, disabled, or expired, and only while no delivery ever succeeded under it.
- A claim younger than 60 seconds is never taken over (KV convergence).
- The
RELAY_COORDINATORDurable Object holds ownership, not KV, so check-then-write is atomic. All requests for one token route to one instance.
Deleting the key record also releases its claims for re-claiming.
Self-registration
Off by default. The shipped wrangler.toml.example sets REGISTRATION_ENABLED = "false". Set it to "true" and redeploy to open /register. With registration closed, the relay issues no keys.
Once open, the Go backend self-registers on first start when PUSH_RELAY_URL (or APNS_RELAY_URL) is set and no key exists. It calls POST /register with {"label":"..."} and persists the key under SECRET_DIR.
One active key per IP. Re-registering from the same IP mints a new key and invalidates the previous one. Servers behind the same public IP share one slot (latest wins). Self-registered keys never expire and are tagged "source":"self".
Rate limits and budget
- Per-minute limit on
/send, enforced by nativePUSH_RATE_LIMITERbinding:simple = { limit = 10, period = 60 }, fixed 60s window, no KV writes.RATE_LIMIT_PER_MINUTEin[vars]is display-only and must matchsimple.limit. Exceeding returns429with{"window":"minute"}andRetry-After. - Rolling hour/day limits were removed (they used KV read-modify-write and capped free tier at ~1000 pushes/day; an accepted send now does zero KV writes).
- Daily ceiling
RELAY_DAILY_BUDGETin[vars]: aggregate across all keys per UTC day, counted by theRELAY_COORDINATOR. Unset means unmetered.0means closed. Exceeding returns429with{"window":"day"}andRetry-Afterto midnight. Not surfaced on/health. /registerhas its own per-IP limiterREGISTER_RATE_LIMITER, bucketed on IPv6 /64.
Both fail closed. A missing binding refuses the request. /register also returns 503 with no usable CF-Connecting-IP. /send and /register return 503 without the Durable Object binding.
Key management
There is no admin API and no ADMIN_SECRET. Manage keys through KV with Wrangler. Records live in the API_KEYS namespace under three prefixes: key:<sha256>, keyid:<id>, ipkey:<bucket>.
NS=<your API_KEYS namespace id>
npx wrangler kv key list --namespace-id $NS --prefix "key:"
npx wrangler kv key get --namespace-id $NS "key:<hash>"
HASH=$(npx wrangler kv key get --namespace-id $NS "keyid:<id>")
npx wrangler kv key delete --namespace-id $NS "key:$HASH"
npx wrangler kv key delete --namespace-id $NS "keyid:<id>"
npx wrangler kv key delete --namespace-id $NS "ipkey:<registeredIp>"
Send counts and last-seen are in Analytics Engine (kypost_push_usage), not in KV.
Setup (maintainer)
Install, log in, create KV namespaces, paste IDs into wrangler.toml (gitignored, template is wrangler.toml.example), set secrets, deploy:
cd worker # or worker-apns
npm install
npx wrangler login
cp wrangler.toml.example wrangler.toml
npx wrangler kv namespace create API_KEYS
npx wrangler kv namespace create OAUTH_CACHE # FCM
npx wrangler kv namespace create APNS_TOKEN_CACHE # APNs
npx wrangler secret put FCM_CLIENT_EMAIL
npx wrangler secret put FCM_PRIVATE_KEY
npx wrangler secret put FCM_PROJECT_ID
# APNs instead:
npx wrangler secret put APNS_AUTH_KEY
npx wrangler secret put APNS_KEY_ID
npx wrangler secret put APNS_TEAM_ID
npx wrangler secret put APNS_TOPIC
npx wrangler secret put APNS_ENVIRONMENT # production or sandbox; unset means production
npx wrangler deploy
The [[migrations]] block creates the RELAY_COORDINATOR Durable Object on first deploy.
APNs notes
- APNs requires HTTP/2. Workers
fetch()negotiates it via ALPN. - The provider token (JWT from
.p8) caches for ~29 minutes (Apple allows ~60). The worker refreshes it and clears cache on invalid token. - Rotate the
.p8key yearly. Apple sends renewal notices.
Observability
- Each request gets a UUID
requestIdinX-Request-Idand error bodies. One JSON log line per request. Tail withnpx wrangler tail. GET /healthreturns{ ok, configured, rateLimits: { perMinute }, registrationEnabled }with no auth.configuredis false until all secrets are set.
APNs environment split
Duplicate wrangler.toml to wrangler.prod.toml, set per-env namespace IDs and APNS_ENVIRONMENT, then npx wrangler deploy --env dev and prod. Point the backend at the right APNS_RELAY_URL.
Clients — Overview
The ecosystem has three native clients. Each talks only to the relay. No client uses IMAP or SMTP. One device pairing with a QR code or deep link creates the relay credential. After that, the relay handles mail, keyword tabs, push, and contact sync.
Which client for which platform
- Android —
kypost-android, packageorg.kysecurity.mail, FCM or UnifiedPush + pull fallback. The package rename is breaking for existing installs. - macOS & iOS —
kypost-for-Mac, bundlecom.urlxl.mail, SwiftUI shared codebase, APNs + polling. - Linux —
kypost-Linux, app idcom.kysecurity.mail, KDE Plasma and Plasma Mobile, Qt6/Kirigami Flatpak, UnifiedPush + polling.
Common traits
All clients share:
- Relay-only access. No mail credentials on the device.
- A per-device credential minted at registration (
deviceId+deviceSecret), revocable one device at a time, that cannot sign in to webmail. - Keyword-based inbox tabs that come from server fields. The user tunes them on each client.
- Compose with contact autocomplete and an address-book picker.
- Two-way contact sync against the relay.
- PGP public-key exchange through QR codes with fingerprint confirmation out of band.
- Optional device enrollment, after which the client reads end-to-end encrypted mail without the server. Until then, encrypted mail hands off to webmail rather than showing ciphertext. See PGP & Encryption.
- Signature verdicts decided locally from keys the device holds, in six states. The relay’s own
verifiedflag is not read. - Certificate pinning at first pairing. Android can additionally receive the pin in the pairing QR, closing the trust-on-first-use window.
- Hostile Location Protection or equivalent keep-no-data-on-device option.
- The same theme system. 15 themes that match byte for byte. Default is Patina Ky.
- Deep-link scheme
kypost://
Differences the READMEs highlight
| Area | Android | macOS/iOS | Linux |
|---|---|---|---|
| UI | Native Android screens | iOS tab layout (MainTabView), macOS NavigationSplitView + pop-out WindowGroup | Plasma desktop 3-column, Plasma Mobile bottom-tab, QML MobileRoot / DesktopRoot |
| Mail rendering | Not detailed | WebKit with JS off, remote content blocked | WebEngineView with JS and remote images disabled |
| Push | FCM native + pull GET polling, per-user push/pull mode on web | APNs + 90s foreground polling + iOS background refresh | UnifiedPush distributor + 90s polling |
| Lock | PIN or biometric, escalating delays, wipe-on-repeat, common PINs rejected | Require Unlock (Face ID/Touch ID/passcode), Hostile Location erases cache | PIN lock with configurable lockout, grace period and erase threshold; credential seal with AES-256-GCM + Argon2 |
| On-device PGP | Reads and writes once enrolled | Reads once enrolled; sending still goes through the relay | Reads and writes through the user’s own gpg-agent |
| Cached mail at rest | SQLCipher, key in the Keystore | SwiftData store with backup exclusion | SQLCipher, converted in place on upgrade |
| Layout | Phone, plus a navigation rail and wide layouts at 600dp and master-detail via activity embedding at 800dp | iOS tab layout, macOS three-pane + pop-out readers | Plasma desktop 3-column, Plasma Mobile bottom-tab |
| QR camera | Scan or show QR on one screen (Play Services code scanner) | iOS scans with camera + pasted link fallback; macOS pasted link only | Camera scan and show |
| Language | Kotlin (org.kysecurity.mail) | Swift, SwiftUI, SwiftData, URLSession, WebKit; GopenPGP as its one external dependency | C++/QML, Qt6/Kirigami, SQLite/SQLCipher, GPGME |
Bundle and naming
- The Mac README says the app name is KyPost everywhere (Dock, Home Screen, About, permission prompts, project and scheme, deep link). Bundle IDs and Keychain group stay
com.urlxl.mailon purpose. Renaming them is a separate high-risk step. SeeBrand_Refresh_KyPost.md. - Android app ID is
org.kysecurity.mail. Existing installs ofcom.urlxl.mailmust reinstall, pair again, and re-enrol any device-held PGP identity. - Linux app ID is
com.kysecurity.mail, renamed fromcom.urlxl.mail. Seedocs/RENAME_NOTES.mdin that repo.
Pairing
All clients pair from the web app. See Pairing for the deep-link format and validation.
Android Client
Source: kypost-android/README.md.
What it is
KyPost for Android is an Android email client backed by a self-hosted KyPost relay. It shows keyword-based inbox tabs and syncs contacts in both directions. Native-push pairing authenticates relay access and contact sync. It gets notifications through native pairing and FCM or direct pull. The current Android application id is org.kysecurity.mail; the package rename is a breaking change, so existing com.urlxl.mail installs must reinstall and pair again.
Features
- Mail: The paired relay proxies mail. The app stores no mail credentials on the device.
- Keyword tabs: Tabs come from server tab and label fields. Tune them in the Keywords screen.
- Compose: To, Cc, and Bcc complete from local contacts. An address-book picker adds recipients.
- Contacts: Two-way sync against the relay. Open it from the Inbox overflow menu.
- PGP: One screen shows your own PGP public-key QR code and scans another person’s, saving that key onto an existing contact. The app encrypts outgoing mail to a recipient’s key on the device — the plaintext body never reaches the relay for that path — and decrypts client-custody mail once it holds a sealed key envelope (see Device enrollment). Without that envelope, encrypted mail hands off to webmail in the user’s own browser or the installed webmail PWA, never a Custom Tab.
- Signature badges: decided on the device from keys in the local contact store. The relay’s
verifiedflag is not read at all. - MFA push approval: Push notifications approve or deny logins. An in-app screen handles approval when OEM background limits block the notification.
- Themes: Shares presets with the web app. Default is Patina Ky. Choose in the Themes screen.
- Push notifications: System notifications plus in-app history for new mail. Delivery is FCM, a UnifiedPush distributor you choose, or App Pull, which polls your own server and involves neither. Each user selects the delivery mode (
pushorpull) on the web Notifications page. - Large screens: a navigation rail and wide inbox, contacts and compose layouts at 600dp, and inbox/contacts master-detail through activity embedding at 800dp — measured on a Fold, where 720dp wrongly split the 751dp cover screen. State survives fold and unfold: the folder, tab, scroll position, an in-progress contact edit, and the app lock.
- Security: an app-lock PIN or biometric with escalating delays and wipe-on-repeat, Hostile Location Protection, and a SQLCipher-encrypted mail database. See Security Model.
Push pairing
Deep link or QR code:
kypost://native-pair?sub=<subscriberId>&srv=<serverUrl>®=<registrationUrl>&pt=<pairingToken>&pin=<tlsPin>
Required params: sub, srv, pt. Optional: reg, pin.
srv and any reg must be https and the same origin. The app refuses the link otherwise, because reg is where the device secret is minted while the confirmation dialog shows the user srv. hash is legacy: a link that still carries it comes from an outdated server, the app ignores it, and the server no longer accepts it.
Authentication is per device, not per account. Registration exchanges the one-time pairingToken for a deviceId and a deviceSecret minted by the server. Every later request sends them as X-Kypost-Device-Id and X-Kypost-Device-Secret headers. Revoke a single device from the server’s Security page.
Storage: a Keystore-backed EncryptedSharedPreferences file holds the subscriber id, server URL, registration URL, pairing token, device id and device secret. Plaintext DataStore holds notification history and sync status.
Registration: The app calls reg from the QR code. If reg is absent, it uses {srv}/api/notifications/native/register. It sends the FCM token. It marks the device as paired only after success (ok:true or synced:true). A scan alone does not pair it. It repeats the call on token refresh, and each successful registration mints a new device secret that invalidates the previous one.
FCM data keys: messageId, senderName, emailSubject, Keywords.
Pull mode (FCM bypass)
The registration response also returns deliveryMode (push or pull) and pullEndpoint. If pullEndpoint is absent, the app uses {srv}/api/notifications/native/pull.
In pull mode the app polls:
GET {pullEndpoint}?after=<cursor>
The cursor is the only query parameter. Authentication is the X-Kypost-Device-Id and X-Kypost-Device-Secret headers, never a query parameter — credentials in a URL end up in access logs and browser history. No session and no Bearer token. FCM stays registered but is not the source of truth.
The same dispatcher that handles an FCM data message renders each polled notification. Tap behavior is identical.
Dedup uses strictly increasing seq. The app keeps a durable per-subscriber lastCursor at max(lastCursor, response.cursor) only after it delivers notifications, so a crash causes a re-fetch, not a loss.
deliveryMode in both register and pull responses is authoritative. A change to push on the web stops polling. A change to pull starts it. The app reads it again on each foreground.
Cadence: WorkManager periodic work at the platform minimum of 15 minutes, plus immediate pull on foreground and after pairing. Near real-time needs a foreground service with a short loop and a persistent notification. That is not the default. The app backs off after 400, 401, 503, and network errors.
Firebase setup
- Create or update the Firebase Android app for
org.kysecurity.mail. - Download
google-services.json. - Put it at
app/google-services.json. - Enable FCM.
Notification permission (Android 13+)
The app requests POST_NOTIFICATIONS at launch. If the user denies it, the app still parses payloads and saves them to in-app history. It does not show system notifications.
Pairing steps from QR
- Web app shows a QR code with the deep link (
sub,srv,pt, optionalregandpin). - In Push Notifications, tap Scan QR Code or open
kypost://native-pairdirectly. The scanner is Google’splay-services-code-scanner; pair by entering the URL instead if you would rather keep Play Services out of that path. - The app validates
sub,srv,pt, checkssrvand anyregarehttpsand same-origin, and resolves the registration endpoint. - It shows a confirmation dialog naming the server host, then calls the native registration endpoint with the FCM token. Success marks the device as paired and stores the returned
deviceIdanddeviceSecret. - On each token refresh, it repeats the registration with stored pairing.
Troubleshooting
- Scheme and host must be exactly
kypost://native-pair. - Required params
sub,srv,ptmust exist, andsrvandregmust behttpsand the same origin. - Device must reach the resolved registration endpoint.
- Firebase config must match
org.kysecurity.mail. 400means malformed or missing field.401means bad or expiredpt; scan a new QR code.503means backend has noPAIRING_SECRETand retry will not help.- On Android 13+, grant notification permission or no system notification appears.
Installing
Signed builds are attached to each tagged
release: an .apk
to sideload, an .aab for Play distribution, and a .sha256 beside each one.
Verify before you install — the digest is the whole point of publishing it:
sha256sum -c kypost-<tag>.apk.sha256
There is no F-Droid or Play listing. The signing key is checked in CI before
publication: the workflow refuses to release unless apksigner reports the
expected signer, so an APK that verifies against the published digest carries
the key the project intends.
Build and test
./gradlew testDebugUnitTest
./gradlew assembleDebug
./gradlew connectedDebugAndroidTest # needs device or emulator
Instrumented tests need a connected device or emulator with a secure lock screen set. The Keystore-backed enrollment vault refuses to create a key without one by design, so on a bare emulator the vault suites fail as though the code were broken. CI sets a PIN first.
Release builds
:app:assembleRelease fails without signing material, deliberately: AGP does not
fall back to the debug keystore, so without the guard a green run would emit an
unsigned APK. Add a gitignored keystore.properties at the repository root with
storeFile, storePassword, keyAlias and keyPassword.
CI’s release-build job generates a throwaway key per run, so every PR verifies
the R8 configuration and lintVitalRelease without the real key being reachable
from a pull_request trigger. Published builds come from release.yml on a v*
tag: it reads the real key from the protected release environment, checks the
tag against versionName, and refuses to publish unless apksigner reports the
expected signer. Never build a release for distribution from a workstation.
Test coverage:
- Deep-link parser (
NativePairingDeepLinkParserTest) - Pairing validation (
PairingValidatorTest) - Registration endpoint resolution (
NativeRegistrationEndpointResolverTest) - Payload parsing (
messageId,senderName,emailSubject,Keywords) - Registration request mapping (
NativeRegistrationRequestMapperTest) - Secure pairing store round-trip and encryption (
SecurePairingStoreTest, instrumented)
macOS & iOS Client
What it is
A native SwiftUI mail client for macOS and iOS. It connects only to the KyPost mail relay. No direct IMAP or SMTP. One QR code or deep-link pairing registers the device. The relay then handles mail, keyword tabs, push, and contact sync.
Name is KyPost everywhere (Dock, Home Screen, About, permission prompts, Xcode project, scheme, folders, deep link kypost://). Bundle IDs and Keychain group stay com.urlxl.mail on purpose. See Brand_Refresh_KyPost.md.
Features
- Inbox with keyword tabs — relay sorts mail into tabs and labels. Set tab visibility in settings.
- Server folders — Inbox and subfolders, Drafts, Junk, Sent, Trash, Archive with subfolders. macOS shows them in the sidebar. iOS in the folder menu on the Inbox screen.
- HTML rendering — themed WebKit reader on both platforms. Links open in the default browser. Plain text renders natively.
- macOS extras — three-pane split view, pop-out email windows (double-click or right-click), preview pane toggle, drag-and-drop onto sidebar folders to move, menu-bar commands (⌘N compose, ⌘R refresh, ⌘⇧S contact sync), native Preferences window (⌘,).
- Compose and send through the relay.
- Push — APNs for new mail and MFA challenges. Polling fallback also runs (90 s foreground, background refresh on iOS).
- MFA approval — approve login challenges from a notification tap.
- Contact sync — two-way sync with the relay. Local edits win first, reconciliation keeps data safe. Contacts carry groups, photo, IM and social handles, websites, relations, extra dates, phonetic names, department, custom fields, pronouns, PGP public key.
- PGP QR exchange — My QR Code makes a pickup link that expires in 2 minutes. Scan to add contact key reads another person link, shows fingerprint for out-of-band confirmation, and saves the key to a contact. iOS scans with camera and accepts a pasted link. macOS accepts only a pasted link (no VisionKit).
- Encryption state on each message — a message the server decrypted says so, and you can then see that the server read your mail. For a server-custody account the app holds no private key at all. For a client-custody account it holds one only after you deliberately run device enrollment: the key is sealed into the Secure Enclave and released on user presence, and a Decrypt action then appears on client-protected messages. Enabling Hostile Location Protection or resetting a stranded app lock destroys that key, and neither brings it back. Until a device is enrolled, encrypted mail links out to webmail. See
docs/E2E_PGP.mdin the server repo. - Signature badges — six states, computed on the device from keys it holds. Identity (confirmed out of band) and continuity (same key as last time) are separate claims, and a bound key that no longer matches its pin raises
keyChangedrather than silently re-pinning. - Folder management — create, rename and delete server folders.
- Cursor-based delta sync — refreshes fetch the delta rather than a full folder snapshot.
- Phishing flag — the app surfaces the server’s flag on mail impersonating KyPost, and seals the body’s link path.
- Encrypted and signed send — for an account whose key the server holds, Encrypt and Sign flags travel with the message and the relay does OpenPGP work. If a recipient lacks a usable key, the relay refuses first and asks you. If you confirm, it mails a one-time pickup link and stores the plaintext on your server for up to 7 days with named recipients. An account whose key only the browser can unwrap cannot encrypt from this app yet: the OpenPGP core is linked and the crypto seam supports encrypt-and-sign, but the send path still goes through the relay. The app saves the draft on the server and hands off to webmail.
- Themes — 15 palettes that match web and Android exactly. Default is Patina Ky.
Security (Settings → Security)
- Require Unlock to Open — gates the app with Face ID, Touch ID, or device passcode (
LAContext). OS controls rate-limit and lockout. No app PIN. iOS locks when you background the app. macOS locks when the screen locks, not when you switch apps. - Hostile Location Protection — keeps no mail, contacts, or attachments on device. All data stays in memory and reloads from the server. The app erases the local cache when you turn it on. Limits: attachment preview writes a file to the app sandboxed temp dir while open (Quick Look needs a file) and deletes on dismissal; erase is a plain delete, not a forensic overwrite.
- Require unlock for notifications and MFA — moves the relay credential behind user presence. Background mail checks and MFA waits until you open and unlock the app. On iPhone this covers all background delivery. On Mac it applies while the screen is locked.
- Always on: TOFU certificate pinning (pins key at first pairing, never re-pins silently; refuses a certificate it cannot hash; re-pair in Settings → Connection if cert changes), sender HTML with JS off and remote content blocked, navigation out of message denied by default, PGP fingerprints computed locally from key bytes, local store with backup exclusion, screen-capture protection (macOS excludes windows from recordings and sharing; iOS covers content during recording or mirroring; neither blocks a plain screenshot).
Requirements
- Xcode 26, deployment target macOS and iOS 26.5.
- A running relay backend (live deployment behind Cloudflare at
mail.urlxl.com). - For push: an APNs key on the backend.
- SwiftData, URLSession and WebKit. One external dependency: GopenPGP, consumed as a checksummed binary XCFramework through the local package in
Dependencies/GopenPGP. It is built from source by.github/workflows/gopenpgp-xcframework.ymlfrom the upstream tag pinned inDependencies/gopenpgp.env, using upstream’s ownbuild.sh, andPackage.swiftpins its SHA-256 so the bytes cannot change without that line changing. Nothing outsideKyPost/Domain/Security/PgpCrypto.swiftand its conforming types may reference the library.
Getting started
- Open
KyPost.xcodeprojin Xcode. - Select the KyPost scheme and your destination (My Mac or iOS device/simulator).
- Build and run.
- Pair the device. In the web frontend, open Notifications → Pair Desktop App. On iOS scan the mobile QR code. The
kypost://native-pair?...deep link registers the device and stores credentials in the Keychain.
Until you pair, the inbox shows a prompt that points to Settings → Connection.
Architecture
One codebase in KyPost/ from layers described in Architecture. Platforms split at the root view.
Wire contracts
The README says the Android repo defines relay endpoints and payload shapes. See Mobile_Mail_Relay.md and Mobile_Contact_Sync.md. The primary list:
GET /api/inbox—{tabs, byTab, cursor, delta, removed}GET /api/inbox/folders?parent=— full paths likeINBOX/ReceiptsPOST /api/inbox/actions— bulk read, archive, spam, delete, movePOST /api/mail/send— comma-joined recipientsPOST /api/mail/draft— same shape as send, without PGP flagsGET /api/pgp/bootstrap—hasIdentity,protectionPOST /api/pgp/recipients/check— contacts-only preflight (never/resolve)GET/POST /api/contacts/sync— cursor-basedGET /api/pgp/qr/token— make pickup token and URL that expire in 2 minutesGET /api/pgp/qr/key?t=— get scanned key and fingerprint (token is credential)POST /api/notifications/native/register— APNs registration
The README warns: before you change any endpoint, read the Android implementation. Do not guess.
Testing
Unit tests use Swift Testing (@Test and #expect) in KyPost Tests/. UI stubs in KyPost UITests/. Run with ⌘U or:
xcodebuild test -project "KyPost.xcodeproj" -scheme "KyPost"
Network tests run against a stubbed HTTPClient. No backend is needed.
Known gaps (v2 candidates)
- Attachments (compose and viewing)
- Read, archive, delete from the reader. Move-by-drag works on macOS.
- Draft saving from compose. PGP webmail handoff saves a draft, but no Save Draft button and no auto-save.
- Server-side search. Search runs against local cache.
- QR scanning with camera on macOS (must paste; camera works on iOS).
- On-device encrypt and sign. Reading client-protected mail has landed; writing it still goes through the relay.
Closed since the last revision of this page: cursor-based delta sync, and folder create, rename and delete.
Linux Client
What it is
KyPost for Linux is a relay-only email client for KDE Plasma and Plasma Mobile, built with Qt6/Kirigami. It does not use IMAP or SMTP. The relay backend carries all mail, contact, and push traffic. One codebase targets two surfaces from one Flatpak:
- Linux Desktop — KDE Plasma, 3-column sidebar, list, detail.
- KDE Mobile — Plasma Mobile, bottom-tab push navigation.
It is a sibling to the Android and SwiftUI apps. All three use the same Go relay.
Features
- Inbox, message detail, HTML composer — reply, reply-all, forward, send, with attachments and drafts. A sandboxed
WebEngineViewrenders the body with JavaScript and remote images disabled. - Server-side folders — standard mailboxes and subfolders. Create, rename, delete.
- Contacts — synced list and detail. Create and edit offline and queue changes. Handles group membership and duplicates.
- PGP — exchange public keys through QR codes. Scan or show a key with the camera, confirm the fingerprint out of band. Webmail can seal the account key to a temporary ECDH device key; KyPost imports it into the user’s GnuPG keyring and clears the transient key bytes. Once enrolled, end-to-end encrypted mail is decrypted and signed on this device through the user’s own
gpg-agent(GPGME), so KyPost never sees the OpenPGP passphrase and hardware tokens and smartcards work unchanged. Sending signs and encrypts locally, gives each blind recipient their own ciphertext, keeps the real subject inside the ciphertext, and refuses to send at all if any recipient has no usable key — there is no silent downgrade. Attachments can be attached to an encrypted message. Mail this device holds no key for is still explained and routed to webmail. - Compose autocomplete — type name or address, select a synced contact.
- Push over UnifiedPush — two tiers with fallback: system distributor, then 90-second polling. Mail still arrives with no distributor. Only your distributor push server sees a notification.
- Security — an optional PIN lock with a lockout, a configurable background-lock grace period, and a configurable erase-after-N-failures threshold (including “never”, which declines only the erase; the rate limit always stays). You can encrypt the pairing credential behind that PIN (AES-256-GCM with Argon2; Qt exposes neither, see
core/security/CredentialCipher.h). The local database is encrypted at rest with SQLCipher, and an existing plaintext profile is converted on the next launch. The client pins the relay’s TLS chain on first use, anchored to the issuer rather than the leaf, so a routine renewal is not reported as impersonation. Hostile Location Protection writes nothing to disk and refuses to turn on at all if it cannot first erase what is already there. - 15 themes — copied byte for byte from the shared design system.
- Device pairing — paste a link or use a
kypost://deep link. - Localized — every user string is wrapped for translation (
po/).
Installing
KyPost has its own signed Flatpak remote, and it is live for both x86_64 and
aarch64:
flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --if-not-exists kypost https://busness-app.github.io/KyPost-for-Linux/kypost.flatpakrepo
flatpak install kypost com.kysecurity.mail
flatpak update installs new versions. Flathub supplies the org.kde.Platform//6.11 runtime. KyPost is not on Flathub and will not be; Flathub bans applications that use AI and the KyPost backend uses AI. A tagged release also attaches a single-file .flatpak bundle per architecture — v0.2.0 carries kypost-x86_64.flatpak and kypost-aarch64.flatpak — but a bundle gives you no automatic updates. Prefer the remote unless you need an offline install. See docs/DISTRIBUTION.md.
Building
KyPost needs Qt6. It dropped Qt5 and Ubuntu Touch. See AGENTS.md for reasons and review conditions. Use one out-of-tree build dir:
cmake -B build -S . -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build
ctest --test-dir build
Dependencies (Arch names): qt6-base, qt6-declarative, qt6-webengine, qt6-multimedia, kirigami (KF6), knotifications (KF6), kstatusnotifieritem (KF6), kdbusaddons (KF6), ki18n (KF6), qtkeychain-qt6, kunifiedpush, zxing-cpp, openssl, argon2, gpgme. For Ubuntu and KDE neon equivalents, see .github/workflows/ci.yml.
openssl supplies AES-256-GCM and argon2 supplies the memory-hard key derivation for the credential seal. See core/security/CredentialCipher.h. gpgme is the C API, not the gpgmepp C++ wrapper, and it is what delegates OpenPGP custody to the user’s gpg-agent.
Encryption at rest needs SQLCipher
The command above builds a working client whose database is not encrypted —
configure reports SQLCipher: not configured (KYPOST_SQLCIPHER_ROOT unset).
SQLCipher has to carry the SONAME libsqlite3.so.0 and be built with
SQLITE_ENABLE_COLUMN_METADATA, which distro packages generally do not do, so
the repo builds it the same way CI and the Flatpak manifest do:
./scripts/build-sqlcipher.sh /tmp/sqlcipher
cmake -B build -S . -DCMAKE_BUILD_TYPE=RelWithDebInfo -DKYPOST_SQLCIPHER_ROOT=/tmp/sqlcipher
Flatpak
flatpak-builder --user --force-clean --install-deps-from=flathub \
build-flatpak packaging/flatpak/com.kysecurity.mail.yaml
flatpak-builder --run build-flatpak packaging/flatpak/com.kysecurity.mail.yaml kypost
This manifest is the packaging target for desktop and mobile. packaging/click/ is an empty placeholder until UBports releases a Qt6/KF6 track.
CI builds the manifest for each PR. For each push to main, CI publishes a signed OSTree repo to gh-pages ( .github/workflows/flatpak.yml). The flatpak remote-add command above uses that repo.
Architecture
As noted in Architecture. The core/ boundary allows only Qt Core, Network, Sql. See AGENTS.md Section 5.
Authoritative design source: Linux_QT_Client_Plan.md (decisions, wire contracts, push state machine, risks and gaps). docs/PARITY.md is the authoritative Android-parity matrix. TESTING.md is the manual checklist. AGENTS.md summarizes rules that break most easily. tests/guards.tsv lists the security guards proven load-bearing, and scripts/verify-guards.sh checks them.
Pairing & Device Registration
All clients pair from the web app. The server creates the link. The client registers its push token.
Deep link format
kypost://native-pair?sub=<subscriberId>&srv=<serverUrl>®=<registrationUrl>&pt=<pairingToken>&pin=<tlsPin>
-
Required:
sub,srv,pt -
Optional:
reg,pin -
sub— subscriber id -
srv— server URL (origin the client must use) -
reg— registration URL (if absent, derive{srv}/api/notifications/native/register) -
pt— signed pairing token, valid for 90 seconds -
pin— the server’s leaf SPKI pin, present only when the server terminates TLS itself. It lets the client pin the registration call before it discloses the pairing token and its push credentials, instead of sending them inside a trust-on-first-use window. Absent means TOFU. It is percent-encoded, and a malformed pin fails closed rather than dropping back to TOFU.
hash is legacy. Older servers emitted a subscriberHash here; the current
clients ignore it and the server no longer accepts an account-wide subscriber
HMAC as authentication. A link that still carries it comes from an outdated
server.
srv and any reg must both be https and the same origin. Clients refuse
the link otherwise, because reg is where the device secret is minted while the
confirmation dialog shows the user srv.
Warning: set SERVER_BASE_URL in .env so srv and reg point to the correct public backend URL, and so SSO’s redirect URI does not come from the request’s Host header. That URL must be https:// because each pairing token, pickup link, and QR key-exchange URL carries a Bearer token in the query string.
Server behavior
- Security → Devices renders a QR code link with
sub,srv,reg,pt, and — when KyPost terminates TLS itself —pin. The panel re-mints a token on every call. ptis valid for 90 seconds. The UI shows a 4px countdown bar under the QR code. It shrinks over 90 seconds and changes from green to red. It is red for the last 15 seconds.- Pairing secret is generated on first start at
/kypost/private/pairing.key. SetPAIRING_SECRETonly if replicas must share one. Value must be 32 bytes or longer or the three features stay disabled.
Native registration:
POST /api/notifications/native/registervalidates the pairing token, stores device metadata and token, and mints adeviceIdplus adeviceSecretreturned exactly once. Each later registration mints a new secret and invalidates the previous one.- Every later request authenticates with
X-Kypost-Device-IdandX-Kypost-Device-Secretheaders — never a query parameter, because credentials in a URL end up in access logs and browser history. GET /api/notifications/native/deviceslists paired native devices.DELETE /api/notifications/native/devicesremoves one device bydeviceId. Revocation is per device.POST /api/notifications/native/unpairrevokes all devices for the user.
A PAIRING_SECRET shorter than 32 bytes disables pairing, pickup links, and PGP QR exchange. The reason is logged. Bytes matter, not characters, because the value is the HMAC key verbatim.
Client behavior
Android
- The current application id is
org.kysecurity.mail. The rename fromcom.urlxl.mailis breaking: existing installs must reinstall, pair again, and re-enroll any device-held PGP identity. - Stores pairing material (subscriber id, server URL, registration URL, pairing token, device id, device secret) in a Keystore-backed
EncryptedSharedPreferencesfile. Notification history and sync status stay in plaintext DataStore. - Validates the required params, checks that
srvand anyregarehttpsand the same origin, resolves the registration endpoint, then shows a confirmation dialog naming the server host before it calls anything. - Calls the registration endpoint with the FCM token (
regor derived). Marks paired only on success (ok:trueorsynced:true). A scan alone does not pair it. Repeats on token refresh, storing the newdeviceIdanddeviceSecreteach time. - The wipe’s server-side deregistration goes over the pinned connection using a pin captured before the wipe deletes it. With no pin it is refused rather than downgraded, so the credential is never handed to an unpinned connection at the moment the device is most likely to be on a hostile network. The cost is that the relay may keep the device listed until it is revoked by hand.
macOS & iOS
- Deep link
kypost://native-pair?...registers the device and stores credentials in the Keychain. - Until you pair, the inbox shows a prompt that points to Settings → Connection.
- TOFU certificate pinning at first pairing; re-pair in Settings → Connection if the cert changes.
- Bundle IDs and the Keychain access group stay
com.urlxl.mailon purpose; only the user-facing name and the deep-link scheme are KyPost.
Linux
- The application id is
com.kysecurity.mail. The rename fromcom.urlxl.mailis recorded indocs/RENAME_NOTES.mdin that repo. - Paste a link or use the
kypost://deep link. - Can encrypt the pairing credential behind the PIN (AES-256-GCM + Argon2).
- TOFU pinning on first use, anchored to the certificate’s issuer, so a renewal is not mistaken for impersonation. A certificate change is recovered from without destroying the pairing.
- “Secret store unreadable” is reported as itself, not as “never paired”.
Troubleshooting (Android list, applies broadly)
- Use exactly
kypost://native-pair. - Provide
sub,srv,pt. A link still carryinghashcomes from an outdated server; it is ignored. srvandregmust both behttpsand the same origin.- Device must reach the resolved registration endpoint.
- Firebase config must match
org.kysecurity.mail(Android). 400malformed,401bad/expiredpt(scan new QR),503backend has noPAIRING_SECRET.- On Android 13+, grant notification permission.
Push Notifications
Two families
The ecosystem uses two push families:
- Browser push: for the web app (VAPID key at
/kypost/config/notifications-vapid-private.pem, subscription endpoints under/api/notifications/*). - Native push: for mobile clients through the relay Workers, plus polling fallbacks on each client.
The server README says each user can choose browser notifications for all mail or for keyword matches only, and each user can pair native devices.
Native push path
KyPost poller --> Go server --> Cloudflare Worker (FCM or APNs) --> FCM / APNs --> device
- The Go server forwards push to
PUSH_RELAY_URLwithPUSH_RELAY_KEY(Android/FCM) orAPNS_RELAY_URLwithAPNS_RELAY_KEY(iOS/APNs). - Each needs
https://except for loopback, because the relay key travels on each request. - Self-hosters ask the relay operator for per-server API keys. See Push Relays for self-registration.
Payload keys from the README: messageId, senderName, emailSubject, Keywords (and token, title, body, data, platform). The Workers translate them to FCM or APNs shapes. Both platforms handle the full payload identically client-side (APNs worker-apns/README.md shows the mapping).
Generic notifications by default
Push notifications are generic by default because subject lines are not encrypted in ordinary PGP/MIME. Sender and subject reach FCM or APNs only if the user turns previews on.
Encrypted mail is excluded from push payloads entirely, whatever the Content Preview setting, because native push travels through a relay and on to FCM or APNs in cleartext at every hop. Under Hostile Location Protection on Android, notifications carry no sender and no subject whether or not the app is locked.
Native push providers handle the payload sent to them and can observe delivery metadata such as device tokens and timing. Web Push is different: its payload is encrypted to the browser’s subscription keys.
Client specifics
Android (from kypost-android/README.md)
- Transports: FCM, a UnifiedPush distributor you choose, or App Pull, which polls your own server and involves neither Google nor a distributor.
- Delivery modes:
pushorpull, per user on the web Notifications page.pullmeans the server sends nothing to FCM and the app polls directly. - FCM path: data payload keys
messageId,senderName,emailSubject,Keywords; system notifications plus in-app history;POST_NOTIFICATIONSpermission on Android 13+. - Pull path:
GET {pullEndpoint}?after=<cursor>. The cursor is the only query parameter; authentication is theX-Kypost-Device-IdandX-Kypost-Device-Secretheaders, never a query parameter, because credentials in a URL end up in access logs and browser history.seqdedup,lastCursordurable per subscriber and advanced only after delivery; respectsdeliveryModefrom register and pull responses on each foreground; background WorkManager at 15 minutes plus foreground pull; optional foreground service for near real-time with a persistent notification (not the default); backs off on400,401,503, network errors. - Storage:
deliveryModeandpullEndpointstored; ifpullEndpointabsent, derived as{srv}/api/notifications/native/pull.
macOS & iOS (from kypost-for-Mac/README.md)
- APNs for new mail and MFA challenges. Polling fallback runs at 90 s in the foreground, background refresh on iOS.
- If Security → Require unlock for notifications and MFA is on, background checks wait until you open and unlock the app. On iPhone this covers all background delivery. On Mac it applies while the screen is locked.
Linux (from kypost-Linux/README.md)
- UnifiedPush with two tiers and fallback: distributor first, then 90-second polling. Only the distributor push server sees a notification. Mail still arrives with no distributor installed.
Server API for native push
POST /api/notifications/native/register(pairing token auth; returns adeviceIdand adeviceSecret, and invalidates the previous secret)GET|DELETE /api/notifications/native/devices(per-device revocation)POST /api/notifications/native/unpair
Everything after registration authenticates with the X-Kypost-Device-Id and
X-Kypost-Device-Secret headers. See Pairing.
Relay protection
- Per-minute limiter (10/min), per-IP register limiter, optional aggregate daily budget
RELAY_DAILY_BUDGET. See Push Relays. - Token pinning: first key to deliver to a token claims it; revoked keys release claims after ~60 s; Durable Object enforces it.
What the READMEs do not say
- Exact browser push enrollment steps beyond
GET /api/notifications/vapid-public-keyandPOST|DELETE /api/notifications/subscriptions. - No mention of sound, badge counts, or notification grouping.
Contacts & CardDAV
CardDAV Support
KyPost has a first-class contacts store and two sync surfaces: the native relay sync and CardDAV.
Address book (server)
- Contacts address book with groups, dedupe, bulk delete, CSV and vCard import and export, photo support.
- CardDAV server at
/davand/.well-known/carddavto sync to phones and desktop apps (per-user DAV password authenticates). - Optional CardDAV client that syncs against an external address book.
Data lives per user in SQLite state.db alongside mailbox state, plus notification prefs in config.yaml. Photos are handled by POST|GET|DELETE /api/contacts/{id}/photo.
Endpoints:
GET|POST /api/contacts,GET|PUT|DELETE /api/contacts/{id}POST /api/contacts/dedupe,GET /api/contacts/search,POST /api/contacts/bulk-deleteGET /api/contacts/exportandPOST /api/contacts/importGET|POST|DELETE /api/contacts/dav-passwordGET|POST|DELETE /api/contacts/carddav-client/configandPOST /api/contacts/carddav-client/syncPOST|GET|DELETE /api/contacts/{id}/photo,POST /api/contacts/{id}/self- Groups:
GET|POST /api/groups,PUT|DELETE /api/groups/{id}
Relay sync for native clients
Mobile two-way sync uses cursor-based endpoints:
GET|POST /api/contacts/sync— cursor-based. Authenticated by the per-device credential (X-Kypost-Device-Id/X-Kypost-Device-Secret), not by a session.- Responses are size-bounded, so a large address book cannot force an unbounded allocation on the client.
Android:
- Two-way sync against the relay, opened from the Inbox overflow menu.
- Compose completes To, Cc, Bcc from local contacts; an address-book picker adds recipients.
Mac:
- Two-way sync, local edits win first, reconciliation keeps data safe.
- Contacts carry an extended schema: groups, photo, IM and social handles, websites, relations, extra dates, phonetic names, department, custom fields, pronouns, PGP public key.
Linux:
- Synced list and detail views. Create and edit offline and queue changes. Handles group membership and duplicates.
- Compose autocomplete from synced contacts.
CardDAV server details
- Per-user DAV password, set via
GET|POST|DELETE /api/contacts/dav-password. - Standard discovery at
/.well-known/carddavand/dav/....
CardDAV client details
- Config at
GET|POST|DELETE /api/contacts/carddav-client/config. - Manual sync via
POST /api/contacts/carddav-client/sync.
PGP & Encryption
Scope
KyPost supports OpenPGP for mail encryption and signing, key discovery over WKD, Autocrypt and keyservers, and QR-based public-key exchange. Your private key is protected in the browser, and it can be enrolled onto the native clients so they can read encrypted mail without the server.
Where your PGP private key lives
Browser-protected (end-to-end)
- Browser generates or imports the key.
- Browser wraps the key with a key derived from your account password: PBKDF2-HMAC-SHA256 with 600,000 iterations and AES-256-GCM. Browser uploads only the wrapped blob and public half.
- Server stores the blob and cannot open it, provided two facts hold:
- Your password never reaches the server. Browser stretches it with a per-account login salt from
GET /api/auth/login-paramsand splits the result into an auth half (sent) and a wrapping half (never leaves the page). - The two halves use different salts. Wrapping uses a random salt in the envelope. Auth uses the account login salt.
- Your password never reaches the server. Browser stretches it with a per-account login salt from
- What this does not defend against: the server ships the JavaScript that does the derivation. A malicious server build can modify the bundle to collect the password. You get protection against over-retention, logs, heap dumps, backups, and data at rest.
- Costs:
- A password reset destroys the key. You must import or generate a new one. Security page offers a browser-generated encrypted recovery backup. Keep the file and the separately shown recovery secret offline. Server never gets plaintext key or secret.
- You unlock the key once per browser session. Browser holds it in page memory only, never in localStorage or sessionStorage. Reload needs password again.
- KyPost does not auto-add verified send-as addresses to your key. The browser must re-sign the key.
On a native client, after device enrollment
A paired device holds no private key until you deliberately enrol it. Enrollment moves the key from the browser to that one device, sealed so that nothing in between — including the server — can read it.
- The device generates a P-256 key pair — held in Android’s Keystore or
Apple’s Secure Enclave, and transient on Linux — and publishes the public
half with
POST /api/pgp/device/enrollment-key, authenticated by its pairing credential. - Both screens display the same short authentication string: 14 Crockford base32 characters (70 bits), derived from the device’s public key and its device id, in a 120-second bucket. The device derives it from the key in its own keystore; the browser derives it from the key the server handed over.
- The browser refuses to seal if they differ. The server stores and serves the device public key, so the server is the party that could substitute one and then open everything sealed to it. The check gates the seal; verifying on the device afterwards would be too late.
- On a match the browser performs ECDH against that key and seals the private
key into a
kypost-device-envelope/v2envelope. The device fetches it withGET /api/pgp/device/envelopeand reports the outcome toPOST /api/pgp/device/enrollment-state. On Linux the unwrapped key is imported into the user’s GnuPG keyring and the transient key bytes are cleared.
The wire format, the code length, and the AAD layout are normative and shared by the web, Android and Qt clients. Changing any of them is a wire break and moves the version tag.
What each client does once enrolled:
| Client | Read encrypted mail | Encrypt and sign on device | Key custody |
|---|---|---|---|
| Web | Yes | Yes | Wrapped under your password, unwrapped in the page |
| Android | Yes | Yes | Sealed envelope under a Keystore key that requires a secure lock screen |
| macOS & iOS | Yes | Not yet — the crypto seam is linked, the send path still uses the relay | Sealed envelope in the Secure Enclave, released on user presence |
| Linux | Yes | Yes | Imported into your own GnuPG keyring; gpg-agent holds it |
Enrollment is reversible and losable by design:
- On Android the envelope is accepted only while Hostile Location Protection is off, and turning HLP on destroys it. A security wipe destroys it too, and if it cannot, the wipe reports itself incomplete and the app fails closed.
- On Apple devices, enabling Hostile Location Protection or resetting a stranded app lock destroys the key, and neither brings it back.
- On Linux the key lives in your GnuPG keyring, so KyPost never sees the OpenPGP passphrase and hardware tokens and smartcards work unchanged.
Shared facts
- PGP/MIME does not encrypt outer subject lines. When possible, KyPost puts the subject inside the encrypted part using the LAMPS protected-headers convention, but the outer header stays.
- Mobile push is generic by default for that reason. Encrypted mail is excluded from push payloads entirely, whatever the Content Preview setting, because native push travels through a relay and on to FCM or APNs in cleartext at every hop.
- A recipient without a key can get a one-time pickup link. It stays until the recipient reads it or it expires. It is not end-to-end — nothing sent to a person with no key can be. Opt-in, not automatic: an encrypted send to a keyless recipient fails with 409 unless
allowPickupFallbackis set, so plaintext never reaches the server as a side effect. See Pickup links for what the server can and cannot read.
Key discovery
The server finds recipient keys through a ladder, and pins what it finds on first use:
- Web Key Directory for the recipient’s own domain.
- Keyservers —
GET /api/pgp/keyserver/lookupquerieskeys.openpgp.org. - Autocrypt headers harvested from DKIM-authenticated mail.
Each rung is switchable under GET|PUT /api/pgp/discovery/settings, and a
discovered key can be rejected per address
(GET /api/pgp/discovery/suppressions,
DELETE /api/pgp/discovery/suppressions/{email},
POST /api/pgp/discovery/suppress-contact). Scanning a fingerprint in person
stays available regardless.
Signature trust
A signature verdict is decided on the reading device, from keys that device
already holds. The relay ships a verified flag with the message; no client
reads it. Fingerprints are computed locally from the key’s own bytes, and the
address binding comes from the server’s address book rather than from the key’s
own — forgeable — User IDs.
There are six states, not a verified/unverified pair, because identity and continuity are different claims and most keys arrive by Autocrypt harvest:
| State | Meaning |
|---|---|
none | Unsigned, or no opinion expressed |
verifiedConfirmed | Bound key that you confirmed out of band, by fingerprint or QR. The only state that claims identity. |
verifiedSeenBefore | Bound key still matching its TOFU pin, never confirmed. Claims continuity: same key as last time. |
signerUnknown | No key bound to this sender. Not an accusation — a new correspondent, a rotated key and a forged From are locally indistinguishable. |
keyChanged | A key is bound to this sender and no longer matches its pin. The one alarm worth raising. |
invalid | Signed, but it does not verify against the bound key. |
A subkey counts only if the primary key vouches for it with a valid binding signature and asserts the sign-data key flag; a revoked or expired primary drops the whole ring. The web client verifies detached signatures in the browser and serves the verbatim signed bytes to do it, so RFC 3156 signed-only mail is checked against what actually arrived.
Publishing your key (WKD)
An admin verifies a domain by DNS (GET|POST /api/pgp/wkd/domains,
POST /api/pgp/wkd/domains/{domain}/verify), and the server then serves
/.well-known/openpgpkey/ for it. Anyone in Thunderbird, GnuPG or ProtonMail
can then encrypt to your users unprompted. Publication is opt-out per user and
only ever covers addresses the user actually sends from.
Sending
POST /api/mail/sendwith optionalattachments: [{name, mimeType, dataBase64}](25 MB total), optionalencryptandsign. Ifencryptis true and a recipient lacks a usable key, call fails with 409. SetallowPickupFallbackto allow the pickup-link fallback instead.POST /api/mail/draftuses the same shape without PGP flags; clients save a draft on the server when the browser must take over.POST /api/pgp/recipients/check— check key status for a set of recipients before you send (contacts-only preflight on Mac).GET /api/pgp/keyserver/lookup— querieskeys.openpgp.org.
Where a client encrypts on the device, the plaintext body never reaches the relay for that path, each blind recipient gets their own ciphertext, and a recipient with no usable key stops the send rather than downgrading it.
QR key exchange
- Server:
GET /api/pgp/qr/tokenmakes a pickup token and URL that expire in 2 minutes.GET /api/pgp/qr/key?t=gets the scanned public key and fingerprint (token is credential). - Android: one screen shows your own PGP public-key QR code and scans another person QR code, saves the key onto an existing contact.
- macOS: My QR Code makes a pickup link that expires in 2 minutes. Scan to add contact key reads another person link, shows fingerprint for out-of-band confirmation, saves to a contact. iOS uses camera and pasted link fallback. macOS needs a pasted link (no VisionKit).
- iOS: same as Android plus paste fallback.
- Linux: exchange through QR, scan or show with camera, confirm fingerprint out of band.
Client limits
- A client that has not been enrolled holds no private key, and hands encrypted mail off to webmail rather than showing ciphertext. On Android the handoff opens the user’s own browser or the installed webmail PWA, never a Custom Tab: a Custom Tab renders inside KyPost’s task, where the app’s
FLAG_SECUREdoes not cover the browser’s window. - Messages the server decrypted say so, and say plainly that the server read the mail.
Pickup links
Single-use links at GET /pickup/{id}?t=<token>. There are two paths, and they
do not offer the same protection.
Browser-sealed (POST /api/pgp/pickup). The sending browser encrypts the
message and posts an opaque blob. The returned URL carries the record id and its
fetch token but not the decryption key — the caller appends that as a URL
fragment, and browsers never transmit fragments, so the key never reaches the
server on the fetch. The server does see the key once, when it relays the
notification email, because it holds the SMTP credentials. That key is never
written to disk: mailcache drops Sent bodies outright and redacts pickup-link
fragments from every body it stores. So an attacker who later obtains the
volume, a backup, or the box gets ciphertext; only a server compromised at the
moment of sending sees the key.
Relay-sealed. Where the relay does the OpenPGP work — a server-custody account sending from a native client — it stores the message’s plaintext for up to 7 days along with the named recipients. The server can read it.
One residue neither path reaches: the copy in the sender’s own Sent folder on their upstream IMAP provider keeps the full link.
For docs on the flow, see docs/WKD_Publishing.md, docs/E2E_PGP.md, and
docs/WEBMAIL_HANDOFF.md in the server repo.
Security Model
Accounts and sessions (server)
- Sessions expire after 24 hours without activity and slide forward on each authenticated request. Hard cap is 7 days from issue. The server sweeps expired sessions hourly. Logout invalidates server-side session and clears cookie. Deactivation or role change takes effect on the next request.
- Roles:
adminanduserin/kypost/config/users.json. Admins manage users, roles, passwords, activation, runtime settings, updates, verified mail domains, diagnostics, logs, health repair, and label rules. Users connect their own IMAP/SMTP, read and label own mail, pair own devices, set own notification prefs, tune own prompt. - Deactivation is soft delete. Data stays until you remove it. You cannot deactivate or demote the last active admin.
- Bootstrap
adminpassword goes tofirst-run-password.txtmode 600, orBOOTSTRAP_ADMIN_PASS/BOOTSTRAP_ADMIN_USERon first run.
IMAP credential storage
- Master keys at
/kypost/private/imap-config.keyand/kypost/private/totp-secret.key. Per-user encrypted IMAP credentials live in/kypost/config/users/<userID>/. See Persistence.
TLS and proxy (server)
- By default the server serves plain HTTP. Session cookie gets
Secureonly when the request came over TLS.KYPOST_BINDis required; compose refuses to start without it.http://on localhost is acceptable. For network access, put TLS in front. Three options: terminate in KyPost (TLS_CERT_FILE+TLS_KEY_FILE), Cloudflare Tunnel, or your own reverse proxy. See Configuration and Deployment. - For proxied setups, set
TRUSTED_PROXY_CIDRSnarrowly. Behind Cloudflare, the server prefersCF-Connecting-IP. Verify withGET /api/status(clientIpmust be your public address,proxyHeadersTrustedtrue except for direct TLS).
CAPTCHA and lockouts (server)
CAPTCHA_PROVIDER:pow(default, self-hosted proof-of-work),turnstile,friendly, ornone. Works with 3-strikes/15-minute account lockout plus a looser per-IP lockout and an instance-wide rate limit.powneeds a secure context (crypto.subtle). On plainhttp://(notlocalhost) nobody can sign in. Difficulty adapts per client IP, decays after 15 minutes or on success, bound to the requesting address with a signed challenge, requiresPOW_SECRETon multi-replica deployments (useopenssl rand -base64 32, must be 16 chars or longer).turnstile/friendlyneedCAPTCHA_SITE_KEY+CAPTCHA_SECRET_KEYand verify against a third-party endpoint.
Single Sign-On (server)
- Any standard OpenID Connect provider. Authorization code with PKCE; ID tokens are verified against the issuer’s JWKS.
- Accounts are claimed by the provider’s
suband never by username or email, so a provider that lets someone re-register a name cannot hand them an existing KyPost account. - KySignOn ships as a one-click preset. Authentik and Keycloak have their admin-group claims mapped.
- Admin-configured under Admin → Server → SSO. Requires
SERVER_BASE_URL— the redirect URI is taken from the configured base URL, never from the request’sHostheader. - Directory replication keeps the account list in step with the provider.
Pairing credentials (per device)
- Registration exchanges the one-time pairing token for a
deviceIdand a server-minteddeviceSecret, sent on every later request asX-Kypost-Device-IdandX-Kypost-Device-Secret. There is no account-wide subscriber HMAC any more. - The secret authorises push delivery, pull, contact sync, MFA response, and device enrollment. It is not the account password and cannot sign in to webmail.
- Each successful registration mints a new secret and invalidates the previous one.
- Revocation is per device, from the server’s Security page. Losing one device does not disturb the others.
- Android wraps the secret locally under a key derived from the app-lock PIN, peppered with a Keystore-held value.
Certificate pinning
- Android: the server’s leaf SPKI is captured at pairing and enforced on every later connection. Where the server terminates TLS itself, the pin also travels in the pairing QR as
pin, so the first registration call — the one that discloses the pairing token and the push credentials — is pinned rather than trusted on faith. A malformed pin fails closed instead of dropping back to TOFU. - macOS & iOS: TOFU pinning at first pairing, never re-pinned silently. Re-pair in Settings → Connection.
- Linux: TOFU on first use, anchored to the certificate’s issuer rather than the leaf, so a routine renewal is not reported as impersonation. A certificate change can be recovered from without destroying the pairing.
- After pinning, every client fails closed and refuses a certificate it cannot hash.
- Linux also allows encrypting the pairing credential behind a PIN.
Encryption at rest on the clients
- Android:
kypost_mail.db— every cached message body, the whole contact book, and contacts’ PGP keys — is encrypted with SQLCipher. The passphrase is 32 random bytes in a Keystore-backedEncryptedSharedPreferencesfile, deliberately not derived from the app-lock PIN, because the database has to open in processes where no PIN has been entered (an FCM delivery, a background sync). Existing installs convert in place on first launch, and the conversion never deletes the original before the replacement is verified. - Linux: the profile database is encrypted with SQLCipher; an existing plaintext profile is converted on the next launch. A build without
KYPOST_SQLCIPHER_ROOTconfigured reports encrypted databases as unavailable rather than pretending. - What this protects against: reading the file offline — root, an unlocked bootloader, a forensic image, a stolen backup.
- What it does not: a live, rooted, running device. Code running as the app’s own UID can ask the keystore to use the key exactly as the app does. Hostile Location Protection is the answer to that, and it is stronger: there is no file at all.
Client hardening
macOS & iOS
Always on:
- Sender HTML renders with JS off and remote content blocked. Navigation out of message denied by default.
- PGP fingerprints computed locally from key bytes.
- Local store has backup exclusion.
- Screen-capture protection: macOS excludes windows from recordings and sharing; iOS covers content during recording or mirroring. Neither blocks a plain screenshot.
Options:
- Require Unlock to Open with Face ID/Touch ID/passcode. iOS locks on background. macOS locks on screen lock.
- Hostile Location Protection — keeps no mail, contacts, or attachments on device; erases local cache on enable; limits are Quick Look temp file while preview is open (deleted on dismissal) and plain delete (not forensic overwrite).
- Require unlock for notifications and MFA — moves relay credential behind user presence; background checks wait.
Linux
- PIN lock with lockout, a configurable background-lock grace period, and a configurable erase-after-N-failures threshold — including “never”, which declines only the erase; the rate limit always stays.
- An interrupted wipe is finished on the next launch, and says so when it cannot.
- Credential seal with AES-256-GCM + Argon2 (see
core/security/CredentialCipher.h).opensslsupplies the cipher andargon2the memory-hard derivation, because Qt exposes neither. - WebEngineView with JS and remote images disabled.
- Hostile Location Protection writes nothing to disk, and refuses to turn on at all if it cannot first erase what is already there.
- OpenPGP custody is delegated to the user’s own
gpg-agentthrough GPGME’s C API, so KyPost never sees the OpenPGP passphrase and hardware tokens and smartcards work unchanged.
Android
- App lock — a PIN or biometric gate. The first two wrong attempts are free, because typos happen; attempt three onward adds a growing delay. After
WIPE_THRESHOLDconsecutive failures local data is wiped. Common PINs are rejected at set time, because an attacker gets a bounded number of guesses. A PIN that cannot be checked — a Keystore pepper gone or unusable — is deliberately distinct from a wrong PIN and does not count toward the threshold. - The wipe fails closed. It reports each step it could not complete rather than claiming a clean erasure, and resumes at the next launch. After three failed resumes it stops retrying but does not forget: every launch from then on blocks the app behind “manual recovery required” rather than showing a first-run screen over data still on disk. Every Keystore alias the app mints is destroyed and the deletion verified — a surviving alias is a durable record that the app was installed.
- Hostile Location Protection — the Room database is in-memory only, push history is volatile, device contact sync is blocked, keyword settings are not persisted, attachments are viewed with no disk write, and notifications carry no sender or subject whether or not the app is locked. The flag is a value plus an HMAC under a non-exportable Keystore key, so tampering fails towards enabled, and it is written with
commit()after the on-disk database is deleted so a process death cannot leave protection off while the user believes it is on. - Known limitation: turning the mode on cannot retract notifications the OS already recorded in Notification History. Clear that from Android’s own settings if it matters.
- Known limitation: attachments saved to the shared Downloads collection while protection is off live outside the app sandbox.
DownloadedAttachmentLedgerrecords those MediaStore rows — synchronously, before the file is written — so the wipe can remove them. Files the user has since moved or copied elsewhere are beyond the app’s reach. - Keystore-backed
EncryptedSharedPreferencesfor pairing material; SQLCipher for cached mail (see above). - Third-party components stated rather than discovered: pairing QR scanning uses Google Play Services (
play-services-code-scanner). Pair by entering the URL instead if you do not want that in the path. Push delivery is FCM, a UnifiedPush distributor you choose, or App Pull, which polls your own server and involves neither.
Push privacy
- Subject lines are not encrypted in ordinary PGP/MIME. The outer header is visible. Push notifications are generic by default.
- Encrypted mail is excluded from push payloads by the server whatever the Content Preview setting, because native push travels through a relay and on to FCM or APNs in cleartext at every hop.
- Native push providers can see the payload sent to them, along with delivery metadata such as device tokens and timing. KyPost therefore omits sender and subject from encrypted-mail payloads regardless of the Content Preview setting. Web Push payloads are separately encrypted to the browser’s subscription keys.
Push relay protection
See Push Relays: token pinning, per-minute and per-IP rate limits, optional daily budget, Durable Object coordination, fail-closed behavior.
Theming
One palette set, five surfaces
KyPost ships 15 theme presets, and they are the same 15 everywhere: the web UI, Android, iOS, macOS, and Linux. The palettes are not “matched by eye” — the values are copied byte for byte between the implementations, and the theme a user picks on one client is the theme they see on the next.
The default is Patina Ky.
The 15 presets
| Theme | Theme | |
|---|---|---|
| Patina Ky (default) | Polished Ky | |
| Dark Matter | Light Matter | |
| Ocean | Sky | |
| Forest | Tropics | |
| Tropic Night | Sun | |
| Coffee | Space | |
| Cyber Punk | Neon Purple | |
| White Cliffs |
Where each implementation lives
| Surface | File |
|---|---|
| Web | frontend/src/theme.ts (kypost-server) |
| Android | AppTheme.kt (kypost-android) |
| macOS & iOS | KyPost/Style/ (kypost-for-Mac) |
| Linux | core/theme/AppTheme.cpp — the palette table; ThemePalette.h holds the invariant (kypost-Linux) |
These four are a binding contract. Changing a colour in one without changing it in the others breaks the promise that the theme travels with the user, so a palette edit is a four-repo change.
Linux additionally asserts the invariant in code: ThemePalette.h checks the
background’s perceptual lightness, so a theme declared light has to actually
present as light.
Choosing a theme
- Web — Settings → Appearance.
- Android — the Themes screen.
- macOS & iOS — Settings (⌘, on macOS).
- Linux — Settings.
Custom themes
There is no custom-theme editor. The preset list is the supported surface; a new palette is a code change in the four files above.
Development & Testing
Server
From kypost-server/README.md:
Backend:
cd backend
go build -buildvcs=false ./...
go test ./...
Frontend:
cd frontend
npm install
npm run build
Structure: backend/ (Go API, poller, adapters, config, state, health), frontend/ (React and Vite), scripts/ (bootstrap and test helpers), Dockerfile (single image with backend, frontend, Ollama), docker-compose.yml, supervisord.conf.
Android
From kypost-android/README.md:
./gradlew testDebugUnitTest
./gradlew assembleDebug
./gradlew connectedDebugAndroidTest # needs device or emulator
Instrumented tests need a connected device or emulator with a secure lock screen set — the Keystore-backed enrollment vault refuses to create a key without one by design, so on a bare emulator the vault suites fail as though the code were broken. CI sets a PIN first.
:app:assembleRelease fails without signing material, deliberately: AGP does not
fall back to the debug keystore. Add a gitignored keystore.properties at the
repository root. Never build a release for distribution from a workstation —
release.yml on a v* tag reads the real key from a protected environment,
checks the tag against versionName, and refuses to publish unless apksigner
reports the expected signer.
Coverage includes: NativePairingDeepLinkParserTest, PairingValidatorTest, NativeRegistrationEndpointResolverTest, payload parser, NativeRegistrationRequestMapperTest, SecurePairingStoreTest.
macOS & iOS
From kypost-for-Mac/README.md:
- SwiftData, URLSession, WebKit, and one external dependency: GopenPGP, as a SHA-256-pinned binary XCFramework built by
.github/workflows/gopenpgp-xcframework.ymlfrom the upstream tag inDependencies/gopenpgp.env. - Xcode 26, deployment target macOS and iOS 26.5.
- CI runs the tests when signing material is available and compiles when it is not.
- Tests use Swift Testing (
@Test,#expect) inKyPost Tests/. UI stubs inKyPost UITests/. Network tests run against stubbedHTTPClient. No backend needed.
xcodebuild test -project "KyPost.xcodeproj" -scheme "KyPost"
Or run with ⌘U in Xcode.
Linux
From kypost-Linux/README.md:
./scripts/build-sqlcipher.sh /tmp/sqlcipher # encryption at rest needs this
cmake -B build -S . -DCMAKE_BUILD_TYPE=RelWithDebInfo -DKYPOST_SQLCIPHER_ROOT=/tmp/sqlcipher
cmake --build build
ctest --test-dir build
Without KYPOST_SQLCIPHER_ROOT, configure reports encrypted databases as
unavailable and the build produces a client whose database is not encrypted. It
says so rather than pretending.
CI: builds the Flatpak manifest for each PR under a private D-Bus session; for
each push to main it publishes a signed OSTree repo to the gh-pages branch,
which is what the kypost remote serves. Tagged v* builds additionally attach
kypost-x86_64.flatpak and kypost-aarch64.flatpak to the GitHub release.
Flatpak:
flatpak-builder --user --force-clean --install-deps-from=flathub \
build-flatpak packaging/flatpak/com.kysecurity.mail.yaml
flatpak-builder --run build-flatpak packaging/flatpak/com.kysecurity.mail.yaml kypost
Tests use QtTest with stubbed HttpClient and FakeRelayServer, driven by
ctest, plus a QML suite. tests/guards.tsv lists the security guards proven
load-bearing and scripts/verify-guards.sh checks them mechanically. See
TESTING.md for the manual checklist, docs/PARITY.md for the authoritative
Android-parity matrix, and AGENTS.md for rules.
Deployment & Operations
Quick start snapshot
See Server Quick Start for the eight steps with KYPOST_BIND.
Runtime checks (server README)
docker compose ps
docker compose logs -f kypost-server
docker exec -it kypost-server ps aux
docker exec -it kypost-server ls -la /kypost/config /kypost/state
docker volume ls | grep kypost
Persistence:
docker compose up --buildkeeps volumes.docker compose down -vremoves volumes and stored data.
Reverse proxy networking
The server README devotes a long note to this. Summary:
KYPOST_BINDdecides which interface publishes 5866. The supplied.env.exampleselects127.0.0.1; Compose still requires the variable to be present.- For a proxy on the same host, use
127.0.0.1. For a proxy elsewhere, use the LAN IP or0.0.0.0to publish everywhere. - Better still, run the proxy as a container on
kypost-net(the Compose network) and point it athttp://KyPost-Server:5866. That DNS name stays valid across rebuilds and ignores published ports. - To detect a mistake, fetch
GET /api/statusafter sign-in.clientIpmust be your public address andproxyHeadersTrustedmust betrue(orfalsefor direct TLS termination in KyPost). IfclientIpis a gateway like172.x.0.1, every user shares one lockout bucket and the cookie is not markedSecure. - Behind different hosts, combine direct TLS in KyPost (
TLS_CERT_FILE+TLS_KEY_FILE) with a proxy. Prefer a certificate the proxy explicitly trusts and keep upstream verification enabled. Disabling upstream verification leaves the proxy-to-KyPost hop vulnerable to interception and should be limited to a network whose trust boundary you understand.
Full snippet and error recovery are in docs/Reverse_Proxy_Networking.md.
Updating (server)
The server checks GitHub releases hourly. It emails the primary admin once and shows the update in Configuration → Application.
Apply the current stable image with health-gated rollback:
./scripts/update-host.sh
That script resolves stable to a digest, verifies the attestation with gh attestation verify, preserves that digest for rollback. It needs Docker Compose v2, Buildx, gh. To pin a release, set KYPOST_VERSION=0.2.0 in .env.
For a locally built image with no published digest, the updater refuses it. Use git pull --ff-only && docker compose up --build -d instead. See CHANGELOG.md for the upgrade/rollback matrix. Back up first.
Automatic updates are opt-in and need systemd:
./scripts/install-auto-update.sh
It installs a daily timer at 03:15 local time plus up to one hour jitter, enables lingering for the Docker user. Disable with systemctl --user disable --now kypost-update.timer. Without systemd, schedule ./scripts/update-host.sh --auto with KYPOST_AUTO_UPDATE=true.
Linux distribution
From kypost-Linux/README.md:
- Own signed Flatpak remote at
https://busness-app.github.io/KyPost-for-Linux/kypost.flatpakrepo, app idcom.kysecurity.mail, plus Flathub for theorg.kde.Platform//6.11runtime. Each release also attaches a.flatpakbundle (no auto-updates). Seedocs/DISTRIBUTION.md. - The remote is live.
kypost.flatpakrepopoints athttps://busness-app.github.io/KyPost-for-Linux/repo/, whose summary is GPG-signed and carriesapp/com.kysecurity.mail/x86_64/masterandapp/com.kysecurity.mail/aarch64/master. - The OSTree repo republishes on every push to
main, soflatpak updatetracks the branch. The.flatpakbundles are a separate thing: they attach only to av*tagged release. The first isv0.2.0. scripts/verify-version.shkeepsCMakeLists.txt, the AppStream<release>entry, and the git tag from drifting apart, and CI gates av*build on all three agreeing. A binary reporting the previous version, or a stale AppStream entry that stops Discover offering the update, is the failure it exists to prevent.- CI builds aarch64 artifacts as well as x86-64, and gates the release on the version matching the tag.
Push relay deployment
Maintainer runs Cloudflare Workers. See Push Relays for setup, secrets, and redeploy. Self-hosters need no Firebase or Apple account.
What the READMEs do not say
- No managed hosting guide.
- No Kubernetes manifest or systemd unit beyond the auto-update timer.
- No log rotation config.
Security Policy
Supported versions
Security fixes are made against the latest KyPost release. Upgrade to the current release before reporting a problem that may already have been fixed.
Reporting a vulnerability
Report vulnerabilities privately through GitHub Security Advisories. Do not open a public issue containing vulnerability details.
Include the affected version, impact, reproduction steps, and any suggested mitigation. Reports are acknowledged within two business days. Disclosure and credit are coordinated with the reporter after a tested fix is available.
Security model and deployment
Read the Security Model before deploying KyPost. It documents the trust boundaries for PGP key custody, native push, device pairing, local AI classification, client storage, TLS termination, and trusted proxy headers.
The server repository contains the authoritative operational guidance:
Contact
- Vulnerability reports: GitHub Security Advisories
- Maintainer: Yoshiofthewire
Licensing
KyPost is developed by Matthew Beacher d/b/a Busnes.app and released under the MIT License.
MIT License
Copyright (c) 2026 Matthew Beacher d/b/a Busnes.app
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Code of Conduct
KyPost handles other people’s mail, mail credentials, and private keys. Getting that right takes people who disagree with each other in public and keep showing up anyway. This document exists so that disagreement stays about the work.
Our Commitment
Everyone who contributes to KyPost — code, documentation, bug reports, security findings, translations, or a question in an issue — is welcome here regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, caste, colour, or sexual identity and orientation.
We are building software for people who want control over their own mail. A project about self-determination that treats its contributors as interchangeable would be lying about what it is.
What We Expect
- Assume the other person is trying. Read the strongest version of what they wrote before you answer it.
- Be specific. “This is wrong” helps nobody. “This drops the lock before the write, so two pollers can interleave here” helps everybody.
- Ask, don’t assume, about people. Use the name and pronouns a person uses for themselves. If you don’t know someone’s pronouns, use they/them. A correction is not an attack; take it, apply it, move on.
- Let inexperience be visible. Someone asking a basic question in public is doing the project a favour — that question is the documentation gap made legible. Answer it or point at the doc; don’t perform surprise that it was asked.
- Accept review gracefully, including when you’re right. Explain your reasoning, link the evidence, and let the maintainers decide.
- Credit work. Say whose idea it was. This includes crediting a bug report that led to a fix, and disclosing AI assistance as CONTRIBUTING requires.
What We Won’t Accept
- Sexualised language or imagery, or unwanted sexual attention of any kind.
- Slurs, demeaning jokes, or “just asking questions” about whether a group of people belongs here.
- Personal attacks, insults, or derogatory comments — including the kind dressed up as technical rigour.
- Deliberate misgendering or persistent use of a name a person has asked you not to use.
- Publishing someone’s private information (physical or email address, private messages, employer, legal name) without their explicit permission.
- Harassment in public or in private, including following someone across threads, repos, or platforms to continue an argument they have left.
- Retaliation against anyone who reports a Code of Conduct problem or a security vulnerability.
Hostile Reviews Are About Code, Never People
KyPost deliberately runs adversarial review: human reviewers, and AI reviewers running skills that are instructed to be unimpressed with your work. Every PR gets torn at on purpose, because the alternative is finding out from a user whose mailbox credentials leaked. See CONTRIBUTING.
That practice has an exact boundary, and the boundary is this:
- In scope: “This design cannot work.” “You have not shown this is safe.” “This adds a silent fallback and the project doesn’t allow those.” “This looks like a rewrite, not a patch.” Blunt, unsoftened, repeated if it wasn’t heard the first time.
- Out of scope: anything about the author. Their competence, their motives, their care, their seniority, whether they “should know better,” whether they used AI, or how many times they’ve been wrong before.
Hostility toward a design is a service. Hostility toward a person is a Code of Conduct violation, and calling it “just being direct,” “the hostile review persona,” or “what the skill told me to say” does not change that. An AI reviewer that crosses this line is the fault of the human who ran it and pasted the output.
If you are the author on the receiving end: a severity-ranked list of things that are wrong with your patch is not a verdict on you. Read it, take what’s real, push back on what isn’t. Nobody here got a clean first review.
Scope
This applies in every project space — issues, pull requests, discussions, commit messages, code comments, the wiki, and any chat or event where you are representing KyPost. It also applies to behaviour outside these spaces when that behaviour is directed at a member of this community because of their participation here.
Reporting
Report a problem privately to the maintainer, @Yoshiofthewire, through GitHub. Do not open a public issue about a Code of Conduct concern; that exposes the reporter first.
Include what happened, where, when, and links if you have them. You do not need to prove your case or have a remedy in mind to report something.
Reports are handled privately. The reporter’s identity is not disclosed to the person reported without the reporter’s consent. You may report on behalf of someone else, and you may report an incident you witnessed but were not the target of.
Security vulnerabilities are not a Code of Conduct matter. Report those through GitHub Security Advisories, as described in SECURITY.md.
Enforcement
The maintainer will review every report and respond. Depending on severity and history, the response may be:
- A private word. A note explaining what was wrong and what to do instead.
- A warning. Formal, with defined consequences for continuing — usually a period of no interaction with the people involved.
- A temporary ban. No participation in project spaces for a stated period.
- A permanent ban. For sustained harassment, aggression toward a group of people, or a pattern that has already been warned about.
Maintainers who do not enforce this document in good faith are subject to it themselves.
Attribution
Adapted from the Contributor Covenant, version 2.1, with sections specific to this project’s adversarial review practice.
Contributing to KyPost
Thanks for wanting to work on this. KyPost is a self-hosted mail client, which means every contribution lands on a machine holding somebody’s IMAP password, their PGP private key, and their mail. This document describes what a contribution has to clear before it merges, and why each gate exists.
Read CODE_OF_CONDUCT first. It is short, and it governs every space here.
Table of Contents
- The user contract
- Getting set up
- Before you build a feature
- AI attribution is mandatory
- Every PR must pass CI
- Every PR must pass hostile AI code review
- Adversarial review skills
- Security trade-offs, and when a feature gets rejected
- Documentation: the DOX chain
- Commits, branches, and PRs
- PR checklist
- Licence
The User Contract
Everything below follows from one promise KyPost makes to the person running it:
KyPost will be as secure as we can make it by default, and every place where security was traded for convenience will be written down, in plain language, where the user reads it before they rely on it.
Two halves, both load-bearing. “Secure by default” alone produces software people route around silently. “Documented” alone produces a footnote nobody reads under a default that quietly loses their mail. The project ships both, and a contribution that breaks either half does not merge — see Security trade-offs.
Three standing invariants fall out of it, and they are not up for re-litigation in a PR:
- KyPost never archives, deletes, or moves a user’s mail on its own. Destructive mail actions happen because a human asked for that specific action. No feature, default, heuristic, or classifier outcome may archive mail. This is absolute.
- Classifier labels are a sorting hint, not a security boundary. The model reads sender-controlled text and can be steered by it. Never build a trust decision, filter action, or access control on a label. See SECURITY.md.
- No silent fallbacks on a security-relevant setting.
KYPOST_BINDhas no default and compose refuses to start without it. Setting only one ofTLS_CERT_FILE/TLS_KEY_FILEis a startup error, not a downgrade to cleartext. Copy that shape; do not add a permissive default to make a first run quieter.
Getting Set Up
Docker is the only requirement to run KyPost:
cp .env.example .env # set KYPOST_BIND — it has no default, on purpose
docker compose up --build -d
To work on the code outside the container you need Go 1.26.5+, Node 26.5.0 (see
frontend/.nvmrc), and npm 12.0.1. Use those versions rather than whatever your
distro ships — CI pins to them because a Node major difference has already
produced a suite that passed on every developer machine and failed all 14 cases
in CI.
# backend
cd backend && go test ./...
# frontend
cd frontend && npm ci && npm test -- --run
README.md covers configuration and runtime layout. SECURITY.md covers trust
boundaries and known limitations — read it before touching anything on the list
of security-sensitive code.
Before You Build a Feature
Open an issue first for anything larger than a bug fix. A feature that conflicts with the user contract is cheaper to discuss in an issue than to reject after you have written it, and the maintainer would rather say “not like that, but like this” before you spend a weekend on it.
Bug fixes, documentation, and test coverage need no prior discussion. Send them.
Fix the root cause, not the reported symptom. If a guard is missing, put it in the shared function every caller reaches, not in the one path the bug report happened to name.
AI Attribution Is Mandatory
AI-assisted contributions are welcome. Undisclosed AI-assisted contributions are not.
This is not a purity test. It is review triage: a reviewer reads machine-generated code differently — checking harder for plausible-looking functions that don’t exist, error handling that swallows the error, tests that assert the implementation back at itself, and confident comments describing behaviour the code doesn’t have. Hiding the provenance costs the reviewer that context and costs you a worse review.
What you must do
1. Attribute in the commit. Use a trailer naming the model or tool:
Co-Authored-By: Claude Opus 5 <[email protected]>
Any tool, same rule — Copilot, Cursor, Codex, a local model, whatever. One trailer per tool that materially contributed.
2. Declare the level in the PR description. State which applies:
| Level | Meaning |
|---|---|
| None | You wrote it. Autocomplete of a variable name doesn’t count. |
| Assisted | AI wrote fragments; you designed it, and you edited what it produced. |
| Generated | AI produced most or all of the diff; you reviewed and tested it. |
| Agentic | An agent ran a multi-step loop over the repo with limited step-by-step supervision. |
3. Say what you verified yourself. For anything above None, the PR must say what you personally ran and read — “ran the backend suite locally, read every line of the diff, hand-tested the draft-expiry path in the UI” — not “the agent says tests pass.”
4. Own it. The human opening the PR is the author of that code for every purpose: licensing, review, correctness, and the bug it causes six months from now. “The model wrote it” is not an explanation of a defect and is never a defence. If you cannot explain why a line is there, delete the line.
What gets rejected on sight
- A PR whose description was clearly generated and does not describe the actual diff.
- Invented APIs, invented config keys, or citations to documentation that does not exist.
- Tests that were written to pass rather than to fail when the logic breaks.
- Bulk AI-generated churn across the repo — reformatting, “improved” comments,
speculative refactors — that nobody asked for. See the
ponytailphilosophy in the server repository’s AGENTS.md: deletion over addition, shortest working diff wins. - A security-sensitive change where the disclosure level is Generated or Agentic and no human describes the trust boundary in their own words.
If you are an AI agent reading this
Every rule above applies to you. Add the trailer, fill in the level, and do not
write the human’s verification statement for them — leave it for them to fill
in and tell them you did. AGENTS.md is a binding contract for this repo and
you are required to follow the DOX chain.
Every PR Must Pass CI
.github/workflows/ci.yml runs on every pull request. All jobs must be green
before merge. No exceptions, no admin override, no “flaky, re-run and ignore” —
if a job is genuinely flaky, that is a bug to fix in the job.
| Job | What it runs |
|---|---|
ci-backend-api | gofmt -l, go vet, golangci-lint, govulncheck, go test -race ./internal/api/... |
ci-backend-other | Same lint gates, go test -race on every other backend package |
ci-frontend | npm audit --omit=dev --audit-level=low (blocking), tsc --noEmit, vitest, vite build |
ci-relay | Typecheck both relay Workers, node --test on the relay behaviour suites |
ci-docker | docker build, container reaches healthy, and the entrypoint still refuses a non-loopback cleartext bind |
Notes that trip people up:
- Run the gates locally before pushing. All of them work on a normal
machine, including the whole frontend suite. “It only fails in CI” is almost
always a version difference — check
.nvmrcandgo.mod. - The frontend audit gate is
--audit-level=lowon runtime dependencies. That is deliberate: a “low” XSS advisory in the editor that renders quoted hostile email is not low here. Do not raise the threshold to get green. ci-dockerhas two halves and both are gates. One proves the container starts; the other proves the bind guard still refuses0.0.0.0without TLS. A red build is never fixed by loosening the entrypoint.- New logic ships with tests. Unit plus integration for new behaviour, a
regression test for anything high-impact. Security-sensitive changes need
attack-path coverage, not just the happy path — see
SECURITY.md and the
backend/internal/api/*_security_fixes_test.gofiles for the pattern.
Every PR Must Pass Hostile AI Code Review
Beyond CI, every pull request gets an adversarial review pass. This is a required gate, not a suggestion, and it exists because ordinary review has a known failure mode: the reviewer shares the author’s mental model and agrees with the blind spot. That failure gets worse, not better, when both the author and the reviewer are agreeable LLMs.
What you do as the contributor: run at least one adversarial review skill against your own diff before you open the PR, address the findings, and paste the surviving findings — the ones you decided not to fix — into the PR description with your reasoning. A PR that says “hostile review found nothing” gets read with more suspicion than one that lists three findings and argues two of them down.
What the maintainer does: runs an independent hostile pass. Findings at BLOCK severity stop the merge until they are fixed or the reasoning is written down in the thread.
Answering a hostile finding. The finding is a claim about the code. Verify it before you act on it — hostile reviewers are wrong sometimes, and implementing a wrong suggestion politely is worse than pushing back. Three valid answers: fix it; show with evidence that it is not real; or accept it as a known limitation and document it where a user will see it. “Good catch, will address later” is not one of them.
Adversarial Review Skills
These are Claude Code skills. If you use another agent, the prompts still work as instructions — the value is in the persona, not the harness.
| Skill | Use it for |
|---|---|
hostile-review | The main gate. A senior engineer who hates what you built, output as severity-ranked criticisms with concrete fixes. Works on code, plans, and designs — run it on the design before you write the code. |
adversarial-reviewer | Three fixed personas — Saboteur (how this breaks in production), New Hire (can anyone maintain this), Security Auditor (OWASP-informed). Each must find at least one issue, so no persona can rubber-stamp. Verdict is BLOCK / CONCERNS / CLEAN. |
security-audit | Required for anything touching auth, sessions, crypto, input parsing, rate limiting, or trust boundaries. Hunts exploitable issues, not theoretical ones. |
ponytail-review | Over-engineering only: what to delete, which dependency is unnecessary, which abstraction is speculative. Run it on any diff that grew while you wrote it. |
/code-review | The maintainer’s working-diff review. /code-review ultra launches a multi-agent cloud review of the branch or a GitHub PR. |
How to pick:
- Any PR:
hostile-reviewon the diff. Minimum bar. - Touching security-sensitive code (the list):
hostile-reviewandsecurity-audit. - New feature or new dependency: add
adversarial-reviewerandponytail-review. The Saboteur persona and the delete-it pass catch different things. - Design or plan, before implementation:
hostile-reviewon the plan. Much cheaper than finding out after the code exists.
Two rules on using them:
- Feed the reviewer the real diff and the real context, including
AGENTS.mdand the relevantSECURITY.mdsection. A reviewer that cannot see the trust boundary cannot tell you that you crossed it. - Hostility points at code, never at people. These skills are instructed to be harsh about the work. That is in bounds. Pasting output that attacks a person is a Code of Conduct violation, and running the skill does not launder it.
Security Trade-Offs, and When a Feature Gets Rejected
A new feature may be rejected outright if it weakens the user contract. Not “merged with a TODO” — rejected. A working, well-tested, wanted feature can still be the wrong feature for this project.
Rules a feature must satisfy
-
Secure by default. If there is a safe mode and a convenient mode, the safe one is the default and the convenient one is an explicit opt-in the user performs knowingly. Never the reverse, never “most people want the easy one.”
-
Fail closed. Missing configuration, a half-set pair of settings, or an unreachable dependency produces a refusal with a readable error and a remediation step — not a quiet downgrade.
-
The trade-off is named in the PR. In your own words: what a user gives up, who can now see or do what they could not before, and what the worst realistic case is. A trade-off you cannot state plainly is one you have not finished understanding.
-
The trade-off is signposted where the user reads it, in three places as applicable:
- README.md — one clear sentence on the feature bullet, so it is visible while deciding to use the feature.
- SECURITY.md — the full explanation under Known Limitations & Trust Boundaries, naming the cost explicitly.
- The UI or
.env.example— at the point of choice, if the user selects it at runtime.
The model for this is the PGP key-custody wording: Browser-Protected, with its costs stated flatly, including the sentence “this is not end-to-end encryption” where it applies. Aim for that. Write what the user loses, not a reassurance.
-
New attack surface is justified. A new dependency, a new outbound network call, a new stored secret, a new endpoint, or a new file in
/kypost/privateeach need a paragraph on why it is necessary and what it can reach. Dependencies that parse hostile input (MIME, vCard, OpenPGP, HTML) get the highest scrutiny — prefer the standard library, then an already-present dependency, then nothing. -
The blast radius is bounded and stated. If it goes wrong, say what is reachable: one message, one user, or the instance.
Rejected on principle
- Anything that archives, deletes, or moves mail without an explicit human action for that message.
- Any trust decision derived from a classifier label.
- Convenience defaults that silently disable a protection — trusting forwarded
headers from any peer, a default bind address, downgrading to cleartext when
a certificate is missing,
unsafe-inlinein the CSP. - Telemetry, analytics, crash reporting, or any phone-home. Self-hosted means the instance talks to the user’s mail server, their model, and the push relay they configured. Nothing else.
- Sending mail content, subjects, or credentials to a third-party service that the user did not explicitly configure — including AI services.
- Weakening a gate to make CI or a review pass.
- A convenience feature whose security cost cannot be explained to a non-expert user in two sentences. If it cannot be explained, it cannot be consented to.
If your trade-off is legitimate
Plenty are. That is the standard: not “no trade-offs,” but no unmarked trade-offs, and never as the default.
Documentation: The DOX Chain
AGENTS.md files are binding contracts for their subtrees. Before editing,
walk from the repository root to each file you intend to touch and read every
AGENTS.md on the way. After editing, update the closest owning AGENTS.md if
your change affected purpose, scope, contracts, workflows, inputs/outputs,
constraints, or side effects — and refresh any affected Child DOX Index.
Read the DOX section of the server repository’s root AGENTS.md in the session you are working in. Do not work from memory of it.
Commits, Branches, and PRs
- Branch off
main. Name it for the work:fix/draft-expiry-bounds,feat/carddav-groups. - Conventional Commits for the subject:
fix:,feat:,docs:,refactor:,test:,chore:. Subject ≤ 50 characters, imperative mood. - The body explains why, not what — the diff already says what. Skip the body when the why is obvious from the subject.
- Include the AI attribution trailer where it applies.
- One logical change per PR. A security fix and a refactor in one diff is two PRs, because the refactor will be reviewed less carefully than it should be.
- Link the issue the PR closes.
PR Checklist
Paste this into your PR description and fill it in.
### What and why
### AI involvement
- Level: None / Assisted / Generated / Agentic
- Tools:
- What I verified myself:
### Gates
- [ ] Ran the gates locally (go test -race, tsc, vitest, build)
- [ ] New logic has tests; security-sensitive changes have attack-path tests
- [ ] Ran adversarial review; findings addressed or argued below
- [ ] DOX pass done — nearest AGENTS.md and any parent/child index updated
### Adversarial review findings
<!-- Skills run, and every finding not fixed, with your reasoning. -->
### Security
- [ ] No change to defaults that weakens a protection
- [ ] Fails closed on missing or partial configuration
- Trade-off introduced (if any), in one sentence:
- Documented in: README.md / SECURITY.md / UI / .env.example / n-a
- [ ] Does not archive, delete, or move mail without an explicit user action
- [ ] No trust decision derived from a classifier label
- New dependencies / network calls / stored secrets, and why:
Licence
KyPost is licensed under the MIT License. By contributing, you agree that your contribution is licensed under the same terms, and that you have the right to submit it — including the right to submit anything an AI tool produced on your behalf.
Appendix: Coverage Gaps
This appendix lists sections that exist for structure but have no data. It guards against guessing.
Topics with no data
| Section | Gap |
|---|---|
| Ecosystem Overview | No world-level roadmap or timeline; no user counts |
| Architecture | No database schemas, no prompt text, no detailed relay auth flow |
| Server Config | No complete env var list (see .env.example), no default TUNING.md contents, no model download failure handling |
| Persistence | No managed-database or S3 backup option; file-level volumes only |
| API | No request/response schemas beyond field notes above |
| Push Relays | No cost estimate or quota beyond RELAY_DAILY_BUDGET |
| Clients Overview | No shared release cadence |
| Android | minSdk is 31; no background-restriction tuning beyond backoff |
| macOS & iOS | No TestFlight steps; known gaps are v2 candidates listed in README |
| Linux | No Plasma Mobile device list |
| Pairing | No signing algorithm beyond HMAC with PAIRING_SECRET |
| Push Notifications | No sound/badge/grouping config |
| Contacts | No dedupe algorithm; no CardDAV sync interval |
| PGP | No token lifetime beyond 2 min QR and up to 7-day pickup storage |
| Security | No formal threat model file |
| Theming | Names and file locations are listed; there is no custom-theme editor to document |
| Development | No style guide copy beyond AGENTS.md/STYLE_GUIDE.md pointers |
| Deployment | No Kubernetes or non-systemd log rotation guide |
| Licensing | No CLA/DCO; no third-party inventory beyond fonts |
Other gaps named as future work
- Mac known gaps (v2 candidates): attachments, read/archive/delete from reader, draft save button, server-side search, QR camera on macOS, and on-device encrypt-and-sign — reading client-protected mail has landed, writing it has not. Cursor/delta sync and folder create/rename/delete have since shipped.
- Linux:
packaging/click/is deferred until UBports releases a Qt6/KF6 track. Inbox window size is an open product question — Linux sends nolimitand gets the relay’s default of 500 untruncated bodies where Android asks for 50. - Releases: Linux, Android, Mac, and the server have tagged releases. Android’s workflow creates its GitHub release as a draft, so a finished build is not necessarily public until a maintainer publishes it from the Releases page.
Where these gaps are tracked upstream
kypost-Linux/docs/PARITY.mdis the authoritative Android↔Linux parity matrix, with a status and a reason per row.kypost-android/SECURITY.mdstates what each control protects and where it stops, including the residues a wipe cannot reach.kypost-server/README.mdstates the classifier’s prompt-injection resistance as a measured property rather than a pending bug.
Placeholders for future data
The book keeps the chapter list above even where the gap table says no data.