About Hmelj

A self-hosted, Gmail-style webmail client for IMAP/SMTP, Microsoft 365 and Exchange

Hmelj is a universal, multi-account webmail client you run yourself. You sign in with a Hmelj account created on its own login page, then attach any number of mailboxes — a home IMAP server, Gmail, GMX, a Microsoft 365 work account, an on-premises Exchange server — through an in-app wizard. Read them one at a time, or all together in a unified All inboxes view where every message carries a coloured chip showing which account it came from.

It is a single Node.js process with no build step: the entire front end is plain HTML, CSS and JavaScript served as-is. Everything it stores — your accounts, settings, filters, contacts, calendars and the local message cache — lives in one directory on your own server. Mailbox passwords are encrypted at rest with AES-256-GCM.

Hmelj is Slovenian for hops. The icon is a hop cone resting in an open envelope.

The Hmelj inbox: folder sidebar, message list and reading pane
The three-pane view — folders, list, reading pane

At a Glance

  • Multi-account and multi-user — every Hmelj user has their own mailboxes, settings, identities, contacts and filters
  • Four backends — IMAP/SMTP, Gmail (app password or OAuth), Microsoft Graph, Exchange EWS — presented identically
  • Unified inbox — merged Inbox and merged Sent across accounts, with per-account colours and unread counts
  • Local SQLite cache — a background poller keeps it warm, so lists are instant instead of a live fetch on every click
  • Real push notifications — Web Push (VAPID) for browsers and PWAs, Firebase for the Android app, with quiet hours and per-folder rules
  • Conversation view — a message and its replies as one row, opened as a stack
  • Server-side filters — move, copy, redirect, auto-reply, delete, mark, star; run on a schedule or on demand, and they catch up on whatever arrived while Hmelj was off
  • Calendar and contacts — CalDAV, Google, Microsoft 365 and Exchange in one view, and Hmelj can publish calendars of its own for a phone to subscribe to
  • Search that goes inside messages — an optional local full-text index, per account, plus saved searches and snooze
  • Installable — PWA on desktop and mobile, plus a native Android APK
  • Light, dark and custom themes, English and Slovenščina
  • One container — published for linux/amd64 and linux/arm64

What it is not

Hmelj is a mail client, not a mail server. It does not receive mail, does not host mailboxes, and has no MX record. It connects to mail servers you already have, the same way Thunderbird or Outlook would. If you are looking for something to run a mail domain, you want Mailcow, Mailu, or Stalwart — and then Hmelj can read it.

It also has no cloud component of any kind. There is no Hmelj service, no account to create with anyone, and nothing phones home. See Security & Privacy.

↑ Top

Features

Everything in the box

📬 Accounts

  • IMAP + SMTP — any server, with TLS and self-signed-certificate options
  • Gmail — an app password, or "Sign in with Google" (OAuth2 / XOAUTH2)
  • Microsoft 365 & Outlook.com — OAuth sign-in, then Microsoft Graph rather than IMAP
  • Exchange (EWS) — on-premises Exchange over NTLM
  • Unified views — merged Inbox and Sent, coloured per-account chips
  • Per-account colour, name, sender name and its own special folders
  • Account presets — admin-editable, prefill server settings for common providers
  • Share an account with another Hmelj user, by username; revocable at any time
  • Connection test before an account is ever saved
  • Full-text index, per account — turn it on only for the mailboxes you actually search inside

📖 Reading

  • Reading pane right, bottom, in a new window, or list-only
  • Conversation view — replies grouped into one row and opened as a stack
  • Sandboxed rendering — message HTML is sanitised server-side and drawn in an isolated iframe
  • External-image policy — always, trusted domains only, ask per message, or never
  • Collapsed quotes — the reply you were sent, with the thread under it behind a ⋯
  • One-click unsubscribe — RFC 8058 POST, mailto:, or the sender's page
  • Attachments — inline preview for images, PDF, audio and video, with a progress bar
  • Find in message (Ctrl/Cmd+F) with live match count
  • Sender verification — the SPF/DKIM/DMARC result your own server recorded, as a quiet chip or a warning
  • Calendar invitations — Accept / Tentative / Decline, with or without a reply
  • Read receipts — asked for, and answered, on your terms
  • Auto mark-as-read — immediately, after a delay, only via the icon, or never
  • Print, View headers, Save as EML, Open in a new view

✏️ Composing

  • Rich HTML or plain text — font, size, colour and highlight, bold/italic/underline/strikethrough, lists, links, quote, code block, indent, alignment, a horizontal rule
  • Emoji picker with categories and a Recent row, working in plain text too
  • Several signatures per identity, chosen per message while writing
  • Multiple identities per account, each with its own signature and signature policy
  • Reply / Reply all / Forward, quote above, below, or not at all
  • Attachments, Cc/Bcc, priority, read-receipt request
  • Draft autosave on a configurable interval, plus manual save
  • Scheduled sending — queued on the server, retried with backoff, reschedulable
  • Undo send — a window you choose, held on the server, so closing the tab still honours it
  • Templates — reusable snippets, inserted where the caret is
  • Before it goes out — "you said it was attached", and "you replied to one of nine"
  • Spell checking as you type — Slovenian and English, detected automatically
  • Contact autocomplete, learned from the mail you actually send
  • Contact groups — one name in To, Cc or Bcc stands for a whole set of addresses
  • Backspace takes a whole recipient at a boundary, group or person, the way Outlook does

🗂 Organising

  • Full folder tree — create, rename, delete, empty, hide, per-folder unread counts
  • Filters — conditions on subject/from/to/content/size/date; move, copy, redirect, auto-reply, delete, mark read/unread, star
  • Spam and Archive — one gesture each way, with the return trip remembered
  • Select mode — Ctrl+click, Shift+click for a whole run, or the checkbox, then act on everything at once
  • Swipe gestures on mobile, with a configurable direction
  • Search — Gmail-style syntax, cached-first with a one-click "search everywhere"
  • Search inside messages — a local full-text index, opt-in per account, with a size ceiling
  • Saved searches — pinned in the sidebar with their own unread count, re-run live every time you open one
  • Snooze — the message really moves out and comes back when you asked, with an optional calendar reminder
  • Keyboard shortcuts — Gmail's letters and Outlook's Del / Ctrl+Q / Ctrl+U, ? for the list
  • Mailbox analytics — where the quota went, who sends the most, what is safe to delete
  • Contacts — address book, Google-CSV and vCard import, and a direct pull from a Microsoft or Exchange account

📅 Calendar & Contacts

  • Calendars from anywhere — CalDAV, Google, Microsoft 365, Exchange, or one that lives in Hmelj itself
  • Month, week, day and agenda, each calendar in its own colour, with time-zone-correct events
  • Create and edit — repeats, all-day events, attendees, and Add to calendar straight from a message
  • Reminders delivered as push, whether or not Hmelj is open
  • Invitations — Accept / Tentative / Decline, replying or not
  • Address book — learned from your own mail, imported from Google CSV or vCard, or pulled from Microsoft and Exchange
  • Groups — name a set of addresses and write to all of them by typing that name
  • Hmelj as a CalDAV/CardDAV server — subscribe a phone or another app, with per-device app passwords and busy-only sharing

🔔 Notifications

  • Web Push (VAPID) — works with Hmelj fully closed, on desktop and mobile browsers
  • Android app push via Firebase, with Mark as read / Delete right on the notification
  • Per-device — see and remove every registered device
  • Quiet hours, per-account and per-folder, with weekday selection
  • Holidays — skip notifications on public holidays (Slovenian calendar)
  • Mute a folder for an hour, until tomorrow, or until a time you pick
  • Launcher badge with the unread count in the Android app

🛠 Running it

  • One container, linux/amd64 and linux/arm64, non-root, with a healthcheck
  • Everything in one volume — accounts, settings, filters, cache, encryption key
  • Admin panel — users, sign-up control, OAuth clients, account presets, custom fonts
  • Per-user error log — filters that failed, mail that could not be sent, servers unreachable
  • Instant cross-device sync over Server-Sent Events
  • Themes light / dark / system, plus custom colours; resizable columns
  • Export — settings, filters, contacts, groups and calendars as one zip; mail as streamed mbox, one folder at a time
  • English and Slovenščina, switchable per user
Unified All inboxes view
All inboxes, with per-account chips
Conversation view
Conversation view
The composer
Composing
Dark theme
Dark theme
↑ Top

Installation

One container, one volume, one port

Hmelj needs Node.js 24 or newer and nothing else — no database server, no Redis, no external services. The published image is built for linux/amd64 and linux/arm64, so it runs on a normal server and on a Raspberry Pi 4/5, a Synology or an ARM VPS alike.

Docker Compose (recommended)

1. Create a directory and put this in docker-compose.yml:

services:
  hmelj:
    image: ghcr.io/thehijacker/hmelj:latest
    container_name: hmelj
    restart: unless-stopped
    ports:
      - "3000:3000"
    env_file: .env
    volumes:
      - hmelj-data:/data

volumes:
  hmelj-data:

2. Create a .env next to it. Every value has a working default, so the shortest useful file is a single line — but read Environment Variables before you settle on one:

PORT=3000
# Encrypts stored mailbox passwords. Leave it unset and Hmelj generates
# /data/secret.key on first start instead — either way, BACK IT UP.
# HMELJ_SECRET=a-long-random-string

3. Start it:

docker compose up -d

Hmelj is now at http://localhost:3000. Go to First Run.

Bind mount instead of a named volume? It is handled. The container starts as root only long enough to take ownership of /data, then drops to an unprivileged user before any application code runs — so a host directory owned by root, which is the usual case, works with no preparation. Set PUID and PGID if you want the files to belong to a specific account instead of the default 1000:1000:
environment:
  - PUID=1000
  - PGID=1000
volumes:
  - /srv/hmelj/data:/data
Starting the container with an explicit --user skips the whole adjustment — you have said what you want, and it could not chown anyway.
Put it behind TLS before anyone else uses it. Users type real mailbox passwords into this page, and those travel over the connection. Installing Hmelj as a PWA also requires HTTPS. See Reverse proxy & TLS.

Plain Docker

docker run -d \
  --name hmelj \
  --restart unless-stopped \
  -p 3000:3000 \
  --env-file .env \
  -v hmelj-data:/data \
  ghcr.io/thehijacker/hmelj:latest

The image runs as the non-root node user, keeps all state in the /data volume, and carries a healthcheck that polls /healthz every 30 seconds — so docker ps tells you whether it is actually serving, not merely running.

Which tag? ghcr.io/thehijacker/hmelj:latest is the newest tagged release. :dev is rebuilt from every push to main — useful for following development, not for a mailbox you care about. Version tags (:1.0.0, :1.0) pin exactly.

From source

git clone https://github.com/thehijacker/hmelj.git
cd hmelj
npm ci
cp .env.example .env      # read it through, then edit
npm start                 # → http://localhost:3000

There is no build step. public/ is served exactly as it is on disk, so an edit shows up on the next refresh.

To try it without a real mail server, Hmelj ships a mock IMAP + SMTP pair with sample messages:

npm run mock    # IMAP on 127.0.0.1:1143, SMTP on 127.0.0.1:1025
npm start       # in another terminal

Sign up, then in the account wizard choose IMAP + SMTP and enter 127.0.0.1 port 1143 for IMAP and 1025 for SMTP, user testuser, password testpass, TLS off, "allow self-signed" on.

Run the test suite with npm test — 19 suites, no framework, no network.

Reverse proxy & TLS

An nginx location block that works:

location / {
    proxy_pass         http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Real-IP         $remote_addr;
    proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_set_header   Upgrade           $http_upgrade;
    proxy_set_header   Connection        "upgrade";

    # Attachments and uploads
    client_max_body_size 50m;

    # Server-Sent Events (/api/events) must not be buffered, or cross-device
    # updates arrive in delayed bursts instead of instantly. Hmelj already sends
    # X-Accel-Buffering: no, which nginx honours by itself — this is only needed
    # if something in front of nginx strips that header.
    # proxy_buffering off;
    proxy_read_timeout 3600s;
}
X-Forwarded-Proto and X-Forwarded-Host matter. Hmelj derives its own public URL from them when building the OAuth redirect URI, and Microsoft and Google both compare that URI byte for byte against what you registered. If your proxy rewrites the host, set HMELJ_PUBLIC_URL explicitly instead of relying on the guess — it is the single most common cause of an OAuth sign-in that fails at the last step.

systemd (without Docker)

[Unit]
Description=Hmelj webmail
After=network.target

[Service]
Type=simple
User=hmelj
WorkingDirectory=/opt/hmelj
EnvironmentFile=/opt/hmelj/.env
ExecStart=/usr/bin/node --openssl-legacy-provider server/index.js
Restart=on-failure

[Install]
WantedBy=multi-user.target
--openssl-legacy-provider is not optional if you use an Exchange (EWS) account. The NTLM handshake computes a DES-ECB LM hash on every authentication — real, historic NTLM behaviour that OpenSSL 3 no longer exposes by default. Without the flag the process throws on every EWS sign-in. It only changes which legacy algorithms are available; nothing else in Hmelj uses them.
A relative FCM_SERVICE_ACCOUNT_PATH resolves against the working directory. Under systemd that is WorkingDirectory=, which is usually not where you were standing when you tested by hand — and the only symptom is one info line saying Android push is "not configured". Use an absolute path, or leave the setting out entirely and put the file at DATA_DIR/fcm-service-account.json.

