Skip to content

Configuring ihasmail

This page lists every setting ihasmail has, and what each one does.

All settings are environment variables: lines like NAME=value in your .env file, or -e NAME=value on docker run. There is no settings screen for the installation, and nothing to migrate when you upgrade. Two optional files exist as well — a settings policy and a server mapping. ihasmail reads both once when it starts, and never writes to them.

Most people only need a few of these

Everything else has a sensible default.

Where settings are read from

ihasmail reads a .env file beside the app if there is one. But a real environment variable always wins: the .env file only fills in names that are not already set. That is why docker run --env-file and a committed .env.example can live side by side safely.

Pointing at Stalwart

Variable Default Notes
STALWART_URL https://mail.example.com Your Stalwart server: scheme and host, no path. A trailing slash is trimmed for you
STALWART_SERVERS_FILE (unset) A JSON file sending different domains to different Stalwart servers — see below
UPSTREAM_TIMEOUT 30000 How long to wait for any one reply from Stalwart, in milliseconds

ihasmail finds Stalwart's JMAP service at <STALWART_URL>/.well-known/jmap. (JMAP is the standard ihasmail uses to talk to Stalwart.)

Only ihasmail talks to Stalwart — your users' browsers never do. So there is no cross-site (CORS) setup to arrange, and Stalwart does not need to be reachable from outside your network unless other mail apps use it. Network and ports shows the whole path.

Several Stalwart servers

One ihasmail can sit in front of more than one Stalwart. It picks the server by the domain in the address somebody signs in with.

Write a JSON file that maps each domain to its server:

servers.json
{
  "example.com": "https://mail.example.com",
  "customer-b.test": "https://jmap.customer-b.test"
}

Then mount it and name it in STALWART_SERVERS_FILE:

docker run -d --name ihasmail \
  -e STALWART_URL=https://mail.example.org \
  -e STALWART_SERVERS_FILE=/etc/ihasmail/servers.json \
  -v /srv/ihasmail/servers.json:/etc/ihasmail/servers.json:ro \
  -e APP_SECRET="$(openssl rand -hex 32)" \
  -p 127.0.0.1:8080:8080 registry.coffeylabs.org/coffey-labs/ihasmail:latest

Mount it read-only (:ro). ihasmail only reads it, so it still works with a read-only container and no volume — the same as a settings policy. stalwart-servers.example.json in the repo is a copy of that file with the rules written into it.

STALWART_URL is still required, and is still the default. The file only adds domains that go somewhere else. If you set nothing else, ihasmail behaves exactly as before, and deleting the file puts everything back.

Signing in as Goes to
[email protected], a domain in the file the server the file names
[email protected], a domain nobody listed STALWART_URL
ana, a bare username with no domain — which Stalwart accepts STALWART_URL

A listed domain never falls back to the default

If a listed domain's server is down, that sign-in fails. It does not try STALWART_URL instead.

Why not fall back?

Falling back would sign somebody in to a server their domain was deliberately sent away from. If the same account name existed there, they would land in another organization's mailbox. The default is only for domains nobody listed, decided before any network call. It is not a way to recover from an outage.

ihasmail refuses to start if the file is wrong, rather than failing later at somebody's sign-in. Four things stop it:

  • the file is not there;
  • the JSON is malformed;
  • the same domain appears twice (after the tidying below);
  • a value is not an http:// or https:// address.

Domains are lower-cased and have any trailing dot removed when the file is read, because that is how a domain taken from a username arrives. Compared any other way, a mapping could silently never match.

The servers are not contacted when ihasmail starts. So one server being down does not stop ihasmail starting for everybody else. It does mean your firewall must let ihasmail reach every server in the file, or those domains fail at sign-in. Network and ports has the rules.

Editing the file means restarting the container, just like a settings policy. The server is worked out from the username at each sign-in, not saved in the session, so the change applies to everyone as soon as you restart.

One server per person, chosen at sign-in

One person cannot use several servers at once — there is no combined inbox across two servers. JMAP account ids are only unique within one server, so that would mean rewriting every id as it passes through ihasmail. Reading someone else's mail, calendars or files on the same server already works through sharing, and needs none of this.

A different administration address per server

A domain's value can also be an object. Use this when that server's own administration pages live somewhere other than the address Stalwart advertises:

servers.json
{
  "customer-b.test": { "url": "https://jmap.customer-b.test", "adminUrl": "https://admin.customer-b.test" }
}

