Author SHA1 Message Date
jcoffey-dev e223f7d327 Release 2026.9.24.3
ci / fork-checks (pull_request) Successful in 45s
ci / build (pull_request) Successful in 4m26s
2026.9.24.3 is 2026.9.24.2 plus the image build fix; 2026.9.24.2's tag never
published an image. Everything in 2026.9.24.2's notes applies.
2026-09-22 23:04:59 -07:00
jcoffey-dev 30df055e39 Image build: put the vendored crate where cargo chef cooks
#27 let the build context see vendor/, but the Dockerfile cooks the
dependencies before it copies the tree, from a recipe that carries only the
workspace's manifests. [patch.crates-io] points sieve-rs at vendor/, so the
cook failed the same way: failed to read /build/vendor/sieve-rs/Cargo.toml.
That's why 2026.9.24.2's publish failed. The builder stage now copies
vendor/ before cooking; a local build got past it into compiling the
dependencies.

context-check.py now also checks that each patched path is copied into the
cooking stage before the cook, and fails on the Dockerfile as it was.
2026-09-22 23:04:50 -07:00
jcoffey-dev 5393c4405a Merge pull request 'Release 2026.9.24.2' (#29) from release/2026.9.24.2 into main
ci / fork-checks (push) Successful in 50s
publish / version (push) Successful in 24s
publish / publish (push) Failing after 2m38s
publish / release (push) Skipped
publish / binaries (push) Skipped
ci / build (push) Successful in 22m35s
Reviewed-on: #29
2026-09-23 05:47:10 +00:00
jcoffey-dev cc532b914c Release 2026.9.24.2
ci / fork-checks (pull_request) Successful in 1m49s
ci / build (pull_request) Successful in 4m8s
Replaces 2026.9.24, whose tag predates the image build fix (#27) and never
published. Carries everything 2026.9.24 did -- upstream 0.16.23 and its
fixes, the scim release-profile fix -- and since then:

- identifiers renamed from the upstream name, with no aliases: the JMAP
  registry capability is urn:inbuxa:jmap:registry, WebDAV tokens
  urn:inbuxa:dav*, Sieve extensions vnd.inbuxa.*, the web interface client
  inbuxa-webui; INBUXA_* settings only. Deploy with admin and webmail
  releases that use the new names.
- the brand in lowercase where people see it.
- the spam filter rules bundled with the server; on first start they add
  the AI classifier's LLM_* scores.
- a Local AI page link in Settings › Spam Filter, for the admin release
  that draws it.
- two start-up migrations: the spam model moves to its renamed keys, and
  the web interface's old OAuth client is retired.
2026-09-22 22:40:17 -07:00
jcoffey-dev c09eff2214 Merge pull request 'Bundle the spam filter rules with the server, and link the Local AI page' (#28) from fork/bundled-spam-rules into main
ci / fork-checks (push) Successful in 20s
ci / build (push) Canceled after 7m16s
Reviewed-on: #28
2026-09-23 05:39:50 +00:00
jcoffey-dev eba4c7a32e Settings › Spam Filter gains Local AI, the AI spam filtering setup page
ci / fork-checks (pull_request) Successful in 14s
ci / build (pull_request) Successful in 4m23s
Adds a link to CustomComponent/LocalAi in the packaged schema's Settings ›
Spam Filter, above LLM Classifier, and updates the schema hash so admins
fetch the new layout rather than a cached one.

INBUXA Admin draws the page (feature/local-ai-setup); this makes it
reachable. An admin from before that page would show "Unknown component"
here, so this lands after the admin release that carries it.
2026-09-22 22:08:50 -07:00
jcoffey-dev 17426f6d60 Bundle the spam filter rules with the server
The server fetched upstream's latest published rules from GitHub at run
time: a version nobody here tested, code-like expressions from an account
we don't control, and the upstream name as a default in the admin form.

The published rules of spam-filter v3.0.2 are now embedded
(resources/spam-filter/, MIT, in THIRD-PARTY.md) and used whenever no other
source is configured. An empty setting and upstream's old default both mean
the bundled rules, so existing installs switch without a settings change;
the URL stays an operator override (https:// or file://). The schema default
is dropped and its description says what empty means, and the strip's
rename pass does the same to each import.

Rules load on first boot as before, and again whenever the bundled version
differs from the last one loaded, which only adds missing rules and tags.
That brings the AI classifier's LLM_* scores to installs that predate them:
production has none today.

upstream-watch now also opens an issue when spam-filter publishes a newer
release; resources/spam-filter/README.md says how to take it.

The antispam test now runs on the bundled rules, the path production
takes; SPAM_RULES_URL tests another set. Unit tests cover the URL handling
and that the bundled rules parse and score the AI tags as the AI spec says.
2026-09-22 22:01:30 -07:00
jcoffey-dev d7c9416713 Merge pull request 'Let the image build see the dependency Cargo patches' (#27) from fix/vendor-in-build-context into main
ci / fork-checks (push) Successful in 14s
ci / build (push) Successful in 31m11s
2026-09-23 04:48:35 +00:00
jcoffey-dev 238079da66 Let the image build see the dependency Cargo patches
ci / fork-checks (pull_request) Successful in 49s
ci / build (pull_request) Successful in 4m22s
The rename pass vendored a patched sieve-rs and pointed Cargo.toml's
[patch.crates-io] at vendor/sieve-rs. .dockerignore ignores everything and
re-includes a short list that did not have vendor on it, so the image build
had no such directory and stopped at

    failed to load source for dependency `sieve-rs`
    failed to read /build/vendor/sieve-rs/Cargo.toml

CI could not have caught that: it builds from a checkout, where the
directory is simply there, and only the image build has a context to prune.
The first that was known about it was a tag that had already been pushed.

So: vendor is re-included, and tools/fork/context-check.py now asserts the
thing that was quietly assumed -- every path a [patch] section names exists
and survives .dockerignore. It runs beside the other fork checks and takes
no toolchain.

Also, the comments in .dockerignore started with // , which Docker does not
read as a comment: they were patterns that happened to match nothing. They
are # now.
2026-09-22 21:43:37 -07:00
jcoffey-dev 0d8caaa514 Merge pull request 'Compile the scim crate in release, and check that profile in CI' (#26) from fix/scim-recursion-limit into main
ci / fork-checks (push) Successful in 1m11s
publish / version (push) Successful in 58s
publish / publish (push) Failing after 34s
publish / release (push) Skipped
publish / binaries (push) Skipped
ci / build (push) Canceled after 25m24s
2026-09-23 04:23:07 +00:00
jcoffey-dev ce6882fe93 Merge pull request 'queue_retry test: measure retries from when each attempt started' (#25) from fix/queue-retry-test into main
ci / fork-checks (push) Canceled after 1m30s
ci / build (push) Canceled after 1m30s
Reviewed-on: #25
2026-09-23 04:21:37 +00:00
jcoffey-dev 3df042e7d4 Compile the scim crate in release, and check that profile in CI
ci / name-check (pull_request) Successful in 3m31s
ci / build (pull_request) Successful in 7m28s
v2026.9.24 was tagged on a commit CI had passed, and its release build could
not compile crates/scim at all:

  error: queries overflow the depth limit!
    = note: query depth increased by 130 when computing layout of
      {async fn body of context::<impl ...>::writable_domain()}

The crate is ours, and the failure is profile-dependent: the release profile
computes those async fn layouts in one go and goes past rustc's default query
depth, while the dev profile never gets that far. CI builds dev, so CI was
green on a commit that could not be released. The tag produced no image and
no release, which is the one merciful part.

Two changes:

- #![recursion_limit = "256"] on the crate, which is what rustc itself
  suggests, with a note saying why it only shows up in release. Proved by
  building -p scim in release locally: it now finishes.

- CI builds the release profile too, on pushes to main. Pull requests stay
  on dev, where the wait is worth less. A few minutes per merge is cheaper
  than learning this from a tag, which throws away a multi-architecture
  build and leaves a version half-cut.
2026-09-22 21:13:50 -07:00
jcoffey-dev 64385007c1 Merge pull request 'Fix the antispam test: pin the rules it scores against, stop live Pyzor' (#24) from fix/antispam-test into main
ci / fork-checks (push) Successful in 2m4s
ci / build (push) Successful in 6m26s
Reviewed-on: #24
2026-09-23 04:12:40 +00:00
jcoffey-dev 4d794c6a65 Merge pull request 'Fork/brand lowercase' (#23) from fork/brand-lowercase into main
ci / fork-checks (push) Successful in 15s
ci / build (push) Canceled after 31s
Reviewed-on: #23
2026-09-23 04:12:06 +00:00
jcoffey-dev a404ca89f0 Merge pull request 'Fork/rename upstream identifiers' (#22) from fork/rename-upstream-identifiers into main
ci / fork-checks (push) Successful in 17s
ci / build (push) Canceled after 27s
Reviewed-on: #22
2026-09-23 04:11:34 +00:00
jcoffey-dev 79f54add2f Merge pull request 'Fork tooling: a build check and a rename pass in the strip, a notice check in CI' (#21) from fork/strip-build-check-and-notices into main
ci / fork-checks (push) Successful in 48s
ci / build (push) Canceled after 1m2s
Reviewed-on: #21
2026-09-23 04:10:32 +00:00
jcoffey-dev 86bf2432a2 queue_retry test: measure retries from when each attempt started
ci / name-check (pull_request) Successful in 2m30s
ci / build (pull_request) Successful in 7m33s
The server sets a deferred recipient's next retry from its clock when the
attempt defers, in whole seconds. The test subtracted its own clock taken
when the loop next saw the message, after saving and reporting, so
whenever that lag crossed a second boundary the 2 s retry measured 1 s
and the test failed. Under load, after the other SMTP tests, that was
most runs.

It now measures from when the test started the attempt, which the
server's deferral can only follow, by under a second: each retry is its
interval or one more. Each position is still checked against its own
interval, so a wrong schedule still fails.
2026-09-22 20:55:42 -07:00
jcoffey-dev 5be578ba3c antispam test: run it serially, like the tests sharing its port
ci / name-check (pull_request) Successful in 17s
ci / build (pull_request) Successful in 7m11s
It listens on HTTP 19048, as the dkim2 DSN and report tests do. Those are
marked serial, this wasn't, so when the SMTP tests ran together (as
upstream's CI runs them) it could start beside them and requests reached
whichever server had the port: missing JMAP creates here, and 'You must
authenticate first' in dkim2_dsn_is_signed.
2026-09-22 20:55:42 -07:00
jcoffey-dev f32992ca36 Fix the antispam test: pin the rules it scores against, stop live Pyzor
ci / name-check (pull_request) Successful in 17s
ci / build (pull_request) Successful in 7m12s
It failed everywhere but upstream's machines, for two reasons:

- The spam rules, which carry every score, came from a path on an
  upstream developer's own disk. Without SPAM_RULES_URL none loaded, every
  score was 0.00 and the combined case came out ham instead of spam at
  13.70. The published rules of spam-filter v3.0.2 are now pinned beside
  the test cases (Apache-2.0 or MIT, taken as MIT; in THIRD-PARTY.md).
  SPAM_RULES_URL still overrides.
- The first combined case expects a Pyzor hit, and its digest (that of an
  empty body) wasn't among the three the test mode answers, so it went to
  a public Pyzor server: it failed offline and would drift with that
  server's counts. Test mode now answers every digest from a fixed table,
  with the empty body's added, and never reaches the network.

The test passes online and offline, alone and with the rest of the SMTP
tests. queue_retry, unrelated, still fails when it runs after the others
in one process, though it passes alone every time.
2026-09-22 20:22:29 -07:00
jcoffey-dev a993f9ab01 Write the brand in lowercase where people see it
ci / fork-checks (pull_request) Successful in 47s
ci / build (pull_request) Successful in 5m4s
The name is inbuxa, lowercase, like the wordmark; INBUXA reads as an
acronym. The admin and webmail already changed. Here that's everything
the server shows people: the brand macro behind the protocol greetings,
the HTTP and SCIM realms, the startup banner and the calendar and contact
PRODID; the first-party OAuth client descriptions; the legacy-protocol
refusals; the default calendar and address book names and the SMTP
greeting default, in the code and the schema served to the admin
(checksum regenerated); startup and shutdown events; the User-Agent;
the sign-in and RSVP pages; the service units; the OpenAPI realm; the
crate descriptions and the README, where it's set in bold.

Identifiers that are uppercase for their own reasons stay: INBUXA_*
settings, SUBSPACE_INBUXA. So do code comments and the AGPL 5(a) notice
lines.

Tests follow: the IMAP ID name, the default collection names, the PRODID
in the iTIP fixtures and the CalDAV free-busy expectations, and the e2e
legacy-protocol refusals. The webdav, imap and jmap suites pass, so do
the unit tests of every crate touched, and 73 of 75 SMTP tests; of the
other two, antispam fails on main too, and queue_retry is a timing flake
that passes on its own.
2026-09-22 20:08:10 -07:00
jcoffey-dev 99096cdc9b Merge pull request 'Release 2026.9.24' (#20) from release/2026.9.24 into main
ci / name-check (push) Successful in 1m6s
publish / version (push) Successful in 1m3s
ci / build (push) Successful in 4m38s
publish / publish (push) Failing after 24m24s
publish / release (push) Skipped
publish / binaries (push) Skipped
2026-09-23 02:53:03 +00:00
jcoffey-dev 96ac70ad28 Release 2026.9.24
ci / name-check (pull_request) Successful in 15s
ci / build (pull_request) Successful in 7m10s
Carries upstream 0.16.23 -- the DSN, POP3, Sieve, DMARC-report, ACME and
DNSSEC-resolver fixes in its own change log -- with the files it changed
marked under AGPL section 5(a), and one upstream test dropped that the fork's
routing makes meaningless.

It is also the first release whose tag attaches binaries: a host install can
now fetch inbuxa-linux-amd64.tar.gz or inbuxa-linux-arm64.tar.gz instead of
pulling the image and copying the file out of it.
2026-09-22 19:45:19 -07:00
jcoffey-dev 835b278e66 Merge pull request 'Attach binaries to a release, for installs that are not containers' (#19) from release/binaries into main
ci / name-check (push) Successful in 44s
ci / build (push) Successful in 4m34s
2026-09-23 02:36:53 +00:00
jcoffey-dev cc6f1eb298 Rename the identifiers that carried the upstream name
ci / fork-checks (pull_request) Successful in 16s
ci / build (pull_request) Successful in 7m53s
Everything clients, users and operators meet now carries the fork's name,
with no aliases (SPEC.md §2.4, changed here from "protocol identifiers
stay"):

- JMAP: upstream's registry capability is urn:inbuxa:jmap:registry, beside
  the fork's own urn:inbuxa:jmap.
- WebDAV lock and sync tokens are urn:inbuxa:dav*; clients resync once.
- Sieve: vnd.inbuxa.while and vnd.inbuxa.expressions. sieve-rs spells these
  into its compiler, so it's vendored (vendor/sieve-rs, 0.7.3) and patched in;
  a unit test fails if Cargo.lock ever moves past the vendored copy. The
  trusted runtime now names itself too, rather than answering sieve-rs's
  default.
- The web interface's OAuth client is inbuxa-webui. On every start the old
  stalwart-webui client is removed and any application naming it is moved
  over.
- The spam filter's blobs are INBUXA_SPAM_*; every start moves any left
  under the old keys, so a trained model survives.
- SQL stores and log files default to inbuxa, in the code and in the
  schema served to the admin (checksum regenerated).
- Settings are INBUXA_* only. A STALWART_* variable that's set where its
  INBUXA_* one isn't stops the server at startup, naming it.
- The version-upgrade messages link docs.inbuxa.org's migration page, and
  the OpenAPI description, smtp crate metadata and web-push test fixtures
  lose the name.

Kept on purpose, allowlisted with reasons: the OAuth key-derivation
contexts (renaming them would end every session and invalidate every
sealed client id) and the hashed application prefix.

Also fixes a latent start-up failure: ensure_client updated an existing
first-party client with a revision of 0, which the registry's assertion
never matches, so adding a redirect URI or changing the webmail secret
failed start-up. And the principal session test now expects
legacyProtocols (C-1, added 2026-09-21), which it had missed.

Tested: the server builds without warnings; common's 106 unit tests,
including the vendoring check; a new integration test for the two
start-up migrations; and the webdav, jmap, imap and SMTP Sieve suites.
2026-09-22 19:33:02 -07:00
jcoffey-dev 674ae5d037 Attach binaries to a release, for installs that are not containers
ci / name-check (pull_request) Successful in 43s
ci / build (pull_request) Successful in 5m25s
A release published an image and nothing else, so there was nothing for a
host install to download -- the only way to get the binary was to pull the
image and copy it out, which makes "install without Docker" depend on
Docker.

Each release now carries inbuxa-linux-amd64.tar.gz, inbuxa-linux-arm64.tar.gz
and SHA256SUMS, named as stalwart-migrator's are.

They are taken out of the image this pipeline just pushed rather than
compiled again. A second Rust build per architecture is the slowest thing
here, and it would leave two artifacts that are meant to be the same build
and only probably are. Extracting makes that identity a fact: the binary in
the tarball is the file the image runs. `docker create` starts nothing, so
copying a file out of an arm64 image on an amd64 runner needs no emulation.

One thing the extraction cannot carry: the image grants the binary
cap_net_bind_service, and a tar archive does not keep that xattr. The
release body says so, and says what to do instead -- setcap, or
AmbientCapabilities in the unit -- because a server that cannot bind 25 and
does not say why is a bad first hour.

Checked by hand against v2026.9.23 before this landed: both architectures
extract to the right ELF, and the amd64 binary runs on a bare Debian 13 with
every library resolved and reports its own version.
2026-09-22 19:31:05 -07:00
jcoffey-dev 4799d191a0 Fork tooling: a build check and a rename pass in the strip, a notice check in CI
ci / fork-checks (pull_request) Successful in 18s
ci / build (pull_request) Successful in 7m11s
strip.py compiles the stripped tree, so a dual-licensed file that only
serves an Enterprise feature fails the import instead of the merge, as
v0.16.23's tests/src/directory/issuer.rs does. Upstream's tests of the
features the fork rebuilt are expected not to compile there and are listed
in build-check-known.txt; an error anywhere else fails the run. Checked
against both imports: v0.16.22 passes with its 16 expected errors, v0.16.23
fails on issuer.rs alone. Imports the strip leaves unused are reported.

It also renames the upstream name where clients, users or operators meet
it as an identifier, from tools/fork/renames.py: wire-protocol names, the
web interface's client id, store keys, configuration defaults and the
served schema. main is renamed with the same module, so a re-import
arrives purged and those lines don't conflict.

notice-check.py fails CI when an upstream file the fork changed, measured
against the upstream branch, lacks its AGPL 5(a) notice; --fix adds it.
It runs beside the name check in a renamed fork-checks job.

Also commits v0.16.23's strip report under docs/fork/strip-reports/, which
the import in #18 left out.
2026-09-22 19:02:34 -07:00
jcoffey-dev c5bf67f1bf Merge pull request 'Merge/upstream v0.16.23' (#18) from merge/upstream-v0.16.23 into main
ci / name-check (push) Successful in 19s
ci / build (push) Successful in 37m41s
Reviewed-on: #18
2026-09-23 00:48:23 +00:00
jcoffey-dev c240946248 Drop upstream's issuer-routing test and an import it left unused
ci / name-check (pull_request) Successful in 52s
ci / build (pull_request) Successful in 21m31s
tests/src/directory/issuer.rs, new in v0.16.23, tests routing a bearer token
to a directory by its issuer. That routing is Enterprise-only upstream (the
body of get_directory_for_issuer), and the fork doesn't build it: a token
naming no address gets the server default (DIR-2). The test also calls a
helper from upstream's Enterprise-only OIDC test, so it can't compile here.

mta.rs imported types::id::Id for code inside an Enterprise snippet; the
stripped tree leaves it unused, upstream's as well as ours.
2026-09-22 17:05:52 -07:00
jcoffey-dev ee4988e00d Mark eight more changed files (AGPL section 5(a))
These upstream files were changed after the fork marked the files it had
modified, and never got the notice: six by the listener and schema-cache
work on 2026-09-20, two by the name check. Found by diffing against the
upstream snapshot branch, as before.
2026-09-22 16:57:15 -07:00
jcoffey-dev b2ded0a776 Merge upstream v0.16.23
Five conflicts, resolved:

- crates/common/src/auth/authentication.rs: upstream's get_directory_for_token
  and JwtClaims replace extract_jwt_domain; the per-domain directory code
  (DIR-1, DIR-5 to DIR-7) is kept, and the token lookup routes through it.
  The release's one new Enterprise snippet was the body of
  get_directory_for_issuer, which stays returning None: a token naming no
  address gets the server default, as DIR-2 specifies and as v0.16.22 did.
- crates/common/src/manager/application.rs: upstream's rewrite of the tests,
  with the temp directory names renamed again, and the 5(a) notice the
  name-purge change should have added.
- crates/common/src/network/mta.rs: both sides' imports.
- crates/main/Cargo.toml: the AGPL-only license kept, version 0.16.23.
- Cargo.lock: upstream's, with the fork's crates added by Cargo.
2026-09-22 16:57:06 -07:00
jcoffey-dev 3a272096c0 Import upstream v0.16.23, stripped
trivy / Check (pull_request) Waiting to run
Upstream commit: 9d1c75ab68435e4417337f768291e5f947686203
Enterprise-only files removed or emptied: 63
Enterprise-only snippets removed: 118 in 50 files
Dangling module declarations removed: 5
Edits turning enterprise off: 25
Third-party code: 14 files, 0 not in THIRD-PARTY.md
Verification: clean

One snippet more than v0.16.22, in crates/common/src/auth/authentication.rs
(3, was 2).
2026-09-22 16:31:25 -07:00
jcoffey-dev b6660554e6 Merge pull request 'CI: open an issue when upstream publishes a release not yet imported' (#15) from ci/upstream-watch into main
ci / name-check (push) Successful in 17s
ci / build (push) Successful in 7m14s
Reviewed-on: #15
2026-09-22 23:23:02 +00:00
jcoffey-dev 7bda874230 Merge pull request 'CI: fail when the upstream name appears in a new string literal' (#16) from ci/name-check into main
ci / name-check (push) Successful in 1m11s
ci / build (push) Canceled after 3m26s
Reviewed-on: #16
2026-09-22 23:19:37 +00:00
jcoffey-dev a4b091578d CI: fail when the upstream name appears in a new string literal
ci / name-check (pull_request) Successful in 1m15s
ci / build (pull_request) Successful in 5m2s
tools/fork/name-check.py reads every string literal in crates/ (comments
and test directories skipped) and fails on any that carries the upstream
name without an entry in name-allowlist.txt. An upstream merge can bring
such strings in without a conflict, so it runs on every push and PR.

The first run found three the earlier sweeps missed, fixed here: the SMTP
HELP reply pointed at upstream's website (now brand_url!), the event
collector thread was named after upstream, and the FreeBSD default data
path still said /var/db/stalwart/ where Linux already had /var/lib/inbuxa/.

Two operator-visible defaults are allowlisted as open, pending a decision:
the log file prefix and the SQL stores' default database and user.
2026-09-22 16:12:47 -07:00
jcoffey-dev 39df888412 CI: open an issue when upstream publishes a release not yet imported
ci / build (pull_request) Successful in 7m22s
Reads metadata only: upstream's releases list from GitHub's API and the
head of the upstream branch from Gitea's. Nothing of upstream's is
fetched, so its history can't land here. Daily at 06:17 UTC.
2026-09-22 15:37:34 -07:00
jcoffey-dev 697f647f8b Merge pull request 'Release 2026.9.23' (#14) from release/2026.9.23 into main
publish / version (push) Successful in 14s
ci / build (push) Successful in 7m8s
publish / publish (push) Successful in 47m16s
publish / release (push) Successful in 2s
2026-09-22 20:34:46 +00:00
jcoffey-dev 14250cee03 Release 2026.9.23
ci / build (pull_request) Successful in 7m10s
Carries the version string and user-visible string fixes: nothing a user or
operator sees names the upstream project any more.
2026-09-22 13:27:05 -07:00
jcoffey-dev cea3d53eb0 Delete .gitlab-ci.yml
ci / build (push) Successful in 4m18s
2026-09-22 20:26:17 +00:00
jcoffey-dev 335281f1de Merge pull request 'Keep the upstream name out of user-visible strings' (#13) from fix/user-visible-name-strings into main
ci / build (push) Canceled after 45s
2026-09-22 20:25:30 +00:00
jcoffey-dev 7f14992e81 Keep the upstream project's name out of user-visible strings
ci / build (pull_request) Successful in 3m46s
The first-party application descriptions and the telemetry service name and
instrumentation scope are shown to operators, and the unpacked-application
temp directory carried the name too.

Left alone deliberately: the OAuth key-derivation contexts (renaming them
would invalidate every sealed token and client id), the migration defaults
that read an upstream installation, links to upstream's upgrade guide, the
wire-protocol identifiers, and upstream's own license and templates.
2026-09-22 13:21:15 -07:00
jcoffey-dev 1f963a9a1c Merge pull request 'Keep the upstream name out of the version string' (#12) from fix/version-string-name into main
ci / build (push) Successful in 4m7s
2026-09-22 20:07:52 +00:00
jcoffey-dev b353f4ad2a Keep the upstream project's name out of the version string
ci / build (pull_request) Successful in 7m9s
brand_version_full! is user-visible -- --version, the startup banner, the
console, telemetry and the JMAP session's implementation field -- and the
name belongs only in copyright notices and the lineage line.
2026-09-22 13:00:13 -07:00
jcoffey-dev d7a428a4ce Merge pull request 'Release 2026.9.22, and pin the cargo-chef base image' (#11) from release/2026.9.22 into main
publish / version (push) Successful in 35s
ci / build (push) Successful in 7m10s
publish / publish (push) Successful in 48m26s
publish / release (push) Successful in 5s
2026-09-22 19:00:36 +00:00
jcoffey-dev 367bb2c641 Release 2026.9.22, and pin the cargo-chef base image
ci / build (pull_request) Successful in 4m42s
The version macro drives the release tag and what the server reports. The
builder's base image was the one image reference still floating on a tag.
2026-09-22 11:55:25 -07:00
jcoffey-dev 10bd747a7b Merge pull request 'Legacy mail apps: tenant switch, session flag, use panel and cleanup' (#10) from fix/tenant-switch-cleanup into main
ci / build (push) Successful in 5m46s
2026-09-22 18:54:31 +00:00
jcoffey-dev ae10c32271 Merge remote-tracking branch 'origin/main' into fix/tenant-switch-cleanup
ci / build (pull_request) Successful in 7m11s
2026-09-22 11:46:42 -07:00
jcoffey-dev c90064f9d8 Merge pull request 'ci: publish the image on tags, port the weekly release' (#9) from ci/publish-and-release into main
publish / version (push) Failing after 14s
publish / publish (push) Skipped
publish / release (push) Skipped
ci / build (push) Successful in 7m5s
2026-09-22 17:03:48 +00:00
jcoffey-dev 8846a280f1 ci: publish the image on tags, port the weekly release
ci / build (pull_request) Successful in 6m49s
publish.yml replaces .github/workflows/publish.yml: on a v* tag it checks the
tag equals v<brand_version!> and is on main, builds the linux/amd64+arm64
image in one buildx run (the Dockerfile already cross-compiles, so only its
final stage goes through QEMU), pushes :<version> and :latest to the
registry, links the package, and creates the tag's release if it has none.

weekly-release.yml ports .github/workflows/release.yml: bump brand_version!
through the contents API, then create the release and so the tag, which
starts publish.yml. It only dry-runs until RELEASE_LIVE=1 and a
RELEASE_TOKEN secret exist.
2026-09-22 09:56:32 -07:00
jcoffey-dev e3717f7990 Merge pull request 'Point links at git.coffeylabs.org after the move from GitHub' (#8) from fix/links-after-move into main
ci / build (push) Successful in 5m9s
2026-09-22 16:17:16 +00:00
jcoffey-dev ef068abbb1 Merge pull request 'ci: raise the Cargo target-dir limit to 60 GB' (#7) from ci/cargo-cache-limit into main
ci / build (push) Canceled after 8m36s
2026-09-22 16:08:41 +00:00
jcoffey-dev 7d2c2d2322 Point links at git.coffeylabs.org after the move from GitHub
ci / build (pull_request) Successful in 6m51s
GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo,
release, raw-file and clone links now go to Gitea at git.coffeylabs.org,
container images to registry.coffeylabs.org, and GitLab-style /-/blob paths
to Gitea's /src/branch form. Go module paths are identifiers and stay as
they are; links to GitHub issues and pull requests are left as history.
2026-09-22 09:08:33 -07:00
jcoffey-dev b37d252660 ci: raise the Cargo target-dir limit to 60 GB
ci / build (pull_request) Successful in 22m47s
The dev and test profiles together already take ~22 GB after one cold build,
so the 25 GB limit would have wiped a warm cache within a build or two.
2026-09-22 08:45:15 -07:00
jcoffey-dev 521b8449bf Merge pull request 'ci: persistent Cargo cache, run on either runner' (#6) from ci/cargo-cache into main
ci / build (push) Successful in 6m57s
2026-09-22 15:37:48 +00:00
jcoffey-dev 1ee2e2a6a3 ci: persistent Cargo cache, run on either runner
ci / build (pull_request) Successful in 38m1s
The build now mounts the named volume inbuxa-server-cargo at /cache and keeps
CARGO_HOME and CARGO_TARGET_DIR there, so a push reuses the compiled
dependency tree (RocksDB included) instead of rebuilding it from scratch.
Both runners allow that one volume; each host keeps its own copy.

With the cache in place the job moves to runs-on: light, so it can run on
host2 as well. Cargo's parallelism now follows the job's CPU cap rather than
the host's core count, and the target dir is dropped past 25 GB.
2026-09-22 07:59:35 -07:00
jcoffey-dev 825f49671e Merge branch 'ci/gitea-actions' into 'main'
ci / build (push) Successful in 37m28s
ci: add Gitea Actions workflow

See merge request inbuxa/inbuxa-server!5
2026-09-22 00:01:05 -07:00
jcoffey-dev 29bcfecb80 ci: add Gitea Actions workflow ported from .gitlab-ci.yml
ci / build (pull_request) Successful in 24m51s
2026-09-21 22:45:05 -07:00
jcoffey-dev 6c6fe91d0c A deleted tenant's legacy protocols switch goes with it
CI / build (pull_request) Canceled after 0s
Deleting a tenant now also removes its stored inbuxa:TenantProtocolPolicy,
in the same place the registry's other per-type clean-ups run. Without it
the row outlived the tenant, and a tenant that later came to have the same
id would have started with legacy protocols off.

The e2e deletes a tenant whose switch a server administrator had turned
off, and would check that a new tenant with the same id starts with them
on. On this build the registry hands out a fresh id instead ("d" after
"c"), so the reuse -- and with it the removal -- isn't observable over
JMAP; the test says so rather than passing silently. The risk it guards
was therefore smaller than feared, and the change is mostly about not
leaving an orphaned row behind. All 72 checks pass.
2026-09-21 14:56:04 -07:00
jcoffey-dev 840215d109 Merge branch 'feat/session-legacy-flag' into feat/legacy-use-panel 2026-09-21 13:48:35 -07:00
jcoffey-dev d2f41bce26 Merge branch 'feat/tenant-legacy-switch' into feat/session-legacy-flag 2026-09-21 13:48:31 -07:00
jcoffey-dev 96c7bab032 Merge branch 'feat/legacy-change-event' into feat/tenant-legacy-switch 2026-09-21 13:48:28 -07:00
jcoffey-dev 79b6787397 Merge main, and put LP-8's event on top of the Hardening link's schema
The Hardening link merged to main changed the packaged schema, which this
branch also changes. The file is gzipped, so the two can't be merged line
by line: this takes main's schema and adds security.legacy-protocols-changed
to it again, with the hash recomputed.
2026-09-21 13:48:25 -07:00
jcoffey-dev 3f40b36032 The switch knows who still uses legacy mail apps (LP-15, server)
The impact panel's data. Every successful sign-in over IMAP, POP3,
ManageSieve or SMTP AUTH records, per account and per protocol, one
timestamp -- nothing else: no address, no IP, no client. It is written at
most once an hour per account and protocol, so a mail app polling every
minute costs a read per sign-in and a write an hour. A record that can't be
written is logged and the sign-in goes ahead.

Both switches serve it as a read-only property, recentLegacyUse, as
wouldClose serves the confirmation: a list of {accountId, name, protocol,
lastUsedAt} for sign-ins in the last 30 days, most recent first.
inbuxa:ProtocolPolicy lists every account; inbuxa:TenantProtocolPolicy
lists only its tenant's own (MT-1). Accounts since deleted are left out. It
is computed only when the property is asked for.

The recording sits where the tenant check already runs once the account is
known, which becomes admit_legacy_session: refuse if the account's tenant
has legacy protocols off, otherwise record. A refused sign-in is never
recorded.

The spec leaves the interface to the implementation; a property on each
switch keeps the panel's data behind the same permission as the switch
itself, with no new object.

Unit tests hold the 30-day window to acceptance test 11 (three days ago
listed, forty not), the hourly throttle and the keys. The e2e proves on a
running server that the admin's IMAP and submission sign-ins are listed
with their time, that a second sign-in within the hour isn't written again,
and that a tenant's list holds its own user and nobody outside the tenant.
All 70 checks pass.
2026-09-21 11:45:05 -07:00
jcoffey-dev cd99037ca4 The session says whether legacy protocols are off for the account
The urn:inbuxa:jmap capability on the signed-in principal's own account
gains legacyProtocols: "enabled" or "disabled", the stricter of the
server's switch and the account's tenant's (legacy-protocols spec,
Interfaces). It is what the webmail needs to tell someone why their phone's
mail app won't connect (LP-19), and it closes acceptance test 13.

contract.md's C-1 gains the line. It is an optional field added, which
C-3 says doesn't bump the contract version.

tests/e2e/legacy_protocols.py reads it back from the session on a running
server: enabled for the tenant's user while both switches are on, disabled
once its tenant turns legacy protocols off while an account outside the
tenant still reads enabled, disabled for everyone while the server switch
is off, and enabled again at the end. All 67 checks pass.
2026-09-21 11:30:34 -07:00
jcoffey-dev b65afb66f9 A tenant can turn legacy protocols off for itself (LP-9 to LP-14a)
The tenant switch. A tenant's administrator turns legacy mail protocols
off for its own tenant, and from then on sign-in over IMAP, POP3,
ManageSieve and SMTP AUTH is refused for every address on the tenant's
domains, while every other domain on the server carries on. No port
closes, since other tenants share them (LP-13): it is one stored fact per
tenant, read at sign-in and when client configuration is answered.

inbuxa:TenantProtocolPolicy/get and /set, one per tenant, id the tenant's:

- Inside a tenant, a principal reaches only its own tenant's switch
  (MT-1): /get with no ids answers with it, another tenant's is notFound
  and can't be changed. At server level /get with no ids lists every
  tenant's.
- Turning it off is always allowed. Turning it back on is refused with
  forbidden, naming inbuxa:ProtocolPolicy, while the server has legacy
  protocols off (LP-9).
- A change raises security.legacy-protocols-changed with policy = tenant,
  the tenant's id, the new value and who made it (LP-14).
- It takes sysDomainGet and sysDomainUpdate, not the two new permissions
  the spec names. The switch governs sign-in on the tenant's domains, so
  whoever manages those domains may turn it -- and the default Tenant
  Administrator role already holds both, where new permissions would reach
  no role already stored on a server (MT-12's note), leaving today's
  tenant administrators without the switch until someone edited their
  role by hand. The same trade inbuxa:AiLimits and inbuxa:ProtocolPolicy
  made. /query is not built yet; /get with no ids covers listing.

Sign-in (LP-10 to LP-12). Before the credentials are looked at, the name
given is resolved to its domain and the domain to its tenant, so a real
account and a made-up address on the domain get the same refusal, with a
right password or a wrong one, counted as no failed sign-in (LP-11). The
words are the spec's: "Your organization allows only INBUXA webmail and
JMAP apps...", in each protocol's form. A bearer token needn't name an
account, so after authentication the account's own tenant is checked too;
a token that named nobody can't slip past.

The refusal carries policy = tenant and the domain, not the tenant's id:
IMAP answers a command's tag from the Id key, so an error holding one was
sent under the wrong tag and the mail app hung waiting for its reply. The
first live run found that; a unit test now holds the refusal to it.

Client configuration (LP-14a). Autoconfig, autodiscover, PACC and the
suggested DNS records now ask whether legacy services are off for the
domain being answered for -- the server's switch, or the domain's
tenant's -- so a tenant's domains stop offering IMAP, POP3 and
submission while others still do.

tests/e2e/legacy_protocols.py builds a tenant with its own domain, a user
and a tenant administrator, and a second tenant, and proves on a running
server: the admin sees and changes only its own tenant's switch (test 10);
turning it off is an event (test 14); the tenant's user is refused over
IMAP with the right password and a wrong one, a made-up address on the
domain the same (tests 6, 7); POP3 and submission refuse in their own
forms and JMAP still works (test 8); an account on another domain signs in
normally (test 6); autoconfig drops IMAP for the tenant's domain only; with
the server off, the tenant can't turn it back on (test 9); and once back
on, the user signs in again. All 62 checks pass.
2026-09-21 11:18:42 -07:00
jcoffey-dev e953c68e2e Merge branch 'feat/legacy-protocols-nav' into 'main'
CI / build (pull_request) Canceled after 0s
Settings › Security gains Hardening (after the admin release and LP-6)

See merge request inbuxa/inbuxa-server!2
2026-09-21 11:17:17 -07:00
jcoffey-dev 64cddc9246 The switch reports every change as an event (LP-8)
Turning legacy mail protocols off or back on raises
security.legacy-protocols-changed (id 643, info level, also in the packaged
schema), with the scope (policy = server), the new value, who made the
change (accountId), whether listeners closed or reopened (details), which
ones (listenerId), and -- only when a listener could not be put back --
which and why (reason).

It is raised in Server::set_protocol_policy rather than by the JMAP
method, so whatever turns the switch is reported. A /set that changes
nothing -- the switch already where it was asked to be, nothing to close
or reopen -- is not a change and raises nothing.

The event is never an error, but jmap's exhaustive map from security
events to HTTP errors has to name it; it joins the other two that can't
occur there. rustfmt now also wraps LP-6's two over-long lines in
enums_impl.rs, which it flagged along with this change's.

tests/e2e/legacy_protocols.py now gives the server a stdout tracer and
reads events from the container's log: turning the switch off is exactly
one event naming the scope, value, author and listeners closed; setting it
off again raises none; turning it on is one event naming the listeners
reopened. It also proves LP-6's side: seven refused submission sign-ins
are seven auth.legacy-protocol-refused events, and there is no auth.failed
or auth.too-many-attempts among them. All checks pass.
2026-09-21 10:53:01 -07:00
jcoffey-dev 9379c1f151 Merge branch 'feat/legacy-listener-create-refused' into 'main'
No legacy listener can be added while the switch is off (LP-4)

See merge request inbuxa/inbuxa-server!4
2026-09-21 10:50:00 -07:00
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 30be928e14 Merge main, and put the Hardening link on top of LP-6's schema
LP-6 added auth.legacy-protocol-refused to the packaged schema, which this
branch also changes. The file is gzipped, so the two can't be merged line
by line: this takes main's schema and adds the Settings › Security ›
Hardening link to it again, with the hash recomputed.
2026-09-21 10:21:01 -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 6b1e5c67e3 Merge branch 'feat/legacy-signin-refusal' into 'main'
Legacy sign-in is refused while the switch is off (LP-6)

See merge request inbuxa/inbuxa-server!3
2026-09-21 10:05:13 -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 1a48474957 Settings › Security gains Hardening, the legacy protocols screen
Adds a link to CustomComponent/LegacyProtocols in the packaged schema's
Settings › Security, between Settings and Blocked IPs, and updates the
schema hash so admins fetch the new layout rather than a cached one.

INBUXA Admin draws the screen; this is what makes it reachable. An admin
from before that screen would show "Unknown component" here, so this
lands after the admin release that carries it.
2026-09-21 09:19:49 -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
315 changed files with 29301 additions and 1029 deletions
+8 -2
View File
@@ -1,10 +1,16 @@
// Ignore everything # Ignore everything
* *
// Allow what is needed # Allow what is needed
!crates !crates
!tests !tests
!resources !resources
# The patched dependency Cargo.toml's [patch.crates-io] points at. Without
# it the build context has no vendor/, and `cargo chef cook` fails on
# "failed to load source for dependency sieve-rs" -- which CI cannot see,
# because CI builds from a checkout and only the image build has a context.
!vendor
!Cargo.lock !Cargo.lock
!Cargo.toml !Cargo.toml
+93
View File
@@ -0,0 +1,93 @@
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
# this directory exists; .github/workflows stays as it was for GitHub.
#
# Every job runs in an image pinned by digest (tag in the trailing comment),
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
# instance resolves short `uses:` against itself, never GitHub, so nothing
# unreviewed can be pulled in.
#
# Not ported, as on GitLab: publish.yml and release.yml still need doing.
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# What an upstream merge can bring in or leave behind without a conflict:
# the upstream name in a new string literal, and a changed upstream file
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
# check diffs against the upstream snapshot branch, hence the full fetch.
fork-checks:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
- run: python3 tools/fork/name-check.py
- if: always()
run: python3 tools/fork/notice-check.py
# Cargo can patch a dependency to a directory in this repository, and
# the image builds from a context .dockerignore prunes to almost
# nothing. CI never sees the difference; a release does.
- if: always()
run: python3 tools/fork/context-check.py
build:
# Either runner (host1 or host2): the build needs no docker socket.
runs-on: light
container:
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
# A named volume per host that outlives the job: Cargo's registry/git
# cache and the target dir. Without it every run recompiled RocksDB and
# the rest of the dependency tree from scratch. Each runner allows this
# one volume in its valid_volumes; each host keeps its own copy.
volumes:
- inbuxa-server-cargo:/cache
env:
CARGO_HOME: /cache/cargo-home
CARGO_TARGET_DIR: /cache/target
# Dependencies are reused whole; incremental data for the workspace
# crates would only bloat a shared target dir.
CARGO_INCREMENTAL: "0"
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
# Cargo sizes its parallelism from the host's core count, not the job's
# CPU cap (2 on host2, 4 on host1); a C++ build of RocksDB at 8-way
# parallelism inside 6 GB gets OOM-killed. Match jobs to the cap.
- run: |
jobs=$(awk '$1 != "max" { printf "%d", $1 / $2 }' /sys/fs/cgroup/cpu.max 2>/dev/null)
echo "CARGO_BUILD_JOBS=${jobs:-$(nproc)}" >> "$GITHUB_ENV"
echo "cargo jobs: ${jobs:-$(nproc)}; cache: $(du -sh /cache 2>/dev/null | cut -f1)"
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null
- run: 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.
- run: cargo test --workspace --locked --no-run
# The release profile, on main only. It is the profile the image is
# built with, and it fails in ways the dev profile does not: v2026.9.24
# was tagged on a commit whose CI was green and whose release build
# could not compile the scim crate at all. A few minutes per merge is
# cheaper than finding that out from a tag, which throws away a
# multi-architecture build and leaves a version half-cut.
#
# Pull requests stay on the dev profile, where the wait is worth less.
- if: github.event_name == 'push'
run: cargo build -p inbuxa --locked --release
# Keep the cache from growing without bound: past 60 GB the target dir
# is dropped and the next build starts cold. The download cache stays.
# Two builds (dev + test profiles) already fill ~22 GB, so the limit
# has to sit well above that or it would wipe a warm cache every run.
- if: always()
run: |
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
echo "target dir: ${used:-0} GB"
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
+225
View File
@@ -0,0 +1,225 @@
# Publish the container image, ported from .github/workflows/publish.yml when
# the project moved to the self-hosted Gitea (2026-09-22). Starts on a v* tag,
# whether a person pushed it or weekly-release.yml created it through the
# releases API.
#
# The image is multi-arch (linux/amd64, linux/arm64) as before, but built in
# one buildx run on host1 instead of one native runner per architecture: the
# Dockerfile's builder stage runs on the build platform and cross-compiles
# with an aarch64 linker, so only the small final stage (apt, setcap) goes
# through QEMU for arm64. No digest-joining job is needed.
#
# Two guards before anything is pushed:
# * the tag must be v<brand_version!>. The version is a string in
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
# with it, so a tag beside an unbumped macro would publish an image that
# reports a different version from its tag.
# * the tag must be on main, so an image never describes code that was never
# reviewed onto the default branch.
#
# :latest moves with every published tag: tags are cut by the weekly release
# (or by hand for a real release); there are no prerelease tags here.
#
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
# own token is refused by the container registry.
name: publish
on:
push:
tags: ['v*']
jobs:
version:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
outputs:
version: ${{ steps.v.outputs.version }}
steps:
# Full history: the ancestry check cannot be answered from a shallow
# clone. The checkout also fetches every branch as origin/*.
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
- id: v
shell: bash
env:
TAG: ${{ github.ref_name }}
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; }
if [ "$TAG" != "v$V" ]; then
echo "Tag $TAG names a commit whose brand_version! says $V." >&2
echo "Refusing to publish an image that would report the wrong version." >&2
exit 1
fi
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|| { echo "$TAG is not on main" >&2; exit 1; }
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "version $V"
publish:
needs: [version]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
DOCKER_BUILDKIT: "1"
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
VERSION: ${{ needs.version.outputs.version }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: |
test -n "$REGISTRY" && test -n "$VERSION"
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
docker run --privileged --rm tonistiigi/binfmt --install arm64
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
# Attestations off, as before: they add manifests of their own to the
# index, and the index should hold the two images and nothing else.
- run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--provenance=false --sbom=false \
--tag "$IMAGE:$VERSION" \
--tag "$IMAGE:latest" \
--push .
docker buildx imagetools inspect "$IMAGE:$VERSION"
# Gitea keeps a container package on its owner; linking it shows it on
# the repository's Packages tab. Idempotent.
- run: |
apk add --no-cache -q curl
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|| echo "package already linked (or link refused); not fatal"
- if: always()
run: docker logout "$REGISTRY" || true
# The weekly release creates its Release (and so the tag) first; a tag
# pushed by hand has none. Either way the tag ends up with exactly one
# Release, created after the image exists so its pull instructions work.
release:
needs: [version, publish]
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
steps:
- shell: bash
env:
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.version.outputs.version }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
REGISTRY: ${{ vars.REGISTRY }}
run: |
python3 - <<'PY'
import json, os, urllib.request, urllib.error
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
h = {"Authorization": f"token {os.environ['TOKEN']}", "Content-Type": "application/json"}
tag, version = os.environ["TAG"], os.environ["VERSION"]
try:
urllib.request.urlopen(urllib.request.Request(f"{api}/releases/tags/{tag}", headers=h))
print(f"{tag} already has a release"); raise SystemExit
except urllib.error.HTTPError as e:
if e.code != 404: raise
image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}"
body = (f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`.\n\n"
"Binaries for a host install are attached: `inbuxa-linux-amd64.tar.gz` and "
"`inbuxa-linux-arm64.tar.gz`, with `SHA256SUMS`. Each is the binary out of this "
"release's image for that architecture, so it is the same build. The image "
"grants it `cap_net_bind_service`; a host install has to grant that itself "
"(`setcap`, or `AmbientCapabilities` in the unit) to bind port 25.")
data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body}).encode()
r = json.load(urllib.request.urlopen(urllib.request.Request(f"{api}/releases", data=data, headers=h)))
print(f"created release {r['tag_name']}")
PY
# The binaries for a host install, taken out of the image that was just
# pushed rather than compiled again.
#
# Building them separately would mean a second Rust build per architecture
# -- the slowest thing this pipeline does -- and would leave two artifacts
# that are supposed to be the same build but only probably are. Extracting
# them makes that identity a fact: the binary in the tarball is the file
# the image runs.
#
# `docker create` does not start anything, so pulling an arm64 image on an
# amd64 runner and copying a file out of it needs no emulation.
binaries:
needs: [version, publish, release]
runs-on: docker
container:
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
env:
REGISTRY: ${{ vars.REGISTRY }}
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
VERSION: ${{ needs.version.outputs.version }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: take the binaries out of the image
run: |
set -euo pipefail
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
mkdir -p /out && cd /out
for arch in amd64 arm64; do
docker pull -q --platform "linux/$arch" "$IMAGE:$VERSION"
id="$(docker create --platform "linux/$arch" "$IMAGE:$VERSION")"
docker cp "$id:/usr/local/bin/inbuxa" "inbuxa"
docker rm -f "$id" >/dev/null
chmod 0755 inbuxa
tar -czf "inbuxa-linux-$arch.tar.gz" inbuxa
rm inbuxa
done
sha256sum inbuxa-linux-*.tar.gz > SHA256SUMS
cat SHA256SUMS
- name: attach them to the release
run: |
set -euo pipefail
apk add --no-cache -q python3
python3 - <<'PY'
import json, os, urllib.request, urllib.error, uuid, pathlib
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
tok = {"Authorization": f"token {os.environ['TOKEN']}"}
tag = os.environ["TAG"]
def get(path):
return json.load(urllib.request.urlopen(urllib.request.Request(api + path, headers=tok)))
rel = get(f"/releases/tags/{tag}")
assets = {a["name"]: a["id"] for a in get(f"/releases/{rel['id']}/assets")}
for path in ["/out/inbuxa-linux-amd64.tar.gz", "/out/inbuxa-linux-arm64.tar.gz", "/out/SHA256SUMS"]:
name = os.path.basename(path)
# A re-run of a tag replaces its assets rather than leaving two
# files with the same name and different contents.
if name in assets:
urllib.request.urlopen(urllib.request.Request(
f"{api}/releases/{rel['id']}/assets/{assets[name]}", headers=tok, method="DELETE"))
boundary = uuid.uuid4().hex
body = b"".join([
f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n".encode(),
b"Content-Type: application/octet-stream\r\n\r\n",
pathlib.Path(path).read_bytes(),
f"\r\n--{boundary}--\r\n".encode(),
])
req = urllib.request.Request(
f"{api}/releases/{rel['id']}/assets?name={name}", data=body, method="POST",
headers={**tok, "Content-Type": f"multipart/form-data; boundary={boundary}"})
urllib.request.urlopen(req)
print("attached", name)
PY
- if: always()
run: docker logout "$REGISTRY" || true
+122
View File
@@ -0,0 +1,122 @@
# Watch upstream for releases the fork hasn't imported yet, and open an issue
# for each one so it waits in the tracker until someone strips it in.
#
# Reads metadata only -- the releases list from GitHub's API and the head of
# this repo's `upstream` branch from Gitea's. Nothing of upstream's is fetched,
# so none of its history (which carries the Enterprise code) can land here.
# Importing is still by hand: tools/fork/strip.py onto `upstream`, then merge,
# as docs/spec/SPEC.md §2.2 and §2.2a describe.
#
# The imported base is the tag in the `upstream` branch's head commit subject
# ("Import upstream v0.16.22, stripped"). Drafts and pre-releases are ignored.
# An issue is opened once per release: an existing one with the same title,
# open or closed, stops a second.
#
# It also watches spam-filter, whose rules the server bundles
# (resources/spam-filter/), and opens an issue for a newer release.
#
# Daily 06:17 UTC; run it by hand with workflow_dispatch.
name: upstream-watch
on:
schedule:
- cron: '17 6 * * *'
workflow_dispatch:
concurrency:
group: upstream-watch
cancel-in-progress: false
jobs:
upstream-watch:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
steps:
- shell: bash
run: |
python3 - <<'PY'
import json, os, re, sys, urllib.request
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
def call(method, url, body=None, token=os.environ["TOKEN"]):
headers = {"Content-Type": "application/json", "User-Agent": "inbuxa-upstream-watch"}
if token:
headers["Authorization"] = f"token {token}"
req = urllib.request.Request(url, method=method, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
SEMVER = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
def key(tag):
return tuple(int(x) for x in SEMVER.match(tag).groups())
subject = call("GET", f"{api}/branches/upstream")["commit"]["message"].splitlines()[0]
m = re.search(r"\bupstream (v\d+\.\d+\.\d+)\b", subject)
if not m:
print(f"Can't read the imported base from the upstream branch: {subject!r}", file=sys.stderr); sys.exit(1)
base = m.group(1)
# Unauthenticated: a public repo, once a day, well inside the limit.
rels = call("GET", "https://api.github.com/repos/stalwartlabs/stalwart/releases?per_page=30", token=None)
newer = sorted((r for r in rels
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
and key(r["tag_name"]) > key(base)),
key=lambda r: key(r["tag_name"]))
if not newer:
print(f"Up to date: {base} is the newest upstream release.")
# Titles and bodies stay free of the upstream project's name, as the
# rest of the fork's user-visible text does.
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=Import+upstream&limit=50")}
for r in newer:
tag = r["tag_name"]
title = f"Import upstream {tag}"
if title in existing:
print(f"{tag}: issue already exists."); continue
body = (f"Upstream published {tag} on {r['published_at'][:10]}. "
f"The fork's imported base is {base}.\n\n"
"Import it as tools/fork/README.md describes:\n\n"
"```bash\n"
"git -C \"$UPSTREAM_CLONE\" fetch --tags\n"
f"tools/fork/strip.py --upstream \"$UPSTREAM_CLONE\" --ref {tag} --out /tmp/strip-{tag}\n"
"```\n\n"
"Commit the stripped tree to `upstream` with the strip report in the message, "
"add any new third-party notices to `THIRD-PARTY.md`, then merge `upstream` into `main`.")
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
print(f"{tag}: opened #{issue['number']}.")
# The spam filter rules bundled with the server (resources/spam-filter/):
# an issue when spam-filter publishes a newer release than the one
# BUNDLED_SPAM_RULES_VERSION names on main.
src = call("GET", f"{api}/contents/crates/common/src/manager/spam_rules.rs?ref=main")
import base64
text = base64.b64decode(src["content"]).decode()
m = re.search(r'BUNDLED_SPAM_RULES_VERSION: &str = "(\d+\.\d+\.\d+)"', text)
if not m:
print("Can't read BUNDLED_SPAM_RULES_VERSION from spam_rules.rs", file=sys.stderr); sys.exit(1)
bundled = "v" + m.group(1)
rels = call("GET", "https://api.github.com/repos/stalwartlabs/spam-filter/releases?per_page=30", token=None)
newer = sorted((r for r in rels
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
and key(r["tag_name"]) > key(bundled)),
key=lambda r: key(r["tag_name"]))
if not newer:
print(f"Up to date: the bundled spam rules are {bundled}, the newest release."); sys.exit(0)
latest = newer[-1]
tag = latest["tag_name"]
title = f"Update the bundled spam rules to {tag}"
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=bundled+spam+rules&limit=50")}
if title in existing:
print(f"spam rules {tag}: issue already exists."); sys.exit(0)
body = (f"spam-filter published {tag} on {latest['published_at'][:10]}. "
f"The server bundles {bundled}.\n\n"
"Update it as resources/spam-filter/README.md describes: take the rules file "
f"from the {tag} release (by tag, not `latest`), set BUNDLED_SPAM_RULES_VERSION, "
"and run the antispam test.")
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
print(f"spam rules {tag}: opened #{issue['number']}.")
PY
+135
View File
@@ -0,0 +1,135 @@
# Weekly release, ported from .github/workflows/release.yml when the project
# moved to the self-hosted Gitea (2026-09-22): cut a release once a week, but
# only if there is something in it. A release with nothing in it moves
# :latest to an identical build, spends a version number, and notifies
# everybody about nothing.
#
# The version is the date, YYYY.M.D unpadded, with a .N suffix from 2 for a
# second release on one day. It lives in crates/types/src/branding.rs
# (brand_version!), deliberately not in Cargo.toml so upstream's version bumps
# merge without conflicts. The bump is committed to main and the tag names that
# commit, so the tree a tag points at reports the version the tag claims --
# publish.yml refuses a tag that doesn't.
#
# Mondays 10:07 UTC, last of the three INBUXA releases: Admin and the webmail
# release ahead of the server they talk to. Run it by hand with
# workflow_dispatch; dry_run defaults to true.
#
# NOT LIVE YET: this only ever dry-runs unless the Actions variable
# RELEASE_LIVE is '1' (repo or org). Going live also needs a repo secret
# RELEASE_TOKEN (jcoffey-dev, write:repository, allowed to push to main):
# * a tag Gitea creates for the job's own token raises no event, and the
# tag must start publish.yml;
# * the bump is committed through the contents API. Gitea has no "only if
# the branch is still at X" guard, so the job checks main's head right
# before writing and refuses if it moved since the commit it counted from;
# run it again. (The API does refuse if the file itself changed, via its
# blob sha.)
name: weekly-release
on:
schedule:
- cron: '7 10 * * 1'
workflow_dispatch:
inputs:
dry_run:
description: Show the decision and stop
type: boolean
default: true
# One at a time: two overlapping runs would race to write the same version and
# create the same tag.
concurrency:
group: weekly-release
cancel-in-progress: false
jobs:
weekly-release:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
env:
READ_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
# Live only with RELEASE_LIVE=1 AND either the schedule or a manual run
# with dry_run unticked.
DRY_RUN: ${{ (vars.RELEASE_LIVE == '1' && (github.event_name == 'schedule' || inputs.dry_run == false || inputs.dry_run == 'false')) && '0' || '1' }}
RELEASE_LIVE: ${{ vars.RELEASE_LIVE }}
REPO: ${{ github.repository }}
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
with:
fetch-depth: 0
- shell: bash
run: |
python3 - <<'PY'
import base64, datetime, json, os, re, subprocess, sys, urllib.request
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
def call(method, path, token, body=None):
req = urllib.request.Request(api + path, method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={"Authorization": f"token {token}", "Content-Type": "application/json"})
with urllib.request.urlopen(req) as r:
return json.load(r)
def git(*a):
return subprocess.run(["git", *a], check=True, capture_output=True, text=True).stdout.strip()
def has_tag(t):
# show-ref matches an exact ref; rev-parse --verify on this git
# can read some tag names as describe output and "find" a tag
# that isn't there.
return subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/tags/{t}"]).returncode == 0
sha = git("rev-parse", "HEAD")
# The newest published release, or empty on a project that has never
# had one -- in which case everything counts as new. A release can
# outlive its tag; falling back to the whole history over-counts,
# which cuts a release that was due anyway.
rels = call("GET", "/releases?draft=false&pre-release=false&limit=1", os.environ["READ_TOKEN"])
previous = rels[0]["tag_name"] if rels else ""
rng = f"{previous}..HEAD" if previous and has_tag(previous) else "HEAD"
count = int(git("rev-list", "--count", rng))
if count == 0:
print(f"Nothing to release: no commits since {previous}."); sys.exit(0)
d = datetime.datetime.now(datetime.timezone.utc)
today = f"{d.year}.{d.month}.{d.day}"
version, n = today, 2
while has_tag(f"v{version}"):
version, n = f"{today}.{n}", n + 1
tag = f"v{version}"
print(f"Releasing {tag} -- {count} commit(s) since {previous or 'the beginning'}, from {sha}.")
if os.environ["DRY_RUN"] == "1":
print(f"Dry run (RELEASE_LIVE='{os.environ.get('RELEASE_LIVE', '')}'): stopping here."); sys.exit(0)
token = os.environ.get("RELEASE_TOKEN", "")
if not token:
print("RELEASE_TOKEN secret is not set on this repository", file=sys.stderr); sys.exit(1)
# 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.
path = "crates/types/src/branding.rs"
src = open(path, encoding="utf-8").read()
out, hits = re.subn(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")',
lambda m: m.group(1) + version + m.group(2), src, count=1)
assert hits == 1, f"brand_version! not found in {path}"
head = call("GET", "/branches/main", token)["commit"]["id"]
if head != sha:
print(f"main moved from {sha} to {head} since this run counted; run it again.", file=sys.stderr); sys.exit(1)
blob = call("GET", f"/contents/{path}?ref={sha}", token)["sha"]
bump = call("PUT", f"/contents/{path}", token, {
"branch": "main", "message": f"Version {version}", "sha": blob,
"content": base64.b64encode(out.encode()).decode()})["commit"]["sha"]
print(f"committed the bump as {bump}")
# Notes bounded to what is new: one line per change on main's
# first-parent history. Creating the release creates the tag, which
# is an ordinary push, so publish.yml builds and pushes the image.
notes = git("log", "--first-parent", "--format=- %s", rng)
rel = call("POST", "/releases", token, {
"tag_name": tag, "target_commitish": bump, "name": f"INBUXA {version}",
"body": f"{count} commit(s) since {previous or 'the beginning'}.\n\n{notes}"})
print(f"created release {rel['tag_name']}")
PY
+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
+2
View File
@@ -9,6 +9,8 @@ run.sh
!.gitignore !.gitignore
!.gitattributes !.gitattributes
!.github !.github
!.gitlab-ci.yml
!.gitea
CLAUDE.md CLAUDE.md
# The cutover rehearsal writes its fixture and state here. # The cutover rehearsal writes its fixture and state here.
+33
View File
@@ -2,6 +2,39 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [0.16.23] - 2026-09-21
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
## Added
- Expressions: `bit_and` function.
## Changed
## Fixed
- MTA:
- A mailing list whose recipients include another mailing list is accepted at `RCPT TO` and then rejected at local delivery with `550 5.5.0 Mailbox not found`.
- DMARC aggregate reports carry two `spf` elements per record and the `version` element of a DMARC aggregate report is written as `1` instead of `1.0`.
- DSNs generated for an alias rewrite or a list expansion emit a doubled `addr-type` in `Original-Recipient` (`rfc822;rfc822;[email protected]`).
- DSNs that cannot be written to the store are discarded, the recipients are flagged as notified and the original message is removed from the queue, losing both the bounce and the message.
- POP3:
- `TOP msg n` counts the `n` lines from the first byte of the message instead of from the first byte of the body.
- A message whose very first line begins with `.` is not byte-stuffed.
- Spam filter: Moving or copying a message from one account into another creates no training sample, so the classifier never learns from it.
- Sieve: `envelope "orcpt"` yields the bare address for an `ORCPT` supplied over SMTP. It now carries the `addr-type` prefix in every case, as required by RFC 6009.
- ACME: The `_acme-challenge` TXT records published for a DNS-01 authorization are never removed.
- DNS: The DNSSEC resolver queries a single nameserver at a time, working around a `hickory-resolver` race that cancels the TCP retry when two nameservers return a truncated response in parallel.
- Troubleshoot tool:
- MX records are resolved through the DNSSEC-validating resolver, matching the resolver used by the delivery path.
- A TLSA lookup that fails or returns bogus records stops the delivery attempt for that host, instead of continuing without DANE.
- OIDC: Bearer tokens that carry no `email`, `preferred_username` or `upn` claim are always authenticated against the default directory.
- Meilisearch: A confirmation timeout is treated as a failed write even when `failOnTimeout` is disabled, so an index whose batches take longer than `pollInterval` x `maxRetries` never completes an indexing task and resubmits the same batch indefinitely.
- WebUI: A failed update no longer takes an `Application` offline.
- FoundationDB: The cached read version is invalidated when any broadcast is received from another node.
- Redis:
- On a cluster, the rate limiter and the blob upload quota issue `INCR` and `EXPIRE` as a `MULTI`/`EXEC` transaction, whose `MOVED` redirects collapse into a single `EXECABORT` that never refreshes the slot map.
- A connection that fails because it is addressing the wrong server is returned to the pool and reused, since the recycle check only issues `PING`.
## [0.16.22] - 2026-09-13 ## [0.16.22] - 2026-09-13
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
+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
+174 -174
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -1,5 +1,7 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
# Vendored crates are patched in below, not built as members.
exclude = ["vendor"]
members = [ members = [
"crates/main", "crates/main",
"crates/types", "crates/types",
@@ -78,3 +80,10 @@ incremental = false
debug-assertions = false debug-assertions = false
overflow-checks = false overflow-checks = false
rpath = false rpath = false
# inbuxa: sieve-rs spells upstream's name into its Sieve extension names
# (vnd.stalwart.*), which scripts `require` and ManageSieve advertises.
# vendor/sieve-rs is the published 0.7.3 with those renamed; see its
# VENDORED.md. Re-vendor when the version in Cargo.lock moves.
[patch.crates-io]
sieve-rs = { path = "vendor/sieve-rs" }
+5 -1
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM docker.io/lukemathwalker/cargo-chef:latest-rust-slim-trixie AS chef FROM --platform=$BUILDPLATFORM docker.io/lukemathwalker/cargo-chef:latest-rust-slim-trixie@sha256:38dfdbf4fda95c516f873f33032e490baa988b75f7d83c7d12f788f770785b36 AS chef
WORKDIR /build WORKDIR /build
FROM --platform=$BUILDPLATFORM chef AS planner FROM --platform=$BUILDPLATFORM chef AS planner
@@ -19,6 +19,10 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
g++-x86-64-linux-gnu binutils-x86-64-linux-gnu g++-x86-64-linux-gnu binutils-x86-64-linux-gnu
RUN rustup target add "$(cat /target.txt)" RUN rustup target add "$(cat /target.txt)"
COPY --from=planner /recipe.json /recipe.json COPY --from=planner /recipe.json /recipe.json
# inbuxa: [patch.crates-io] points sieve-rs at vendor/, and the recipe only
# carries the workspace's own manifests, so cooking the dependencies needs the
# vendored crate itself (the context allows it since #27; this puts it here).
COPY vendor/ vendor/
RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json
COPY . . COPY . .
RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
+8 -7
View File
@@ -8,13 +8,13 @@
--- ---
**INBUXA** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP, **inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart). end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
Project site: [inbuxa.org](https://inbuxa.org). Documentation: [docs.inbuxa.org](https://docs.inbuxa.org). Project site: [inbuxa.org](https://inbuxa.org). Documentation: [docs.inbuxa.org](https://docs.inbuxa.org).
Stalwart ships some features only in a paid Enterprise Edition: multi-tenancy, Stalwart ships some features only in a paid Enterprise Edition: multi-tenancy,
masked email, undelete and others. INBUXA ships everything to everybody under masked email, undelete and others. **inbuxa** ships everything to everybody under
the AGPL-3.0, rebuilding those features independently and without using any the AGPL-3.0, rebuilding those features independently and without using any
of Stalwart's Enterprise code. of Stalwart's Enterprise code.
@@ -46,25 +46,26 @@ docker build -t inbuxa . # or the container image
``` ```
Settings are read from `INBUXA_*` environment variables. An existing Stalwart Settings are read from `INBUXA_*` environment variables. An existing Stalwart
install's `STALWART_*` variables still work, with a warning to rename them. install's `STALWART_*` variables aren't read: the server stops at startup and
names each one to rename.
New installs keep their data in `/var/lib/inbuxa` and logs in New installs keep their data in `/var/lib/inbuxa` and logs in
`/var/log/inbuxa`. Existing installs keep the paths their configuration `/var/log/inbuxa`. Existing installs keep the paths their configuration
already names, so none of their data moves. already names, so none of their data moves.
## License and credits ## License and credits
INBUXA is free software under the [GNU Affero General Public License, **inbuxa** is free software under the [GNU Affero General Public License,
version 3](./LICENSES/AGPL-3.0-only.txt). version 3](./LICENSES/AGPL-3.0-only.txt).
It is a fork of Stalwart, copyright © Stalwart Labs LLC, **modified by It is a fork of Stalwart, copyright © Stalwart Labs LLC, **modified by
Coffey Labs in 2026**. Upstream's copyright notices are kept on every file Coffey Labs in 2026**. Upstream's copyright notices are kept on every file
they cover, and every upstream file this fork changed says so in its header, they cover, and every upstream file this fork changed says so in its header,
under the notice it came with. Stalwart's files are dual-licensed under the notice it came with. Stalwart's files are dual-licensed
AGPL-3.0-only or Stalwart's Enterprise License, and INBUXA takes them under AGPL-3.0-only or Stalwart's Enterprise License, and **inbuxa** takes them under
the AGPL-3.0 only. A few of those files also carry code from other projects the AGPL-3.0 only. A few of those files also carry code from other projects
under MIT or BSD licenses, which stays under those licenses; under MIT or BSD licenses, which stays under those licenses;
[THIRD-PARTY.md](./THIRD-PARTY.md) lists it with its notices. "Stalwart" is [THIRD-PARTY.md](./THIRD-PARTY.md) lists it with its notices. "Stalwart" is
Stalwart Labs' name. INBUXA isn't affiliated with or endorsed by Stalwart Stalwart Labs' name. **inbuxa** isn't affiliated with or endorsed by Stalwart
Labs. Labs.
The INBUXA mark reuses ihasmail's cat-and-envelope artwork. The **inbuxa** mark reuses ihasmail's cat-and-envelope artwork.
+2 -2
View File
@@ -35,8 +35,8 @@ to Stalwart Labs with credit to you, and you'll be told that has happened.
This repository is the mail server. The web front ends have their own: This repository is the mail server. The web front ends have their own:
- [inbuxa-admin](https://github.com/inbuxa/inbuxa-admin) - [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
- [ihasmail-inbuxa](https://github.com/inbuxa/ihasmail-inbuxa) - [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa)
Upstream's own security documents are kept in `.github-upstream/` for Upstream's own security documents are kept in `.github-upstream/` for
reference. They describe Stalwart Labs' process, not this project's. reference. They describe Stalwart Labs' process, not this project's.
+1
View File
@@ -24,6 +24,7 @@ carry their own license files.
| `crates/common/src/network/acme/directory.rs`, `crates/common/src/network/acme/jose.rs`, `crates/common/src/network/acme/order.rs` | [rustls-acme](https://github.com/FlorianUekermann/rustls-acme) (MIT or Apache-2.0) | Copyright (c) Florian Uekermann | | `crates/common/src/network/acme/directory.rs`, `crates/common/src/network/acme/jose.rs`, `crates/common/src/network/acme/order.rs` | [rustls-acme](https://github.com/FlorianUekermann/rustls-acme) (MIT or Apache-2.0) | Copyright (c) Florian Uekermann |
| `crates/types/src/id.rs` | [crockford](https://github.com/archer884/crockford) (MIT or Apache-2.0) | Copyright (c) 2017 J/A <archer884@gmail.com> | | `crates/types/src/id.rs` | [crockford](https://github.com/archer884/crockford) (MIT or Apache-2.0) | Copyright (c) 2017 J/A <archer884@gmail.com> |
| `crates/nlp/src/tokenizers/types.rs` | test cases from [linkify](https://github.com/robinst/linkify) (MIT or Apache-2.0) | Copyright (c) 2017 Robin Stocker | | `crates/nlp/src/tokenizers/types.rs` | test cases from [linkify](https://github.com/robinst/linkify) (MIT or Apache-2.0) | Copyright (c) 2017 Robin Stocker |
| `resources/spam-filter/spam-filter-rules.json.gz` | the published rules of [spam-filter](https://github.com/stalwartlabs/spam-filter) v3.0.2, unmodified, built into the server as its default spam rules (MIT or Apache-2.0) | Copyright (C) 2024, Stalwart Labs LLC |
Each notice above applies with this permission notice: Each notice above applies with this permission notice:
+7 -7
View File
@@ -1,8 +1,8 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Stalwart Management API title: inbuxa Management API
description: | description: |
REST Management API for Stalwart server. These endpoints are helpers REST Management API for the inbuxa server. These endpoints are helpers
that complement the JMAP API — most of the server's configuration and data that complement the JMAP API — most of the server's configuration and data
is managed via JMAP (see `POST /jmap/`). The endpoints documented here cover is managed via JMAP (see `POST /jmap/`). The endpoints documented here cover
interactive login, account introspection, configuration schema retrieval and interactive login, account introspection, configuration schema retrieval and
@@ -12,11 +12,11 @@ info:
name: AGPL-3.0-only OR LicenseRef-SEL name: AGPL-3.0-only OR LicenseRef-SEL
servers: servers:
- url: https://{host} - url: https://{host}
description: Stalwart server description: inbuxa server
variables: variables:
host: host:
default: mail.example.com default: mail.example.com
description: The hostname of Stalwart server description: The hostname of the inbuxa server
security: security:
- bearerAuth: [] - bearerAuth: []
- basicAuth: [] - basicAuth: []
@@ -154,7 +154,7 @@ paths:
operationId: getSchema operationId: getSchema
summary: Return the configuration schema at a specific hash summary: Return the configuration schema at a specific hash
description: | description: |
Returns the JSON Schema describing the full Stalwart configuration tree. Returns the JSON Schema describing the full inbuxa configuration tree.
The response is always gzip-encoded (`Content-Encoding: gzip`) and served The response is always gzip-encoded (`Content-Encoding: gzip`) and served
with an immutable cache policy — the schema for a given hash never with an immutable cache policy — the schema for a given hash never
changes. If the hash does not match the server's current schema, the changes. If the hash does not match the server's current schema, the
@@ -183,7 +183,7 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
description: JSON Schema document describing Stalwart config description: JSON Schema document describing inbuxa config
additionalProperties: true additionalProperties: true
'302': '302':
description: Redirect to the current schema URL when the hash is stale description: Redirect to the current schema URL when the hash is stale
@@ -395,7 +395,7 @@ components:
WWW-Authenticate: WWW-Authenticate:
schema: schema:
type: string type: string
example: Bearer realm="Stalwart Server" example: Bearer realm="inbuxa Server"
content: content:
application/problem+json: application/problem+json:
schema: schema:
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "common" name = "common"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"
@@ -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"] }
+147 -32
View File
@@ -11,7 +11,7 @@ use crate::{
auth::{ auth::{
AccessToken, AuthRequest, DomainCache, AccessToken, AuthRequest, DomainCache,
credential::{ApiKey, AppPassword}, credential::{ApiKey, AppPassword},
oauth::GrantType, oauth::{GrantType, token::TOKEN_HEADER},
}, },
}; };
use base64::{Engine, engine::general_purpose}; use base64::{Engine, engine::general_purpose};
@@ -23,7 +23,8 @@ use registry::schema::{
enums::Permission, enums::Permission,
structs::{self, Credential}, structs::{self, Credential},
}; };
use std::{net::IpAddr, sync::Arc}; use serde::Deserialize;
use std::{borrow::Cow, net::IpAddr, sync::Arc};
use store::write::now; use store::write::now;
use trc::AddContext; use trc::AddContext;
@@ -321,19 +322,12 @@ impl Server {
// Obtain external directory, if any. When no username is supplied // Obtain external directory, if any. When no username is supplied
// (e.g. HTTP bearer auth), peek at the JWT claims to find the // (e.g. HTTP bearer auth), peek at the JWT claims to find the
// user's domain so per-domain OIDC directories are reachable. // user's domain so per-domain OIDC directories are reachable.
let directory = if let Some(username) = username.as_deref().map(UsernameParts::new) let directory = match username.as_deref().map(UsernameParts::new) {
{ Some(username) => match username.auth_as().domain() {
if let Some(domain_name) = username.auth_as().domain() { Some(domain_name) => self.get_directory_for_domain(domain_name).await?,
self.get_directory_for_domain(domain_name).await? None => self.get_directory_for_token(token).await?,
} else if let Some(domain_name) = extract_jwt_domain(token) { },
self.get_directory_for_domain(&domain_name).await? None => self.get_directory_for_token(token).await?,
} else {
self.get_default_directory()
}
} else if let Some(domain_name) = extract_jwt_domain(token) {
self.get_directory_for_domain(&domain_name).await?
} else {
self.get_default_directory()
}; };
// Try external directory authentication first if supported, then fallback to internal OAuth. // Try external directory authentication first if supported, then fallback to internal OAuth.
@@ -563,6 +557,29 @@ impl Server {
}) })
} }
async fn get_directory_for_token(&self, token: &str) -> trc::Result<Option<&Arc<Directory>>> {
let Some(payload) = JwtClaims::decode_payload(token) else {
return Ok(self.get_default_directory());
};
let Some(claims) = JwtClaims::parse(&payload) else {
return Ok(self.get_default_directory());
};
match (claims.domain(), claims.iss.as_deref()) {
(Some(domain_name), _) => self.get_directory_for_domain(domain_name).await,
(None, Some(issuer)) => Ok(self
.get_directory_for_issuer(issuer)
.or_else(|| self.get_default_directory())),
(None, None) => Ok(self.get_default_directory()),
}
}
/// inbuxa: DIR-2: a token naming no address gets the server default, so
/// no directory is chosen by issuer.
fn get_directory_for_issuer(&self, _issuer: &str) -> Option<&Arc<Directory>> {
None
}
/// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A /// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A
/// `directoryId` naming no directory the server built is unavailable, /// `directoryId` naming no directory the server built is unavailable,
/// never the internal directory. /// never the internal directory.
@@ -622,25 +639,50 @@ pub fn unavailable_directory() -> &'static Arc<Directory> {
}) })
} }
fn extract_jwt_domain(token: &str) -> Option<String> { #[derive(Deserialize)]
let mut parts = token.split('.'); struct JwtClaims<'x> {
let _header = parts.next()?; #[serde(borrow, default)]
let payload = parts.next()?; iss: Option<Cow<'x, str>>,
let _signature = parts.next()?; #[serde(borrow, default)]
if parts.next().is_some() { email: Option<Cow<'x, str>>,
return None; #[serde(borrow, default)]
} preferred_username: Option<Cow<'x, str>>,
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?; #[serde(borrow, default)]
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; upn: Option<Cow<'x, str>>,
for claim in ["email", "preferred_username", "upn"] { }
if let Some(val) = claims.get(claim).and_then(|v| v.as_str())
&& let Some((_, domain)) = val.rsplit_once('@') impl<'x> JwtClaims<'x> {
&& !domain.is_empty() fn decode_payload(token: &str) -> Option<Vec<u8>> {
{ if token.starts_with(TOKEN_HEADER) {
return Some(domain.to_ascii_lowercase()); return None;
} }
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let _signature = parts.next()?;
if parts.next().is_some() {
return None;
}
general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()
}
fn parse(payload: &'x [u8]) -> Option<Self> {
serde_json::from_slice(payload).ok()
}
fn domain(&self) -> Option<&str> {
[&self.email, &self.preferred_username, &self.upn]
.into_iter()
.flatten()
.find_map(|claim| {
claim
.rsplit_once('@')
.map(|(_, domain)| domain)
.filter(|domain| !domain.is_empty())
})
} }
None
} }
impl UsernameParts { impl UsernameParts {
@@ -738,3 +780,76 @@ impl AuthRequest {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn jwt(payload: &str) -> String {
format!(
"eyJhbGciOiJSUzI1NiJ9.{}.c2lnbmF0dXJl",
general_purpose::URL_SAFE_NO_PAD.encode(payload)
)
}
fn hints(token: &str) -> Option<(Option<String>, Option<String>)> {
let payload = JwtClaims::decode_payload(token)?;
let claims = JwtClaims::parse(&payload)?;
Some((
claims.domain().map(str::to_string),
claims.iss.as_deref().map(str::to_string),
))
}
#[test]
fn jwt_claims_are_extracted() {
for (payload, domain, issuer) in [
(
r#"{"iss":"https://idp.example.org","email":"[email protected]"}"#,
Some("Example.ORG"),
Some("https://idp.example.org"),
),
(
r#"{"preferred_username":"[email protected]","upn":"[email protected]"}"#,
Some("example.net"),
None,
),
(
r#"{"email":"broken@","upn":"[email protected]"}"#,
Some("example.com"),
None,
),
(
r#"{"iss":"https://idp.example.org","sub":"5db2d1b6","aud":["a","b"],"scope":"openid"}"#,
None,
Some("https://idp.example.org"),
),
(r#"{"sub":"5db2d1b6"}"#, None, None),
(r#"{"email":"[email protected]"}"#, Some("example.net"), None),
] {
assert_eq!(
hints(&jwt(payload)),
Some((domain.map(str::to_string), issuer.map(str::to_string))),
"Unexpected claims for {payload}"
);
}
}
#[test]
fn non_jwt_tokens_are_ignored() {
for token in [
"sw1.eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLm9yZyJ9",
"sw1.eyJhbGciOiJSUzI1NiJ9",
"opaque-token",
"one.two",
"one.two.three.four",
"",
] {
assert!(
JwtClaims::decode_payload(token).is_none(),
"Token {token:?} was parsed as a JWT"
);
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub const FAILED_TO_DECODE_TOKEN: &str = concat!(
"the Authentication object." "the Authentication object."
); );
const TOKEN_HEADER: &str = "sw1."; pub(crate) const TOKEN_HEADER: &str = "sw1.";
const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1"; const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1";
const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000 const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000
+4
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 super::server::tls::build_self_signed_cert; use super::server::tls::build_self_signed_cert;
@@ -67,6 +69,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 +225,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(),
+23 -1
View File
@@ -143,7 +143,9 @@ impl Scripting {
.with_cpu_limit(trusted.max_cpu_cycles as usize) .with_cpu_limit(trusted.max_cpu_cycles as usize)
.with_max_nested_includes(trusted.max_nested_includes as usize) .with_max_nested_includes(trusted.max_nested_includes as usize)
.with_max_received_headers(trusted.max_received_headers as usize) .with_max_received_headers(trusted.max_received_headers as usize)
.with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs()); .with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs())
// inbuxa: without it, `environment "name"` answers sieve-rs's default
.with_env_variable("name", types::brand_server!());
trusted_runtime.set_local_hostname(local_hostname.clone()); trusted_runtime.set_local_hostname(local_hostname.clone());
untrusted_runtime.set_local_hostname(local_hostname); untrusted_runtime.set_local_hostname(local_hostname);
@@ -279,3 +281,23 @@ impl Clone for Scripting {
} }
} }
} }
#[cfg(test)]
mod tests {
use sieve::compiler::grammar::Capability;
// inbuxa: sieve-rs is vendored (vendor/sieve-rs) to carry the fork's
// name in its Sieve extensions. If Cargo.lock moves sieve-rs past the
// vendored version, Cargo drops the patch with only a warning and
// upstream's spelling comes back; this fails instead.
#[test]
fn sieve_extensions_carry_the_fork_name() {
for (capability, name) in [
(Capability::While, "vnd.inbuxa.while"),
(Capability::Expressions, "vnd.inbuxa.expressions"),
] {
assert_eq!(capability.to_string(), name);
assert_eq!(Capability::parse(name), capability);
}
}
}
@@ -243,7 +243,8 @@ impl SpamFilterConfig {
spam_threshold: spam.score_spam.into_inner() as f32, spam_threshold: spam.score_spam.into_inner() as f32,
}, },
grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()), grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()),
spam_rules_url: spam.spam_filter_rules_url, // inbuxa: unset, empty or upstream's old default means the bundled rules
spam_rules_url: crate::manager::spam_rules::rules_url(spam.spam_filter_rules_url),
url_client: utils::http::http_client_builder(true) url_client: utils::http::http_client_builder(true)
.pool_max_idle_per_host(0) .pool_max_idle_per_host(0)
.redirect(reqwest::redirect::Policy::none()) .redirect(reqwest::redirect::Policy::none())
+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,
}, },
}; };
@@ -214,6 +214,7 @@ impl Resolvers {
let config_dnssec = resolver_config.clone(); let config_dnssec = resolver_config.clone();
let mut opts_dnssec = opts.clone(); let mut opts_dnssec = opts.clone();
opts_dnssec.validate = true; opts_dnssec.validate = true;
opts_dnssec.num_concurrent_reqs = 1;
let dnssec = DnssecResolver { let dnssec = DnssecResolver {
resolver: TokioResolver::builder_with_config( resolver: TokioResolver::builder_with_config(
@@ -343,6 +344,7 @@ impl Default for Resolvers {
let config_dnssec = config.clone(); let config_dnssec = config.clone();
let mut opts_dnssec = opts.clone(); let mut opts_dnssec = opts.clone();
opts_dnssec.validate = true; opts_dnssec.validate = true;
opts_dnssec.num_concurrent_reqs = 1;
Self { Self {
dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"), dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"),
+2 -2
View File
@@ -583,10 +583,10 @@ impl Metrics {
pub async fn parse(bp: &mut Bootstrap) -> Self { pub async fn parse(bp: &mut Bootstrap) -> Self {
let metrics = bp.setting_infallible::<structs::Metrics>().await; let metrics = bp.setting_infallible::<structs::Metrics>().await;
let resource = Resource::builder() let resource = Resource::builder()
.with_service_name("stalwart") .with_service_name("inbuxa")
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!())) .with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
.build(); .build();
let instrumentation = InstrumentationScope::builder("stalwart") let instrumentation = InstrumentationScope::builder("inbuxa")
.with_version(types::brand_version_full!()) .with_version(types::brand_version_full!())
.build(); .build();
+7
View File
@@ -23,6 +23,13 @@ pub(crate) fn fn_is_number(v: Vec<Variable>) -> Variable {
matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into() matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into()
} }
pub(crate) fn fn_bit_and(v: Vec<Variable>) -> Variable {
match (v[0].to_integer(), v[1].to_integer()) {
(Some(lhs), Some(rhs)) => Variable::Integer(lhs & rhs),
_ => Variable::Integer(0),
}
}
pub(crate) fn fn_is_ip_addr(v: Vec<Variable>) -> Variable { pub(crate) fn fn_is_ip_addr(v: Vec<Variable>) -> Variable {
v[0].to_string() v[0].to_string()
.as_str() .as_str()
+1
View File
@@ -46,6 +46,7 @@ pub(crate) const FUNCTIONS: &[(&str, fn(Vec<Variable>) -> Variable, u32)] = &[
("email_part", email::fn_email_part, 2), ("email_part", email::fn_email_part, 2),
("is_empty", misc::fn_is_empty, 1), ("is_empty", misc::fn_is_empty, 1),
("is_number", misc::fn_is_number, 1), ("is_number", misc::fn_is_number, 1),
("bit_and", misc::fn_bit_and, 2),
("is_ip_addr", misc::fn_is_ip_addr, 1), ("is_ip_addr", misc::fn_is_ip_addr, 1),
("is_ipv4_addr", misc::fn_is_ipv4_addr, 1), ("is_ipv4_addr", misc::fn_is_ipv4_addr, 1),
("is_ipv6_addr", misc::fn_is_ipv6_addr, 1), ("is_ipv6_addr", misc::fn_is_ipv6_addr, 1),
+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,
+234 -118
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::{Server, manager::fetch_resource}; use crate::{Server, manager::fetch_resource};
@@ -11,8 +13,11 @@ use registry::schema::{enums::CompressionAlgo, structs::Application};
use std::{ use std::{
borrow::Cow, borrow::Cow,
io::{self, Cursor, Read}, io::{self, Cursor, Read},
path::PathBuf, path::{Path, PathBuf},
sync::Arc, sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::Duration, time::Duration,
}; };
use store::{ use store::{
@@ -36,16 +41,18 @@ enum IndexEdit<'x> {
pub struct WebApplications { pub struct WebApplications {
applications: ArcSwap<Vec<WebApplicationManager>>, applications: ArcSwap<Vec<WebApplicationManager>>,
routes: ArcSwap<AHashMap<String, Arc<AppRoutes>>>, routes: ArcSwap<AHashMap<String, Arc<AppRoutes>>>,
generation: AtomicU64,
} }
pub struct AppRoutes { pub struct AppRoutes {
resources: AHashMap<String, Resource<PathBuf>>, resources: AHashMap<String, Resource<PathBuf>>,
oauth_client_id_meta: Option<String>, oauth_client_id_meta: Option<String>,
_bundle_dir: TempDir,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct WebApplicationManager { pub struct WebApplicationManager {
bundle_path: TempDir, base_path: PathBuf,
prefixes: Vec<String>, prefixes: Vec<String>,
description: String, description: String,
url: String, url: String,
@@ -79,6 +86,7 @@ impl WebApplications {
Self { Self {
applications: ArcSwap::new(Arc::new(Vec::new())), applications: ArcSwap::new(Arc::new(Vec::new())),
routes: ArcSwap::new(Arc::new(AHashMap::new())), routes: ArcSwap::new(Arc::new(AHashMap::new())),
generation: AtomicU64::new(0),
} }
} }
@@ -128,48 +136,55 @@ impl WebApplications {
} }
pub async fn unpack_all(&self, server: &Server, update: bool) { pub async fn unpack_all(&self, server: &Server, update: bool) {
let mut routes = AHashMap::new(); let previous = self.routes.load_full();
let sweep_orphans = previous.is_empty();
let mut routes = AHashMap::with_capacity(previous.len());
for app in self.applications.load().as_ref() { for app in self.applications.load().as_ref() {
if update && let Err(err) = app.delete(server).await { match app
trc::event!( .unpack(server, self.next_generation(), update, sweep_orphans)
Resource(trc::ResourceEvent::Error), .await
Reason = err, {
Url = app.url.clone(), Ok(app_routes) => {
Details = format!( let app_routes = Arc::new(app_routes);
"Failed to delete application bundle for prefixes: {}",
app.prefixes.join(", ")
)
);
}
match app.unpack(server).await {
Ok(resources) => {
let app_routes = Arc::new(AppRoutes {
resources,
oauth_client_id_meta: app
.oauth_client_id
.as_deref()
.map(oauth_client_id_meta),
});
for prefix in &app.prefixes { for prefix in &app.prefixes {
routes.insert(prefix.clone(), app_routes.clone()); routes.insert(prefix.clone(), app_routes.clone());
} }
} }
Err(err) => { Err(err) => {
let mut is_retained = false;
for prefix in &app.prefixes {
if let Some(app_routes) = previous.get(prefix) {
routes.insert(prefix.clone(), app_routes.clone());
is_retained = true;
}
}
trc::event!( trc::event!(
Resource(trc::ResourceEvent::Error), Resource(trc::ResourceEvent::Error),
Reason = err, Reason = err,
Url = app.url.clone(), Url = app.url.clone(),
Details = format!( Details = format!(
"Failed to unpack application for prefixes: {}", "Failed to unpack application for prefixes: {}, {}",
app.prefixes.join(", ") app.prefixes.join(", "),
if is_retained {
"the previously unpacked bundle remains in service"
} else {
"no bundle is available to serve"
}
) )
); );
} }
} }
} }
self.routes.store(Arc::new(routes)); self.routes.store(Arc::new(routes));
} }
fn next_generation(&self) -> u64 {
self.generation.fetch_add(1, Ordering::Relaxed)
}
} }
impl WebApplicationManager { impl WebApplicationManager {
@@ -182,7 +197,7 @@ impl WebApplicationManager {
.join(app.id.id().to_string()); .join(app.id.id().to_string());
Self { Self {
bundle_path: TempDir::new(base_path), base_path,
blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()), blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()),
url: app.object.resource_url, url: app.object.resource_url,
description: app.object.description, description: app.object.description,
@@ -202,82 +217,43 @@ impl WebApplicationManager {
} }
} }
async fn unpack(&self, server: &Server) -> trc::Result<AHashMap<String, Resource<PathBuf>>> { async fn unpack(
// Delete any existing bundles &self,
self.bundle_path.clean().await.map_err(unpack_error)?; server: &Server,
generation: u64,
// Obtain application bundle force_refresh: bool,
let bundle = if let Some(bundle) = server sweep_orphans: bool,
.blob_store() ) -> trc::Result<AppRoutes> {
.get_blob(self.blob_key.as_slice(), 0..usize::MAX) let cached = if force_refresh {
.await? None
{
bundle
} else { } else {
// Fetch app bundle
let resource = fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
.await
.map_err(|err| {
trc::ResourceEvent::Error
.caused_by(trc::location!())
.ctx(Key::Url, self.url.clone())
.reason(err)
.details("Failed to fetch application bundle")
})?;
// Store in blob store for future use
server server
.blob_store() .blob_store()
.put_blob(self.blob_key.as_slice(), &resource, CompressionAlgo::None) .get_blob(self.blob_key.as_slice(), 0..usize::MAX)
.await .await?
.caused_by(trc::location!())?; };
let is_cached = cached.is_some();
// Schedule expiration let bundle = match cached {
let mut batch = BatchBuilder::new(); Some(bundle) => bundle,
batch None => self.fetch().await?,
.set(
BlobOp::Link {
hash: self.blob_key.clone(),
to: BlobLink::Temporary {
until: now() + self.expiry,
},
},
vec![],
)
.set(
BlobOp::Commit {
hash: self.blob_key.clone(),
},
Vec::new(),
);
server
.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
trc::event!(
Resource(trc::ResourceEvent::ApplicationUpdated),
Url = self.url.clone(),
Details = self.description.clone(),
);
resource
}; };
let staging = TempDir::new(self.base_path.join(format!("{:x}-{generation:x}", now())));
staging.create().await.map_err(unpack_error)?;
let url = self.url.clone(); let url = self.url.clone();
let bundle_path = self.bundle_path.path.clone(); let bundle_path = staging.path.clone();
let routes = tokio::task::spawn_blocking(move || -> trc::Result<_> { let (resources, bundle) = tokio::task::spawn_blocking(move || -> trc::Result<_> {
let mut bundle = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| { let mut archive = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
trc::ResourceEvent::Error trc::ResourceEvent::Error
.caused_by(trc::location!()) .caused_by(trc::location!())
.reason(err) .reason(err)
.ctx(Key::Url, url.clone()) .ctx(Key::Url, url.clone())
.details("Failed to decompress application bundle") .details("Failed to decompress application bundle")
})?; })?;
let mut routes = AHashMap::new(); let mut resources = AHashMap::with_capacity(archive.len());
for i in 0..bundle.len() { for i in 0..archive.len() {
let mut file = bundle.by_index(i).map_err(|err| { let mut file = archive.by_index(i).map_err(|err| {
trc::ResourceEvent::Error trc::ResourceEvent::Error
.caused_by(trc::location!()) .caused_by(trc::location!())
.reason(err) .reason(err)
@@ -315,9 +291,9 @@ impl WebApplicationManager {
contents: path, contents: path,
}; };
routes.insert(file_name, resource); resources.insert(file_name, resource);
} }
Ok(routes) Ok((resources, archive.into_inner().into_inner()))
}) })
.await .await
.map_err(|err| { .map_err(|err| {
@@ -327,21 +303,81 @@ impl WebApplicationManager {
.details("Bundle unpack task panicked") .details("Bundle unpack task panicked")
})??; })??;
if !is_cached && let Err(err) = self.cache(server, &bundle).await {
trc::event!(
Resource(trc::ResourceEvent::Error),
Reason = err,
Url = self.url.clone(),
Details = "Failed to cache application bundle, it will be downloaded again"
);
}
if sweep_orphans {
remove_siblings(&self.base_path, &staging.path).await;
}
trc::event!( trc::event!(
Resource(trc::ResourceEvent::ApplicationUnpacked), Resource(trc::ResourceEvent::ApplicationUnpacked),
Url = self.url.clone(), Url = self.url.clone(),
Path = self.bundle_path.path.to_string_lossy().into_owned(), Path = staging.path.to_string_lossy().into_owned(),
); );
Ok(routes) Ok(AppRoutes {
resources,
oauth_client_id_meta: self.oauth_client_id.as_deref().map(oauth_client_id_meta),
_bundle_dir: staging,
})
} }
async fn delete(&self, server: &Server) -> trc::Result<()> { async fn fetch(&self) -> trc::Result<Vec<u8>> {
fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
.await
.map_err(|err| {
trc::ResourceEvent::Error
.caused_by(trc::location!())
.ctx(Key::Url, self.url.clone())
.reason(err)
.details("Failed to fetch application bundle")
})
}
async fn cache(&self, server: &Server, bundle: &[u8]) -> trc::Result<()> {
server server
.blob_store() .blob_store()
.delete_blob(self.blob_key.as_slice()) .put_blob(self.blob_key.as_slice(), bundle, CompressionAlgo::None)
.await .await
.map(|_| ()) .caused_by(trc::location!())?;
let mut batch = BatchBuilder::new();
batch
.set(
BlobOp::Link {
hash: self.blob_key.clone(),
to: BlobLink::Temporary {
until: now() + self.expiry,
},
},
vec![],
)
.set(
BlobOp::Commit {
hash: self.blob_key.clone(),
},
Vec::new(),
);
server
.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
trc::event!(
Resource(trc::ResourceEvent::ApplicationUpdated),
Url = self.url.clone(),
Details = self.description.clone(),
);
Ok(())
} }
pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> { pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> {
@@ -361,7 +397,6 @@ impl Resource<Vec<u8>> {
} }
} }
#[derive(Clone)]
pub struct TempDir { pub struct TempDir {
pub path: PathBuf, pub path: PathBuf,
} }
@@ -371,11 +406,36 @@ impl TempDir {
TempDir { path } TempDir { path }
} }
pub async fn clean(&self) -> io::Result<()> { pub async fn create(&self) -> io::Result<()> {
if tokio::fs::metadata(&self.path).await.is_ok() { if tokio::fs::metadata(&self.path).await.is_ok() {
let _ = tokio::fs::remove_dir_all(&self.path).await; let _ = tokio::fs::remove_dir_all(&self.path).await;
} }
tokio::fs::create_dir(&self.path).await tokio::fs::create_dir_all(&self.path).await
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
async fn remove_siblings(base_path: &Path, keep: &Path) {
let Ok(mut entries) = tokio::fs::read_dir(base_path).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path == keep {
continue;
}
if matches!(entry.file_type().await, Ok(file_type) if file_type.is_dir()) {
let _ = tokio::fs::remove_dir_all(&path).await;
} else {
let _ = tokio::fs::remove_file(&path).await;
}
} }
} }
@@ -385,12 +445,6 @@ fn unpack_error(err: std::io::Error) -> trc::Error {
.details("Failed to unpack application bundle") .details("Failed to unpack application bundle")
} }
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
impl Default for WebApplications { impl Default for WebApplications {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@@ -460,12 +514,12 @@ mod tests {
#[test] #[test]
fn index_is_rewritten_with_the_prefix_and_client_id() { fn index_is_rewritten_with_the_prefix_and_client_id() {
let meta = oauth_client_id_meta("stalwart-webui"); let meta = oauth_client_id_meta("inbuxa-webui");
let html = String::from_utf8(rewrite_index(INDEX, "admin", Some(&meta))).unwrap(); let html = String::from_utf8(rewrite_index(INDEX, "admin", Some(&meta))).unwrap();
assert!(html.contains("<base href=\"/admin/\" />"), "{html}"); assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
assert!( assert!(
html.contains("<meta name=\"oauth-client-id\" content=\"stalwart-webui\" />"), html.contains("<meta name=\"oauth-client-id\" content=\"inbuxa-webui\" />"),
"{html}" "{html}"
); );
assert!(html.contains("<title>Portal</title>"), "{html}"); assert!(html.contains("<title>Portal</title>"), "{html}");
@@ -487,7 +541,7 @@ mod tests {
#[test] #[test]
fn index_without_a_placeholder_is_left_alone() { fn index_without_a_placeholder_is_left_alone() {
let bundle = "<head>\n <base href=\"/\" />\n</head>"; let bundle = "<head>\n <base href=\"/\" />\n</head>";
let meta = oauth_client_id_meta("stalwart-webui"); let meta = oauth_client_id_meta("inbuxa-webui");
let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap(); let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap();
assert_eq!(html, "<head>\n <base href=\"/admin/\" />\n</head>"); assert_eq!(html, "<head>\n <base href=\"/admin/\" />\n</head>");
@@ -521,9 +575,9 @@ mod tests {
); );
} }
async fn fixture(name: &str, client_id: Option<&str>) -> (WebApplications, TempDir) { async fn fixture(name: &str, client_id: Option<&str>) -> WebApplications {
let dir = TempDir::new(std::env::temp_dir().join(format!("stalwart-app-{name}"))); let dir = TempDir::new(std::env::temp_dir().join(format!("inbuxa-app-{name}")));
dir.clean().await.unwrap(); dir.create().await.unwrap();
tokio::fs::write(dir.path.join("index.html"), INDEX) tokio::fs::write(dir.path.join("index.html"), INDEX)
.await .await
.unwrap(); .unwrap();
@@ -544,6 +598,7 @@ mod tests {
let routes = Arc::new(AppRoutes { let routes = Arc::new(AppRoutes {
resources, resources,
oauth_client_id_meta: client_id.map(oauth_client_id_meta), oauth_client_id_meta: client_id.map(oauth_client_id_meta),
_bundle_dir: dir,
}); });
let mut map = AHashMap::new(); let mut map = AHashMap::new();
@@ -553,7 +608,7 @@ mod tests {
let apps = WebApplications::new(); let apps = WebApplications::new();
apps.routes.store(Arc::new(map)); apps.routes.store(Arc::new(map));
(apps, dir) apps
} }
async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String { async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String {
@@ -565,7 +620,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn serving_index_injects_the_prefix_and_client_id() { async fn serving_index_injects_the_prefix_and_client_id() {
let (apps, _dir) = fixture("serve-configured", Some("pocket-id-client")).await; let apps = fixture("serve-configured", Some("pocket-id-client")).await;
let html = serve_html(&apps, "admin", "index.html").await; let html = serve_html(&apps, "admin", "index.html").await;
assert!(html.contains("<base href=\"/admin/\" />"), "{html}"); assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
@@ -584,7 +639,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn unknown_paths_fall_back_to_a_rewritten_index() { async fn unknown_paths_fall_back_to_a_rewritten_index() {
let (apps, _dir) = fixture("serve-fallback", Some("pocket-id-client")).await; let apps = fixture("serve-fallback", Some("pocket-id-client")).await;
let html = serve_html(&apps, "admin", "settings/directory").await; let html = serve_html(&apps, "admin", "settings/directory").await;
assert!(html.contains("<base href=\"/admin/\" />"), "{html}"); assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
@@ -596,7 +651,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn assets_and_unknown_prefixes_are_untouched() { async fn assets_and_unknown_prefixes_are_untouched() {
let (apps, _dir) = fixture("serve-assets", Some("pocket-id-client")).await; let apps = fixture("serve-assets", Some("pocket-id-client")).await;
let served = apps.serve("admin", "app.js").await.unwrap().unwrap(); let served = apps.serve("admin", "app.js").await.unwrap().unwrap();
assert_eq!(served.resource.contents, b"export const x = 1;\n"); assert_eq!(served.resource.contents, b"export const x = 1;\n");
@@ -608,7 +663,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn serving_index_without_a_client_id_keeps_the_placeholder() { async fn serving_index_without_a_client_id_keeps_the_placeholder() {
let (apps, _dir) = fixture("serve-unconfigured", None).await; let apps = fixture("serve-unconfigured", None).await;
let html = serve_html(&apps, "admin", "index.html").await; let html = serve_html(&apps, "admin", "index.html").await;
assert!(html.contains("<base href=\"/admin/\" />"), "{html}"); assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
@@ -624,4 +679,65 @@ mod tests {
assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes()); assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes());
} }
#[tokio::test]
async fn missing_parent_directories_are_created() {
let base = std::env::temp_dir().join("inbuxa-app-nested");
let _ = tokio::fs::remove_dir_all(&base).await;
let dir = TempDir::new(base.join("webui").join("0"));
dir.create().await.unwrap();
assert!(tokio::fs::metadata(&dir.path).await.is_ok());
drop(dir);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn dropping_the_routes_removes_the_bundle_directory() {
let apps = fixture("drop-guard", None).await;
let path = apps
.routes
.load()
.get("admin")
.unwrap()
._bundle_dir
.path
.clone();
assert!(tokio::fs::metadata(&path).await.is_ok());
apps.routes.store(Arc::new(AHashMap::new()));
assert!(tokio::fs::metadata(&path).await.is_err());
}
#[tokio::test]
async fn sweeping_orphans_spares_the_current_generation() {
let base = std::env::temp_dir().join("inbuxa-app-sweep");
let _ = tokio::fs::remove_dir_all(&base).await;
let current = TempDir::new(base.join("1"));
current.create().await.unwrap();
let orphan = base.join("0");
tokio::fs::create_dir_all(&orphan).await.unwrap();
let stray = base.join("webui.zip");
tokio::fs::write(&stray, b"not a bundle").await.unwrap();
remove_siblings(&base, &current.path).await;
assert!(tokio::fs::metadata(&current.path).await.is_ok());
assert!(tokio::fs::metadata(&orphan).await.is_err());
assert!(tokio::fs::metadata(&stray).await.is_err());
drop(current);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[test]
fn generations_never_repeat() {
let apps = WebApplications::new();
assert_ne!(apps.next_generation(), apps.next_generation());
}
} }
+14 -5
View File
@@ -530,13 +530,22 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
use store::write::BatchBuilder; use store::write::BatchBuilder;
use types::id::Id; use types::id::Id;
if bp.registry.count_object(ObjectType::SpamRule).await? == 0 // inbuxa: rules are always to hand, since a copy ships with the server
&& bp // (spam_rules). They load on first boot, and again when the bundled
.registry // version differs from the one last loaded, which only adds what's
// missing: new tags and rules, never a changed score.
let rules_url = super::spam_rules::rules_url(
bp.registry
.object::<SpamSettings>(Id::singleton()) .object::<SpamSettings>(Id::singleton())
.await? .await?
.is_none_or(|spam| spam.spam_filter_rules_url.is_some()) .and_then(|spam| spam.spam_filter_rules_url),
{ );
let bundled_is_new = rules_url.is_none()
&& super::spam_rules::applied_version(&bp.data_store)
.await?
.as_deref()
!= Some(super::spam_rules::BUNDLED_SPAM_RULES_VERSION);
if bp.registry.count_object(ObjectType::SpamRule).await? == 0 || bundled_is_new {
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance { batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules, maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules,
+68 -10
View File
@@ -10,7 +10,7 @@
//! that ship with it are registered for it, on every start: //! that ship with it are registered for it, on every start:
//! //!
//! - the web interface the server serves itself (`Application`, `/admin` and //! - the web interface the server serves itself (`Application`, `/admin` and
//! `/account`), as its OAuth client id, `stalwart-webui` unless the //! `/account`), as its OAuth client id, `inbuxa-webui` unless the
//! application names another; //! application names another;
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL` //! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
//! is set; //! is set;
@@ -29,7 +29,7 @@ use directory::core::secret::{hash_secret, verify_secret_hash};
use registry::{ use registry::{
schema::{ schema::{
enums::{PasswordHashAlgorithm, ServiceProtocol}, enums::{PasswordHashAlgorithm, ServiceProtocol},
prelude::{ObjectType, Property, UTCDateTime}, prelude::{Object, ObjectInner, ObjectType, Property, UTCDateTime},
structs::{Application, OAuthClient, SystemSettings}, structs::{Application, OAuthClient, SystemSettings},
}, },
types::map::Map, types::map::Map,
@@ -40,9 +40,12 @@ use store::registry::{
}; };
/// The client id the upstream web interface uses when its application names none. /// The client id the upstream web interface uses when its application names none.
pub const WEB_INTERFACE_CLIENT_ID: &str = "stalwart-webui"; pub const WEB_INTERFACE_CLIENT_ID: &str = "inbuxa-webui";
pub const ADMIN_CLIENT_ID: &str = "inbuxa-admin"; pub const ADMIN_CLIENT_ID: &str = "inbuxa-admin";
pub const WEBMAIL_CLIENT_ID: &str = "ihasmail-inbuxa"; pub const WEBMAIL_CLIENT_ID: &str = "ihasmail-inbuxa";
/// The web interface's client id before the fork renamed it (SPEC §2.4).
/// Only ever read to retire it.
const LEGACY_WEB_INTERFACE_CLIENT_ID: &str = "stalwart-webui";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirstPartyClient { pub struct FirstPartyClient {
@@ -102,7 +105,7 @@ pub fn first_party_clients(
if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) { if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) {
clients.push(FirstPartyClient { clients.push(FirstPartyClient {
client_id: ADMIN_CLIENT_ID.to_string(), client_id: ADMIN_CLIENT_ID.to_string(),
description: "INBUXA Admin".to_string(), description: "inbuxa Admin".to_string(),
redirect_uris: vec![format!("{url}/oauth/callback")], redirect_uris: vec![format!("{url}/oauth/callback")],
secret: None, secret: None,
}); });
@@ -187,6 +190,7 @@ fn env(name: &str) -> Option<String> {
} }
pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> { pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> {
retire_legacy_web_interface_client(bp).await?;
let system = bp.setting_infallible::<SystemSettings>().await; let system = bp.setting_infallible::<SystemSettings>().await;
let base_url = base_url(bp, &system); let base_url = base_url(bp, &system);
let applications = bp let applications = bp
@@ -213,6 +217,56 @@ pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Resul
Ok(()) Ok(())
} }
/// An install from before the rename, upstream's or this fork's, has the web
/// interface registered as `stalwart-webui`, and may
/// have an application naming it. The application is moved to the current id
/// and the old client removed, so the old id stops working rather than
/// living on as an alias; anyone signed in to the web interface signs in
/// again. Runs on every start and does nothing once both are gone.
async fn retire_legacy_web_interface_client(bp: &mut Bootstrap) -> trc::Result<()> {
for app in bp.list_infallible::<Application>().await {
if app.object.oauth_client_id.as_deref() != Some(LEGACY_WEB_INTERFACE_CLIENT_ID) {
continue;
}
let mut updated = app.object.clone();
updated.oauth_client_id = Some(WEB_INTERFACE_CLIENT_ID.to_string());
// The old object carries its revision: the write asserts on it.
let current = Object::with_revision(ObjectInner::from(app.object), app.revision);
let result = bp
.registry
.write(RegistryWrite::update(app.id.id(), &updated.into(), &current))
.await?;
if !matches!(result, RegistryWriteResult::Success(_)) {
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to move an application to the renamed web interface client.")
.reason(result.to_string())
.caused_by(trc::location!()));
}
}
if let Some(object_id) = bp
.registry
.primary_key(
ObjectType::OAuthClient.into(),
Property::ClientId,
LEGACY_WEB_INTERFACE_CLIENT_ID.as_bytes().to_vec(),
)
.await?
{
let result = bp.registry.write(RegistryWrite::delete(object_id)).await?;
if !matches!(result, RegistryWriteResult::Success(_)) {
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to remove the web interface's pre-rename OAuth client.")
.reason(result.to_string())
.caused_by(trc::location!()));
}
}
Ok(())
}
async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Result<()> { async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Result<()> {
let existing = match bp let existing = match bp
.registry .registry
@@ -223,15 +277,18 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
) )
.await? .await?
{ {
// inbuxa: read as an Object, keeping the revision the update below
// asserts on (a bare OAuthClient converts back with revision 0, which
// never matches, so any update failed start-up).
Some(object_id) => bp Some(object_id) => bp
.registry .registry
.object::<OAuthClient>(object_id.id()) .get(object_id)
.await? .await?
.map(|object| (object_id.id(), object)), .map(|object| (object_id.id(), object.revision, OAuthClient::from(object))),
None => None, None => None,
}; };
let result = if let Some((id, current)) = existing { let result = if let Some((id, revision, current)) = existing {
let mut updated = current.clone(); let mut updated = current.clone();
for uri in &client.redirect_uris { for uri in &client.redirect_uris {
if !updated.redirect_uris.contains(uri) { if !updated.redirect_uris.contains(uri) {
@@ -255,8 +312,9 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
if updated == current { if updated == current {
return Ok(()); return Ok(());
} }
let current = Object::with_revision(ObjectInner::from(current), revision);
bp.registry bp.registry
.write(RegistryWrite::update(id, &updated.into(), &current.into())) .write(RegistryWrite::update(id, &updated.into(), &current))
.await? .await?
} else { } else {
let secret = match &client.secret { let secret = match &client.secret {
@@ -298,7 +356,7 @@ mod tests {
fn web_interface() -> Application { fn web_interface() -> Application {
Application { Application {
description: "Stalwart Web Interface".to_string(), description: "inbuxa Web Interface".to_string(),
enabled: true, enabled: true,
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]), url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
..Default::default() ..Default::default()
@@ -312,7 +370,7 @@ mod tests {
clients, clients,
vec![FirstPartyClient { vec![FirstPartyClient {
client_id: WEB_INTERFACE_CLIENT_ID.to_string(), client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
description: "Stalwart Web Interface (served by this server)".to_string(), description: "inbuxa Web Interface (served by this server)".to_string(),
redirect_uris: vec![ redirect_uris: vec![
"https://mail.example.org/admin/oauth/callback".to_string(), "https://mail.example.org/admin/oauth/callback".to_string(),
"https://mail.example.org/account/oauth/callback".to_string(), "https://mail.example.org/account/oauth/callback".to_string(),
+3 -2
View File
@@ -22,9 +22,10 @@ pub mod console;
pub mod defaults; pub mod defaults;
pub mod first_party; pub mod first_party;
pub mod restore; pub mod restore;
pub mod spam_rules; // inbuxa: rules bundled with the server
pub const SPAM_TRAINER_KEY: &[u8] = "STALWART_SPAM_TRAIN_DATA.lz4".as_bytes(); pub const SPAM_TRAINER_KEY: &[u8] = "INBUXA_SPAM_TRAIN_DATA.lz4".as_bytes();
pub const SPAM_CLASSIFIER_KEY: &[u8] = "STALWART_SPAM_CLASSIFIER_MODEL.lz4".as_bytes(); pub const SPAM_CLASSIFIER_KEY: &[u8] = "INBUXA_SPAM_CLASSIFIER_MODEL.lz4".as_bytes();
pub async fn fetch_resource( pub async fn fetch_resource(
url: &str, url: &str,
+103
View File
@@ -0,0 +1,103 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: the spam filter rules that ship with the server.
//!
//! Upstream fetches its latest published rules from GitHub at run time, so
//! scoring changes with a release nobody here tested and depends on reaching
//! it. The fork embeds a pinned copy (resources/spam-filter/, with its version
//! and license) and uses it whenever no other source is configured. The rules
//! URL remains an operator override (`https://` or `file://`).
//!
//! Loading rules only ever adds what's missing, never changes an existing rule
//! or score. They load on first boot, and again whenever the bundled version
//! differs from the one last applied, so an upgrade brings new tags (the AI
//! classifier's `LLM_*` scores, say) to an install that already had rules.
use std::io::Read;
use store::{
SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// The version of spam-filter the embedded rules come from.
pub const BUNDLED_SPAM_RULES_VERSION: &str = "3.0.2";
static BUNDLED_SPAM_RULES: &[u8] =
include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz");
/// Upstream's default rules source, the value every install created before
/// the rules were bundled has saved. Read only to treat it as unset.
const LEGACY_DEFAULT_URL: &str =
"https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
/// The URL to fetch rules from, or `None` for the bundled rules. An empty
/// setting and upstream's old default both mean the bundled rules.
pub fn rules_url(configured: Option<String>) -> Option<String> {
configured.filter(|url| !url.trim().is_empty() && url != LEGACY_DEFAULT_URL)
}
/// The bundled rules, uncompressed: the same JSON the rules URL serves.
pub fn bundled_rules() -> Result<Vec<u8>, String> {
let mut json = Vec::new();
mail_auth::flate2::read::GzDecoder::new(BUNDLED_SPAM_RULES)
.read_to_end(&mut json)
.map_err(|err| format!("Failed to decompress the bundled spam rules: {err}"))?;
Ok(json)
}
fn applied_key() -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: b"Sr".to_vec(),
})
}
/// The bundled version last loaded into the registry, if any.
pub async fn applied_version(data: &Store) -> trc::Result<Option<String>> {
data.get_value::<String>(ValueKey::from(applied_key()))
.await
.caused_by(trc::location!())
}
/// Records that the bundled rules of this version have been loaded.
pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(applied_key(), version.as_bytes().to_vec());
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upstream_default_and_empty_mean_bundled() {
assert_eq!(rules_url(None), None);
assert_eq!(rules_url(Some(String::new())), None);
assert_eq!(rules_url(Some(" ".into())), None);
assert_eq!(rules_url(Some(LEGACY_DEFAULT_URL.into())), None);
assert_eq!(
rules_url(Some("file:///srv/rules.json.gz".into())).as_deref(),
Some("file:///srv/rules.json.gz")
);
}
#[test]
fn bundled_rules_parse_and_score_the_ai_tags() {
let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap();
let tags = rules["SpamTag"].as_array().unwrap();
for (tag, score) in [("LLM_UNSOLICITED_HIGH", 3.0), ("LLM_LEGITIMATE_HIGH", -3.0)] {
let found = tags.iter().find(|t| t["tag"] == tag).unwrap();
assert_eq!(found["score"].as_f64(), Some(score), "{tag}");
}
assert!(!rules["SpamRule"].as_array().unwrap().is_empty());
}
}
+55 -12
View File
@@ -98,7 +98,38 @@ impl AcmeRequestBuilder {
reuse_key_pem: Option<String>, reuse_key_pem: Option<String>,
dns_parameters: Option<AcmeDnsParameters>, dns_parameters: Option<AcmeDnsParameters>,
) -> AcmeResult<PemCert> { ) -> AcmeResult<PemCert> {
let mut params = CertificateParams::new(domains.clone()).map_err(|err| { let mut published = BTreeSet::new();
let result = self
.run_order(
server,
&domains,
reuse_key_pem,
dns_parameters.as_ref(),
&mut published,
)
.await;
if let Some(dns_parameters) = &dns_parameters {
for (zone, challenge_name) in published {
let _ = dns_parameters
.updater
.delete_rrset(&zone, &challenge_name, dns_update::DnsRecordType::TXT)
.await;
}
}
result
}
async fn run_order(
&self,
server: &Server,
domains: &[String],
reuse_key_pem: Option<String>,
dns_parameters: Option<&AcmeDnsParameters>,
published: &mut BTreeSet<(String, String)>,
) -> AcmeResult<PemCert> {
let mut params = CertificateParams::new(domains.to_vec()).map_err(|err| {
AcmeError::Crypto(format!("Failed to create certificate params: {}", err)) AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
})?; })?;
params.distinguished_name = DistinguishedName::new(); params.distinguished_name = DistinguishedName::new();
@@ -110,7 +141,7 @@ impl AcmeRequestBuilder {
AcmeError::Crypto(format!("Failed to generate key pair: {}", err)) AcmeError::Crypto(format!("Failed to generate key pair: {}", err))
})?, })?,
}; };
let response = self.new_order(domains.clone()).await?; let response = self.new_order(domains.to_vec()).await?;
let order_url = response.location; let order_url = response.location;
let mut order = response.body; let mut order = response.body;
let mut retry_after = None; let mut retry_after = None;
@@ -119,7 +150,7 @@ impl AcmeRequestBuilder {
Acme(AcmeEvent::OrderStart), Acme(AcmeEvent::OrderStart),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Details = order_url.to_string(), Details = order_url.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
Type = self.challenge.as_str(), Type = self.challenge.as_str(),
); );
@@ -128,19 +159,20 @@ impl AcmeRequestBuilder {
OrderStatus::Pending => { OrderStatus::Pending => {
if matches!(self.challenge, ChallengeType::Dns01) { if matches!(self.challenge, ChallengeType::Dns01) {
for url in &order.authorizations { for url in &order.authorizations {
self.authorize(server, url, dns_parameters.as_ref()).await?; self.authorize(server, url, dns_parameters, Some(published))
.await?;
} }
} else { } else {
let auth_futures = order let auth_futures = order
.authorizations .authorizations
.iter() .iter()
.map(|url| self.authorize(server, url, dns_parameters.as_ref())); .map(|url| self.authorize(server, url, dns_parameters, None));
try_join_all(auth_futures).await?; try_join_all(auth_futures).await?;
} }
trc::event!( trc::event!(
Acme(AcmeEvent::AuthCompleted), Acme(AcmeEvent::AuthCompleted),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
); );
let response = self.order(&order_url).await?; let response = self.order(&order_url).await?;
order = response.body; order = response.body;
@@ -151,7 +183,7 @@ impl AcmeRequestBuilder {
trc::event!( trc::event!(
Acme(AcmeEvent::OrderProcessing), Acme(AcmeEvent::OrderProcessing),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
Total = i, Total = i,
); );
@@ -179,7 +211,7 @@ impl AcmeRequestBuilder {
trc::event!( trc::event!(
Acme(AcmeEvent::OrderReady), Acme(AcmeEvent::OrderReady),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
); );
let csr = params.serialize_request(&key_pair).map_err(|err| { let csr = params.serialize_request(&key_pair).map_err(|err| {
@@ -192,10 +224,10 @@ impl AcmeRequestBuilder {
trc::event!( trc::event!(
Acme(AcmeEvent::OrderValid), Acme(AcmeEvent::OrderValid),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
); );
let certificate = self.select_certificate(&domains, certificate).await?; let certificate = self.select_certificate(domains, certificate).await?;
return Ok(PemCert { return Ok(PemCert {
certificate, certificate,
@@ -213,7 +245,7 @@ impl AcmeRequestBuilder {
Acme(AcmeEvent::OrderInvalid), Acme(AcmeEvent::OrderInvalid),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Details = order_url.to_string(), Details = order_url.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
Reason = reason.clone(), Reason = reason.clone(),
); );
@@ -228,6 +260,7 @@ impl AcmeRequestBuilder {
server: &Server, server: &Server,
url: &String, url: &String,
dns_parameters: Option<&AcmeDnsParameters>, dns_parameters: Option<&AcmeDnsParameters>,
published: Option<&mut BTreeSet<(String, String)>>,
) -> AcmeResult<()> { ) -> AcmeResult<()> {
let response = self let response = self
.auth(url) .auth(url)
@@ -289,7 +322,12 @@ impl AcmeRequestBuilder {
.await?; .await?;
} }
ChallengeType::Dns01 => { ChallengeType::Dns01 => {
let dns_parameters = dns_parameters.unwrap(); let Some(dns_parameters) = dns_parameters else {
return Err(AcmeError::Invalid(
"DNS-01 challenge requested but a DNS provider was not configured"
.to_string(),
));
};
let domain = domain.strip_prefix("*.").unwrap_or(&domain); let domain = domain.strip_prefix("*.").unwrap_or(&domain);
let zone = dns_parameters let zone = dns_parameters
@@ -310,6 +348,11 @@ impl AcmeRequestBuilder {
) )
.await .await
.map_err(AcmeError::Dns)?; .map_err(AcmeError::Dns)?;
if let Some(published) = published {
published.insert((zone.to_string(), challenge_name.clone()));
}
dns_parameters dns_parameters
.updater .updater
.wait_for_txt_propagation(&challenge_name, zone, &proof) .wait_for_txt_propagation(&challenge_name, zone, &proof)
@@ -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,15 @@ 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, LP-14a
let legacy_off = match emailaddress.rsplit_once('@') {
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?,
None => self.legacy_protocols_off_for("").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, LP-14a
let legacy_off = self.legacy_protocols_off_for(domain).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::{
@@ -34,6 +40,8 @@ impl Server {
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();
let domain_name = domain.name.as_str(); let domain_name = domain.name.as_str();
// inbuxa: legacy-protocols LP-7, LP-14a
let legacy_off = self.legacy_protocols_off_for(domain_name).await?;
let domain_name_suffix = format!(".{domain_name}"); let domain_name_suffix = format!(".{domain_name}");
for record_type in record_types { for record_type in record_types {
@@ -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, LP-14a
let pacc = if self.legacy_protocols_off_for(domain_name).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))
}) })
} }
} }
+30
View File
@@ -1150,6 +1150,36 @@ impl DnsUpdater {
Ok(()) Ok(())
} }
pub async fn delete_rrset(
&self,
origin: &str,
name: &str,
record_type: DnsRecordType,
) -> Result<(), String> {
if let Err(err) = self
.updater
.set_rrset(
name,
record_type,
self.ttl.as_secs() as u32,
Vec::new(),
origin,
)
.await
{
trc::event!(
Dns(DnsEvent::RecordDeletionFailed),
Hostname = name.to_string(),
Details = origin.to_string(),
Type = record_type.as_str(),
Reason = err.to_string(),
);
return Err(format!("Failed to delete DNS RRSet: {}", err));
}
Ok(())
}
pub async fn add_to_rrset( pub async fn add_to_rrset(
&self, &self,
origin: &str, origin: &str,
+633
View File
@@ -0,0 +1,633 @@
/*
* 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 -- the server's, or for a tenant's
//! domains, the tenant's (LP-14a).
//!
//! 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, auth::AccessToken, config::server::Listeners, network::TcpAcceptor};
use directory::Credentials;
use inbuxa_features::security::{
legacy_use::{self, LegacyUse},
listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener},
tenant_protocol_policy,
};
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?;
// LP-8. Raised here rather than by the JMAP method, so whatever turns
// the switch is reported. A /set that changed nothing -- the switch
// already where it was asked to be, nothing to close or reopen -- is
// not a change.
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() {
let (moved, direction) = if policy.legacy_protocols.is_disabled() {
(&change.closed, "closed")
} else {
(&change.reopened, "reopened")
};
trc::event!(
Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "server",
Value = if policy.legacy_protocols.is_disabled() {
"disabled"
} else {
"enabled"
},
AccountId = policy.changed_by.clone(),
Details = direction,
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
// Only when a listener could not be put back (LP-5).
Reason = (!change.failed.is_empty()).then(|| listener_names(
change
.failed
.iter()
.map(|(l, why)| format!("{}: {why}", l.id))
)),
);
}
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())
}
}
/// Names for an event field: the listeners a change closed, reopened or
/// failed to reopen (LP-8).
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
trc::Value::Array(names.map(Into::into).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",
}
}
/// The same protocol, as the impact panel's record names it (LP-15).
pub fn as_use(&self) -> LegacyUse {
match self {
LegacyProtocol::Imap => LegacyUse::Imap,
LegacyProtocol::Pop3 => LegacyUse::Pop3,
LegacyProtocol::ManageSieve => LegacyUse::ManageSieve,
LegacyProtocol::Submission => LegacyUse::Submission,
}
}
/// What the mail app is told (LP-12). 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. At server scope "Your
/// organization" reads "This server" (LP-6).
pub fn refusal(&self, scope: RefusalScope) -> &'static str {
match (scope, self) {
(RefusalScope::Server, LegacyProtocol::Imap) => {
"This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
}
(RefusalScope::Server, LegacyProtocol::Pop3) => {
"[AUTH] This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
}
(RefusalScope::Server, LegacyProtocol::ManageSieve) => {
"This server allows only inbuxa webmail and JMAP apps."
}
(RefusalScope::Server, LegacyProtocol::Submission) => {
"535 5.7.0 This server allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
}
(RefusalScope::Tenant(_), LegacyProtocol::Imap) => {
"Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
}
(RefusalScope::Tenant(_), LegacyProtocol::Pop3) => {
"[AUTH] Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
}
(RefusalScope::Tenant(_), LegacyProtocol::ManageSieve) => {
"Your organization allows only inbuxa webmail and JMAP apps."
}
(RefusalScope::Tenant(_), LegacyProtocol::Submission) => {
"535 5.7.0 Your organization 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, the scope and the domain,
/// never the account; the session adds the remote IP.
///
/// Not the tenant's id: `Id` is what IMAP answers a command's tag from,
/// so an error carrying one is sent under the wrong tag and the mail app
/// waits for a reply that never comes. The domain names the tenant.
pub fn refused(&self, scope: RefusalScope, domain: Option<String>) -> trc::Error {
trc::AuthEvent::LegacyProtocolRefused
.into_err()
.details(self.refusal(scope))
.ctx(trc::Key::Source, self.as_str())
.ctx(
trc::Key::Policy,
match scope {
RefusalScope::Server => "server",
RefusalScope::Tenant(_) => "tenant",
},
)
.ctx_opt(trc::Key::Domain, domain)
}
}
/// One account's last sign-in over one legacy protocol, as the impact panel
/// shows it (LP-15).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecentUse {
pub account_id: u32,
pub name: String,
pub protocol: &'static str,
/// Seconds since the epoch.
pub at: u64,
}
/// Whose switch refused a sign-in: the server's (LP-6) or a tenant's (LP-10).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefusalScope {
Server,
Tenant(u32),
}
/// 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), or while the switch of the tenant that owns the named
/// domain is (LP-10). Called before the credentials are checked, so the
/// answer is the same for a right password, a wrong one and an address
/// that doesn't exist (LP-11): a tenant's domain answers for every address
/// on it.
///
/// Read from the store on each sign-in rather than cached, so every node
/// of a cluster answers the same the moment a switch turns.
pub async fn refuse_legacy_sign_in(
&self,
protocol: LegacyProtocol,
credentials: &Credentials,
) -> trc::Result<()> {
let domain = domain_of(credentials);
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
return Err(protocol.refused(RefusalScope::Server, domain));
}
if let Some(name) = &domain
&& let Some(domain) = self.domain(name).await?
&& let Some(tenant_id) = domain.id_tenant
&& self.tenant_legacy_protocols_off(tenant_id).await?
{
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
}
Ok(())
}
/// Once the account is known: refuses it if its tenant has legacy
/// protocols off, and otherwise records the sign-in for the impact panel.
///
/// The refusal is LP-10 again for a bearer token, which needn't name an
/// account and so can't be judged by its domain beforehand; for a
/// password sign-in it has already been decided. The record is LP-15's:
/// one timestamp per account and protocol, at most hourly. A record that
/// can't be written is logged and the sign-in goes ahead -- a panel is
/// not worth locking anyone out over.
pub async fn admit_legacy_session(
&self,
protocol: LegacyProtocol,
access_token: &AccessToken,
) -> trc::Result<()> {
if let Some(tenant_id) = access_token.tenant_id()
&& self.tenant_legacy_protocols_off(tenant_id).await?
{
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
}
if let Err(err) = legacy_use::record(
&self.core.storage.data,
access_token.account_id(),
protocol.as_use(),
store::write::now(),
)
.await
{
trc::error!(err.details("Failed to record a legacy sign-in (LP-15)."));
}
Ok(())
}
/// Who signed in over a legacy protocol in the last 30 days, most recent
/// first, for the impact panel (LP-15): everyone at server scope, or one
/// tenant's accounts. Accounts that no longer exist are left out.
pub async fn recent_legacy_use(&self, tenant_id: Option<u32>) -> trc::Result<Vec<RecentUse>> {
let mut recent = Vec::new();
for entry in legacy_use::recent(&self.core.storage.data, store::write::now()).await? {
let Some(account) = self.try_account(entry.account_id).await? else {
continue;
};
if tenant_id.is_some() && account.id_tenant != tenant_id {
continue;
}
recent.push(RecentUse {
account_id: entry.account_id,
name: account.name.to_string(),
protocol: entry.protocol.as_str(),
at: entry.at,
});
}
recent.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.name.cmp(&b.name)));
Ok(recent)
}
/// Whether legacy protocols are off for this account: the stricter of the
/// server's switch and its tenant's. What the JMAP session tells the
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can
/// say why a mail app won't connect (LP-19).
pub async fn legacy_protocols_off_for_account(
&self,
access_token: &AccessToken,
) -> trc::Result<bool> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
return Ok(true);
}
match access_token.tenant_id() {
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
None => Ok(false),
}
}
/// Whether a tenant has turned legacy protocols off for itself (LP-10).
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> {
Ok(
tenant_protocol_policy::get(&self.core.storage.data, tenant_id)
.await?
.legacy_protocols
.is_disabled(),
)
}
}
/// 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 legacy services are off for this domain, for the answers that
/// must stop offering them: off for the whole server (LP-7), or for the
/// tenant the domain belongs to (LP-14a). Read per answer, as sign-in
/// reads it. A name that is no domain here answers for the server alone.
pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result<bool> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
return Ok(true);
}
match self.domain(domain_name).await? {
Some(domain) => match domain.id_tenant {
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
None => Ok(false),
},
None => Ok(false),
}
}
}
#[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).
let server = RefusalScope::Server;
assert_eq!(
LegacyProtocol::Imap.refusal(server),
"This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
);
assert!(
LegacyProtocol::Pop3
.refusal(server)
.starts_with("[AUTH] This server allows")
);
assert_eq!(
LegacyProtocol::ManageSieve.refusal(server),
"This server allows only inbuxa webmail and JMAP apps."
);
assert_eq!(
LegacyProtocol::Submission.refusal(server),
"535 5.7.0 This server allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
);
}
#[test]
fn a_tenant_refusal_speaks_for_the_organization() {
// LP-12, exactly as the spec writes them.
let tenant = RefusalScope::Tenant(7);
assert_eq!(
LegacyProtocol::Imap.refusal(tenant),
"Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
);
assert_eq!(
LegacyProtocol::Pop3.refusal(tenant),
"[AUTH] Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
);
assert_eq!(
LegacyProtocol::ManageSieve.refusal(tenant),
"Your organization allows only inbuxa webmail and JMAP apps."
);
assert_eq!(
LegacyProtocol::Submission.refusal(tenant),
"535 5.7.0 Your organization allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
);
let err = LegacyProtocol::Imap.refused(tenant, Some("example.org".into()));
assert_eq!(err.value_as_str(trc::Key::Policy), Some("tenant"));
// IMAP answers the command's tag from Id; the refusal must leave it be.
assert!(err.value(trc::Key::Id).is_none());
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
}
#[test]
fn a_refusal_is_not_a_failed_sign_in() {
let err = LegacyProtocol::Imap
.refused(RefusalScope::Server, domain_of(&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);
}
}
+49 -2
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 super::{ use super::{
@@ -26,6 +28,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 +328,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 +346,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 +385,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 {
+4
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 self::limiter::{ConcurrencyLimiter, InFlight}; use self::limiter::{ConcurrencyLimiter, InFlight};
@@ -33,8 +35,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;
+56 -1
View File
@@ -23,6 +23,7 @@ use crate::{
manager::SPAM_CLASSIFIER_KEY, manager::SPAM_CLASSIFIER_KEY,
network::RcptResolution, network::RcptResolution,
}; };
use ahash::AHashSet;
use directory::Recipient; use directory::Recipient;
use mail_auth::IpLookupStrategy; use mail_auth::IpLookupStrategy;
use registry::schema::enums::ExpressionVariable; use registry::schema::enums::ExpressionVariable;
@@ -37,6 +38,7 @@ use store::{
write::{AlignedBytes, Archive, QueueClass, ValueClass}, write::{AlignedBytes, Archive, QueueClass, ValueClass},
}; };
use trc::{AddContext, SpamEvent}; use trc::{AddContext, SpamEvent};
use utils::DomainPart;
impl Server { impl Server {
pub async fn rcpt_resolve( pub async fn rcpt_resolve(
@@ -163,7 +165,10 @@ impl Server {
} }
EmailCache::MailingList(id) => { EmailCache::MailingList(id) => {
if let Some(list) = self.try_list(id).await? { if let Some(list) = self.try_list(id).await? {
return Ok(RcptResolution::Expand(list.recipients.clone())); return Ok(RcptResolution::Expand(
self.expand_nested_lists(id, list.recipients.clone())
.await?,
));
} else { } else {
self.inner self.inner
.cache .cache
@@ -195,6 +200,56 @@ impl Server {
} }
} }
async fn expand_nested_lists(
&self,
list_id: u32,
recipients: Arc<[Box<str>]>,
) -> trc::Result<Arc<[Box<str>]>> {
let mut has_nested = false;
for member in recipients.iter() {
if let Some(EmailCache::MailingList(_)) = self.rcpt_id_from_email(member).await? {
has_nested = true;
break;
}
}
if !has_nested {
return Ok(recipients);
}
let mut expanded = Vec::with_capacity(recipients.len());
let mut seen: AHashSet<Box<str>> = AHashSet::with_capacity(recipients.len());
let mut visited = AHashSet::from_iter([list_id]);
let mut pending: Vec<Arc<[Box<str>]>> = Vec::new();
let mut members = recipients;
loop {
for member in members.iter() {
if let Some(EmailCache::MailingList(nested_id)) =
self.rcpt_id_from_email(member).await?
{
if !visited.insert(nested_id) {
continue;
}
if let Some(nested) = self.try_list(nested_id).await? {
pending.push(nested.recipients.clone());
continue;
}
}
if seen.insert(member.to_canonical_address().into()) {
expanded.push(member.clone());
}
}
let Some(next) = pending.pop() else {
break;
};
members = next;
}
Ok(expanded.into())
}
pub async fn get_dkim_signers( pub async fn get_dkim_signers(
&self, &self,
domain: &str, domain: &str,
+10 -8
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 base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
@@ -313,16 +315,16 @@ B4yDfR2rGOd2H6Kv3fQNHPj9Nu5Tks8QYMLzrX8ONCNoFnNUQl9S0r0QS6phVqD0
#[test] #[test]
fn contact_is_normalized_to_a_uri() { fn contact_is_normalized_to_a_uri() {
for (input, expected) in [ for (input, expected) in [
("hello@stalw.art", Some("mailto:hello@stalw.art")), ("hello@example.org", Some("mailto:hello@example.org")),
(" hello@stalw.art ", Some("mailto:hello@stalw.art")), (" hello@example.org ", Some("mailto:hello@example.org")),
("mailto:hello@stalw.art", Some("mailto:hello@stalw.art")), ("mailto:hello@example.org", Some("mailto:hello@example.org")),
("MAILTO:hello@stalw.art", Some("MAILTO:hello@stalw.art")), ("MAILTO:hello@example.org", Some("MAILTO:hello@example.org")),
( (
"https://stalw.art/contact", "https://example.org/contact",
Some("https://stalw.art/contact"), Some("https://example.org/contact"),
), ),
("stalw.art", None), ("example.org", None),
("http://stalw.art", None), ("http://example.org", None),
("tel:+123456789", None), ("tel:+123456789", None),
("", None), ("", None),
] { ] {
+2 -2
View File
@@ -29,11 +29,11 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer
let (_, mut rx) = builder.register(); let (_, mut rx) = builder.register();
tokio::spawn(async move { tokio::spawn(async move {
let resource = Resource::builder() let resource = Resource::builder()
.with_service_name("stalwart") .with_service_name("inbuxa")
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!())) .with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
.build(); .build();
let instrumentation = InstrumentationScope::builder("stalwart") let instrumentation = InstrumentationScope::builder("inbuxa")
.with_version(types::brand_version_full!()) .with_version(types::brand_version_full!())
.build(); .build();
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "coordinator" name = "coordinator"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dav-proto" name = "dav-proto"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dav" name = "dav"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+3 -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 super::ETag; use super::ETag;
@@ -490,7 +492,7 @@ impl LockRequestHandler for Server {
for cond in &if_.list { for cond in &if_.list {
match cond { match cond {
Condition::StateToken { token, .. } => { Condition::StateToken { token, .. } => {
if token.starts_with("urn:stalwart:davsync:") { if token.starts_with("urn:inbuxa:davsync:") {
needs_sync_token = true; needs_sync_token = true;
} else { } else {
needs_lock_token = true; needs_lock_token = true;
+7 -5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* 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::{DavError, DavResourceName}; use crate::{DavError, DavResourceName};
@@ -181,12 +183,12 @@ impl OwnedUri<'_> {
impl Urn { impl Urn {
pub fn try_extract_sync_id(token: &str) -> Option<&str> { pub fn try_extract_sync_id(token: &str) -> Option<&str> {
token token
.strip_prefix("urn:stalwart:davsync:") .strip_prefix("urn:inbuxa:davsync:")
.map(|x| x.split_once(':').map(|(x, _)| x).unwrap_or(x)) .map(|x| x.split_once(':').map(|(x, _)| x).unwrap_or(x))
} }
pub fn parse(input: &str) -> Option<Self> { pub fn parse(input: &str) -> Option<Self> {
let inbox = input.strip_prefix("urn:stalwart:")?; let inbox = input.strip_prefix("urn:inbuxa:")?;
let (kind, id) = inbox.split_once(':')?; let (kind, id) = inbox.split_once(':')?;
match kind { match kind {
"davlock" => u64::from_str_radix(id, 16).ok().map(Urn::Lock), "davlock" => u64::from_str_radix(id, 16).ok().map(Urn::Lock),
@@ -223,12 +225,12 @@ impl Urn {
impl Display for Urn { impl Display for Urn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Urn::Lock(id) => write!(f, "urn:stalwart:davlock:{id:x}",), Urn::Lock(id) => write!(f, "urn:inbuxa:davlock:{id:x}",),
Urn::Sync { id, seq } => { Urn::Sync { id, seq } => {
if *seq == 0 { if *seq == 0 {
write!(f, "urn:stalwart:davsync:{id:x}") write!(f, "urn:inbuxa:davsync:{id:x}")
} else { } else {
write!(f, "urn:stalwart:davsync:{id:x}:{seq:x}") write!(f, "urn:inbuxa:davsync:{id:x}:{seq:x}")
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "directory" name = "directory"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -40,7 +40,7 @@ impl OpenIdDirectory {
pub async fn new(config: OidcConfig) -> Result<Self, OidcError> { pub async fn new(config: OidcConfig) -> Result<Self, OidcError> {
let http = utils::http::http_client_builder(false) let http = utils::http::http_client_builder(false)
.user_agent("INBUXA/1.0") // types::brand!(); this crate does not depend on types .user_agent("inbuxa/1.0") // types::brand!(); this crate does not depend on types
.timeout(Duration::from_secs(30)) .timeout(Duration::from_secs(30))
.build() .build()
.map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?; .map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?;
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "email" name = "email"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+8
View File
@@ -22,6 +22,8 @@ use std::{borrow::Cow, future::Future};
use store::ahash::AHashMap; use store::ahash::AHashMap;
use types::blob_hash::BlobHash; use types::blob_hash::BlobHash;
pub const ORCPT_ADDR_TYPE: &str = "rfc822;";
#[derive(Debug)] #[derive(Debug)]
pub struct IngestMessage { pub struct IngestMessage {
pub sender_address: String, pub sender_address: String,
@@ -40,6 +42,12 @@ pub struct IngestRecipient {
} }
impl IngestRecipient { impl IngestRecipient {
pub fn orcpt_parameter(&self) -> Option<String> {
self.orcpt
.as_deref()
.map(|orcpt| format!("{ORCPT_ADDR_TYPE}{orcpt}"))
}
pub fn is_spam(&self) -> bool { pub fn is_spam(&self) -> bool {
self.spam_percentage self.spam_percentage
.is_some_and(|percentage| percentage >= 50) .is_some_and(|percentage| percentage >= 50)
+2 -1
View File
@@ -126,6 +126,7 @@ impl SieveScriptIngest for Server {
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
// Create Sieve instance // Create Sieve instance
let orcpt = envelope_to.orcpt_parameter();
let mut instance = self.core.sieve.untrusted_runtime.filter_parsed(message); let mut instance = self.core.sieve.untrusted_runtime.filter_parsed(message);
// Set account name and email // Set account name and email
@@ -141,7 +142,7 @@ impl SieveScriptIngest for Server {
// Set envelope // Set envelope
instance.set_envelope(Envelope::From, envelope_from); instance.set_envelope(Envelope::From, envelope_from);
instance.set_envelope(Envelope::To, envelope_to.address.as_str()); instance.set_envelope(Envelope::To, envelope_to.address.as_str());
if let Some(orcpt) = &envelope_to.orcpt { if let Some(orcpt) = &orcpt {
instance.set_envelope(Envelope::Orcpt, orcpt.as_str()); instance.set_envelope(Envelope::Orcpt, orcpt.as_str());
} }
instance.set_spam_status(spam_status(envelope_to.spam_percentage)); instance.set_spam_status(spam_status(envelope_to.spam_percentage));
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "inbuxa-features" name = "inbuxa-features"
description = "INBUXA's rebuilt features: behavior Stalwart ships only in its Enterprise Edition, rebuilt clean-room" description = "inbuxa's rebuilt features: behavior Stalwart ships only in its Enterprise Edition, rebuilt clean-room"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
version = "0.16.22" version = "0.16.22"
edition = "2024" edition = "2024"
+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;
+220
View File
@@ -0,0 +1,220 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! When each account last signed in over each legacy protocol, for the
//! impact panel (legacy-protocols spec, LP-15, "Last use per protocol").
//!
//! One timestamp per account per protocol, and nothing else: no address, no
//! IP, no client. It is written at most once an hour per account and
//! protocol, so a mail app polling every minute costs one read per sign-in
//! and one write an hour. Stored under `P` `u`, the account id and a protocol
//! byte, in the fork's subspace.
use store::{
Deserialize, IterateParams, SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// How long a recorded use stands before the next sign-in rewrites it.
pub const WRITE_EVERY_SECS: u64 = 3600;
/// How far back the impact panel looks (LP-15).
pub const RECENT_SECS: u64 = 30 * 24 * 3600;
/// The protocols the panel names, as they are spelled over JMAP.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LegacyUse {
Imap,
Pop3,
ManageSieve,
Submission,
}
impl LegacyUse {
pub fn as_str(&self) -> &'static str {
match self {
LegacyUse::Imap => "imap",
LegacyUse::Pop3 => "pop3",
LegacyUse::ManageSieve => "manageSieve",
LegacyUse::Submission => "submission",
}
}
fn byte(&self) -> u8 {
match self {
LegacyUse::Imap => b'i',
LegacyUse::Pop3 => b'p',
LegacyUse::ManageSieve => b's',
LegacyUse::Submission => b'm',
}
}
fn from_byte(byte: u8) -> Option<Self> {
match byte {
b'i' => Some(LegacyUse::Imap),
b'p' => Some(LegacyUse::Pop3),
b's' => Some(LegacyUse::ManageSieve),
b'm' => Some(LegacyUse::Submission),
_ => None,
}
}
}
/// One account's last use of one protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Use {
pub account_id: u32,
pub protocol: LegacyUse,
/// Seconds since the epoch.
pub at: u64,
}
fn key(account_id: u32, protocol: Option<LegacyUse>) -> ValueKey<ValueClass> {
let mut key = Vec::with_capacity(7);
key.extend_from_slice(b"Pu");
key.extend_from_slice(&account_id.to_be_bytes());
key.push(protocol.map_or(0, |p| p.byte()));
ValueKey::from(ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
}))
}
/// Reads a stored key back into who and what, if it is one of ours.
fn parse_key(key: &[u8]) -> Option<(u32, LegacyUse)> {
// The iterator may or may not hand back the subspace byte; the tail is
// what identifies an entry: two bytes of prefix, four of account id and
// one of protocol.
let tail = key.get(key.len().checked_sub(7)?..)?;
(tail[..2] == *b"Pu").then_some(())?;
let account_id = u32::from_be_bytes(tail[2..6].try_into().ok()?);
Some((account_id, LegacyUse::from_byte(tail[6])?))
}
struct At(u64);
impl Deserialize for At {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
bytes
.try_into()
.map(|bytes| At(u64::from_be_bytes(bytes)))
.map_err(|_| trc::StoreEvent::DataCorruption.caused_by(trc::location!()))
}
}
/// Whether a use at `at` is recent enough for the panel at `now` (LP-15).
pub fn is_recent(at: u64, now: u64) -> bool {
at >= now.saturating_sub(RECENT_SECS)
}
/// Whether a use at `now` should be written over one stored at `stored`.
fn due(stored: Option<u64>, now: u64) -> bool {
stored.is_none_or(|stored| now.saturating_sub(stored) >= WRITE_EVERY_SECS)
}
/// Records a successful sign-in, unless one was recorded within the hour.
pub async fn record(
data: &Store,
account_id: u32,
protocol: LegacyUse,
now: u64,
) -> trc::Result<()> {
let stored = data
.get_value::<At>(key(account_id, Some(protocol)))
.await
.caused_by(trc::location!())?
.map(|At(at)| at);
if !due(stored, now) {
return Ok(());
}
let mut batch = BatchBuilder::new();
batch.set(
key(account_id, Some(protocol)).class,
now.to_be_bytes().to_vec(),
);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// Every use recent at `now` (LP-15), across all accounts.
pub async fn recent(data: &Store, now: u64) -> trc::Result<Vec<Use>> {
let mut uses = Vec::new();
data.iterate(
IterateParams::new(key(0, None), key(u32::MAX, Some(LegacyUse::Submission))).ascending(),
|key, value| {
if let Some((account_id, protocol)) = parse_key(key)
&& let Ok(At(at)) = At::deserialize(value)
&& is_recent(at, now)
{
uses.push(Use {
account_id,
protocol,
at,
});
}
Ok(true)
},
)
.await
.caused_by(trc::location!())?;
Ok(uses)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn written_at_most_once_an_hour() {
assert!(due(None, 100));
assert!(!due(Some(100), 100 + WRITE_EVERY_SECS - 1));
assert!(due(Some(100), 100 + WRITE_EVERY_SECS));
// A clock that went backwards doesn't write.
assert!(!due(Some(100), 50));
}
#[test]
fn the_panel_looks_back_thirty_days() {
// Acceptance test 11: three days ago is listed, forty days ago isn't.
let now = 1_800_000_000;
let day = 24 * 3600;
assert!(is_recent(now - 3 * day, now));
assert!(is_recent(now - 30 * day, now));
assert!(!is_recent(now - 30 * day - 1, now));
assert!(!is_recent(now - 40 * day, now));
}
#[test]
fn keys_read_back() {
for protocol in [
LegacyUse::Imap,
LegacyUse::Pop3,
LegacyUse::ManageSieve,
LegacyUse::Submission,
] {
let ValueClass::Any(any) = key(42, Some(protocol)).class else {
panic!()
};
assert_eq!(parse_key(&any.key), Some((42, protocol)));
// With the subspace byte in front, too.
let mut with_subspace = vec![SUBSPACE_INBUXA];
with_subspace.extend_from_slice(&any.key);
assert_eq!(parse_key(&with_subspace), Some((42, protocol)));
}
assert_eq!(parse_key(b"Pp"), None);
assert_eq!(parse_key(b"Xx\0\0\0\x2ai"), None);
}
#[test]
fn only_protocols_are_recorded() {
// Nothing but the four legacy protocols has a byte of its own.
assert_eq!(LegacyUse::from_byte(0), None);
assert_eq!(LegacyUse::from_byte(b'x'), None);
}
}
+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"])
));
}
}
+16
View File
@@ -0,0 +1,16 @@
/*
* 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 legacy_use;
pub mod listeners;
pub mod protocol_policy;
pub mod tenant_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");
}
}
@@ -0,0 +1,168 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:TenantProtocolPolicy`, one tenant's legacy mail protocols switch
//! (legacy-protocols spec, LP-9 to LP-14a). Stored as JSON under `P` `t` and
//! the tenant id in the fork's subspace; a tenant with nothing stored has
//! legacy protocols on.
//!
//! A tenant's switch closes no port -- other tenants share them (LP-13). It
//! refuses sign-in on the tenant's domains, and keeps client configuration
//! for them from offering what's refused. That is all it is: one fact per
//! tenant, easy to turn back, touching no listener, role or permission.
use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// One tenant's switch.
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TenantProtocolPolicy {
/// The switch itself.
pub legacy_protocols: LegacyProtocols,
/// When it last changed, in milliseconds since the epoch.
pub changed_at: Option<u64>,
/// The account that last changed it.
pub changed_by: Option<String>,
}
/// Why a tenant's switch can't be set this way, if it can't (LP-9).
///
/// A tenant can always turn legacy protocols off for itself. It can turn
/// them back on only while the server has them on: server off means off for
/// everyone.
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> {
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some(
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \
so they can't be turned back on for one organization.",
)
}
fn key(tenant_id: u32) -> ValueClass {
let mut key = Vec::with_capacity(6);
key.extend_from_slice(b"Pt");
key.extend_from_slice(&tenant_id.to_be_bytes());
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key,
})
}
struct Json(TenantProtocolPolicy);
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 tenant's policy, or the default (on) when it has never been set.
pub async fn get(data: &Store, tenant_id: u32) -> trc::Result<TenantProtocolPolicy> {
Ok(data
.get_value::<Json>(ValueKey::from(key(tenant_id)))
.await
.caused_by(trc::location!())?
.map(|Json(policy)| policy)
.unwrap_or_default())
}
/// Stores the tenant's policy.
pub async fn set(data: &Store, tenant_id: u32, policy: &TenantProtocolPolicy) -> 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(tenant_id), bytes);
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
/// Forgets a tenant's switch, when the tenant is deleted. Otherwise a tenant
/// that came to have the same id would start with the old one's switch.
pub async fn remove(data: &Store, tenant_id: u32) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.clear(key(tenant_id));
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
fn server(legacy_protocols: LegacyProtocols) -> ProtocolPolicy {
ProtocolPolicy {
legacy_protocols,
..Default::default()
}
}
#[test]
fn a_tenant_starts_with_legacy_protocols_on() {
assert!(
!TenantProtocolPolicy::default()
.legacy_protocols
.is_disabled()
);
}
#[test]
fn a_tenant_can_always_turn_them_off() {
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None);
}
}
#[test]
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
// LP-9, acceptance test 9.
assert_eq!(
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled),
None
);
let why =
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
}
#[test]
fn keys_are_per_tenant_and_clear_of_the_server_policy() {
let ValueClass::Any(a) = key(1) else { panic!() };
let ValueClass::Any(b) = key(2) else { panic!() };
assert_ne!(a.key, b.key);
assert_eq!(&a.key[..2], b"Pt");
assert_ne!(a.key, b"Pp".to_vec());
}
#[test]
fn stored_json_reads_back() {
let policy = TenantProtocolPolicy {
legacy_protocols: LegacyProtocols::Disabled,
changed_at: Some(1),
changed_by: Some("b".into()),
};
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
assert_eq!(back, policy);
// Unknown and missing fields read as defaults.
let Json(back) = Json::deserialize(br#"{"futureField":1}"#).unwrap();
assert_eq!(back, TenantProtocolPolicy::default());
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "groupware" name = "groupware"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "http_proto" name = "http_proto"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* 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 common::manager::application::Resource; use common::manager::application::Resource;
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "http" name = "http"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+6 -10
View File
@@ -230,14 +230,7 @@ async fn delivery_diagnose(
// Lookup MX // Lookup MX
let now = Instant::now(); let now = Instant::now();
let mxs = match server let mxs = match server.mx_lookup(domain.as_str()).await {
.core
.smtp
.resolvers
.dns
.mx_lookup(&domain, Some(&server.inner.cache.dns_mx))
.await
{
Ok(mxs) => mxs, Ok(mxs) => mxs,
Err(err) => { Err(err) => {
tx.send(DeliveryStage::MxLookupError { tx.send(DeliveryStage::MxLookupError {
@@ -419,7 +412,7 @@ async fn delivery_diagnose(
}) })
.await?; .await?;
None continue 'outer;
} }
Ok(TlsaResult::Missing) => { Ok(TlsaResult::Missing) => {
tx.send(DeliveryStage::TlsaNotFound { tx.send(DeliveryStage::TlsaNotFound {
@@ -440,14 +433,17 @@ async fn delivery_diagnose(
reason: "No TLSA records found for MX".to_string(), reason: "No TLSA records found for MX".to_string(),
}) })
.await?; .await?;
None
} else { } else {
tx.send(DeliveryStage::TlsaLookupError { tx.send(DeliveryStage::TlsaLookupError {
elapsed: now.elapsed_ms(), elapsed: now.elapsed_ms(),
reason: err.to_string(), reason: err.to_string(),
}) })
.await?; .await?;
continue 'outer;
} }
None
} }
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "imap_proto" name = "imap_proto"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "imap" name = "imap"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+16 -1
View File
@@ -2,12 +2,14 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* 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
@@ -92,6 +100,13 @@ impl<T: SessionStream> Session<T> {
}) })
.and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?; .and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?;
// inbuxa: legacy-protocols LP-10 for a bearer token that named no
// account, and LP-15: the sign-in is recorded for the impact panel
self.server
.admit_legacy_session(LegacyProtocol::Imap, &access_token)
.await
.map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?;
// Enforce concurrency limits // Enforce concurrency limits
let in_flight = match access_token.is_imap_request_allowed() { let in_flight = match access_token.is_imap_request_allowed() {
LimiterResult::Allowed(in_flight) => Some(in_flight), LimiterResult::Allowed(in_flight) => Some(in_flight),
+32
View File
@@ -390,6 +390,16 @@ impl<T: SessionStream> SessionData<T> {
.await .await
.imap_ctx(&arguments.tag, trc::location!())?; .imap_ctx(&arguments.tag, trc::location!())?;
let mut dest_cache = None; let mut dest_cache = None;
let train_spam = if dest_mailbox_id == JUNK_ID {
Some(true)
} else if src_mailbox.id.mailbox_id == JUNK_ID && dest_mailbox_id != TRASH_ID {
Some(false)
} else {
None
};
let mut train_batch = BatchBuilder::new();
let mut did_train = false;
train_batch.with_account_id(src_account_id);
for (id, imap_id) in ids { for (id, imap_id) in ids {
match self match self
.server .server
@@ -515,11 +525,33 @@ impl<T: SessionStream> SessionData<T> {
} }
}; };
if let Some(is_spam) = train_spam {
self.server
.add_account_spam_sample(
&mut train_batch,
src_account_id,
id,
is_spam,
self.session_id,
)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
train_batch.commit_point();
did_train = true;
}
if is_move { if is_move {
destroy_ids.insert(id); destroy_ids.insert(id);
} }
} }
if did_train {
self.server
.commit_batch(train_batch)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
}
// Untag or delete emails // Untag or delete emails
if !destroy_ids.is_empty() { if !destroy_ids.is_empty() {
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jmap_proto" name = "jmap_proto"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -0,0 +1,201 @@
/*
* 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,
/// Server-set: who signed in over a legacy protocol in the last 30
/// days, and when, for the impact panel (LP-15).
RecentLegacyUse,
}
#[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::RecentLegacyUse => "recentLegacyUse",
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"recentLegacyUse" => ProtocolPolicyProperty::RecentLegacyUse,
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::RecentLegacyUse
| 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
}
}
@@ -0,0 +1,186 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:TenantProtocolPolicy/get` and `/set` under `urn:inbuxa:jmap`: one
//! tenant's legacy mail protocols switch (legacy-protocols spec, LP-9 to
//! LP-14). One per tenant; its id is the tenant's id.
//!
//! `tenantId`, `changedAt` and `changedBy` are the server's to say. 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 TenantProtocolPolicy;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TenantProtocolPolicyProperty {
Id,
/// Server-set: the tenant this is the switch of.
TenantId,
/// The switch: `enabled` or `disabled`.
LegacyProtocols,
ChangedAt,
ChangedBy,
/// Server-set: who signed in over a legacy protocol in the last 30
/// days, and when, for the impact panel (LP-15).
RecentLegacyUse,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TenantProtocolPolicyValue {
Id(Id),
}
impl Property for TenantProtocolPolicyProperty {
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
TenantProtocolPolicyProperty::parse(value)
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
TenantProtocolPolicyProperty::Id => "id",
TenantProtocolPolicyProperty::TenantId => "tenantId",
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
}
.into()
}
}
impl TenantProtocolPolicyProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => TenantProtocolPolicyProperty::Id,
b"tenantId" => TenantProtocolPolicyProperty::TenantId,
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
)
}
}
impl TenantProtocolPolicyProperty {
/// 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,
TenantProtocolPolicyProperty::TenantId
| TenantProtocolPolicyProperty::ChangedAt
| TenantProtocolPolicyProperty::ChangedBy
| TenantProtocolPolicyProperty::RecentLegacyUse
)
}
}
impl FromStr for TenantProtocolPolicyProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
TenantProtocolPolicyProperty::parse(s).ok_or(())
}
}
impl Element for TenantProtocolPolicyValue {
type Property = TenantProtocolPolicyProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(TenantProtocolPolicyProperty::Id) => {
Id::from_str(value).ok().map(TenantProtocolPolicyValue::Id)
}
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
TenantProtocolPolicyValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for TenantProtocolPolicy {
type Property = TenantProtocolPolicyProperty;
type Element = TenantProtocolPolicyValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = TenantProtocolPolicyProperty::Id;
}
impl From<Id> for TenantProtocolPolicyValue {
fn from(id: Id) -> Self {
TenantProtocolPolicyValue::Id(id)
}
}
impl JmapObjectId for TenantProtocolPolicyValue {
fn as_id(&self) -> Option<Id> {
match self {
TenantProtocolPolicyValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
TenantProtocolPolicyValue::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 = TenantProtocolPolicyValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for TenantProtocolPolicyProperty {
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
}
}
+2
View File
@@ -22,6 +22,8 @@ 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_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
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;
+6
View File
@@ -61,6 +61,12 @@ 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::TenantProtocolPolicy(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,10 @@ 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::TenantProtocolPolicy(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 +93,12 @@ 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::TenantProtocolPolicy(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)?
} }
+8 -3
View File
@@ -91,7 +91,7 @@ pub enum Capability {
FileNode = 1 << 15, FileNode = 1 << 15,
#[serde(rename(serialize = "urn:ietf:params:jmap:mail:share"))] #[serde(rename(serialize = "urn:ietf:params:jmap:mail:share"))]
MailShare = 1 << 16, MailShare = 1 << 16,
#[serde(rename(serialize = "urn:stalwart:jmap"))] #[serde(rename(serialize = "urn:inbuxa:jmap:registry"))]
Stalwart = 1 << 17, Stalwart = 1 << 17,
#[serde(rename(serialize = "urn:ietf:params:jmap:webpush-vapid"))] #[serde(rename(serialize = "urn:ietf:params:jmap:webpush-vapid"))]
WebPushVapid = 1 << 18, WebPushVapid = 1 << 18,
@@ -142,6 +142,11 @@ pub struct InbuxaAccountCapabilities {
/// The logo that applies to the principal (MT-22): a URL or a data URL. /// The logo that applies to the principal (MT-22): a URL or a data URL.
#[serde(rename(serialize = "logo"))] #[serde(rename(serialize = "logo"))]
pub logo: Option<String>, pub logo: Option<String>,
/// Whether legacy mail protocols are `enabled` or `disabled` for the
/// principal: the stricter of the server's switch and its tenant's
/// (legacy-protocols spec, Interfaces; LP-19).
#[serde(rename(serialize = "legacyProtocols"))]
pub legacy_protocols: &'static str,
} }
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]
@@ -348,7 +353,7 @@ impl Capability {
Capability::PrincipalsAvailability => "urn:ietf:params:jmap:principals:availability", Capability::PrincipalsAvailability => "urn:ietf:params:jmap:principals:availability",
Capability::FileNode => "urn:ietf:params:jmap:filenode", Capability::FileNode => "urn:ietf:params:jmap:filenode",
Capability::MailShare => "urn:ietf:params:jmap:mail:share", Capability::MailShare => "urn:ietf:params:jmap:mail:share",
Capability::Stalwart => "urn:stalwart:jmap", Capability::Stalwart => "urn:inbuxa:jmap:registry",
Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid", Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid",
Capability::EmailPush => "urn:ietf:params:jmap:emailpush", Capability::EmailPush => "urn:ietf:params:jmap:emailpush",
Capability::Inbuxa => "urn:inbuxa:jmap", Capability::Inbuxa => "urn:inbuxa:jmap",
@@ -496,7 +501,7 @@ impl Capability {
"urn:ietf:params:jmap:contacts:parse" => Capability::ContactsParse, "urn:ietf:params:jmap:contacts:parse" => Capability::ContactsParse,
"urn:ietf:params:jmap:calendars:parse" => Capability::CalendarsParse, "urn:ietf:params:jmap:calendars:parse" => Capability::CalendarsParse,
"urn:ietf:params:jmap:mail:share" => Capability::MailShare, "urn:ietf:params:jmap:mail:share" => Capability::MailShare,
"urn:stalwart:jmap" => Capability::Stalwart, "urn:inbuxa:jmap:registry" => Capability::Stalwart,
"urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid, "urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid,
"urn:ietf:params:jmap:emailpush" => Capability::EmailPush, "urn:ietf:params:jmap:emailpush" => Capability::EmailPush,
"urn:inbuxa:jmap" => Capability::Inbuxa, "urn:inbuxa:jmap" => Capability::Inbuxa,
+18
View File
@@ -49,6 +49,8 @@ pub enum MethodObject {
DeletedAccount, DeletedAccount,
// inbuxa: AI call limits // inbuxa: AI call limits
AiLimits, AiLimits,
ProtocolPolicy,
TenantProtocolPolicy,
} }
impl MethodObject { impl MethodObject {
@@ -75,6 +77,8 @@ 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,
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
} }
} }
} }
@@ -252,6 +256,14 @@ 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",
(MethodFunction::Get, MethodObject::TenantProtocolPolicy) => {
"inbuxa:TenantProtocolPolicy/get"
}
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
"inbuxa:TenantProtocolPolicy/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 +389,10 @@ 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),
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, 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 +446,8 @@ 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::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
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());
+8
View File
@@ -116,6 +116,10 @@ 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>>),
TenantProtocolPolicy(
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
),
} }
#[derive(Debug)] #[derive(Debug)]
@@ -139,6 +143,10 @@ 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>>),
TenantProtocolPolicy(
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
),
} }
#[derive(Debug)] #[derive(Debug)]
+32
View File
@@ -169,6 +169,22 @@ 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::TenantProtocolPolicy) => match seq.next_element() {
Ok(Some(value)) => {
RequestMethod::Get(GetRequestMethod::TenantProtocolPolicy(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 +350,22 @@ 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::TenantProtocolPolicy) => match seq.next_element() {
Ok(Some(value)) => {
RequestMethod::Set(SetRequestMethod::TenantProtocolPolicy(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),
+44
View File
@@ -103,6 +103,10 @@ 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>),
TenantProtocolPolicy(
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
),
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@@ -127,6 +131,10 @@ 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>>),
TenantProtocolPolicy(
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
),
} }
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
@@ -287,6 +295,42 @@ 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_tenant_protocol_policy::TenantProtocolPolicy>>
for ResponseMethod<'x>
{
fn from(
value: GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
) -> Self {
ResponseMethod::Get(GetResponseMethod::TenantProtocolPolicy(value))
}
}
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
for ResponseMethod<'x>
{
fn from(
value: SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
) -> Self {
ResponseMethod::Set(SetResponseMethod::TenantProtocolPolicy(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))
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jmap" name = "jmap"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+26 -1
View File
@@ -77,6 +77,13 @@ 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,
// inbuxa: legacy protocols off, per tenant. It governs
// sign-in on the tenant's domains, so it takes the domain's
// permissions, which a tenant administrator already holds.
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
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 +180,22 @@ 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,
),
// inbuxa: legacy protocols off, per tenant, with the domain's
SetRequestMethod::TenantProtocolPolicy(s) => validate_set(
s,
self,
Permission::SysDomainUpdate,
Permission::SysDomainUpdate,
Permission::SysDomainUpdate,
),
SetRequestMethod::VacationResponse(s) => validate_set( SetRequestMethod::VacationResponse(s) => validate_set(
s, s,
self, self,
@@ -282,7 +305,9 @@ impl JmapAuthorization for AccessToken {
| MethodObject::SieveScript | MethodObject::SieveScript
| MethodObject::MaskedEmail | MethodObject::MaskedEmail
| MethodObject::DeletedAccount | MethodObject::DeletedAccount
| MethodObject::AiLimits => Permission::JmapEmailChanges, | MethodObject::AiLimits
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy => 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(),
}, },
+6 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* 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::blob::UploadResponse; use crate::blob::UploadResponse;
@@ -186,7 +188,10 @@ impl ToRequestError for trc::Error {
trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => { trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => {
RequestError::forbidden() RequestError::forbidden()
} }
trc::SecurityEvent::IpBlockExpired | trc::SecurityEvent::IpAllowExpired => { // inbuxa: legacy-protocols LP-8 is an event, never an error
trc::SecurityEvent::IpBlockExpired
| trc::SecurityEvent::IpAllowExpired
| trc::SecurityEvent::LegacyProtocolsChanged => {
RequestError::internal_server_error() RequestError::internal_server_error()
} }
}, },
+34
View File
@@ -221,6 +221,12 @@ 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::TenantProtocolPolicy(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 +382,20 @@ 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()
}
// inbuxa: inbuxa:TenantProtocolPolicy/get (legacy protocols off, per tenant)
GetRequestMethod::TenantProtocolPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::tenant_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 +637,20 @@ 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()
}
// inbuxa: inbuxa:TenantProtocolPolicy/set (legacy protocols off, per tenant)
SetRequestMethod::TenantProtocolPolicy(mut req) => {
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
crate::inbuxa::tenant_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)?;
+10 -1
View File
@@ -66,9 +66,18 @@ impl SessionHandler for Server {
Capability::Inbuxa, Capability::Inbuxa,
Capabilities::Empty(EmptyCapabilities::default()), Capabilities::Empty(EmptyCapabilities::default()),
); );
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? {
"disabled"
} else {
"enabled"
};
account.account_capabilities.append( account.account_capabilities.append(
Capability::Inbuxa, Capability::Inbuxa,
Capabilities::Inbuxa(InbuxaAccountCapabilities { logo }), Capabilities::Inbuxa(InbuxaAccountCapabilities {
logo,
legacy_protocols,
}),
); );
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks // inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
if access_token.has_permission(Permission::SysMaskedEmailGet) { if access_token.has_permission(Permission::SysMaskedEmailGet) {
+2
View File
@@ -418,6 +418,8 @@ impl IntermediateChangesResponse {
| MethodObject::MaskedEmail | MethodObject::MaskedEmail
| MethodObject::DeletedAccount | MethodObject::DeletedAccount
| MethodObject::AiLimits | MethodObject::AiLimits
| MethodObject::ProtocolPolicy
| MethodObject::TenantProtocolPolicy
| MethodObject::Registry(_) => unreachable!(), | MethodObject::Registry(_) => unreachable!(),
}) })
} }
+30 -1
View File
@@ -11,7 +11,11 @@ use crate::{
use common::{Server, auth::AccessToken}; use common::{Server, auth::AccessToken};
use email::{ use email::{
cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess}, cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess},
message::copy::{CopyMessageError, EmailCopy}, mailbox::JUNK_ID,
message::{
copy::{CopyMessageError, EmailCopy},
ingest::EmailIngest,
},
}; };
use http_proto::HttpSessionData; use http_proto::HttpSessionData;
use jmap_proto::{ use jmap_proto::{
@@ -29,6 +33,7 @@ use jmap_proto::{
}; };
use jmap_tools::{Key, Value}; use jmap_tools::{Key, Value};
use std::future::Future; use std::future::Future;
use store::write::BatchBuilder;
use trc::AddContext; use trc::AddContext;
use types::acl::Acl; use types::acl::Acl;
use utils::map::vec_map::VecMap; use utils::map::vec_map::VecMap;
@@ -87,6 +92,9 @@ impl JmapEmailCopy for Server {
}; };
let on_success_delete = request.on_success_destroy_original.unwrap_or(false); let on_success_delete = request.on_success_destroy_original.unwrap_or(false);
let mut destroy_ids = Vec::new(); let mut destroy_ids = Vec::new();
let mut train_batch = BatchBuilder::new();
let mut did_train = false;
train_batch.with_account_id(from_account_id);
'create: for (id, create) in request.create.into_valid() { 'create: for (id, create) in request.create.into_valid() {
let mut from_message_id = None; let mut from_message_id = None;
@@ -208,6 +216,7 @@ impl JmapEmailCopy for Server {
} }
// Add response // Add response
let train_spam = mailboxes.contains(&JUNK_ID);
match self match self
.copy_message( .copy_message(
from_account_id, from_account_id,
@@ -221,6 +230,20 @@ impl JmapEmailCopy for Server {
.await? .await?
{ {
Ok(email) => { Ok(email) => {
if train_spam {
self.add_account_spam_sample(
&mut train_batch,
from_account_id,
from_message_id.document_id(),
true,
session.session_id,
)
.await
.caused_by(trc::location!())?;
train_batch.commit_point();
did_train = true;
}
response response
.created .created
.append(id, ingested_into_object(email).into()); .append(id, ingested_into_object(email).into());
@@ -245,6 +268,12 @@ impl JmapEmailCopy for Server {
} }
} }
if did_train {
self.commit_batch(train_batch)
.await
.caused_by(trc::location!())?;
}
// Update state // Update state
if !response.created.is_empty() { if !response.created.is_empty() {
response.new_state = self.get_cached_messages(account_id).await?.get_state(false); response.new_state = self.get_cached_messages(account_id).await?.get_state(false);
+2
View File
@@ -9,6 +9,8 @@
pub mod access; pub mod access;
pub mod ai_limits; pub mod ai_limits;
pub mod protocol_policy;
pub mod tenant_protocol_policy;
pub mod deleted_account; pub mod deleted_account;
pub mod fastmail; pub mod fastmail;
pub mod masked_email; pub mod masked_email;
+480
View File
@@ -0,0 +1,480 @@
/*
* 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, RecentUse},
};
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,
P::RecentLegacyUse,
];
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],
recent: &[RecentUse],
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()),
// Who would notice, before anything changes (LP-15).
P::RecentLegacyUse => recent_value(recent, |id| ProtocolPolicyValue::Id(Id::from(id))),
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// The impact panel's list (LP-15): who, over what, and when, in
/// milliseconds as `changedAt` is. Shared with the tenant's switch.
pub(crate) fn recent_value<Pr, V>(
recent: &[RecentUse],
id: impl Fn(u32) -> V,
) -> Value<'static, Pr, V>
where
Pr: jmap_tools::Property,
V: jmap_tools::Element<Property = Pr>,
{
Value::Array(
recent
.iter()
.map(|entry| {
let mut out = Map::with_capacity(4);
out.insert_unchecked(
Key::Borrowed("accountId"),
Value::Element(id(entry.account_id)),
);
out.insert_unchecked(Key::Borrowed("name"), Value::Str(entry.name.clone().into()));
out.insert_unchecked(Key::Borrowed("protocol"), Value::Str(entry.protocol.into()));
out.insert_unchecked(
Key::Borrowed("lastUsedAt"),
Value::Number((entry.at * 1000).into()),
);
Value::Object(out)
})
.collect(),
)
}
/// 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()
};
let recent = if properties.contains(&P::RecentLegacyUse) {
server.recent_legacy_use(None).await?
} else {
Vec::new()
};
match ids {
None => response
.list
.push(to_value(&policy, &would_close, &recent, &properties)),
Some(ids) => {
for id in ids {
if id.is_singleton() {
response
.list
.push(to_value(&policy, &would_close, &recent, &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}"
);
}
}
}
@@ -0,0 +1,235 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:TenantProtocolPolicy/get` and `/set`: one tenant's legacy mail
//! protocols switch (legacy-protocols spec, LP-9 to LP-14). There is one per
//! tenant, and its id is the tenant's.
//!
//! Inside a tenant, a principal reaches only its own tenant's (MT-1): `/get`
//! with no ids answers with it, and any other id is `notFound`. At server
//! level, `/get` with no ids answers with every tenant's.
//!
//! Turning it off never needs the server's leave; turning it back on is
//! refused with `forbidden` while the server has legacy protocols off (LP-9).
//! A tenant's switch closes no port (LP-13) -- sign-in and client
//! configuration read it (LP-10, LP-14a).
use crate::inbuxa::protocol_policy::recent_value;
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
use inbuxa_features::{
security::{
protocol_policy::LegacyProtocols,
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
},
tenancy::quota::all_tenants,
};
use jmap_proto::{
error::set::SetError,
method::{
get::{GetRequest, GetResponse},
set::{SetRequest, SetResponse},
},
object::inbuxa_tenant_protocol_policy::{
TenantProtocolPolicy, TenantProtocolPolicyProperty as P, TenantProtocolPolicyValue,
},
request::IntoValid,
};
use jmap_tools::{Key, Map, Value};
use types::id::Id;
type PValue = Value<'static, P, TenantProtocolPolicyValue>;
const ALL: &[P] = &[
P::Id,
P::TenantId,
P::LegacyProtocols,
P::ChangedAt,
P::ChangedBy,
P::RecentLegacyUse,
];
/// The tenants this principal may reach: its own inside a tenant (MT-1),
/// every tenant at server level.
async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<Vec<u32>> {
match access_token.tenant_id() {
Some(tenant_id) => Ok(vec![tenant_id]),
None => all_tenants(server.registry()).await,
}
}
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
let mut out = Map::with_capacity(properties.len());
for property in properties {
let value = match property {
P::Id | P::TenantId => {
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
}
P::LegacyProtocols => Value::Str(
match policy.legacy_protocols {
LegacyProtocols::Enabled => "enabled",
LegacyProtocols::Disabled => "disabled",
}
.into(),
),
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),
P::RecentLegacyUse => {
recent_value(recent, |id| TenantProtocolPolicyValue::Id(Id::from(id)))
}
};
out.insert_unchecked(Key::Property(property.clone()), value);
}
Value::Object(out)
}
/// `inbuxa:TenantProtocolPolicy/get`.
pub async fn get(
server: &Server,
access_token: &AccessToken,
mut request: GetRequest<TenantProtocolPolicy>,
) -> trc::Result<GetResponse<TenantProtocolPolicy>> {
let properties = request.unwrap_properties(ALL);
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
let mut response = GetResponse {
account_id: request.account_id.into(),
state: None,
list: Vec::new(),
not_found,
};
let reachable = reachable(server, access_token).await?;
let wanted = match ids {
None => reachable.iter().map(|id| Id::from(*id)).collect(),
Some(ids) => ids,
};
for id in wanted {
let tenant_id = id.document_id();
if reachable.contains(&tenant_id) {
let policy = tenant_protocol_policy::get(&server.core.storage.data, tenant_id).await?;
// The tenant's own people only (LP-15, MT-1).
let recent = if properties.contains(&P::RecentLegacyUse) {
server.recent_legacy_use(Some(tenant_id)).await?
} else {
Vec::new()
};
response
.list
.push(to_value(tenant_id, &policy, &recent, &properties));
} else {
response.push_not_found(id);
}
}
Ok(response)
}
/// `inbuxa:TenantProtocolPolicy/set`: turns one tenant's switch. Unset
/// (`null`) puts legacy protocols back on, which LP-9 may refuse.
pub async fn set(
server: &Server,
access_token: &AccessToken,
mut request: SetRequest<'_, TenantProtocolPolicy>,
) -> trc::Result<SetResponse<TenantProtocolPolicy>> {
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
// A tenant's switch comes and goes with the tenant; it is only turned.
for (client_id, _) in request.unwrap_create() {
response.not_created.append(
client_id,
SetError::forbidden().with_description("A tenant's switch exists with the tenant."),
);
}
for id in request.unwrap_destroy().into_valid() {
response.not_destroyed.append(
id,
SetError::forbidden().with_description("A tenant's switch exists with the tenant."),
);
}
let reachable = reachable(server, access_token).await?;
for (id, value) in request.unwrap_update().into_valid() {
let tenant_id = id.document_id();
if !reachable.contains(&tenant_id) {
response.not_updated.append(id, SetError::not_found());
continue;
}
let data = &server.core.storage.data;
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
let mut policy = previous.clone();
let mut error = None;
for (key, value) in value.into_expanded_object() {
let result = match &key {
Key::Property(P::LegacyProtocols) => match value {
Value::Null => {
policy.legacy_protocols = LegacyProtocols::Enabled;
Ok(())
}
value => match value.as_str().as_deref() {
Some("enabled") => {
policy.legacy_protocols = LegacyProtocols::Enabled;
Ok(())
}
Some("disabled") => {
policy.legacy_protocols = LegacyProtocols::Disabled;
Ok(())
}
_ => Err(r#"must be "enabled" or "disabled""#),
},
},
Key::Property(P::Id) => Err("is immutable"),
Key::Property(_) => Err("is set by the server"),
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
};
if let Err(why) = result {
error = Some(
SetError::invalid_properties()
.with_property(key.into_owned())
.with_description(why),
);
break;
}
}
if let Some(error) = error {
response.not_updated.append(id, error);
continue;
}
// LP-9: server off means off for everyone.
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) {
response
.not_updated
.append(id, SetError::forbidden().with_description(why));
continue;
}
if policy.legacy_protocols != previous.legacy_protocols {
policy.changed_at = Some(store::write::now() * 1000);
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
// LP-14. A tenant's switch closes and reopens nothing (LP-13).
trc::event!(
Security(trc::SecurityEvent::LegacyProtocolsChanged),
Policy = "tenant",
Id = tenant_id,
Value = if policy.legacy_protocols.is_disabled() {
"disabled"
} else {
"enabled"
},
AccountId = policy.changed_by.clone(),
);
}
response.updated.append(id, None);
}
Ok(response)
}
@@ -208,7 +208,7 @@ pub(crate) async fn bootstrap_set(
.with_description(concat!( .with_description(concat!(
"The selected data store contains information from an older version. ", "The selected data store contains information from an older version. ",
"Please follow the upgrade instructions at ", "Please follow the upgrade instructions at ",
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md" "https://docs.inbuxa.org/install/migrating/"
)), )),
); );
break; break;
@@ -657,7 +657,7 @@ fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option<registry::schema::s
// FreeBSD keeps variable application data under /var/db (hier(7)) // FreeBSD keeps variable application data under /var/db (hier(7))
// rather than FHS /var/lib. // rather than FHS /var/lib.
const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") { const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") {
"/var/db/stalwart/" "/var/db/inbuxa/"
} else { } else {
"/var/lib/inbuxa/" "/var/lib/inbuxa/"
}; };
@@ -679,7 +679,7 @@ fn build_default_bootstrap(server: &Server) -> Bootstrap {
directory: DirectoryBootstrap::Internal, directory: DirectoryBootstrap::Internal,
tracer: Tracer::Log(TracerLog { tracer: Tracer::Log(TracerLog {
path: "/var/log/inbuxa/".to_string(), path: "/var/log/inbuxa/".to_string(),
prefix: "stalwart".to_string(), prefix: "inbuxa".to_string(),
ansi: true, ansi: true,
enable: true, enable: true,
..Default::default() ..Default::default()
+13
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 {
@@ -846,6 +851,14 @@ impl RegistrySet for Server {
if let ObjectInner::MaskedEmail(mask) = &object.inner { if let ObjectInner::MaskedEmail(mask) = &object.inner {
crate::inbuxa::masked_email::destroyed(self, id, mask).await?; crate::inbuxa::masked_email::destroyed(self, id, mask).await?;
} }
// inbuxa: legacy-protocols, a tenant's switch goes with it
if matches!(object.inner, ObjectInner::Tenant(_)) {
inbuxa_features::security::tenant_protocol_policy::remove(
&self.core.storage.data,
id.document_id(),
)
.await?;
}
cache_invalidator.process_delete(id, &object); cache_invalidator.process_delete(id, &object);
set.response.destroyed.push(id); set.response.destroyed.push(id);
} }
+3 -3
View File
@@ -1,13 +1,13 @@
[package] [package]
name = "inbuxa" name = "inbuxa"
description = "INBUXA Mail and Collaboration Server, a fork of Stalwart" description = "inbuxa mail and collaboration server, a fork of Stalwart"
authors = [ "Stalwart Labs LLC <[email protected]>"] authors = [ "Stalwart Labs LLC <[email protected]>"]
homepage = "https://inbuxa.org" homepage = "https://inbuxa.org"
keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"] keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
categories = ["email"] categories = ["email"]
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; INBUXA takes the AGPL only. # Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only.
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
+80 -36
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* 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.
*/ */
#![warn(clippy::large_futures)] #![warn(clippy::large_futures)]
@@ -9,14 +11,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 +79,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 +112,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,
),
}
}
+2 -2
View File
@@ -57,7 +57,7 @@ pub async fn insert_test_data(server: &Server) {
server.inner.data.queue_id_gen.generate(), server.inner.data.queue_id_gen.generate(),
QueueName::default(), QueueName::default(),
); );
assert!(qm.save_changes(server, None).await); assert!(qm.save_changes(server, None, None).await);
} }
for report in sample_tls_internal_reports() { for report in sample_tls_internal_reports() {
@@ -163,7 +163,7 @@ fn sample_queued_messages(blob_hashes: Vec<BlobHash>) -> Vec<Message> {
}, },
}), }),
flags: RCPT_DSN_SENT, flags: RCPT_DSN_SENT,
orcpt: Some("rfc822;[email protected]".into()), orcpt: Some("[email protected]".into()),
}, },
], ],
received_from_ip: std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), received_from_ip: std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),

Some files were not shown because too many files have changed in this diff Show More