Updating

docker compose pull
docker compose up -d

The volume is untouched, so accounts, settings, filters and the cache all survive. The browser picks up the new front end on the next load — Hmelj's service worker is network-first for the app shell, so there is no stale-cache dance. If a page looks half-updated, one hard refresh settles it.

↑ Top

Data Directory

Everything Hmelj knows, in one place

DATA_DIR (/data in the container, ./data from source) holds all persistent state. Back up this directory and you have backed up Hmelj.

/data
├── auth.json                   Hmelj login accounts (scrypt-hashed passwords, admin flag)
├── secret.key                  AES-256-GCM key for stored mailbox credentials (mode 600)
├── presets.json                account presets, editable in Settings → Admin
├── oauth.json                  OAuth client IDs (Google's secret encrypted), mode 600
├── fcm-service-account.json    optional: Firebase key for Android push (you add this)
├── cache.sqlite                message cache, analytics index and the per-user
│                               error log (+ its -wal and -shm sidecars)
└── users/
    └── <user>/
        ├── accounts.json       mail accounts; passwords and OAuth tokens encrypted
        ├── settings.json       that user's settings
        ├── identities.json     identities and signatures
        ├── contacts.json       address book
        ├── filters.json        filter rules
        ├── push-subscriptions.json  every device registered for notifications
        ├── accountOverrides.json    per-viewer tweaks to a shared account
        ├── refile-origins.json  where spam/archived mail came from, for the trip back
        ├── unsubscribed.json    newsletters already unsubscribed from
        ├── holiday-overrides.json / custom-holidays.json  scheduler calendar edits
        └── scheduled/           one file per queued scheduled send
secret.key is the one file you cannot regenerate. It decrypts every stored mailbox password and OAuth refresh token. Lose it and every account has to be signed in to again; replace it with a different value and Hmelj will fail to decrypt what it already has. If you set HMELJ_SECRET instead, that value is the key — treat it the same way.

Putting the cache somewhere else

cache.sqlite is written constantly by the background sync, and those writes are synchronous. If DATA_DIR sits on network-attached storage — NFS, SMB, a NAS mount — a slow write can block Node's event loop and stall completely unrelated work, including interactive requests. Point just the cache at fast local disk:

CACHE_DIR=/var/lib/hmelj-cache

The cache is disposable. Deleting it costs nothing but a re-sync.

↑ Top

First Run

From an empty container to reading your mail

The first user is the admin

There is no default account and no default password. Open Hmelj, click Sign up, and pick a username and password. That first account is automatically an admin: it gets a Settings → Admin tab where it can manage other users, control sign-ups, register OAuth clients, edit account presets and upload fonts.

Hmelj accounts are entirely separate from mail accounts. The password you choose here is a Hmelj password, hashed with scrypt in auth.json; it is never sent to any mail server.

"Stay signed in" gives you a 90-day sliding session in a persistent cookie. Left unchecked you get a one-day session in a cookie the browser drops when it closes. Either way, sessions live in memory — restarting Hmelj signs everyone out. Login attempts are rate-limited per IP.

Letting others sign up

By default anyone who can reach the page can create an account. Once your household or team have theirs, turn it off — either with ALLOW_SIGNUP=false in the environment, or at runtime in Settings → Admin → Sign-up. The very first user can always register, so you cannot lock yourself out of a fresh install.

Attaching a mailbox

A brand-new Hmelj account has no mail in it, because it has no mailbox yet. Open the user menu (your avatar, bottom left) → Mail accountsAdd mail account, and follow Accounts.

The wizard tests the connection before saving, so a typo in a hostname or a wrong password fails there and then rather than becoming a mysteriously empty inbox.

The add-account wizard
The account wizard — choose a type, then fill in only what that type needs
↑ Top

Accounts

Attaching mailboxes, and what each type needs

Account types

TypeTalks toCredentialUse it for
IMAP + SMTP Your IMAP and SMTP servers directly Password (or app password) Anything standard — a home server, GMX, Fastmail, a hosting provider, Gmail with an app password
Gmail (sign in with Google) imap.gmail.com / smtp.gmail.com, with an OAuth token instead of a password Google sign-in Gmail, when you would rather not create an app password
Microsoft (Outlook / Microsoft 365) Microsoft Graph — not IMAP at all Microsoft sign-in outlook.com, hotmail.com, live.com, and Microsoft 365 work accounts
Exchange (EWS) An on-premises Exchange server's Exchange.asmx endpoint Username, password, NTLM domain Exchange 2013 and later, on your own network

Mix them freely. Every type ends up in the same list, the same folder tree and the same unified inbox; the differences are noted where they exist.

The wizard, field by field

Shared by every type:

  • Account name — what you call it: Home, Work, GMX. Shown on the account chip.
  • E-mail address — the address this mailbox is; also the default IMAP username.
  • Sender name — shown next to your address on outgoing mail.
  • Colour — one of the swatches or a custom colour, used for this account's chip everywhere in the UI.

IMAP + SMTP only:

  • Provider — an optional preset that prefills the server fields (see Account presets).
  • Incoming (IMAP) — server and port, username (defaults to the e-mail address), password, Use TLS, Allow self-signed certificate.
  • Outgoing (SMTP)Same server as IMAP and Same credentials as IMAP are on by default and hide the fields they cover; untick either to set them separately.

Exchange (EWS) only: the full https://…/EWS/Exchange.asmx URL, the NTLM domain if your server requires one, username, password, and an Allow self-signed certificate option.

The two sign-in types have no server, port or password fields at all — signing in is the configuration. You pick a provider and press Sign in; the provider's own page opens in a separate window (or your browser, in the Android app). Hmelj never sees your password, only the token the sign-in hands back, encrypted like every other credential.

An admin has to register an OAuth client first. Until then the wizard says so instead of offering a sign-in button that could only fail. See Creating the Google client ID and Creating the app registration.

Finish with Test & save. Hmelj connects, verifies the credentials, and auto-detects the Sent, Drafts, Trash, Junk and Archive folders from the server's own IMAP SPECIAL-USE flags — including Gmail's [Gmail]/… names. Only then is anything saved.

Checking for new mail

Set per account, in the wizard's New mail section. This is the single biggest lever on how quickly mail appears and how much load Hmelj puts on the server.

SettingWhat happens
Live (IMAP)Holds one extra connection open using IMAP IDLE. New mail shows up in about a second, at the cost of that connection.
Live (Exchange)A pull subscription polled every ~15 seconds. Exchange reports exactly what changed since the last watermark, which a plain timer cannot — a timer re-reads a fixed newest-50 window and a busy mailbox can outrun it. True EWS streaming is not possible over this project's NTLM transport.
Live (Microsoft Graph)Asks Microsoft for the Inbox counts every ~30 seconds — one small request per check. Graph's real push would require this Hmelj to be reachable from the public internet, so it is not used.
Every 30 s … 15 minA fixed timer. Pick a long one for mailboxes you do not need to hear about immediately.

Mix freely — watch your main mailbox live and check a low-traffic one every 15 minutes. Live accounts still run a slow background poll as a safety net, so a dropped watcher costs latency and nothing else. Accounts that say nothing default to SYNC_INTERVAL_MS.

A second connection

By default Hmelj holds one IMAP connection per account and every operation takes an exclusive lock on it, so opening a message can end up queued behind an in-flight background sync. Allow a second connection lets background sync use its own.

Off by default, and worth leaving off unless you know your provider allows more than one simultaneous connection per account. Some — Gmail in particular — throttle concurrent sessions harder than sequential ones, so a second connection can make things slower.

Sharing an account

Settings → Accounts → Share on an account you own, then type another Hmelj user's username. They see that mailbox in their own account list, badged 🔗 Shared by <your username>, and can read it and send through it.

What stays with the owner:

  • Special-folder mapping, and creating, renaming, deleting or emptying folders — these touch the real mailbox.
  • Which folders are synced. A folder the owner excluded has nothing cached, so the grantee cannot show it either.
  • The credentials. A grantee never sees them and cannot edit the connection.

What the grantee gets of their own: which of the account's folders appear in their sidebar, and their own identities for sending through it. Revoking access (the ✕ on the grantee's chip) removes both.

↑ Top

Gmail

Two ways in, and the trade-off between them

Google stopped accepting an ordinary account password over IMAP years ago. Hmelj offers the two things it does still accept:

App passwordSign in with Google
Account type in the wizardIMAP + SMTPGmail (sign in with Google)
Set-up work for youTwo minutes in your Google accountNone
Set-up work for the Hmelj adminNoneA Google Cloud OAuth client, once per Hmelj server
Requires 2-step verification on the Google accountYes — app passwords do not exist without itNo
What Hmelj storesA 16-character password, encryptedA refresh token, encrypted
UnderneathIdentical: IMAP to imap.gmail.com, SMTP to smtp.gmail.com

App password

1. Turn on 2-step verification at myaccount.google.com/security if it is not on already. Google will not offer app passwords without it.

2. Go to myaccount.google.com/apppasswords, name it "Hmelj", and copy the 16-character password it generates. Google shows it once.

3. In Hmelj, add an IMAP + SMTP account. Choose the Gmail preset if your admin left it in place, or enter the servers by hand:

ServerPortTLS
IMAPimap.gmail.com993Yes
SMTPsmtp.gmail.com465Yes

Username is your full Gmail address; the password is the app password, with the spaces removed.

Sign in with Google

Choose the Gmail (sign in with Google) account type, press Sign in, and complete Google's own sign-in page in the window that opens. There is nothing else to fill in — Hmelj already knows Gmail's servers.

Google will warn that this app is not verified. That "app" is your own Hmelj server, registered by your own admin in their own Google Cloud project. Choose Advanced → Continue.

The token is used exactly where the app password would have been: as an XOAUTH2 credential on ordinary IMAP and SMTP. Hmelj does not use the Gmail API, so nothing about how your mail is read, sent, filtered or cached changes between the two methods.

Creating the Google client ID (admin, once)

This is done once per Hmelj server by an administrator. It is free, and the client stays yours — Hmelj deliberately has no shared client to fall back on.

1. Create a project. Open the Google Cloud Console and create a project (or reuse one). Everything below happens inside it.

2. Configure the OAuth consent screen. APIs & Services → OAuth consent screen. Choose External unless you are a Google Workspace organisation configuring this for your own domain, fill in an app name and your own e-mail as the support and developer contact.

3. Add the scope. On the consent screen's Scopes step, add https://mail.google.com/ — the only scope Google offers that permits IMAP access at all. It is listed as a restricted scope; that is expected and does not need verification for your own use.

4. Set the publishing status to "In production". This one matters more than it looks — see the warning below.

5. Create the client. APIs & Services → Credentials → Create credentials → OAuth client ID, application type Web application. Under Authorized redirect URIs add exactly:

https://your-hmelj-host/oauth/callback

Settings → Admin → OAuth providers shows the exact string for your instance — copy it from there. It must match byte for byte.

6. Tell Hmelj. Google shows a Client ID (ending in .apps.googleusercontent.com) and a Client secret (starting GOCSPX-). Paste both into Settings → Admin → OAuth providers → Google, and save. The secret is stored encrypted and is never sent back to the browser.

Leave the consent screen in "Testing" and Google expires the refresh token after 7 days. Every account signed in this way then dies weekly with an invalid_grant and a "sign in again" badge. Setting the publishing status to In production removes that expiry and does not require going through Google's verification review — the unverified-app warning simply stays, which is fine for a server you run for yourself.
Why "Web application" and a secret, when Microsoft needs neither? Google's only server-side redirect flow is the Web-application client, and that is a confidential client whose token endpoint requires the secret. Google's alternative, "Desktop app", only accepts http://localhost or a custom-scheme redirect, which a server-side callback on a real hostname cannot use.

Instead of the Admin UI you can set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET in the environment; those win, and the Admin fields then show as read-only.

↑ Top

Microsoft / Outlook

Outlook.com, hotmail.com, live.com and Microsoft 365

Microsoft no longer accepts a password for IMAP or SMTP on these accounts — not even an app password. They sign in with OAuth2, and Hmelj then talks to the mailbox over Microsoft Graph. You complete the real Microsoft sign-in page once; Hmelj stores the resulting refresh token, encrypted with the same key as everything else.

Why Graph, not IMAP with a token

Microsoft does still support IMAP with an OAuth token — but IMAP has to be switched on for the mailbox itself, and it is off by default on personal Outlook.com accounts. The result is a sign-in that visibly succeeds followed by User is authenticated but not connected, a failure with no obvious cause and no fix inside Hmelj. Graph never touches that stack, so there is nothing to enable in your Outlook settings.

Creating the app registration (admin, once)

You need your own Microsoft app registration. It is free, takes a few minutes, and stays yours.