adminUrl is the per-server version of STALWART_ADMIN_URL (see Where the dashboard links). Like the rest of the file, it is checked at startup. A listed domain is never sent to the default server's administration.

Sessions and sign-in

ihasmail never puts passwords in the browser. It checks the sign-in with Stalwart, then keeps the credentials on its own server, encrypted with a key made from APP_SECRET. A plain-text password is never written anywhere.

Variable Default Notes
APP_SECRET (required in production) The encryption keys are made from it. Create one with openssl rand -base64 48
SESSION_TTL 43200 How long a session lasts while idle, in seconds — 12 hours
SESSION_REMEMBER_TTL 2592000 The same, when This is my own device is ticked — 30 days
SESSION_FILE (unset) A file to keep sessions in across restarts. The Docker image sets /data/sessions.json, on its named volume. Empty means memory only
IMMUTABLE 0 1 promises a read-only container, and ihasmail checks it — see Running immutably
SECURE_COOKIES auto auto marks cookies Secure when the request came over HTTPS. 1 or 0 forces it
COOKIE_NAME ihm_session Change it if two ihasmails share one hostname
LOGIN_RATE_LIMIT 10 Failed sign-ins allowed in any 15 minutes, counted per IP address and per IP address plus username

When This is my own device is not ticked, SESSION_TTL applies instead, the cookie lasts only until the browser closes, and the app signs itself out after 5 minutes idle.

Only encrypted data reaches SESSION_FILE. Losing the file signs everyone out. Leaking it gives nothing away without APP_SECRET.

Changing APP_SECRET signs everyone out — every existing session stops working. That makes it the fastest way to sign the world out, if you need to.

Running immutably

"Immutable" means the container writes nothing to disk. SESSION_FILE is the only file ihasmail ever writes to. Leave it empty, set IMMUTABLE=1, and run the container --read-only with no volume:

docker run --read-only --tmpfs /tmp \
  -e IMMUTABLE=1 -e SESSION_FILE= \
  --env-file .env -p 127.0.0.1:8080:8080 \
  registry.coffeylabs.org/coffey-labs/ihasmail:latest

IMMUTABLE=1 does not change how ihasmail behaves. It is a promise that ihasmail checks: it refuses to start if SESSION_FILE is still set, or if the files it is installed in turn out to be writable.

The trade-off

Sessions are held in memory, so every restart or upgrade signs everyone out. If you would rather they stayed signed in, leave IMMUTABLE unset and mount a volume for SESSION_FILE.

Running immutably covers the whole picture: what it gives you, what it costs, how to check it, and how to go back.

Behind a reverse proxy

A reverse proxy is the web server in front of ihasmail — nginx, Caddy or similar — that handles HTTPS.

Variable Default Notes
HOST / PORT 0.0.0.0 / 8080 The address and port ihasmail listens on
TRUST_PROXY 1 Believe the proxy's X-Forwarded-* headers — but only from an address in TRUSTED_PROXIES
TRUSTED_PROXIES (loopback + private ranges) Comma-separated addresses or ranges (CIDRs) whose forwarding headers are believed
BASE_PATH (the domain root) Serve from a path such as /mail. Also needed when building the image — see below

The defaults already cover the usual setup: a proxy on the same machine or the same Docker network. You only need TRUSTED_PROXIES if your proxy is somewhere else.

Why ihasmail does not believe every proxy

The proxy tells ihasmail each visitor's real address in a header. Anyone can send that header, though. So a request from outside the trusted addresses is judged by where it really came from, whatever its headers claim. Otherwise anyone could pretend to be a new address on every try, and the sign-in rate limit would never catch them.

Do not set TRUSTED_PROXIES to 0.0.0.0/0

That tells ihasmail to believe every visitor about who they are. The sign-in rate limit, and every address in the logs, become fiction.

Serving from a subpath

BASE_PATH puts the whole app under a path, such as https://example.com/mail/. Use it when the hostname is shared with other sites:

docker build --build-arg BASE_PATH=/mail -t ihasmail:mail .
docker run -e BASE_PATH=/mail ... ihasmail:mail

/mail, mail and /mail/ all mean the same thing. Everything moves under the path together: /mail/api/health, every link into the app, the icons, the app manifest, the service worker, and the sign-in cookie.

You have to build your own image for this

The path is written into the app's web page when the image is built, so an image built without it cannot load under a path. The published images are built for the root, so build your own with --build-arg as above.

