Aevistle holds the password to your mailbox and can send mail as you without you being present. That is a meaningful amount of trust, so this document says plainly what the app defends against, what it does not, and how to tell us when we got it wrong.
Please do not open a public issue for a security bug.
Use GitHub's private reporting: Report a vulnerability
Useful things to include: what you did, what happened, which build and OS, and whether you needed local access. A proof of concept helps but is not required — a clear description of the flaw is enough to get started.
Expect a first reply within a week. If a fix is warranted it ships in the next release with credit, unless you would rather not be named.
Defended against
- Another user account on the same computer reading your mail password
- An app on the same Android device triggering a send, or reading stored data
- A network attacker between you and your mail server reading credentials
- Malicious content in a message — a subject, body, template or attachment name — escalating into extra mail headers or code execution
- A corrupted or hand-edited settings file crashing the app or escaping its data directory
- A received message's HTML escalating into script execution, or a remote image inside one being used to probe your own network or confirm the message was opened
Not defended against, and cannot be
- Malware already running as your user on your machine. It can read the keystore exactly as the app can; every local credential store has this property.
- A compromised or hostile mail provider. Aevistle authenticates to the server you name and hands it your message — this applies equally to sending and to receiving.
- Someone with your unlocked device. There is no separate app lock yet.
- Traffic analysis. Your provider necessarily knows who you mailed and when, and, for accounts with receiving turned on, who mailed you.
The password is never written into the settings file, never included in an export, and never sent anywhere other than the SMTP server you configured.
| Platform | Mechanism |
|---|---|
| Windows | Electron safeStorage, backed by DPAPI. The ciphertext is bound to your Windows user account; copying secrets.json to another machine yields nothing. |
| Android | A 256-bit AES-GCM key generated in the Android Keystore, hardware-backed on devices with a secure element. The key is non-exportable — even a rooted device cannot read it out, only ask the keystore to use it. |
state.json (schedules, contacts, activity log) is deliberately kept separate
from secrets.json so that the file a user might copy or paste into a bug
report never contains a credential.
An account's IMAP password (for receiving) and SMTP password (for sending)
are stored under separate keystore entries, even though they belong to the
same account — namespaced by a :imap suffix on the storage key. Turning
receiving off, or removing just the IMAP credential, can never clobber the
SMTP password the account still needs to send.
Remote images and SSRF. Loading a remote image on request is a
"fetch an attacker-chosen URL" primitive, so the destination is checked against
private, loopback and link-local ranges before anything connects. Two paths
have to be covered, not one: hostnames resolve through a custom lookup hook
so the address checked is the address connected to (no DNS-rebinding gap), and
URLs that already contain a literal IP are rejected up front — those never
reach the hook at all, because the networking stack only resolves what needs
resolving. INBOX-05 in scripts/audit.mjs fails the build if either half
goes missing.
If no separate receiving password was ever stored, receiving falls back to the account's own sending password. This does not widen anything: it is the same credential, for the same account, held under the same OS encryption, used against the server the same account already authenticates to. Every provider with a preset here issues one app password covering both protocols, and asking for it a second time only adds a way to mistype it. Storing a distinct IMAP password still takes precedence.
SMTP header injection. The single highest-impact bug class in any mail
client. A newline inside a subject or an address lets an attacker append
Bcc: or a new body and turn the app into an open relay. Every string bound
for a header is rejected if it contains CR, LF, NUL, VT, FF, U+2028 or U+2029 —
in the renderer, again in the Electron main process, and again in the Android
sender. Three checks for one bug is deliberate: the renderer is the part an
attacker reaches first, and the other two are where a bad header would become
bytes on a socket.
Renderer privileges. contextIsolation: true, nodeIntegration: false,
sandbox: true. The renderer reaches the operating system only through the
method list in src/core/ipc-contract.ts, which takes plain serialisable data
and nothing else. A cross-site scripting bug in the UI stays a UI bug.
Content Security Policy. script-src 'self' with no unsafe-inline and no
unsafe-eval; object-src, frame-src and frame-ancestors are none. The
app never loads remote content, so nothing legitimate is lost.
Navigation. window.open is intercepted and denied; cross-origin
will-navigate is cancelled. openExternal accepts only http: and https:,
so a crafted link cannot hand a file:// path or a custom protocol handler to
the OS.
TLS. Certificates are verified and TLS 1.2 is the floor. Accepting invalid certificates is a per-account switch, painted red, that says in plain words what it costs you. It exists because self-hosted servers with private CAs are real, not because it is ever a good default.
Attachments. Paths are resolved and stat-ed before a connection is opened,
so a missing file fails cleanly rather than half-sending. The filename written
into the MIME part is passed through basename, so a crafted name cannot talk
the receiving client into writing outside its downloads folder. There is a hard
total-size ceiling independent of the per-provider limit.
Android components. AlarmReceiver is exported="false" — without that,
any app on the device could broadcast an intent and make Aevistle send mail.
allowBackup="false" keeps adb backup from copying schedules and settings off
the device. usesCleartextTraffic="false". The permission list is four entries
long and each one is justified in a comment in the manifest.
Content URIs. Files picked on Android are copied into app-private storage
immediately. A content:// grant expires; a reminder scheduled for next month
would otherwise find its attachment unreadable.
Received mail. Receiving is opt-in per account and everything below applies the moment it is turned on:
- A message's HTML is sanitized to a strict tag/attribute allowlist — no
script,iframe,object,embed,form,style,link,meta, oron*handler survives — before it crosses any process or WebView boundary. On desktop this happens in the Electron main process, inelectron/sanitizeHtml.ts(sanitize-html); on Android, inInboxSanitizer.java(JsoupSafelist), which goes one step stricter and drops thestyleattribute entirely rather than trying to filter individual CSS properties. - The sanitized result is rendered inside a sandboxed
<iframe>with noallow-scripts, so even a sanitizer bug would still hit a wall that cannot execute anything — the renderer never usesinnerHTMLordangerouslySetInnerHTMLfor message content, the same invariant the audit script already enforced everywhere else in the codebase. - Every remote
<img>is blocked by default and replaced with a placeholder; loading the real image is an explicit action, and the fetch that follows goes through a resolver that rejects private, loopback, and link-local addresses before connecting — so a message cannot use an<img>tag to probe your own LAN or a cloud metadata endpoint. Desktop closes this completely: Node'shttp(s).requesttakes a custom DNS resolver, so the address checked is the exact same one connected to, with no gap for a DNS-rebinding attacker to answer differently between the two. Android has a narrower version of the same guard —java.net.HttpURLConnectionhas no equivalent hook, so the check and the connection resolve the hostname separately, leaving a small window for a rebinding attack. Pinning the connection to the checked IP address would close it, but breaks TLS SNI for any image host behind SNI-based virtual hosting (most real CDNs); the trade-off was made in the guard's favour of not breaking HTTPS for the common case, with the gap noted here rather than silently accepted. - Clicking a link inside a message body always shows the resolved destination
host before handing it to the same
openExternalSafely()path a template's links already used — link text and href disagreeing is the oldest phishing trick in email. - Removing a message from the inbox view deletes the local cache only —
Aevistle never sends an IMAP
\Deleted/EXPUNGE to the server. Providers disagree on what server-side "delete" should even mean; guessing wrong risks losing mail the user did not intend to lose.
Supply chain. On desktop: nodemailer and imapflow for SMTP/IMAP,
mailparser and sanitize-html for parsing and sanitizing received mail,
plus the Capacitor packages used only on mobile. On Android: android-mail
and android-activation (send and receive both go through the same JavaMail
dependency) and Jsoup (received-mail sanitization). The icon set is
hand-drawn SVG rather than an icon library, and there is no analytics,
crash-reporting or auto-update dependency. package-lock.json is committed.
npm run audit:selfTwenty-one checks covering committed credentials, .gitignore coverage,
Electron window flags, navigation guards, CSP strictness, eval/innerHTML
usage, header-injection guards on both platforms, TLS configuration, attachment
path handling, Android manifest exposure, keystore usage, dependency surface,
received-mail sanitization, remote-image CSP coverage, IMAP credential
namespacing, the revealPath path guard, and update-checksum fail-closed
behavior. Exit code 1 if anything needs attention.
Alongside it:
npm run audit:deps # advisories, against registry.npmjs.org
npm run check # the above plus typecheck and every behavioural guardaudit:deps distinguishes three outcomes rather than two: clean, an advisory
at or above the threshold (exit 1), and the database could not be reached —
which is reported loudly and does not pass silently as if nothing were wrong.
Every release publishes SHA256SUMS.txt next to the binaries.
sha256sum -c SHA256SUMS.txtSHA256SUMS.txt proves a download was not corrupted on the way to you. On its
own it cannot prove who published it — the file and the binaries it describes
come from the same place, reachable by the same credential, so anyone able to
replace one can replace the other. That is the gap the signature closes.
gpg --import aevistle-public-key.asc # published with every release
gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt
sha256sum -c SHA256SUMS.txtSigning key fingerprint:
<!-- AEVISTLE_GPG_FINGERPRINT -->57753E473F94B09DB3AA9A6AB865B328D5535D9B
If gpg --verify reports anything other than a good signature from that
fingerprint, do not install the download — whatever else is wrong, it did not
come from this project's key.
Maintainers: the key is created once with scripts/setup-signing-key.ps1
(Windows) or scripts/setup-signing-key.sh. On Windows use the PowerShell one
— typing bash there resolves to WSL, which has a different home directory, so
the key would land where nothing else can find it.
After that, publishing is one command:
npm run release -- v0.1.6It hashes the artifacts, signs the manifest, verifies that signature in a
keyring holding only the published public key, uploads, then downloads
everything back and verifies again from the published copies. Signing is not a
step in it — it happens before anything is uploaded, so a run that cannot sign
stops without publishing rather than leaving an unsigned release behind for
someone to notice later. npm run check:signing is the backstop, and it runs
as part of npm run check.
Signer changed at v0.1.19 (2026-08-06). The original signing keystore was lost — not compromised, just absent from every machine and backup this project builds from. Every release through v0.1.18 was signed with it; every release from v0.1.19 on is signed with a newly generated key. Android will refuse to install v0.1.19 as an upgrade over v0.1.18 or earlier — the platform treats a different signer as a different app, by design. Anyone on v0.1.18 or earlier must uninstall before installing v0.1.19+. This is stated here rather than smoothed over because the paragraph below used to promise this would never happen.
Check that the APK you have is the one this project built:
apksigner verify --print-certs Aevistle-<version>.apkReleases through v0.1.18:
Signer #1 certificate SHA-256 digest: 563e9757d43f821986d271d0c27e9b7422124d9d7d024d230dd28f6f7697d08d
Signer #1 certificate SHA-1 digest: 15e23a5fb7ec677cabcfd50529c22e873d20a29d
Releases from v0.1.19 on:
Signer #1 certificate SHA-256 digest: 21cf7b4971a6eb635d78623fb41a4b28069c16159436913ab21f1af3e2916745
Signer #1 certificate SHA-1 digest: 866b48cea89f629b81db729d542b4bbbbf20dea6
A fingerprint outside both of these means a different signer — not a new
version of this app. The Windows builds are not code-signed (see
"Hardening choices"), so SHA256SUMS.txt is the only integrity check
available for those.
It runs on the source tree, so it works on a clone as well as on this repository.
Pre-1.0, only the latest release receives fixes.
| Version | Supported |
|---|---|
| 0.1.x | ✅ |