1. Register the app. Microsoft Entra admin centerApp registrationsNew registration.

  • Supported account types: Accounts in any organizational directory and personal Microsoft accounts. This is what makes outlook.com / hotmail.com / live.com addresses work.
  • Leave the Redirect URI blank here. It is added in the next step, where the platform matters.

2. Add the redirect URI — as a public client. AuthenticationAdd a platformMobile and desktop applicationsCustom redirect URIs:

https://your-hmelj-host/oauth/callback
The platform is the part people get wrong, because the same URI under the wrong one looks identical in the portal.
  • Registered as Web, Azure treats the app as a confidential client and the token endpoint demands a client secret — AADSTS7000218.
  • Registered as Single-page application, it demands a browser-side token exchange Hmelj cannot do — AADSTS9002327 — and caps refresh tokens at 24 hours.
Only the Mobile and desktop applications platform gives a server-side PKCE exchange with no secret and the normal 90-day sliding refresh token.

3. Do not create a client secret. There is nothing to add under Certificates & secrets. Hmelj signs in as a public client and proves the exchange with PKCE. An old secret from a previous set-up can be deleted; Hmelj never sends one.

4. Grant the API permissions. API permissionsAdd a permissionMicrosoft GraphDelegated permissions:

PermissionNeeded for
Mail.ReadWriteReading, flagging, moving, deleting, drafts
Mail.SendSending
offline_accessGetting a refresh token at all — without it the account stops working in an hour
Contacts.ReadOnly the "Import from Microsoft" button in Settings → Contacts. Leave it out and everything else still works.

openid and email are also requested, purely so the token response carries an id_token Hmelj can read the signed-in address out of — that is what catches "you typed one address and signed in as another".

Telling Hmelj about it

5. Settings → Admin → OAuth providers → Microsoft. Paste the Application (client) ID. Leave Tenant as common unless you registered a single-tenant app. Save.

6. Settings → Accounts → Add mail account → account type Microsoft (Outlook / Microsoft 365) → type the address → Sign inTest & save. There is no server, port or password to fill in.

The environment-variable equivalents are MS_OAUTH_CLIENT_ID and MS_OAUTH_TENANT.

Reusing a registration you already had? One written for a public-client flow is already the right kind — add the /oauth/callback URI under Mobile and desktop applications, check the Graph permissions above are present, and you are done. One registered for an older IMAP/XOAUTH2 set-up needs its redirect URI moved off the Web platform, and its Office 365 Exchange Online permissions replaced with the Graph ones — IMAP.AccessAsUser.All and SMTP.Send grant nothing to Graph.

Hmelj translates Microsoft's AADSTS… codes into what to change and where, while keeping the original text — it carries the trace IDs Microsoft support asks for.

What behaves differently

  • New mail on Live monitoring is a 30-second poll of the Inbox counts, not true push (see Checking for new mail).
  • "Replied to" markers are not tracked. Graph exposes that only as an extended MAPI property.
  • Everything else — read, send, reply, flag, move, delete, drafts, search, attachments, filters, the unified inbox — behaves exactly as it does for an IMAP account.
The sign-in page cannot be embedded. It opens in a popup on desktop, and in a Chrome Custom Tab in the Android app. Microsoft and Google both refuse to be framed, and Google rejects OAuth started inside an Android WebView outright (403 disallowed_useragent). If a popup blocker eats the window, allow popups for your Hmelj host.
↑ Top

Exchange (EWS)

On-premises Exchange, over NTLM

For an Exchange server on your own network that speaks Exchange Web Services. Tested against Exchange 2013; later versions use the same protocol.

FieldExample
Server URLhttps://mail.example.com/EWS/Exchange.asmx
DomainThe NTLM domain, if your server requires one
UsernameDefaults to the e-mail address
PasswordThe mailbox password
Allow self-signed certificateFor an internal CA

Reading, sending, moving, deleting, flagging, search, contact import, calendars and meeting responses all work, and so does managing folders — create, rename, move, delete and empty. Live monitoring is a 15-second pull subscription: Exchange reports exactly what changed since the last watermark, which is more reliable than a timer on a busy mailbox.

Two folder actions behave the way Outlook behaves, which is not quite how they behave on IMAP. Deleting a folder moves it to Deleted Items rather than destroying it, so it is recoverable — and it does reappear in your folder tree under Trash, which is honest about where it went. Emptying a folder is permanent, and leaves any subfolders alone: they are folders, not contents.
The server process must be started with --openssl-legacy-provider or every EWS sign-in throws. The Docker image and npm start already do; a hand-written systemd unit or process manager must too. See systemd.
True EWS streaming notifications are not possible over this transport: the NTLM library builds its own keep-alive HTTP agent, ignores any agent handed to it, and hard-floors socket inactivity at 8 seconds — which kills a long poll every time. Hence the pull subscription.
↑ Top

Identities & Signatures

Who a message is from, and what it ends with

An identity is a from-address plus the details that travel with it. Every identity is linked to a sending account, so a reply from your GMX identity goes out through GMX's SMTP and lands in GMX's Sent folder. Aliases are just extra identities on the same account.

Settings → Identities. Each one has:

  • Name and e-mail address — what recipients see
  • Organization and Reply-To — optional headers
  • Account — which mailbox actually sends it
  • Default — the one preselected in a new message
  • Signature, with its own policy: new messages only, new messages and replies, always, or never

The signature is placed above the quoted original on a reply or forward — where a person reads it — rather than at the very bottom under the whole thread.

↑ Top

Folders

The tree, the special ones, and what shows in the sidebar

Hmelj shows the account's full IMAP folder tree. Settings → Folders manages it, one account at a time.

Special folders

Five roles are mapped to real folders: Sent, Drafts, Trash, Junk/Spam and Archive. Hmelj auto-detects them from the server when the account is added — using IMAP SPECIAL-USE flags, which is what makes Gmail's [Gmail]/Sent Mail land in the right place. Fix the mapping here if sending or saving a draft ever files something in the wrong folder.

Junk and Archive may be set to (None), and that genuinely means none: Mark as spam and Move to Archive then stop appearing in the message menus for that account.

Managing folders

  • Show in sidebar — per folder, per account. Hiding a folder does not delete anything.
  • Create, rename, delete, empty — these act on the real mailbox.
  • Mark all as read and Mute live on the folder's own right-click menu in the sidebar.
  • Each folder shows its message and unread counts.
On a shared account, a grantee sees only the "show in sidebar" checkboxes — special folders and create/rename/delete/empty stay with the owner, because they change the real mailbox. A folder the owner excluded from sync has nothing cached to show, so its checkbox is locked off rather than offered as a false choice.

What gets synced

The background sync does not walk every folder. It covers the Inbox and its subfolders plus Sent — that is what the unified views and the fast lists are built from. Other folders are browsed live against the server when you open them, and are always fully reachable through Search everywhere.

↑ Top

The Message List

Everything you can do without opening anything

All inboxes vs one account

The account switcher at the top of the sidebar chooses between All inboxes — a merged Inbox and merged Sent across every account, with a coloured chip on each row naming its source — and one account's own full folder tree.

The unified view spans every folder of an account except Sent, Trash, Junk and Drafts, so mail your server filed into a subfolder still shows up there.

Right-click / long-press

Right-click a row on a desktop, long-press it on a phone:

  • Mark as read / unread
  • Mark as spam or Not spam, and Move to Archive or Move out of Archive — whichever the account has folders for
  • Delete

Select mode

Ctrl+click (Cmd on a Mac) a row turns select mode on and ticks it; from then on an ordinary click keeps toggling. Then act on the whole selection at once — delete, move, mark read or unread. Desktop only: a touch screen has no modifier key, and long-press already means something on these rows.

Shift+click takes a whole run at once: click a message, hold Shift, click one further down, and everything between them is ticked, both ends included. It works before select mode is on and after, and it only ever adds — a second Shift+click extends the run rather than re-cutting it, so a mis-aimed click cannot quietly unpick messages you had already chosen.

Escape

Escape dismisses whatever is on top — a dialog, the theme picker, the attachment preview, compose (through its usual save-or-discard prompt), Settings, mailbox analytics — one layer per press, in the order they are stacked on screen. It is the keyboard's version of the phone's back key, which walks the same list.

It stops at the last overlay, deliberately. The back key carries on into the page itself — closing the message you are reading, then returning to All inboxes — and Escape doing that would be a surprise rather than a convenience.

Ticking a conversation row selects every message in it — but never the copies in Sent, so deleting an inbox thread never deletes your own replies.

Swipe gestures

On a phone, drag a row sideways. Past about a third of the width the action fires: read/unread one way, delete the other. Which direction does which is a setting, and the whole gesture can be turned off. A swipe-delete can be taken back from the message that appears afterwards.

Spam and Archive

Neither entry appears unless the account actually has that folder — the answer comes from the server's own folder list, so the menu can never offer something that then fails. Hmelj also stands in the server's real \Junk / \Archive folder if the stored name has gone stale.

On the way out, where each message came from is written down. On the way back, Not spam returns each message to the folder it actually left — one move per remembered folder, and the Inbox for anything unrecorded, which is the common case since most spam was filed by the server and was never anywhere else. Both directions are optimistic and offer an Undo.

Snooze

A message you cannot deal with now, back in front of you when you can. Right-click a row (long-press on a phone) → Snooze…, or press z. Pick Later today, Tomorrow morning, This weekend, Next week, or an exact date and time.

The message really moves, into a Snoozed folder created on the account the first time you use it, and comes back to where it was at the time you chose — marked unread, with a notification. It moves for a reason: hiding it only inside Hmelj would leave it sitting in the Inbox on your phone, in Thunderbird, and in your provider's own webmail.

  • A 🕰️ Snoozed row appears in the sidebar with a count, listing what is waiting and when each one is due. The Snoozed folder itself is not shown there — it would be a second, worse entry for the same thing, saying that a message is gone without saying when it comes back. It is still a real folder on the server, visible from Outlook and from your phone, and it is listed in Settings → Folders marked as Hmelj's.
  • Click one, or use its menu, to bring it back now or change the time.
  • If Hmelj is not running when a message comes due, it comes back as soon as Hmelj is. Nothing is missed, it is just late.
  • Moved a snoozed message somewhere yourself in the meantime? That is treated as your decision — Hmelj drops the reminder rather than dragging the message back out of where you filed it.
Snoozed messages
Picking when a message comes back, and the Snoozed view
A reminder in your calendar as well. If you have a calendar Hmelj can write to, snoozing offers Snooze and remind me: an event at the wake-up time, named after the message and linking back to it, with an alarm set for the moment it starts. Useful when the deadline is the point and the mail is only the record of it — a reply due in two months lands as a calendar entry you will see coming, not just a message reappearing on the day. Snoozed messages carrying one are marked 📅 in the list.

Sorting and filtering

The list header sorts by date, sender or subject. The toolbar filters to unread only or starred only. Note that a filter or a search switches conversation grouping off — a count that meant "matching messages" rather than "messages" would be misleading.

↑ Top

Reading a Message

What the reading pane does, and why

Message bodies are sanitised on the server and then rendered inside a sandboxed iframe with no same-origin access. A newsletter's own CSS therefore cannot reach out and restyle Hmelj, and its scripts never run at all. <style> blocks and inline styles are kept, because that is what makes newsletters look like themselves; @import, expression() and behavior are always stripped.

A message open in the reading pane
Header, body and attachments

Every person in the header — From, To and Cc — is a chip showing both the display name and the address. Right-click or long-press one for Copy address and New message.

Is the sender who they say?

When a message arrives, the receiving mail server checks it against the rules the sender's domain publishes — SPF (was this server allowed to send for that domain), DKIM (is the signature intact) and DMARC (does the domain in the From line stand behind either result) — and writes the outcome into the message. Hmelj reads that verdict back out and shows it.

VerdictYou see
Checks passed🔒 Verified sender, a quiet chip beside the sender's name. Hover it for the individual SPF, DKIM and DMARC results.
DMARC failedA red banner above the message: it claims a domain it is not authorised to send for. Treat its links and attachments as untrusted.
Name does not match addressA red banner. The message wears the name of somebody in your contacts over an address that is not theirs.
Nothing was checked, or only partlyNothing at all.

Why an unchecked message is not marked as suspect. A mailing list breaks SPF by design — it sends on your behalf from its own servers — and plenty of small mail hosts run no checks whatsoever. Flagging either would put a warning on ordinary mail every day, which is precisely how people learn to click past warnings. Only the two cases worth interrupting you over say anything.

The second banner is the one that catches people. A message from ceo@totally-real-invoices.com signed "Ana Novak" passes every check there is, because the domain it really came from did authorise it. Nothing in SPF, DKIM or DMARC can see that. Matching the display name against your own address book can.

Only the topmost verification header is read — the one your own server wrote. Anything below it arrived with the message and was written by whoever's honesty is the question, and a convincing forged "everything passed" is trivial to include. If your mail passes through several of your own servers before reaching the mailbox, the account can be told which one to trust by name (authservId, settable through the accounts API; there is no field for it in Settings yet).

Turn the whole thing off with Show whether the sender is verified in Settings → Reading.

Sender verification
The chip on a message that passed, and the banner on one that did not

Dark mode and unreadable mail

