Author SHA1 Message Date
jcoffey-dev 4b585905d7 Nothing advertises the legacy protocols while they are off (LP-7)
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.
2026-09-21 10:41:20 -07:00
jcoffey-dev 7dfe4c8e70 No legacy listener can be added while the switch is off (LP-4)
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.
2026-09-21 10:06:28 -07:00
jcoffey-dev 04252000da Legacy sign-in is refused while the switch is off (LP-6)
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.
2026-09-21 09:49:40 -07:00
jcoffey-dev 3ce50abcaa Merge branch 'ci/gitlab-pipeline' into 'main'
Run CI on the self-hosted GitLab

See merge request inbuxa/inbuxa-server!1
2026-09-20 20:56:38 -07:00
jcoffey-dev 0fb98a6f4c Run CI on the self-hosted GitLab
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.
2026-09-20 20:17:24 -07:00
jcoffey-dev bc2ae32207 The weekly release lands its bump through a pull request
main is protected as of today -- no force-push, no deletion, and a pull
request with a green build to merge -- and GITHUB_TOKEN is not a bypass
actor. `git push origin HEAD:main` in the cut job would have been refused
from Monday, on a scheduled run nobody watches.

GitHub would not take the obvious fix. Adding the Actions integration as a
bypass actor is rejected ("must be part of the ruleset source or owner
organization") because the organization has no app installations. The
other two routes -- an organization-level ruleset, a deploy key with write
access -- both amount to handing the release a credential that outranks
the rule, which is a worse thing to own than a slower Monday.

So the bump lands the way every other change does. It commits to
release/v<version>, opens a pull request, waits for the build the ruleset
requires, merges, and tags what came out. The waiting is not merely the
rule being satisfied: a release cut from a tree that does not compile is
the failure this whole arrangement exists to prevent, and until now
nothing checked.

Three details that would each have produced a wrong tag. The sha comes
from GitHub's merge commit, not the tip that was pushed, because a rebase
merge rewrites it. The pull request is tracked by number, not by branch,
because the branch is deleted on merge and a deleted branch no longer
resolves to its pull request. And a failed or slow build leaves the pull
request open and cuts nothing, rather than tagging whatever main happened
to hold.

Quiet weeks are unaffected: the tag still names the bump commit, so
`previous..HEAD` is still zero when nothing else has landed.

The cost is a Monday run that now takes as long as a full build -- about
25 minutes at the moment, most of it saving the cache.
2026-09-20 16:53:29 -07:00
jcoffey-dev 8ffdeea85d Write down how a change reaches main, now that it is enforced
main has a ruleset as of today: no force-push, no deletion, and a pull
request with a green build to merge. CONTRIBUTING said nothing about any
of it, and a contributor's first clue would have been a rejected push.

No approving review is required. A review gate nobody can pass is not a
gate, and this is a project with one maintainer; the build is the part
that has to hold.

The section also says why the rule exists rather than only what it is.
The weekly release cuts from main on a Monday and ships whatever is there,
so main is expected to be releasable continuously -- which makes "not
finished" a thing that belongs behind a default-off switch or off main
altogether, not a state main passes through on a Thursday.

Administrators can bypass. That is written down as being for correcting
the tree, not for skipping the path, because an undocumented bypass
becomes the normal route.
2026-09-20 16:32:43 -07:00
jcoffey-dev b73aa13fa3 Prove the switch on a running server, not just in unit tests
tests/e2e/legacy_protocols.py boots the debug binary in a container, turns
the switch off and on, and checks the ports themselves. Everything below it
was unit-tested and none of it could have told us this worked.

What it establishes: IMAP and POP3 stop answering while inbound SMTP,
submission and JMAP keep going (LP-1, LP-2, LP-3); the listeners are saved
whole (LP-1); a restart does not reopen them, which is the point of taking
the objects away rather than only the sockets; both come back on their own
without a restart (LP-5); savedListeners empties; and asking to close
submission is overruled to false and reported, with 465 still answering
(LP-21, acceptance test 18). wouldClose named imaps, pop3s and sieve, and
those were exactly the three that closed (LP-16).

One caveat about the method, because it nearly produced a false pass in
reverse. A published Docker port accepts connections whether or not
anything is listening in the container, so connecting proves nothing. The
first run of this script reported IMAP still open after the switch, and
that was the script being wrong, not the server. Each port now has to
speak: a TLS handshake on 993, 995 and 465, a greeting on 25.

It lives under tests/ because target/ is ignored and this is worth keeping.
It derives its own root, needs Docker and a debug build, and clears its
state directory first -- a half-bootstrapped one from an earlier run is no
longer in bootstrap mode and the recovery admin stops working.
2026-09-20 15:55:49 -07:00
jcoffey-dev 3f689529c7 A listener put back has to be bound, or it never comes up
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.
2026-09-20 15:46:46 -07:00
jcoffey-dev f95f10809a Release weekly, and publish an image
The fork had CI and nothing after it. v2026.9.20 was tagged and released
by hand, and there has never been an image: running INBUXA meant
building the tree yourself, or using install.sh to do it for you.

This adds the three workflows ihasmail already runs -- weekly release,
publish, prune.

Monday 10:07 UTC, and nothing on a quiet week. Last of the three, so
INBUXA Admin and the webmail release ahead of the server they talk to,
and staggered so a bad Monday names one repository rather than three.

The version is the difference from ihasmail. ihasmail derives its
version from the commit it builds, so its release only reads. INBUXA's
lives in the brand_version! macro, deliberately apart from Cargo.toml so
upstream's bumps merge without conflicts -- so the release writes it:
the bump is committed to main and the tag names that commit. The tree a
tag points at therefore reports the version the tag claims, which a tag
placed beside an unbumped macro cannot promise.

Both the bump and the read are scoped to the macro body and fail if they
do not match exactly once. branding.rs holds other string literals, and
a bump that silently edited one of those, or an image tagged from one,
would be worse than a run that stops.

The existing Dockerfile needs nothing: it cross-compiles from
BUILDPLATFORM and takes no arguments beyond TARGETPLATFORM, so each
architecture builds on its own native runner as ihasmail's does, without
docker-bake.hcl. `docker build --check` is clean.

Two things to expect from the first run. GHCR creates a package private
the first time even in a public repository, and no workflow can change
that, so the first image will refuse an anonymous pull until its
visibility is set by hand. And a full Rust build of this tree is long;
the per-platform GitHub Actions cache is what keeps the second one from
being just as long, and it is worth watching that it stays inside the
cache limit.
2026-09-20 15:42:25 -07:00
jcoffey-dev 1b3ec64862 inbuxa:ProtocolPolicy over JMAP
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).
2026-09-20 15:38:32 -07:00
jcoffey-dev 3b29ca3571 The switch now reaches the running server
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.
2026-09-20 15:27:32 -07:00
jcoffey-dev 08f12fa158 SMTP and JMAP are locked open, and the selector will show them so
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.
2026-09-20 15:24:23 -07:00
jcoffey-dev f04dbc3417 Taking the legacy listeners away, and putting them back
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.
2026-09-20 15:19:08 -07:00
jcoffey-dev c35b24b123 inbuxa:ProtocolPolicy, the switch itself
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.
2026-09-20 15:12:45 -07:00
jcoffey-dev 33c529fd8a Cargo.lock: the base64 sequoia-openpgp actually resolves to
Left over from this morning's dependabot merges: the minor-and-patch group
freed sequoia-openpgp to use base64 0.22.1, but the lockfile still pinned
0.21.7 for it. Any cargo invocation rewrites the line, so it was showing up
as spurious drift in unrelated diffs.

