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
STALWART_URLandAPP_SECRET— the two from the quick start.- Behind a reverse proxy — if your proxy is
on another machine, or you serve ihasmail from a path like
/mail. - Settings your installation decides — to switch a feature on for everybody.
- What Stalwart needs switched on — if scheduled send, notifications or the dashboard do not work.
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:
{
"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://orhttps://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:
{
"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— withouturi 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.
Where the dashboard links¶
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 —
/adminunless 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— oradminUrlfor 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
{
"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:
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 missingversionorsettings, stops ihasmail at startup. The value can be any unique text. A timestamp like20260902084513is 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.