Skip to content

Configuring ihasmail

Everything is an environment variable. There is no configuration file to manage, no admin UI, and nothing to migrate between versions — which is a consequence of ihasmail keeping no state of its own rather than a stylistic choice.

The server reads a .env beside the app if there is one, but a real environment variable always wins: the loader only fills in names that are not already set. That is what makes docker run --env-file and a committed .env.example coexist safely.

Pointing at Stalwart

Variable Default Notes
STALWART_URL https://mail.example.com Scheme and host, no path. The JMAP session is discovered at <STALWART_URL>/.well-known/jmap; a trailing slash is trimmed for you
UPSTREAM_TIMEOUT 30000 Milliseconds to wait on any one upstream call

Only the ihasmail server talks to Stalwart. Browsers never do, which is why there is no CORS to arrange and no reason for Stalwart to be reachable from outside your network unless you want it to be for other clients.

Sessions and cookies

ihasmail never puts credentials in the browser. It authenticates against Stalwart, then keeps the upstream credentials server-side, sealed with a key derived from APP_SECRET — plaintext passwords are never written anywhere.

Variable Default Notes
APP_SECRET (required in production) Session encryption keys are derived from it. openssl rand -base64 48
SESSION_TTL 43200 Idle lifetime in seconds — 12 hours
SESSION_REMEMBER_TTL 2592000 Idle lifetime with keep me signed in — 30 days
SESSION_FILE (unset) Persist sessions across restarts. The Docker image sets /data/sessions.json, on the named volume
SECURE_COOKIES auto auto marks cookies Secure when the request arrived over HTTPS; 1 or 0 to force
COOKIE_NAME ihm_session Rename if two instances share a hostname
LOGIN_RATE_LIMIT 10 Failed sign-ins allowed per 15-minute sliding window, counted per IP and per IP + username

Only ciphertext reaches SESSION_FILE. Losing it signs everyone out; leaking it gives up nothing without APP_SECRET. Changing APP_SECRET invalidates every existing session, which is the fastest way to sign the world out.

Behind a reverse proxy

Variable Default Notes
HOST / PORT 0.0.0.0 / 8080 Listen address
TRUST_PROXY 1 Honour X-Forwarded-* — but only from a peer in TRUSTED_PROXIES
TRUSTED_PROXIES (loopback + private ranges) Comma-separated CIDRs or addresses whose forwarding headers are believed

The default covers the ordinary case — a proxy on the same host or the same Docker network — without naming anything. A request from outside those ranges is attributed to its socket address whatever its headers claim, because otherwise anyone could pick their own key for the login rate limiter and never be limited.

Do not set TRUSTED_PROXIES to 0.0.0.0/0

That tells ihasmail to believe every client about who it is. The rate limiter, and every log line naming an address, become fiction.

Uploads, images and branding

Variable Default Notes
MAX_UPLOAD_BYTES 52428800 50 MiB. Stalwart has its own limit; the lower of the two wins, and your proxy needs a body limit above both
IMAGE_PROXY 1 Route remote images in messages through the privacy proxy, Gmail-style. 0 loads them from the sender's server directly
APP_NAME ihasmail Branding, shown in the UI and by /api/health
SOURCE_URL the upstream repo Where this instance's source can be had
STATIC_DIR web/dist Where the built SPA is served from. The image sets /app/web/dist

SOURCE_URL is a licence obligation, not decoration

ihasmail is AGPL-3.0-or-later. Section 13 asks whoever runs a modified version over a network to offer its users that version's source — not the one it was forked from. If you have patched it, point SOURCE_URL at your own tree: the sign-in page and Settings › About both show it, so the people using your instance are told where the code they are actually running can be found.

Remote images are blocked by default regardless, with a per-sender allow-list in the UI. IMAGE_PROXY decides what happens when someone allows them: through your server (the sender learns nothing about the reader) or directly (the sender learns the reader's IP address, user agent and the moment they opened it). The proxy is SSRF-safe — it will not be talked into fetching your internal network.

Settings that are not here

User-facing preferences are not server configuration. Identity, signatures, locale, date format, theme, labels, templates and the rest live in a settings.json in the account's own JMAP Files, on Stalwart — so they follow the account between browsers and devices, including private windows, and they are backed up with the mail store. ihasmail stores nothing itself.

Settings that describe this screen rather than the account deliberately stay in the browser: pane sizes, density, font size, sidebar state, and the notification toggles, which track a permission the browser grants per device.

Stalwart-side settings some features need

Three features depend on the server being configured a particular way. None of them can be fixed from ihasmail's side.

Scheduled send needs futureRelease

This one is worth reading twice, because the failure is silent.

Stalwart advertises the delay in the account's urn:ietf:params:jmap:submission capability — maxDelayedSend: 2592000 and FUTURERELEASE among its submissionExtensions. But the MTA only honours a hold when futureRelease is set under the session's MTA extensions, and that setting defaults to false. With it off, Stalwart accepts the HOLDUNTIL parameter, skips the hold, and sends the message immediately without an error — while the capability still claims thirty days.

So set futureRelease to the longest hold you want to allow before relying on scheduled send. Shorter than 30 days is fine: a request past the limit is refused honestly, naming it. The capability is no evidence either way. Only a submission tells you.

The Scheduled folder is ihasmail's, not Stalwart's

A held message would otherwise land in Sent the moment the submission is created, so ihasmail files it in Scheduled and reconciles the folder on the way in — released messages to Sent, cancelled ones back to Drafts. Nothing in Stalwart does that moving, so if ihasmail is never opened again the message still goes out; it is only the folder that waits to be tidied.

Requires Stalwart 0.16.17 or newer for three fixes it depends on, including HOLDUNTIL accepting RFC 3339 date-times again.

Web Push needs VAPID

Notifications that arrive with ihasmail closed are Web Push signed with VAPID (RFC 9749), which Chromium and Safari require. ihasmail offers it only when Stalwart advertises urn:ietf:params:jmap:webpush-vapid with a real applicationServerKey; where the capability is absent the feature is hidden rather than failing.

Stalwart pushes straight to the browser's own push service — no relay, no extra service to run, and ihasmail's server is not in the delivery path. With emailpush also advertised, the payload carries sender and subject, so the notification is useful without a round-trip, and the filter deciding what is worth notifying about stays on the server, so spam never leaves it.

\"Closed\" means ihasmail, not the browser

Web Push is delivered over a connection the browser holds, so something of it has to be running. With the browser open and every ihasmail tab closed, a notification arrives immediately. With the browser fully quit and continue running background apps off, nothing arrives until it is started again — and a push message carries a TTL, so one that expires first is dropped rather than delivered late. Installing ihasmail as a PWA does not change this on a desktop.

Self-service credentials and the account locale

Settings › Security — password change, app passwords, two-factor — is built on Stalwart's x:AccountPassword and x:AppPassword registry objects, which are 0.16 and later only. Password changes are refused by Stalwart for accounts backed by an external directory (LDAP, SQL, OIDC); ihasmail shows the server's own message when that happens.

The account's locale is read from x:AccountSettings/get, whose permission the built-in user role carries. It falls back to x:Account/get, which needs the admin-only sysAccountGet, and then to the browser's locale — so a user picking their own date format by hand is the expected outcome on a server that will not answer, not a fault.


Next: Using ihasmail — the parts of the app that are not self-evident.