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 tokenmdl1.<payload>.<signature>, Ed25519-signed. The payload carrieslicenseId,tier,maxMailboxes,maxDomains,issuedAt,expiresAt,features[], an optional machinefingerprint, andissuedTofor 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 aspnpm devwith no key, reportsunlicensed, 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,owneronly): the key is verified before it is stored, so a bad key is refused at the point it is pasted. It is stored sealed underSECRET_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 withlicense_fingerprint_mismatchrather than stored and silently ignored, and a build with no licence public key answers503 license_unavailable. -
Transfer (
POST /license/transfer,owneronly): moves this subscription onto this machine. No body — the credential is the key already installed here, which the API presents to the licence server’sPOST /v1/transferand 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 differentSECRET_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) andlicense.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.claimedElsewhereis true when the key is portable and the licence server refused the last heartbeat asfingerprint_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, soPOST /license/transferanswerslicense_transfer_stalewithout asking. The Licence page andmailctl license statussay so and name the way out: support issuing a key for this machine. That server’s key names that server, soactivaterefuses it here. Before this the page showed a barefingerprint_mismatchand 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 replacesgracerather than preceding it: at day 30 an install with no key isdegraded, at 60restrictedand at 90send_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 reachesdegraded30 days andrestricted60 days after its last check-in, not 7 days later (the grace countdown starts at about 23 days), and neversend_suspended. A key on another machine isdegradedat once and stays there. -
degraded, days 30–60: new mailboxes and domains are refused with402 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: everythingdegradedrefuses, 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 pastrestricted, 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
activewhatever 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 anotherSECRET_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 insend_suspendedfor 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,daysRemaininguntil the next one, andlapse,billingortechnical). -
A fingerprint mismatch is a technical
degradedfrom 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
mailctland written to.envasMACHINE_FINGERPRINTatinstall, everyupgradeandrestore, and bylicense transferwhen it differs (from0.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 nextupgradeorrestore, and the licence must be transferred. The upgrade is refused at the registry and puts.envback, old value included. Its message names both values and says to runmailctl license transfer, then the upgrade again.transferrecords this host’s value and restartsapiandworkeron it, so the api sees the mismatch and moves the licence. This works from0.4.2; with0.4.1the transfer answered “already here” and support moved the licence. A licence bound to a container’s value before0.4.1is unbound by support before the upgrade to0.4.1, not after.
-
-
What each stage refuses. The one code for “your licence lapsed” is
402 license_degradedat every stage, distinct from402 license_limit_reached; its message names the stage and what is refused. Fromdegraded:POST /domains,POST /domains/:id/mailboxesandPOST /migrations/:id/precreate(the same mailbox gate, from a migration); every AI call, the admin copilot’s included (GET /ai/availabilityanswerslicence_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 /tokenswithmessages:send; other tokens, and tokens already issued, are untouched). Every webmail user sees a notice fromdegradedon (MailAccount.licenseNotice). Fromrestricted, 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 (aPATCHwhose every field is one of those), deactivating an admin-console user (aPATCH /users/:idcarrying onlyactive), 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 (POSTandPUT /ai/opt-in, which are not administration); andPOST /messages, which is the next stage’s. Fromsend_suspended(billing lapses only), mail leaving the server is refused at submission: Postfix’ssubmissionandsmtpsservices 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 with554 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 as422 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 answers202, the messagequeued, with itserrornaming 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’sreplyandrejectactions relay throughpostfix: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 ownnoreply@<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_exemptbefore 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,restrictedandsend_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_noticeholds 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 todegraded, so its warnings are the evaluation’s last week and then each stage. It goes fromnoreply@<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 thesend_suspendedwarning 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 holdsnoreply@<first domain>, nothing is sent and the API logs it once; with a customer’s ownnoreply@mailbox there, which the send stop does not exempt, thesend_suspendedwarning 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 asnoreply@: 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.yamlpasses it through from.env): reports{ licenseId, key, fingerprint, usage, version }toLICENSE_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 movelastHeartbeatAt: that clock not advancing is the entire degrade mechanism. -
Registry login (
mailctl): before pulling,installandupgradeexchangeLICENSE_IDfor short-lived credentials at/v1/registry/tokenanddocker loginwith them. An install with noLICENSE_IDskips 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. Duringupgradea refusal exits non-zero rather than restarting the stack on the images it already has and reporting success.upgradealso sends the installed key, read from the database throughpsqland opened with.env’sSECRET_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 withkey_required;mailctlthen says to runmailctl license activate <key>first. A server on0.3.0or earlier sends no key, so it cannot claim an unbound licence (the licence server refuses it withkey_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, andGET /licensealso admitsdomain_admin): the licence page and, above every admin screen, a banner that appears forevaluation,graceand 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/licenseendpoints, so the CLI and the admin page cannot describe a server differently:statusprints the state, the reason, the plan, usage against its limits, both machine IDs and the last check-in. It exits 1 fromdegradedon —evaluationandgraceblock 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 torestricted, delivery, IMAP, POP3, webmail and sending; atsend_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 recordsLICENSE_IDin the install’s.env— not because it enforces anything, but becauseinstallandupgradeexchange it for registry credentials, and a hand-activated server whose.envstill said nothing would fail its next image pull for no visible reason.transfermoves this subscription onto this machine. On the install’s host, once it has credentials — an owner’s, checked before anything changes, an explicit--tokenincluded — it records this host’s machine ID in.envwhen it differs, and restartsapiandworkeron 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 — noLICENSE_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.--keystill 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
ownertoken, because a leaked one is a full takeover.--tokenandMAILDOCK_TOKENare still read and still checked for theownerrole, for a shell with no TTY, but they are not the scripted path. Nothing is cached to disk.
-
Limits:
maxMailboxescounts non-systemmailboxes (the DMARC ingestion mailbox is not billable);maxDomainscounts every domain, including deactivated ones.0means unlimited. Exceeding a limit returns402 license_limit_reached— distinct fromlicense_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_URLis 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.appis the shipped default (apps/cli/src/lib/install-dir.ts,defaultEnv);--license-serverorMAILDOCK_LICENSE_SERVERoverrides it. A test asserts the default is a host on the product domain: it once readlicense.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/transferwith 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. Until0.4.3it was the lowest MAC of every interface, which on a Docker host is often a container’sveth, so the value changed whenever containers were recreated. The firstmailctlrun on0.4.3therefore computes a new value: a bound licence is refused at that upgrade, which names both IDs, and onemailctl license transfersettles it. It is computed bymailctland fixed in.envuntil the nextinstall,upgrade,restoreorlicense 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 nomailctlran, 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.