Email client for Android, desktop, web and iOS: downloads IMAP/POP3 mail, composes with a formatting toolbar, and keeps junk out of the Inbox with open DNS blocklists, an offline brand-impersonation detector, a learning classifier and user rules. The web client is laid out like Microsoft 365 Outlook.
Quillbox for Android is the Outlook-style client packaged as an app. The APK contains the
same mail engine and the same interface as the web and desktop versions: at start-up the app
launches the backend on 127.0.0.1 inside its own process and shows the UI full-screen in a
WebView. Nothing leaves the phone except the connections to your own mail provider, the DNS
blocklist lookups and the provider autodiscovery, exactly as on the desktop.
- Application ID:
info.socrtwo.quillbox - minSdk: 26 (Android 8.0) · compile/target SDK: 35 (Android 15)
- Also runs on ChromeOS (install the APK) and on Android tablets, where the layout becomes folders + list with the reading pane sliding in, like Outlook on an iPad.
What the Android shell adds on top of the shared UI:
- the back button closes dialogs, the folder drawer and the open message before leaving the app;
- attachments are saved to Downloads/Quillbox and offered to a viewer app; compose attachments use the system file picker;
- links in messages open in the browser,
mailto:links from other apps open the composer; - Print uses Android's print service; the theme follows the system light/dark setting;
- the account (with its password, when Keep me signed in is ticked) is stored in the app's private WebView storage; rules, settings and the learned classifier live under the app's private files directory.
| Concern | Choice |
|---|---|
| Language / build | Kotlin, Gradle Kotlin DSL, Gradle wrapper 8.13 |
| UI | The web client (web/src/main/resources/web) in a WebView |
| Backend | Ktor 3 (CIO engine) compiled from web/src/main/kotlin |
| Mail protocols | Jakarta Mail — org.eclipse.angus:angus-mail (IMAP/POP3/SMTP) |
| Junk engine | Shared with web/desktop (web/src/main/kotlin/.../spam) |
app/src/main/java/info/socrtwo/quillbox/
├── MainActivity.kt // WebView shell: back button, downloads, file picker, links, printing
└── LocalServer.kt // starts the shared Ktor backend on 127.0.0.1 (stable port, app data dir)
web/src/main/kotlin/ // the backend and junk engine, compiled into the APK as-is
web/src/main/resources/web/ // index.html, styles.css, app.js — the UI, packaged into the APK
Requires JDK 17+ and the Android SDK (platform 35, build-tools). The Gradle wrapper is committed, so:
./gradlew assembleDebugThe sideloadable debug APK is written to:
app/build/outputs/apk/debug/app-debug.apk
Install it on a device with adb install app/build/outputs/apk/debug/app-debug.apk.
CI:
.github/workflows/android.ymlbuildsassembleDebugon every push and uploadsapp-debug.apkas a build artifact (thequillbox-debug-apkartifact).
web/ is a self-hosted web mail client: a Ktor backend that talks IMAP/POP3/SMTP on your behalf,
plus a browser front end laid out like Microsoft 365 Outlook (folder pane, message list, reading
pane, command bar, keyboard shortcuts, dark mode, responsive down to phone width).
cd web
./gradlew run # http://localhost:8080Environment variables: PORT (default 8080), HOST (default 0.0.0.0), QUILLBOX_DATA_DIR
(default ~/.quillbox; holds per-account rules, settings, the learned classifier and the
analysis cache — never passwords).
To try it without a real mailbox:
cd web
./gradlew demo # in-memory IMAP/SMTP seeded with genuine, spoofed and blacklisted mailthen sign in as demo@quillbox.test / demo and, under Server settings, use IMAP
127.0.0.1:3143 (security None) and SMTP 127.0.0.1:3025 (None).
A three-step wizard, as in Outlook:
- Choose your provider. Tiles for the hundred most used mailbox domains worldwide plus
Proton Mail — Gmail, Yahoo, Outlook.com/Hotmail/Live/MSN, AOL, iCloud, Comcast/Xfinity,
Verizon, AT&T/SBCGlobal/BellSouth, Cox, Spectrum, EarthLink, Erols/Astound, Orange/Wanadoo,
Free, SFR, La Poste, GMX, WEB.DE, T-Online, Libero, Virgilio, TIM/Alice, Ziggo, KPN,
Telenet, Proximus, Bluewin, BT, Sky, Virgin Media, TalkTalk, QQ, NetEase 163/126, Sina,
Sohu, Alibaba, Yahoo! JAPAN, docomo, au, @nifty, BIGLOBE, OCN, Naver, Daum/Kakao, Nate,
Rediffmail, Yandex, Mail.ru, Rambler, UOL, BOL, iG, Terra, Telstra/BigPond, Optus and more
(
web/src/main/kotlin/.../Providers.kt, 100+ providers, 350+ domains) — grouped by region with a search box, plus Other for any mailbox. Historic brands that were merged (Verizon → AOL, Erols → Astound, Voilà → Orange, Chello → Ziggo…) point at the successor's servers and say so. - Name, address and password. The provider's servers, caveats and Create an app password link are shown right away (Gmail, Yahoo, iCloud, AOL, Fastmail, Yandex, Mail.ru and QQ need an app password or authorisation code). With Other the settings are looked up from the address (built-in table → Mozilla ISPDB → the domain's autoconfig → MX record → port probe).
- Review the server settings and Test connection before signing in. Tick Keep me signed in to store the account on the device.
A branded splash screen covers start-up on every platform (system launch splash on Android and iOS, then the page's own splash until the mailbox is ready).
Every new message is analysed on the server and, when it is junk, moved to the Junk folder automatically (POP3 accounts get a local Junk view instead, since POP3 has no folders). Signals, all free and needing no API key:
| Signal | What it does |
|---|---|
| DNS blocklists | The sending IPs from the Received chain are checked against Spamhaus ZEN, SpamCop, Barracuda and PSBL; the sender, Reply-To and link domains against Spamhaus DBL, SURBL and URIBL. Lists can be toggled, re-weighted and extended with your own zones in Settings → Junk email. Spamhaus refuses queries via public resolvers (8.8.8.8, 1.1.1.1); the UI shows refused when that happens. |
| Authentication results | SPF / DKIM / DMARC verdicts recorded by your own mail provider. |
| Impersonation detector | An offline dictionary of ~2,500 organisations (Fortune/Global 500 companies, banks, insurers, retailers, carriers, government agencies, universities) and the domains they really send from; every domain was checked to resolve in DNS when the table was generated (web/src/main/resources/brands/*.tsv, regenerated with scripts/gen-brands.py). The sender's display name, address, subject and body signature are scanned for a claimed organisation and compared with the real sending domain; look-alike domains (paypa1.com, arnazon.com, secure-paypal-login.net, paypal.com.verify.ru) are caught with homoglyph normalisation and edit distance. A mismatch is shown as "Claims to be PayPal — domain is not PayPal's". |
| Learned classifier | A naive-Bayes model that trains on your Junk / Not junk clicks (seeded with a small built-in corpus, and deliberately weak until you have taught it). |
| Content heuristics | Urgency and prize language, link-text/href mismatches, bare-IP links, shorteners, hidden text, dangerous attachment types, Reply-To on another domain, and so on. |
| Rules, safe and blocked senders | Deterministic and always win. |
Every verdict is explained: the reading pane shows the sender's address in large bold type with the domain underlined and coloured by verdict, badges for the claimed organisation and the authentication results, and a Why? list with the weight of each signal.
Per-message buttons (toolbar, hover actions, context menu and keyboard):
- Junk (
J) moves the message to Junk and trains the classifier; Not junk (Shift+J) restores it, trains the classifier and guarantees it is never auto-filed again. Both have Undo. - Analyse & make rule runs the full analysis and proposes a rule that would catch this message and others like it (sender domain or exact address, spoofed display name, distinctive subject phrase, scam phrase). Each condition explains why it was chosen. Edit it, Preview matches against the current folder, then Save rule or Save & apply to current mail to file the matching messages that are already in the folder.
Optionally, a locally running open-source language model served by Ollama (Settings → AI models) adds a second opinion and extra rule conditions. Nothing is ever sent to a cloud service; the built-in engine needs no model at all.
Reply / reply all / forward with quoting, rich-text compose with attachments (drag-and-drop or
paste), drafts, flags, read/unread, move, archive, delete with undo, server-side search, remote
image blocking with per-sender allow list, sanitised HTML rendering in a sandboxed frame,
attachment download, message headers view, print, unsubscribe, three reading-pane layouts,
compact/comfortable density, and Outlook-style shortcuts (N, R, A, F, Del, E, J,
U, S, /, ?).
The backend exposes a JSON API (/api/session, /api/folders, /api/messages, /api/message,
/api/junk, /api/analyze, /api/rules/preview, /api/rules/apply, /api/settings, …) with a
bearer token from /api/session. The original stateless endpoints /api/inbox and /api/send
are unchanged, so the iOS client keeps working.
Quillbox started as an Android app; sibling clients live in their own folders. Each is built by its own GitHub Actions workflow.
| Folder | Target | Stack | Build artifact |
|---|---|---|---|
app/ |
Android (and ChromeOS) | WebView shell around the embedded web client (Ktor CIO backend in-process, Jakarta Mail) | .apk |
desktop/ |
Windows / macOS / Linux / Raspberry Pi OS | Native launcher (Compose Desktop) around the embedded web client | .msi/.dmg/.deb (x64 + arm64) + portable archives |
web/ |
Browser (self-hosted) | Ktor backend (JVM, Jakarta Mail) + Outlook-style web UI, junk engine | server .zip |
ios/ |
iPhone / iPad | SwiftUI + WebKit shell showing the web client served by your own Quillbox server | .app (simulator) |
Notes on the architecture choices:
- One interface everywhere. The Outlook-style UI and the backend live in
web/and are compiled into every other target, so Android, iOS, Windows, macOS, Linux, ChromeOS and the browser all show the same three-pane client (stacked panes with a folder drawer on phones, folders + list with a sliding reading pane on tablets), the same junk verdicts, the same Analyse & make rule dialog and the same settings. - Android runs the backend inside the app process and shows the UI in a WebView
(
app/src/main/java/.../LocalServer.kt,MainActivity.kt). No server needed. - ChromeOS runs the Android APK directly — no separate build. The Linux
.debor the web zip also work in the ChromeOS Linux container. - Desktop is the web client packaged as a native app: it embeds the same backend and
Outlook-style UI (compiled straight from
web/), starts it on127.0.0.1and opens it in your browser, with a tray icon to reopen or quit. Run locally withcd desktop && ./gradlew run; package installers with./gradlew packageDistributionForCurrentOS(.msi / .dmg / .deb, the arm64 .deb being the Raspberry Pi OS package). - Web: browsers cannot open IMAP/SMTP sockets, so
web/is a small backend that does the mail work and serves a browser UI. See the section above. - iOS: Jakarta Mail is JVM-only and Apple does not allow a JVM on iOS, so the iPhone/iPad
app cannot run the backend itself. It shows the same web client full-screen in WebKit, served
by a Quillbox server on your own network — the
quillbox-webzip on a PC, Mac, Raspberry Pi or NAS (it binds to all interfaces by default), or the desktop app started withHOST=0.0.0.0. Enter the address (for examplehttp://192.168.1.20:8080) on the first screen; Settings → Account → Change server changes it later. Printing, attachment saving (share sheet), external links andmailto:are handled natively. The Xcode project is generated fromios/project.ymlvia XcodeGen (cd ios && xcodegen generate && open Quillbox.xcodeproj).
Pushing a tag such as v1.2.0 makes GitHub Actions build every target and attach the files
to one GitHub Release (Releases → Quillbox v1.2.0):
| Target | Release file |
|---|---|
| Android, ChromeOS | quillbox-v1.2.0.apk (signed when the signing secrets exist, else -unsigned) |
| Windows | quillbox-desktop-v1.2.0-windows-x64.msi, or the portable .zip |
| macOS | quillbox-desktop-v1.2.0-macos-arm64.dmg, or the portable .tar.gz |
| Linux x64 | quillbox-desktop-v1.2.0-linux-x64.deb, or the portable .tar.gz |
| Raspberry Pi OS (64-bit) / Linux arm64 | quillbox-desktop-v1.2.0-linux-arm64.deb, or the portable .tar.gz |
| Web / any OS with Java 17+ (servers, Termux, ChromeOS Linux) | quillbox-web-v1.2.0-any-jvm.zip → unzip, run bin/quillbox-web |
| iOS | Quillbox-iOS-simulator.app.zip (unsigned simulator build; a device build needs an Apple Developer account) |
Windows, macOS and iOS packages can only be built on those systems, so from a phone the job is: build and test what the phone can (the web/JVM server), then push a tag and let GitHub Actions build all eight targets. Everything below is typed into Termux.
One-time setup:
pkg update && pkg upgrade -y
pkg install -y git openjdk-17 gh unzip
termux-setup-storage # optional: reach ~/storage/downloads
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
gh auth login # GitHub CLI login (HTTPS + browser/token)
git clone https://github.com/socrtwo/quillbox.git
cd quillbox
mkdir -p ~/.gradle && printf 'org.gradle.jvmargs=-Xmx1536m\norg.gradle.daemon=false\n' >> ~/.gradle/gradle.propertiesBuild and test the web/JVM server on the phone (this is the same zip the release ships):
cd ~/quillbox/web
chmod +x gradlew
./gradlew build distZip --no-daemon # runs the junk-engine tests, writes build/distributions/quillbox-web-1.2.0.zip
cd build/distributions && unzip -o quillbox-web-1.2.0.zip && cd quillbox-web-1.2.0
PORT=8080 ./bin/quillbox-web # open http://localhost:8080 in the phone browserCut the release (all targets are built by GitHub Actions and attached to one GitHub Release):
cd ~/quillbox
git checkout main && git pull
# bump the version first if needed: app/build.gradle.kts (versionCode/versionName),
# web/build.gradle.kts (version), desktop/build.gradle.kts (version, packageVersion)
git commit -am "Release v1.2.0" # only if you changed something
git push
git tag -a v1.2.0 -m "Quillbox 1.2.0"
git push origin v1.2.0 # <- this triggers the Android, Desktop, Web and iOS release builds
gh run list --limit 8 # watch the four workflows
gh run watch # follow one interactively
gh release view v1.2.0 # list the attached files when they are done
gh release download v1.2.0 -D ~/storage/downloads/quillbox-v1.2.0If the repository has no signing secrets, the APK arrives unsigned; sign it on the phone:
pkg install -y apksigner
keytool -genkeypair -v -keystore ~/quillbox-release.jks -alias quillbox -keyalg RSA -keysize 2048 -validity 10000
apksigner sign --ks ~/quillbox-release.jks --ks-key-alias quillbox \
--out quillbox-v1.2.0.apk ~/storage/downloads/quillbox-v1.2.0/quillbox-v1.2.0-unsigned.apkTo have CI sign it instead, add the secrets KEYSTORE_BASE64, KEYSTORE_PASSWORD, KEY_ALIAS
and KEY_PASSWORD (see Automated signed builds in CI above):
base64 -w 0 ~/quillbox-release.jks | gh secret set KEYSTORE_BASE64
gh secret set KEYSTORE_PASSWORD
gh secret set KEY_ALIAS --body quillbox
gh secret set KEY_PASSWORDdocs/RELEASING.md has the longer version, including the unofficial way to build the APK inside Termux and what a phone cannot build (Windows/macOS installers, a signed iOS app).
Credentials and downloaded mail are stored locally only and are transmitted solely to the user's own mail servers (on the web/iOS clients, via your own Quillbox backend). Use real credentials only on your own device; the repository ships with placeholder values only.
The web backend keeps passwords in memory for the life of a session and writes only rules, settings and analysis results to disk. Put it behind HTTPS (a reverse proxy such as Caddy or nginx) whenever it is reachable from outside the machine it runs on. The junk engine's only outbound traffic is DNS: blocklist lookups for sending IPs and domains.