A lot of HTML mail hard-codes its own colours — black text, a white panel — on the assumption that it will be read on white. In the dark theme that arrives as black text on a near-black background.

Hmelj repairs it, on two passes. Backgrounds an element sets for itself are dropped in favour of the theme's, except where doing so would strand that element's own text (white on grey is deliberate, and stripping the grey leaves white on white). Then every piece of text is measured against what it actually ends up sitting on, and only the ones that genuinely fail are repainted in the theme's own colour — the link colour, inside a link.

The test is contrast, not the theme, so the same pass fixes white-on-white in the light theme. Mail that was already readable is left exactly as its sender wrote it. Where it does step in, the sender's colour choice is replaced rather than nudged: keeping the hue would mean inventing a colour they did not pick either, and the result is at least the colour the rest of the message is in.

External images

Remote images are the standard way a sender learns you opened their mail. Settings → Reading offers four policies:

PolicyBehaviour
Always loadNo protection; everything loads.
Only from trusted domains (default)A list you keep, one domain per line, in Settings → Reading.
Ask per messageA banner on each message with images to load.
Never loadImages never load, no banner.

The banner also offers trust this sender's domain, which adds it to the list in one click. CSS url(...) loads follow exactly the same policy as <img> tags, so a tracking pixel cannot hide in a background image.

Collapsed quotes

A reply shows what was written this time; the quoted conversation underneath it sits behind a button. Detection runs on the server, on the sanitised HTML, and plain-text messages split on the usual quote markers.

Unsubscribe

Newsletters that publish a List-Unsubscribe header get an Unsubscribe button under the header. Three ways out, preferred in this order:

  1. An RFC 8058 one-click POST, sent by the server — a browser cannot POST cross-origin.
  2. A mailto: unsubscribe message.
  3. Opening the sender's page, done by the browser inside your click so no pop-up blocker eats it.

Most senders publish no header at all, so Hmelj falls back to finding the unsubscribe link in the message footer. A body link is a guess, so it is only ever opened — never posted to, never mailed — the banner says where it came from, and links inside a collapsed quote are ignored.

Nothing is ever sent without pressing the button, and the confirmation dialog always names where the request would go. Worth remembering that unsubscribing tells the sender your address is read — not always what you want on mail you never asked for.

On by default, with a compact mode (also default) that folds the banner to just the 📭 icon and the button; clicking the icon unfolds the explanation for that message. Both in Settings → Reading.

Attachments

Attachments appear as chips under the message. Clicking one opens a preview overlay for images, PDFs, audio and video; anything else downloads.

  • The download shows a progress bar with the real byte count, and can be cancelled.
  • Video streams rather than buffering, so a large file starts playing immediately.
  • Once fetched, an attachment is remembered by the browser and by the server, so a second look is instant.
  • In the Android app, a non-previewable attachment is handed to whichever app can open it.
  • From two attachments up, a Download all chip appears beside them and hands over the lot as a single .zip, named after the message.