If the two do not match, the page comes up blank. ihasmail checks for this on the first request and says so in its log.

Your proxy must pass the path through unchanged. Point it at ihasmail without stripping the path:

  • nginx: proxy_pass http://127.0.0.1:8080; — with no trailing slash.
  • Caddy: reverse_proxy — without uri strip_prefix.

A proxy that strips the path is talking to an app at the root, and should be used with no BASE_PATH at all.

Uploads, images and branding

Variable Default Notes
MAX_UPLOAD_BYTES 52428800 The largest upload: 50 MiB. See the note below
IMAGE_PROXY 1 Load pictures in messages through your server. 0 loads them straight from the sender's server
APP_NAME ihasmail The name shown in the app and by /api/health
SOURCE_URL the upstream repo Where people can get the source code of your copy
STATIC_DIR web/dist Where the built web app is served from. The image sets /app/web/dist

Uploads have three limits. Stalwart has its own upload limit, and the lower of Stalwart's and MAX_UPLOAD_BYTES wins. Your reverse proxy also needs its body limit set above both.

Pictures in messages are blocked until someone allows them, whatever IMAGE_PROXY says. People can allow them per sender. IMAGE_PROXY decides what happens then:

  • On (the default): your server fetches the pictures, and the sender learns nothing about the reader.
  • Off: the pictures load directly, and the sender learns the reader's IP address, browser details, and the moment they opened the message.

The proxy is protected against being tricked into fetching addresses on your internal network.

SOURCE_URL is a license requirement, not decoration

ihasmail is licensed AGPL-3.0-or-later. Section 13 says that whoever runs a modified version for people over a network must offer them that version's source — not the original it was based on. If you have changed ihasmail, point SOURCE_URL at your own code. The sign-in page and Settings › About both show it, so your users can find the code they are actually running. Rebranding has more.

Administration

Accounts whose Stalwart role can manage the server get an Administration page in the account menu. It has a dashboard, and accounts, groups, mailing lists, roles, tenants and domains, as far as the role allows.

There is nothing to set up. ihasmail reads the account's permissions from Stalwart at sign-in and offers what they allow. Stalwart checks every change against the role.

Variable Default Notes
ADMINISTRATION 1 0 turns Administration off for everyone — see below
STALWART_ADMIN_URL (found from the server) Where the dashboard links to Stalwart's own administration, if the address it finds is wrong
SHOW_ENTERPRISE_NOTICES 0 1 shows the Tenants are a Stalwart Enterprise feature notice even on an Enterprise server. Meant for a demo; a real installation leaves it off

ADMINISTRATION=0 is enforced by the server, not just by hiding the menu. The menu entry goes, and so does the access behind it:

  • A session that may not administer is sent no permissions.
  • ihasmail refuses Stalwart's management requests, except the ones about the signed-in account itself: its password, app passwords, API keys, public keys, masked addresses and settings.

Without that second part, an administrator could still make every request the page makes, from the browser's developer tools. Stalwart's own administration pages are not affected either way.

Administration is also refused to a session signed in without This is my own device ticked, whatever ADMINISTRATION says. There is no setting for that: a borrowed or shared computer is no place to reset passwords or remove domains from.

Under its cards, the dashboard says that detailed metrics, the delivery queue, logs and server settings are in Stalwart's own administration — and links there.

ihasmail finds that address for you. It takes:

  • the public address Stalwart advertises in its own session — the one people reach it at, even when ihasmail talks to it on a private address; and
  • the path Stalwart's web interface is installed under, read from Stalwart's list of installed applications — /admin unless it was moved.

Some cases work out differently:

  • A server whose web interface is disabled, or moved to another path, gets no link.
  • An administrator whose role may not read that list of applications gets Stalwart's default, /admin.
  • STALWART_ADMIN_URL — or adminUrl for a domain in the servers file — replaces the address, for an administration that lives on another host.

Settings that are not here

Your users' own preferences are not server settings. Identity, signatures, language, date format, theme, labels, templates and the rest are kept in a settings.json file in each account's own files on Stalwart. So they follow the person between browsers and devices, including private windows, and they are backed up with the mail. ihasmail stores none of it itself.

A few settings describe this screen rather than the account, so they stay in the browser on purpose: pane sizes, density, font size, whether the sidebar is open, and the notification switches (which follow a permission each browser grants separately).

An installation can still start people off with preferences, or lock them, without taking them over — see the next section.