No manifest changed and nothing is upgraded here; this only writes down
what cargo already resolves.
2026-09-20 15:10:47 -07:00
jcoffey-dev fee6b74e79 Listeners can be stopped one at a time, which LP-2 needs
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.
2026-09-20 15:10:41 -07:00
dependabot[bot] 3b68e27d3c Bump opentelemetry-otlp from 274b4d3 to 80a14a3
Bumps [opentelemetry-otlp](https://github.com/stalwartlabs/opentelemetry-rust) from `274b4d3` to `80a14a3`.
- [Commits](https://github.com/stalwartlabs/opentelemetry-rust/compare/274b4d324794280ce6f4def095a3428197a9e6e3...80a14a3b6846f62f85506d68d2600c948fccc9d2)

---
updated-dependencies:
- dependency-name: opentelemetry-otlp
  dependency-version: 80a14a3b6846f62f85506d68d2600c948fccc9d2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-20 14:24:46 -07:00
dependabot[bot] fac33548d1 Bump rocksdb from 0.24.0 to 0.25.0
Bumps [rocksdb](https://github.com/rust-rocksdb/rust-rocksdb) from 0.24.0 to 0.25.0.
- [Release notes](https://github.com/rust-rocksdb/rust-rocksdb/releases)
- [Changelog](https://github.com/rust-rocksdb/rust-rocksdb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-rocksdb/rust-rocksdb/compare/v0.24.0...v0.25.0)

---
updated-dependencies:
- dependency-name: rocksdb
  dependency-version: 0.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-20 14:24:02 -07:00
dependabot[bot] 94be824147 Bump the minor-and-patch group with 5 updates
Bumps the minor-and-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [rustls](https://github.com/rustls/rustls) | `0.23.44` | `0.23.45` |
| [calcard](https://github.com/stalwartlabs/calcard) | `0.3.13` | `0.3.14` |
| [jsonwebtoken](https://github.com/Keats/jsonwebtoken) | `11.0.0` | `11.1.0` |
| [tinyvec](https://github.com/Lokathor/tinyvec) | `1.13.2` | `1.13.3` |
| [ece](https://github.com/mozilla/rust-ece) | `2.3.1` | `2.4.2` |


Updates `rustls` from 0.23.44 to 0.23.45
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.44...v/0.23.45)

Updates `calcard` from 0.3.13 to 0.3.14
- [Changelog](https://github.com/stalwartlabs/calcard/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stalwartlabs/calcard/commits)

Updates `jsonwebtoken` from 11.0.0 to 11.1.0
- [Changelog](https://github.com/Keats/jsonwebtoken/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Keats/jsonwebtoken/compare/v11.0.0...v11.1.0)

Updates `tinyvec` from 1.13.2 to 1.13.3
- [Changelog](https://github.com/Lokathor/tinyvec/blob/main/changelog.md)
- [Commits](https://github.com/Lokathor/tinyvec/compare/v1.13.2...v1.13.3)

Updates `ece` from 2.3.1 to 2.4.2
- [Release notes](https://github.com/mozilla/rust-ece/releases)
- [Commits](https://github.com/mozilla/rust-ece/commits)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: calcard
  dependency-version: 0.3.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: jsonwebtoken
  dependency-version: 11.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: tinyvec
  dependency-version: 1.13.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: ece
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-20 14:23:21 -07:00
dependabot[bot] b39694f03b Bump Swatinem/rust-cache in the actions group
Bumps the actions group with 1 update: [Swatinem/rust-cache](https://github.com/swatinem/rust-cache).


Updates `Swatinem/rust-cache` from 49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c to 6323deb102c322ba6fcbdcafc7e3dddab59af2b6
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c...6323deb102c322ba6fcbdcafc7e3dddab59af2b6)

---
updated-dependencies:
- dependency-name: Swatinem/rust-cache
  dependency-version: 6323deb102c322ba6fcbdcafc7e3dddab59af2b6
  dependency-type: direct:production
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-20 14:22:45 -07:00
dependabot[bot] e490f515a1 Bump decancer from 3.3.3 to 4.0.0
Bumps [decancer](https://github.com/null8626/decancer) from 3.3.3 to 4.0.0.
- [Release notes](https://github.com/null8626/decancer/releases)
- [Commits](https://github.com/null8626/decancer/compare/v3.3.3...v4.0.0)

---
updated-dependencies:
- dependency-name: decancer
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-20 14:20:02 -07:00
jcoffey-dev b8a9d5a9d9 spam-filter: reach the &str by deref, not by str::as_str
decancer 4.0 changes CuredString's Deref target from String to str. That
is all it takes to break two call sites in the classifier: .as_str() used
to resolve to String::as_str through one deref, and now resolves to the
inherent str::as_str, which is still unstable (rust-lang #130366). Stable
rustc rejects it, so the whole crate fails to compile -- the two E0658s
that are currently red on the decancer bump in PR #4.

Neither call site wanted an inherent method, only a &str. Deref coercion
gives that under either target, so dropping the .as_str() fixes 4.0 and
keeps 3.3.3 building; cargo check passes against both. The result is
identical either way, so no behaviour changes here.

Committed against 3.3.3, which is still what the lockfile pins. The bump
itself stays PR #4's to carry, and rebases onto this.

Translation::String going from Cow<'static, str> to CuredString, the other
breaking change in the 4.0 notes, touches nothing: the type appears
nowhere in the tree.
2026-09-20 03:19:09 -07:00
50 changed files with 3513 additions and 121 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
# including one pushed by whoever compromises the account. Dependabot # including one pushed by whoever compromises the account. Dependabot
# updates both halves together -- do not "simplify" a pin back to a tag. # updates both halves together -- do not "simplify" a pin back to a tag.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2.9.2 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: System dependencies - name: System dependencies
# foundationdb and the search backends are off by default, but the # foundationdb and the search backends are off by default, but the
# default feature set still links against the system's C libraries. # default feature set still links against the system's C libraries.
+69
View File
@@ -0,0 +1,69 @@
# Prune old image versions from GHCR.
#
# Releases are kept forever -- they carry no assets and their generated notes
# are this project's only changelog, so deleting one destroys history that
# cannot be reconstructed for nothing saved. Images are the opposite: a
# multi-arch build a week, and the by-digest push in publish.yml leaves two
# untagged per-architecture manifests behind each time on top of the tagged
# index. Those accumulate and nobody wants fifty of them.
#
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
# `delete-only-untagged-versions` -- will happily delete the per-architecture
# manifests that a multi-arch tag points *at*, because they are untagged by
# design. Nothing appears to break: the tag still exists, and pulls simply
# start failing for one architecture. This action understands manifest lists
# and will not orphan a retained index, and `validate` re-checks every
# multi-arch manifest against the registry afterwards.
#
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
# exactly what would be deleted without rebuilding and re-pushing an image to
# find out.
name: Prune images
on:
workflow_call:
inputs:
dry_run:
type: boolean
default: false
workflow_dispatch:
inputs:
dry_run:
description: "List what would be deleted, delete nothing"
type: boolean
default: true
jobs:
prune:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
# The only third-party action here that is not published by GitHub or
# Docker, and the one with the most to lose: it is handed
# `packages: write` and its whole job is deletion, so a ref repointed at
# something else -- by a compromise or a mistake upstream -- is a bad
# day. It was pinned to a commit long before the rest of them were.
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
with:
owner: inbuxa
package: inbuxa-server
token: ${{ secrets.GITHUB_TOKEN }}
# Ten weekly releases is roughly a quarter of history, which is more
# than enough to roll back to and far less than the year's worth that
# would otherwise pile up. Older *releases* stay either way; this
# only removes the images.
keep-n-tagged: 10
# Belt and braces on top of the action's own manifest awareness:
# `latest` is never a candidate for deletion under any counting.
exclude-tags: latest
delete-untagged: true
# Sweeps the wreckage of a half-failed run: an index whose platform
# images did not all land, and referrers whose parent is gone.
delete-partial-images: true
delete-orphaned-images: true
# Checks every remaining multi-architecture manifest still resolves
# in the registry. This is the step that would catch the footgun
# above rather than leaving a reader to discover it on `docker pull`.
validate: true
dry-run: ${{ inputs.dry_run }}
+198
View File
@@ -0,0 +1,198 @@
# Publish the container image to GHCR.
#
# The README and the docs site have told people to run
# `ghcr.io/inbuxa/inbuxa-server:latest` for a long time, and nothing ever
# pushed it: `docker pull` answered `denied`, because the package did not
# exist. This is the workflow that makes those instructions true. It is also
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
# both install by pulling an image and neither builds from source.
#
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
# a public repository, and an anonymous `docker pull` will still answer
# `denied`. Nothing in a workflow can change that -- the visibility is set once
# by hand under the package's settings, and until it is, this looks like it
# worked while the docs stay just as wrong as before. Check with a logged-out
# pull, not with one from a machine that has credentials.
#
# Two architectures, each built on its own native runner rather than under
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
# instruction translation, which takes tens of minutes and occasionally runs
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
# the same work at native speed. The cost is the by-digest dance below: each
# runner pushes an untagged image, and a final job joins the two digests into
# one multi-arch tag.
name: Publish image
on:
release:
types: [published]
# Callable, so release.yml can build the release it just cut. This is not a
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
# `release` event -- GitHub refuses to let a token trigger another workflow,
# to stop a workflow looping on its own output. A scheduled job that cut a
# release and expected this file to notice would silently never publish. The
# alternatives are a personal access token kept as a secret, or calling the
# workflow directly. This is the one that needs no credential.
workflow_call:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
type: string
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
# orphans can be neither rerun nor canceled, and this workflow otherwise
# only fires on a release -- which is not something to cut twice because a
# runner died. `ref` also allows publishing an image for a tag that predates
# this workflow, which is how the first one gets built.
workflow_dispatch:
inputs:
ref:
description: "Tag, branch or SHA to build"
required: true
default: main
tag_latest:
description: "Also move :latest to this build"
type: boolean
default: false
env:
# Hardcoded rather than derived from github.repository, which would have to
# be lowercased to be a legal registry path. This is the string the docs name.
IMAGE: ghcr.io/inbuxa/inbuxa-server
jobs:
# The version is read once and handed to both builds, so the two
# architectures cannot disagree about what they are. It is read from the
# macro the binary itself compiles in, which the weekly release commits
# before this runs -- so the image is tagged with the version it reports.
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- id: v
run: |
set -euo pipefail
# Scoped to the macro body: branding.rs holds other string literals,
# and tagging an image from one of those would be worse than failing.
V="$(awk '/macro_rules! brand_version/,/^}/' crates/types/src/branding.rs \
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
# A date version carries nothing a Docker tag objects to, so there is
# no second, sanitized form of it here.
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "version $V"
build:
needs: version
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: ${{ matrix.platform }}
# Attestations are off deliberately: they add manifests of their own
# to the index, and `imagetools create` below expects the two entries
# it pushed rather than four.
provenance: false
sbom: false
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Save the digest
run: |
mkdir -p /tmp/digests
# The prefix is stripped here and put back in the merge job, so the
# filename is the bare hash. Leaving it on produces
# `image@sha256:sha256:...` when the reference is rebuilt.
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# One artifact per platform; the merge job globs them back together.
name: digest-${{ strategy.job-index }}
path: /tmp/digests/*
retention-days: 1
if-no-files-found: error
# Joins the per-architecture digests into a single tagged manifest, so
# `docker pull ghcr.io/inbuxa/inbuxa-server:<tag>` resolves on both.
publish:
needs: [version, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create the manifest
run: |
# Arrays rather than a string: the tags and the digest references
# have to reach docker as separate arguments, and building them by
# word-splitting an unquoted variable is the version of this that
# breaks the day a value contains a space.
tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}")
# :latest follows real releases only. A prerelease that moved it
# would hand every `:latest` deployment an unfinished build, and a
# dispatch run has to ask for it on purpose.
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
tags+=(-t "${IMAGE}:latest")
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
tags+=(-t "${IMAGE}:latest")
fi
refs=()
for f in /tmp/digests/*; do
refs+=("${IMAGE}@sha256:$(basename "$f")")
done
echo "tags: ${tags[*]}"
echo "refs: ${refs[*]}"
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
- name: Show what landed
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}"
# Runs only after a successful publish, because that is the only moment the
# package grows. See cleanup.yml for why this is not the obvious one-liner.
prune:
needs: publish
permissions:
packages: write
uses: ./.github/workflows/cleanup.yml
+246
View File
@@ -0,0 +1,246 @@
# Cut a release once a week, but only if there is something in it.
#
# It does nothing on a quiet week. A release with no commits in it is worse
# than no release: it moves `:latest` to an identical build, spends a version
# number, and mails everybody watching the repository about nothing.
#
# INBUXA's version is a string in crates/types/src/branding.rs, deliberately
# not in Cargo.toml so that upstream's version bumps merge without conflicts.
# So this writes it: the bump is committed to main, and the tag names that
# commit. The tree a tag points at therefore reports the version the tag
# claims, which a tag placed beside an unbumped macro cannot promise.
name: Weekly release
on:
schedule:
# Mondays, 10:07 UTC, and last of the three: INBUXA Admin and the webmail
# release ahead of the server they talk to. Staggered rather than
# simultaneous so three releases do not compete for runners, and so a bad
# Monday names one repository instead of three. GitHub runs scheduled jobs
# best-effort and can delay a run considerably, so the exact minute is not
# a promise; the odd minute keeps it off the crowded top of the hour.
#
# Note also that GitHub disables scheduled workflows in a repository with
# no activity for 60 days, which is worth checking for before assuming
# this file is broken.
- cron: "7 10 * * 1"
workflow_dispatch:
inputs:
dry_run:
description: "Work out what would be released, then stop"
type: boolean
default: false
# One at a time. Two overlapping runs would race to write the same version and
# create the same tag, and the loser fails noisily for a reason that has
# nothing to do with the code.
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_release: ${{ steps.decide.outputs.should_release }}
version: ${{ steps.decide.outputs.version }}
tag: ${{ steps.decide.outputs.tag }}
previous: ${{ steps.decide.outputs.previous }}
count: ${{ steps.decide.outputs.count }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- id: decide
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# The newest published release, or empty on a repository that has
# never had one -- in which case everything counts as new. Drafts are
# excluded: an unpublished draft is not a release anybody has, so
# counting from it would hide commits that have never shipped.
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
# A tag named by a release is normally present after a full checkout,
# but a release can outlive its tag. Falling back to the whole
# history is the safe direction to be wrong in: it over-counts, which
# cuts a release that was due anyway, where under-counting would skip
# one that was.
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
count="$(git rev-list --count "${previous}..HEAD")"
else
count="$(git rev-list --count HEAD)"
fi
# INBUXA's version is the date: YYYY.M.D, unpadded, as branding.rs
# documents. A second release on one day takes a `.N` suffix,
# counting from 2, which is why this asks the tags rather than
# assuming today is free.
today="$(date -u +%Y.%-m.%-d)"
version="$today"
n=2
while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do
version="${today}.${n}"
n=$((n + 1))
done
should_release=true
reason=""
if [ "$count" -eq 0 ]; then
should_release=false
reason="no commits since ${previous}"
fi
{
echo "should_release=$should_release"
echo "version=$version"
echo "tag=v${version}"
echo "previous=$previous"
echo "count=$count"
} >> "$GITHUB_OUTPUT"
# Written to the run summary so a skipped week reads as a decision
# rather than as a workflow that quietly did nothing.
{
echo "### Weekly release"
echo
if [ "$should_release" = "true" ]; then
echo "Releasing **v${version}** — ${count} commit(s) since ${previous:-the beginning}."
else
echo "Nothing to release: ${reason}."
fi
} >> "$GITHUB_STEP_SUMMARY"
cut:
needs: check
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
sha: ${{ steps.land.outputs.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
- id: bump
env:
VERSION: ${{ needs.check.outputs.version }}
BRANCH: release/v${{ needs.check.outputs.version }}
run: |
set -euo pipefail
# Scoped to the macro body rather than replacing the first quoted
# string in the file, and asserted to have matched exactly once.
# branding.rs holds other string literals, and a bump that silently
# edited one of those -- or none -- would ship a build whose version
# disagrees with its tag.
python3 - <<'PY'
import os, re
path = "crates/types/src/branding.rs"
src = open(path, encoding="utf-8").read()
pattern = re.compile(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")')
out, n = pattern.subn(lambda m: m.group(1) + os.environ["VERSION"] + m.group(2), src, count=1)
assert n == 1, f"brand_version! not found in {path}"
open(path, "w", encoding="utf-8").write(out)
PY
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add crates/types/src/branding.rs
git commit -m "Version ${VERSION}"
git push origin "HEAD:refs/heads/${BRANCH}"
# main is protected: it takes a pull request with a green build, and
# GITHUB_TOKEN is not among the bypass actors. So the bump lands the way
# every other change does. The alternative was to hand the release a
# credential that outranks the rule, which is a worse thing to own than
# a slower Monday.
- id: land
env:
VERSION: ${{ needs.check.outputs.version }}
BRANCH: release/v${{ needs.check.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
url="$(gh pr create --base main --head "${BRANCH}" \
--title "Version ${VERSION}" \
--body "Weekly release. Bumps \`brand_version!\` to ${VERSION} so the tag names a tree that reports the version the tag claims.")"
# The number, not the branch: the branch is deleted on merge, and a
# deleted branch no longer resolves to its pull request.
pr="${url##*/}"
echo "Opened #${pr}"
# The build is what the rule actually requires, and it is also the
# thing worth waiting for: a release cut from a tree that does not
# compile is the failure this whole arrangement exists to prevent.
# A full build of this tree is long, so the deadline is generous.
deadline=$(( SECONDS + 3600 ))
while :; do
state="$(gh pr view "${pr}" --json statusCheckRollup \
--jq '[.statusCheckRollup[]? | .conclusion // "PENDING"] | join(",")')"
case "${state}" in
*FAILURE*|*CANCELLED*|*TIMED_OUT*)
echo "::error::CI failed on ${BRANCH} (${state}); no release cut. PR #${pr} is left open."
exit 1 ;;
*SUCCESS*) break ;;
esac
if [ "${SECONDS}" -ge "${deadline}" ]; then
echo "::error::timed out waiting for CI on ${BRANCH}. PR #${pr} is left open."
exit 1
fi
sleep 30
done
gh pr merge "${pr}" --rebase --delete-branch
# A rebase merge rewrites the commit, so the sha to tag is the one
# GitHub recorded for the merge, not the tip that was pushed. It can
# take a moment to appear.
sha=""
for _ in $(seq 1 30); do
sha="$(gh pr view "${pr}" --json mergeCommit --jq '.mergeCommit.oid // ""')"
[ -n "${sha}" ] && break
sleep 5
done
if [ -z "${sha}" ]; then
echo "::error::#${pr} merged but GitHub reported no merge commit; nothing safe to tag."
exit 1
fi
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
args=(--target "${{ steps.land.outputs.sha }}"
--title "INBUXA ${{ needs.check.outputs.version }}"
--generate-notes)
# Bound the notes to what is actually new. Without a start tag the
# generator reaches back to whatever it decides is previous, which on
# a repository carrying upstream's tag shapes is not always the last
# release.
if [ -n "${{ needs.check.outputs.previous }}" ]; then
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
fi
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
# Called rather than left to the `release` trigger on purpose: see the note
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
# event, so without this the tag would exist and no image would follow it.
publish:
needs: [check, cut]
permissions:
contents: read
packages: write
uses: ./.github/workflows/publish.yml
with:
ref: ${{ needs.cut.outputs.sha }}
tag_latest: true
+1
View File
@@ -9,6 +9,7 @@ run.sh
!.gitignore !.gitignore
!.gitattributes !.gitattributes
!.github !.github
!.gitlab-ci.yml
CLAUDE.md CLAUDE.md
# The cutover rehearsal writes its fixture and state here. # The cutover rehearsal writes its fixture and state here.
+50
View File
@@ -0,0 +1,50 @@
# CI on the self-hosted GitLab, ported from .github/workflows/ci.yml when the
# GitHub account was suspended on 2026-09-20. The Actions file stays in the
# tree: it is the reference this was written from and works unchanged if the
# appeal succeeds.
#
# The image is pinned by digest, with its tag in the trailing comment. That
# replaces the SHA-pinned `uses:` in the workflow -- GitLab has no action
# allowlist, so the digest is the only thing fixing what actually runs.
#
# Not ported here:
# * cleanup.yml pruned GHCR with dataaxiom/ghcr-cleanup-action. GitLab has
# no equivalent action because it does not need one: the container
# registry has a cleanup policy on the project itself, which is where that
# job's settings now live.
# * publish.yml and release.yml still need doing; they are larger and are
# being handled separately.
stages: [build]
default:
interruptible: true
build:
stage: build
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
# This is a big workspace and a cold build is expensive, so the registry and
# the target directory are cached between runs. Both are kept inside the
# project directory because that is the only path the runner will cache --
# and deliberately not on /tmp, which on this host is a tmpfs that a Rust
# build of this size has filled before.
variables:
CARGO_HOME: "$CI_PROJECT_DIR/.cargo"
CARGO_TARGET_DIR: "$CI_PROJECT_DIR/target"
CARGO_INCREMENTAL: "0"
cache:
key:
files: [Cargo.lock]
paths:
- .cargo/registry/
- target/
before_script:
- apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null
script:
- cargo build -p inbuxa --locked
# --no-run: the workflow compiled every test target without running them,
# which catches a test that no longer builds without paying for the suite.
- cargo test --workspace --locked --no-run
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+19
View File
@@ -11,6 +11,25 @@ and it should be worth that.
Small fixes — a bug, a typo, a test — need no ceremony. Send them. Small fixes — a bug, a typo, a test — need no ceremony. Send them.
## How a change lands
`main` is protected. It cannot be force-pushed or deleted, and a change
reaches it through a pull request whose `build` check has passed. No approving
review is required — this is a small project and a gate nobody can pass is not
a gate — but the build is not optional.
So the shape of a change is: a branch, a pull request, a green CI run, a merge.
Branches are deleted on merge. Repository administrators can bypass the rule,
which exists so the maintainer can correct the tree quickly, not so that the
ordinary path can be skipped; use it for an emergency, not for convenience.
Releases are cut weekly from `main` by `.github/workflows/release.yml`, on
Monday morning UTC, and nothing is released on a quiet week. That is the reason
the rule matters: whatever is on `main` when the run starts is what ships, so
`main` is expected to be releasable at all times rather than at the end of a
piece of work. A change that is not finished should be behind something that
defaults to off, or it should not be on `main` yet.
## What this repository is ## What this repository is
INBUXA is a fork of Stalwart, taken under the AGPL-3.0-only half of its dual INBUXA is a fork of Stalwart, taken under the AGPL-3.0-only half of its dual
Generated
+44 -52
View File
@@ -597,12 +597,6 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -1041,16 +1035,16 @@ dependencies = [
[[package]] [[package]]
name = "calcard" name = "calcard"
version = "0.3.13" version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b779382e675380a1ff8a4873acee5ba60158be7a00458fb98e6b85aad1f2ee" checksum = "c601473ec15a875626bce73db1a1f0fc81e9c949ca25f1463b714947c0a70a1f"
dependencies = [ dependencies = [
"ahash", "ahash",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"hashify", "hashify",
"jmap-tools", "jmap-tools",
"mail-builder 0.5.0", "mail-builder 1.0.0",
"mail-parser", "mail-parser",
"rkyv", "rkyv",
"serde", "serde",
@@ -1987,11 +1981,10 @@ dependencies = [
[[package]] [[package]]
name = "decancer" name = "decancer"
version = "3.3.3" version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d" checksum = "453589e364ce786381e7bcbf0d659088ff7e4d3e77f948dd1cf861344acf7a86"
dependencies = [ dependencies = [
"lazy_static",
"regex", "regex",
] ]
@@ -2309,11 +2302,11 @@ dependencies = [
[[package]] [[package]]
name = "ece" name = "ece"
version = "2.3.1" version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746" checksum = "c2467bac73e5a36d75e16cab0fa8d40676f075db6afde7d78b35f033e1f66e37"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.22.1",
"byteorder", "byteorder",
"hex", "hex",
"hkdf 0.12.4", "hkdf 0.12.4",
@@ -2322,7 +2315,7 @@ dependencies = [
"openssl", "openssl",
"serde", "serde",
"sha2 0.10.9", "sha2 0.10.9",
"thiserror 1.0.69", "thiserror 2.0.20",
] ]
[[package]] [[package]]
@@ -3540,7 +3533,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.6.5", "socket2 0.5.10",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -4401,9 +4394,9 @@ dependencies = [
[[package]] [[package]]
name = "jsonwebtoken" name = "jsonwebtoken"
version = "11.0.0" version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"base64 0.22.1", "base64 0.22.1",
@@ -4616,9 +4609,9 @@ dependencies = [
[[package]] [[package]]
name = "librocksdb-sys" name = "librocksdb-sys"
version = "0.17.3+10.4.2" version = "0.19.0+11.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" checksum = "4f45e86edad8e88efe97dbf384b4e48e1ff0f111eabf154c7b09d7a1e5fb573c"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"bzip2-sys", "bzip2-sys",
@@ -4626,6 +4619,7 @@ dependencies = [
"libc", "libc",
"libz-sys", "libz-sys",
"lz4-sys", "lz4-sys",
"rustflags",
"zstd-sys", "zstd-sys",
] ]
@@ -5532,8 +5526,8 @@ dependencies = [
[[package]] [[package]]
name = "opentelemetry" name = "opentelemetry"
version = "0.31.0" version = "0.32.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink", "futures-sink",
@@ -5545,8 +5539,8 @@ dependencies = [
[[package]] [[package]]
name = "opentelemetry-http" name = "opentelemetry-http"
version = "0.31.0" version = "0.32.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
@@ -5557,10 +5551,11 @@ dependencies = [
[[package]] [[package]]
name = "opentelemetry-otlp" name = "opentelemetry-otlp"
version = "0.31.0" version = "0.32.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
dependencies = [ dependencies = [
"http 1.5.0", "http 1.5.0",
"httpdate",
"opentelemetry", "opentelemetry",
"opentelemetry-http", "opentelemetry-http",
"opentelemetry-proto", "opentelemetry-proto",
@@ -5575,8 +5570,8 @@ dependencies = [
[[package]] [[package]]
name = "opentelemetry-proto" name = "opentelemetry-proto"
version = "0.31.0" version = "0.32.0"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
dependencies = [ dependencies = [
"opentelemetry", "opentelemetry",
"opentelemetry_sdk", "opentelemetry_sdk",
@@ -5587,13 +5582,13 @@ dependencies = [
[[package]] [[package]]
name = "opentelemetry-semantic-conventions" name = "opentelemetry-semantic-conventions"
version = "0.31.0" version = "0.32.1"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
[[package]] [[package]]
name = "opentelemetry_sdk" name = "opentelemetry_sdk"
version = "0.31.0" version = "0.32.1"
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3" source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-executor", "futures-executor",
@@ -6265,7 +6260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"itertools 0.14.0", "itertools 0.13.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -6404,7 +6399,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2 0.6.5", "socket2 0.5.10",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"tracing", "tracing",
@@ -6445,7 +6440,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2 0.6.5", "socket2 0.5.10",
"tracing", "tracing",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -7070,9 +7065,9 @@ dependencies = [
[[package]] [[package]]
name = "rocksdb" name = "rocksdb"
version = "0.24.0" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f" checksum = "d8d90add70d1d420ee487bce4a1449880a8d147451c6051b2ee5f8354553dcbf"
dependencies = [ dependencies = [
"libc", "libc",
"librocksdb-sys", "librocksdb-sys",
@@ -7213,6 +7208,12 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustflags"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a39e0e9135d7a7208ee80aa4e3e4b88f0f5ad7be92153ed70686c38a03db2e63"
[[package]] [[package]]
name = "rusticata-macros" name = "rusticata-macros"
version = "4.1.0" version = "4.1.0"
@@ -7237,9 +7238,9 @@ dependencies = [
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.44" version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"log", "log",
@@ -7579,7 +7580,7 @@ dependencies = [
"sha2 0.10.9", "sha2 0.10.9",
"sha3 0.10.9", "sha3 0.10.9",
"slh-dsa", "slh-dsa",
"thiserror 2.0.20", "thiserror 1.0.69",
"twofish", "twofish",
"typenum", "typenum",
"x25519-dalek", "x25519-dalek",
@@ -8736,18 +8737,9 @@ dependencies = [
[[package]] [[package]]
name = "tinyvec" name = "tinyvec"
version = "1.13.2" version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tls-listener" name = "tls-listener"
+1 -1
View File
@@ -54,7 +54,7 @@ sha2 = "0.11"
md5 = "0.8.1" md5 = "0.8.1"
whatlang = "0.18" whatlang = "0.18"
idna = "1.1" idna = "1.1"
decancer = "3.3.3" decancer = "4.0.0"
unicode-security = "0.1.2" unicode-security = "0.1.2"
infer = "0.22" infer = "0.22"
bincode = { version = "2.0.1", features = ["serde"] } bincode = { version = "2.0.1", features = ["serde"] }
+2
View File
@@ -67,6 +67,7 @@ impl Data {
Data { Data {
spam_classifier: ArcSwap::from_pointee(SpamClassifier::default()), spam_classifier: ArcSwap::from_pointee(SpamClassifier::default()),
listener_control: Default::default(),
tls_certificates: ArcSwap::from_pointee(certificates), tls_certificates: ArcSwap::from_pointee(certificates),
tls_self_signed_cert: build_self_signed_cert( tls_self_signed_cert: build_self_signed_cert(
subject_names subject_names
@@ -222,6 +223,7 @@ impl Default for Data {
fn default() -> Self { fn default() -> Self {
Self { Self {
spam_classifier: Default::default(), spam_classifier: Default::default(),
listener_control: Default::default(),
tls_certificates: Default::default(), tls_certificates: Default::default(),
tls_self_signed_cert: Default::default(), tls_self_signed_cert: Default::default(),
blocked_ips: Default::default(), blocked_ips: Default::default(),
+25 -6
View File
@@ -47,6 +47,9 @@ pub struct Network {
#[derive(Clone)] #[derive(Clone)]
pub struct NetworkInfo { pub struct NetworkInfo {
pub pacc: Pacc, pub pacc: Pacc,
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
/// served while legacy protocols are off (legacy-protocols LP-7).
pub pacc_jmap_only: Pacc,
pub mxs: Vec<MailExchanger>, pub mxs: Vec<MailExchanger>,
pub services: VecMap<ServiceProtocol, Service>, pub services: VecMap<ServiceProtocol, Service>,
} }
@@ -320,11 +323,26 @@ impl Network {
} }
} }
let (prefix, suffix) = serde_json::to_string(&pacc) let split = |pacc: &Configuration| {
.unwrap_or_default() serde_json::to_string(pacc)
.rsplit_once(SPLIT_HERE) .unwrap_or_default()
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) .rsplit_once(SPLIT_HERE)
.unwrap(); .map(|(prefix, suffix)| Pacc {
prefix: prefix.to_string(),
suffix: suffix.to_string(),
})
.unwrap()
};
// inbuxa: legacy-protocols LP-7
let pacc_jmap_only = {
let mut pacc = pacc.clone();
pacc.protocols.imap = None;
pacc.protocols.pop3 = None;
pacc.protocols.smtp = None;
pacc.protocols.managesieve = None;
split(&pacc)
};
let pacc = split(&pacc);
let mut network = Network { let mut network = Network {
node_id: bp.node_id() as u64, node_id: bp.node_id() as u64,
server_name: default_hostname.to_string(), server_name: default_hostname.to_string(),
@@ -339,7 +357,8 @@ impl Network {
info: NetworkInfo { info: NetworkInfo {
mxs: system.mail_exchangers.into_iter().collect(), mxs: system.mail_exchangers.into_iter().collect(),
services: system.services, services: system.services,
pacc: Pacc { prefix, suffix }, pacc,
pacc_jmap_only,
}, },
}; };
+4
View File
@@ -150,6 +150,10 @@ pub struct Data {
pub blocked_ips: RwLock<BlockedIps>, pub blocked_ips: RwLock<BlockedIps>,
pub lookup_stores: ArcSwap<AHashMap<Box<str>, InMemoryStore>>, pub lookup_stores: ArcSwap<AHashMap<Box<str>, InMemoryStore>>,
// inbuxa: the running listeners and their shutdown switches, so one
// protocol's ports can close while the rest keep accepting (LP-2)
pub listener_control: crate::network::control::ListenerControl,
pub asn_geo_data: AsnGeoLookupData, pub asn_geo_data: AsnGeoLookupData,
pub jmap_id_gen: SnowflakeIdGenerator, pub jmap_id_gen: SnowflakeIdGenerator,
@@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Server, manager::application::Resource}; use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
use quick_xml::Reader; use quick_xml::Reader;
use quick_xml::XmlVersion; use quick_xml::XmlVersion;
use quick_xml::events::Event; use quick_xml::events::Event;
@@ -55,7 +57,12 @@ impl Server {
let _ = writeln!(&mut config, "\t\t<Account>"); let _ = writeln!(&mut config, "\t\t<Account>");
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>"); let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>"); let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
for (protocol, service) in &self.core.network.info.services { for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
continue;
}
let (protocol, ports) = match protocol { let (protocol, ports) = match protocol {
ServiceProtocol::Imap => ("IMAP", [143, 993]), ServiceProtocol::Imap => ("IMAP", [143, 993]),
ServiceProtocol::Pop3 => ("POP3", [110, 995]), ServiceProtocol::Pop3 => ("POP3", [110, 995]),
@@ -2,9 +2,11 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Server, manager::application::Resource}; use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
use registry::schema::enums::ServiceProtocol; use registry::schema::enums::ServiceProtocol;
use std::fmt::Write; use std::fmt::Write;
use utils::url_params::UrlParams; use utils::url_params::UrlParams;
@@ -28,6 +30,9 @@ impl Server {
("%EMAILADDRESS%", default_host.as_str()) ("%EMAILADDRESS%", default_host.as_str())
}; };
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
// Build XML response // Build XML response
let mut config = String::with_capacity(1024); let mut config = String::with_capacity(1024);
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"); config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
@@ -40,6 +45,9 @@ impl Server {
"\t\t<displayShortName>{domain}</displayShortName>" "\t\t<displayShortName>{domain}</displayShortName>"
); );
for (protocol, service) in &self.core.network.info.services { for (protocol, service) in &self.core.network.info.services {
if legacy_off && is_legacy_service(protocol) {
continue;
}
let (protocol, tag, ports) = match protocol { let (protocol, tag, ports) = match protocol {
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]), ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]), ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
+299
View File
@@ -0,0 +1,299 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Per-listener shutdown (legacy-protocols spec, LP-2).
//!
//! Upstream gives every listener a clone of one `watch` channel, so the only
//! shutdown signal that exists stops all of them at once — port 25 included.
//! That is enough for "stop the server" and no use at all for "close the IMAP
//! port and leave the rest running", which is what the legacy-protocols switch
//! needs.
//!
//! So each listener gets its own channel, and this registry holds the sending
//! ends, keyed by listener id. Firing one stops exactly one listener: the
//! accept loop in [`super::listen`] breaks and drops its `TcpListener`, which
//! closes the socket. Whole-server shutdown still works, by firing all of them
//! ([`ListenerControl::stop_all`]).
//!
//! What this does **not** do is touch the host's firewall, NAT port-forwards
//! or any proxy in front of the server (LP-20). Closing a listener means this
//! process stops answering; anything that still routes the port is the
//! operator's to reconcile, and is deliberately left alone.
use crate::config::server::{Listener, ServerProtocol};
use crate::network::TcpAcceptor;
use ahash::AHashMap;
use parking_lot::RwLock;
use std::sync::OnceLock;
use tokio::sync::watch;
/// How a listener is spawned. Only `main` knows how to build the session
/// manager for a protocol, so it leaves this behind at startup and the policy
/// uses it to put a listener back without a restart (LP-5).
pub type SpawnListener = Box<dyn Fn(Listener, TcpAcceptor, watch::Receiver<bool>) + Send + Sync>;
/// A listener that is currently accepting, and the switch that stops it.
struct Running {
protocol: ServerProtocol,
ports: Vec<u16>,
shutdown_tx: watch::Sender<bool>,
}
/// What a caller is told about a running listener.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListenerInfo {
pub id: String,
pub protocol: ServerProtocol,
pub ports: Vec<u16>,
}
/// The registry of running listeners and their shutdown switches.
#[derive(Default)]
pub struct ListenerControl {
running: RwLock<AHashMap<String, Running>>,
spawner: OnceLock<SpawnListener>,
}
impl ListenerControl {
/// Registers a listener about to be spawned, returning the receiver its
/// accept loop should select on.
pub fn register(
&self,
id: impl Into<String>,
protocol: ServerProtocol,
ports: Vec<u16>,
) -> watch::Receiver<bool> {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
self.running.write().insert(
id.into(),
Running {
protocol,
ports,
shutdown_tx,
},
);
shutdown_rx
}
/// Remembers how to spawn a listener, once, at startup. Later calls are
/// ignored, so nothing can swap the spawner out from under a running
/// server.
pub fn set_spawner(&self, spawner: SpawnListener) {
let _ = self.spawner.set(spawner);
}
/// Whether a spawner has been left behind. Without one, a listener can be
/// stopped but not started, and the caller has to say so rather than
/// promise a port that will not open until a restart.
pub fn can_spawn(&self) -> bool {
self.spawner.get().is_some()
}
/// Starts a listener and registers it, so it can be stopped again.
/// Returns false when no spawner was left behind.
pub fn spawn(&self, listener: Listener, acceptor: TcpAcceptor) -> bool {
let Some(spawner) = self.spawner.get() else {
return false;
};
let ports = listener.listeners.iter().map(|l| l.addr.port()).collect();
let shutdown_rx = self.register(listener.id.clone(), listener.protocol, ports);
spawner(listener, acceptor, shutdown_rx);
true
}
/// Stops one listener by id. Returns what was stopped, or `None` when no
/// listener of that id is running.
pub fn stop(&self, id: &str) -> Option<ListenerInfo> {
let running = self.running.write().remove(id).map(|running| {
let _ = running.shutdown_tx.send(true);
ListenerInfo {
id: id.to_string(),
protocol: running.protocol,
ports: running.ports,
}
});
running
}
/// Stops every running listener whose protocol `is_legacy` accepts, except
/// those whose id is in `keep`. Returns what was stopped.
///
/// The caller decides what counts as legacy, because the inbound SMTP
/// listener shares its protocol with submission and must never be stopped
/// (LP-3); `keep` is how it is spared.
pub fn stop_matching(
&self,
is_legacy: impl Fn(ServerProtocol, &[u16]) -> bool,
keep: &[String],
) -> Vec<ListenerInfo> {
let ids: Vec<String> = {
let running = self.running.read();
running
.iter()
.filter(|(id, listener)| {
!keep.contains(id) && is_legacy(listener.protocol, &listener.ports)
})
.map(|(id, _)| id.clone())
.collect()
};
ids.iter().filter_map(|id| self.stop(id)).collect()
}
/// Stops everything. This is whole-server shutdown, and replaces the single
/// shared channel upstream fired.
pub fn stop_all(&self) {
for (_, running) in self.running.write().drain() {
let _ = running.shutdown_tx.send(true);
}
}
/// Every listener currently accepting.
pub fn running(&self) -> Vec<ListenerInfo> {
let mut out: Vec<ListenerInfo> = self
.running
.read()
.iter()
.map(|(id, listener)| ListenerInfo {
id: id.clone(),
protocol: listener.protocol,
ports: listener.ports.clone(),
})
.collect();
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
/// Whether a listener of this id is accepting.
pub fn is_running(&self, id: &str) -> bool {
self.running.read().contains_key(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn control() -> ListenerControl {
let control = ListenerControl::default();
control.register("smtp", ServerProtocol::Smtp, vec![25]);
control.register("submission", ServerProtocol::Smtp, vec![465]);
control.register("imap", ServerProtocol::Imap, vec![993]);
control.register("pop3", ServerProtocol::Pop3, vec![995]);
control.register("sieve", ServerProtocol::ManageSieve, vec![4190]);
control.register("https", ServerProtocol::Http, vec![443]);
control
}
/// One listener stops and the others keep accepting (LP-2).
#[test]
fn stop_one_leaves_the_rest() {
let control = control();
let stopped = control.stop("imap").expect("imap was running");
assert_eq!(stopped.protocol, ServerProtocol::Imap);
assert_eq!(stopped.ports, vec![993]);
assert!(!control.is_running("imap"));
for still in ["smtp", "submission", "pop3", "sieve", "https"] {
assert!(control.is_running(still), "{still} should still accept");
}
}
/// Stopping the same listener twice is not an error, and says so.
#[test]
fn stop_is_idempotent() {
let control = control();
assert!(control.stop("imap").is_some());
assert!(control.stop("imap").is_none());
}
/// The accept loop's receiver sees the stop.
#[test]
fn the_listener_is_told() {
let control = ListenerControl::default();
let rx = control.register("imap", ServerProtocol::Imap, vec![993]);
assert!(!*rx.borrow());
control.stop("imap");
assert!(*rx.borrow(), "the accept loop must see true and break");
}
/// The legacy protocols stop; inbound SMTP and HTTPS do not (LP-1, LP-3).
#[test]
fn stop_matching_spares_inbound_and_http() {
let control = control();
let keep = vec!["smtp".to_string()];
let stopped = control.stop_matching(
|protocol, _ports| {
matches!(
protocol,
ServerProtocol::Imap
| ServerProtocol::Pop3
| ServerProtocol::ManageSieve
| ServerProtocol::Smtp
)
},
&keep,
);
let mut stopped_ids: Vec<String> = stopped.into_iter().map(|l| l.id).collect();
stopped_ids.sort();
assert_eq!(stopped_ids, vec!["imap", "pop3", "sieve", "submission"]);
assert!(
control.is_running("smtp"),
"port 25 must never close (LP-3)"
);
assert!(control.is_running("https"), "JMAP must keep working");
}
/// Without `closeSubmission`, submission stays open and only the mail-app
/// protocols close (LP-1).
#[test]
fn stop_matching_can_leave_submission_open() {
let control = control();
let keep = vec!["smtp".to_string(), "submission".to_string()];
let stopped = control.stop_matching(
|protocol, _ports| {
matches!(
protocol,
ServerProtocol::Imap | ServerProtocol::Pop3 | ServerProtocol::ManageSieve
)
},
&keep,
);
assert_eq!(stopped.len(), 3);
assert!(control.is_running("submission"));
assert!(control.is_running("smtp"));
}
/// Whole-server shutdown still stops everything.
#[test]
fn stop_all_stops_everything() {
let control = control();
let rx = control.register("extra", ServerProtocol::Imap, vec![143]);
control.stop_all();
assert!(*rx.borrow());
assert!(control.running().is_empty());
}
/// `running` reports what is accepting, in a stable order.
#[test]
fn running_lists_what_accepts() {
let control = control();
control.stop("pop3");
let ids: Vec<String> = control.running().into_iter().map(|l| l.id).collect();
assert_eq!(ids, vec!["https", "imap", "sieve", "smtp", "submission"]);
}
}
+44 -9
View File
@@ -2,9 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record}; use crate::{
Server,
config::network::Pacc,
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
};
use ahash::{AHashMap, AHashSet}; use ahash::{AHashMap, AHashSet};
use base64::{Engine, engine::general_purpose}; use base64::{Engine, engine::general_purpose};
use dns_update::{ use dns_update::{
@@ -33,6 +39,8 @@ impl Server {
let mut records = Vec::new(); let mut records = Vec::new();
let network = &self.core.network; let network = &self.core.network;
let default_host = network.server_name.as_str(); let default_host = network.server_name.as_str();
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
let domain_name = domain.name.as_str(); let domain_name = domain.name.as_str();
let domain_name_suffix = format!(".{domain_name}"); let domain_name_suffix = format!(".{domain_name}");
@@ -193,6 +201,25 @@ impl Server {
ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)], ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)],
}; };
// inbuxa: legacy-protocols LP-7. While they are off, every
// name says "not offered" -- target "." (RFC 6186 section
// 3.4) -- rather than vanishing, so a client that looks
// is told, and an old record left in the zone is replaced.
if legacy_off && is_legacy_service(protocol) {
for (service_name, _) in services {
records.push(NamedDnsRecord {
name: format!("_{service_name}._tcp.{domain_name}."),
record: DnsRecord::SRV(SRVRecord {
target: ".".to_string(),
priority: 0,
weight: 0,
port: 0,
}),
});
}
continue;
}
for (is_tls, (service_name, port)) in services.into_iter().enumerate() { for (is_tls, (service_name, port)) in services.into_iter().enumerate() {
if is_tls == 1 || service.cleartext { if is_tls == 1 || service.cleartext {
records.push(NamedDnsRecord { records.push(NamedDnsRecord {
@@ -277,6 +304,14 @@ impl Server {
for (protocol, service) in &network.info.services { for (protocol, service) in &network.info.services {
let hostname = service.hostname.as_deref().unwrap_or(default_host); let hostname = service.hostname.as_deref().unwrap_or(default_host);
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name { if hostname.ends_with(&domain_name_suffix) || hostname == domain_name {
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
// the switch has closed. Submission's port stays open
// (the SMTP lock), so its record stays.
if legacy_off
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
{
continue;
}
let port = match protocol { let port = match protocol {
ServiceProtocol::Imap => 993, ServiceProtocol::Imap => 993,
ServiceProtocol::Pop3 => 995, ServiceProtocol::Pop3 => 995,
@@ -382,6 +417,12 @@ impl Server {
} }
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> { pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
// inbuxa: legacy-protocols LP-7
let pacc = if self.legacy_protocols_off().await? {
&self.core.network.info.pacc_jmap_only
} else {
&self.core.network.info.pacc
};
self.get_directory_for_domain(domain_name) self.get_directory_for_domain(domain_name)
.await .await
.caused_by(trc::location!()) .caused_by(trc::location!())
@@ -390,15 +431,9 @@ impl Server {
.and_then(|directory| { .and_then(|directory| {
directory directory
.oidc_discovery_document() .oidc_discovery_document()
.map(|doc| self.core.network.info.pacc.build(&doc.url)) .map(|doc| pacc.build(&doc.url))
})
.unwrap_or_else(|| {
self.core
.network
.info
.pacc
.build(&self.core.network.http.url_https)
}) })
.unwrap_or_else(|| pacc.build(&self.core.network.http.url_https))
}) })
} }
} }
+409
View File
@@ -0,0 +1,409 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Turning the legacy-protocols switch, and making it true of the running
//! server (legacy-protocols spec, LP-1, LP-2 and LP-5).
//!
//! Two halves meet here. `inbuxa_features::security` decides what the policy
//! means and owns the listener **objects**; [`ListenerControl`] owns the
//! running **sockets**. Neither can do the job alone, and only `Server` has
//! both, so the join lives here.
//!
//! Order matters in both directions. Closing removes the object first and then
//! stops the socket: a socket stopped before its object is gone would come
//! back on the next restart. Opening puts the object back first and then
//! spawns, for the same reason in reverse.
//!
//! Sign-in is the second lock (LP-6): while the switch is off, a sign-in over
//! a legacy protocol is refused before any password is looked at, so a
//! listener that exists by mistake still lets nobody in.
//!
//! And nothing advertises what is closed (LP-7): client configuration and
//! the suggested DNS records leave the legacy services out, or mark them as
//! not offered, while the switch is off.
//!
//! Nothing here touches the host's firewall, NAT port-forwards or any proxy
//! (LP-20). The server stops answering; what still routes the port is the
//! operator's to reconcile.
use crate::{Server, config::server::Listeners, network::TcpAcceptor};
use directory::Credentials;
use inbuxa_features::security::{
listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener},
};
use registry::schema::enums::ServiceProtocol;
use registry::types::{error::Error, id::ObjectId};
use store::registry::bootstrap::Bootstrap;
/// What turning the switch actually did.
#[derive(Debug, Default)]
pub struct PolicyChange {
/// Listeners removed and stopped (LP-1).
pub closed: Vec<SavedListener>,
/// Listeners put back and started again (LP-5).
pub reopened: Vec<SavedListener>,
/// Listeners that could not be put back, with the reason. Each stays
/// saved for another try (LP-5).
pub failed: Vec<(SavedListener, String)>,
/// Properties the locks overruled (LP-21).
pub overruled: Vec<&'static str>,
/// Listeners whose object is right but whose socket needs a restart,
/// because no spawner was left behind. Empty on a normally booted server.
pub pending_restart: Vec<String>,
}
impl PolicyChange {
/// Whether anything at all happened, for the caller deciding to emit
/// `security.legacy-protocols-changed` (LP-8).
pub fn is_empty(&self) -> bool {
self.closed.is_empty()
&& self.reopened.is_empty()
&& self.failed.is_empty()
&& self.overruled.is_empty()
}
}
impl Server {
/// The policy in force.
pub async fn protocol_policy(&self) -> trc::Result<ProtocolPolicy> {
protocol_policy::get(&self.core.storage.data).await
}
/// Turns the switch, and makes it true of the running server.
///
/// `requested` is what the client asked for; the locks are applied to it
/// first (LP-21), so what gets stored is what the server allows, not what
/// was asked. Returns what actually happened, for the response and the
/// event.
pub async fn set_protocol_policy(
&self,
requested: ProtocolPolicy,
changed_by: Option<String>,
) -> trc::Result<PolicyChange> {
let mut policy = requested;
let mut change = PolicyChange {
overruled: policy.apply_locks(),
..Default::default()
};
// Carry forward what earlier changes saved: the client never sets
// this, and a /set that omitted it must not lose the listeners still
// waiting to come back.
let previous = self.protocol_policy().await?;
policy.saved_listeners = previous.saved_listeners;
policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = changed_by;
if policy.legacy_protocols.is_disabled() {
self.close_legacy_listeners(&mut policy, &mut change).await?;
} else {
self.reopen_legacy_listeners(&mut policy, &mut change)
.await?;
}
protocol_policy::set(&self.core.storage.data, &policy).await?;
Ok(change)
}
/// Removes the listener objects the policy closes, then stops their
/// sockets (LP-1, LP-2).
async fn close_legacy_listeners(
&self,
policy: &mut ProtocolPolicy,
change: &mut PolicyChange,
) -> trc::Result<()> {
let removed = listeners::close(self.registry(), policy).await?;
for saved in &removed {
// The runtime registry is keyed by the listener's name, which is
// what `close` returns as the saved listener's id.
self.inner.data.listener_control.stop(&saved.id);
}
policy.saved_listeners.extend(removed.iter().cloned());
change.closed = removed;
Ok(())
}
/// Puts back every saved listener and starts it again (LP-5).
async fn reopen_legacy_listeners(
&self,
policy: &mut ProtocolPolicy,
change: &mut PolicyChange,
) -> trc::Result<()> {
if policy.saved_listeners.is_empty() {
return Ok(());
}
let saved = std::mem::take(&mut policy.saved_listeners);
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
// A listener that could not be put back stays saved for another try.
policy.saved_listeners = failed.iter().map(|(listener, _)| listener.clone()).collect();
change.failed = failed;
if !restored.is_empty() {
change.pending_restart = self.spawn_restored_listeners(&restored).await?;
}
change.reopened = restored;
Ok(())
}
/// Binds and spawns the listeners just put back, so a port opens without a
/// restart. Returns the names that still need one.
async fn spawn_restored_listeners(&self, restored: &[SavedListener]) -> trc::Result<Vec<String>> {
let control = &self.inner.data.listener_control;
if !control.can_spawn() {
return Ok(restored.iter().map(|listener| listener.id.clone()).collect());
}
// Re-parse from the registry rather than from the saved object: the
// socket has to be created and bound afresh, and the parser is what
// knows how. The objects are already back, so this sees them.
let mut bootstrap = Bootstrap::new(self.registry().clone()).await;
let mut parsed = Listeners::parse(&mut bootstrap).await;
parsed
.parse_tcp_acceptors(&mut bootstrap, self.inner.clone())
.await;
// Only the wanted listeners, so re-parsing does not bind a port some
// other listener already holds.
let wanted: Vec<&str> = restored.iter().map(|l| l.id.as_str()).collect();
parsed
.servers
.retain(|listener| wanted.contains(&listener.id.as_str()));
// Bind, but do not drop privileges again. A port below 1024 fails
// here once privileges are gone; that listener is reported as needing
// a restart rather than quietly left dead.
let errors_before = bootstrap.errors.len();
parsed.bind(&mut bootstrap);
let unbindable: Vec<ObjectId> = bootstrap.errors[errors_before..]
.iter()
.filter_map(|error| match error {
Error::Build { object_id, .. } => Some(*object_id),
_ => None,
})
.collect();
parsed
.servers
.retain(|listener| !unbindable.contains(&listener.registry_id));
let mut spawned = Vec::new();
let mut acceptors = std::mem::take(&mut parsed.tcp_acceptors);
for listener in parsed.servers {
if !wanted.contains(&listener.id.as_str()) || control.is_running(&listener.id) {
continue;
}
let acceptor = acceptors
.remove(&listener.id)
.unwrap_or(TcpAcceptor::Plain);
let id = listener.id.clone();
if control.spawn(listener, acceptor) {
spawned.push(id);
}
}
Ok(restored
.iter()
.map(|listener| listener.id.clone())
.filter(|id| !spawned.contains(id))
.collect())
}
}
/// A protocol a mail app signs in over, which the switch refuses (LP-6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegacyProtocol {
Imap,
Pop3,
ManageSieve,
/// SMTP AUTH, on any SMTP listener: only mail apps authenticate, so
/// inbound delivery is untouched (LP-3).
Submission,
}
impl LegacyProtocol {
pub fn as_str(&self) -> &'static str {
match self {
LegacyProtocol::Imap => "imap",
LegacyProtocol::Pop3 => "pop3",
LegacyProtocol::ManageSieve => "manageSieve",
LegacyProtocol::Submission => "submission",
}
}
/// What the mail app is told, at server scope (LP-12, LP-6). Each
/// protocol's own framing — IMAP's `[ALERT]`, ManageSieve's quoting —
/// is added by its session; POP3 carries `[AUTH]` in the text, since its
/// errors have no separate code, and SMTP is the whole reply line.
pub fn refusal(&self) -> &'static str {
match self {
LegacyProtocol::Imap => {
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
}
LegacyProtocol::Pop3 => {
"[AUTH] This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
}
LegacyProtocol::ManageSieve => "This server allows only INBUXA webmail and JMAP apps.",
LegacyProtocol::Submission => {
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
}
}
}
/// The refusal as an error: `auth.legacy-protocol-refused`, not
/// `auth.failed`, so it never counts against the account or feeds the
/// auto-ban (LP-11). It names the protocol and the domain, never the
/// account; the session it is raised in adds the remote IP.
pub fn refused(&self, credentials: &Credentials) -> trc::Error {
trc::AuthEvent::LegacyProtocolRefused
.into_err()
.details(self.refusal())
.ctx(trc::Key::Source, self.as_str())
.ctx(trc::Key::Policy, "server")
.ctx_opt(trc::Key::Domain, domain_of(credentials))
}
}
/// The domain a sign-in is for, from the name it gives, if it gives one.
fn domain_of(credentials: &Credentials) -> Option<String> {
let username = match credentials {
Credentials::Basic { username, .. } => Some(username.as_str()),
Credentials::Bearer { username, .. } => username.as_deref(),
}?;
username
.rsplit_once('@')
.map(|(_, domain)| domain.trim().to_lowercase())
.filter(|domain| !domain.is_empty())
}
impl Server {
/// Refuses a sign-in over a legacy protocol while the server-wide switch
/// is off (LP-6). Called before the credentials are checked, so the
/// answer is the same for a right password, a wrong one and an account
/// that doesn't exist (LP-11).
///
/// 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.
pub async fn refuse_legacy_sign_in(
&self,
protocol: LegacyProtocol,
credentials: &Credentials,
) -> trc::Result<()> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
Err(protocol.refused(credentials))
} else {
Ok(())
}
}
}
/// The services mail apps sign in to, which the switch turns off: nothing may
/// offer them while it is (LP-7). SMTP here is submission -- mail apps
/// sending -- since inbound mail is never a configured service.
pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
matches!(
protocol,
ServiceProtocol::Imap
| ServiceProtocol::Pop3
| ServiceProtocol::Smtp
| ServiceProtocol::Managesieve
)
}
impl Server {
/// Whether the server-wide switch is off, for the answers that must stop
/// offering legacy services (LP-7). Read per answer, as sign-in reads it.
pub async fn legacy_protocols_off(&self) -> trc::Result<bool> {
Ok(self.protocol_policy().await?.legacy_protocols.is_disabled())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn basic(username: &str) -> Credentials {
Credentials::Basic {
username: username.to_string(),
secret: "wrong or right, it is never read".to_string(),
mfa_token: None,
}
}
#[test]
fn refusals_read_as_the_spec_writes_them() {
// LP-12, with "Your organization" read as "This server" (LP-6).
assert_eq!(
LegacyProtocol::Imap.refusal(),
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
);
assert!(
LegacyProtocol::Pop3
.refusal()
.starts_with("[AUTH] This server allows")
);
assert_eq!(
LegacyProtocol::ManageSieve.refusal(),
"This server allows only INBUXA webmail and JMAP apps."
);
assert_eq!(
LegacyProtocol::Submission.refusal(),
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
);
}
#[test]
fn a_refusal_is_not_a_failed_sign_in() {
let err = LegacyProtocol::Imap.refused(&basic("[email protected]"));
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
assert!(!err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)));
// The session stays open: the mail app is told, not thrown off.
assert!(!err.must_disconnect());
assert!(err.should_write_err());
assert_eq!(err.value_as_str(trc::Key::Domain), Some("example.org"));
assert_eq!(err.value_as_str(trc::Key::Source), Some("imap"));
assert_eq!(err.value_as_str(trc::Key::AccountName), None);
}
#[test]
fn only_the_services_mail_apps_sign_in_to_are_legacy() {
for protocol in [
ServiceProtocol::Imap,
ServiceProtocol::Pop3,
ServiceProtocol::Smtp,
ServiceProtocol::Managesieve,
] {
assert!(is_legacy_service(&protocol), "{protocol:?}");
}
for protocol in [
ServiceProtocol::Jmap,
ServiceProtocol::Caldav,
ServiceProtocol::Carddav,
ServiceProtocol::Webdav,
] {
assert!(!is_legacy_service(&protocol), "{protocol:?}");
}
}
#[test]
fn the_domain_comes_from_the_name_given() {
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
assert_eq!(domain_of(&basic("no-domain")), None);
assert_eq!(domain_of(&basic("trailing@")), None);
let bearer = Credentials::Bearer {
username: None,
token: "t".to_string(),
};
assert_eq!(domain_of(&bearer), None);
}
}
+47 -2
View File
@@ -26,6 +26,8 @@ use tokio_rustls::server::TlsStream;
use trc::{EventType, HttpEvent, ImapEvent, ManageSieveEvent, Pop3Event, SmtpEvent}; use trc::{EventType, HttpEvent, ImapEvent, ManageSieveEvent, Pop3Event, SmtpEvent};
use utils::UnwrapFailure; use utils::UnwrapFailure;
use super::control::ListenerControl;
impl Listener { impl Listener {
pub fn spawn( pub fn spawn(
self, self,
@@ -324,8 +326,14 @@ impl SocketOpts {
} }
impl Listeners { impl Listeners {
pub fn bind_and_drop_priv(&self, bp: &mut Bootstrap) { /// Binds every socket, reporting each failure against its listener.
// Bind as root ///
/// Split out of [`Listeners::bind_and_drop_priv`] so a listener can be
/// bound again at runtime, when the legacy-protocols switch puts one back
/// (LP-5), without dropping privileges a second time. A port below 1024
/// will fail here once privileges are gone, which is one of the cases
/// LP-5 expects and reports rather than hides.
pub fn bind(&self, bp: &mut Bootstrap) {
for server in &self.servers { for server in &self.servers {
for listener in &server.listeners { for listener in &server.listeners {
if let Err(err) = listener.socket.bind(listener.addr) { if let Err(err) = listener.socket.bind(listener.addr) {
@@ -336,6 +344,11 @@ impl Listeners {
} }
} }
} }
}
pub fn bind_and_drop_priv(&self, bp: &mut Bootstrap) {
// Bind as root
self.bind(bp);
// Drop privileges // Drop privileges
#[cfg(not(target_env = "msvc"))] #[cfg(not(target_env = "msvc"))]
@@ -370,6 +383,38 @@ impl Listeners {
} }
(shutdown_tx, shutdown_rx) (shutdown_tx, shutdown_rx)
} }
/// As [`Listeners::spawn`], but each listener gets its own shutdown
/// channel, registered in `control` under the listener's id, so one can be
/// stopped without touching the others (legacy-protocols LP-2).
///
/// The returned sender no longer reaches the listeners: whole-server
/// shutdown must also call [`ListenerControl::stop_all`]. `control` has to
/// outlive the listeners, because it owns the sending ends — dropping it
/// would stop every listener at once.
pub fn spawn_with_control(
mut self,
control: &ListenerControl,
spawn: impl Fn(Listener, TcpAcceptor, watch::Receiver<bool>),
) -> (watch::Sender<bool>, watch::Receiver<bool>) {
let (shutdown_tx, shutdown_rx) = watch::channel(false);
for server in self.servers {
let acceptor = self
.tcp_acceptors
.remove(&server.id)
.unwrap_or(TcpAcceptor::Plain);
let ports = server
.listeners
.iter()
.map(|listener| listener.addr.port())
.collect();
let listener_rx = control.register(server.id.clone(), server.protocol, ports);
spawn(server, acceptor, listener_rx);
}
(shutdown_tx, shutdown_rx)
}
} }
impl TcpListener { impl TcpListener {
+2
View File
@@ -33,8 +33,10 @@ use utils::snowflake::SnowflakeIdGenerator;
pub mod acme; pub mod acme;
pub mod asn; pub mod asn;
pub mod autoconfig; pub mod autoconfig;
pub mod control;
pub mod dkim; pub mod dkim;
pub mod dns; pub mod dns;
pub mod legacy;
pub mod limiter; pub mod limiter;
pub mod listen; pub mod listen;
pub mod mta; pub mod mta;
+1
View File
@@ -21,5 +21,6 @@
pub mod ai; pub mod ai;
pub mod branding; pub mod branding;
pub mod masked_email; pub mod masked_email;
pub mod security;
pub mod tenancy; pub mod tenancy;
pub mod undelete; pub mod undelete;
+297
View File
@@ -0,0 +1,297 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Taking the legacy listeners away and putting them back (legacy-protocols
//! spec, LP-1 and LP-5).
//!
//! The switch removes the listener **objects**, not just their sockets. A
//! stopped socket comes back on the next restart, which would reopen every
//! port the operator just closed; a removed object does not. It also means a
//! server that boots with the switch on never spawns them in the first place,
//! with no boot-time special case.
//!
//! Each removed object is kept whole in the policy so LP-5 can put it back
//! exactly as it was. Closing the running socket is a separate step, and lives
//! in `common`, which owns the listener registry: this crate sits below it.
//!
//! None of this touches the host's firewall or any port-forward (LP-20).
use crate::security::protocol_policy::{ProtocolPolicy, SavedListener};
use registry::schema::{
enums::NetworkListenerProtocol,
prelude::Object,
structs::NetworkListener,
};
use store::{
RegistryStore,
registry::write::{RegistryWrite, RegistryWriteResult},
};
use trc::AddContext;
/// How the registry schema spells a listener's protocol. These are the strings
/// [`ProtocolPolicy::closes`] matches on.
pub fn protocol_name(protocol: NetworkListenerProtocol) -> &'static str {
match protocol {
NetworkListenerProtocol::Smtp => "smtp",
NetworkListenerProtocol::Lmtp => "lmtp",
NetworkListenerProtocol::Http => "http",
NetworkListenerProtocol::Imap => "imap",
NetworkListenerProtocol::Pop3 => "pop3",
NetworkListenerProtocol::ManageSieve => "manageSieve",
}
}
/// Every port a listener binds. A listener may bind several, and one of them
/// being 25 makes the whole listener inbound (LP-3).
pub fn ports(listener: &NetworkListener) -> Vec<u16> {
listener.bind.iter().map(|addr| addr.0.port()).collect()
}
/// Whether the policy closes this listener.
pub fn closes(policy: &ProtocolPolicy, listener: &NetworkListener) -> bool {
policy.closes(protocol_name(listener.protocol), &ports(listener))
}
/// Saves a listener whole, ready to be put back (LP-5).
fn save(listener: &NetworkListener) -> trc::Result<SavedListener> {
Ok(SavedListener {
id: listener.name.clone(),
protocol: protocol_name(listener.protocol).to_string(),
ports: ports(listener),
object: serde_json::to_value(listener).map_err(|err| {
trc::StoreEvent::UnexpectedError
.caused_by(trc::location!())
.reason(err)
})?,
})
}
/// Removes every listener the policy closes, saving each one whole first
/// (LP-1). Returns what was removed, in the order the registry listed it.
///
/// The caller then stops the matching running sockets, by the `id` of each
/// returned listener — which is the listener's name, the same key the runtime
/// registry uses.
pub async fn close(
registry: &RegistryStore,
policy: &ProtocolPolicy,
) -> trc::Result<Vec<SavedListener>> {
let mut removed = Vec::new();
for listener in registry
.list::<NetworkListener>()
.await
.caused_by(trc::location!())?
{
if !closes(policy, &listener.object) {
continue;
}
let saved = save(&listener.object)?;
match registry
.write(RegistryWrite::delete(listener.id))
.await
.caused_by(trc::location!())?
{
RegistryWriteResult::Success(_) | RegistryWriteResult::NotFound { .. } => {
removed.push(saved);
}
// Anything else means the registry declined the delete. Leave the
// listener alone and say nothing was removed, so the policy does
// not claim a port is closed while it is still accepting.
_ => {}
}
}
Ok(removed)
}
/// Puts back every saved listener (LP-5).
///
/// Returns the ones restored and the ones that could not be, each with the
/// reason. A listener that cannot come back — its port taken in the meantime,
/// say — does not stop the others, and stays saved for another try.
pub async fn reopen(
registry: &RegistryStore,
saved: &[SavedListener],
) -> trc::Result<(Vec<SavedListener>, Vec<(SavedListener, String)>)> {
let mut restored = Vec::new();
let mut failed = Vec::new();
for listener in saved {
let object: NetworkListener = match serde_json::from_value(listener.object.clone()) {
Ok(object) => object,
Err(err) => {
failed.push((listener.clone(), format!("saved listener unreadable: {err}")));
continue;
}
};
let object: Object = object.into();
match registry
.write(RegistryWrite::insert(&object))
.await
.caused_by(trc::location!())?
{
RegistryWriteResult::Success(_) => restored.push(listener.clone()),
other => failed.push((listener.clone(), format!("{other:?}"))),
}
}
Ok((restored, failed))
}
/// The listener objects that exist right now, as `(name, protocol, ports)`.
/// The confirmation (LP-16) lists exactly what will close, before anything
/// happens.
pub async fn would_close(
registry: &RegistryStore,
policy: &ProtocolPolicy,
) -> trc::Result<Vec<SavedListener>> {
let mut out = Vec::new();
for listener in registry
.list::<NetworkListener>()
.await
.caused_by(trc::location!())?
{
if closes(policy, &listener.object) {
out.push(save(&listener.object)?);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::protocol_policy::LegacyProtocols;
use registry::{schema::prelude::SocketAddr, types::map::Map};
use std::str::FromStr;
fn listener(name: &str, protocol: NetworkListenerProtocol, binds: &[&str]) -> NetworkListener {
NetworkListener {
name: name.to_string(),
protocol,
bind: Map::new(
binds
.iter()
.map(|addr| SocketAddr::from_str(addr).unwrap())
.collect::<Vec<_>>(),
),
..Default::default()
}
}
fn disabled() -> ProtocolPolicy {
ProtocolPolicy {
legacy_protocols: LegacyProtocols::Disabled,
..Default::default()
}
}
/// The protocol names match what the policy matches on.
#[test]
fn protocol_names_are_the_schema_spelling() {
assert_eq!(protocol_name(NetworkListenerProtocol::Imap), "imap");
assert_eq!(protocol_name(NetworkListenerProtocol::Pop3), "pop3");
assert_eq!(
protocol_name(NetworkListenerProtocol::ManageSieve),
"manageSieve"
);
assert_eq!(protocol_name(NetworkListenerProtocol::Smtp), "smtp");
}
/// Every bound port is seen, so a listener that also binds 25 is caught.
#[test]
fn every_bound_port_is_seen() {
let l = listener(
"mixed",
NetworkListenerProtocol::Smtp,
&["[::]:465", "0.0.0.0:25"],
);
let mut p = ports(&l);
p.sort();
assert_eq!(p, vec![25, 465]);
}
/// The mail-app listeners close; inbound and JMAP do not (LP-1, LP-3).
#[test]
fn the_right_listeners_close() {
let policy = disabled();
for (name, protocol, binds) in [
("imaps", NetworkListenerProtocol::Imap, &["[::]:993"][..]),
("pop3s", NetworkListenerProtocol::Pop3, &["[::]:995"][..]),
(
"sieve",
NetworkListenerProtocol::ManageSieve,
&["[::]:4190"][..],
),
] {
assert!(
closes(&policy, &listener(name, protocol, binds)),
"{name} should close"
);
}
for (name, protocol, binds) in [
("smtp", NetworkListenerProtocol::Smtp, &["[::]:25"][..]),
// Locked whole, so submission stays too (LP-21).
(
"submissions",
NetworkListenerProtocol::Smtp,
&["[::]:465"][..],
),
("https", NetworkListenerProtocol::Http, &["[::]:443"][..]),
("lmtp", NetworkListenerProtocol::Lmtp, &["[::]:11200"][..]),
] {
assert!(
!closes(&policy, &listener(name, protocol, binds)),
"{name} must stay"
);
}
}
/// A submission listener that also binds 25 is inbound, and stays (LP-3).
/// Kept so LP-3 stays covered if the LP-21 lock is ever lifted.
#[test]
fn a_listener_that_also_binds_25_stays() {
let policy = disabled();
let l = listener(
"mixed",
NetworkListenerProtocol::Smtp,
&["[::]:465", "[::]:25"],
);
assert!(!closes(&policy, &l));
}
/// A saved listener keeps every field, including ones this code never
/// reads, and comes back as the same object (LP-5).
#[test]
fn a_saved_listener_round_trips() {
let mut original = listener("imaps", NetworkListenerProtocol::Imap, &["[::]:993"]);
original.socket_no_delay = true;
original.socket_backlog = Some(2048);
let saved = save(&original).unwrap();
assert_eq!(saved.id, "imaps");
assert_eq!(saved.protocol, "imap");
assert_eq!(saved.ports, vec![993]);
let back: NetworkListener = serde_json::from_value(saved.object).unwrap();
assert_eq!(back, original);
}
/// While the switch is off, nothing closes.
#[test]
fn enabled_closes_nothing() {
let policy = ProtocolPolicy::default();
assert!(!closes(
&policy,
&listener("imaps", NetworkListenerProtocol::Imap, &["[::]:993"])
));
}
}
+14
View File
@@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Security hardening INBUXA adds of its own.
//!
//! Unlike the rest of this crate, these are not rebuilds of anything upstream
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
//! `legacy-protocols.md`.
pub mod listeners;
pub mod protocol_policy;
@@ -0,0 +1,427 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:ProtocolPolicy`, the server-wide legacy mail protocols switch
//! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under
//! `P` + `p` in the fork's subspace; unset fields read as the defaults.
//!
//! This module is the fact, not the act. It holds what the operator chose and
//! which listeners were taken away to honour it. Closing sockets belongs to
//! `common`, which owns the listener registry, and removing the listener
//! objects belongs to the caller that has the registry to hand: this crate
//! sits below both.
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// Whether the legacy mail protocols may be used at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub enum LegacyProtocols {
/// IMAP, POP3, ManageSieve and SMTP submission work as configured.
#[default]
Enabled,
/// They are off: the ports are closed and sign-in over them is refused.
Disabled,
}
impl LegacyProtocols {
pub fn is_disabled(&self) -> bool {
matches!(self, LegacyProtocols::Disabled)
}
}
/// A listener taken away to honour the switch, kept whole so it can be put
/// back exactly as it was (LP-1, LP-5).
///
/// `object` is the listener's registry object verbatim. Keeping the whole
/// object rather than a few fields is what lets LP-5 promise "exactly the
/// saved listeners": a listener has proxy networks, TLS timeouts and socket
/// options that nobody should have to re-derive, and a field this code has
/// never heard of must survive the round trip too.
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedListener {
/// The listener's name, as the operator knows it.
pub id: String,
/// `imap`, `pop3`, `manageSieve` or `smtp`, as the registry spells it.
pub protocol: String,
/// The ports it was accepting on, for the confirmation's list (LP-16).
pub ports: Vec<u16>,
/// The registry object, whole.
pub object: serde_json::Value,
}
/// The server-wide switch.
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ProtocolPolicy {
/// The switch itself.
pub legacy_protocols: LegacyProtocols,
/// With `disabled`, also close SMTP submission (LP-3). The inbound
/// listener on port 25 is never closed, whatever this says.
pub close_submission: bool,
/// The listeners removed when the switch went off (LP-1), for LP-5.
pub saved_listeners: Vec<SavedListener>,
/// When the switch last changed, in milliseconds since the epoch.
pub changed_at: Option<u64>,
/// The account that last changed it.
pub changed_by: Option<String>,
}
impl Default for ProtocolPolicy {
fn default() -> Self {
ProtocolPolicy {
legacy_protocols: LegacyProtocols::Enabled,
close_submission: true,
saved_listeners: Vec::new(),
changed_at: None,
changed_by: None,
}
}
}
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
pub const PROPERTIES: &[&str] = &[
"legacyProtocols",
"closeSubmission",
"savedListeners",
"changedAt",
"changedBy",
];
/// The registry protocols the switch closes, as the schema spells them.
///
/// `smtp` is deliberately absent: an SMTP listener is submission or inbound
/// depending on its port, which only the caller can tell (LP-3).
pub const LEGACY_PROTOCOLS: &[&str] = &["imap", "pop3", "manageSieve"];
/// The port that always means inbound mail, and is never closed (LP-3).
pub const INBOUND_SMTP_PORT: u16 = 25;
/// Protocols the switch may never close, whatever is asked of it (LP-21).
///
/// `http` carries JMAP, so closing it would lock every account out of its mail
/// and the operator out of INBUXA Admin. `smtp` is locked whole — inbound and
/// submission alike — by John's decision of 2026-09-20; LP-3 already spared
/// inbound, and this extends it to 465 and 587. `lmtp` is internal and was
/// never a candidate.
///
/// Locking submission costs the feature nothing: the ports stay open and
/// sign-in over them is still refused (LP-6), which is the case acceptance
/// test 2 already describes.
///
/// The front ends read this list rather than carry their own copy, so
/// unlocking later is a server change and no admin release.
pub const LOCKED_PROTOCOLS: &[&str] = &["smtp", "lmtp", "http"];
/// Whether this protocol is locked open (LP-21).
pub fn is_locked(protocol: &str) -> bool {
LOCKED_PROTOCOLS
.iter()
.any(|locked| locked.eq_ignore_ascii_case(protocol))
}
impl ProtocolPolicy {
/// Whether a listener of this protocol and these ports is one the switch
/// closes. A listener bound to port 25 is inbound whatever its name, and
/// any other SMTP listener counts as submission (LP-3).
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
if !self.legacy_protocols.is_disabled() {
return false;
}
// The lock is checked first and answers for every caller, so no
// request phrasing can reach past it (LP-21).
if is_locked(protocol) {
return false;
}
if LEGACY_PROTOCOLS.contains(&protocol) {
return true;
}
protocol.eq_ignore_ascii_case("smtp")
&& self.close_submission
&& !ports.contains(&INBOUND_SMTP_PORT)
}
/// Applies the locks to what a client asked for, returning what was
/// overruled so the response can say so (LP-21).
///
/// `closeSubmission` is recorded and ignored rather than refused: the
/// field is specified, and the lock is meant to be temporary.
pub fn apply_locks(&mut self) -> Vec<&'static str> {
let mut overruled = Vec::new();
if self.close_submission && is_locked("smtp") {
self.close_submission = false;
overruled.push("closeSubmission");
}
overruled
}
/// Whether creating a listener of this protocol is refused right now
/// (LP-4), so a closed port cannot be quietly reopened.
pub fn refuses_new_listener(&self, protocol: &str, ports: &[u16]) -> bool {
self.closes(protocol, ports)
}
/// What's wrong with these values, naming the property.
pub fn check(&self) -> Result<(), (&'static str, String)> {
if self.saved_listeners.len() > 1024 {
return Err((
"savedListeners",
"must hold at most 1024 listeners".to_string(),
));
}
for saved in &self.saved_listeners {
if saved.id.is_empty() {
return Err(("savedListeners", "a saved listener has no id".to_string()));
}
}
Ok(())
}
}
fn key() -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: b"Pp".to_vec(),
})
}
struct Json(ProtocolPolicy);
impl Deserialize for Json {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
serde_json::from_slice(bytes).map(Json).map_err(|err| {
trc::StoreEvent::DataCorruption
.caused_by(trc::location!())
.reason(err)
})
}
}
/// The policy in force.
pub async fn get(data: &Store) -> trc::Result<ProtocolPolicy> {
Ok(data
.get_value::<Json>(ValueKey::from(key()))
.await
.caused_by(trc::location!())?
.map(|Json(policy)| policy)
.unwrap_or_default())
}
/// Stores a new policy.
pub async fn set(data: &Store, policy: &ProtocolPolicy) -> trc::Result<()> {
let bytes = serde_json::to_vec(policy).map_err(|err| {
trc::StoreEvent::UnexpectedError
.caused_by(trc::location!())
.reason(err)
})?;
let mut batch = BatchBuilder::new();
batch.set(key(), bytes);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
fn disabled() -> ProtocolPolicy {
ProtocolPolicy {
legacy_protocols: LegacyProtocols::Disabled,
..Default::default()
}
}
/// The default is on, and every property survives the round trip.
#[test]
fn defaults_and_partial_json() {
let policy = ProtocolPolicy::default();
assert_eq!(policy.legacy_protocols, LegacyProtocols::Enabled);
assert!(policy.close_submission);
assert!(policy.check().is_ok());
let partial: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "disabled"}"#).unwrap();
assert!(partial.legacy_protocols.is_disabled());
assert!(
partial.close_submission,
"an unset closeSubmission reads as the default, true"
);
let json = serde_json::to_value(&policy).unwrap();
for property in PROPERTIES {
assert!(json.get(property).is_some(), "{property}");
}
assert_eq!(json["legacyProtocols"], "enabled");
}
/// While the switch is on, nothing closes (LP-1).
#[test]
fn enabled_closes_nothing() {
let policy = ProtocolPolicy::default();
for (protocol, ports) in [
("imap", vec![993]),
("pop3", vec![995]),
("manageSieve", vec![4190]),
("smtp", vec![465]),
("smtp", vec![25]),
] {
assert!(!policy.closes(protocol, &ports), "{protocol} {ports:?}");
}
}
/// The mail-app protocols close. Submission does not, while SMTP is
/// locked (LP-1, LP-21).
#[test]
fn disabled_closes_the_legacy_protocols() {
let policy = disabled();
assert!(policy.closes("imap", &[993]));
assert!(policy.closes("pop3", &[995]));
assert!(policy.closes("manageSieve", &[4190]));
assert!(!policy.closes("smtp", &[465]), "SMTP is locked (LP-21)");
assert!(!policy.closes("smtp", &[587]), "SMTP is locked (LP-21)");
}
/// SMTP and JMAP cannot be closed, however the question is put (LP-21).
#[test]
fn smtp_and_jmap_are_locked() {
assert!(is_locked("smtp"));
assert!(is_locked("SMTP"), "the lock ignores case");
assert!(is_locked("http"));
assert!(is_locked("lmtp"));
assert!(!is_locked("imap"));
assert!(!is_locked("pop3"));
assert!(!is_locked("manageSieve"));
// Even asked for directly, with closeSubmission set by hand.
let forced = ProtocolPolicy {
legacy_protocols: LegacyProtocols::Disabled,
close_submission: true,
..Default::default()
};
for ports in [vec![465], vec![587], vec![25], vec![2525]] {
assert!(!forced.closes("smtp", &ports), "smtp {ports:?}");
}
assert!(!forced.closes("http", &[443]));
}
/// A client asking to close submission is overruled, not refused, and the
/// overrule is reported (LP-21, acceptance test 18).
#[test]
fn close_submission_is_overruled_and_reported() {
let mut policy = ProtocolPolicy {
legacy_protocols: LegacyProtocols::Disabled,
close_submission: true,
..Default::default()
};
let overruled = policy.apply_locks();
assert_eq!(overruled, vec!["closeSubmission"]);
assert!(!policy.close_submission);
// Applying twice says nothing the second time.
assert!(policy.apply_locks().is_empty());
}
/// Port 25 is inbound whatever the listener is called, and never closes
/// (LP-3). It is doubly safe now that SMTP is locked (LP-21), and this
/// test stands so LP-3 stays covered if the lock is ever lifted.
#[test]
fn port_25_is_never_closed() {
let policy = disabled();
assert!(!policy.closes("smtp", &[25]));
assert!(
!policy.closes("smtp", &[25, 465]),
"a listener that also binds 25 is inbound and stays"
);
}
/// JMAP, DAV and internal delivery are never touched.
#[test]
fn http_and_lmtp_are_never_closed() {
let policy = disabled();
assert!(!policy.closes("http", &[443]));
assert!(!policy.closes("lmtp", &[11200]));
}
/// Without `closeSubmission`, 465 and 587 stay open (acceptance test 2).
/// The lock makes this the only behaviour for now (LP-21).
#[test]
fn submission_stays_open_when_asked() {
let policy = ProtocolPolicy {
legacy_protocols: LegacyProtocols::Disabled,
close_submission: false,
..Default::default()
};
assert!(!policy.closes("smtp", &[465]));
assert!(!policy.closes("smtp", &[587]));
assert!(
policy.closes("imap", &[993]),
"the mail-app protocols close regardless"
);
}
/// A new legacy listener is refused while the switch is on (LP-4), and an
/// inbound one is still allowed.
#[test]
fn new_legacy_listeners_are_refused() {
let policy = disabled();
assert!(policy.refuses_new_listener("imap", &[143]));
assert!(!policy.refuses_new_listener("smtp", &[25]));
assert!(!ProtocolPolicy::default().refuses_new_listener("imap", &[143]));
}
/// A saved listener keeps its whole registry object, so LP-5 can put back
/// fields this code never reads.
#[test]
fn saved_listeners_survive_the_round_trip() {
let policy = ProtocolPolicy {
legacy_protocols: LegacyProtocols::Disabled,
saved_listeners: vec![SavedListener {
id: "imaps".to_string(),
protocol: "imap".to_string(),
ports: vec![993],
object: serde_json::json!({
"bind": ["[::]:993"],
"tls": {"implicit": true},
"somethingThisCodeHasNeverHeardOf": 7,
}),
}],
..Default::default()
};
assert!(policy.check().is_ok());
let round_tripped: ProtocolPolicy =
serde_json::from_slice(&serde_json::to_vec(&policy).unwrap()).unwrap();
assert_eq!(round_tripped, policy);
assert_eq!(
round_tripped.saved_listeners[0].object["somethingThisCodeHasNeverHeardOf"],
7
);
}
/// A saved listener with no id is refused, naming the property.
#[test]
fn a_nameless_saved_listener_is_refused() {
let policy = ProtocolPolicy {
saved_listeners: vec![SavedListener {
id: String::new(),
protocol: "imap".to_string(),
ports: vec![993],
object: serde_json::Value::Null,
}],
..Default::default()
};
assert_eq!(policy.check().unwrap_err().0, "savedListeners");
}
}
+9 -1
View File
@@ -2,12 +2,14 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::core::{Session, SessionData, State}; use crate::core::{Session, SessionData, State};
use common::{ use common::{
auth::AuthRequest, auth::AuthRequest,
network::{SessionStream, limiter::LimiterResult}, network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
}; };
use directory::Credentials; use directory::Credentials;
use imap_proto::{ use imap_proto::{
@@ -67,6 +69,12 @@ impl<T: SessionStream> Session<T> {
} }
pub async fn authenticate(&mut self, credentials: Credentials, tag: String) -> trc::Result<()> { pub async fn authenticate(&mut self, credentials: Credentials, tag: String) -> trc::Result<()> {
// inbuxa: legacy-protocols LP-6, before the password is looked at
self.server
.refuse_legacy_sign_in(LegacyProtocol::Imap, &credentials)
.await
.map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?;
// Authenticate // Authenticate
let access_token = self let access_token = self
.server .server
@@ -0,0 +1,195 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:ProtocolPolicy/get` and `/set` under `urn:inbuxa:jmap`: the
//! server-wide legacy mail protocols switch (legacy-protocols spec). A
//! singleton, id `singleton`.
//!
//! Three of its properties are the server's to say, not the client's:
//! `savedListeners` (LP-1), `lockedProtocols` (LP-21) and `wouldClose`
//! (LP-16). A client that sets them is answered with `invalidProperties`.
use crate::object::{AnyId, JmapObject, JmapObjectId};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct ProtocolPolicy;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProtocolPolicyProperty {
Id,
/// The switch: `enabled` or `disabled`.
LegacyProtocols,
/// Whether submission closes with it. Forced false while SMTP is locked.
CloseSubmission,
/// Server-set: the listeners taken away, for LP-5.
SavedListeners,
ChangedAt,
ChangedBy,
/// Server-set: the protocols that cannot be closed, so the selector can
/// render them locked rather than carry its own list (LP-21).
LockedProtocols,
/// Server-set: exactly which listeners turning the switch would close,
/// by name and port, for the confirmation (LP-16).
WouldClose,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProtocolPolicyValue {
Id(Id),
}
impl Property for ProtocolPolicyProperty {
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
ProtocolPolicyProperty::parse(value)
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
ProtocolPolicyProperty::Id => "id",
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
ProtocolPolicyProperty::SavedListeners => "savedListeners",
ProtocolPolicyProperty::ChangedAt => "changedAt",
ProtocolPolicyProperty::ChangedBy => "changedBy",
ProtocolPolicyProperty::LockedProtocols => "lockedProtocols",
ProtocolPolicyProperty::WouldClose => "wouldClose",
}
.into()
}
}
impl ProtocolPolicyProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => ProtocolPolicyProperty::Id,
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
b"changedBy" => ProtocolPolicyProperty::ChangedBy,
b"lockedProtocols" => ProtocolPolicyProperty::LockedProtocols,
b"wouldClose" => ProtocolPolicyProperty::WouldClose,
)
}
}
impl ProtocolPolicyProperty {
/// Whether this property is the server's to say. A client that sets one
/// is answered with `invalidProperties`.
pub fn is_server_set(&self) -> bool {
matches!(
self,
ProtocolPolicyProperty::SavedListeners
| ProtocolPolicyProperty::ChangedAt
| ProtocolPolicyProperty::ChangedBy
| ProtocolPolicyProperty::LockedProtocols
| ProtocolPolicyProperty::WouldClose
)
}
}
impl FromStr for ProtocolPolicyProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
ProtocolPolicyProperty::parse(s).ok_or(())
}
}
impl Element for ProtocolPolicyValue {
type Property = ProtocolPolicyProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(ProtocolPolicyProperty::Id) => Id::from_str(value).ok().map(ProtocolPolicyValue::Id),
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
ProtocolPolicyValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for ProtocolPolicy {
type Property = ProtocolPolicyProperty;
type Element = ProtocolPolicyValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = ProtocolPolicyProperty::Id;
}
impl From<Id> for ProtocolPolicyValue {
fn from(id: Id) -> Self {
ProtocolPolicyValue::Id(id)
}
}
impl JmapObjectId for ProtocolPolicyValue {
fn as_id(&self) -> Option<Id> {
match self {
ProtocolPolicyValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
ProtocolPolicyValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = ProtocolPolicyValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for ProtocolPolicyProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}
+1
View File
@@ -22,6 +22,7 @@ pub mod email;
pub mod email_submission; pub mod email_submission;
pub mod fastmail_masked_email; // inbuxa: masked email pub mod fastmail_masked_email; // inbuxa: masked email
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
pub mod inbuxa_deleted_account; // inbuxa: undelete pub mod inbuxa_deleted_account; // inbuxa: undelete
pub mod file_node; pub mod file_node;
pub mod identity; pub mod identity;
+3
View File
@@ -61,6 +61,9 @@ impl Response<'_> {
GetResponseMethod::AiLimits(response) => { GetResponseMethod::AiLimits(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
GetResponseMethod::ProtocolPolicy(response) => {
response.eval_jptr(path, &mut results)
}
GetResponseMethod::Principal(response) => { GetResponseMethod::Principal(response) => {
response.eval_jptr(path, &mut results) response.eval_jptr(path, &mut results)
} }
@@ -46,6 +46,7 @@ impl Response<'_> {
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?, GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?, GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?, GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
GetRequestMethod::Principal(request) => request.resolve_references(self)?, GetRequestMethod::Principal(request) => request.resolve_references(self)?,
GetRequestMethod::Quota(request) => request.resolve_references(self)?, GetRequestMethod::Quota(request) => request.resolve_references(self)?,
GetRequestMethod::Blob(request) => request.resolve_references(self)?, GetRequestMethod::Blob(request) => request.resolve_references(self)?,
@@ -89,6 +90,9 @@ impl Response<'_> {
SetRequestMethod::AiLimits(request) => { SetRequestMethod::AiLimits(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
SetRequestMethod::ProtocolPolicy(request) => {
request.resolve_references(self, 1, false)?
}
SetRequestMethod::AddressBook(request) => { SetRequestMethod::AddressBook(request) => {
request.resolve_references(self, 1, false)? request.resolve_references(self, 1, false)?
} }
+7
View File
@@ -49,6 +49,7 @@ pub enum MethodObject {
DeletedAccount, DeletedAccount,
// inbuxa: AI call limits // inbuxa: AI call limits
AiLimits, AiLimits,
ProtocolPolicy,
} }
impl MethodObject { impl MethodObject {
@@ -75,6 +76,7 @@ impl MethodObject {
MethodObject::MaskedEmail => Capability::FastmailMaskedEmail, MethodObject::MaskedEmail => Capability::FastmailMaskedEmail,
MethodObject::DeletedAccount => Capability::Inbuxa, MethodObject::DeletedAccount => Capability::Inbuxa,
MethodObject::AiLimits => Capability::Inbuxa, MethodObject::AiLimits => Capability::Inbuxa,
MethodObject::ProtocolPolicy => Capability::Inbuxa,
} }
} }
} }
@@ -252,6 +254,8 @@ impl MethodName {
(MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set", (MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set",
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get", (MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set", (MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
(MethodFunction::Get, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/get",
(MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set",
(method, MethodObject::Registry(obj)) => { (method, MethodObject::Registry(obj)) => {
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str())); return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
} }
@@ -377,6 +381,8 @@ impl MethodName {
"inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set), "inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set),
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get), "inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set), "inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
).or_else(|| { ).or_else(|| {
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?; let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
@@ -430,6 +436,7 @@ impl Display for MethodObject {
MethodObject::MaskedEmail => "MaskedEmail", MethodObject::MaskedEmail => "MaskedEmail",
MethodObject::DeletedAccount => "inbuxa:DeletedAccount", MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
MethodObject::AiLimits => "inbuxa:AiLimits", MethodObject::AiLimits => "inbuxa:AiLimits",
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
MethodObject::Registry(obj) => { MethodObject::Registry(obj) => {
f.write_str("x:")?; f.write_str("x:")?;
return f.write_str(obj.as_str()); return f.write_str(obj.as_str());
+2
View File
@@ -116,6 +116,7 @@ pub enum GetRequestMethod {
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>), MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>), DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>), AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
} }
#[derive(Debug)] #[derive(Debug)]
@@ -139,6 +140,7 @@ pub enum SetRequestMethod<'x> {
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>), MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>), DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>), AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
} }
#[derive(Debug)] #[derive(Debug)]
+14
View File
@@ -169,6 +169,13 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); return Err(de::Error::invalid_length(1, &self));
} }
}, },
(MethodFunction::Get, MethodObject::ProtocolPolicy) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ProtocolPolicy(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() { (MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)), Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
Err(err) => RequestMethod::invalid(err), Err(err) => RequestMethod::invalid(err),
@@ -334,6 +341,13 @@ impl<'de> Visitor<'de> for CallVisitor {
return Err(de::Error::invalid_length(1, &self)); return Err(de::Error::invalid_length(1, &self));
} }
}, },
(MethodFunction::Set, MethodObject::ProtocolPolicy) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ProtocolPolicy(value)),
Err(err) => RequestMethod::invalid(err),
Ok(None) => {
return Err(de::Error::invalid_length(1, &self));
}
},
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() { (MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)), Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
Err(err) => RequestMethod::invalid(err), Err(err) => RequestMethod::invalid(err),
+18
View File
@@ -103,6 +103,7 @@ pub enum GetResponseMethod {
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>), MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>), DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>), AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@@ -127,6 +128,7 @@ pub enum SetResponseMethod {
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>), MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>), DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>), AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@@ -287,6 +289,22 @@ impl<'x> From<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEm
} }
// inbuxa: AI call limits // inbuxa: AI call limits
impl<'x> From<GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>
for ResponseMethod<'x>
{
fn from(value: GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>) -> Self {
ResponseMethod::Get(GetResponseMethod::ProtocolPolicy(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>
for ResponseMethod<'x>
{
fn from(value: SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>) -> Self {
ResponseMethod::Set(SetResponseMethod::ProtocolPolicy(Box::new(value)))
}
}
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> { impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self { fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
ResponseMethod::Get(GetResponseMethod::AiLimits(value)) ResponseMethod::Get(GetResponseMethod::AiLimits(value))
+13 -1
View File
@@ -77,6 +77,9 @@ impl JmapAuthorization for AccessToken {
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet, GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
// inbuxa: AI call limits, with the classifier's permissions // inbuxa: AI call limits, with the classifier's permissions
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet, GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
// inbuxa: legacy protocols off. It takes listeners away and
// puts them back, so it takes the listener's permissions
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet, GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet, GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
GetRequestMethod::Blob(_) => Permission::JmapBlobGet, GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
@@ -173,6 +176,14 @@ impl JmapAuthorization for AccessToken {
Permission::SysSpamLlmUpdate, Permission::SysSpamLlmUpdate,
Permission::SysSpamLlmUpdate, Permission::SysSpamLlmUpdate,
), ),
// inbuxa: legacy protocols off, with the listener's
SetRequestMethod::ProtocolPolicy(s) => validate_set(
s,
self,
Permission::SysNetworkListenerUpdate,
Permission::SysNetworkListenerUpdate,
Permission::SysNetworkListenerUpdate,
),
SetRequestMethod::VacationResponse(s) => validate_set( SetRequestMethod::VacationResponse(s) => validate_set(
s, s,
self, self,
@@ -282,7 +293,8 @@ impl JmapAuthorization for AccessToken {
| MethodObject::SieveScript | MethodObject::SieveScript
| MethodObject::MaskedEmail | MethodObject::MaskedEmail
| MethodObject::DeletedAccount | MethodObject::DeletedAccount
| MethodObject::AiLimits => Permission::JmapEmailChanges, | MethodObject::AiLimits
| MethodObject::ProtocolPolicy => Permission::JmapEmailChanges,
// inbuxa: x:MaskedEmail/changes reads what /get reads // inbuxa: x:MaskedEmail/changes reads what /get reads
MethodObject::Registry(object_type) => object_type.get_permission(), MethodObject::Registry(object_type) => object_type.get_permission(),
}, },
+17
View File
@@ -221,6 +221,9 @@ impl RequestHandler for Server {
SetResponseMethod::AiLimits(set_response) => { SetResponseMethod::AiLimits(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
SetResponseMethod::ProtocolPolicy(set_response) => {
set_response.update_created_ids(&mut response);
}
SetResponseMethod::AddressBook(set_response) => { SetResponseMethod::AddressBook(set_response) => {
set_response.update_created_ids(&mut response); set_response.update_created_ids(&mut response);
} }
@@ -376,6 +379,13 @@ impl RequestHandler for Server {
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:ProtocolPolicy/get (legacy protocols off)
GetRequestMethod::ProtocolPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::protocol_policy::get(self, access_token, *req)
.await?
.into()
}
GetRequestMethod::Principal(req) => { GetRequestMethod::Principal(req) => {
self.principal_get(*req, access_token).await?.into() self.principal_get(*req, access_token).await?.into()
} }
@@ -617,6 +627,13 @@ impl RequestHandler for Server {
.await? .await?
.into() .into()
} }
// inbuxa: inbuxa:ProtocolPolicy/set (legacy protocols off)
SetRequestMethod::ProtocolPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::protocol_policy::set(self, access_token, *req)
.await?
.into()
}
SetRequestMethod::AddressBook(mut req) => { SetRequestMethod::AddressBook(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?; resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
access_token.assert_has_access(req.account_id, Collection::AddressBook)?; access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
+1
View File
@@ -418,6 +418,7 @@ impl IntermediateChangesResponse {
| MethodObject::MaskedEmail | MethodObject::MaskedEmail
| MethodObject::DeletedAccount | MethodObject::DeletedAccount
| MethodObject::AiLimits | MethodObject::AiLimits
| MethodObject::ProtocolPolicy
| MethodObject::Registry(_) => unreachable!(), | MethodObject::Registry(_) => unreachable!(),
}) })
} }
+1
View File
@@ -9,6 +9,7 @@
pub mod access; pub mod access;
pub mod ai_limits; pub mod ai_limits;
pub mod protocol_policy;
pub mod deleted_account; pub mod deleted_account;
pub mod fastmail; pub mod fastmail;
pub mod masked_email; pub mod masked_email;
+432
View File
@@ -0,0 +1,432 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:ProtocolPolicy/get` and `/set`: the server-wide legacy mail
//! protocols switch (legacy-protocols spec). Server-level: a principal in a
//! tenant can neither read nor change it, and turns its own switch instead
//! (LP-9).
//!
//! `/set` does not write the policy itself. It hands what was asked to
//! [`Server::set_protocol_policy`], which applies the locks (LP-21), removes
//! or restores the listener objects (LP-1, LP-5) and closes or opens their
//! sockets (LP-2). What comes back is what actually happened.
//!
//! [`validate_listener`] is the registry's side of it: while the switch is
//! off, no listener it would close may be created, or made by an update
//! (LP-4).
use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult};
use common::{Server, auth::AccessToken, network::legacy::PolicyChange};
use inbuxa_features::security::{
listeners,
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_protocol_policy::{
ProtocolPolicy, ProtocolPolicyProperty as P, ProtocolPolicyValue,
},
request::IntoValid,
};
use jmap_tools::{Key, Map, Value};
use registry::schema::{prelude::Property, structs::NetworkListener};
use types::id::Id;
type PValue = Value<'static, P, ProtocolPolicyValue>;
const ALL: &[P] = &[
P::Id,
P::LegacyProtocols,
P::CloseSubmission,
P::SavedListeners,
P::ChangedAt,
P::ChangedBy,
P::LockedProtocols,
P::WouldClose,
];
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
if access_token.tenant_id().is_some() {
Err(trc::JmapEvent::Forbidden
.into_err()
.details("The server-wide protocol policy is server-level."))
} else {
Ok(())
}
}
/// A saved or would-be-closed listener, as the confirmation shows it (LP-16).
fn listener_value(listener: &SavedListener) -> PValue {
let mut out = Map::with_capacity(3);
out.insert_unchecked(
Key::Property(P::Id),
Value::Str(listener.id.clone().into()),
);
out.insert_unchecked(
Key::Property(P::LegacyProtocols),
Value::Str(listener.protocol.clone().into()),
);
out.insert_unchecked(
Key::Property(P::WouldClose),
Value::Array(
listener
.ports
.iter()
.map(|port| Value::Number((*port as u64).into()))
.collect(),
),
);
Value::Object(out)
}
fn to_value(policy: &Policy, would_close: &[SavedListener], properties: &[P]) -> PValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
P::LegacyProtocols => Value::Str(
match policy.legacy_protocols {
LegacyProtocols::Enabled => "enabled",
LegacyProtocols::Disabled => "disabled",
}
.into(),
),
P::CloseSubmission => Value::Bool(policy.close_submission),
P::SavedListeners => Value::Array(
policy
.saved_listeners
.iter()
.map(listener_value)
.collect(),
),
P::ChangedAt => policy
.changed_at
.map(|at| Value::Number(at.into()))
.unwrap_or(Value::Null),
P::ChangedBy => policy
.changed_by
.as_ref()
.map(|by| Value::Str(by.clone().into()))
.unwrap_or(Value::Null),
// The selector renders these locked rather than carrying its own
// list, so unlocking later needs no admin release (LP-21).
P::LockedProtocols => Value::Array(
LOCKED_PROTOCOLS
.iter()
.map(|protocol| Value::Str((*protocol).into()))
.collect(),
),
// Exactly what turning the switch on would close, by name and
// port, so the confirmation can say so before anything happens
// (LP-16).
P::WouldClose => Value::Array(would_close.iter().map(listener_value).collect()),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// The listeners turning the switch on would close, whatever it is now.
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
let mut hypothetical = policy.clone();
hypothetical.legacy_protocols = LegacyProtocols::Disabled;
hypothetical.apply_locks();
listeners::would_close(server.registry(), &hypothetical).await
}
/// `inbuxa:ProtocolPolicy/get`.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<ProtocolPolicy>,
) -> trc::Result<GetResponse<ProtocolPolicy>> {
assert_server_level(access_token)?;
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(1)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let policy = server.protocol_policy().await?;
// Only worth asking the registry when the answer is wanted.
let would_close = if properties.contains(&P::WouldClose) {
would_close(server, &policy).await?
} else {
Vec::new()
};
match ids {
None => response
.list
.push(to_value(&policy, &would_close, &properties)),
Some(ids) => {
for id in ids {
if id.is_singleton() {
response
.list
.push(to_value(&policy, &would_close, &properties));
} else {
response.push_not_found(id);
}
}
}
}
Ok(response)
}
fn apply(
policy: &mut Policy,
property: &P,
value: &Value<'_, P, ProtocolPolicyValue>,
) -> Result<(), String> {
match property {
P::LegacyProtocols => {
policy.legacy_protocols = match value.as_str().as_deref() {
Some("enabled") => LegacyProtocols::Enabled,
Some("disabled") => LegacyProtocols::Disabled,
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()),
}
}
P::CloseSubmission => {
policy.close_submission = value
.as_bool()
.ok_or_else(|| "must be true or false".to_string())?
}
P::Id => return Err("is immutable".to_string()),
// savedListeners, changedAt, changedBy, lockedProtocols and wouldClose
// are the server's to say (LP-1, LP-16, LP-21).
other if other.is_server_set() => return Err("is set by the server".to_string()),
_ => return Err("is immutable".to_string()),
}
Ok(())
}
/// Puts a property back to its default (a `null` in `/set`).
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
match property {
P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols,
P::CloseSubmission => policy.close_submission = defaults.close_submission,
P::Id => return Err("is immutable".to_string()),
other if other.is_server_set() => return Err("is set by the server".to_string()),
_ => return Err("is immutable".to_string()),
}
Ok(())
}
/// What the server made of the update, when that differs from what was asked.
///
/// A locked property is overruled rather than refused (LP-21), so the client
/// is told by being handed the value that was actually stored. `None` when
/// nothing was overruled, which JMAP reads as "exactly as you asked".
fn updated_value(change: &PolicyChange) -> Option<PValue> {
if change.overruled.is_empty() {
return None;
}
let mut out = Map::with_capacity(change.overruled.len());
for property in &change.overruled {
if *property == "closeSubmission" {
out.insert_unchecked(Key::Property(P::CloseSubmission), Value::Bool(false));
}
}
Some(Value::Object(out))
}
/// `inbuxa:ProtocolPolicy/set`: turns the switch. Unset (`null`) restores a
/// property's default.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, ProtocolPolicy>,
) -> trc::Result<SetResponse<ProtocolPolicy>> {
assert_server_level(access_token)?;
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
for (client_id, _) in request.unwrap_create() {
response.not_created.append(client_id, SetError::singleton());
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(id, SetError::singleton());
}
for (id, value) in request.unwrap_update().into_valid() {
if !id.is_singleton() {
response.not_updated.append(id, SetError::not_found());
continue;
}
let mut policy = server.protocol_policy().await?;
let defaults = Policy::default();
let mut error = None;
for (key, value) in value.into_expanded_object() {
let Key::Property(property) = &key else {
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
break;
};
let result = if matches!(value, Value::Null) {
reset(&mut policy, property, &defaults)
} else {
apply(&mut policy, property, &value)
};
if let Err(why) = result {
error = Some(
SetError::invalid_properties()
.with_property(property.clone())
.with_description(why),
);
break;
}
}
if error.is_none()
&& let Err((property, why)) = policy.check()
{
error = Some(
SetError::invalid_properties()
.with_property(property.parse::<P>().unwrap_or(P::Id))
.with_description(format!("{property} {why}.")),
);
}
match error {
Some(error) => response.not_updated.append(id, error),
None => {
let change = server
.set_protocol_policy(policy, Some(Id::from(access_token.account_id()).to_string()))
.await?;
// An overruled property is reported, not refused: the value
// is specified and the lock is temporary (LP-21). The update
// succeeded, so the client is told by being handed what was
// actually stored.
response.updated.append(id, updated_value(&change));
}
}
}
Ok(response)
}
/// LP-4: while legacy protocols are off, a listener the switch would close
/// may not be created, nor may an update make one. Otherwise a listener could
/// quietly reopen a port the switch is meant to keep closed.
///
/// The rule is the switch's own ([`listeners::closes`]), so a locked protocol
/// or the inbound port is never refused here, and what the switch would close
/// is exactly what can't be added. Putting saved listeners back (LP-5) goes
/// through the registry directly, not through `/set`, so it isn't affected.
pub(crate) async fn validate_listener(
set: &RegistrySetResponse<'_>,
listener: &NetworkListener,
) -> ValidationResult {
let policy = set.server.protocol_policy().await?;
Ok(match listener_refusal(&policy, listener) {
Some((property, why)) => Err(SetError::invalid_properties()
.with_property(property)
.with_description(why)),
None => Ok(ObjectResponse::default()),
})
}
/// Why this listener can't exist under this policy, naming the policy and the
/// property to change, or `None` when it can.
fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Property, String)> {
if !listeners::closes(policy, listener) {
return None;
}
let protocol = listeners::protocol_name(listener.protocol);
// A submission listener closes because of its port, not its protocol
// (LP-3), so the port is what would have to change.
let property = if protocol == "smtp" {
Property::Bind
} else {
Property::Protocol
};
Some((
property,
format!(
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \
listener would reopen a port the switch keeps closed. Turn legacy protocols \
back on first."
),
))
}
#[cfg(test)]
mod tests {
use super::*;
use registry::{
schema::{enums::NetworkListenerProtocol, prelude::SocketAddr},
types::map::Map,
};
use std::str::FromStr;
fn listener(protocol: NetworkListenerProtocol, bind: &str) -> NetworkListener {
NetworkListener {
name: "new".to_string(),
protocol,
bind: Map::new(vec![SocketAddr::from_str(bind).unwrap()]),
..Default::default()
}
}
fn off() -> Policy {
let mut policy = Policy {
legacy_protocols: LegacyProtocols::Disabled,
..Default::default()
};
policy.apply_locks();
policy
}
#[test]
fn on_refuses_nothing() {
let on = Policy::default();
for protocol in [
NetworkListenerProtocol::Imap,
NetworkListenerProtocol::Pop3,
NetworkListenerProtocol::ManageSieve,
] {
assert!(listener_refusal(&on, &listener(protocol, "[::]:1993")).is_none());
}
}
#[test]
fn off_refuses_every_legacy_protocol_naming_the_policy() {
for protocol in [
NetworkListenerProtocol::Imap,
NetworkListenerProtocol::Pop3,
NetworkListenerProtocol::ManageSieve,
] {
let (property, why) =
listener_refusal(&off(), &listener(protocol, "[::]:1993")).expect("refused");
assert_eq!(property, Property::Protocol);
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
}
}
#[test]
fn off_still_allows_what_the_switch_never_closes() {
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
// so there is nothing for a new one to reopen.
for (protocol, bind) in [
(NetworkListenerProtocol::Smtp, "[::]:25"),
(NetworkListenerProtocol::Smtp, "[::]:587"),
(NetworkListenerProtocol::Http, "[::]:443"),
(NetworkListenerProtocol::Lmtp, "[::]:24"),
] {
assert!(
listener_refusal(&off(), &listener(protocol, bind)).is_none(),
"{protocol:?} on {bind}"
);
}
}
}
+5
View File
@@ -502,6 +502,11 @@ impl RegistrySet for Server {
) )
.await? .await?
} }
// inbuxa: legacy-protocols LP-4
ObjectInner::NetworkListener(listener) => {
crate::inbuxa::protocol_policy::validate_listener(&set, listener)
.await?
}
// inbuxa: ME-12 to ME-17 // inbuxa: ME-12 to ME-17
ObjectInner::MaskedEmail(mask) => { ObjectInner::MaskedEmail(mask) => {
let old = match &modification { let old = match &modification {
+78 -36
View File
@@ -9,14 +9,21 @@
#![warn(clippy::cast_possible_wrap)] #![warn(clippy::cast_possible_wrap)]
#![warn(clippy::cast_sign_loss)] #![warn(clippy::cast_sign_loss)]
use common::{BuildServer, config::server::ServerProtocol, manager::boot::BootManager}; use common::{
BuildServer, Inner,
config::server::{Listener, ServerProtocol},
manager::boot::BootManager,
network::TcpAcceptor,
};
use http::HttpSessionManager; use http::HttpSessionManager;
use imap::core::ImapSessionManager; use imap::core::ImapSessionManager;
use managesieve::core::ManageSieveSessionManager; use managesieve::core::ManageSieveSessionManager;
use pop3::Pop3SessionManager; use pop3::Pop3SessionManager;
use services::{StartServices, broadcast::subscriber::spawn_broadcast_subscriber}; use services::{StartServices, broadcast::subscriber::spawn_broadcast_subscriber};
use smtp::{StartQueueManager, core::SmtpSessionManager}; use smtp::{StartQueueManager, core::SmtpSessionManager};
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::sync::watch;
use trc::Collector; use trc::Collector;
use utils::wait_for_shutdown; use utils::wait_for_shutdown;
@@ -70,42 +77,31 @@ async fn main() -> std::io::Result<()> {
} }
// Spawn servers // Spawn servers
let (shutdown_tx, shutdown_rx) = init.servers.spawn(|server, acceptor, shutdown_rx| { // Each listener gets its own shutdown channel, registered under its id, so
match &server.protocol { // the legacy-protocols switch can close one protocol's ports and leave the
ServerProtocol::Smtp | ServerProtocol::Lmtp => server.spawn( // rest accepting (legacy-protocols LP-2). The registry lives in `Data` and
SmtpSessionManager::new(init.inner.clone()), // so outlives the listeners, which it must: it owns the sending ends.
init.inner.clone(), let listener_control = &init.inner.data.listener_control;
acceptor, let spawn_inner = init.inner.clone();
shutdown_rx, let (shutdown_tx, shutdown_rx) =
), init.servers
ServerProtocol::Http => server.spawn( .spawn_with_control(listener_control, |server, acceptor, shutdown_rx| {
HttpSessionManager::new(init.inner.clone()), spawn_listener(&spawn_inner, server, acceptor, shutdown_rx);
init.inner.clone(), });
acceptor,
shutdown_rx, // Leave behind how to spawn a listener, so putting one back opens its port
), // without a restart (LP-5). Only this file knows the session manager for a
ServerProtocol::Imap => server.spawn( // protocol, so only this file can say.
ImapSessionManager::new(init.inner.clone()), let spawn_inner = init.inner.clone();
init.inner.clone(), init.inner
acceptor, .data
shutdown_rx, .listener_control
), .set_spawner(Box::new(move |server, acceptor, shutdown_rx| {
ServerProtocol::Pop3 => server.spawn( spawn_listener(&spawn_inner, server, acceptor, shutdown_rx);
Pop3SessionManager::new(init.inner.clone()), }));
init.inner.clone(),
acceptor,
shutdown_rx,
),
ServerProtocol::ManageSieve => server.spawn(
ManageSieveSessionManager::new(init.inner.clone()),
init.inner.clone(),
acceptor,
shutdown_rx,
),
};
});
// Start broadcast subscriber // Start broadcast subscriber
let inner = init.inner.clone();
spawn_broadcast_subscriber(init.inner, shutdown_rx); spawn_broadcast_subscriber(init.inner, shutdown_rx);
// Wait for shutdown signal // Wait for shutdown signal
@@ -114,11 +110,57 @@ async fn main() -> std::io::Result<()> {
// Shutdown collector // Shutdown collector
Collector::shutdown(); Collector::shutdown();
// Stop services // Stop services, then the listeners: the shutdown sender no longer reaches
// them, since each holds its own channel (LP-2).
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);
inner.data.listener_control.stop_all();
// Wait for services to finish // Wait for services to finish
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
Ok(()) Ok(())
} }
/// Starts one listener under the session manager its protocol calls for.
///
/// Used twice: once for every listener at startup, and again whenever the
/// legacy-protocols switch puts a listener back (LP-5).
fn spawn_listener(
inner: &Arc<Inner>,
server: Listener,
acceptor: TcpAcceptor,
shutdown_rx: watch::Receiver<bool>,
) {
match &server.protocol {
ServerProtocol::Smtp | ServerProtocol::Lmtp => server.spawn(
SmtpSessionManager::new(inner.clone()),
inner.clone(),
acceptor,
shutdown_rx,
),
ServerProtocol::Http => server.spawn(
HttpSessionManager::new(inner.clone()),
inner.clone(),
acceptor,
shutdown_rx,
),
ServerProtocol::Imap => server.spawn(
ImapSessionManager::new(inner.clone()),
inner.clone(),
acceptor,
shutdown_rx,
),
ServerProtocol::Pop3 => server.spawn(
Pop3SessionManager::new(inner.clone()),
inner.clone(),
acceptor,
shutdown_rx,
),
ServerProtocol::ManageSieve => server.spawn(
ManageSieveSessionManager::new(inner.clone()),
inner.clone(),
acceptor,
shutdown_rx,
),
}
}
+8 -1
View File
@@ -2,12 +2,14 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::core::{Command, Session, State, StatusResponse}; use crate::core::{Command, Session, State, StatusResponse};
use common::{ use common::{
auth::AuthRequest, auth::AuthRequest,
network::{SessionStream, limiter::LimiterResult}, network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
}; };
use directory::Credentials; use directory::Credentials;
use imap_proto::{ use imap_proto::{
@@ -65,6 +67,11 @@ impl<T: SessionStream> Session<T> {
} }
}; };
// inbuxa: legacy-protocols LP-6, before the password is looked at
self.server
.refuse_legacy_sign_in(LegacyProtocol::ManageSieve, &credentials)
.await?;
// Authenticate // Authenticate
let access_token = self let access_token = self
.server .server
+8 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -10,7 +12,7 @@ use crate::{
}; };
use common::{ use common::{
auth::AuthRequest, auth::AuthRequest,
network::{SessionStream, limiter::LimiterResult}, network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
}; };
use directory::Credentials; use directory::Credentials;
use mail_parser::decoders::base64::base64_decode; use mail_parser::decoders::base64::base64_decode;
@@ -61,6 +63,11 @@ impl<T: SessionStream> Session<T> {
} }
pub async fn handle_auth(&mut self, credentials: Credentials) -> trc::Result<()> { pub async fn handle_auth(&mut self, credentials: Credentials) -> trc::Result<()> {
// inbuxa: legacy-protocols LP-6, before the password is looked at
self.server
.refuse_legacy_sign_in(LegacyProtocol::Pop3, &credentials)
.await?;
// Authenticate // Authenticate
let access_token = self let access_token = self
.server .server
+26 -1
View File
@@ -2,10 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::core::Session; use crate::core::Session;
use common::{auth::AuthRequest, network::SessionStream}; use common::{
auth::AuthRequest,
network::{SessionStream, legacy::LegacyProtocol},
};
use directory::Credentials; use directory::Credentials;
use mail_parser::decoders::base64::base64_decode; use mail_parser::decoders::base64::base64_decode;
use registry::schema::enums::Permission; use registry::schema::enums::Permission;
@@ -108,6 +113,26 @@ impl<T: SessionStream> Session<T> {
} }
pub async fn authenticate(&mut self, credentials: Credentials) -> Result<bool, ()> { pub async fn authenticate(&mut self, credentials: Credentials) -> Result<bool, ()> {
// inbuxa: legacy-protocols LP-6. Refused before the password is looked
// at, and not counted as an authentication error (LP-11). Only mail
// apps authenticate, so this never touches inbound delivery (LP-3).
if let Err(err) = self
.server
.refuse_legacy_sign_in(LegacyProtocol::Submission, &credentials)
.await
{
let refused = err.matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused));
trc::error!(err.span_id(self.data.session_id));
if refused {
self.write(LegacyProtocol::Submission.refusal().as_bytes())
.await?;
} else {
self.write(b"454 4.7.0 Temporary authentication failure\r\n")
.await?;
}
return Ok(false);
}
// Authenticate // Authenticate
let result = self let result = self
.server .server
+1 -1
View File
@@ -19,7 +19,7 @@ tokio = { version = "1.53", features = ["net", "macros"] }
psl = "2" psl = "2"
hyper = { version = "1.11.1", features = ["server", "http1", "http2"] } hyper = { version = "1.11.1", features = ["server", "http1", "http2"] }
idna = "1.1" idna = "1.1"
decancer = "3.3.3" decancer = "4.0.0"
unicode-security = "0.1.2" unicode-security = "0.1.2"
infer = "0.22" infer = "0.22"
hashify = "0.2" hashify = "0.2"
+1 -2
View File
@@ -1136,7 +1136,7 @@ impl<'x> Tokens<'x> {
{ {
if word.len() > MAX_TOKEN_LENGTH { if word.len() > MAX_TOKEN_LENGTH {
self.insert(Token::Word { self.insert(Token::Word {
value: truncate_word(cured_word.as_str(), MAX_TOKEN_LENGTH) value: truncate_word(&cured_word, MAX_TOKEN_LENGTH)
.to_string() .to_string()
.into(), .into(),
}); });
@@ -1282,7 +1282,6 @@ impl Token<'static> {
} else if !is_ascii { } else if !is_ascii {
let word: String = if let Ok(cured) = decancer::cure(s, decancer::Options::default()) { let word: String = if let Ok(cured) = decancer::cure(s, decancer::Options::default()) {
cured cured
.as_str()
.chars() .chars()
.filter(|ch| ch.is_alphabetic()) .filter(|ch| ch.is_alphabetic())
.take(MAX_TOKEN_LENGTH) .take(MAX_TOKEN_LENGTH)
+1 -1
View File
@@ -9,7 +9,7 @@ types = { path = "../types" }
nlp = { path = "../nlp" } nlp = { path = "../nlp" }
trc = { path = "../trc" } trc = { path = "../trc" }
registry = { path = "../registry" } registry = { path = "../registry" }
rocksdb = { version = "0.24", optional = true, features = ["multi-threaded-cf"] } rocksdb = { version = "0.25", optional = true, features = ["multi-threaded-cf"] }
foundationdb = { version = "0.11", features = ["embedded-fdb-include", "fdb-7_4"], optional = true } foundationdb = { version = "0.11", features = ["embedded-fdb-include", "fdb-7_4"], optional = true }
rusqlite = { version = "0.40", features = ["bundled"], optional = true } rusqlite = { version = "0.40", features = ["bundled"], optional = true }
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"], optional = true } rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"], optional = true }
+5 -2
View File
@@ -8,8 +8,9 @@
// This file is auto-generated. Do not edit directly. // This file is auto-generated. Do not edit directly.
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54) // inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
pub const TOTAL_EVENT_COUNT: usize = 642; // auth.legacy-protocol-refused (legacy-protocols LP-6)
pub const TOTAL_EVENT_COUNT: usize = 643;
pub const TOTAL_METRIC_COUNT: usize = 369; pub const TOTAL_METRIC_COUNT: usize = 369;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -116,6 +117,8 @@ pub enum AuthEvent {
Error = 34, Error = 34,
Warning = 595, Warning = 595,
CredentialExpired = 276, CredentialExpired = 276,
// inbuxa: legacy-protocols LP-6
LegacyProtocolRefused = 642,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+16
View File
@@ -56,6 +56,8 @@ impl EventType {
b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired), b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired),
b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts), b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts),
b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration), b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration),
// inbuxa: legacy-protocols LP-6
b"auth.legacy-protocol-refused" => EventType::Auth(AuthEvent::LegacyProtocolRefused),
b"auth.error" => EventType::Auth(AuthEvent::Error), b"auth.error" => EventType::Auth(AuthEvent::Error),
b"auth.warning" => EventType::Auth(AuthEvent::Warning), b"auth.warning" => EventType::Auth(AuthEvent::Warning),
b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired), b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired),
@@ -705,6 +707,8 @@ impl EventType {
EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required", EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required",
EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts", EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts",
EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration", EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration",
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "auth.legacy-protocol-refused",
EventType::Auth(AuthEvent::Error) => "auth.error", EventType::Auth(AuthEvent::Error) => "auth.error",
EventType::Auth(AuthEvent::Warning) => "auth.warning", EventType::Auth(AuthEvent::Warning) => "auth.warning",
EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired", EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired",
@@ -1489,6 +1493,8 @@ impl EventType {
EventType::Auth(AuthEvent::MfaRequired) => 36, EventType::Auth(AuthEvent::MfaRequired) => 36,
EventType::Auth(AuthEvent::TooManyAttempts) => 38, EventType::Auth(AuthEvent::TooManyAttempts) => 38,
EventType::Auth(AuthEvent::ClientRegistration) => 555, EventType::Auth(AuthEvent::ClientRegistration) => 555,
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => 642,
EventType::Auth(AuthEvent::Error) => 34, EventType::Auth(AuthEvent::Error) => 34,
EventType::Auth(AuthEvent::Warning) => 595, EventType::Auth(AuthEvent::Warning) => 595,
EventType::Auth(AuthEvent::CredentialExpired) => 276, EventType::Auth(AuthEvent::CredentialExpired) => 276,
@@ -2137,6 +2143,8 @@ impl EventType {
36 => Some(EventType::Auth(AuthEvent::MfaRequired)), 36 => Some(EventType::Auth(AuthEvent::MfaRequired)),
38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)), 38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)),
555 => Some(EventType::Auth(AuthEvent::ClientRegistration)), 555 => Some(EventType::Auth(AuthEvent::ClientRegistration)),
// inbuxa: legacy-protocols LP-6
642 => Some(EventType::Auth(AuthEvent::LegacyProtocolRefused)),
34 => Some(EventType::Auth(AuthEvent::Error)), 34 => Some(EventType::Auth(AuthEvent::Error)),
595 => Some(EventType::Auth(AuthEvent::Warning)), 595 => Some(EventType::Auth(AuthEvent::Warning)),
276 => Some(EventType::Auth(AuthEvent::CredentialExpired)), 276 => Some(EventType::Auth(AuthEvent::CredentialExpired)),
@@ -2848,6 +2856,8 @@ impl EventType {
EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info, EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info,
EventType::Auth(AuthEvent::Success) => Level::Info, EventType::Auth(AuthEvent::Success) => Level::Info,
EventType::Auth(AuthEvent::ClientRegistration) => Level::Info, EventType::Auth(AuthEvent::ClientRegistration) => Level::Info,
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => Level::Info,
EventType::Calendar(CalendarEvent::AlarmSent) => Level::Info, EventType::Calendar(CalendarEvent::AlarmSent) => Level::Info,
EventType::Calendar(CalendarEvent::ItipMessageSent) => Level::Info, EventType::Calendar(CalendarEvent::ItipMessageSent) => Level::Info,
EventType::Calendar(CalendarEvent::ItipMessageReceived) => Level::Info, EventType::Calendar(CalendarEvent::ItipMessageReceived) => Level::Info,
@@ -3187,6 +3197,8 @@ impl EventType {
EventType::Auth(AuthEvent::MfaRequired) => "Missing MFA token for authentication", EventType::Auth(AuthEvent::MfaRequired) => "Missing MFA token for authentication",
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration", EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration",
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "Legacy mail protocol sign-in refused",
EventType::Auth(AuthEvent::Error) => "Authentication error", EventType::Auth(AuthEvent::Error) => "Authentication error",
EventType::Auth(AuthEvent::Warning) => "Authentication warning", EventType::Auth(AuthEvent::Warning) => "Authentication warning",
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
@@ -3951,6 +3963,8 @@ impl EventType {
} }
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error", EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error",
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "This server allows only INBUXA webmail and JMAP apps",
EventType::Auth(AuthEvent::Error) => "Authentication error", EventType::Auth(AuthEvent::Error) => "Authentication error",
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error", EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error",
@@ -4259,6 +4273,8 @@ impl EventType {
EventType::Auth(AuthEvent::MfaRequired), EventType::Auth(AuthEvent::MfaRequired),
EventType::Auth(AuthEvent::TooManyAttempts), EventType::Auth(AuthEvent::TooManyAttempts),
EventType::Auth(AuthEvent::ClientRegistration), EventType::Auth(AuthEvent::ClientRegistration),
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused),
EventType::Auth(AuthEvent::Error), EventType::Auth(AuthEvent::Error),
EventType::Auth(AuthEvent::Warning), EventType::Auth(AuthEvent::Warning),
EventType::Auth(AuthEvent::CredentialExpired), EventType::Auth(AuthEvent::CredentialExpired),
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
-DHPbeChvvEHbLbAO3wDU6KCP8HrzaWZHfkka30YoIU C32Zc43ANGr52j0cZkTq3IEPrGtbFUX0d2-R91noCho
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env python3
"""Local end-to-end check of the legacy-protocols switch.
Run it with `python3 tests/e2e/legacy_protocols.py` after
`cargo build -p inbuxa`. Needs Docker. Working state goes under target/e2e.
Boots the debug binary, turns the switch off, and checks that the IMAP and
POP3 ports really stop accepting while SMTP, submission and JMAP keep going.
Then turns it back on and checks the ports come back.
This is the part unit tests cannot reach: whether a socket actually closes on
a running server (LP-2), and whether a listener put back actually binds again
(LP-5). Acceptance tests 15, 17 and 18.
It also checks the second lock (LP-6): while the switch is off, sign-in over
submission -- locked open -- is refused with the spec's words, with the right
password and with a wrong one, and refusals never add up to a disconnect
(LP-11). And that a normal IMAP sign-in works with the switch on, before and
after. And that while it is off, no listener the switch would close can be
created, or made by an update (LP-4, test 4), and nothing advertises what is
closed: autoconfig, autodiscover and PACC offer no IMAP, POP3 or submission,
and the suggested zone marks their SRV names not offered (LP-7, test 5).
Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1.
"""
import base64, json, os, secrets, shutil, socket, ssl, subprocess, sys, time, urllib.request, urllib.error
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DIR = f"{ROOT}/target/e2e"
NAME = "inbuxa-legacy"
HTTP = "http://127.0.0.1:18080"
# port -> how to tell a live server from Docker's proxy. Publishing a port
# makes the host side accept connections whether or not anything is listening
# inside the container, so a bare connect proves nothing: each port has to be
# made to speak.
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025}
TLS_PORTS = {18993, 18995, 18465}
SMTP_REFUSAL = ("535 5.7.0 This server allows only INBUXA webmail and JMAP apps. "
"This mail app can't send.")
INBUXA = "urn:inbuxa:jmap"
failures = []
def check(cond, what):
print(("ok " if cond else "FAIL ") + what)
if not cond:
failures.append(what)
def secret_file(name, value=None):
path = f"{DIR}/secrets/{name}"
if value is None:
value = secrets.token_urlsafe(24)
with open(path, "w") as f:
f.write(value)
os.chmod(path, 0o600)
return value
def docker(*args, check_rc=True):
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check_rc)
def start(env_file=None):
args = ["run", "-d", "--name", NAME, "--user", f"{os.getuid()}:{os.getgid()}",
"--entrypoint", "/usr/local/bin/inbuxa",
"-v", f"{ROOT}/target/debug/inbuxa:/usr/local/bin/inbuxa:ro",
"-v", f"{DIR}/etc-legacy:/etc/inbuxa", "-v", f"{DIR}/data-legacy:/var/lib/inbuxa",
"-p", "127.0.0.1:18080:8080",
"-p", f"127.0.0.1:{PORTS['submissions']}:465",
"-p", f"127.0.0.1:{PORTS['imap']}:993",
"-p", f"127.0.0.1:{PORTS['pop3']}:995",
"-p", f"127.0.0.1:{PORTS['smtp']}:25"]
if env_file:
args += ["--env-file", env_file]
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
docker(*args)
for _ in range(120):
try:
urllib.request.urlopen(f"{HTTP}/.well-known/jmap", timeout=2)
except urllib.error.HTTPError:
return
except Exception:
time.sleep(1)
continue
return
sys.exit("server didn't come up: " + docker("logs", "--tail", "40", NAME, check_rc=False).stderr)
def stop():
docker("rm", "-f", NAME, check_rc=False)
def jmap(user, password, calls, using=("urn:ietf:params:jmap:core", "urn:stalwart:jmap", INBUXA)):
body = json.dumps({"using": list(using), "methodCalls": calls}).encode()
req = urllib.request.Request(f"{HTTP}/jmap/", data=body, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode())
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)["methodResponses"]
def one(user, password, method, args):
return jmap(user, password, [[method, args, "0"]])[0]
def session(user, password):
req = urllib.request.Request(f"{HTTP}/jmap/session")
req.add_header("Authorization", "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode())
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
def accepts(port, timeout=5):
"""Whether a server is really answering on this port.
Docker's published port accepts and then closes when nothing is listening
in the container, so connecting is not enough. A TLS port must complete a
handshake; a plain one must send its greeting.
"""
try:
with socket.create_connection(("127.0.0.1", port), timeout=timeout) as raw:
if port in TLS_PORTS:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with ctx.wrap_socket(raw):
return True
raw.settimeout(timeout)
return bool(raw.recv(1))
except (OSError, ssl.SSLError):
return False
def tls(port, timeout=10):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=timeout))
def lines(sock):
"""Yields reply lines, CRLF stripped."""
buf = b""
while True:
while b"\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
return
buf += chunk
line, buf = buf.split(b"\r\n", 1)
yield line.decode(errors="replace")
def imap_login(port, user, password):
"""The tagged reply to LOGIN, over implicit TLS."""
with tls(port) as sock:
read = lines(sock)
next(read) # greeting
quote = lambda v: '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
sock.sendall(f"a1 LOGIN {quote(user)} {quote(password)}\r\n".encode())
for line in read:
if line.startswith("a1 "):
return line[3:]
return ""
def smtp_auths(port, user, passwords):
"""The reply to AUTH PLAIN for each password in turn, on one connection.
A reply of "" means the server hung up."""
# Every connection reaches the server from Docker's gateway, one IP, and
# the stock inbound throttle takes five a second from it. The port checks
# just before can use those up, so wait the second out.
time.sleep(1.1)
replies = []
with tls(port) as sock:
read = lines(sock)
next(read) # greeting
sock.sendall(b"EHLO e2e.test\r\n")
for line in read:
if line[3:4] == " ":
break
for password in passwords:
token = base64.b64encode(f"\0{user}\0{password}".encode()).decode()
try:
sock.sendall(f"AUTH PLAIN {token}\r\n".encode())
replies.append(next(read, ""))
except OSError:
replies.append("")
return replies
def advertised(admin, admin_pw):
"""What each client-configuration answer and the suggested zone offer."""
with urllib.request.urlopen(f"{HTTP}/mail/[email protected]",
timeout=30) as resp:
autoconfig = resp.read().decode()
body = ('<?xml version="1.0" encoding="utf-8"?><Autodiscover xmlns="http://schemas.'
'microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request>'
'<EMailAddress>[email protected]</EMailAddress><AcceptableResponseSchema>http://'
'schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a'
'</AcceptableResponseSchema></Request></Autodiscover>').encode()
req = urllib.request.Request(f"{HTTP}/autodiscover/autodiscover.xml", data=body, method="POST")
req.add_header("Content-Type", "text/xml")
with urllib.request.urlopen(req, timeout=30) as resp:
autodiscover = resp.read().decode()
with urllib.request.urlopen(f"{HTTP}/.well-known/user-agent-configuration.json",
timeout=30) as resp:
pacc = json.load(resp).get("protocols", {})
got = one(admin, admin_pw, "x:Domain/get", {"ids": None, "properties": ["name", "dnsZoneFile"]})
zone = next((d.get("dnsZoneFile") or "" for d in got[1].get("list", [])
if d.get("name") == "legacy.test"), "")
srv = {}
for line in zone.splitlines():
fields = line.split()
if "SRV" in fields and fields[0].startswith("_"):
srv[fields[0].split(".")[0] + "." + fields[0].split(".")[1]] = fields[-1]
return {
"autoconfig": {t for t in ("imap", "pop3", "smtp") if f'type="{t}"' in autoconfig},
"autodiscover": {t for t in ("IMAP", "POP3", "SMTP") if f"<Type>{t}</Type>" in autodiscover},
"pacc": {t for t in ("imap", "pop3", "smtp", "managesieve") if t in pacc},
"jmap": "jmap" in pacc,
"srv": srv,
}
def settle(port, want, tries=30):
"""Wait for a port to reach the wanted state, so the check is not a race."""
for _ in range(tries):
if accepts(port) == want:
return True
time.sleep(0.5)
return False
def main():
stop()
# Start from nothing. A half-bootstrapped data directory left by an
# earlier run is no longer in bootstrap mode, and the recovery admin
# stops authenticating the moment a real admin exists.
for sub in ("etc-legacy", "data-legacy"):
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
for sub in ("etc-legacy", "data-legacy", "secrets"):
os.makedirs(f"{DIR}/{sub}", exist_ok=True)
os.chmod(f"{DIR}/secrets", 0o700)
stop()
# First boot, with a recovery admin from an env file.
recovery = secret_file("legacy-recovery")
env_file = f"{DIR}/secrets/legacy-env"
with open(env_file, "w") as f:
f.write(f"INBUXA_RECOVERY_ADMIN=admin:{recovery}\n")
os.chmod(env_file, 0o600)
start(env_file)
got = one("admin", recovery, "x:Bootstrap/get", {"ids": None})
singleton = got[1]["list"][0]["id"]
res = one("admin", recovery, "x:Bootstrap/set", {"update": {singleton: {
"serverHostname": "mail.legacy.test", "defaultDomain": "legacy.test",
"requestTlsCertificate": False}}})
updated = res[1].get("updated", {}).get(singleton)
check(bool(updated), "bootstrap completed")
if not updated:
sys.exit(json.dumps(res))
admin, admin_pw = updated["username"], secret_file("legacy-admin", updated["secret"])
stop()
start()
sess = session(admin, admin_pw)
account = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0]
policy_get = {"accountId": account, "ids": None}
policy_set = lambda update: {"accountId": account, "update": {"singleton": update}}
# The ports we expect a default install to be accepting on.
check(accepts(PORTS["imap"]), "IMAP accepts before the switch")
check(accepts(PORTS["pop3"]), "POP3 accepts before the switch")
check(accepts(PORTS["submissions"]), "submission accepts before the switch")
check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch")
# What is advertised with the switch on -- the control for LP-7.
before = advertised(admin, admin_pw)
print(" advertised before:", {k: sorted(v) if isinstance(v, set) else v
for k, v in before.items() if k != "srv"})
check(before["autoconfig"] and before["autodiscover"],
"autoconfig and autodiscover offer mail apps a server with the switch on")
check(before["srv"].get("_imaps._tcp", ".") != ".",
"the suggested zone offers IMAP with the switch on")
# A normal sign-in works with the switch on -- the control for LP-6.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"IMAP sign-in works with the switch on")
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
"submission sign-in works with the switch on")
# What the screen reads: the locked set and what would close (LP-16, LP-21).
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
if got[0] != "inbuxa:ProtocolPolicy/get":
sys.exit("ProtocolPolicy/get failed: " + json.dumps(got))
policy = got[1]["list"][0]
check(policy["legacyProtocols"] == "enabled", "switch starts enabled")
check(set(policy["lockedProtocols"]) >= {"smtp", "http"},
"SMTP and JMAP report as locked (LP-21)")
would = {l["id"] for l in policy["wouldClose"]}
print(" wouldClose:", sorted(would))
check(would, "wouldClose names the listeners that would close (LP-16)")
# Turn it off, and ask for submission to close too: the lock must overrule.
res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
policy_set({"legacyProtocols": "disabled", "closeSubmission": True}))
if not res[1].get("updated"):
sys.exit("ProtocolPolicy/set failed: " + json.dumps(res))
overruled = res[1]["updated"].get("singleton")
check(overruled is not None and overruled.get("closeSubmission") is False,
"closeSubmission overruled to false and reported (LP-21, test 18)")
# The ports themselves (LP-1, LP-2, LP-3, test 15).
check(settle(PORTS["imap"], False), "IMAP stopped accepting")
check(settle(PORTS["pop3"], False), "POP3 stopped accepting")
check(accepts(PORTS["smtp"]), "inbound SMTP still accepts (LP-3)")
check(accepts(PORTS["submissions"]), "submission still accepts, being locked (LP-21)")
# JMAP still works, which is the whole point of locking it.
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
check(got[0] == "inbuxa:ProtocolPolicy/get", "JMAP still works while the switch is off")
policy = got[1]["list"][0]
check(policy["legacyProtocols"] == "disabled", "switch reads back disabled")
saved = {l["id"] for l in policy["savedListeners"]}
print(" savedListeners:", sorted(saved))
check(saved, "the closed listeners were saved (LP-1)")
# The second lock (LP-6). Submission stays open, being locked, so sign-in
# over it is refused instead -- right password or wrong, the same words,
# and never enough of them to be thrown off (LP-11, test 2, test 18).
replies = smtp_auths(PORTS["submissions"], admin, [admin_pw] + ["wrong"] * 6)
check(replies[0] == SMTP_REFUSAL, "submission refuses the right password (LP-6)")
check(all(r == SMTP_REFUSAL for r in replies[1:]),
"submission refuses wrong passwords the same way, and doesn't hang up (LP-11)")
if not all(r == SMTP_REFUSAL for r in replies):
print(" replies:", replies)
# Nothing advertises what is closed (LP-7, test 5).
during = advertised(admin, admin_pw)
check(not during["autoconfig"], "autoconfig offers no IMAP, POP3 or submission (LP-7)")
check(not during["autodiscover"], "autodiscover offers no IMAP, POP3 or submission (LP-7)")
check(not during["pacc"] and during["jmap"], "PACC offers JMAP and nothing legacy (LP-7)")
names = ("_imap._tcp", "_imaps._tcp", "_pop3._tcp", "_pop3s._tcp",
"_submission._tcp", "_submissions._tcp")
offered = {n: t for n, t in during["srv"].items() if n in names and t != "."}
check(not offered and "_imaps._tcp" in during["srv"],
"the suggested zone marks the legacy SRV names not offered, target . (LP-7)")
if offered or "_imaps._tcp" not in during["srv"]:
print(" srv:", during["srv"])
# No listener the switch would close can be added while it is off (LP-4,
# test 4), and the refusal names the policy.
res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": {
"name": "imap-new", "protocol": "imap", "bind": {"0.0.0.0:1993": True},
"tlsImplicit": True}}})
refused = (res[1].get("notCreated") or {}).get("m") or {}
check(refused.get("type") == "invalidProperties"
and "protocol" in (refused.get("properties") or [])
and "inbuxa:ProtocolPolicy" in (refused.get("description") or ""),
"creating an IMAP listener is refused, naming the policy (LP-4)")
if not refused:
print(" reply:", json.dumps(res[1])[:300])
# What the switch never closes can still be added; turning it into a
# listener the switch would close is refused like creating one.
res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"s": {
"name": "submission-extra", "protocol": "smtp", "bind": {"0.0.0.0:2587": True}}}})
extra = (res[1].get("created") or {}).get("s", {}).get("id")
check(extra is not None, "an SMTP listener can still be created, being locked (LP-4, LP-21)")
if extra:
res = one(admin, admin_pw, "x:NetworkListener/set",
{"update": {extra: {"protocol": "imap"}}})
refused = (res[1].get("notUpdated") or {}).get(extra) or {}
check(refused.get("type") == "invalidProperties",
"turning it into an IMAP listener is refused (LP-4)")
one(admin, admin_pw, "x:NetworkListener/set", {"destroy": [extra]})
# A restart must not reopen them: the objects are gone, not just the sockets.
stop()
start()
check(settle(PORTS["imap"], False), "IMAP still closed after a restart")
check(accepts(PORTS["smtp"]), "inbound SMTP still accepts after a restart")
# Turn it back on: the listeners come back and bind again (LP-5).
res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
policy_set({"legacyProtocols": "enabled"}))
if "updated" not in res[1]:
sys.exit("ProtocolPolicy/set back on failed: " + json.dumps(res))
check(settle(PORTS["imap"], True), "IMAP accepts again without a restart (LP-5)")
check(settle(PORTS["pop3"], True), "POP3 accepts again without a restart (LP-5)")
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
policy = got[1]["list"][0]
check(policy["legacyProtocols"] == "enabled", "switch reads back enabled")
check(not policy["savedListeners"], "savedListeners is empty again (LP-5)")
after = advertised(admin, admin_pw)
check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"],
"autoconfig and the suggested zone offer them again once back on")
# And sign-in works again, with no restart.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"IMAP sign-in works again once the switch is back on")
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
"submission sign-in works again once the switch is back on")
print()
if failures:
print(f"{len(failures)} FAILED:")
for f in failures:
print(" - " + f)
else:
print("all checks passed")
return 1 if failures else 0
if __name__ == "__main__":
rc = 1
try:
rc = main()
finally:
if not os.environ.get("KEEP"):
stop()
sys.exit(rc)