The archive is built on the server, because that is where the parts are — each one is its own fetch from the mail server, through the same cache a single download uses, so bundling a message whose attachments you have already opened costs nothing extra. Embedded images (a newsletter's logo and spacers) are left out: they are part of the body, and the chips do not list them either. Two attachments that happen to share a filename both come out, numbered the way a file manager would — a .zip tolerates duplicate names and most unpackers silently keep only the last.

Extracting one attachment costs far more than its size suggests: every backend answers by pulling the whole message down and parsing all of it to cut out one part. That is what ATTACHMENT_CACHE_MB exists for — see Environment Variables.
The attachment preview overlay
Attachment preview, with a determinate progress bar

Find in message

Ctrl+F (Cmd+F on a Mac) over an open message, or ⋯ → Search in message. Live match count, ↑/↓ or Enter / Shift+Enter to step through, and each hit is scrolled clear of the floating bar. The search runs inside the sandboxed frame and highlights without modifying the message.

Calendar invitations

A message carrying a meeting invitation shows the event details and three buttons — Accept, Tentative, Decline. Each opens a small menu:

  • Send the response now
  • Edit the response first — add a message to the organiser
  • Do not send a response — answer for your own records only

Read receipts

When a sender asks for a read receipt, Hmelj shows a banner and lets you decide. Nothing is confirmed automatically. You can also request one on your own outgoing mail — per message in the composer, or by default in Settings → Compose.

The ⋯ menu

Every open message has one, carrying:

  • Reply, Reply all, Forward
  • Mark as read / unread
  • Move, and the Spam / Archive entries the account supports
  • Search in message
  • View headers — the full header block, copyable, and Save as EML next to it
  • Print
  • Open in new view (desktop only)
  • Delete
↑ Top

Conversation View

A message and its replies as one row

Off by default — turn it on in Settings → General. With it on, a message and its replies collapse into a single list row carrying a count chip. Opening that row stacks the whole conversation in the reading pane, oldest at the top, newest at the bottom and scrolled to.

Conversation view
The newest message open, the earlier ones collapsed above it
  • Only the newest message is expanded; the rest are one-line headers you click to open. Expand every message in a conversation (Settings → General) changes that — at the cost of one fetch per message, so a long thread takes longer to open.
  • The scope is the folder you are looking at, plus Sent, so your own replies appear inline between the incoming ones. Opened from All inboxes, the scope is the same set of folders that view spans, plus Sent.
  • Actions on a conversation row never touch the copies in Sent.
  • Each message is marked read as it is expanded — opening a 20-message thread does not silently mark all of it read.

How messages are grouped

By the root of the References chain, computed once for each message on its way into the cache. Every message in a chain therefore derives the same key independently of the order they arrive in. Microsoft Graph and Exchange use their own ConversationId instead, which is better than anything reconstructed from headers.

Subject-based merging is deliberately not done. It is what makes threading glue unrelated mail together; header threading covers the overwhelming majority.

Conversation grouping needs the local cache, so it is off whenever the cache is (CACHE_ENABLED=false), whenever a folder has not synced yet, and whenever a search or an unread/starred filter is active. In those cases the list falls back to one row per message.
↑ Top

Composing

Writing, quoting, scheduling, sending

The composer is a floating window, so you can keep reading while you write. Open it with the Compose button, or from Reply / Reply all / Forward on any message.

The composer
The composer, with the formatting toolbar

The editor

  • Rich HTML or plain text, per message; the default is a setting.
  • Font, size, text colour and highlight, B / I / U / S, bulleted and numbered lists, and links.
  • From chooses the identity — and with it, which account actually sends.
  • Cc and Bcc, revealed on demand.
  • Priority — one button in the footer, whose arrow and colour say which of the three is set (↑ red, ↓ green, and an unremarkable ≡ for normal) — and Request a read receipt, per message.
  • Attachments — pick files or drag them in. Forwarding carries the original's attachments.
  • An emoji picker, with categories and a Recent row that fills up with the ones you actually use.
  • The buttons light up for the formatting the cursor is inside, so the bar says what you are standing in rather than only what you can turn on.
  • holds everything used less often: quote, code block, indent and outdent, alignment, a horizontal line, clear formatting, templates, and which signature this message uses.
  • Recipient fields autocomplete from your address book, from your contact groups, and from your own identity and account addresses (badged you, and never stored as contacts).
The composer's formatting toolbar with a picker open
Font, size, colour and emoji open as pickers; ⋯ holds the rest
What comes out is the old, boring markup on purpose<font size>, <font color>, <b> — rather than the CSS a web editor would emit. It is not fashionable and it is what Outlook renders without argument, which is the only test that matters for something somebody else opens. A quote and a code block carry their styling inline for the same reason: the person reading has none of Hmelj's stylesheets.

On a phone the toolbar scrolls sideways rather than wrapping onto three rows — the message is what matters on a small screen, and a swipe to reach the last two buttons costs less than two thirds of the writing area. The spell-check language and the Plain switch sit outside the scrolling part, because they say what the composer currently is and should not be able to scroll out of sight.

In plain text mode the formatting controls grey out, as there is nothing for them to do — except emoji, which stays available. An emoji is a character, not formatting.

On a desktop the composer remembers whether you keep it enlarged and opens that way next time. On a phone it is always full-page, which is not remembered and not negotiable — a 560px floating panel is unusable there.

Signatures

An identity can have several — a full one for new mail, a two-line one for replies, one for a different language. They are edited in Settings → Identities, on the identity they belong to: name each one, mark one Default, and add or remove them freely.

The default is what a message starts with. To use a different one, open ⋯ → Signature in the composer and pick it — the one already in the message is replaced, not added to, and None takes it out altogether. That choice applies to the message you are writing and is forgotten afterwards; it does not change the identity.

Picking a signature by hand works even when the identity is set not to add one automatically. Signature used — on new messages, on new and replies, every time, never — answers "should Hmelj put one in by itself". Asking for one is not Hmelj putting one in by itself.

An identity that has always had a single signature keeps it, as its one entry, named Signature. Nothing needs migrating by hand.

Several signatures on one identity
Settings → Identities: name them, and mark one Default

The recipient fields

Start typing in To, Cc or Bcc and the suggestions appear: your own addresses first, then any matching group, then contacts. / move, Enter or Tab takes the highlighted one, Esc closes the list without leaving the field.

A group goes in as a single token — 👥 Družina — and is turned into real addresses when the message is sent.

Backspace takes a whole recipient, group or person, when the cursor sits just after one: the first press highlights it, the second removes it, exactly as it works with Outlook's chips. Typing instead simply replaces it. In the middle of an address Backspace still deletes one character, so correcting a typo works as it always did.

Delete is different on purpose: with the suggestion list open it means remove this contact from my address book, and it asks twice — one press arms the highlighted row, the second removes it. A right-click or long-press on a row offers the same thing as a menu. Neither works on a group or on a contact synced from another server; those are removed in Settings → Contacts, where the row says where it lives.

A contact group in the composer
A group offered in the recipient list, and the token it leaves in To

Where the quoted original goes on a reply — below your reply, above it, or not at all — is a setting. The signature goes above the quote either way.

Attachments and images

Three ways to put a file in a message, all of which do the same thing:

  • the 📎 button
  • drag files onto the composer — anywhere on it
  • pasteCtrl+V a screenshot straight from the clipboard

An image pasted or dropped into the message body appears where the cursor is, as part of the text. Dropped onto the header or the attachment strip instead, it is attached as a file. In plain-text mode there is no such thing as an inline image, so a pasted one is always attached.

An inline image is sent as a real embedded part, not as a data: URL. That distinction is invisible while you write and total for the person reading: Gmail, Outlook and most webmail strip data: images, so the message would look right on your screen and arrive with a blank box in it.

Pasting ordinary text is unaffected, including from a spreadsheet or a web page — those put both an image and the real content on the clipboard, and Hmelj takes the content.

Drafts

Drafts autosave to the account's Drafts folder on an interval you choose (0 disables it), and can be saved by hand at any time. Open one from Drafts to carry on writing — with its attachments and inline images intact, fetched back from the server as the draft opens.

Scheduled sending

Instead of sending now, pick a time. The message is queued on the server, so it goes out whether or not your browser is open.

  • Queued messages appear in a Scheduled view with their send time, account badge, and a click-to-read preview.
  • Right-click or long-press one to Reschedule or Cancel and edit.
  • A send that fails is retried with a growing backoff, and the reason is shown on the queued message and recorded in Settings → Log.
Rescheduling never reopens the composer. Pushing a body back through the editor to change one timestamp risks the message coming back subtly different from the one you approved.

Undo send

Send holds the message back for a few seconds first, and the toast that appears offers Undo. Take it and the message comes straight back into the composer, exactly as you wrote it. Ignore it and the message goes out when the time is up.

Undo send window in Settings → Compose sets how long: ten seconds by default, up to two minutes, 0 to send immediately.

The wait is on the server, not in your browser tab. Press Send and close Hmelj, and the message still goes out on time — the delay is a very short scheduled send, running through the same queue. That also means the last moment of the window belongs to the server: once it has picked the message up, Undo says so plainly rather than pretending it worked. Queued undo sends do not clutter the Scheduled view.

Before it goes out

Two checks, both optional, both in Settings → Compose.

  • Warn about a missing attachment. If what you wrote mentions an attachment and nothing is attached, Hmelj asks before sending. It reads English and Slovenian, including forms typed without šumniki (prilozeno counts), and matches the stems rather than exact words, so priloga, prilogi and prilagam all trigger it.
  • Offer Reply to all. Press Reply on a message that had other people on it and Hmelj offers to include them. A message addressed only to you never asks.

Only your own text is read. The quoted original and your signature are skipped, so replying to somebody who wrote "v prilogi" does not ask you where your attachment is.

When the language of a short message is genuinely unclear — and "Pozdravljeni, v prilogi." is four words, which is not enough to be sure — every language's word list is checked rather than a guessed one. A false alarm costs one dismissed dialog; the other kind of mistake is the invoice not going out.

Templates

Text you send over and over — a standard reply, a set of directions to the office, a disclaimer. Write them in Settings → Templates, then insert one while composing from the menu.

They are written with the composer's own toolbar — the same font, size, colour, lists, links, quote and code block — because a template is going into a message, so the options for writing one are the options for writing the other. The same editor is used for signatures, with two extra buttons those need: insert an image, and edit the HTML source.

A template lands where your text is — at the caret, above the signature and above the quoted message, never underneath your sign-off. Templates are yours alone: they are not shared with anyone you share a mailbox with.

Spell checking

On by default. Slovenian and English, detected automatically from what you are writing. Spelling only — no grammar. Turning it off hands the job back to your browser's own spellchecker.

↑ Top

Keyboard Shortcuts

Gmail's letters and Outlook's combinations, both

Press ? anywhere in the mail view for the whole list without leaving the page. Shortcuts do nothing while you are typing in a field, and never fight a browser or system shortcut — anything with Alt or Cmd held is left alone.

KeyDoes
j / Next message
k / Previous message
Enter / oOpen
uBack to the list
cCompose
/Jump to the search box
Del / Backspace / #Delete
Ctrl+QMark as read
Ctrl+UMark as unread
eArchive
!Mark as spam
sStar
zSnooze
r / a / fReply · Reply to all · Forward
?This list

The cursor is not the open message. j and k move an outline around a row without opening it, and that outlined row is what the next Del or e acts on. Acting on a row moves the cursor to the next one, so a run of mail can be cleared without looking back at the screen. If you have not moved the cursor at all, the shortcuts act on whatever is open.

Ctrl+Q and Ctrl+U name a direction, they do not toggle — the same as Outlook. Pressing Ctrl+U twice leaves a message unread rather than putting it back. Ctrl+F deliberately stays "find in message" rather than Outlook's Forward, because every other page in your browser uses it that way; forward is f.

They work with the caret inside a message body too, even though that body is a sandboxed frame with a document of its own — keystrokes are handed back out to the app.

Nothing here fires while you are typing in a field, so Backspace in the composer's To, Cc and Bcc means something else entirely — it takes a whole recipient rather than deleting a message.

The calendar has its own small set, listed under The views: to page, t for today, and m w d a to switch view.

The keyboard shortcut list
The ? overlay
↑ Top

Filters

Rules that run on the server

Settings → Filters. Each filter belongs to one account, has a name, can be enabled or disabled, and combines conditions with actions.

The filter editor
One filter — its account, its match mode, its rules and its actions

Matching

Three modes: All rules must match, At least one rule matches, or All messages (no conditions at all).

FieldOperators
Subject, From, To, Contentcontains · does not contain · is exactly
Size (KB)greater than · less than
Dategreater than · less than

Actions

  • Move to folder / Copy to folder — a dropdown of that account's real folders
  • Redirect to address — forwards the message on
  • Reply with message — an auto-reply; each sender is answered once
  • Mark as read / Mark as unread
  • Star
  • Delete

A filter can carry several actions, and switching a filter to a different account swaps its folder dropdowns to that account's tree — a folder target the new account does not have is dropped rather than silently retargeted.

When they run

  • Automatically, as the background sync finds new mail. This is the main path.
  • On Inbox open, if Run filters when opening Inbox is on (Settings → General).
  • On demandRun filters in the user menu.

A filter that fails — an unreachable server, a folder that no longer exists — is recorded in Settings → Log with the reason, rather than failing silently.

Mail that arrived while Hmelj was off

It gets filtered anyway. Hmelj remembers, per folder, how far its filters have got, and on the first check after a restart it works through everything that arrived since — however long the gap was, up to 30 days, saying so in Settings → Log if it ever has to stop at that ceiling.

Nothing is filed twice. Each message is recorded against each filter that acted on it, so an overlapping catch-up run recognises what it has already done — a filter that forwards a message does not forward it again, and an auto-reply is not sent a second time.

Why this is not Sieve. Filters could be pushed to the mail server and run there, which would make downtime irrelevant. Hmelj deliberately does not: your rules would then live on someone else's server in a language it may or may not support fully, and every provider supports a different subset. Keeping them here means they behave identically on every account type — an IMAP server, Gmail, Microsoft 365 and Exchange alike — and moving to a different provider does not lose them.
↑ Top

Contacts

An address book that mostly maintains itself

Settings → Contacts, or Contacts in the user menu. Contacts feed the composer's recipient autocomplete.

Learned automatically

Two opt-out behaviours, deliberately asymmetric:

  • People you send to are added. Only the ones you typed — a reply's prefilled recipients ride along and are subtracted, so replying to a mailing list does not swallow the whole list into your address book.
  • Incoming mail only ever fills in a missing name. It never adds anyone. Receiving mail is not consent to be in someone's address book.

Both can be turned off in Settings → Contacts. A contact can also be deleted straight from the composer's autocomplete list.

Groups

A group is a name for a set of addresses — Družina, the board, the team. Type that name into To, Cc or Bcc, pick it from the suggestions, and the field holds one short token instead of eight addresses:

To:  👥 Družina, 

Hmelj puts the people in when the message is actually sent. The addresses appear in the message that goes out and in the copy saved to Sent, exactly as if you had typed them — everyone still sees who else got it, so a group is a shortcut for you, not a way to hide the recipient list. Put the group in Bcc if that is what you want.

Settings → Contacts, the Groups card
Groups live on the Contacts tab, above the address book they are made from

Making one

Settings → Contacts, in the Groups card at the top:

  • + New group, then give it a name. The name is what you will type, so it has to be unique — Hmelj adds a number if you reuse one rather than leaving two groups answering to the same word.
  • Click the N people button to open a group and search the address book for people to put in it. You can also type an address that is not a contact at all.
  • Or go the other way: tick contacts in the list below and use Add to group.
  • starts a message to a group. removes the group — never the contacts in it.

Groups are saved with the rest of the tab, by Save. A group you have just made cannot be written to until then: the token is resolved by the server, and it has not been told about the group yet.

Things worth knowing

  • A group holds addresses, not contacts. Renaming or re-syncing a contact does not disturb any group they are in, and a group can mix people from your own address book with people from a synced one.
  • Sending to a group that no longer exists is refused, by name, rather than quietly going to fewer people than you meant. So is sending to an empty one.
  • A draft is never refused. Drafts save on their own whenever a composer closes, so an unresolvable name is simply left in the field for you to fix.
  • A scheduled or held-back message keeps the membership the group had when you pressed Send. Editing the group afterwards does not change who that message goes to.
  • Groups are yours, like filters and saved searches — two people sharing a mailbox do not share them — and they are included in the settings export.

Importing

  • Google CSV / vCard — a .csv, .vcf or .txt export
  • Import from Microsoft — pulls the contacts of a Microsoft account you have attached (needs the Contacts.Read permission)
  • Import from Exchange — the same, for an EWS account

Hmelj also suggests people you correspond with who are not contacts yet, with how many messages you have exchanged, so you can add them in a batch.

↑ Top

Calendar

Your calendars, in one place — and shared back out again

Hmelj shows calendars from CalDAV servers, Google, Microsoft 365 and Exchange, all together, in month, week, day and agenda views. It can create and change events on the ones that allow it, remind you before an event starts, and publish calendars of its own for a phone or another app to subscribe to.

The month view
Month view, with each calendar in its own colour

Adding a calendar

Settings → Calendars → + , then pick where it comes from.

TypeWhat you needCan Hmelj change events?
Hmelj calendarNothing. It lives in Hmelj itself.Yes
CalDAVThe server address, a username and a passwordYes
Google CalendarA Gmail account already signed in with GoogleYes
Microsoft 365A Microsoft account already addedYes
ExchangeAn Exchange account already addedYes

If your provider uses two-factor authentication — iCloud, Fastmail, a Nextcloud with 2FA switched on — the account password will always be refused. Those need an app-specific password, generated in that provider's own settings.

Making a new calendar

Settings → Calendars → New calendar, on the source you want it in. It is created on that provider's own server, so it turns up in their apps too — a calendar made in a Gmail source is a real Google calendar, on your phone as well.

Everywhere except Exchange. Exchange can do it, but Hmelj has no folder-management code for EWS yet, so the button is not offered there.

If you rename a calendar on the server

Syncing a calendar reads its events, not its name — so a calendar renamed in Google Calendar, Outlook or any other client keeps its old name in Hmelj until the calendar list itself is re-read. That happens on Sync now (Settings → Calendars) straight away, and by itself about once an hour otherwise. The same goes for a calendar's colour and for a calendar that has become read-only. It works the same way for every kind of calendar — CalDAV, Google, Microsoft 365 and Exchange.

A calendar you have given a colour of your own keeps it — the server's colour does not overwrite your choice. Calendars that are new on the server still need the source's Edit → discover step, deliberately: a work server routinely shares a dozen nobody asked for.

Colours

Every calendar has one, and every event takes its calendar's — until you give the event a colour of its own, which then wins. That is what lets one Bins calendar carry paper in blue, plastic in yellow and everything else in brown, instead of needing three calendars to say the same thing.

  • A calendar's colour — the little square beside it in Settings → Calendars. Your choice sticks: it is not overwritten the next time the calendar list is re-read from the server. Double-click the square to hand it back to the server's own colour.
  • An event's colour — the swatch strip in the event form. Set it once on a repeating event and every occurrence follows. The first swatch (✕) means "same as the calendar".

An event's colour is Hmelj's own, and Hmelj keeps it. It is not written into the event on the calendar server, and that is deliberate rather than a limitation: it was, as the standard iCalendar COLOR property, and Google's CalDAV silently discarded it — an event saved with a colour came back minutes later as Google's own copy with the colour gone. Microsoft and Exchange never stored iCalendar to begin with. So the colour is stored beside your other settings, which also means it works on every kind of calendar. The trade is that it stays here: it does not travel to Google Calendar or Outlook, and it will not follow the event to another device unless that device is also Hmelj.

A repeat Microsoft cannot express is refused, not approximated. iCalendar states a recurrence as one rule with independent parts; Microsoft states it as one of six named patterns. Most rules map exactly, the last day of the month included — that one goes out as the relative pattern Outlook itself uses for it. A few do not: two dates in the same month, a day counted from the end other than the last, weekdays at different positions ("the first Monday and the third Friday"). Those have no Microsoft pattern, and Hmelj declines to save them there rather than storing something close. Saving an approximation would produce an event that is wrong on almost every occurrence and looks like it saved fine. Keep those on a CalDAV or Hmelj calendar.

Microsoft 365 and Exchange calendars reach only so far into the future. Those two work out repeating events themselves rather than handing Hmelj the rules, so Hmelj asks them for a window — by default 120 days back and 550 ahead. Scroll past that and they look empty. CalDAV and Google calendars store the rules and are known for all time. The window is adjustable with CALENDAR_WINDOW_PAST_DAYS and CALENDAR_WINDOW_FUTURE_DAYS.

Turning calendar sync on for a Google or Microsoft account asks you to sign in once more. That is not a bug: the sign-in you already have covers mail, and reading a calendar needs permission you have not granted yet. Only the account you switch it on for is affected.

Google Calendar needs the CalDAV API switched on in your own Google Cloud project — the same project your GOOGLE_CLIENT_ID comes from. It is off by default, and without it Google answers every request with accessNotConfigured no matter how correct the sign-in is.

Open console.cloud.google.com/apis/library/caldav.googleapis.com, pick the right project, press Enable, give it a minute, then add the calendar again. This is a one-off per Hmelj installation, not per account.

The week view
Week view — the time grid

The views

Month, Week, Day and Agenda, with   Today  to move. On a keyboard, and page, t jumps to today, and m w d a switch views. On a phone, swipe left and right.

A phone gets two deliberate differences. Agenda is what opens by default, and the month grid shows a coloured dot per event rather than a title — seven columns at 380 pixels leaves about 45 each, which is not enough to read. Tap a day to open it. Week shows three days at a time rather than seven.

Each calendar in the sidebar can be switched off to hide it without unsubscribing. Settings → Calendars is where you choose which ones are synced at all.

Time zones. An event that carries one is shown at the moment that zone names, whoever is looking. An all-day event is on the same date everywhere. Anything written with no zone at all follows yours, which you can set in Settings → Calendars → Time zone (empty means "follow this device").

Creating and editing

in the calendar toolbar, or Add to calendar in a message's menu, which fills in the subject and offers everyone the message was addressed to as attendees.

Anyone you list as an attendee is emailed an invitation when you save — unless the calendar is on Google, Microsoft or Exchange, which send their own. Hmelj deliberately does not send a second one; two invitations from two addresses is how replies end up going to the wrong place.

Repeats

The Repeats dropdown has the usual ones — daily, weekly, fortnightly, monthly, yearly — plus two worth knowing about:

  • The last day of the month lands on the 28th, 30th or 31st as that month requires. Not the same as "every month on the 30th", which silently skips February.
  • Custom… reveals Every [n] [days / weeks / months / years], for any interval at all. Two events four days apart, each repeating every four days, is how you get one thing on Monday-Friday-Tuesday and another on Wednesday-Sunday-Thursday.

An event whose rule says more than these controls can — a rule written by another client, with several weekdays or an end date — is left alone: the dropdown offers Keep the existing repeat rule rather than quietly simplifying it on the next save.

Which repeats each kind of calendar can hold

Hmelj, CalDAV and Google store the repeat rule exactly as written and work the dates out locally, so they hold anything the iCalendar standard allows — including rules Hmelj's own dropdown cannot express, which is why "Keep the existing repeat rule" exists.

Microsoft 365 and Exchange do not store a rule. They store one of six named patterns, and Hmelj translates. Most things map exactly; a few have no pattern at all and are refused rather than approximated — saving a near-miss would produce an event that is wrong on almost every occurrence and looks like it saved fine.

RepeatHmelj · CalDAV · GoogleMicrosoft 365 · Exchange
Every N daysyesyes
Every N weeks, on chosen weekdaysyesyes
Every N months on a dateyesyes
Every N months on the last dayyesyes
The 1st–4th or last <weekday> of the monthyesyes
Every N years, on a date or an Nth weekdayyesyes
Ends never / after N times / on a dateyesyes
Two or more dates in the same month (the 1st and the 15th)yesno
A day counted from the end other than the last (second-to-last)yesno
Weekdays at different positions (first Monday and third Friday)yesno
The 5th <weekday> of a monthyesno — it counts four and "last"
Stated by week or day of the yearyesno
More often than daily (hourly)yesno

A refusal names what it could not express and leaves the event unsaved, so nothing is lost. If you need one of those, keep the event on a Hmelj, CalDAV or Google calendar.

One more difference worth knowing: Microsoft and Exchange also only know their calendars over a rolling window of dates (see the environment settings), while the other three know a repeating event for all time — so scrolling to 2031 works on a Google calendar and may show nothing on an Exchange one.

Changing a repeating event asks which occurrences you mean.

  • This event — only the one you opened. The rest of the series is untouched.
  • This and following — the old series is given an end date and a new one carries the change onward. You will see the old one visibly stop rather than disappear.
  • All events — the whole series.

Deleting works the same way. Deleting one occurrence leaves a hole in the series rather than removing anything else.

If somebody else changes an event while you have it open, saving gives you a conflict rather than quietly overwriting them. Reopen the event to see their version.

The event form
Creating an event

Reminders

Set per calendar in Settings → Calendars, and per event in the event form. An event that carries its own reminder always wins over the calendar's default — if you set 45 minutes on one meeting, that is what you get.

Reminders arrive through the same notification channel as new mail, so they work with Hmelj closed as long as notifications are set up (Notifications). They do not follow your mail quiet hours by default: a 07:00 meeting reminder is usually wanted even when mail is silent until 08:00. There is a per-calendar switch if you disagree.

A reminder that came due while the server was off is not delivered late. Starting Hmelj in the morning does not replay the night.

Sharing from Hmelj

Hmelj can act as a CalDAV and CardDAV server, so a phone, Thunderbird or Apple Calendar can subscribe to a calendar or address book it holds. Two steps.

  1. Publish something. Settings → Calendars → Share from Hmelj, then + Calendar or + Contacts. Tick one source to publish it as itself, or several to merge them into one shared collection.
  2. Create an app password. Settings → Login → App passwords → New app password. Give it a name you will recognise and tick only what that device needs. The password is shown once — copy it then, because it is not stored in a form that can be shown again.
  3. Add the account on the device, using the server address shown under Settings → Calendars, your Hmelj username, and the app password. Most clients find the rest themselves.
Publishing a calendar
Settings › Calendars › Share from Hmelj
App passwords
Settings › Login › App passwords

Your Hmelj password is never accepted there. A calendar client stores what you give it in plain form and sends it on every request; if that were your account password, one calendar subscription would be handing over your mail, your settings and every mailbox credential the account can reach. An app password can be revoked from Settings without changing anything else, and cannot be used to sign in to Hmelj itself.

"Busy only" is the useful one for sharing with somebody else. A source set to busy contributes the time and nothing else — the title becomes "Busy", and the place, the notes and the attendees are gone. A household calendar can then show that Thursday afternoon is taken without showing what for.

A merged collection is always read-only. There is no honest answer to which of its calendars a change would belong to, so it refuses rather than guessing. A single Hmelj calendar published on its own is read-write: events added on your phone appear in Hmelj straight away.

Anything published from another server — a mirror of your work calendar, say — is read-only too. Change it where it lives.

Clients poll for changes rather than being pushed to, and Hmelj does not implement sync-collection: serving it wrongly would let a client keep showing a cancelled meeting forever, so it is left out and clients fall back to the (correct, just chattier) approach of comparing a collection tag. Expect a subscriber to notice a change within its own polling interval, typically a few minutes.

↑ Top

Mailbox Analytics

Where the quota went, and what is safe to delete

Mailbox analytics in the user menu. It answers questions the ordinary message list cannot: how many messages there really are, who sends the most of them, and which ones are eating the space.

Mailbox analytics
The Overview tab, after a scan

It needs its own index, so it starts with an explicit Scan you press. The ordinary message cache deliberately holds only each folder's newest few hundred messages; a 35,000-message inbox has a couple of hundred rows in it. The analytics scan builds a second, complete-but-thin index — sender, subject, date, size, two flags, and no bodies at all. It runs in the background, shows progress, and can be stopped.

TabShows
OverviewTotals, size, and how the mailbox is distributed
SendersWho sends the most, by count and by the space they cost
LargestThe biggest individual messages
Search & clean upFind matching mail across the whole index and act on it in bulk
Gmail is counted honestly. On Gmail the same physical message appears under the Inbox and under every label it carries, so summing per-folder counts double-counts — and deleting from a label only removes that label, leaving the message in All Mail still consuming quota. So on Gmail the scan indexes All Mail (plus Trash and Spam, which hold quota of their own and are not in All Mail) and treats that as the truth. Everywhere else, folders are a non-overlapping tree and the scan simply walks all of them. Messages are de-duplicated by the server's own unique id on top of that.
↑ Top

Notifications

New mail on your desktop and phone, on your schedule

Turn them on per device in Settings → General → Notifications. Where the browser supports it, they arrive even with Hmelj fully closed, with the sender and subject. On Android and desktop the notification itself carries Mark as read and Delete; tapping it opens straight to the message.

Send test notification pushes to every device you have enabled, not just the one you are on — so you can trigger it from a laptop while watching your phone, without waiting for real mail.

Web Push (VAPID)

Real browsers and installed PWAs use Web Push. The server needs a VAPID key pair; until it has one, notifications stay off. Generate a pair once:

npm run vapid-keys

…then put both halves in the environment:

VAPID_PUBLIC_KEY=…
VAPID_PRIVATE_KEY=…
VAPID_SUBJECT=mailto:you@example.com

VAPID_SUBJECT must be a mailto: or https: address identifying you; some push services use it to reach you if your server misbehaves, and reject requests without one.

Changing the keys later invalidates every existing subscription — everyone has to switch notifications off and on again on every device. Generate them once and keep them.
Why PUSH_TTL_SECONDS defaults to 15 minutes. A browser only talks to its push service while it is running, so everything sent while it was closed is queued there and delivered in one burst at start-up. A long TTL turns switching a laptop on into a replay of every notification it missed — mail you have very likely already read on your phone. Fifteen minutes still covers a suspended laptop, a dropped Wi-Fi link or an Android Doze window; anything older has stopped being news. Raise it if you would rather see late notifications than miss them; 0 means "deliver now or not at all".

Devices

Settings → General lists every device currently registered for this Hmelj account, with what it is. Enabling notifications on one device does not register any other, so a phone you no longer use can be removed here without needing to sign in from it.

Android app devices are listed too — they go through Firebase instead of Web Push, since Android's WebView has no Push API at all. See Android App.

Quiet hours & the scheduler

Settings → Scheduler, per account. An account-wide schedule applies to all its folders, and any folder can override it.

The notification scheduler
Account-wide notification hours, with per-folder overrides
  • Always — notify whenever mail arrives
  • Never — mail still syncs, you are simply not told
  • Scheduled — a time window and the weekdays it applies to
  • Skip public holidays — see below

Per-folder rows show whether they Inherit the account setting or carry their own, and an override can be cleared to go back to inheriting.

The holiday calendar is Slovenian. It is computed rather than listed (Easter included), and covers both work-free days and non-work-free state observances. Only work-free days are skipped by default, but any individual holiday's work-free flag can be flipped — and you can add your own dates. If you are not in Slovenia, the time-window and weekday parts of the scheduler still work exactly as described; only the built-in holiday list will not be yours.

Muting a folder

Right-click or long-press a folder in the sidebar → Mute: for a set period, until tomorrow morning, or until a time you pick. A muted folder shows a 🔕 marker, and the mute can be lifted from the sidebar or from the Scheduler tab — which is also where a mysteriously quiet folder gets explained.

↑ Top

Settings Reference

Every tab, every option

Settings are per Hmelj user. A few — marked this device below — are stored in the browser instead, because they describe the screen you are looking at rather than the account.

General

SettingWhat it does
LanguageEnglish or Slovenščina
App font / size / weight (this device)The whole interface. The reading pane has its own font setting under Reading.
Keep the screen on (this device)Stops the screen dimming and locking while Hmelj is open. Phones and tablets only, where it means something. On by default.
Time format24-hour or 12-hour
Date format31.12.2026, 12/31/2026, 2026-12-31 or 31 Dec 2026
Conversation viewGroup a message and its replies into one row. Off by default — see Conversation View.
Expand every message in a conversationOtherwise only the newest opens. Each expanded message is its own fetch, so a long thread is slower to open.
Messages per page10–200
Messages kept per folderHow many of each folder's newest messages are cached locally for fast browsing (50–5000). Lower syncs faster; the rest is always still on the server and reachable via Search everywhere.
Messages kept ready to open instantlyHow many of each folder's newest messages have their full content pre-fetched, so opening them is instant. 0 turns it off — a message is still cached the first time you open it, just not before. Cannot exceed the setting above.
Search box autocompleteFinishes words as you type, from your own mail history
Search index size limitA ceiling in MB on the full-text index that lets search look inside messages — turned on per account under Accounts. 0 means no limit. A stop, not a cleanup: at the ceiling nothing new is indexed, and what is already there keeps working. See Searching inside messages.
Run filters when opening InboxIn addition to the automatic run on new mail
Delete behaviorMove to Trash · only mark \Deleted and leave in place · delete permanently (expunge)
Mark as read when deleting
NotificationsMaster switch for this device, plus the test button and the device list — see Notifications
Swipe gestures on mobileAnd which direction does read/unread vs delete
Export your dataSettings as one zip, mail as mbox — see Exporting Your Data

The theme is not here — it lives in the user menu's Theme picker, which previews each theme live and applies it the moment you pick one, and can also set custom background and foreground colours.

Reading

SettingWhat it does
Reading paneRight side · Bottom · New window · List only
Auto mark as readImmediately on open · After a delay · Only via the ✉ icon in the list · Never
Mark-as-read delay0–120 seconds, used by "After a delay"
External imagesAlways · Only from trusted domains · Ask per message · Never
Trusted domainsOne per line
Show deleted messagesMessages flagged \Deleted appear struck through
Unsubscribe buttonAnd whether its banner is shown compact
Show whether the sender is verifiedThe SPF/DKIM/DMARC result your server recorded — a chip when the checks pass, a warning when a message claims a domain it may not use or wears a contact's name over a different address. Unchecked mail is left unmarked. See Is the sender who they say?
Message font / sizeThe reading pane only
Also use them for formatted mailMakes those two win over the sender's own fonts

Why the font picker may look like it does nothing. On its own it is a fallback: it styles the message body, and formatted mail almost never falls back to it, because it states its own fonts directly on the elements — in inline styles, its own stylesheet, <font> tags and table attributes, all of which outrank a plain body rule. Plain-text mail follows the setting; a designed newsletter does not.

Also use them for formatted mail is the switch that changes that. Your font is then forced everywhere, and every size in the message is scaled by the same amount rather than flattened to one — a heading stays bigger than the body text under it, and fine print stays smaller, everything simply larger. Set the size to 18 and a message built at 15 comes out 20% bigger throughout.

The cost, and the reason it is off by default: a newsletter designed around its own typeface stops looking the way its sender built it. A message that uses an icon font will show letters where the icons were.

Compose

SettingWhat it does
Undo send window (seconds)How long Send holds a message back so you can pull it out again. 0 sends immediately, 120 is the maximum. The wait is on the server, so it still applies if you close Hmelj.
Warn about a missing attachmentChecks your own text — English and Slovenian, šumniki optional — for a mention of an attachment when nothing is attached
Offer Reply to allWhen you press Reply on a message that had other people on it
Default formatHTML (rich text) or plain text
Default fontFor what you write — not the quoted original. Rich text only; the toolbar still overrides it per message.
Quoted message on replyBelow my reply · Above my reply · Do not quote
Autosave drafts everySeconds; 0 disables autosave
Request read receipts by default
Check spelling as I typeSlovenian and English, detected automatically. Off uses the browser's own spellchecker.

Identities

See Identities & Signatures.

Filters

See Filters.

Subject

Rules that shorten long subjects in the message list and in notifications. Machine-generated mail often buries the useful half behind a fixed prefix, so every row on a phone reads identically; a rule strips the prefix, and the next one can shorten a word inside what is left. Rules run top to bottom, each on the previous one's output, matching plain text or a regular expression with $1, $2 for what it captured.

Nothing is changed on the mail server. Search still matches what the sender actually wrote, opening the message shows the real subject, and a reply goes out quoting the real one. A Test a subject box at the bottom shows what any subject would come out as, using the rules exactly as edited — saved or not.

Saved searches

Rename, reorder and delete them — see Saved searches, which also covers the unread count each one carries in the sidebar.

Templates

Reusable snippets, inserted while writing with the 📋 toolbar button — see Templates.

Folders

See Folders.

Scheduler

See Quiet hours & the scheduler.

Contacts

See Contacts. This tab carries the address book itself, the two learn-as-you-go switches, synced address books, and groups.

Calendars

See Calendar — which calendars are synced, their colours, the time zone, and sharing them back out of Hmelj.

Accounts

See Accounts. This tab also carries Share, the entry into the Folders tab for a given account, and each account's Search inside messages switch with its live index size.

Login

Your Hmelj credentials — not any mailbox's. Change password asks for the current one. Change username signs you out on every device, since the login name is what sessions are tied to; changing only the capitalisation of your own name is a display change and does not. App passwords live here too — one per device, for subscribing a phone or another app to Hmelj's CalDAV/CardDAV server.

Log

Problems with your accounts, in plain language: filters that failed, mail that could not be sent, servers that could not be reached. Filter by Errors / Warnings / Info, expand an entry for the detail, and clear the whole log when you have dealt with it. Debug noise, per-poll timings and internal state stay in the server's own log and never appear here.

↑ Top

Admin

The tab only administrators see

The first user to sign up is an admin. The Admin tab is hidden entirely for everyone else.

Settings → Admin
Users, sign-up, OAuth providers, presets and fonts

Users

Every Hmelj login account, with its admin and disabled state. Each can be disabled (kept, but unable to sign in) or removed.

Sign-up

Allow new users to sign up — the runtime equivalent of ALLOW_SIGNUP. Turn it off once everyone who needs an account has one.

OAuth providers

Registers the client IDs that let users add a mailbox by signing in with the provider instead of storing a password — required for Outlook.com and Microsoft 365, and the alternative to an app password for Gmail.

Each provider card shows the exact redirect URI to register — click it to select it — and a line saying precisely how that provider expects the app to be registered, because the two disagree:

  • Microsoft — a public client (Mobile and desktop applications platform), client ID only, no secret. Tenant defaults to common.
  • Google — a Web application client, which is confidential and needs its secret as well.

The stored secret is never sent back to the browser: the field shows only whether one exists, and blank means "keep the one you have" — the same rule as every password field in Hmelj. Setting the matching environment variables instead makes the card read-only and say so.

Full walkthroughs: Google · Microsoft.

Account presets

Named server configurations that prefill the account wizard — IMAP and SMTP host, port and TLS, plus optional help text and a help link shown to the user. Instance-wide, so adding one for your own mail provider saves every user on the server from typing it.

Custom fonts

Upload .ttf, .otf, .woff or .woff2 files and they appear in every user's App font and Message font pickers. Select several at once — the family name and style (Regular / Bold / Italic / Bold Italic) are detected from each filename, so Bookerly-Regular.ttf and Bookerly-BoldItalic.ttf land in the right slots by themselves.

↑ Top

Exporting Your Data

Both halves, in the format that can actually be read back

Settings → General → Export your data. It comes out in two shapes, because one shape cannot do both jobs.

Export settings

One zip, small, containing:

  • your settings, identities and signatures
  • filters, subject rules, saved searches and templates
  • contacts — as JSON and as vCard, so any address book can import them
  • contact groups
  • calendars that live in Hmelj, as .ics

Everything is the same JSON Hmelj stores on the server, so restoring is copying a file back into DATA_DIR/users/<your user>/ with the server stopped. A README.txt inside the zip says so, and says what is missing.

Your mail accounts are deliberately not in it, and the zip contains a README saying so. Their passwords are encrypted with this server's own key, so a copy would be unreadable anywhere else — and writing them out in the clear instead would put every mailbox password you own into your downloads folder. Server names and ports are quick to retype; a leaked password is not quick to undo.

Export mail

One folder at a time, as an mbox file — the format Thunderbird, Apple Mail and every migration tool import. Pick the account, the folder, and optionally "only messages since…".

The file streams as it is read, a message at a time, so a folder of any size uses the same small amount of memory and there is no size ceiling. A big folder takes a while and the download appears when the whole thing has been read — the toast says as much when it starts.

Messages are exported exactly as they arrived, headers and all, so signatures still verify and nothing has been re-encoded on the way out.

↑ Top

Environment Variables

Everything Hmelj reads from the environment

All of these are optional — every one has a working default. In Docker they go in the .env file named by env_file:, or straight into environment:. The repository's .env.example is the same list with the reasoning attached.

Core

VariableDefaultWhat it does
PORT3000Port to listen on
HOST0.0.0.0Bind address
DATA_DIR./data (/data in Docker)Where everything persistent lives — see Data Directory
CACHE_DIRDATA_DIRWhere cache.sqlite lives. Point it at fast local disk if DATA_DIR is on a network mount — those writes are synchronous and can stall the whole process.
HMELJ_SECRETauto-generatedKey encrypting stored mailbox passwords and OAuth tokens. Unset, Hmelj writes DATA_DIR/secret.key instead. Back it up.
ALLOW_SIGNUPtrueWhether new users may register. The first user always can. Also togglable in Settings → Admin.
LOGinfoerror · warn · info · debug. debug logs every IMAP round trip and HTTP request — useful while diagnosing, very noisy otherwise.
HMELJ_PUBLIC_URLderived per requestPublic base URL, no trailing slash. Only OAuth needs it. Set it if a reverse proxy rewrites the host.

Sync and caching

VariableDefaultWhat it does
SYNC_INTERVAL_MS120000Default poll interval for the background sync, minimum 30000. Each account can override it, or opt out entirely — see Checking for new mail.
CONTACT_SYNC_INTERVAL_MS300000How often each synced address book is checked for changes, minimum 60000. A check that finds nothing costs one request per address book and transfers no contacts.
CALENDAR_SYNC_INTERVAL_MS300000How often each synced calendar is checked for changes, minimum 60000.
CALENDAR_WINDOW_PAST_DAYS120How far back Microsoft 365 and Exchange calendars are kept. Those two expand repeating events themselves rather than handing over the rules, so their calendars reach exactly this far and no further. CalDAV and Google calendars are unaffected — they store the rules and are known for all time.
CALENDAR_WINDOW_FUTURE_DAYS550The same, ahead.
The CalDAV/CardDAV server has no settings of its own. It is always mounted at /dav/ and is unreachable until you publish something and create an app password — see Sharing calendars and contacts.
CACHE_ENABLEDtrueKill switch for the background poller and the SQLite cache. false runs Hmelj fully live: unified views are fetched fresh from the mail server on every request. Slower, but nothing can go stale. Conversation view and cached search need the cache and switch off with it.
ATTACHMENT_CACHE_MB32RAM — never disk — held aside for attachment bytes already extracted once. Emptied on restart; 0 disables it.

Notifications

VariableDefaultWhat it does
VAPID_PUBLIC_KEYWeb Push key pair, from npm run vapid-keys. Notifications stay off until both are set.
VAPID_PRIVATE_KEY
VAPID_SUBJECTmailto:admin@localhostA mailto: or https: address identifying you. Some push services reject requests without one.
PUSH_TTL_SECONDS900How long a push service may hold a notification for an unreachable device. 0 means deliver now or not at all.
FCM_SERVICE_ACCOUNT_PATHDATA_DIR/fcm-service-account.jsonFirebase service-account key, for Android app push. Just dropping the file at the default path needs no variable at all.

OAuth clients

The environment alternative to Settings → Admin → OAuth providers. Set here, these win, and the Admin fields become read-only.

VariableWhat it does
MS_OAUTH_CLIENT_IDAzure Application (client) ID. No secret — the registration is a public client.
MS_OAUTH_TENANTcommon unless you registered a single-tenant app
GOOGLE_OAUTH_CLIENT_IDEnds in .apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRETStarts with GOCSPX-. Google's Web-application client is confidential and needs it.
↑ Top

Languages

English and Slovenščina

The interface language is per user, in Settings → General. The login page has its own selector, since you have not signed in yet.

Translation is keyed on the English source string and applied by a MutationObserver, so static HTML and dynamically rendered content translate alike and no template needs marking up. Message content is never translated — the reading pane, editors and input fields are excluded.

Adding a language

Copy public/i18n/sl.json to your language code, translate the values (the keys are the English source strings and must stay exactly as they are), and add the code to the language list in public/js/i18n.js. Nothing else needs touching.

The Android shell has its own strings, already translated into English, Slovenian, German, Spanish, French, Italian and Portuguese — but those only cover the native chrome: the server-URL screen and the notification action buttons. Everything else a user sees is the web app.

↑ Top

Security & Privacy

What is stored, where, and who can see it

Credentials

  • Hmelj passwords are scrypt-hashed in auth.json and never stored in a recoverable form.
  • Mailbox passwords and OAuth refresh tokens are encrypted at rest with AES-256-GCM, using HMELJ_SECRET or the auto-generated secret.key (mode 600).
  • Google's OAuth client secret is encrypted with the same key and is never sent back to the browser.
  • Sessions are httpOnly cookies backed by in-memory state — a restart signs everyone out. Login attempts are rate-limited per IP.

Message rendering

  • HTML is sanitised server-side, then rendered in a sandboxed iframe with no same-origin access. Scripts never run.
  • @import, expression() and behavior are always stripped.
  • CSS url(...) loads obey the same external-image policy as <img>, so a tracker cannot hide in a background.
  • Read receipts are never confirmed automatically.

The network

Run Hmelj behind TLS. Mailbox passwords travel over this connection, and PWA installation requires HTTPS anyway. See Reverse proxy & TLS.

Hmelj connects out to the mail servers you configure, to the push services your browsers and devices use (Mozilla's autopush, Google's FCM, Apple's relay), and to Google or Microsoft when someone signs in with OAuth. Nothing else. There is no telemetry, no analytics, no ads, and no Hmelj-operated service of any kind — the project's authors never receive, see or store any of your data.

The full statement, in the form Google Play asks for, is on the Privacy Policy page.

↑ Top

Install as an App (PWA)

Hmelj in its own window, on any platform

Hmelj is a Progressive Web App. Installing it gives you a window with no browser chrome, a launcher icon, and a "Compose" shortcut on the icon's context menu.

PlatformHow
Chrome / Edge, desktopThe install icon in the address bar, or ⋮ → Install
Android (Chrome)⋮ → Add to Home screen, or use the native app
iOS / iPadOS (Safari)Share → Add to Home Screen
Firefox, desktopNo install; it works as an ordinary tab, with notifications
Installation requires HTTPS (or localhost). A Hmelj served over plain HTTP on a LAN address will not offer to install.

The service worker is network-first for the app shell, so you always get the current version and a local edit shows up on refresh. It deliberately does not cache /api/*: mail is cached, but by the app itself rather than by the service worker — see Working Offline, which explains why.

↑ Top

Working Offline

Reading and answering mail with no server

Hmelj keeps a copy of your newest mail on each device, so it opens and works with no connection at all — on a plane, on the underground, or simply when the server is down. It is on by default and needs no setup.

What you can read

While you are online and the app is idle, Hmelj quietly downloads the newest 300 messages of each account (adjustable), including the images inside them. So offline you can open mail you had never opened before, not only what you happened to read earlier. If you read mail in a custom font, that font file is saved too, so a message offline is set the way it is online rather than dropping to a fallback face. Your folder list, contacts, identities, settings and the calendar weeks you have looked at are saved with it, which is what lets the app start at all with the server unreachable.

A message whose body was not saved — older than the limit, or dropped to stay under the storage cap — says so plainly when you open it, and opens normally once you are back.

The search box keeps working; it searches what is saved on the device rather than asking the server, and the results say so. from:, to:, subject:, is:unread, is:starred and has:attachment all work, as do plain words, which also match the text of any message whose body is saved.

The Outbox

Anything you do offline is queued rather than refused: marking read or unread, starring, deleting, moving, archiving, marking spam, saving a draft and sending. The list updates immediately, an Outbox row appears in the sidebar with a count, and the queue survives closing the app. When the connection returns it is sent in the order you did it.

Right-click (or long-press) anything in the Outbox to retry it, edit it — a queued message reopens in the composer — or discard it.

If a message was moved or deleted from another device while you were offline, the queued action for it can no longer be applied. Hmelj says so once and drops it, rather than retrying something that can never succeed.

Storage, and what it costs

Settings › Offline is per device — a phone and a desktop sharing one Hmelj account keep their own answers. It sets whether to keep mail offline at all, how many messages per account, whether to include attachments (off by default; inline images are kept either way), and a storage ceiling (250 MB by default). When the ceiling is reached the oldest messages are dropped first. The same panel shows what is stored right now and can delete all of it.

Saved mail is not encrypted. It lives in the browser's own storage for this site, like any other site's data, and anyone with access to the unlocked device profile can read it. On a shared or unencrypted machine, turn offline mode off. Logging out deletes it, and so does signing in as a different user.

On Android

The native app works offline too. Because it usually points at a plain-http LAN address — which browsers do not treat as a secure context, so there is no service worker there — it loads the app from the WebView's own cache when there is no network, instead of the "no connection" screen. That screen still appears if the app has never been loaded on that device.

↑ Top

Android App

A native shell around the same web app

The Android app is a full-screen WebView pointed at your Hmelj server. It contains no mail code. On first launch it asks for the server URL and remembers it; press back twice on the main screen to change it.

The Android app's first-run screen
First run — point it at your server

Installing it

Download the APK from GitHub Releases and install it. Android will ask you to allow installing from that source; that is the normal sideloading prompt, not a warning about the app.

Each release carries a release APK (what you want) and a debug APK (larger, unoptimised, for diagnosing a problem). Both are signed, so an update installs over the previous version.

An APK you build yourself is signed with your key, and Android refuses to install it over one signed with a different key. Uninstall first — which erases the app's stored server URL and its notification permission — or build with the same key. See Android Studio.

Google Play distribution is planned but not live yet.

Why it exists

Android's WebView implements no Web Notification or Push API at all — on every Android version, in every WebView build. It is a platform gap, not a permission you can grant. So a Hmelj PWA on Android cannot receive push while it is closed, and the native shell exists to fill exactly that hole, using Firebase Cloud Messaging.

What the shell adds over the PWA:

  • Push while fully closed, with Mark as read and Delete buttons on the notification
  • An unread badge on the launcher icon
  • OAuth sign-in in a real browser tab — Google rejects OAuth started inside a WebView outright
  • Attachment hand-off to whichever installed app can open the file
  • Keep the screen on, and correct padding for notches and gesture bars
  • Battery-optimisation exemption prompt, so background delivery is not killed by Doze
A new-mail notification with action buttons
Mark as read and Delete, without opening the app

Firebase setup

Android push needs a Firebase project. Two pieces, on two sides:

1. In the app — google-services.json

a. Open the Firebase console and create a project (the free Spark tier is enough for FCM), or open one you already use. Reusing a project is what keeps an existing server-side key working; a project ID can never be changed once created, only its display name.

b. The ⚙ gear next to Project OverviewProject settings → the General tab → scroll to Your apps.

c. Add app → the Android icon.

d. Fill in the registration:

  • Android package namecom.hmelj.app, exactly and case-sensitively. A typo here produces a config that looks fine and fails the build with an unhelpful message.
  • App nickname — anything; a console-only label.
  • Debug signing certificate SHA-1leave empty. It is needed for Google Sign-In, Dynamic Links, Phone Auth and App Check, none of which this app uses. FCM does not need it.

e. Download google-services.json and save it as Android/app/google-services.json.

f. The wizard then offers "Add Firebase SDK" Gradle snippets — skip them. app/build.gradle already applies the plugin (conditionally, only when the file exists) and already declares the messaging dependency. Next → Next → Continue to console.

g. Optional: repeat with the package name com.hmelj.app.debug if you want push in debug builds and side-by-side debug and release installs. Download google-services.json once at the end — one file describes every app in the project.

The Gradle plugin fails the build outright for an application ID that has no client in google-services.json — it does not merely disable push. That is why com.hmelj.app.debug only gets its .debug suffix when a client for it actually exists.

2. On the server — the service-account key

a. Firebase console → Project settingsService accountsGenerate new private key. This downloads a JSON credential.

b. Put it on the Hmelj server at DATA_DIR/fcm-service-account.json and chmod 600 it. That is the default path, so no environment variable is needed. Restart Hmelj.

The startup log says which it found, or that it found none and Android push is therefore off — everything else is unaffected either way.

This key is a server credential. It never goes in the app, never in the APK, and never in version control. It is project-level, so one key covers every app registered under that Firebase project.
↑ Top

Building & Releasing

Docker images, signing keys, and cutting a release

Everything here is automated by GitHub Actions in the repository. This section is for whoever maintains it — or for anyone building their own fork.

Docker images

.github/workflows/docker.yml runs the test suite, then builds a multi-arch image (linux/amd64 and linux/arm64) with QEMU and Buildx and pushes it to GitHub Container Registry.

TriggerTags pushed
Push to main:dev
Tag v1.2.3:latest, :1.2.3, :1.2
Pull requestbuilds only, pushes nothing
No secret to create. It authenticates with the built-in GITHUB_TOKEN, granted packages: write in the workflow. After the first successful run the image appears under your GitHub profile → Packages; a package starts private, so link it to the repository and set it public if you want others to pull it.

Building locally instead is one command — but note that an emulated arm64 leg compiles better-sqlite3 from source under QEMU and is slow:

docker buildx build --platform linux/amd64,linux/arm64 -t hmelj:local .

Android signing keys

Android will only install an update over an existing app if the new APK carries the same signing certificate. That makes the signing key a long-lived commitment, and it is why Hmelj keeps two of them apart:

KeySignsWhy separate
Dev / sideload keyThe APK attached to every GitHub ReleasePeople install these by hand. If the key ever changes, none of them can update — they have to uninstall and lose their settings.
Play upload keyThe AAB uploaded to Google PlayPlay re-signs with its own app-signing key anyway; the upload key only proves the upload is from you.
The release-APK build deliberately never looks at the Play key. Mixing the two once made the sideload APK silently switch identity the moment a Play secret was added, breaking every existing install's next update.
Already signing releases with a keystore? Use that one. Do not generate a new key just because the project is new. Whatever key signed the APK people already have installed is the only key whose next build they can install as an update — and the alias matters as much as the file. Take the storeFile, storePassword, keyAlias and keyPassword out of your existing Android/keystore.properties and put those into the DEV_KEYSTORE_* secrets below. The steps that follow are for a project that has never released anything.

Create the dev / sideload keystore — do this once, and keep the file somewhere you will still have it in five years:

keytool -genkeypair \
  -keystore dev-release.jks \
  -alias hmelj-release \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -dname "CN=Your Name, OU=Hmelj, O=Hmelj, L=City, ST=State, C=SI"

Create the Play upload keystore — only when you are ready to publish:

keytool -genkeypair \
  -keystore play-upload.jks \
  -alias hmelj-upload \
  -keyalg RSA -keysize 2048 -validity 10000 \
  -dname "CN=Your Name, OU=Hmelj, O=Hmelj, L=City, ST=State, C=SI"
FlagMeans
-keystoreThe file that holds the key. Guard it like a password — it cannot be regenerated.
-aliasThe key's name inside that file. You need it again to sign.
-validity 10000Days — about 27 years. Google Play requires a certificate valid until at least 2033.
-dnameThe certificate subject. Nobody verifies it; fill in something honest.

keytool prompts for a store password and a key password. They may be the same. Write both down.

GitHub secrets

Repository → SettingsSecrets and variablesActionsNew repository secret. Keystores and the Firebase config are binary or multi-line, so they go in base64-encoded:

base64 -w0 dev-release.jks                  # Linux
base64 -i dev-release.jks                  # macOS
certutil -encode dev-release.jks out.txt   # Windows

Paste the whole result, newlines and all. The workflows strip line breaks and the -----BEGIN/END----- wrappers certutil adds, so output from any of the three works as-is.

SecretValueNeeded for
DEV_KEYSTORE_FILEdev-release.jks, base64The sideload APK attached to each Release. Without these, CI signs it with a throwaway key and the result cannot be installed as an update.
DEV_KEYSTORE_PASSWORDStore password
DEV_KEY_ALIASThe alias inside that keystore — keytool -list -keystore <file> prints it
DEV_KEY_PASSWORDKey password
KEYSTORE_FILEplay-upload.jks, base64The Play AAB. Optional until you publish — without them the AAB falls back to the dev key and is still built.
KEYSTORE_PASSWORDStore password
KEY_ALIASThe alias inside the upload keystore
KEY_PASSWORDKey password
GOOGLE_SERVICES_JSONgoogle-services.json, base64Android push in CI builds. Without it the APK still builds; push is simply off.

Building in Android Studio

Requires JDK 17 and the Android SDK with API 35. Open the Android/ directory as the project.

To produce an APK that installs over a CI-built one, sign it with the same key. Create Android/keystore.properties — it is git-ignored — next to build.gradle:

storeFile=keystore/dev-release.jks
storePassword=your-store-password
keyAlias=hmelj-release
keyPassword=your-key-password

storeFile is resolved relative to the Android/ directory, so putting the .jks in Android/keystore/ (also git-ignored) works as written. Then:

gradlew.bat assembleDebug      :: app\build\outputs\apk\debug\
gradlew.bat assembleRelease    :: app\build\outputs\apk\release\
gradlew.bat bundleRelease      :: app\build\outputs\bundle\release\  (Google Play)

…or Build → Generate Signed App Bundle / APK in the IDE. Drop google-services.json into Android/app/ first if you want push in your local build.

The signing config picks the first source it finds: the CI Play-key environment variables, then the CI dev-key ones, then keystore.properties. If none is present, the release build is left unsigned on purpose — falling back to the debug key would produce an APK that installs once and can never be updated.

Cutting a release

1. Bump the version in package.json, and versionCode / versionName in Android/app/build.gradle. Play refuses an upload whose versionCode is not higher than the last one.

2. Update CHANGELOG.md, commit, and push.

3. Tag and push the tag:

git tag -a v1.0.0 -m "Hmelj 1.0.0"
git push origin v1.0.0

That one push starts both release workflows:

  • Docker — pushes :latest, :1.0.0 and :1.0.
  • Android — builds the debug and release APKs and attaches them to the GitHub Release, and builds the AAB as a run GitHub Actions artifact. The AAB is deliberately not attached to the public Release: download it from the run and upload it to Play Console yourself, to the Internal testing track first.

Publishing these docs

Repository → SettingsPages → Source: Deploy from a branch, branch main, folder /docs. The site is plain static files with no build step, so it goes live within a minute or two at https://<user>.github.io/hmelj/.

Screenshots live in docs/screenshots/. They ship as blank placeholders so nothing 404s; replace them one at a time with real captures. docs/screenshots/README.md lists what each one should show, and node scripts/gen-placeholder.mjs regenerates any that go missing.

↑ Top

Troubleshooting

The failures that actually happen

Look here first

Settings → Log records the problems that concern your accounts in plain language — a filter that failed, mail that could not be sent, a server that could not be reached. Most questions are answered there before the server log is worth opening.

For the server side, docker compose logs -f hmelj, and LOG=debug when you need every IMAP round trip and HTTP request.

SymptomLikely cause
OAuth sign-in fails at the very last step, after Microsoft or Google accepted the login The redirect URI does not match byte for byte. Copy it from Settings → Admin → OAuth providers, and set HMELJ_PUBLIC_URL if a proxy rewrites the host.
AADSTS7000218 — "client_assertion or client_secret required" The Azure redirect URI is registered under the Web platform. Move it to Mobile and desktop applications.
AADSTS9002327 Registered as a Single-page application. Same fix.
A Gmail OAuth account stops working every 7 days with invalid_grant The Google consent screen is still in Testing. Set the publishing status to In production.
Google says "this app is not verified" Expected. That app is your own Hmelj server. Advanced → Continue.
Outlook.com: "User is authenticated but not connected" An IMAP account against Outlook.com. Use the Microsoft (Outlook / Microsoft 365) account type, which goes through Graph instead.
Every Exchange sign-in crashes the process Missing --openssl-legacy-provider. See systemd.
Notifications never arrive on any device VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY are not set, so Web Push is off. Check the startup log.
Notifications arrive everywhere except the Android app No Firebase service-account key on the server, or the APK was built without google-services.json. The startup log says which.
A burst of stale notifications when a laptop wakes up Expected, and bounded by PUSH_TTL_SECONDS (default 15 minutes). Lower it, or set it to 0.
Cross-device updates arrive in delayed bursts A proxy is buffering /api/events. Hmelj sends X-Accel-Buffering: no, which nginx honours; a proxy that ignores it needs proxy_buffering off;.
Search finds far fewer messages than you expect It searched the local cache. Use Search everywhere, or raise Messages kept per folder.
Sending fails, or drafts vanish The Sent or Drafts folder is mapped to the wrong path. Settings → Folders → Special folders.
Everything is slow, and the container is on a NAS Synchronous cache writes on a network mount. Set CACHE_DIR to local disk.
SqliteError: unable to open database file / SQLITE_CANTOPEN at startup Hmelj cannot write to DATA_DIR. The container normally fixes this itself; you see it if you started it with an explicit --user, or if the mount is read-only. chown -R 1000:1000 the host directory, or set PUID/PGID.
Accounts appear to have vanished after a move or upgrade The volume was not carried across, or HMELJ_SECRET changed. The data is intact; the key is what is missing.
The APK will not install over the existing one It is signed with a different key. Uninstall first, or build with the same keystore.

Starting the cache over

The message cache is disposable. If it has drifted from the server, stop Hmelj, delete cache.sqlite and its -wal / -shm sidecars, and start again — it rebuilds on the next sync. Nothing else in DATA_DIR is safe to delete this way.

To rule the cache out of a problem entirely, run with CACHE_ENABLED=false: no background polling and no local cache, with everything fetched live.

↑ Top