Settings your installation decides

Asking three thousand pupils to switch on a security warning is not a plan. This section lets you decide for them:

  • what a new account starts with;
  • what nobody may change;
  • what to switch on once for people who are already here.

Nothing is set by default. If you use none of this, ihasmail behaves exactly as before and you will not notice it exists.

Variable Default Notes
SETTINGS_POLICY_FILE (unset) A JSON file holding all three sections. Read once at startup, never written
SETTINGS_DEFAULTS (unset) The defaults section as JSON, for a setup that cannot mount a file
SETTINGS_ENFORCED (unset) The enforced section as JSON
SETTINGS_CHANGES (unset) The changes list as JSON

If SETTINGS_POLICY_FILE is set, the other three are ignored entirely. So a file and a stray variable can never half-apply together.

Section Applies to Can the person change it?
defaults accounts that have never had settings of their own Yes, any time
enforced everyone, every time the app loads No — the control stays visible but grayed out
changes everyone, once each, existing accounts included Yes, afterwards, and it stays changed

The three sections, in detail

defaults are a starting point, not a rule. They fill in an account that has no settings file of its own yet. People can change them straight away. An account that already exists never sees them.

enforced settings are put back every time the app loads, and cannot be changed. Their controls stay visible in Settings, grayed out, with a line saying why.

Why the controls stay visible, and why enforcement cannot be dodged

A control that is simply missing looks like a bug to somebody who has used ihasmail without a policy. So it stays, grayed out.

Enforcement happens where settings are stored, not only on the controls. So importing a settings file, syncing one from a device that predates the policy, or "reset to defaults" cannot get around it. Reset goes back to your defaults, not ihasmail's.

changes switch something on once, for people who are already here. A default cannot reach existing accounts; enforcement takes the choice away. A change does neither: it applies once, then the person has the last word.

Each change has a version. Every account remembers the versions it has had, so a change is applied exactly once per person. Someone who turns it back off keeps it off.

A change does override a choice somebody already made

That is the point of it, so be deliberate. If someone turned the outside-sender banner off last week, and you add a change that turns it on, it comes back on for them — once. Their next choice sticks. If you want it to stay on no matter what, use enforced, not changes.

People are told when a change is applied: a notice says how many settings the administrator changed, with a link into Settings.

Passing a policy to Docker

A file is easier to manage than JSON squeezed into a command line, especially once it has changes in it. Mount one and name it:

docker run -d --name ihasmail \
  -e STALWART_URL=https://mail.example.org \
  -e APP_SECRET="$(openssl rand -hex 32)" \
  -e SETTINGS_POLICY_FILE=/etc/ihasmail/policy.json \
  -v /srv/ihasmail/policy.json:/etc/ihasmail/policy.json:ro \
  -p 127.0.0.1:8080:8080 registry.coffeylabs.org/coffey-labs/ihasmail:latest
policy.json
{
  "defaults": { "externalSenderBanner": true },
  "enforced": { "externalRecipientConfirm": true },
  "changes": [
    { "version": "20260902084513", "settings": { "externalSenderBanner": true } },
    { "version": "20261014091500", "settings": { "externalLinkWarning": true } }
  ]
}

Mount it read-only (:ro). ihasmail only reads it, so it keeps working with a read-only container — see Running immutably.

Or use no file at all, which suits a read-only container with no volume:

docker run -d --name ihasmail --read-only --tmpfs /tmp \
  -e IMMUTABLE=1 -e SESSION_FILE= \
  -e STALWART_URL=https://mail.example.org \
  -e APP_SECRET="$(openssl rand -hex 32)" \
  -e SETTINGS_DEFAULTS='{"externalSenderBanner":true}' \
  -e SETTINGS_ENFORCED='{"externalRecipientConfirm":true}' \
  -e SETTINGS_CHANGES='[{"version":"20260902084513","settings":{"externalSenderBanner":true}}]' \
  -p 127.0.0.1:8080:8080 registry.coffeylabs.org/coffey-labs/ihasmail:latest

In docker-compose.yml:

docker-compose.yml
services:
  ihasmail:
    image: registry.coffeylabs.org/coffey-labs/ihasmail:latest
    environment:
      SETTINGS_POLICY_FILE: /etc/ihasmail/policy.json
    volumes:
      - ./policy.json:/etc/ihasmail/policy.json:ro

Editing a policy means restarting the container

