write_reload_target sent AllowedIp writes to the blocked-IP reload, but
that reload rebuilds only BlockedIps. Allowed IPs are parsed into the
core's security settings (Security::parse), which only a full reload
rebuilds, so an AllowedIp write reported x:settingsReload applied: true
while the change wasn't live until the next full reload.
AllowedIp now maps to the full reload, like the other settings objects;
BlockedIp keeps its targeted reload.
system::auto_reload::settings_reload_tests now creates an allowed IP
over JMAP and checks that is_ip_allowed sees it with no ReloadSettings,
and that destroying it takes it out again. On main it fails ("allowed
IP not in the running settings").
A 3-node rehearsal (PostgreSQL + NATS + Garage) found two ways a crash
leaves work stuck:
Pool hangs. The PostgreSQL pool (deadpool) was built with no timeouts,
so a request waited for a free connection, and for one to be opened or
recycled, for as long as it took: forever when the server stopped
answering. MySQL's pool (mysql_async) has no wait timeout at all.
- PostgreSQL: wait 30 s (or the store's timeout if longer), create the
store's timeout or 15 s (it bounds the whole handshake, where
tokio-postgres's connect_timeout covers only the TCP connect), recycle
10 s. The pool config is now always set, not only with
poolMaxConnections.
- MySQL: every connection is taken through MysqlStore::conn(), which
gives up after 30 s.
- Both: TCP keepalive after 60 s idle, so a server that vanished
without closing the connection is noticed in minutes rather than the
two-hour system default.
The DataStore schema has no pool timeout settings, so these are fixed
defaults; the store's own timeout bounds connecting on PostgreSQL.
Task locks. A task lock lasted an hour, so after a hard crash the dead
node's tasks waited up to an hour and five minutes. The lock is now a
five-minute lease: while this node runs a task, the task manager renews
its lock every third of the lifetime (InMemoryStore::renew_lock, a
compare-and-set on the store backends and SET XX EX on Redis, which
leaves a lock that already expired alone). A killed node's tasks run
elsewhere within about five minutes plus the claim recheck. A task this
node holds isn't handed to a worker again by the scan.
store::pool_timeout (new): a local listener that accepts connections
and never answers plays a hung server; a PostgreSQL store with a 2 s
timeout returns an error in about 4 s, and a MySQL store in 30 s.
Without the timeouts both wait for good. store::task_locks gains a
task held for 1.5 lock lifetimes: its lease is still held, and released
when the task ends.
A 3-node rehearsal found taskQueueProcessing didn't filter anything:
roles.task_manager only decided whether the task manager started, and
report, ACME, DKIM, DNS, calendar, thread-merge and restore tasks ran on
any node with a task manager (manager.rs returned true for them). A node
whose role left taskQueueProcessing off still ran them if it indexed or
did maintenance.
Every task type now answers to one ClusterTaskType (task_enabled):
- IndexDocument, UnindexDocument, IndexTrace: searchIndexing
- AccountMaintenance, TenantMaintenance, DestroyAccount:
accountMaintenance
- StoreMaintenance: storeMaintenance
- SpamFilterMaintenance: spamClassifierTraining
- DmarcReport, TlsReport: outboundMta. They build and send reports to
other domains (TLS reports can go straight to an HTTPS endpoint),
which is the outbound MTA's business.
- CalendarAlarmEmail, CalendarAlarmNotification, CalendarItipMessage,
MergeThreads, RestoreArchivedItem, AcmeRenewal, DkimManagement,
DnsManagement: taskQueueProcessing, the role for queue tasks with no
role of their own.
A node that may not run a task leaves it unclaimed (no lock), so a node
that may picks it up. The task manager also starts on a node whose only
task role is outboundMta, so reports still run there.
cluster::task_roles::task_role_tests (new, two task managers over one
PostgreSQL store): node A (taskQueueProcessing only) runs a DNS task and
leaves an unindex task and a TLS report pending; node B (searchIndexing
and outboundMta) comes up and runs those two; a DNS task scheduled next
stays pending on B and runs on A. On main node A runs the TLS report.
A 3-node rehearsal found that saving an MtaDeliverySchedule left it
unknown to the queue ("Queue strategy not found") until someone ran
x:Action ReloadSettings; only Directory and Authentication writes
reloaded (DIR-17). The admin UI has to remember a separate reload after
every save, and a script or API client that doesn't gets a server
running stale settings.
x:<Object>/set now reloads the running settings when it created,
updated or destroyed an object they are built from, and broadcasts the
same RegistryChange::Reload over the coordinator as ReloadSettings, so
every node applies it:
- Settings objects (MTA, spam filter, listeners, tracers, Sieve system
scripts, cluster roles, directories, ...: the object types the core,
telemetry, listener and directory builders read) get a full reload.
- Certificates, lookup stores and blocked/allowed IPs get their own
targeted reloads.
- Accounts, domains, roles and other data read as needed, stores (they
take a restart) and applications (their own reload action) get none.
Full reloads are coalesced: a write waits for a reload that started
after it was stored and joins one if it can, so a burst of writes, or
a request with many objects, costs one or two reloads, not one each.
The write itself is never undone. When the reload is refused (build
errors in objects that were working, the rule from the previous
commit), the set response says so in a new x:settingsReload field,
{"applied": false, "description": "Saved, but the running settings
were not reloaded. <object>: <error>"}; {"applied": true} otherwise.
The field is absent when the write needs no reload. The description
helper is shared with ReloadSettings' refusal.
Each reload sends the queue a ReloadSettings event, so the SMTP test
harness's read_event, try_read_event and assert_no_events now pass over
those; expect_reload_settings still waits for one.
system::auto_reload::settings_reload_tests (new): an MtaVirtualQueue
and an MtaDeliverySchedule created over JMAP are in the running
settings with no ReloadSettings, and gone once destroyed; eight
concurrent creates all land; a write whose reload fails is stored and
reported applied: false with the error; a domain write carries no
x:settingsReload. On main the new schedule is missing. The cluster
broadcast test (three nodes, PostgreSQL + NATS) now checks that every
node has a schedule created on node 0 without a reload.
A 3-node rehearsal found every settings reload refused, cluster-wide,
because one node couldn't resolve the Pyzor server:
- PyzorConfig::parse resolved the host while building the settings and
made a failed lookup a build error. It now keeps the host and port and
resolves when a message is checked (an IP address is used as is, a
name is reused for five minutes, the lookup counts against the Pyzor
timeout). A failure there is a Pyzor error for that message.
- A milter's hostname was resolved the same way, with a blocking
to_socket_addrs in async code. An IP address is kept; a name is now
resolved on each connection.
Other build-time I/O is already non-fatal: directories that can't
connect become unavailable with a warning (DIR-21), and the AI model
locality check only warns.
reload_registry swapped the core only when the whole build was free of
errors, while boot runs with whatever built. One failing object thus
refused every later reload, and the running settings went stale. Now a
reload is refused only for errors in objects that built when the
running settings were built (at boot or by the last applied reload):
applying it would lose those. Objects that already failed then are
missing from the running settings anyway, as at boot, so their errors
are logged and returned as known_errors but don't hold the reload back.
Refusing on new errors keeps a bad edit from taking a working object
out of service; the admin gets the error instead.
ReloadSettings now says "Settings were not reloaded." and names the
object and its error ("Tracer with id ...: Only one console tracer is
allowed"), with a count of any further errors. A refused reload after a
directory change logs its errors too.
system::reload::reload_tests (new): with Pyzor enabled on an
unresolvable host, ReloadSettings succeeds (on main it fails with
"Invalid address: failed to lookup address information"); an IP host
needs no lookup; a new build error refuses the reload, names the object
and leaves the running settings unchanged; the same error, once known
from the running settings' build, no longer blocks; once fixed, a new
error there blocks again. smtp::inbound::milter's session test now
names its milter "localhost", so the connect-time lookup is exercised.
A 3-node PostgreSQL rehearsal found IMAP SEARCH FROM "noreply" matched
0-2 messages where RocksDB matched 23 of 930. The message indexer hands
each address and display name of From/To/Cc/Bcc to the search store as
keyword text (Language::None). The built-in index splits keyword text
into lowercase runs of alphanumerics, so an address is found by its full
form, its local part, its domain or a display-name word. The SQL
backends didn't:
- PostgreSQL's text parser keeps "[email protected]" as one email
token (host names and URLs likewise), so neither "noreply" nor
"amazon.com" ever matched it. Keyword text is now split the same way
as the built-in index (SpaceTokenizer) before to_tsvector on insert
and before plainto_tsquery/phraseto_tsquery on search, still under
the 'simple' configuration, so the GIN index keeps serving the query.
The sort columns keep the raw text.
- MySQL's FULLTEXT parser already splits on punctuation, but InnoDB
never indexes its stopwords ("com", "de", "www", ...) or words under
innodb_ft_min_token_size (3), and a required +word it hasn't indexed
matches no row. So "amazon.com", "[email protected]" or "jane doe" found
nothing. Those words are now matched with a word-boundary REGEXP on
the rows the indexed words select. In language text (bodies,
subjects) they are dropped when other words remain, and only checked
when nothing else is left, so "the invoice" no longer finds nothing
either.
Existing PostgreSQL search indexes hold the old single-token vectors and
need a reindex (the reindexAccounts task) before address searches find
old messages. MySQL needs none: only the query changed.
store::search_tests gains test_address_search: five messages, 28
FROM/TO/CC/BCC searches by full address, local part, domain, domain
labels, display name and hyphenated local part, plus a TEXT-style OR,
with the same expected ids on every backend. It passes on RocksDB,
SQLite, PostgreSQL and MySQL; on main it fails on PostgreSQL (From
"noreply") and MySQL (From "[email protected]").
A node that started while NATS was down never got a coordinator. The
connect failed at boot, bootstrap recorded a build error and the node ran
with Coordinator::None until restarted. It had no broadcast subscriber
or publisher, so cross-node push and cache invalidation to it stayed
broken, and its healthcheck said nothing about it. Losing NATS after
startup was silent too.
- The NATS client now connects in the background
(retry_on_initial_connect): startup never waits on NATS or fails over
it, the node gets its coordinator, subscriber and publisher at once,
and the client keeps trying (async-nats's backoff, at most 4 s apart)
until NATS answers. Subscriptions made meanwhile start delivering when
it does. A configured maxReconnects still ends the attempts.
- Three new events report the connection: cluster.coordinator-connected
(info), cluster.coordinator-disconnected (warn: lost, closed, gave up,
or not connected within the connection timeout at startup) and
cluster.coordinator-error (warn: a failed attempt, reported once per
outage rather than every retry, and server errors, slow consumers and
lame duck mode). They are in the packaged schema, ids 644 to 646.
- GET /healthz/cluster reports the coordinator: 200
{"coordinator":"connected"}, 503 {"coordinator":"disconnected"}, or
200 with "none" (no coordinator) or "unknown" (a backend that doesn't
track its connection). /healthz/live and /healthz/ready are unchanged
on purpose: a node without its coordinator still serves mail, and
failing those would have orchestrators restart, or pull out of
service, every node at once whenever NATS is down.
Only NATS connects lazily; the other coordinator backends still fail at
boot as before.
cluster::coordinator::coordinator_reconnect_tests starts a node against a
NATS port with nothing behind it, checks it boots with a coordinator and
reports it disconnected, subscribes, then starts NATS on that port: the
node connects on its own and the subscription receives a message from a
second client. Stopping and restarting NATS shows disconnected, then
connected, and the same subscription keeps working.
A cluster rehearsal (PostgreSQL + NATS) left index tasks pending well
past the one-hour task lock after the node that claimed them was stopped
or killed. The exact cause there isn't confirmed; this closes every path
found in the task manager that stretches a takeover past the lock, or
keeps a task claimed without running it:
- A graceful stop never released the locks it held, so every task the
node had claimed stayed blocked for an hour. The server now tracks the
locks it holds (common::ipc::TaskLocks) and, once the shutdown signal
arrives, stops claiming and releases them before exiting.
- A node that failed to claim a task (another node held it) set its own
local hold for a full lock lifetime from that scan. If the holder
claimed it just after the scan began, or ran on a clock ahead, that
hold ran out a moment before the lock did and was set for another
hour: two hours in all. Such claims are now tried again every five
minutes (a twelfth of the lock lifetime), and the task manager wakes
up for them: before, a node without a coordinator could sleep up to
five minutes past the recheck, or until something else woke it.
- A worker that panicked took its task type down on that node for good,
while the scan kept claiming that type's tasks and failing to hand them
over, re-taking each lock as it expired and so starving every other
node of them. Each batch now runs on a task of its own; a panic is
logged, the batch's locks are released and the worker carries on. A
failed hand-over releases the lock too.
- A claimed task the worker couldn't read, or found gone, kept its lock
for the hour. It is released.
- An IndexDocument task for a file (not indexed) returned no result,
which shifted every later result in the batch onto the wrong task in
update_tasks. It returns Ignored. Nothing queues such a task today.
The lock lifetime stays one hour; it now lives per server so the tests
can shorten it.
store::task_locks::task_lock_tests plays a second node by writing its
locks straight into the in-memory store: tasks it claimed and abandoned
run here once its locks expire, including locks that outlive this node's
view of them, and a graceful stop hands this node's locks back at once
and claims nothing more. It passes on RocksDB, SQLite and PostgreSQL.
With the old recheck it fails.
The trace index task wrote the event type (its name) and the queue id as
text, but the tracing search index types both as integers on every
backend: BIGINT on PostgreSQL and MySQL, long on Elasticsearch. On
PostgreSQL every batch holding a trace document failed with "cannot
convert between the Rust type String and the Postgres type int8", and
since a batch writes trace and email documents together, email indexing
stalled behind it.
The document is now built by trace_search_document(), which writes:
- the event type as the opening event's numeric id, the event
x:Trace/query's event filter already matches on;
- the queue id as an integer, the first one the trace names;
- every queue id into the keywords as well, since the column holds one
value and an SMTP session can queue several messages.
index_keyword() replaced the field on every call, so before this only the
last event type and queue id survived anyway.
x:Trace/query's queueId filter parses the id (a string, or now a number)
and matches the column or the keywords, so a session is found by any of
its queue ids on every backend. The monitoring spec says what is indexed.
Traces indexed before this on the built-in index keep their text values;
the reindexTelemetry maintenance task rebuilds them.
Tests: the search store suite builds trace documents with the index
task's code, indexes them and finds them by queue id, event type and
keyword (Sqlite, PostgreSQL, MySQL); the monitoring suite finds a real
trace by queueId through x:Trace/query.
The broadcast subscriber waited 1 << retry_count.max(6) seconds between
failed subscribe attempts. max(6) turns the cap into a floor: the first
retry waited 64 s instead of 1 s, and each later one doubled without a
bound (and would overflow the shift after enough failures).
The delay now comes from subscribe_retry_delay(), 1 s, 2 s, 4 s ... capped
at 64 s, and the retry counter saturates. A unit test pins the schedule
and the top of the range.
--export skipped three things, so a move from one database to another
(RocksDB to PostgreSQL, say) lost them without a word:
- archived items (subspace j), the records behind undelete;
- spam training samples (subspace w);
- the trained spam classifier and its trainer state, blobs stored under
fixed names that no blob link points at, so the walk over links never
reached them.
j and w now travel with the registry family, where their indexes and id
counters already were, so EXPORT_TYPES=registry keeps them consistent.
The two named blobs travel with the blob family. The file format is
unchanged and import reads any subspace it is given, so an export made
by an older binary still imports.
The full-text index (subspace z) stays out, on purpose. It belongs to one
search backend: PostgreSQL and MySQL index into their own tables and have
no z table at all, and external engines keep the index themselves. So
--import now returns the subspaces it wrote, and boot queues the
reindexAccounts and reindexTelemetry store maintenance tasks, the same
ones an administrator can queue by hand, to rebuild the index for
whichever search store the server runs with once it starts.
The round trip also turned up a loss in import itself: the SQL stores
add a negative amount with an UPDATE, which does nothing to a row that
isn't there yet, so every negative counter or quota vanished on import
into PostgreSQL, MySQL or SQLite. Import now creates the row first.
The in-memory subspaces (m, y) stay out: rate limits, locks, greylisting,
ACME challenge tokens and OAuth codes, all short-lived. Issued
certificates are registry objects and travel.
The store test now writes archived items, spam samples, directory
entries, the fork's own subspace and the named blobs, checks they come
back in place, then imports the same export into a fresh store of the
other local backend (RocksDB to SQLite, or SQLite to RocksDB), compares
it key for key and counter for counter, and checks the queued reindex.
It fails on the old export code ("Subspace j was not exported").
--help now says what an export holds.
Replaces 2026.9.24, whose tag predates the image build fix (#27) and never
published. Carries everything 2026.9.24 did -- upstream 0.16.23 and its
fixes, the scim release-profile fix -- and since then:
- identifiers renamed from the upstream name, with no aliases: the JMAP
registry capability is urn:inbuxa:jmap:registry, WebDAV tokens
urn:inbuxa:dav*, Sieve extensions vnd.inbuxa.*, the web interface client
inbuxa-webui; INBUXA_* settings only. Deploy with admin and webmail
releases that use the new names.
- the brand in lowercase where people see it.
- the spam filter rules bundled with the server; on first start they add
the AI classifier's LLM_* scores.
- a Local AI page link in Settings › Spam Filter, for the admin release
that draws it.
- two start-up migrations: the spam model moves to its renamed keys, and
the web interface's old OAuth client is retired.
The server fetched upstream's latest published rules from GitHub at run
time: a version nobody here tested, code-like expressions from an account
we don't control, and the upstream name as a default in the admin form.
The published rules of spam-filter v3.0.2 are now embedded
(resources/spam-filter/, MIT, in THIRD-PARTY.md) and used whenever no other
source is configured. An empty setting and upstream's old default both mean
the bundled rules, so existing installs switch without a settings change;
the URL stays an operator override (https:// or file://). The schema default
is dropped and its description says what empty means, and the strip's
rename pass does the same to each import.
Rules load on first boot as before, and again whenever the bundled version
differs from the last one loaded, which only adds missing rules and tags.
That brings the AI classifier's LLM_* scores to installs that predate them:
production has none today.
upstream-watch now also opens an issue when spam-filter publishes a newer
release; resources/spam-filter/README.md says how to take it.
The antispam test now runs on the bundled rules, the path production
takes; SPAM_RULES_URL tests another set. Unit tests cover the URL handling
and that the bundled rules parse and score the AI tags as the AI spec says.
v2026.9.24 was tagged on a commit CI had passed, and its release build could
not compile crates/scim at all:
error: queries overflow the depth limit!
= note: query depth increased by 130 when computing layout of
{async fn body of context::<impl ...>::writable_domain()}
The crate is ours, and the failure is profile-dependent: the release profile
computes those async fn layouts in one go and goes past rustc's default query
depth, while the dev profile never gets that far. CI builds dev, so CI was
green on a commit that could not be released. The tag produced no image and
no release, which is the one merciful part.
Two changes:
- #![recursion_limit = "256"] on the crate, which is what rustc itself
suggests, with a note saying why it only shows up in release. Proved by
building -p scim in release locally: it now finishes.
- CI builds the release profile too, on pushes to main. Pull requests stay
on dev, where the wait is worth less. A few minutes per merge is cheaper
than learning this from a tag, which throws away a multi-architecture
build and leaves a version half-cut.
It failed everywhere but upstream's machines, for two reasons:
- The spam rules, which carry every score, came from a path on an
upstream developer's own disk. Without SPAM_RULES_URL none loaded, every
score was 0.00 and the combined case came out ham instead of spam at
13.70. The published rules of spam-filter v3.0.2 are now pinned beside
the test cases (Apache-2.0 or MIT, taken as MIT; in THIRD-PARTY.md).
SPAM_RULES_URL still overrides.
- The first combined case expects a Pyzor hit, and its digest (that of an
empty body) wasn't among the three the test mode answers, so it went to
a public Pyzor server: it failed offline and would drift with that
server's counts. Test mode now answers every digest from a fixed table,
with the empty body's added, and never reaches the network.
The test passes online and offline, alone and with the rest of the SMTP
tests. queue_retry, unrelated, still fails when it runs after the others
in one process, though it passes alone every time.
The name is inbuxa, lowercase, like the wordmark; INBUXA reads as an
acronym. The admin and webmail already changed. Here that's everything
the server shows people: the brand macro behind the protocol greetings,
the HTTP and SCIM realms, the startup banner and the calendar and contact
PRODID; the first-party OAuth client descriptions; the legacy-protocol
refusals; the default calendar and address book names and the SMTP
greeting default, in the code and the schema served to the admin
(checksum regenerated); startup and shutdown events; the User-Agent;
the sign-in and RSVP pages; the service units; the OpenAPI realm; the
crate descriptions and the README, where it's set in bold.
Identifiers that are uppercase for their own reasons stay: INBUXA_*
settings, SUBSPACE_INBUXA. So do code comments and the AGPL 5(a) notice
lines.
Tests follow: the IMAP ID name, the default collection names, the PRODID
in the iTIP fixtures and the CalDAV free-busy expectations, and the e2e
legacy-protocol refusals. The webdav, imap and jmap suites pass, so do
the unit tests of every crate touched, and 73 of 75 SMTP tests; of the
other two, antispam fails on main too, and queue_retry is a timing flake
that passes on its own.
Carries upstream 0.16.23 -- the DSN, POP3, Sieve, DMARC-report, ACME and
DNSSEC-resolver fixes in its own change log -- with the files it changed
marked under AGPL section 5(a), and one upstream test dropped that the fork's
routing makes meaningless.
It is also the first release whose tag attaches binaries: a host install can
now fetch inbuxa-linux-amd64.tar.gz or inbuxa-linux-arm64.tar.gz instead of
pulling the image and copying the file out of it.
Everything clients, users and operators meet now carries the fork's name,
with no aliases (SPEC.md §2.4, changed here from "protocol identifiers
stay"):
- JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside
the fork's own urn:inbuxa:jmap.
- WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once.
- Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these
into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in;
a unit test fails if Cargo.lock ever moves past the vendored copy. The
trusted runtime now names itself too, rather than answering sieve-rs's
default.
- The web interface's OAuth client is inbuxa-webui. On every start the old
stalwart-webui client is removed and any application naming it is moved
over.
- The spam filter's blobs are INBUXA_SPAM_*; every start moves any left
under the old keys, so a trained model survives.
- SQL stores and log files default to inbuxa, in the code and in the
schema served to the admin (checksum regenerated).
- Settings are INBUXA_* only. A STALWART_* variable that's set where its
INBUXA_* one isn't stops the server at startup, naming it.
- The version-upgrade messages link docs.inbuxa.org's migration page, and
the OpenAPI description, smtp crate metadata and web-push test fixtures
lose the name.
Kept on purpose, allowlisted with reasons: the OAuth key-derivation
contexts (renaming them would end every session and invalidate every
sealed client id) and the hashed application prefix.
Also fixes a latent start-up failure: ensure_client updated an existing
first-party client with a revision of 0, which the registry's assertion
never matches, so adding a redirect URI or changing the webmail secret
failed start-up. And the principal session test now expects
legacyProtocols (C-1, added 2026-09-21), which it had missed.
Tested: the server builds without warnings; common's 106 unit tests,
including the vendoring check; a new integration test for the two
start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
tests/src/directory/issuer.rs, new in v0.16.23, tests routing a bearer token
to a directory by its issuer. That routing is Enterprise-only upstream (the
body of get_directory_for_issuer), and the fork doesn't build it: a token
naming no address gets the server default (DIR-2). The test also calls a
helper from upstream's Enterprise-only OIDC test, so it can't compile here.
mta.rs imported types::id::Id for code inside an Enterprise snippet; the
stripped tree leaves it unused, upstream's as well as ours.
These upstream files were changed after the fork marked the files it had
modified, and never got the notice: six by the listener and schema-cache
work on 2026-09-20, two by the name check. Found by diffing against the
upstream snapshot branch, as before.
Five conflicts, resolved:
- crates/common/src/auth/authentication.rs: upstream's get_directory_for_token
and JwtClaims replace extract_jwt_domain; the per-domain directory code
(DIR-1, DIR-5 to DIR-7) is kept, and the token lookup routes through it.
The release's one new Enterprise snippet was the body of
get_directory_for_issuer, which stays returning None: a token naming no
address gets the server default, as DIR-2 specifies and as v0.16.22 did.
- crates/common/src/manager/application.rs: upstream's rewrite of the tests,
with the temp directory names renamed again, and the 5(a) notice the
name-purge change should have added.
- crates/common/src/network/mta.rs: both sides' imports.
- crates/main/Cargo.toml: the AGPL-only license kept, version 0.16.23.
- Cargo.lock: upstream's, with the fork's crates added by Cargo.
tools/fork/name-check.py reads every string literal in crates/ (comments
and test directories skipped) and fails on any that carries the upstream
name without an entry in name-allowlist.txt. An upstream merge can bring
such strings in without a conflict, so it runs on every push and PR.
The first run found three the earlier sweeps missed, fixed here: the SMTP
HELP reply pointed at upstream's website (now brand_url!), the event
collector thread was named after upstream, and the FreeBSD default data
path still said /var/db/stalwart/ where Linux already had /var/lib/inbuxa/.
Two operator-visible defaults are allowlisted as open, pending a decision:
the log file prefix and the SQL stores' default database and user.
The first-party application descriptions and the telemetry service name and
instrumentation scope are shown to operators, and the unpacked-application
temp directory carried the name too.
Left alone deliberately: the OAuth key-derivation contexts (renaming them
would invalidate every sealed token and client id), the migration defaults
that read an upstream installation, links to upstream's upgrade guide, the
wire-protocol identifiers, and upstream's own license and templates.
brand_version_full! is user-visible -- --version, the startup banner, the
console, telemetry and the JMAP session's implementation field -- and the
name belongs only in copyright notices and the lineage line.
Deleting a tenant now also removes its stored inbuxa:TenantProtocolPolicy,
in the same place the registry's other per-type clean-ups run. Without it
the row outlived the tenant, and a tenant that later came to have the same
id would have started with legacy protocols off.
The e2e deletes a tenant whose switch a server administrator had turned
off, and would check that a new tenant with the same id starts with them
on. On this build the registry hands out a fresh id instead ("d" after
"c"), so the reuse -- and with it the removal -- isn't observable over
JMAP; the test says so rather than passing silently. The risk it guards
was therefore smaller than feared, and the change is mostly about not
leaving an orphaned row behind. All 72 checks pass.
The impact panel's data. Every successful sign-in over IMAP, POP3,
ManageSieve or SMTP AUTH records, per account and per protocol, one
timestamp -- nothing else: no address, no IP, no client. It is written at
most once an hour per account and protocol, so a mail app polling every
minute costs a read per sign-in and a write an hour. A record that can't be
written is logged and the sign-in goes ahead.
Both switches serve it as a read-only property, recentLegacyUse, as
wouldClose serves the confirmation: a list of {accountId, name, protocol,
lastUsedAt} for sign-ins in the last 30 days, most recent first.
inbuxa:ProtocolPolicy lists every account; inbuxa:TenantProtocolPolicy
lists only its tenant's own (MT-1). Accounts since deleted are left out. It
is computed only when the property is asked for.
The recording sits where the tenant check already runs once the account is
known, which becomes admit_legacy_session: refuse if the account's tenant
has legacy protocols off, otherwise record. A refused sign-in is never
recorded.
The spec leaves the interface to the implementation; a property on each
switch keeps the panel's data behind the same permission as the switch
itself, with no new object.
Unit tests hold the 30-day window to acceptance test 11 (three days ago
listed, forty not), the hourly throttle and the keys. The e2e proves on a
running server that the admin's IMAP and submission sign-ins are listed
with their time, that a second sign-in within the hour isn't written again,
and that a tenant's list holds its own user and nobody outside the tenant.
All 70 checks pass.
The urn:inbuxa:jmap capability on the signed-in principal's own account
gains legacyProtocols: "enabled" or "disabled", the stricter of the
server's switch and the account's tenant's (legacy-protocols spec,
Interfaces). It is what the webmail needs to tell someone why their phone's
mail app won't connect (LP-19), and it closes acceptance test 13.
contract.md's C-1 gains the line. It is an optional field added, which
C-3 says doesn't bump the contract version.
tests/e2e/legacy_protocols.py reads it back from the session on a running
server: enabled for the tenant's user while both switches are on, disabled
once its tenant turns legacy protocols off while an account outside the
tenant still reads enabled, disabled for everyone while the server switch
is off, and enabled again at the end. All 67 checks pass.
The tenant switch. A tenant's administrator turns legacy mail protocols
off for its own tenant, and from then on sign-in over IMAP, POP3,
ManageSieve and SMTP AUTH is refused for every address on the tenant's
domains, while every other domain on the server carries on. No port
closes, since other tenants share them (LP-13): it is one stored fact per
tenant, read at sign-in and when client configuration is answered.
inbuxa:TenantProtocolPolicy/get and /set, one per tenant, id the tenant's:
- Inside a tenant, a principal reaches only its own tenant's switch
(MT-1): /get with no ids answers with it, another tenant's is notFound
and can't be changed. At server level /get with no ids lists every
tenant's.
- Turning it off is always allowed. Turning it back on is refused with
forbidden, naming inbuxa:ProtocolPolicy, while the server has legacy
protocols off (LP-9).
- A change raises security.legacy-protocols-changed with policy = tenant,
the tenant's id, the new value and who made it (LP-14).
- It takes sysDomainGet and sysDomainUpdate, not the two new permissions
the spec names. The switch governs sign-in on the tenant's domains, so
whoever manages those domains may turn it -- and the default Tenant
Administrator role already holds both, where new permissions would reach
no role already stored on a server (MT-12's note), leaving today's
tenant administrators without the switch until someone edited their
role by hand. The same trade inbuxa:AiLimits and inbuxa:ProtocolPolicy
made. /query is not built yet; /get with no ids covers listing.
Sign-in (LP-10 to LP-12). Before the credentials are looked at, the name
given is resolved to its domain and the domain to its tenant, so a real
account and a made-up address on the domain get the same refusal, with a
right password or a wrong one, counted as no failed sign-in (LP-11). The
words are the spec's: "Your organization allows only INBUXA webmail and
JMAP apps...", in each protocol's form. A bearer token needn't name an
account, so after authentication the account's own tenant is checked too;
a token that named nobody can't slip past.
The refusal carries policy = tenant and the domain, not the tenant's id:
IMAP answers a command's tag from the Id key, so an error holding one was
sent under the wrong tag and the mail app hung waiting for its reply. The
first live run found that; a unit test now holds the refusal to it.
Client configuration (LP-14a). Autoconfig, autodiscover, PACC and the
suggested DNS records now ask whether legacy services are off for the
domain being answered for -- the server's switch, or the domain's
tenant's -- so a tenant's domains stop offering IMAP, POP3 and
submission while others still do.
tests/e2e/legacy_protocols.py builds a tenant with its own domain, a user
and a tenant administrator, and a second tenant, and proves on a running
server: the admin sees and changes only its own tenant's switch (test 10);
turning it off is an event (test 14); the tenant's user is refused over
IMAP with the right password and a wrong one, a made-up address on the
domain the same (tests 6, 7); POP3 and submission refuse in their own
forms and JMAP still works (test 8); an account on another domain signs in
normally (test 6); autoconfig drops IMAP for the tenant's domain only; with
the server off, the tenant can't turn it back on (test 9); and once back
on, the user signs in again. All 62 checks pass.
Turning legacy mail protocols off or back on raises
security.legacy-protocols-changed (id 643, info level, also in the packaged
schema), with the scope (policy = server), the new value, who made the
change (accountId), whether listeners closed or reopened (details), which
ones (listenerId), and -- only when a listener could not be put back --
which and why (reason).
It is raised in Server::set_protocol_policy rather than by the JMAP
method, so whatever turns the switch is reported. A /set that changes
nothing -- the switch already where it was asked to be, nothing to close
or reopen -- is not a change and raises nothing.
The event is never an error, but jmap's exhaustive map from security
events to HTTP errors has to name it; it joins the other two that can't
occur there. rustfmt now also wraps LP-6's two over-long lines in
enums_impl.rs, which it flagged along with this change's.
tests/e2e/legacy_protocols.py now gives the server a stdout tracer and
reads events from the container's log: turning the switch off is exactly
one event naming the scope, value, author and listeners closed; setting it
off again raises none; turning it on is one event naming the listeners
reopened. It also proves LP-6's side: seven refused submission sign-ins
are seven auth.legacy-protocol-refused events, and there is no auth.failed
or auth.too-many-attempts among them. All checks pass.
While the switch is off, the answers that tell a mail app where to connect
stop offering what the switch closed, so a new phone or desktop app is not
sent to a port that is shut or a sign-in that will be refused:
- Thunderbird-style autoconfig (/mail/config-v1.1.xml and its other
paths) and Outlook autodiscover leave out IMAP, POP3 and SMTP
submission.
- PACC (/.well-known/user-agent-configuration.json) offers JMAP, CalDAV,
CardDAV and WebDAV, and no IMAP, POP3, SMTP or ManageSieve. The document
is rendered once per configuration load, so the JMAP-only version is
rendered beside it and chosen per request; the _ua-auto-config digest in
the suggested zone follows, since it hashes the same document.
- The suggested zone publishes _imap, _imaps, _pop3, _pop3s, _submission
and _submissions with target "." -- "not offered", RFC 6186 section 3.4 --
the spec's decision, rather than dropping them: a client that looks is
told, and an automatically managed zone replaces the old records instead
of leaving them behind.
- It also drops the TLSA records for ports 993 and 995. A TLS pin for a
port the switch has closed advertises a service that is not there.
Submission's 465 keeps its record: the SMTP lock keeps that port open.
The switch is read per answer, as sign-in reads it, so every node agrees
the moment it turns. Inbound mail, MX records and the JMAP, CalDAV and
CardDAV answers are untouched.
tests/e2e/legacy_protocols.py checks all four on a running server: with the
switch on they offer IMAP, POP3 and SMTP (the control); while it is off
they offer none of them and every legacy SRV name has target "."; and once
it is back on, autoconfig and the zone read as they did before. All checks
pass.
While legacy mail protocols are off, x:NetworkListener/set refuses to
create a listener the switch would close, and refuses an update that would
turn an existing one into such a listener -- otherwise changing a
listener's protocol would walk straight past the check. The refusal is
invalidProperties on protocol (or on bind, for a submission listener once
SMTP is unlocked, since its port is what makes it one), and its description
names inbuxa:ProtocolPolicy and says to turn legacy protocols back on first.
The rule is the switch's own, listeners::closes, so what can't be added is
exactly what the switch would close: locked protocols (SMTP, LMTP, HTTP)
and the inbound port are never refused. Putting saved listeners back
(LP-5) writes through the registry, not /set, so it is unaffected.
The e2e changes with it. LP-6's check that an IMAP listener "created by
mistake" refuses sign-in can't be set up any more -- LP-4 is what stops
that listener existing -- so that step now proves test 4 instead: creating
an IMAP listener is refused, naming the policy; an SMTP listener can still
be created; and updating it to IMAP is refused. LP-6 stays proven live over
submission, and its IMAP wording by unit tests. All checks pass.
The second lock. While legacy mail protocols are off, a sign-in over IMAP,
POP3, ManageSieve or SMTP AUTH is refused for every account, so a listener
that exists by mistake -- or submission, which the SMTP lock keeps open --
still lets nobody in.
The check sits at the top of each protocol's sign-in, before the
credentials are looked at. So the answer is the same for a right password,
a wrong one and an account that doesn't exist; it isn't auth.failed, so it
counts nothing against the account and never feeds the auto-ban; and the
session stays open, since the mail app is being told, not thrown off.
Mail apps read the spec's words (LP-12, at server scope):
IMAP NO [ALERT] This server allows only INBUXA webmail and JMAP
apps. This mail app can't sign in.
POP3 -ERR [AUTH] ...the same...
ManageSieve NO "This server allows only INBUXA webmail and JMAP apps."
SMTP 535 5.7.0 This server allows only INBUXA webmail and JMAP
apps. This mail app can't send.
SMTP AUTH is refused on every SMTP listener, port 25 included: only mail
apps authenticate, so inbound delivery is untouched. LMTP is left alone.
The policy is read from the store on each sign-in rather than cached, so
every node of a cluster answers the same the moment the switch turns.
Each refusal raises a new event, auth.legacy-protocol-refused (id 642, info
level, also in the packaged schema), with the protocol as source, the
policy's scope and the domain -- never the account. The session adds the
listener and remote IP.
tests/e2e/legacy_protocols.py now also proves, on a running server: a
normal IMAP and submission sign-in works with the switch on, before and
after; while off, submission refuses the right password and six wrong ones
with the same words and without hanging up; and an IMAP listener created by
mistake while off refuses the right password, a wrong one and an account
that doesn't exist. All 33 checks pass. SMTP sign-ins in the script wait
out a second first: every connection arrives from Docker's gateway, and the
stock inbound throttle takes five a second from one IP.
Found while setting up the live check, which is the only place it could
have shown: every unit test passes without it.
spawn_restored_listeners re-parsed the listeners and spawned them, but
never bound their sockets. Binding is not part of parsing -- it happens in
bind_and_drop_priv, once, at startup -- so listen() would have failed on an
unbound socket and the port would have stayed shut while the policy
recorded it as reopened. LP-5 would have been a promise the server did not
keep, and the operator's only clue a log line.
bind() is now split out of bind_and_drop_priv and called on its own here.
It cannot be the whole of bind_and_drop_priv, because that also drops
privileges, which must happen once at startup and never again.
That split has a consequence worth stating: a listener on a port below 1024
cannot be bound again once privileges are gone. Ports 143 and 110 are the
realistic cases. Rather than leave such a listener parsed, spawned and
silently dead, the bind errors are read back and those listeners are
reported as needing a restart -- which is the "cannot be recreated" case
LP-5 already anticipated, and it stays saved for another try.
Re-parsing is also narrowed to the listeners being restored, so putting one
back cannot bind a port another listener already holds.
The switch is now reachable. /get and /set on a server-level singleton,
wired through jmap-proto the way inbuxa:AiLimits is: object, method names,
request and response variants, reference resolution and evaluation.
/set does not write the policy. It hands what was asked to
Server::set_protocol_policy, which applies the locks, moves the listener
objects and opens or closes their sockets, and reports what happened. So
the method cannot drift from what the switch actually does.
Two properties exist for the screen rather than the server. lockedProtocols
serves LP-21's locked set, so the selector renders SMTP and JMAP locked
from what the server says instead of a list the front end carries -- and
unlocking later needs no admin release. wouldClose answers LP-16: exactly
which listeners turning the switch on would close, by name and port, before
anything happens. It is computed against a hypothetical disabled policy, so
it reads the same whichever way the switch is set, and the registry is only
asked when the property was requested.
savedListeners, changedAt, changedBy and both of those are the server's to
say; a client that sets one gets invalidProperties naming it. closeSubmission
is different: locked, not immutable, so it is overruled rather than refused
and the response hands back what was really stored (false). JMAP already has
the place for that, the value beside an updated id.
Permissions reuse SysNetworkListenerGet and SysNetworkListenerUpdate rather
than adding to a schema-generated enum -- the same choice AiLimits made with
the classifier's. It also reads right: this takes listeners away and puts
them back, so whoever may edit a listener may turn the switch.
changedBy stores the account id, not the name, which survives a rename.
Still no screen, no sign-in refusal (LP-6) and no event (LP-8).
The join: the policy decides, features owns the listener objects,
ListenerControl owns the running sockets, and only Server has both.
Server::set_protocol_policy is what a click performs. It applies the locks
to what was asked before storing anything (LP-21), so what is recorded is
what the server allows. Closing removes each listener object and then stops
its socket; opening puts the object back and then spawns it. The order is
the point in both directions -- a socket stopped while its object remains
returns on the next restart, and a socket spawned before its object exists
has nothing to come back to.
saved_listeners is carried over from the stored policy rather than taken
from the request. A client never sets it, and a /set that omitted it would
otherwise lose the listeners still waiting to come back.
Putting a listener back has to bind a fresh socket, so it re-parses from
the registry -- the objects are already back by then -- rather than trying
to revive the saved one. Only main knows which session manager a protocol
wants, so it leaves a spawner behind at startup and spawn_listener is now
shared between that and the initial spawn. Without a spawner a restored
listener is reported as pending a restart rather than promised, which is
what the test servers will see.
A listener that cannot be put back does not stop the others and stays
saved for another try (LP-5).
Still nothing an operator can reach: no JMAP method calls this yet, and no
sign-in is refused. What it does do is close and reopen a port on a
running server, which is the part that did not exist this morning.
John, 2026-09-20: "SMTP and JMAP should be shown with the selector locked,
we want to prevent those two protocols from being shutdown for now."
The selector lists every mail protocol the server speaks, so the operator
sees the whole surface at once; SMTP and JMAP sit in it named and visibly
not switchable. JMAP was never closeable -- closing it locks everyone out
of their mail and the operator out of INBUXA Admin, with no way back but
the host -- and is now visibly so. SMTP is locked whole. LP-3 already
spared inbound on 25; this extends that to submission on 465 and 587,
which LP-1 would otherwise have closed by default.
So closeSubmission has no effect while the lock stands, and is forced to
false. A client that asks for true is not refused: the value is recorded,
overruled, and the overrule reported, because the field is specified and
the lock is meant to be temporary. is_locked() is consulted before
anything else in closes(), so no phrasing of a request reaches past it.
This costs the feature nothing. Submission's ports stay open and sign-in
over them is still refused once LP-6 lands, which is the case acceptance
test 2 already described: a mail app reaching 465 is told it cannot sign
in rather than finding nothing listening. The operator also keeps a port
they may well be forwarding, which is the LP-20 problem in miniature.
The locked set is a server constant the front ends read, not a list they
carry, so unlocking later is a server change and no admin release. The
LP-3 tests stay as they are, to keep it covered if the lock is lifted.
Recorded as LP-21, with acceptance tests 17 and 18.
LP-1 and LP-5, the registry half. close() removes every listener object
the policy closes, saving each one whole first; reopen() puts them back.
The switch removes the listener objects, not just their sockets. A stopped
socket returns on the next restart, which would reopen every port the
operator had just closed, and the operator would have no way to know. A
removed object stays removed, and a server that boots with the switch on
never spawns those listeners at all -- so there is no boot-time special
case to write or to forget.
LP-3 is decided here, on the object rather than the running socket, and
sees every address a listener binds: a submission listener that also binds
25 is inbound and stays. lmtp and http are never candidates.
A listener that cannot be put back does not stop the others; it comes back
with its reason and stays saved for another try (LP-5). A delete the
registry declines is reported as not removed, so the policy never claims a
port is closed while it is still accepting.
Stopping the running socket is still a separate step in common, which owns
the listener registry. Nothing calls any of this yet.
The server-wide legacy-protocols policy: the switch, whether submission
closes with it, the listeners taken away to honour it, and who last
changed it. Stored like inbuxa:AiLimits, as JSON in the fork's subspace,
so an unset field reads as its default and an old record still loads.
closes() is where LP-3 lives. imap, pop3 and manageSieve are named
outright; smtp is not, because an SMTP listener is inbound or submission
depending on its port and nothing else can tell them apart. A listener
bound to 25 is inbound whatever it is called, including one that also
binds 465, so it stays. http and lmtp are never candidates at all.
savedListeners keeps each listener's registry object whole rather than a
few fields of it. LP-5 promises the listeners come back exactly as they
were, and a listener carries proxy networks, TLS timeouts and socket
options that no one should have to re-derive -- a field this code has
never heard of has to survive the round trip too, and a test holds that.
The module is under security/ rather than beside the rebuilt features,
because this one is not a rebuild: upstream has nothing like it.
Still only a fact. Nothing reads this policy yet, so no port closes and
no sign-in is refused; the acting code needs the listener registry and
the config store, which live above this crate.
The legacy-protocols switch has to close the IMAP, POP3 and ManageSieve
ports and leave everything else accepting. The server could not do that.
Two findings from the source, both now recorded in the spec. A settings
reload never closes a port: cache/reload.rs parses the listeners only to
collect configuration errors and drops the result, and sockets are bound
once at startup through init.servers.spawn in main.rs. And there is only
one shutdown signal -- Listeners::spawn makes a single watch channel and
hands every listener a clone -- so the one thing the server could do was
stop all of them at once, port 25 included. That answers the spec's open
question 1, and the answer was neither of the two it offered.
So each listener gets its own channel. ListenerControl holds the sending
ends keyed by listener id; firing one breaks that accept loop, which drops
its TcpListener and closes the socket. The accept loop itself is unchanged
-- it already did the right thing, it just had no way to be told about one
listener. stop_matching takes a predicate and a keep list, because the
inbound listener shares its protocol with submission and telling them
apart is the caller's job (LP-3), not this registry's.
spawn_with_control is a second method rather than a change to spawn. The
registry owns the senders, so a dropped registry would stop every listener
at once; the four test callers pass no registry and keep the old shared
channel exactly as it was.
Whole-server shutdown now fires the per-listener channels too, since the
returned sender no longer reaches them.
No policy, no JMAP and no screen yet: this is only the mechanism, with
seven tests over stopping one, stopping many, sparing port 25 and sparing
submission. It closes no port on its own, and it does not touch the host's
firewall or any port-forward -- that is LP-20, and stays the operator's.