Skip to content

Licensing

Purpose. The server knows which subscription it is running under, enforces the plan’s limits, and when the subscription lapses takes features away in stages, telling the admin what each one costs and when — receiving and reading mail are never touched.

Behaviour.

  • Key format (@mailserver/license): a single-line token mdl1.<payload>.<signature>, Ed25519-signed. The payload carries licenseId, tier, maxMailboxes, maxDomains, issuedAt, expiresAt, features[], an optional machine fingerprint, and issuedTo for display. Both the product and the licence server use this one module to sign and verify, so the two cannot drift into a format only one of them reads.

  • Verification is offline. The product checks the signature against LICENSE_PUBLIC_KEY (base64 SPKI) and never contacts the licence server to answer a request. A build with no public key configured, which Compose cannot produce (see Configuration) and only happens with the API run outside it, such as pnpm dev with no key, reports unlicensed, enforces nothing, and says which of the two it is: the reason reads “this build has no licence public key” rather than “no licence key installed”, and the API logs a warning at boot. The two are otherwise identical from the outside, which is how a build that could verify nothing went unnoticed.

  • Activation (POST /license/activate, owner only): the key is verified before it is stored, so a bad key is refused at the point it is pasted. It is stored sealed under SECRET_ENCRYPTION_KEY, because it is the credential a transfer presents; a key installed before that is sealed at the API’s next boot. A key bound to another machine is refused with license_fingerprint_mismatch rather than stored and silently ignored, and a build with no licence public key answers 503 license_unavailable.

  • Transfer (POST /license/transfer, owner only): moves this subscription onto this machine. No body — the credential is the key already installed here, which the API presents to the licence server’s POST /v1/transfer and never lets out of the database. The re-issued key comes back and is verified before it is stored, by the same path a pasted one takes. The one licensing call that leaves the host, and it is never on a request path: status, verification and enforcement stay offline. Refusals are reported as themselves — license_not_installed (409, no verified key here to present), license_unreadable (409, a key is installed but sealed under a different SECRET_ENCRYPTION_KEY, so there is nothing to present until it is activated again), license_transfer_stale (409, this server’s key names a machine the licence has already left), license_transfer_rejected (409, the licence server did not recognise the key), license_unknown (404, it has no record of the licence), license_revoked (403), license_server_unconfigured (503), license_server_unreachable (502) — because each leads somewhere different.

  • Status (GET /license, any admin role): state, plain-English reason, days remaining, the decoded plan, live usage, last heartbeat, this server’s machine fingerprint (quote it to support) and license.boundTo — the fingerprint the key names, or null for a portable key. Both fingerprints are reported rather than reduced to a boolean, because a transfer is a conversation about two of them. claimedElsewhere is true when the key is portable and the licence server refused the last heartbeat as fingerprint_mismatch. That means another server has claimed the licence, the first machine to heartbeat with a portable key binds it. This key cannot move it back: the licence server takes only the current key, so POST /license/transfer answers license_transfer_stale without asking. The Licence page and mailctl license status say so and name the way out: support issuing a key for this machine. That server’s key names that server, so activate refuses it here. Before this the page showed a bare fingerprint_mismatch and the transfer said “already here”.

  • States — an escalating schedule (decided by the user 2026-10-10):

    • active: current subscription, heartbeat recent (or never, on a fresh activation).

    • evaluation: no key installed, for 30 days from the end of setup. Everything works, with a banner and a countdown. The evaluation replaces grace rather than preceding it: at day 30 an install with no key is degraded, at 60 restricted and at 90 send_suspended, as a billing lapse.

    • grace, days 0–30 after a lapse: expired, or no heartbeat has succeeded for more than 7 days. Everything still works; the admin sees a banner and a countdown. A server that loses its licence server is a technical lapse: it reaches degraded 30 days and restricted 60 days after its last check-in, not 7 days later (the grace countdown starts at about 23 days), and never send_suspended. A key on another machine is degraded at once and stays there.

    • degraded, days 30–60: new mailboxes and domains are refused with 402 license_degraded (pre-creating mailboxes for a migration included), and so are AI features, starting or resuming a migration and new tokens that can send.

    • restricted, days 60–90: everything degraded refuses, and the admin panel is read-only.

    • send_suspended, from day 90, billing lapses only (an expired or cancelled subscription, or an install never licensed): mail leaving the server is refused at submission. A technical lapse (a stale heartbeat, a key on another machine) never goes past restricted, so a firewall change, our outage or a VM move cannot stop a paying customer’s outbound mail.

    • Renewing restores everything at once: a current key makes the state active whatever stage it was in.

    • unlicensed: a server that is not assessed at all — setup not finished, a build with no public key, or an installed key this build cannot read (signed for another public key, or sealed under another SECRET_ENCRYPTION_KEY; the reason says so) (license-service.ts).

    • No clock starts before this build first assessed the install: the API records that moment once (server_setup.licence_schedule_from), and each lapse counts from the later of its own date and that one. An install upgraded to the release that brings the schedule therefore gets all of it, announced, rather than landing in send_suspended for a key that expired months ago.

    • Which refusals are wired is in What each stage refuses below; the state is computed and reported for every stage (state, daysRemaining until the next one, and lapse, billing or technical).

    • A fingerprint mismatch is a technical degraded from the first moment, with no grace. It is weighed beside the expiry and heartbeat clocks, not instead of them, so an expired key on another machine reaches the expiry’s stage.

    • The fingerprint is the host’s, computed by mailctl and written to .env as MACHINE_FINGERPRINT at install, every upgrade and restore, and by license transfer when it differs (from 0.4.2). Compose passes it to the api and the worker, which cannot compute it inside their containers: no machine-id, and a MAC and hostname that change with every container. Until 2026-09-26 each container computed its own, so an activated licence was bound to one worker container and the api degraded on it. Moving the install to new hardware gives a new fingerprint at the next upgrade or restore, and the licence must be transferred. The upgrade is refused at the registry and puts .env back, old value included. Its message names both values and says to run mailctl license transfer, then the upgrade again. transfer records this host’s value and restarts api and worker on it, so the api sees the mismatch and moves the licence. This works from 0.4.2; with 0.4.1 the transfer answered “already here” and support moved the licence. A licence bound to a container’s value before 0.4.1 is unbound by support before the upgrade to 0.4.1, not after.

  • What each stage refuses. The one code for “your licence lapsed” is 402 license_degraded at every stage, distinct from 402 license_limit_reached; its message names the stage and what is refused. From degraded: POST /domains, POST /domains/:id/mailboxes and POST /migrations/:id/precreate (the same mailbox gate, from a migration); every AI call, the admin copilot’s included (GET /ai/availability answers licence_lapsed, and the streamed routes refuse before they open); starting or resuming a migration (POST /migrations/:id/run — a run already copying finishes, and pause, cancel and cutover stay open); and a new token that can send (POST /tokens with messages:send; other tokens, and tokens already issued, are untouched). Every webmail user sees a notice from degraded on (MailAccount.licenseNotice). From restricted, administration is read-only: one hook in front of every API route (plugins/licence-restriction.ts) refuses an administrator’s write — an admin API token’s included — so a route added later is read-only there by default. Open, by the user’s list: signing in and an administrator’s own two-factor, the licence, setup, the webmail (a mailbox user is never touched by this stage), and the security actions and data out — suspending, deactivating or unblocking a mailbox (a PATCH whose every field is one of those), deactivating an admin-console user (a PATCH /users/:id carrying only active), resetting a password, clearing a broken TOTP, revoking a token or an app password, lifting a sign-in ban, the mail queue’s hold, release and delete, export, opening a mailbox to read it, and the checks that only look (DNS verification, a configuration preview, a filter check and the filter editor’s compile preview, POST /mailboxes/:id/filters/compile); pausing, cancelling or finishing a migration; a user’s own AI consent (POST and PUT /ai/opt-in, which are not administration); and POST /messages, which is the next stage’s. From send_suspended (billing lapses only), mail leaving the server is refused at submission: Postfix’s submission and smtps services permit a recipient on this server first (permit_auth_destination), then consult /etc/postfix/license_send.regexp, which is empty until this stage and then holds one rule rejecting every other recipient with 554 5.7.1 Sending is suspended: this server's licence has lapsed. Mail to addresses on this server is still delivered; ask the administrator to renew the licence. A mail client shows that text, the messages API answers it as 422 rejected, and so does the webmail’s send. A message scheduled with Send later that falls due during the stage goes through the same submission, so it ends Not sent (failed) with that reply when every recipient is outside, and is not tried again. Delivery between this server’s mailboxes, receiving and reading continue. The map is an apply-time render extra; the API re-applies when the stage begins or ends — every five minutes, at boot (checking Postfix’s live copy, so a restart after a renewal still empties it) and at once when a key is activated or moved, so renewing restores sending straight away. When a message goes to recipients on this server and outside it, Postfix takes it for the first and refuses the others: the webmail keeps the composer open with only the refused recipients and Postfix’s reply, and the messages API answers 202, the message queued, with its error naming whom it did not reach. A failed apply is retried at the next five-minute tick, backing off to fifteen minutes between attempts as containment’s does; an activation’s apply is not held back by that wait, and one that arrives while an apply is running is applied when it ends. Forwarders, Sieve redirects, the autoresponder and a filter’s reply and reject actions relay through postfix:25, not submission, and are not stopped. Never refused at any stage: receiving and reading mail (delivery, IMAP, POP3, webmail reads), signing in, entering a licence, the security actions (suspending a mailbox, resetting a password, unblocking a sender), backup and export. One sender passes the send stop: the server’s own noreply@<first domain>, which mails password-reset links (Password reset by email), so an admin-console account outside this server can still recover its password. Submission checks the login against /etc/postfix/license_send_exempt before the stop’s map; it is filled with that one address while sending is suspended, and only while the address is a system mailbox or nobody’s yet. A system mailbox’s password cannot be set (409), so nothing but the server sends as it.

  • The owners are emailed as a lapse moves on. The server mails every active owner account a week before the evaluation ends, when grace starts, a week before grace ends, and on entering degraded, restricted and send_suspended: what stopped, what still works, the days to the next stage and a link to the licence page. Each owner gets a message of their own, so no owner sees another’s address. Each warning is sent once per step forward (server_setup.licence_notice holds the last one): it is not repeated while the lapse only moves on, a step back (a heartbeat restored, a better key) is recorded without a mail so the next step forward warns again, and renewing, or a state that is not assessed (unlicensed, which includes a key this build cannot read), clears it, so the next lapse warns from the start. An install never licensed goes from its evaluation straight to degraded, so its warnings are the evaluation’s last week and then each stage. It goes from noreply@<first domain>, the one sender the send stop lets through, on the API’s five-minute licence tick right after the stop is applied, so the send_suspended warning reaches an outside owner too; and it is sent by the server itself, so a technical lapse, with no licence server reachable, is warned about as well. A warning is recorded only once every owner has it: an owner Postfix refused, or a send that failed, is tried again at the next tick, and only that owner (within one API process: a restart mails the whole warning again), and a tick that arrives while one is still sending does nothing. With no sender, because an alias holds noreply@<first domain>, nothing is sent and the API logs it once; with a customer’s own noreply@ mailbox there, which the send stop does not exempt, the send_suspended warning reaches only owners on this server and the refusal is logged once per owner (Password reset by email describes the same two cases for a reset link). Nothing else sends as noreply@: the messages API refuses a system mailbox as the sender (422 sender_not_a_mailbox). Stripe’s own billing emails are the other half, switched on with live mode.

  • Heartbeat (worker, every LICENSE_HEARTBEAT_INTERVAL_MINUTES — default 1440, i.e. daily, minimum 5; compose/compose.yaml passes it through from .env): reports { licenseId, key, fingerprint, usage, version } to LICENSE_SERVER_URL/v1/heartbeat. The installed key goes with it because an unbound licence is claimed with its key, never its id alone; a bound licence ignores it. It installs the key it gets back, which is how a renewal reaches a customer with nobody copying anything. A refreshed key is verified before it replaces the old one, so a licence server handing back something unreadable cannot un-licence someone who is paying. A failed heartbeat records the error and deliberately does not move lastHeartbeatAt: that clock not advancing is the entire degrade mechanism.

  • Registry login (mailctl): before pulling, install and upgrade exchange LICENSE_ID for short-lived credentials at /v1/registry/token and docker login with them. An install with no LICENSE_ID skips rather than failing — self-built images need no credentials — but a licence that is refused stops the command, because the alternative is a confusing “image not found” from docker. During upgrade a refusal exits non-zero rather than restarting the stack on the images it already has and reporting success. upgrade also sends the installed key, read from the database through psql and opened with .env’s SECRET_ENCRYPTION_KEY (it is never written to .env), because an unbound licence is claimed with its key. An install has no key yet and sends the id alone, which an unbound licence refuses with key_required; mailctl then says to run mailctl license activate <key> first. A server on 0.3.0 or earlier sends no key, so it cannot claim an unbound licence (the licence server refuses it with key_required) until it upgrades; a bound one is unaffected.

  • Admin UI (/license, linked in the navigation for owner/admin only — apps/admin/src/routes/layout.tsx; the route itself is not role-guarded, and GET /license also admits domain_admin): the licence page and, above every admin screen, a banner that appears for evaluation, grace and every lapsed stage, naming what it refuses and counting down to the next — see Admin UI. Installing a key and moving the licence to this server are the owner’s; the banner renders for every admin role. On a fingerprint mismatch the owner gets a Move this licence to this server button beside the diagnosis, which is the whole self-service path in one click.

  • mailctl license, on the host — three subcommands over the same /license endpoints, so the CLI and the admin page cannot describe a server differently:

    • status prints the state, the reason, the plan, usage against its limits, both machine IDs and the last check-in. It exits 1 from degraded on — evaluation and grace block nothing, so they exit 0 — which makes it usable as a monitoring check. It counts down to the next stage, and a lapsed server is told in as many words what still works (up to restricted, delivery, IMAP, POP3, webmail and sending; at send_suspended, receiving, reading and local delivery): that is the question someone actually has when a banner has frightened them.
    • activate [KEY] installs a key (prompting for it when the argument is omitted, so it stays out of argv and shell history) and then records LICENSE_ID in the install’s .env — not because it enforces anything, but because install and upgrade exchange it for registry credentials, and a hand-activated server whose .env still said nothing would fail its next image pull for no visible reason.
    • transfer moves this subscription onto this machine. On the install’s host, once it has credentials — an owner’s, checked before anything changes, an explicit --token included — it records this host’s machine ID in .env when it differs, and restarts api and worker on it, so the api reports the machine the command runs on; not with --api-url. Then it diagnoses the two machine IDs, then asks the control plane to make the move and installs what comes back. It says “nothing to transfer” for the three cases that are not a transfer (no key, a portable key nobody else has claimed, a key already bound here), and refuses a portable key another server has claimed (claimed_elsewhere), naming support as the way out rather than walking someone through a procedure they do not need. When the move cannot happen — no LICENSE_SERVER_URL, an unreachable server, a stale key, a revoked licence — it names that reason and prints the manual procedure with both values it needs, then exits 1. --key still installs a key that was re-issued for you, which is the path support uses when a host is gone and its key with it.
    • Credentials: run interactively — it asks for the owner’s email and password, and for a TOTP code only when the server says one is needed; or the owner uses the admin panel. Activating and moving a licence is a once-a-year act, and decided interactive-only by the user on 2026-10-10: the tokens page offers no owner token, because a leaked one is a full takeover. --token and MAILDOCK_TOKEN are still read and still checked for the owner role, for a shell with no TTY, but they are not the scripted path. Nothing is cached to disk.
  • Limits: maxMailboxes counts non-system mailboxes (the DMARC ingestion mailbox is not billable); maxDomains counts every domain, including deactivated ones. 0 means unlimited. Exceeding a limit returns 402 license_limit_reached — distinct from license_degraded, so the UI can say “upgrade your plan” rather than “renew your subscription”.

