Both runners carry `light` (host1, and host2 over the wg-hosts link), so
these jobs run on whichever host is free. Jobs that mount the docker socket
keep `runs-on: docker`, which only host1 has.
The builder on the host's network (the last change here) didn't help: the
next publish failed exactly as before. Looking on the host showed why.
Both builders resolve git.coffeylabs.org publicly; the token isn't fetched
by the builder at all. buildx fetches registry tokens on the client side,
in the job container, and on ci-net the name git.coffeylabs.org belongs to
the gitlab container itself (172.30.0.2) -- which is how the runner clones
over plain HTTP, and which has nothing on 443. So every push asked
https://git.coffeylabs.org/jwt/auth for a token and was refused. The login
before it worked because the host's daemon does the login, and the host
resolves the name publicly.
For the publish job only, the name now points at its public address in the
job's /etc/hosts, looked up from a public resolver, as the host sees it.
/etc/hosts wins over Docker's DNS, and nothing else in the job is affected:
the checkout is done, and image layers go to the registry's own DNS-only
name, not this one. The builder goes back to the shared ci-builder; its
network was never the problem.
The lookup and the /etc/hosts write were tried in the job's own image
(docker:28-cli, same digest): it picks the first public IPv4 address and
getent then returns it.
The first release's image never reached the registry. Both platforms
built, then the push failed:
failed to fetch oauth token: Post "https://git.coffeylabs.org/jwt/auth":
dial tcp 172.30.0.2:443: connect: connection refused
buildx's docker-container builder is a container of its own on the host's
daemon, and it does the push, token and all. On the network it was
created on, git.coffeylabs.org resolves to an internal address with
nothing listening on 443. The job's own `docker login` worked because it
goes through the host daemon, which resolves the name publicly.
ihasmail's publish failed the same way this morning (job 513), so this is
the runners, not this pipeline.
The builder now runs on the host's network, so it resolves the name as
the login does. Only the token request goes to git.coffeylabs.org; image
layers still go to registry.coffeylabs.org, the registry's DNS-only name.
It gets a new name, ci-builder-host: `ci-builder` is a long-lived
container shared between jobs, and `create || use` would keep reusing it
on its old network.
A card above the tenant form: whether legacy mail protocols are on or off
for the organization, a one-click "Turn legacy protocols back on", and
"Turn off legacy protocols…", which opens -- before anything can change --
the impact panel with the tenant's own people (LP-15), the statement at
tenant scope and the typed confirmation (LP-16, LP-17). The statement reads
"everyone in {organization}" and names no ports and no firewall: a
tenant's switch closes nothing, other tenants share the ports (LP-13).
It reads and turns inbuxa:TenantProtocolPolicy, with the domain
permissions the server checks for it. It shows on the read-only tenant page
too: a tenant administrator reads its tenant without changing it (MT-12)
and may still turn the switch. Turning it back on while the server has
legacy protocols off is refused by the server, and the refusal is shown.
The banner (LP-18) falls back to the tenant's switch where the server's
can't be read -- inside a tenant -- and reads "off for your
organization".
The impact panel, statement and confirmation move to parts.tsx, shared by
Hardening and the tenant card; the statement takes its scope.
Checked by hand against a local server with a tenant, two of its users and
the admin signed in over IMAP: the tenant card's panel lists the tenant's
two users and not the admin; the statement reads "everyone in Example Co"
with no ports or firewall; turning it off makes the tenant's user get "NO
[ALERT] Your organization allows only INBUXA webmail and JMAP apps" over
IMAP; one click turns it back on; Hardening's panel lists all three. Not
checked by hand: the banner as a tenant administrator sees it.
The impact panel, above the statement, shown before anything can change:
"3 accounts used a legacy mail app in the last 30 days. Their mail apps
will stop working the moment you turn this on:", then one line per
account with every protocol it used and how long ago it last did, most
recent first. With nobody, it says so in one line. While the switch is
off it isn't shown -- there is nothing left to warn about.
It reads recentLegacyUse from inbuxa:ProtocolPolicy, which the server
fills from one timestamp per account and protocol (inbuxa-server
feat/legacy-use-panel). A server without it gets no panel at all rather
than a false "nobody": null and an empty list are kept apart.
Protocols read as the table names them (IMAP, POP3, ManageSieve, SMTP
submission), and "2 days ago" comes from Intl.RelativeTimeFormat in the
reader's language.
Ports release.yml and publish.yml, the two workflows left behind when the
GitHub account was suspended; nothing has released INBUXA Admin since.
weekly-release keeps release.yml's decision unchanged: on a schedule that
sets RELEASE_WEEKLY=1, count the commits on main since the newest published
release, stop if there are none, and otherwise pick today's version
(YYYY.M.D, .2 and on for a second release in a day), commit it to main in
inbuxa-version.json and cut the release at that commit. DRY_RUN=1 stops
after the decision.
What changes is how it writes. GitHub's job pushed the bump with
GITHUB_TOKEN; here the bump goes through the commits API with RELEASE_TOKEN,
and last_commit_id pins it to the commit the job counted from, so a main
that moved underneath makes the job fail rather than release something the
notes don't describe. The release and its tag go through the releases API
with the same token, which as an ordinary push starts a tag pipeline --
replacing publish.yml's workflow_call, which only existed because a
GITHUB_TOKEN release raises no event.
publish runs in that tag pipeline, after the build job has built and
tested the tag again. It pushes a two-architecture image (arm64 under QEMU,
as ihasmail's does) to the project registry,
registry.coffeylabs.org/inbuxa/inbuxa-admin, tagged with the version and
latest. Only date tags publish, not the inherited v1.0.x, and a tag whose
commit's inbuxa-version.json says anything else is refused, so an image
never reports a version other than its tag.
Tag lookups use git show-ref, after ihasmail's port found rev-parse
--verify reading some tag names as describe output on the git in these
images.
Needs, on the project: a RELEASE_TOKEN variable (project access token,
Maintainer, api scope, protected and masked) whose role may push to main,
and a pipeline schedule on main, Mondays 09:37 UTC, setting
RELEASE_WEEKLY=1.
The screen for inbuxa:ProtocolPolicy, the server-wide switch that closes
IMAP, POP3 and ManageSieve (legacy-protocols spec). Reached as
CustomComponent/LegacyProtocols, which the server's schema places under
Settings › Security; a server without that link never shows it.
- The selector lists every mail protocol, with what the switch does to
each and on which ports. SMTP and JMAP are shown locked, from the
server's lockedProtocols rather than a list carried here, so unlocking
later needs no admin release (LP-21).
- The statement is shown in full before the switch moves and while it is
off, with the listeners that close by name and port, and the note that
firewall rules and port-forwards are the operator's to reconcile
(LP-16, LP-20).
- Turning it off takes the typed phrase "turn off legacy mail", matched
exactly. Turning it back on is one click (LP-17).
- Listeners that could not be reopened stay listed, with a Try again
(LP-5).
- A banner on the Security settings and the dashboard while it is off
(LP-18). It stays silent on a server without the switch.
Visible to whoever may see listeners, changeable by whoever may update
them, matching the permissions the server checks.
Not here yet: the impact panel (LP-15), which needs the server to record
last use per protocol, and the tenant switch (LP-9 to LP-14).
Ports .github/workflows/ci.yml after the GitHub account was suspended and
Actions stopped being reachable. Same checks, same order, with the image
pinned by digest in place of the workflow's SHA-pinned actions.
cleanup.yml is not ported: it pruned GHCR through an action, and GitLab
keeps that as a container registry cleanup policy on the project rather
than as a pipeline. publish.yml and release.yml are larger and follow
separately.
The Actions workflows stay in the tree as the reference.
.gitignore blanket-ignores dotfiles, so .gitlab-ci.yml is negated there the
same way .github already is.
It had none: no security policy, no contributing guide, no code of conduct,
no sponsor link, and no CI. A public repository with an administrative
interface in it should at least say where to send a vulnerability, so that
part names what is worth reporting here specifically -- a session acting
beyond its permissions, one tenant's data reaching another, a token landing
somewhere that outlives the session -- and where a report goes if it turns
out to belong to the server or to upstream.
CI is what a contributor can run: typecheck, lint, test, build. Nothing in
it needs a live server, so a red run means the code, not the runner.
Publishing the source is what asks for it: a modified version has to carry
prominent notices saying it was modified, with a date. These files already
added a Coffey Labs copyright line beside upstream's, which implies as much
without saying it; now they say it.
38 files, found by diffing against the merge base with upstream rather than
by guessing. Upstream's own notices are untouched. The build is unchanged.
The old web UI put everything in a sidebar, and a reskin of it would
still read as the old web UI. The modern shell splits navigation in two
instead: the layout switcher already in the top bar is tier one, and a
new bar under it carries that layout's own top-level items, each group
opening its children in a menu. Nothing on the left, so a queue table or
a dashboard gets the whole window.
- Management's nine entries and Account's eleven suit a menu bar; the
bar measures its items once per layout and folds whatever doesn't fit
into "More", recomputing on resize from the cached widths.
- Settings keeps the sidebar. Nineteen groups is a configuration browser,
not a set of destinations, and a menu bar stops helping however wide
the window; the threshold is on the count, not on the name, so a
changed schema can't strand anyone.
- Which shell to use is the reader's, beside the theme: user menu,
Layout, Modern or Legacy. Modern is the default, and the choice is
remembered in the browser rather than with the account — settings.json
is shared with the webmail, which has no notion of this.
- A phone is unchanged. The section bar is hidden below md and the
sidebar stays as the slide-over behind the hamburger.
- The tree walking both shells need moves to lib/navTree.ts, so the
sidebar and the section bar agree on what is visible, what is locked
and what is active.
VITE_REFRESH_TOKEN, when set next to VITE_ACCESS_TOKEN, is handed to the
admin with the server's token endpoint, so the ordinary refresh keeps a
dev session alive instead of it ending when the access token expires.
- A domain's name in any list opens a card on hover: whether it's taking
mail, how many people it has, which of its key records (mail routing,
SPF, DKIM, DMARC) are live in public DNS, and whether DNS, signing keys
and certificates are automatic.
- A person's shows their name, storage against their quota, role, groups
and when they joined. The server keeps no last sign-in on the account,
so the card doesn't claim one.
- Cards load on open and are cached for a minute.
- Option tooltips end with the option's default, and an option set away
from its default gets a small "changed" mark.
- Each option's explanation moves from a line of text under its label to
a small tooltip on an ⓘ beside it, so forms read calmer and the help is
still one hover away.
- Help text is ours where written (src/help/texts.ts: domains, people,
DNS providers, blocked addresses, and the main pages), and the schema's
description elsewhere.
- A "?" on every list and form opens a side panel: what the page is for,
what people usually do there, and every option explained.
- Every tooltip and panel carries a stable help id (x:Domain.dnsManagement,
x:Domain), and manual.ts turns an id into a link to the admin manual
once one is configured (VITE_MANUAL_URL, or <meta name="manual-url">).
Until then no link shows.
- Cards and charts link to the page they're about: pending messages to the
queue, bans to blocked IPs, report warnings to the reports, and so on,
shown only to viewers who may open that page.
- A one-line status under the greeting: what needs a look (failed tasks,
messages retrying, recipients given up on) or, when nothing does, what's
there. Each phrase is a link.
- Counts from the server's own objects stand in for live metrics it can't
report, and a live number with no source reads as unknown, not zero.
- Who uses the space: a treemap of people sized by storage, colored by how
near their quota they are, each tile opening the account.
- Where mail is waiting: queued recipients by destination, split into
waiting, retrying and given up, each row opening the filtered queue.
- The weekly rhythm: messages by hour and weekday, shown once metric
history exists.
- Dashboard tabs are titled by their label.
A job that has a wizard now asks "Guide me / I'll do it myself" each time
it starts; nothing is remembered. The shared wizard shell gives every guide
a stepper, a side panel on what each step does and how to undo it, and the
way forward or back.
Automatic DNS, from a domain's DNS section:
- finds where the domain's DNS is hosted from its SOA and NS records, and
offers that host when the server can drive it;
- for the major hosts, steps to create the narrowest credential, and the
field named as the steps name it;
- records grouped by what they do, TLSA off unless the zone is signed;
- saves the provider and switches the domain over, removing the provider
again if the switch fails;
- watches the publishing task and public DNS, ticking each record green,
and boils a host's refusal down to its distinct messages;
- for hosts it can't drive, or domains not in DNS yet, every record laid
out for copying, with the same live checks.
The palette and light/dark choice now live in the account's JMAP Files
(ihasmail/settings.json), the same file and keys INBUXA webmail uses, so
the theme follows the user across devices and both apps. The admin
reads it at sign-in and writes only palette, mode and the derived
legacy theme, after a fresh read, keeping every other key. A palette
change keeps the stored mode. localStorage stays as the first-paint
cache.
Under the user menu, Theme offers ihasmail's palettes, in the same order and
under the same names. ihasmail is the default look, the colors the admin
already had, until someone picks another. The choice is remembered and
applied before the first paint, and dark mode still toggles on its own.
The other palettes' colors are ihasmail's, already contrast-checked, mapped
onto the admin's tokens by scripts/import-palettes.py; re-running it re-syncs
them. NOTICE carries the palettes' MIT notices and the fonts' OFL notices.
It greets the account by its full name where it has one, and otherwise by
the name it signs in with. The line beneath says who is signed in. The account
is looked up by its local part and matched on the whole address, so a
same-named account on another domain can't answer. The username is kept from
the JMAP session and cleared on sign-out.
Management, Settings and Account move to the top bar, as icons with tooltips
beside the theme toggle. The sidebar opens with its area's name and the
collapse toggle, in both states. The source link lives in the user menu, the
sign-in card and the version tooltip, so the sidebar no longer repeats it.
- The INBUXA palette on warm surfaces, softer cards, buttons and inputs, and
self-hosted Inter and Space Grotesk.
- Each section's icon sits on a colored tile, colored by what the section is
about.
- The sidebar gets a guide line for sub-pages and a labeled Management /
Settings / Account switch, and folds to a rail of tiles (remembered).
- Every page has a header with its section's tile.
- Fields and pages with no label of their own are spelled out in words
(defaultCertificateId becomes Default certificate ID).
- The dashboard greets you, and its stat cards wear colored tiles.
- Unavailable live numbers are a calm note, not an error.
- The cat appears in empty lists and while loading.
- Chart colors work again: they were hex values wrapped in hsl().
Every build writes the exact tree it was built from, uncommitted work
included, as source.tar.gz next to the app, and names that tree. The sidebar,
user menu, sign-in card and version tooltip link to it.
Coffey Labs' copyright line is added below Stalwart Labs' in every inherited
file changed, and the new files carry Coffey Labs' alone.
The upgrade prompt and its links are gone, the edition tooltip is neutral,
storage keys and the package description are INBUXA's own, and the README
states the lineage once, in the fine print.
INBUXA Admin talks to one known server whose own page signs in every account,
so it reads the server's OpenID configuration instead of looking endpoints up
per account. The login card now only shows while redirecting, or to retry
when that fails.
inbuxa-version.json holds INBUXA's version. package.json keeps upstream's so
its bumps merge cleanly. The top bar reads "INBUXA Admin 2026.9.18 (WebUI 1.0.11)".
- One edition: whatever edition the server reports, nothing is hidden or
disabled as Enterprise-only (accountStore.setAccountInfo).
- INBUXA branding: name, logo (mark plus a text-colored wordmark), favicon,
page titles, setup wizard text, and ihasmail's palette in light and dark.
- Two-factor setup names INBUXA as the issuer and drops the image parameter
that made authenticator apps fetch a logo from a third-party site.
- Runs apart from the server: the server address can be set at deploy time
with <meta name="api-base-url">, and OAuth endpoints the server returns as
relative paths are resolved against the server's address, not the page's.
Verified end to end against a separate inbuxa-server.
- Upstream's release workflow moved to .github-upstream so it never runs.
Upstream's history contains no Enterprise-only code, so this is an ordinary
fork. upstream is a fetch-only remote.