ihasmail reads it once, at startup. There is deliberately no way to reload it while running: a setting for the whole installation changing under a running app is harder to reason about than one that changes when you say so.

Writing a policy

The quickest way: set up one account by hand, then export it. A policy uses the same names and values as a settings export. So configure an account the way you want everyone to start, use Settings → General → Export, and copy out the settings you care about.

Three mistakes are caught loudly, because a policy that silently did not apply looks exactly like the feature not working:

  • Malformed JSON stops ihasmail at startup.
  • Every change needs its own version. Two changes sharing one, or a change missing version or settings, stops ihasmail at startup. The value can be any unique text. A timestamp like 20260902084513 is handy because it sorts in order and never repeats.
  • Settings this version of ihasmail does not have are dropped — the same rule as importing a settings file. So a policy written for a newer ihasmail cannot put a dead setting into everyone's settings file. A change whose settings are all unknown is dropped whole, not recorded as applied — so it still runs later, once ihasmail has the setting.

What Stalwart needs switched on

A few features only work if your Stalwart server is set up a certain way. None of these can be fixed from ihasmail.

Scheduled send needs futureRelease

Read this one carefully: when it is wrong, nothing tells you.

Scheduled send holds a message and sends it later. By default, Stalwart sends a scheduled message straight away, with no error.

To fix it, set futureRelease in Stalwart's MTA extensions settings, to the longest delay you want to allow. Anything shorter than 30 days is fine: a message scheduled further out is refused, with a message naming the limit.

Why Stalwart looks like it supports it anyway

Stalwart advertises scheduled sending in the account's urn:ietf:params:jmap:submission capability — maxDelayedSend: 2592000 (thirty days) and FUTURERELEASE in its submissionExtensions. But its mail transfer agent only honors 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 at once — while still advertising thirty days.

So what Stalwart advertises proves nothing either way. Only sending a scheduled message tells you.

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

Stalwart would put a held message in Sent the moment it is scheduled. So ihasmail files it in Scheduled, and tidies that folder each time it opens: sent messages move to Sent, canceled ones back to Drafts. Stalwart does none of that moving. If ihasmail is never opened again, the message still goes out — only the folder waits to be tidied.

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

Web Push needs VAPID

Web Push is what delivers notifications while ihasmail is closed. Chromium browsers and Safari require it to be signed with VAPID (RFC 9749).

ihasmail offers these notifications only when Stalwart advertises urn:ietf:params:jmap:webpush-vapid with a real applicationServerKey. If it does not, the option is hidden rather than left to fail.

Stalwart sends notifications straight to the browser's own push service. There is no relay and no extra service to run, and ihasmail's server is not involved in delivery. If Stalwart also advertises emailpush, each notification carries the sender and subject, so it is useful without opening the app. The decision about what is worth a notification stays on the server, so spam never leaves it.

\"Closed\" means ihasmail is closed, not the browser

Web Push arrives over a connection the browser keeps open, so some part of the browser has to be running.

  • Browser open, every ihasmail tab closed: notifications arrive straight away.
  • Browser fully quit, and continue running background apps off: nothing arrives until the browser starts again. Each notification also has a time limit (TTL), and one that expires first is dropped, not delivered late.

Installing ihasmail as an app (PWA) does not change this on a desktop.

The dashboard's history and Tenants need Enterprise

The dashboard's Server memory, Received and Sent cards read Stalwart's metric history. That history is a Stalwart Enterprise feature, and it has to be switched on: enable the metrics store in Stalwart's settings.

  • A server that refuses the history — Community edition does — simply leaves those three cards off, rather than showing them broken.
  • A server that records nothing says so, rather than showing a day of zeroes.

Tenants are Enterprise too. On any other server, the Tenants page is a notice and nothing else.

Self-service passwords and the account's language

Settings › Security & sessions — changing your password, and app passwords — uses Stalwart's x:AccountPassword and x:AppPassword objects, which exist from Stalwart 0.16 on.

Stalwart refuses password changes for accounts that come from an external directory (LDAP, SQL or OIDC). ihasmail shows Stalwart's own message when that happens.

The account's language and region are read from x:AccountSettings/get, which the built-in user role is allowed to use. If that fails, ihasmail tries x:Account/get, which needs the admin-only sysAccountGet permission, and then falls back to the browser's language. So on a server that will not answer, people picking their own date format by hand is expected — not a fault.


Next: Getting started — signing in, and the parts of the app worth knowing about.