Configuration. LICENSE_ID (the subscription, used only for the registry exchange) and LICENSE_SERVER_URL live in .env, written by mailctl install. LICENSE_SERVER_URL reaches both the worker (the daily heartbeat) and the api (self-service transfer, and nothing else); empty disables both, which is a valid dev configuration and leaves every other licensing behaviour intact. It defaults to https://licence.maildock.app and --license-server (or MAILDOCK_LICENSE_SERVER) overrides it.

LICENSE_PUBLIC_KEY — base64 SPKI of the signing key’s public half — does not. It is delivered by the compose bundle: compose/compose.yaml carries it as a single anchored default (x-license-public-key) which both the api and the worker take, and mailctl install/mailctl upgrade materialise that file onto the host. A public key is not a secret, and .env holds values that differ per install: the secrets, ports, paths and image tags, plus the install’s own identity — LICENSE_ID, and MACHINE_FINGERPRINT — never a value every install shares; shipping it in the bundle also means a corrected or rotated key reaches installs with an ordinary release instead of a hand-edit. Setting it in .env still wins, which is how a test server is pointed at a throwaway keypair. The api and worker read one anchor rather than two copies because a build where only the api could verify would look healthy and quietly stop accepting renewals. Since 2026-09-02 the bundle ships the real public half, so a released install verifies licences out of the box. There is no setting that disables verification, and that is deliberate. Compose’s :- treats an empty value as unset, so an empty LICENSE_PUBLIC_KEY= line runs with the production key: the dev stack and CI verify against production and read unlicensed without a key installed. The alternative, -, would let one empty line in a customer’s .env switch enforcement off, and failing closed is the better rule. To test against something other than production, point the variable at a throwaway keypair. The private half never appears in this repository or on a customer’s server: it exists only as /maildock/license/signing-key, an SSM SecureString in our own account, and nothing can read it back but the licence server.

Limits & edge cases.

  • Self-service transfer needs a reachable licence server, and an install whose LICENSE_SERVER_URL is empty or wrong falls back to the support procedure in Move a licence to a different machine. Nothing else degrades: the server is no worse off than before the button existed.
  • licence.maildock.app is the shipped default (apps/cli/src/lib/install-dir.ts, defaultEnv); --license-server or MAILDOCK_LICENSE_SERVER overrides it. A test asserts the default is a host on the product domain: it once read license.maildock.io, a domain nobody here has ever held, and nothing noticed for a phase because both users of the URL fail quietly.
  • A transfer still cannot help a host whose key is gone. The credential is the installed key, so a machine with no verifiable key has nothing to present, and that case remains ours to do — POST /v1/admin/licenses/:id/transfer with the admin token.
  • A stolen key now moves a binding, where before it only pulled images. Recovery is symmetric — transfer it back — and the licence server records the trade in full in its own decision record.
  • The machine fingerprint hashes the host’s machine-id + the MAC of the interface carrying the default route + hostname. The MAC is never a virtual interface’s: veth*, docker*, br-* and their kind are skipped, and without a usable default-route interface (no route table, no default route, or only one on a virtual interface or one without a MAC) the lowest remaining MAC counts. Until 0.4.3 it was the lowest MAC of every interface, which on a Docker host is often a container’s veth, so the value changed whenever containers were recreated. The first mailctl run on 0.4.3 therefore computes a new value: a bound licence is refused at that upgrade, which names both IDs, and one mailctl license transfer settles it. It is computed by mailctl and fixed in .env until the next install, upgrade, restore or license transfer. A running server keeps its value; losing any of the three (a rebuilt host, a renamed NIC) changes it at the next of those commands, which is why binding is optional on the key and why a transfer path has to exist. Only the dev stack, where no mailctl ran, still computes it inside a container.
  • Enforcement reads usage from the database on each gated call. That is two count(*)s on a creation path, which is not a hot path.
  • A verified payload is cached in memory per token; changing the key drops the cache. A restart re-verifies once.