262 Commits
Author SHA1 Message Date
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 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
jcoffey-dev 604d889220 Version 2026.9.20, and an install.sh that tells the truth
brand_version! goes to 2026.9.20 (SPEC.md 2.6: YYYY.M.D), the version this
release is tagged at. Verified from the built binary rather than the
source: --version prints "2026.9.20 (Stalwart 0.16.22)" and --help leads
with "INBUXA Server 2026.9.20 (Stalwart 0.16.22)".

install.sh still said "See https://inbuxa.org once it's up", which stopped
being true when the site went up this morning, and offered nothing but a
cargo line. It now names both ways to build, says what a server with no
configuration does, and points at the releases page and the docs.

The installer it stands in for is still unbuilt (SPEC.md 6.1), and the
script says so plainly: an installer trusted with a mail host is not a
thing to improvise, so it declines rather than half-doing one.

README: inbuxa.org is up, so stop saying it is not, and link the docs.
2026-09-20 03:13:52 -07:00
jcoffey-dev d264429298 Cutover: the old install is retired, and that changes what a rollback is
John, 2026-09-20: committed, remove it. Done the same day as the cutover and
before the certificate-renewal gate this page proposed, which is the
operator's call to make and is recorded as such.

Archived first and the archive verified off-host by checksum, then the tree,
the unit and its drop-in removed. The stalwart user stays: redis-server runs
as it, which step 0's pgrep had already shown and which is exactly the kind
of thing that makes "remove the service user" a bad reflex.

Two things the doing taught, both for migration.md. The unit does not live
inside the tree it manages, so an archive of /opt/stalwart alone is not a
restorable rollback -- stalwart.service and its drop-in had to be saved
separately, and a tool that archives before retiring has to take them too.
And retiring changes what a rollback means: up to that moment it was a
service swap against a store still on disk, minutes and no restore; after
it, an untar, a chown, a unit to reinstate and a webmail image that is no
longer on the host. Still possible, slower, and no longer what the Rollback
section describes.
2026-09-20 01:58:34 -07:00
jcoffey-dev 80972695b9 Cutover: the run, 2026-09-20 — 78 seconds, and what it found
INBUXA is on the fork. The window was 78 seconds, 2.2 G of store copied in
1.4, mail queued at senders and nothing lost, both front ends up within the
hour, and the rollback never needed.

The page stops being a plan and becomes the record of one, which is what
migration.md is built from. Written up by kind rather than in order, because
nobody reading it later wants the chronology.

What step 0 was worth, most of all. Reading `systemctl cat stalwart` before
touching anything found a network namespace nothing in this document knew
about, and that was two failures rather than one: a collision with nginx on
443, loud and quickly understood, and egress from the wrong address, which
would have cost the provider's port 25 exemption and failed outbound mail
at every receiver with no local symptom and no logs to find it in.

What the copied store brought with it, three times in three guises: a
tracer still writing to the old tree, Stalwart's own web interface being
served from the registry's Application entries, and — from the other
direction — a front end configured by copying variable names the fork had
renamed. A front end reporting healthy is not a front end talking to the
right server; the health check passed while it pointed at example.com.

What this document had wrong: `systemctl mask` cannot mask a unit that
lives in /etc/systemd/system; there is no "let mail flow" gate, because the
fork takes port 25 as it starts and the free-rollback window closes there;
and IMAP's INBOX is not JMAP's account, so the counts differ before and
after alike.

And the bug it found, which only exists when §5.3 is followed: INBUXA Admin
hosted off the mail server cannot fetch its schema, because that response
was publicly cacheable and immutable for a year while its CORS headers vary
by origin. Fixed in 7c4add8.

The Open section loses the two the run settled and gains the two it
created, and names the ACME date: ~28 October, because R12 renews at the
halfway point and nothing brings that forward.
2026-09-20 01:47:10 -07:00
jcoffey-dev 7c4add8425 The schema is cached per-origin and privately, not publicly for a year
INBUXA Admin, hosted off the mail server as SPEC.md §5.3 requires, signs in
and then cannot load: "Failed to load the admin panel configuration. Failed
to fetch." Every other endpoint works from the same origin with the same
token; only /api/schema fails, and it is the one thing a schema-driven
interface cannot do without.

It is Chrome's cache, not CORS. Measured from the page itself: a normal
fetch fails, while cache: "reload", cache: "no-store" and a cache-busted URL
all return 200. The server never sees the failing request, which is why the
logs had nothing to show and why it looked like a CORS fault for so long.

Two things made that possible, and both are fixed here.

The schema response was `public, max-age=31536000, immutable`. It is served
behind authenticate_headers and its CORS headers vary by Origin, so it is
neither public nor safe to freeze for a year on a hash-named URL that never
changes. It is now `private`, matching what DownloadResponse already does
for the same reason. The other caller of with_immutable_cache serves the
applications' static bundles, which really are public, and keeps it.

And `Vary: Origin` was only emitted when an origin list existed. Before the
front ends are configured that list is empty, so a response cached in that
window carries neither CORS headers nor Vary, and a cache will later replay
it to an origin that should have been allowed. Vary now goes on every
response, so entries key on the origin whatever the configuration was when
they were stored.

Verified against a bootstrapped server in restrictive CORS mode, from a
browser on a separate origin: /api/account, /api/schema and the hashed
target all return 200, with `private, max-age=31536000, immutable` and
`Vary: Origin`.

Nobody hit this before because the admin has always been served from the
mail host at /admin, where it is same-origin and no CORS applies. The first
deployment that follows §5.3 meets it immediately.
2026-09-20 01:43:15 -07:00
jcoffey-dev 92d2b07c2e record-before: ignore the bytecode cache the path import leaves 2026-09-20 00:02:06 -07:00
jcoffey-dev 987ed55d06 record-before: the step 4 recording, from the server about to be replaced
cutover-run.md step 4 says to write down what has to be true afterwards
while the old server can still be asked, and step 10 checks against it.
Done by hand it gets skipped, and skipping it turns "each mailbox holds
what was recorded" into "each mailbox holds something", which is a
different check and will not catch a partial copy.

It reuses record-compat.py's client rather than growing a second one, so
the guard that makes it safe to point at a live server — call() refuses
any method that is not a /get or a /query — covers this too. Verified
that it bites: x:Account/set is refused before anything is sent.

From the administrator alone it records every account with its address,
aliases, tenant and usedDiskQuota, which is the number that moves if mail
goes missing, plus the domains and tenants. Exact per-mailbox counts need
the mailbox's own credentials, since an administrator has reach over an
account but not always into it, so --as takes one and repeats. For a
handful of mailboxes that is worth it: it makes step 10 an equality
rather than an estimate.

Aliases are resolved to the domain's name rather than its id, because an
id is not what anyone checks against at 2am.
2026-09-20 00:02:01 -07:00
jcoffey-dev a63839f6b0 The repository's own .github, now that it is public
SPEC 2.2a says INBUXA writes its own when the repository is first published,
and it is. Until now the public repository carried Stalwart's: a security
policy telling people to report vulnerabilities to Stalwart Labs, and a
contributing guide whose policy is that pull requests from anyone not on
upstream's vouched list are closed automatically. Neither is this project's,
and both were being offered to anyone who looked.

So: a security policy that says where to send a report, and what happens if
it turns out to be upstream's bug rather than ours; a contributing guide that
says what a fork of someone else's code needs from a contributor, including
the clean-room question, since the record has to stay true; the Contributor
Covenant; and a sponsor link. Upstream's two security documents move to
.github-upstream/ beside its workflows -- kept, not used, not presented as
ours.

CI builds the server and compiles every test target, and deliberately runs
no suite. The unit tests only build with the integration crate in the graph,
and the integration suites want a STORE, fixed ports and a container apiece,
so running them here would mean a tick that skipped everything or a cross
that means "the runner has no Redis". The workflow says as much, so nobody
has to rediscover it.

Also ignores /artifact: two hand-built binaries, ~190 MB, one `git add -A`
away from a public repository.
2026-09-20 00:00:05 -07:00
jcoffey-dev 6a53d47106 Mark the files this fork changed (AGPL section 5(a))
The AGPL asks a modified version to carry prominent notices saying it was
modified, and giving a date. Publishing the source is the conveyance that
asks for it, so it wants doing before the repository is public rather than
at the release.

Every upstream file the fork changed now says so in its header, beneath the
notice it came with: 164 files, found by diffing against the upstream
snapshot branch rather than by guessing, so the list is what actually
differs. Files the fork wrote itself already carry their own copyright and
need nothing. Upstream's notices are untouched, which its licence requires
and which was already true.

The README says the same thing in prose, since the obligation is on the
work as a whole and not only its Rust files.

Builds unchanged: the server and the test binary both compile.
2026-09-19 23:48:35 -07:00
jcoffey 77fce247e2 Update README to remove development status
Removed status update about development and production use.
2026-09-19 23:47:31 -07:00
jcoffey-dev 0117500d85 Cutover: the sheet to follow at the terminal on the night
cutover.md is the reasoning and is too long to read at 2am. This is the
same sequence as commands, for this install: eight mailboxes, two people
and a printer.

That scale settles three things the general plan leaves open. The store is
small enough that the two-pass rsync buys nothing, so it is one cp inside
the window and a simpler sequence when it matters. "Every account still
works" is two sign-ins. And a reboot inside the window is affordable, which
is the only honest proof that the fork comes up on boot and the old unit
does not — is-enabled says what is configured, a reboot says what happens.

The rollback leads with chattr -i, because step 7's guard stops the
Enterprise build exactly as it stops the fork, and finding that out during
a rollback costs the worst ten minutes of the night.

And it names the printer as its own check. It is the one user that cannot
report a fault: a hardcoded credential and an old TLS stack, of the kind a
stricter default quietly refuses. The people will phone; the printer will
just stop, and nobody will notice for a fortnight.
2026-09-19 23:08:19 -07:00
jcoffey-dev 8c3450dffa Migration: copy in two passes, and time the second one
Taken from the cutover page, where the reasoning is worked out: with no
filesystem snapshot to take, a single copy inside the window makes every
byte downtime. A first pass while the server still serves moves the bulk
and is deliberately inconsistent; a delta pass after the process has exited
makes it consistent and moves little, because a RocksDB store is mostly
immutable SST files.

That is the difference between a window proportional to the store and one
proportional to the delta, which is the number this tool exists to
advertise. Also carried over: hand the copy to the user the fork runs as,
and make the original unwritable before the fork starts, since the old
server's store lock was the only thing holding that line until it stopped.
2026-09-19 22:58:30 -07:00
jcoffey-dev 7196f9dda2 Specs: when the source goes out, and who owes the offer
AGPL section 13 starts at the cutover, not at the announcement: the fork is
a modified AGPL program and its users reach it over a network. Settled
today that the source is released after the cutover, with the links live
then, and in the meantime the server's users are the operator's household,
so the people owed an offer and the people holding the repository are the
same people.

Anyone migrating their own server inherits that obligation on their first
day and has no such overlap, so the migration tool says so at the end of a
successful run instead of leaving it to be discovered.
2026-09-19 22:58:30 -07:00
jcoffey-dev efe0b01bac Specs: what may carry the Stalwart name, and when it stops
Two decisions §2 implied but never settled.

§2.4 said no "Stalwart" in UI text; §2.6 requires the startup banner, the
JMAP implementation string and OpenTelemetry's service.version to name the
base. Taken literally, the first would strip exactly what the second exists
to keep. Version and build metadata are now exempt, with the distinction
written down: §2.4's first bullet governs identity, the new one governs
provenance.

A second bullet covers material outside the product. The name appears with
its trademark attribution; the fork relationship is stated once in the
provenance or license section; the migration path names the server it
migrates from, because an operator searching for it has to find it. The base
version stays out of taglines, page titles and social previews, where it
reads as a source identifier rather than a fact. No comparison in either
direction: what INBUXA offers is stated on its own terms.

§2.6 said the base drops out of the version string "when it happens", which
left someone judging the moment. The trigger is now the first release that
isn't a rebase on an upstream tag. Because the reason for publishing the
base is one-way store conversion, and that outlives the string, the
amendment routes it to the upgrade documentation rather than letting it go.

§8 no longer asks whether the fork follows upstream's version numbers. §2.6
answered that on 2026-09-18: it has its own.
2026-09-19 22:53:28 -07:00
jcoffey-dev 04a5a8bdc2 Specs: features 6 to 9 are built, and the table hadn't caught up
Monitoring, SCIM, scale-out storage and per-domain directories each say
"Built 2026-09-19" in their own implementation-status sections, and the
code is where those sections say it is. The §4 table still listed them as
specs awaiting a build, which made the whole feature set look half-finished
to anyone reading the table alone.

Each row now names where the feature landed, as rows 1 to 5 already did.
Monitoring and per-domain directories sit outside crates/features, so their
rows name the paths rather than the crate.

§2.2b already recorded that all nine were rebuilt by 2026-09-19 and every
pending-rebuild gate came off. Only the table lagged.
2026-09-19 22:53:28 -07:00
jcoffey-dev 848bfeda4e Migration: name the four ways this goes badly, not just that it might
The section warned in general and so warned about nothing. An operator
reading "it can fail in ways it cannot undo" learns less than one reading
that the window is usually longer than guessed, that opening the source
store with the new server ends the rollback permanently, that a rollback
after mail has flowed does not bring that mail with it, and that a
certificate which stops renewing says nothing for ninety days. Each of
those has been measured or seen; each has something the operator can do
about it.

Also sharpens the part that matters most and is easiest to get wrong: the
old install is a service safety net, not a data one. One copy, same
machine, one moment. A backup is a copy elsewhere that has been restored
from, and anyone who cannot say when they last restored one does not yet
know whether they have one.

And replaces the flat line about nobody else being responsible with what
it was trying to say: the operator carries the outcome, because this is
software running against a server it has never seen, holding data somebody
else depends on.
2026-09-19 22:52:12 -07:00
jcoffey-dev 4ce5fc6f68 Migration: say what the operator is carrying, before the tool moves anything
Asked for by John, 2026-09-19. A tool that stops somebody's mail server
should say so while there is still time to stop it, rather than leaving the
licence to have said it in a file nobody opens. AGPL-3.0 §15 and §16
already disclaim warranty and liability and this narrows neither; it is the
same thing at the moment it is useful.

Specific rather than blanket, because a blanket one protects less and helps
nobody: what the tool does to the server, what a rollback does not return,
and that backups and recovery are the operator's. Keeping the source
install is not a backup — it is one copy, on one machine, of one moment,
and the same disk failure takes both.

Paired with what the tool does to earn the trust it is asking for, because
that is the half that reduces the friction: a dry run the real run refuses
to start without, never writing to the source, verification before mail
flows with automatic rollback, the old install kept, and every phase timed.
--yes skips the prompt, not the dry run.
2026-09-19 22:48:50 -07:00
jcoffey-dev 3e69b6139f Cutover: the old install is a reference, kept until someone says otherwise
John, 2026-09-19, on both counts. The old install is kept, shut down, not
removed: its unit installed and disabled, its store read-only, started
again if a rollback is ever wanted. When it stops being worth the disk the
tool asks — keep or delete — rather than deciding, because it does not
remove the thing its own rollback depends on.

And the new stack depends on nothing in it. That is the shape's purpose:
/opt/stalwart is a reference, everything needed is copied to new paths, and
when it goes nothing notices. Nothing in the fork works against that —
inbuxa.service substitutes its own prefix, no path names the old tree, and
certificates and ACME keys are in the registry inside the store — so a
dependency, if one appears, was made by hand during the move.

Which is worth proving rather than asserting, and reversibly: nothing open
under the old tree, then rename it and leave it a day under real traffic.
Deleting proves the same thing and cannot be undone.

Two corrections this forces. The rollback has to make the original store
writable again first: the guard of step 3 blocks the Enterprise build
exactly as it blocks the fork, and finding that out during a rollback is
the worst time. And the copy has to be chowned — rsync -a preserves
ownership, so it arrives owned by the old service user while the unit runs
as User=inbuxa.

Also drops the stale "untested" wording about carrying the data back. It
was tested; it is impossible.
2026-09-19 22:45:09 -07:00
jcoffey-dev 7c27bae4f3 Cutover: ext4, so copy in two passes and time the second one
The mail host is ext4 (John, 2026-09-19). There is no filesystem snapshot
to take, so the sequence as written puts the whole store inside the
downtime: stop, copy everything, start.

An rsync before the stop and a second one after it moves the bulk while
mail is still flowing and leaves only the delta in the window. The first
pass is knowingly inconsistent and exists only as a warm-up; the second,
once the process has actually exited, is what makes the copy consistent.
A RocksDB store suits this, being mostly immutable SST files: what changes
between the passes is the WAL, the MANIFEST and any compaction output.

Step 3 now says so, and says to time both during the rehearsal, because
the second pass is the window and nobody knows yet how long it is.
2026-09-19 22:41:59 -07:00
jcoffey-dev bfda639d73 Cutover: the fork must not live on /opt/stalwart, which is to be removed
John, 2026-09-19: the fork takes a copy of the config rather than pointing
at the old one, and /opt/stalwart goes away once the migration is
confirmed.

That turns step 4's store path from a free choice into a constraint. The
step said an existing install keeps whatever its configuration names, which
is true of the server and no longer true of this migration: nothing the
fork runs on may sit under a directory that is going to be deleted. Worth
checking before starting rather than after removing.

Removing it strands nothing else. ACME account keys and issued certificates
are written to the registry, inside the store, so they came across with the
copy; /opt/stalwart holds the old binary, its config and its data and
nothing the fork reads.

What it does end is the rollback, so the new section says when. The
rollback stops being one within hours anyway — after mail has flowed,
going back means losing what arrived since — so the real question is how
long to keep a cold copy of the pre-cutover state. The gate is the first
certificate renewal, which is the one check in "The first week" whose
failure would send anyone back; forcing a renewal closes it in a day
rather than ninety. Archive the store off-host before removing the
directory.
2026-09-19 22:36:51 -07:00
jcoffey-dev a6c7152c52 cutover-rehearsal: ignore the fixture it writes 2026-09-19 22:34:50 -07:00
jcoffey-dev daaa06a5fb Cutover: stopping the old server is what removes the guard, so add one
Step 2 stops stalwart.service and step 4 points the fork at a store path.
Between those two moments nothing protects the original, and the rehearsal
had already shown that one open by the fork costs the rollback for good.

What protects it until then turns out to be the running server itself:
RocksDB refuses a second opener with "While lock file: LOCK: Resource
temporarily unavailable". So the intuition that a service shutdown prevents
the mistake is backwards — the shutdown is what enables it.

Measured, in probe_guard.py, in the three states that matter: held by the
running server, the fork is refused and the rollback is intact; stopped but
read-only, the fork is refused while rotating its own log and the Enterprise
build still starts on it afterwards; stopped and writable, the fork opens,
adds its column family, and upstream never starts again.

So step 3 now makes the original read-only as soon as the copy is taken,
which turns a discipline problem into a one-line one, and the answered
section carries the table.
2026-09-19 22:34:45 -07:00
jcoffey-dev 67619f64e9 Cutover: rehearsed, and the Enterprise build can't read the fork's store
The runbook said nothing in it had been rehearsed. Now the sequence has
been, on data made up for the purpose: upstream 0.16.22 in a container as
the install running today, the fork beside it, both unprivileged with
CAP_NET_BIND_SERVICE. It rehearses the sequence, not the data, which is
what the compat tests are for. 27 of 27 checks passed and the rollback
took 1.5 seconds.

The question §"Open" asked about carrying a store back is answered, and
the answer is no. Upstream refuses to start on a store the fork has
opened: "Column families not opened: _". The fork adds one RocksDB column
family for masked email (SUBSPACE_INBUXA = b'_') and opens with
create_missing_column_families, so it creates it on first open; upstream
has no descriptor for it and RocksDB will not open a database holding one
it was not told about.

That makes step 3's "a copy, not a move" load-bearing in a way the step
did not say. One open by the fork is enough: pointing it at the original
even once, to check something, leaves the Enterprise install unable to
start, and there is no rollback after that. It fails loudly and before
reading anything, which is the good version of this failure, but it is
not recoverable.

Two things the rehearsal found that would have wasted time on the day:
memberTenantId does not come down from the domain and is refused on
create, so a tenant "admin" set up the obvious way is a server
administrator and the check passes while proving nothing; and IMAP's
INBOX is not JMAP's account, because mail from an unauthenticated sender
is filed as spam, so the two counts differ before and after alike.

What the rehearsal does not cover is in its README and in §"Open":
systemd and `systemctl disable stalwart` above all, ACME renewal, load,
the front ends, and INBUXA's own data.
2026-09-19 22:23:10 -07:00
jcoffey-dev f338ddf57d Specs: point a stock ihasmail at the migrated server afterwards
Asked for by John, 2026-09-19. Public ihasmail is Stalwart-facing and knows
nothing about INBUXA, so running an unmodified one against the migrated
server checks something the fork's own suites cannot: that a client written
for upstream still works.

Each difference it finds is one of two things, and the point is to say
which: a regression against upstream's contract, which the fork's tests
would not catch because they test the fork; or a feature that now expects
INBUXA's own front ends, which belongs in the contract and the release
notes rather than in a user's surprise.

After mail is flowing, not as a gate. It informs the contract; it doesn't
block a cutover.
2026-09-19 21:36:10 -07:00
jcoffey-dev 7d468ad22e Specs: a migration tool, with rollback as a path rather than an appendix
INBUXA's cutover is the first run of something other operators will want:
an existing Stalwart server becoming an INBUXA one with nothing re-entered
and nothing re-issued. Accounts, passwords, app passwords, OAuth sessions,
aliases, tenants, DNS records and provider settings, certificates and ACME
state, Sieve scripts, the queue and the mail all live in the store, so a
migration that copies the store carries them.

Offered beside the fresh-install workflow, which has different questions to
ask, so §6.1 now names both.

It rolls back, which is where it parts company with stalwart-migrator:
that one upgrades in place and says outright it cannot undo a migration.
This one never writes to what it migrates from, so going back is stopping
one service and starting another. Rollback is automatic when verification
fails, available on demand while the old install stands, honest about the
mail that stays behind, and never points the old server at the store the
fork has written.

Every phase is timed, and the number to advertise is the downtime, phases
2 to 7, not the total that preflight and the copy dominate. The report
writes both as JSON so a release note quotes something measured.
2026-09-19 21:34:11 -07:00
jcoffey-dev 92aed3df21 Cutover: side by side, with the old install left standing
John's plan, 2026-09-19: stop the Enterprise server and the ihasmail
container, install the fork at its own path, copy the data across, bring up
INBUXA Admin and the new webmail, and every account carries on.

That is a better shape than the in-place swap this draft assumed, because
the rollback becomes a service swap rather than a restore: the old install
and its data are untouched, so going back is stopping one unit and starting
another. What it costs is whatever the fork accepted in between, since the
two stores diverge the moment the fork starts.

It buys one failure the in-place swap couldn't produce: both servers on one
set of ports, each with its own store, if a reboot brings the old unit back.
So the unit is disabled, not just stopped.

Also written down: the front ends' OAuth clients travel inside the store, so
a front end that keeps its client id and redirect URIs keeps working and one
deployed fresh needs them set up, which fails looking like an account
problem when it isn't.
2026-09-19 21:31:50 -07:00
jcoffey-dev 63fe315457 Specs: ufw is in the way of the ACME test, and the rules say so
/etc/ufw/user.rules is world-readable, so this needed no privilege after
all. The default input policy is DROP and 8899 is not among the allowed
ports, so pebble's connection to the suite's listener is dropped. That
matches the live run, where twelve probes from a container completed no
handshake while the same openssl reached pebble's own TLS port.

The nc readings that pointed the other way — instant refusals on closed
ports, where a DROP should hang — are still unexplained, and are left on
the page as unexplained rather than quietly dropped, since they are what
sent an earlier pass through this page in the wrong direction.

Nobody has added the allow rule and re-run the suite, so the fix is
written down as a prediction. The cutover draft says the same: the test
failing here is not evidence that renewal works on the host.
2026-09-19 21:27:40 -07:00
jcoffey-dev 26759a1847 Specs: a draft runbook for the cutover
Steps 1 to 3 of SPEC §7 are met, so the remaining one is running the fork
as the mail server. The draft covers the sequence on the host, what to
check before letting mail flow, and what to watch in the first week.

Two things it refuses to gloss: rolling back stops being a snapshot restore
the moment the fork accepts a message, because nobody has tested whether
the Enterprise build reads a store the fork has written; and certificate
renewal is the failure that arrives 90 days late and quietly, on the one
path the suites couldn't settle.

Nothing in it has been rehearsed. The rehearsal on a copy is step 2 of
"Before the day", and it is what turns "the data opens" into "the server
runs on it".
2026-09-19 21:24:25 -07:00
jcoffey-dev f646f2ec3a Compat: all eight pass against a copy of INBUXA's data
Run from a copy of the stopped server's RocksDB store. Eight green lines,
which are worth reading carefully: six carry weight, and masked_email and
undelete carry none, because there are no masked addresses and retention is
off, so they iterate an empty list. tenant_compat checked the tenant, its
quotas and its members, but not what a tenant administrator can see, which
needs a --tenant-admin recording.

ai_compat is the one that might have looked vacuous and isn't: the twelve
LLM_ tags are there with the scores that were observed.

Three things had to be fixed first, each failing all eight identically and
none about the data: listener names, privileged ports, pending tasks. The
next import's copy will bring the same three, so they are written down.

SPEC §7: cutover steps 1 to 3 are met. Step 4 remains.
2026-09-19 21:19:56 -07:00
jcoffey-dev bfd2784819 Compat: the copy's pending tasks aren't this run's to wait for
The run against INBUXA's store hung printing "Waiting for pending task
AcmeRenewal(...)": the copy carries that server's task queue, and a renewal
due in 2026-11 will not come due while a test watches it.

Under NO_INSERT the wait now skips tasks that aren't due and ones that have
permanently failed, which leaves the tasks the test itself caused — a
restore in undelete_compat comes due at once — and gives up after a minute
with the offending task printed. A test that was really waiting on its own
work now fails on its assertion, which says more than a spinner.

Ordinary runs are untouched: system_tests, which waits on tasks throughout,
still passes in 135s.
2026-09-19 21:17:21 -07:00
jcoffey-dev 7714bca8d3 run-compat: pass the test server's logging through
--log <level> reaches the server as LOG, which is the only way to see why
an authentication or a task failed rather than that it failed.
2026-09-19 21:09:37 -07:00
jcoffey-dev 01c5503a19 Compat: a copy's own listeners aren't ours to bind
The first run against INBUXA's store failed all eight tests identically,
before reading a single record: the copy carries that server's listeners on
25, 443, 465, 587, 110, 143, 993 and 995, and nothing in a test run is
root, so each one failed with "Permission denied (os error 13)".

The builder now remembers the listeners it adds, and under NO_INSERT drops
build errors for any it didn't. Every other error still stands, including a
bind failing on one of its own, so this can't hide the case where the
harness's own port is taken.

The copy isn't edited for this: its listeners are simply not what a compat
run needs, and it reaches the server over the compat- ones instead. Checked
that a NO_INSERT run still boots and that scim_tests, which takes the
ordinary path, still passes.
2026-09-19 20:58:49 -07:00
jcoffey-dev 1da75e986e Compat: run them from a copy, and keep our listeners out of the way
Rehearsed the run against a real RocksDB store, and it died at startup
before checking anything: the harness inserts listeners of its own, the
registry keys them by name, and a real server already has a "jmap" and an
"imap". The message was "Primary key conflict on property name with
existing object NetworkListener", which says nothing about what to do.
Under NO_INSERT the harness now calls its listeners compat-jmap and so on,
and the same run gets through to the test's own checks.

run-compat.sh copies the store for each test and removes the copy after,
because several of these write to what they open: monitoring_compat purges
the history it reads and undelete_compat restores what it finds. The source
stays untouched, which matters when it is the only copy of a production
store anyone took that day.

INBUXA runs RocksDB, so a copy is a directory copy. The SQL backends would
need more than this: the harness builds its own container and connects to
fixed local credentials, so it cannot open a dump in place.
2026-09-19 20:21:05 -07:00
jcoffey-dev d407509f59 Compat: what the recording holds, and what two of the tests now prove
record-compat.py ran against the live server as a server-level
administrator: 8 accounts, which matches the dashboard, so it reached all
of them. One tenant with its quotas and members; no masked addresses, no
archived items.

The empty files are right rather than short. INBUXA has no masked
addresses and retention is off, so masked_email_compat and undelete_compat
iterate an empty list: they pass without comparing anything, which is worth
saying plainly, because a green run from either would otherwise read as
evidence of compatibility. That puts them where scim_compat and
per_domain_directory_compat already sit.

So one recording carries weight, expected.json, and it is made. SPEC §7's
cutover steps 2 and 3 come down to the tenant, the domains and the
accounts until either feature is switched on.
2026-09-19 20:10:50 -07:00
jcoffey-dev 3d28f6bff2 record-compat: tell reach apart from permission in a refusal
"You are not an owner of account X" is not a permission the server is
withholding; it is how far that identity can see. Answering it with "needs
sysMaskedEmailGet" sends you off to grant something that changes nothing.
A refusal that mentions ownership now says so, and says which account the
run wants: the administrator with the run of the server, with tenant
administrators passed as --tenant-admin.
2026-09-19 19:52:37 -07:00
jcoffey-dev 045e9f6234 record-compat: a refused section costs its file, not the run
A recording runs against a server that may not be up again soon, so an
administrator missing one permission shouldn't throw away the whole pass.
Each of the three is recorded on its own now: what the server allows is
written, what it refuses is named at the end with the permission it wants,
and the exit is still non-zero so an incomplete recording can't pass for a
finished one.

A refusal used to print the raw JMAP error. It now reads, for example,
"[email protected] may not x:Tenant/get: You are not authorized to perform
this action (needs sysTenantGet)".

Checked both ways: the refusal path against a stubbed client, where the
other two sections still record; the whole thing against a live test
server, which recorded 3 tenants and 53 masked addresses and exited 0.
2026-09-19 19:21:22 -07:00
jcoffey-dev 7816f38c29 record-compat: check every credential before reading anything
A wrong tenant-administrator password failed only once the script had
already enumerated every account, on a server that may not be up for long.
All the identities are now checked against the session endpoint first, and
a refusal names each one that failed, with the two things that usually
explain it: basic authentication wants the account's name rather than its
email address, and an account with two-factor or OAuth-only sign-in needs
an app password. It also gives the curl line to test one on its own.

The unreachable message no longer suggests --insecure for a refused
connection; that hint is now only for a certificate it couldn't verify.
2026-09-19 19:10:03 -07:00
jcoffey-dev fd7747ef21 Fork tooling: record what the compat tests compare against
Three of the eight compat tests check INBUXA's data against a recording of
how the Enterprise server read it, and that recording can only be made
while that server is still up. SPEC §7 gives it 45 days from the notice, so
the capture shouldn't wait on the cutover being scheduled.

record-compat.py writes all three files: the tenants with their quotas and
members and what each tenant administrator sees, every masked address and
its state, and every archived item whole, since undelete_compat compares
every property it recorded. It only reads, and refuses to send a method
that isn't /get or /query, because it is the one tool here that runs
against the live server. Queries follow their pages, so a server that caps
one doesn't leave a short recording behind.

Exercised against the fork's own test server, which answers the same JMAP:
3 tenants with members, 8 masked addresses and 3 archived items, each in
the shape its test reads.
2026-09-19 18:42:25 -07:00
jcoffey-dev 2a127d6b9a Specs: the ACME network question is open, not answered
Probing further contradicted the previous two commits. During a live run,
when the suite is certainly listening on 8899, twelve handshakes from a
container completed nothing, with or without the ACME ALPN, while the same
openssl in the same container talks to pebble's TLS port and prints its
certificate. A listener that is up but unreachable from a container is what
a ufw DROP looks like. An instant refusal on a closed port, which nc saw
from two images, is not. Both were observed minutes apart.

So the ufw suspicion is neither confirmed nor dismissed, and the page now
says that rather than picking the reading that suits the last probe.
Settling it needs `sudo ufw status verbose` and a listener bound by hand,
neither of which this session could do.

What stands on pebble's own log, and does not depend on any of this: it
runs validations and marks the authorizations invalid, so "never validates,
stays pending" was wrong.
2026-09-19 18:10:22 -07:00
jcoffey-dev 261175aae5 Specs: say only what the ACME probes support
The previous commit called the bridge open on the strength of one nc run.
A later openssl s_client against the same closed port hung for its whole
timeout instead of reporting the refusal nc had just seen. Repeating the nc
test from a second image, with a control port and two closed ports, agreed
with the first: immediate refusal, which is not what a DROP looks like. The
openssl behavior is still unexplained, so the claim now carries what was
measured and the anomaly beside it.

The ALPN probe is recorded as proving nothing, for the same reason: it
hangs against a port with nothing behind it, so its silence during a
renewal says nothing about acme-tls/1.

Pebble's log is untouched by any of this: it validates and marks the
authorizations invalid, which is what makes the old explanation wrong.
2026-09-19 18:06:10 -07:00
jcoffey-dev 4336251cea Specs: the ACME failure isn't ufw, and pebble does validate
The page blamed ufw for blocking the docker bridge, and said pebble never
validates, so the authorizations stay pending. All three are wrong.

From a container on the ACME network the host answers on both gateway
addresses: port 22 connects, and 8899 refuses at once with nothing
listening, where a DROP would hang. No firewall rule was read or changed to
establish that. Pebble's own log shows 20 validation attempts in the
regression run, five for each of the four tls.org names, each ending in
"INVALID by completed challenge". The challenges are answered and refused.

So the order goes invalid, no certificate is issued, and the test unwraps a
None. What's left to explain is the TLS-ALPN handshake. The responder is
intact and listen.rs picks it per connection from has_acme_tls_challenge,
which is computed when the network config is parsed, while the test adds
its provider after boot. That's written down as a hypothesis, not a
finding: it hasn't been tested.
2026-09-19 17:57:52 -07:00
jcoffey-dev 8839078a2b Compat tests: dry-run the harness, and say so when the admin can't log in
None of the eight had ever executed, so all eight ran against an empty store
with synthetic inputs. The plumbing works: the documented JSON shapes parse,
and NO_INSERT stops each one before the harness touches the store, which a
sentinel file in each store directory confirmed — it survived every run,
including the one launched without NO_INSERT.

Two things the runbook got wrong, both of which would have cost a day on the
day the copy exists:

- TMPDIR is the copy's parent, not the copy. The harness opens
  $TMPDIR/<test name>, so a TMPDIR pointing at the copy gets an empty store
  created beside it and the test calls INBUXA's data missing.
- masked_email_compat and undelete_compat need INBUXA_COMPAT_MASKS and
  INBUXA_COMPAT_ARCHIVED, which only the tests' doc comments mentioned.

Every run ended on a 401 raised as "Missing list in response", which reads
as INBUXA's data being wrong when the login is what's wrong. Each test now
authenticates once first and names the variable that failed.
2026-09-19 17:51:42 -07:00
jcoffey-dev 29d9263071 One edition: the last enterprise gates come out of shared code (SPEC 2.3)
Every one of the 14 was `#[cfg(not(feature = "enterprise"))]` on the arm the
fork always compiles: the Enterprise arms went with the import, and nothing
turns the feature on. Removing the attribute leaves the same code, now
unconditional, in 11 files.

Two of them looked like behavior worth checking before touching: the
`validate_tenant_quota` stub that always passes, and the refusal to cancel a
pending DestroyAccount task. The stub is vestigial — the rebuilt
multi-tenancy enforces quotas in `crates/features/src/tenancy/quota.rs` for
those objects and more — and the refusal is undelete's open question, which
this change leaves exactly as it was.

The binary builds with no new warnings, and `system_tests` and `jmap_tests`,
which cover the touched registry, task-manager and auth paths, both pass.
The feature definitions stay in the manifests, inert: taking them out would
widen every sync's diff for nothing.
2026-09-19 17:44:20 -07:00
jcoffey-dev dac1808bbd Tests: retire pending-rebuild, which nothing has been gated behind for a day
The feature held the shared tests of features the fork hadn't rebuilt yet.
All nine are built and the last gate came off with per-domain directories,
so the feature was defined and documented but gated nothing.

The crate builds and holds the same tests without it: 113 by default, 118
with postgres, mysql and redis. SPEC §2.2b keeps its account of the first
import and now says when the gates came off, so a spec that still describes
a suite as gated reads as the record of its own date, which is what
monitoring.md's gate table already calls itself.
2026-09-19 17:28:14 -07:00
jcoffey-dev 08b3210cff Specs: the regression after the ACME fix, 87 passed and 3 failed
Ran it single-threaded on RocksDb: 87 passed, 3 failed, 23 ignored, all 113
tests a default build holds. lmtp_delivery passed this time, which is what
the note about queue timing under a sequential run predicted, so the four
documented failures are three. The ACME suite logged no 400 at all, so the
renewal fix holds; it still ends on the certificate that never came,
because pebble can't validate across the bridge with ufw up.

The line it replaces claimed 86 passed, 4 failed, 27 ignored from the same
command. That totals 117, and no feature set of this tree produces 117 —
113 by default, 118 with all three backends — with no test added or removed
since. Said so rather than presenting the two as a trend.
2026-09-19 17:21:58 -07:00
jcoffey-dev 9f444d2458 Fork tooling: the third-party code upstream carries, listed and checked
A few of upstream's dual-licensed files carry code from other projects
under MIT or BSD terms. The fork redistributes it, so their licenses
require the notices to travel with it. THIRD-PARTY.md reproduces them.

strip.py now reads the stripped tree's comments for another copyright
holder, another license, or a note that code came from somewhere else, and
names any file THIRD-PARTY.md doesn't cover. It reports, never fails: the
notice goes in with the merge that brings the release in.

On v0.16.22 it finds 14 files, all of them covered. The rest of the report
is byte-for-byte what the committed one says, so the scan disturbs nothing
it already did.
2026-09-19 16:59:36 -07:00
jcoffey-dev adfa22c817 Specs: the nine ignored suites re-run, and the STORE each one needs
Re-ran all nine on containers removed beforehand, one suite at a time.
All nine pass. mysql_replica_position_tests failed the first time on test
18's lag assertion, with the pair half a minute old and still catching up,
and passed on a second run against the same containers; it fails before
the point where it changes the replica's settings, so it leaves nothing to
restore. Noted both, with each suite's time.

The table's STORE column said "default" for five suites, which reads as
"leave it unset". The harness has no default: it panics with "Missing or
invalid store type" before the suite starts. They run on RocksDb, as the
regression does, so the column now names it.
2026-09-19 16:54:27 -07:00
jcoffey-dev 309835be1d ACME: post a challenge once, then poll the authorization
The renewal loop re-posted the challenge every time it polled and found the
authorization still pending. RFC 8555 section 7.5.1 has the client post a
challenge once to say it is ready and then poll; a server that has already
moved the challenge to "processing" refuses a second post, and pebble
answers 400 malformed, which failed the whole renewal.

Verified against pebble: the 400s are gone and the client polls. The suite
still can't finish on this machine, because pebble never reaches the test
server to validate the challenge; that path is the environment, and the
runbook now says so.
2026-09-19 16:28:15 -07:00
jcoffey-dev ce12659138 Specs: what a plain regression leaves failing, and why none of it is ours 2026-09-19 16:15:09 -07:00
jcoffey-dev e492b7875a Specs: how to run the suites a plain regression doesn't reach
`cargo test -p tests` runs none of the fork's own feature suites except
what's inside `system_tests`: SCIM, per-domain directories, the sharded
stores and the four replica suites are all ignored, each needing a
container, a STORE the harness only builds on request, or both. The
runbook lists what each one needs, and the container-reuse trap.
2026-09-19 16:14:44 -07:00
jcoffey-dev 02ed679890 Tests: drop an unused import, so the tests crate builds without warnings 2026-09-19 16:14:44 -07:00
jcoffey-dev 5cc28784df SCIM: turning a domain's SCIM authority off takes effect at once (SCIM-60)
`allowScimProvisioning` is cached with the domain as DOMAIN_FLAG_SCIM, but
it wasn't among the fields whose change drops the cached entry, so flipping
the flag changed nothing until something else evicted the domain. SCIM-60
says the change takes effect without a restart.

Found by the two acceptance checks that were written but never called from
the driver, so neither had ever run: `authority` (SCIM-58 to SCIM-60,
through `synchronize_account` itself) and `rate_limits` (SCIM-14). Both are
wired in now, and `scim_tests` passes with them.
2026-09-19 16:14:38 -07:00
jcoffey-dev 2f1e4bd2c7 Tests: give the fail2ban and task-manager checks room under load
Two long-standing flakes in system_tests, both timing:

- security: the ban period was set to one second, but the test makes a
  hundred more requests before it checks that a valid password from the
  banned address is refused, so under load the ban had already expired.
  Five seconds, and the expiry check sleeps six.
- task: a task scheduled one second out was queried for straight away,
  and under load the query landed after the task manager had run and
  removed it. Three seconds.

Seven consecutive system_tests runs, neither recurred.
2026-09-19 15:38:01 -07:00
jcoffey-dev cee4fd6bc6 Specs: how to run the eight compat tests on a copy of INBUXA's data, and the statuses of tests 10, 11, 17 to 19
docs/spec/compat-tests.md lists each compat test, what it needs, what it
checks and what a failure means, and says plainly that they run against a
copy only, since the monitoring one purges the history it reads. The
scale-out and per-domain statuses record the acceptance tests that now
run.
2026-09-19 15:11:04 -07:00
jcoffey-dev c0377df942 Per-domain directories: a refused token counts toward the sign-in ban, and tests 10 and 18 (DIR-12, DIR-30)
A token the OIDC directory rejects is an authentication failure, so it
counts toward the ban; a network, provider or configuration fault stays
an error and doesn't. Before, a rejected token was an error too, so bad
tokens never led to a ban.

The Keycloak container now imports a second realm, so test 10 checks
/api/discover and the PACC record answer with each domain's own provider.
Test 18 checks that eight sign-ins during an outage don't ban the client,
while bad tokens do.
2026-09-19 15:09:52 -07:00
jcoffey-dev 0755fad51e Scale-out storage: IMAP CONDSTORE raises the mark, and the two-node check (ST-7, test 11)
A FETCH with CHANGEDSINCE presents its mod-sequence to the read scope, so
a replica must have that change before it answers, as a JMAP sinceState
already did. replica_cluster_tests covers test 11: with the replica's
replay paused, a write on one node is read back through a second store
with its own marks, sharing through Redis.

Composite stores nest store futures deeply enough to pass rustc's default
query depth once both postgres and redis are compiled in, so the server
crates raise their recursion limit.
2026-09-19 14:59:46 -07:00
jcoffey-dev e913a22b66 Scale-out storage: read replicas on MySQL, tested (ST-10, ST-15, tests 17 to 19)
Two source-and-replica pairs run in containers: one replicating with
GTIDs, one by binary log position. mysql_replica_tests covers test 17
(tests 9, 10 and 12 with GTIDs) and mysql_replica_position_tests covers
test 18 (lag from Seconds_Behind_Source, and a replica whose account
lacks REPLICATION CLIENT getting no reads) and test 19 (a parallel
replica without replica_preserve_commit_order left out at startup).

The lag reader now takes Seconds_Behind_Source whatever numeric type the
server returns, accepts the older column name, and says in the log why it
gave up measuring.
2026-09-19 14:53:31 -07:00
jcoffey-dev 216bb5b711 Scale-out storage: implementation status of read replicas (ST-5 to ST-15)
Where the routing lives and what a read scope covers, which acceptance
tests the container suite runs, what isn't exercised (two nodes, the
primary stopped, and the MySQL paths, which are built but unrun), what
was settled from the code, and the known limits.
2026-09-19 14:21:54 -07:00
jcoffey-dev 1518c69033 Scale-out storage: PostgreSQL and MySQL read replicas (ST-5 to ST-15)
A data store with readReplicas becomes a replicated store. Writes,
operator-written SQL and everything outside a read scope go to the
primary. JMAP reads before a request's first write, IMAP LIST, STATUS,
SEARCH, SORT and FETCH, POP3 RETR and TOP, DAV GET, PROPFIND and REPORT,
and blob downloads run in a read scope. Only account data (properties,
indexes, change logs, counters, ACLs, blobs, the search index) is read
from a replica; the registry, in-memory values, the task queue and the
rest stay on the primary.

In a scope, the first read picks a replica round-robin among those up
and under the lag limit, and only if it has every change this node has
written or heard of for the scope's accounts: marks come from write
results, the cluster's state-change broadcasts, a sinceState the client
presents, and, with more than one node, Redis. A write inside the scope
sends the rest of it to the primary. A miss on a replica is looked up on
the primary, and a replica error retries the read there and marks the
replica down.

Each node samples lag every second (WAL positions on PostgreSQL; GTID
sets or Seconds_Behind_Source on MySQL), stops reading from a replica
over 5 s and starts again under 2.5 s, and probes a down replica every
10 s. At startup a replica is left out if it's the primary, isn't
read-only, applies out of commit order, or doesn't show a marker written
to the primary within six tries.

replica_tests (postgres, STORE=PostgreSqlReplicated) runs a primary and a
streaming hot standby in containers: tests 9, 10, 12, 13, 14 and 15 pass.
2026-09-19 14:05:59 -07:00
jcoffey-dev a635b490ec Per-domain directories: implementation status and the compat check (DIR-1 to DIR-32, test 20)
per_domain_directory_compat, ignored, checks a copy of INBUXA's data has
no directory, no server default and no domain with its own directory, as
observed. The status names where the rules live, which suite covers each
acceptance test and how far, what was settled from the code, and the
known limits. SCIM's status notes its test 5 now passes.
2026-09-19 10:51:41 -07:00
jcoffey-dev 0237d6fa92 Per-domain directories: the OIDC directory tests, rebuilt from the spec; SCIM's OIDC test runs (DIR-7, DIR-10, DIR-14, DIR-15, DIR-18, DIR-26, DIR-29, SCIM test 5)
directory_tests now runs a new oidc module in place of the removed one,
with Keycloak as example.org's own directory: first sign-in creates the
account with its name and group, an existing account is reused, a token
named as another user is refused, password sign-in is refused, forged
JWTs (HS256, unknown kid, another issuer, expired) are refused, an OIDC
address is a recipient only once an administrator creates it, and sync
can't pass a tenant's account limit. scim_oidc_tests, deferred until
this feature, passes.
2026-09-19 10:45:37 -07:00
jcoffey-dev 3158277b04 Per-domain directories: changes apply on the next request, and one broken directory doesn't block reloads (DIR-17, DIR-21)
A write to x:Directory or Authentication reloads the directories at once,
here and across the cluster. A directory that fails to open is logged as
a warning against its id and becomes unavailable; before, it was a build
error, and any build error stopped every later reload from applying.

per_domain_directory_tests covers acceptance tests 1, 3, 4, 6, 7, 9, 11,
15 and 19 over SQL directories on SQLite files: each domain against its
own directory and the default, no fallback to internal passwords, a
directory answering for another directory's domain, aliases and groups
dropped, recipients through the directory and a 4xx while it's down, app
passwords while it's down, password changes refused and then allowed
after a move to the internal directory, linked directories, tenant
foreign keys, and changes taking effect without a reload.
2026-09-19 10:42:33 -07:00
jcoffey-dev f72e3bb85c Per-domain directories: each domain signs in against its own directory (DIR-1 to DIR-15)
The two lookups every caller uses now honor Domain.directoryId, then the
server default, then the internal directory, so sign-in, bearer routing,
recipient lookup, discovery, the PACC record and the refusal of password
changes on external accounts all follow the domain. A directoryId, or a
server default, naming a directory that doesn't exist is unavailable,
never the internal directory.

A directory speaks only for the domains it serves: an account it returns
on another directory's domain is refused, for sign-in and recipients
alike, and aliases and group claims on such domains are dropped with a
warning. A bearer token must belong to the user the client names, or the
name must be one of its aliases with alias sign-in allowed. Accounts and
groups that sync creates pass the tenant checks, limits included.
2026-09-19 10:32:57 -07:00
jcoffey-dev b080fa0736 Scale-out storage: implementation status of the sharded stores (ST-1 to ST-4, ST-16 to ST-30)
Where the code lives, which suite covers each acceptance test, what isn't
exercised, what was settled from the code, and that read-replica routing
(ST-5 to ST-15) waits for per-domain directories.
2026-09-19 10:21:30 -07:00
jcoffey-dev fdfa2bc259 SCIM: box each bulk operation's dispatch, so the crate builds with the redis feature (SCIM-51)
With redis on, bulk()'s future nested past the compiler's query depth
limit. Boxing the per-operation call keeps it shallow; behavior is
unchanged.
2026-09-19 10:15:39 -07:00
jcoffey-dev e5e326de6b Scale-out storage: sharded blob, in-memory and lookup stores; configured read replicas reported (ST-1 to ST-4, ST-16 to ST-30)
A Sharded blob store places each blob on xxh3(key) mod N, over the whole
key; reads fall back to the other members, so blobs placed under an
earlier member list stay readable, and deletes find them wherever they
are. The member list is recorded in the data store (secrets left out):
added or reordered members are a warning, a missing one refuses to open.
Blobs are compressed and marked before they reach a member. A Sharded
in-memory or lookup store sends each key to its home Redis member, and
prefix deletes and purges to all; a node whose member list differs from
the recorded one logs an error and runs on. Members are checked for
duplicates and must all open.

Until read-replica routing is built, each configured replica is reported
at startup instead of being silently ignored, and nothing connects to it.
The new scaleout_blob_tests covers tests 2 to 7, and the existing blob
suite passes against three FileSystem members (BLOB_STORE=Sharded).
2026-09-19 10:11:30 -07:00
jcoffey-dev e7efa91cbc Scale-out storage decision: sharded stores first, read-replica routing after per-domain directories, with configured replicas reported at startup meanwhile (ST-2) 2026-09-19 10:01:34 -07:00
jcoffey-dev 00b2eb6f4b SCIM: implementation status (SCIM-1 to SCIM-61)
Where each part lives, which suite covers each acceptance test, test 5
deferred to per-domain directories and test 31 unrun until a copy of
INBUXA's data, what was settled from the code, and the known limits.
2026-09-19 09:59:53 -07:00
jcoffey-dev 3df72b355a SCIM tests: the authenticated rate limit, and the compat check on INBUXA's data (SCIM-14, test 31)
A principal's key without unlimitedRequests gets 429 with Retry-After
over the limit, while its unlimited key still works. scim_compat, ignored,
checks a copy of INBUXA's data reads back as observed: no domain open to
SCIM and no externalId.
2026-09-19 09:53:36 -07:00
jcoffey-dev a18e209015 SCIM: publish meta in /Schemas, and run the conformance container on the host network (SCIM-6, SCIM-30)
scim2-client builds its models from /Schemas, so meta is described there
as the mapping tables give it, without lastModified. The tester container
now shares the host's network, so a host firewall that drops the Docker
bridge doesn't block the test server. With SCIM_CONFORMANCE=1, the
scim2-client lifecycle (12 steps), the eight replayed Okta, Keycloak and
Entra payloads, and scim2-tester (errors only for its generated
non-address userName) all pass.
2026-09-19 09:52:11 -07:00
jcoffey-dev 3ffaee0695 SCIM: a suspended account's open sessions end, and no credential it holds still works (SCIM-52)
The push router records which account each subscription belongs to, and
a new Revoke event drops every subscription the account holds, so its
IMAP IDLE, JMAP event streams and WebSockets on this node end. Other
users' subscriptions to its shared mailboxes stay. Test 28 now checks an
open IDLE is ended, and that the account's password over HTTP and its
own API key, both already cached, are refused on the next request.
2026-09-19 09:42:03 -07:00
jcoffey-dev 940b42f3b4 SCIM: just-in-time directory sync is read-only on domains open to SCIM (SCIM-58 to SCIM-60)
On such a domain, sign-in sync creates no account (the person gets an
ordinary authentication failure), changes nothing on an existing one,
and creates no group from a groups claim. Without the flag sync works as
before, and turning it off hands accounts back to sync with no restart.
Checked through synchronize_account itself; acceptance test 5 does the
same over OIDC once per-domain directories exist.
2026-09-19 09:38:00 -07:00
jcoffey-dev 0ca26070d7 SCIM: users, groups, queries, PATCH, Bulk and cursors at /scim/v2, over x:Account (SCIM-1 to SCIM-57)
Every SCIM operation becomes the x:Account get, query or set JMAP makes,
as the service principal, so permissions, tenant scope and limits,
address uniqueness and account destruction are enforced in one place.
Discovery is anonymous; everything else takes an API key as a bearer
token and nothing else. Domains open to SCIM carry a flag in the domain
cache. Filters take eq and and, answered from the account indexes, with
unindexed attributes checked on at most 200 candidates. Cursors are
stateless, HMAC-sealed under the server key. PATCH applies to the
resource in memory and saves it as a PUT, so it is all or nothing.
Groups get an address from their display name on the principal's
domain; membership is written on each user.

Every write emits one of five new scim.* events (ids 637 to 641), also
added to the packaged schema. The helpers the surviving SCIM suites
import are rebuilt from the spec; scim_tests runs the new acceptance
suite and the surviving tenant isolation suite, and both pass.
2026-09-19 09:35:23 -07:00
jcoffey-dev 776d18d06e SCIM: the protocol crate: URNs, the error document, and the filter and PATCH path grammars (SCIM-42, SCIM-45)
The filter parser reads all of RFC 7644's grammar, so a server supporting
only eq and and can name the construct it refuses. PATCH paths take a
schema URN prefix, sub-attributes and value filters.
2026-09-19 09:06:50 -07:00
jcoffey-dev 2d3f8251c5 SCIM decision: a groups value equal to the current membership in a POST or PUT body is accepted and ignored, since Okta sends groups: [] on create (SCIM-28) 2026-09-19 09:04:25 -07:00
jcoffey-dev 4b389b6b4e SCIM decisions: build before per-domain directories, with the OIDC authority test deferred; build to the spec without the profile drafts or vendor docs (SCIM-61, open questions 12 and 13) 2026-09-19 08:56:53 -07:00
jcoffey-dev f107443b29 Monitoring: implementation status, and the compat test for INBUXA's data (test 26)
monitoring_compat, ignored, checks the observed settings against a copy
of INBUXA's data, reads the old history without an error, and purges it.
The status names where each part lives, which suite covers each
acceptance test, the tests not exercised, and the known limits.
2026-09-19 08:54:21 -07:00
jcoffey-dev d9b36a3806 Undelete tests: restore the default upload settings on entry (UD-1)
Inside system_tests the quota suite leaves uploads with a 1-second
lifetime, so test 6's Sieve script upload could expire before its set
(BlobNotFound), intermittently. The suite now resets upload quota, count
and lifetime to their defaults first.
2026-09-19 08:54:21 -07:00
jcoffey-dev 2edb552b6b Monitoring: the fork's acceptance suite (MON-1 to MON-31)
system::monitoring::monitoring_tests covers the defaults, metric
sampling, the Prometheus gauges, trace history over SMTP and LMTP
(probe sessions skipped, no raw I/O), trace destroy, indexTelemetry off,
live metrics and live tracing with their tokens and stream limit,
alerts by event and by email, and tenant admins refused. Also removes a
leftover enterprise-only attribute on the Prometheus counters loop,
which would have dropped counters from an enterprise-feature build.
2026-09-19 08:43:45 -07:00
jcoffey-dev 49e3733428 Monitoring: alerts over metrics, by event and by email (MON-25 to MON-30)
Every minute the node that calculates metrics evaluates each enabled
x:Alert, read from the registry, with metric('name') or the underscore
name. An alert fires when its condition turns true: it emits
telemetry.alert-event with the rendered message, and queues one email per
recipient through the outbound queue, placeholders filled and
Auto-Submitted set. An alert naming an unknown metric is refused on save.
The shared alerts suite runs; every telemetry suite is un-gated.
2026-09-19 08:39:17 -07:00
jcoffey-dev 7b6959155b Monitoring: live tracing and live metrics streams, and their tokens (MON-20 to MON-24)
GET /api/token/tracing and /api/token/metrics issue a 60-second token for
holders of liveTracing or liveMetrics outside a tenant. /api/live/tracing
streams event: trace frames of x:TraceEvents, never raw I/O, filtered by
text or by key, with a ping while idle; /api/live/metrics streams event:
metrics frames of current totals every interval. At most eight streams run
per node, each for 30 minutes. Upstream's documented paths are aliases.
2026-09-19 08:34:51 -07:00
jcoffey-dev 143a10fcf0 Monitoring decision: a live token may be reused within its 60 seconds, since INBUXA Admin reconnects with the same token (MON-23) 2026-09-19 08:34:03 -07:00
jcoffey-dev fc2d4f237f Monitoring: trace history, trace search, and x:Trace over the stored traces (MON-10 to MON-17, MON-32)
A lossy collector subscriber keeps each inbound SMTP session that reached
MAIL FROM and each delivery attempt, info level and above and never raw
I/O, at most 1,000 events with strings cut at 4 KiB, and writes it when
the span closes as an x:Trace under the telemetry key class, scheduling
its indexing. The index task builds a document of event types, queue ids
and keywords when indexTelemetry is on. x:Trace/get derives timestamp,
from, to and size; /query filters by opening event, text, queueId and
time; /set destroys only. The data purge honours holdTracesFor. The shared
tracing and webhook suites run.
2026-09-19 08:31:04 -07:00
jcoffey-dev 9f7035588f Monitoring: metric history, one edition of metrics, and x:Metric over the stored samples (MON-4 to MON-7, MON-9, MON-17, MON-39)
Every node writes a sample per metric on metricsCollectionInterval:
counters as the increase since its last sample, gauges always, histograms
as totals when changed. Samples are x:Metric in the registry's encoding
under the telemetry key class, ids time-ordered. x:Metric/get and /query
read them with metric and timestamp filters and full paging, hide what's
past holdMetricsFor, and the data purge deletes it. The is_enterprise
split is gone, so every gauge and histogram is collected and exported, and
queue.count is set from the queue itself. The shared metrics suite runs.
2026-09-19 08:21:45 -07:00
jcoffey-dev 715f219528 Monitoring: correct the storage decision; history uses the AGPL telemetry key classes with the registry's encoding, and INBUXA's old history can't be read 2026-09-19 08:12:11 -07:00
jcoffey-dev 3ea242f7fb Monitoring decisions before implementation: live streams and stored histograms match INBUXA Admin, and history is stored as x:Trace and x:Metric registry items (MON-4, MON-20, MON-22, MON-23) 2026-09-19 08:10:27 -07:00
jcoffey-dev 88090b3ea6 AI spam classification: calibration results for commercially usable models; Qwen3 4B Instruct 2507 recommended, 4 vCPU minimum 2026-09-19 07:42:12 -07:00
jcoffey-dev 2e6c234152 AI spam classification: default to 2 KiB of text and a +2.0 cap on the model's tag, as calibrated for low-end CPU-only instances
The calibration harness (tests/src/system/ai_calibration.rs, ignored) sends
what the classifier sends to a real local model and scores its answers with
the classifier's parser.
2026-09-19 06:21:08 -07:00
jcoffey-dev 554cc4fcd2 AI spam classification decisions from calibration: maxContentBytes 2048 and spamMaxAdded 2.0 by default, for low-end CPU-only instances 2026-09-19 06:19:38 -07:00
jcoffey-dev 9490fc4677 AI spam classification: the model's opinion as one bounded spam signal, and the llm_prompt Sieve function (AI-1 to AI-28)
The classifier sends only the subject and text, between unforgeable markers
after the operator's prompt, to an OpenAI-compatible endpoint the operator
configured; nothing is preset. Its answer maps to an LLM_ tag whose score is
clamped (+5.0, -1.0 by default) and can never discard or reject on its own;
X-Spam-LLM is sanitized, encoded and folded, and a planted one is removed.
Failures, timeouts past the ceiling, a full slot or a paused model leave
mail flowing untagged. llm_prompt answers trusted scripts, and accounts
holding interactAi within an hourly limit. Redirects aren't followed and no
content or secret is logged. The limits live in inbuxa:AiLimits.
Acceptance tests 1 and 3 to 21; test 2 as the re-enabled shared llm case,
whose setup no longer waits on a rules file from a developer's own path;
test 22 written as the ignored ai_compat.
2026-09-19 00:41:00 -07:00
jcoffey-dev cba48cf03b AI spam classification decision before implementation: the limits singleton is inbuxa:AiLimits under urn:inbuxa:jmap (open question 10) 2026-09-18 23:50:33 -07:00
jcoffey-dev e844878ba7 Tests: stabilize the OIDC suite with five times upstream's token lifetimes
Expiry counts whole seconds, so a 1s access token, code or device code could
lapse before a debug build's next request: 4 of 8 baseline runs failed at
six different points. Lifetimes are now 5s (tokens, codes), 15s (refresh)
and 10s (renewal), with the expiry checks' sleeps scaled to match. OIDC then
passed in 12 of 12 runs.
2026-09-18 22:49:27 -07:00
jcoffey-dev 725fbe7ec7 Tests: drop an unused undelete test helper 2026-09-18 22:27:19 -07:00
jcoffey-dev 0bc6b03dcd Branding and templates: per-domain, tenant and server logos, /logo, operator calendar email templates and RSVP page (BT-1 to BT-26)
Logos resolve domain, then tenant, then server-wide, then the built-in, with
subdomains finding their domain. GET /logo serves a data-URL image, redirects
to a URL logo without fetching it, sandboxes SVG, and answers 404 when no
custom logo applies. Emails embed the first PNG, JPEG or GIF logo. Logo and
template writes are checked; stored templates are read at send time, always
escaped, and fall back to the built-in with a build warning when they don't
parse. The RSVP page is served byte for byte with a CSP and no-referrer. The
sign-in and RSVP pages load the logo through an image element. MT-22's
session logo follows the chain to the server-wide logo.
Acceptance tests 1 to 17; test 18 written as the ignored branding_compat.
2026-09-18 22:27:19 -07:00
jcoffey-dev ecbdfd533b Undelete: deleted accounts are kept for their period, hold their addresses, and are restored or destroyed through inbuxa:DeletedAccount (UD-15 to UD-17a)
With archiveDeletedAccountsFor set, a destroyed account's record is kept in
the fork subspace with its id, its DestroyAccount task is due at the end of
the period, and its shares are suspended both ways. Its addresses can't be
taken by new accounts, aliases, lists or masks. inbuxa:DeletedAccount/get
lists kept accounts to server and tenant administrators; /set restores one
with a new password (same id, task cancelled, shares reinstated) or destroys
it now. The destroy task also clears undelete's own records.
Acceptance test 14; test 16 written as the ignored undelete_compat.
2026-09-18 21:35:48 -07:00
jcoffey-dev 0a2c29e8fd Undelete decision before implementation: a kept account's shares are suspended both ways and reinstated on restore (UD-17a) 2026-09-18 21:19:09 -07:00
jcoffey-dev 5b963b06c6 Undelete: deleted files, calendar events, contacts and Sieve scripts are kept and restored where they were (UD-1, UD-8 to UD-11)
Files, events and contacts are noted when deleted for good and archived by
the unindex task when retention is on; Sieve scripts are archived at
deletion. A restore goes back to its folder, calendar or address book if it
still exists, takes a free " (restored)" name, comes back inactive for
scripts, and is refused over quota with the item left archived.
Acceptance tests 6, 8 and 12.
2026-09-18 21:07:27 -07:00
jcoffey-dev 4a631bd0b5 Undelete: deleted email is kept, restored where it was, and managed over x:ArchivedItem (UD-1 to UD-14 for email)
Every way of deleting mail for good (JMAP, IMAP expunge, POP3, Trash
emptying, mailbox removal) notes the message's mailboxes and keywords while
archiving is on, fixing its deadline then; when its data is finally removed
it becomes an x:ArchivedItem record, written as upstream writes them, with
its copy held until the deadline. Retention is read at deletion time, so a
change applies at once. Restore puts a message back in the mailboxes it was
in (Trash only if that was all), with its keywords, and removes the record;
over quota it stays archived. x:ArchivedItem/get returns status and
accountId; query filters on type, archivedAt and text; set requests a
restore once or destroys; /changes is a fork addition. Expired items go in
the data purge. The shared account-access rule moves to jmap::inbuxa::access.
system_tests now calls undelete::test, and the archiving gate is gone.
2026-09-18 20:05:56 -07:00
jcoffey-dev a1ce14b76b Undelete decisions before implementation: restore data, kept accounts, the restore API (UD-4, UD-15a, UD-17) 2026-09-18 19:49:34 -07:00
jcoffey-dev ec4d668bc4 Multi-tenancy: impersonate has no effect inside a tenant (MT-1, MT-15)
The ceiling always disables impersonate for principals in a tenant, so no
tenant setting or grant lets them reach accounts beyond it.
2026-09-18 18:51:42 -07:00
jcoffey-dev ac2232c98d Multi-tenancy decision before implementation: impersonate has no effect inside a tenant (MT-1, MT-15) 2026-09-18 18:49:10 -07:00
jcoffey-dev 4a9aa9c548 Masked email: rewrite to the owner at RCPT TO, create responses carry the address, admins query all masks (ME-4, ME-9, ME-13, ME-19)
Found by running system_tests, which masked email no longer stops:
- rcpt_resolve rewrites a live mask to its owner's address, so
  Delivered-To names the account; delivery recognizes the mask from the
  original recipient when it belongs to that account.
- x:MaskedEmail/set create responses carry the server-set email.
- x:MaskedEmail/query returns every mask to a server-level impersonate
  holder, and filters on accountId.
- The refusal for an unlinked emailDomain uses upstream's wording.
- The shared delivery test checks the fork's address format (ME-13).
- The masked email test's tenant domain uses manual DKIM, so its cleanup
  leaves nothing behind.
2026-09-18 18:29:19 -07:00
jcoffey-dev f58aea000f Cargo.lock: inbuxa-features for the tests crate (masked email) 2026-09-18 16:56:29 -07:00
jcoffey-dev 31b8ca8ed5 Masked email built: implementation status, deferral and known limits (ME-1 to ME-19) 2026-09-18 16:40:14 -07:00
jcoffey-dev d319f013ec Masked email acceptance test 12 against a copy of INBUXA's data (compat) 2026-09-18 16:37:53 -07:00
jcoffey-dev f07c00fffb Masked email acceptance tests 1 to 11, with ME-9, ME-10, ME-11 and /changes (ME-1 to ME-19)
tests/src/system/masked_email.rs runs from system_tests and alone as
masked_email_tests. Fastmail's MaskedEmail/set updates from the stored
object, so the registry's revision check holds.
2026-09-18 16:37:12 -07:00
jcoffey-dev d04aafd3d7 Masked email: Fastmail's Masked Email API, MaskedEmail/get and /set (ME-1, ME-7a, ME-16)
Advertised as https://www.fastmail.com/dev/maskedemail in the session and on
every account that may hold masks. Masks created through it start pending
unless the create sets a state; pending can't be set again once left; state
and the other mutable fields map onto the same records the x: API uses.
2026-09-18 16:29:47 -07:00
jcoffey-dev 7080028437 Masked email: x:MaskedEmail/changes and a state in /get (fork additions)
The fork's per-account change log answers /changes, collapsing a mask
created and destroyed in the window. /changes on a registry type needs that
type's get permission.
2026-09-18 16:25:54 -07:00
jcoffey-dev 53ccc8f4de Masked email: owners send as their live masks, over JMAP identities and SMTP submission (ME-11) 2026-09-18 16:23:49 -07:00
jcoffey-dev 60d1d8b84a Masked email: delivery through masks (ME-4, ME-5, ME-6, ME-7, ME-9, ME-10)
A live mask accepts mail at RCPT TO and delivers to its owner, with an
X-Masked-Email header naming it. A disabled mask files straight to Trash,
past the owner's Sieve script. Deleted and expired masks are refused like
unknown addresses, without being cached as unknown. Mail moves lastMessageAt
and turns a pending mask enabled. Sub-addresses on a mask work.
2026-09-18 16:22:50 -07:00
jcoffey-dev 3b052a57da Masked email: upstream's x:MaskedEmail API (ME-2, ME-3, ME-6a, ME-7a, ME-12 to ME-19)
x:MaskedEmail is no longer refused as unbuilt. Creates generate the address
on an allowed domain and check the prefix, maxMaskedAddresses and the create
rate; updates keep server-set fields; enabled reads and writes map to the
shared state; query filters on enabled, forDomain and text; a tenant
administrator reaches its tenant's accounts' masks.
2026-09-18 16:20:39 -07:00
jcoffey-dev aaca8fe537 Masked email: the fork's subspace and the masked_email module (ME-1, ME-2, ME-3, ME-6a, ME-7, ME-8, ME-13, ME-14, ME-15)
Subspace _ holds the fork's own data, with its own SQL table and RocksDB
column family, and is part of backup. The masked_email module keeps each
mask's state, last mail and pending deadline beside upstream's record, an
index from address to mask with tombstones, and a per-account change log;
and generates addresses in the fork's format.
2026-09-18 16:17:47 -07:00
jcoffey-dev 7ef2cdc273 Masked email: the fork's subspace is _, not X, which SQL backends fold into upstream's x 2026-09-18 16:10:50 -07:00
jcoffey-dev edfb357cb1 Masked email decisions before implementation: storage, start state, rate config, who administers (ME-7a, ME-15, ME-19) 2026-09-18 16:09:57 -07:00
jcoffey-dev e109cf86ae The session test expects urn:inbuxa:jmap (MT-22, contract C-1) 2026-09-18 16:00:25 -07:00
jcoffey-dev 2e43f5fc0d Multi-tenancy built: implementation status, deferrals and known limits (MT-1 to MT-23) 2026-09-18 15:38:29 -07:00
jcoffey-dev dd584dc9ce Multi-tenancy acceptance tests 1 to 14, and test 15 against a copy of INBUXA's data (MT-1 to MT-23)
tests/src/system/tenant.rs is new, and system_tests calls it again without
the pending-rebuild gate. tenant_tests runs it alone; tenant_compat is test 15,
ignored until a copy of INBUXA's data is provided.
2026-09-18 15:34:21 -07:00
jcoffey-dev 11d780b3d3 Copyright of the fork's own files is Coffey Labs's 2026-09-18 15:30:27 -07:00
jcoffey-dev 05ee6ac2be Multi-tenancy: a new domain's generated DKIM keys count against maxDkimKeys (MT-17) 2026-09-18 15:28:59 -07:00
jcoffey-dev 2cbecac415 Multi-tenancy decision before implementation: generated DKIM keys count against maxDkimKeys (MT-17) 2026-09-18 15:28:24 -07:00
jcoffey-dev bcf4a49325 Multi-tenancy: sharing grants stay within the owner's tenant (MT-1, MT-3)
JMAP shareWith refuses a grantee outside the owner's tenant, or missing, with
invalidForeignKey naming the account. WebDAV ACL answers AllowedPrincipal and
IMAP SETACL answers as for an unknown account.
2026-09-18 15:20:04 -07:00
jcoffey-dev cea8b1593b Multi-tenancy: the applicable logo in the JMAP session (MT-22, MT-23)
The session's own account carries urn:inbuxa:jmap with logo: its domain's
logo, else its tenant's, as stored. The session lists urn:inbuxa:jmap as a
server capability too.
2026-09-18 15:20:04 -07:00
jcoffey-dev 406aa05dc0 Multi-tenancy: recalculateQuota and resetTenantQuotas (MT-21) 2026-09-18 15:20:04 -07:00
jcoffey-dev 3896f720a1 Multi-tenancy: queue visibility (MT-5)
A tenant sees queued mail with a recipient on its domains, and mail its own
authenticated senders sent from them, in get, query, update and destroy.
2026-09-18 15:20:04 -07:00
jcoffey-dev 5a22e79992 Multi-tenancy: registry reach, links, membership and count limits over JMAP (MT-2, MT-3, MT-6, MT-7, MT-8, MT-11, MT-12, MT-17, MT-18)
Inside a tenant, server-level object types are forbidden and x:Tenant reads
return only the caller's own tenant, which it can't change. Registry writes
refuse links across tenant boundaries in both directions, give a new
principal its domain's tenant, move a domain's principals and DKIM keys with
it into a tenant, refuse moves out of a tenant while its people remain, and
refuse creates past a tenant's count limits with overQuota and
limit.tenant-quota.
2026-09-18 15:20:04 -07:00
jcoffey-dev ad0db8b2b0 Multi-tenancy: the inbuxa-features crate, the permission ceiling and tenant disk quota (MT-12, MT-13, MT-14, MT-15, MT-16, MT-19, MT-20)
New crate crates/features (inbuxa-features), AGPL-3.0-only, holding the
tenancy rules. Hooks in common: a tenant's roles and permission lists cap
its people's permissions; a change to a tenant, or to a role a tenant holds,
drops its members' cached permissions; delivery and every other write check
the tenant's maxDiskQuota; usedDiskQuota reads the tenant usage counter.
The default Tenant Administrator role gains sysTenantGet and sysTenantQuery.
2026-09-18 15:19:58 -07:00
jcoffey-dev b6b345a129 Multi-tenancy decision before implementation: sharing grants across tenants (MT-3) 2026-09-18 15:17:03 -07:00
jcoffey-dev d0d4ac4317 Open decisions: the Enterprise License text stays until the headers are cleaned up 2026-09-18 14:52:45 -07:00
jcoffey-dev a1254cd3c4 Multi-tenancy decision before implementation: what moves with a domain (MT-8, MT-17) 2026-09-18 14:50:57 -07:00
jcoffey-dev edc6a8ccbd Multi-tenancy decisions before implementation: MT-3, MT-7, MT-12, MT-19a, MT-22
Test 3 expects invalidForeignKey, as MT-3 says. A tenant admin reads its own
tenant through sysTenantGet and sysTenantQuery, added to the default Tenant
Administrator role for new installs only. The server adds nothing for MT-7's
dashboard list. The MT-19a submission warning is deferred. MT-22's logo is
the urn:inbuxa:jmap account capability's logo field (contract C-1).
2026-09-18 14:48:22 -07:00
jcoffey-dev cf59d5183a Branding: INBUXA's last two logo values removed 2026-09-18 14:45:38 -07:00
jcoffey-dev 8c1879e853 Cross-origin requests only from the front ends' origins (contract C-14) 2026-09-18 13:44:55 -07:00
jcoffey-dev a45e0ef8b1 Feature specs 4-9: observations from INBUXA's live server 2026-09-18 13:42:00 -07:00
jcoffey-dev cd7c1a6d4c Feature specs 4-9: branding and templates, AI spam classification, monitoring, SCIM, scale-out storage, per-domain directories 2026-09-18 13:18:39 -07:00
jcoffey-dev 31e64f5a4d Rebrand the packaged schema: defaults, labels and descriptions; the Enterprise page becomes Branding; log prefix inbuxa.log 2026-09-18 13:15:17 -07:00
jcoffey-dev 05d220ae4f No web interface on the mail host: first boot installs and downloads none (SPEC §5.3) 2026-09-18 13:10:32 -07:00
jcoffey-dev c2be7e2956 Multi-tenancy implementation hand-off brief 2026-09-18 13:06:47 -07:00
jcoffey-dev d6fc4600cd OAuth: registration required by default; first-party clients registered on every start (contract C-5, C-6) 2026-09-18 13:04:20 -07:00
jcoffey-dev f82f15d863 Contract: note the browser sign-in check after the OAuth fix 2026-09-18 12:53:59 -07:00
jcoffey-dev ad3322183a Contract: record the OAuth registration fix applied on production 2026-09-18 12:40:33 -07:00
jcoffey-dev 2e2eb76301 Contract: C-7 matches C-5 (third-party OAuth apps are admin-registered unless open registration is chosen) 2026-09-18 12:31:18 -07:00
jcoffey-dev 559bb3d1a6 Contract: INBUXA production OAuth findings; C-5 also turns anonymous client registration off 2026-09-18 12:31:07 -07:00
jcoffey-dev 0ed540f43a Contract spec: inbuxa-server, ihasmail-inbuxa and INBUXA Admin
Discovery and a contract version in the session; front ends configured once
(x:FrontEnds); OAuth with required registration, first-party clients,
server-hosted sign-in and consent for everything else; per-grant revocation;
cross-origin limited to the front ends; an admin lane by scope; push
unchanged. Records what upstream does today, including that it accepts any
client and redirect URI by default, and the phishing that allows.
2026-09-18 12:19:47 -07:00
jcoffey-dev b2de803680 Spec: public ihasmail stays Stalwart-facing; INBUXA work goes to an INBUXA fork of ihasmail 2026-09-18 12:09:12 -07:00
jcoffey-dev faedf7a1da Versioning: INBUXA's own dated version, with the Stalwart base shown
inbuxa --version, the banner, startup events, OpenTelemetry and the JMAP
implementation string read "2026.9.18 (Stalwart 0.16.22)". The base comes
from Cargo, which keeps following upstream so version bumps merge cleanly.
Received headers and IMAP ID carry the INBUXA version.
2026-09-18 12:08:41 -07:00
jcoffey-dev bde11d54c3 Spec: two web front ends, ihasmail and INBUXA Admin (a fork of Stalwart WebUI)
Administration is no longer moving wholesale into ihasmail. INBUXA Admin, a
fork of the schema-driven webui (no Enterprise-only code, ordinary fork),
covers the whole server, setup and recovery from its own deployment.
ihasmail keeps its account and tenant administration. Contract additions:
the inbuxa-admin OAuth client, cross-origin access limited to the two
front ends (the server currently allows every origin), and relative OAuth
endpoints on servers with no public URL.
2026-09-18 12:01:36 -07:00
jcoffey-dev c9c761fab5 Quick fixes from the first boot: recovery admin, upsell, warnings
- The recovery administrator (INBUXA_RECOVERY_ADMIN, or STALWART_RECOVERY_ADMIN)
  is honored only in bootstrap and recovery mode. On a configured server it's
  ignored with a startup warning. Before, it was a standing full-admin login
  for as long as the variable stayed set.
- The Enterprise upsell error is replaced by "This feature isn't available in
  INBUXA yet" for the features still to be rebuilt.
- Workspace warnings: 25 to 0. cargo fix removed the unused imports. The
  seven places where Enterprise code used to plug in keep their parameters,
  each with an inbuxa: comment naming the rebuild that uses it again. The
  antispam test's mock-server imports are back behind pending-rebuild.
2026-09-18 11:36:44 -07:00
jcoffey-dev d3f0b36dd2 Packaging: the binary and package are inbuxa, INBUXA_* settings with STALWART_* fallback
- crates/main: package and [[bin]] renamed to inbuxa; homepage inbuxa.org;
  license AGPL-3.0-only (upstream is dual; the fork takes the AGPL).
- types::branding::env_var reads INBUXA_<name>, falling back to
  STALWART_<name> with a warning, for all nine server settings.
  STALWART_APP_ and STALWART_SPAM_* storage keys are unchanged.
- New-install default paths /var/lib/inbuxa and /var/log/inbuxa.
- Dockerfiles, systemd unit, launchd plist and AppArmor profile renamed.
- Upstream's .github moved to .github-upstream so none of it runs.
- install.sh stubbed: upstream's would install Stalwart.
- Two missed brand strings: the SMTP Received header and the utils user agent.
2026-09-18 11:09:22 -07:00
jcoffey-dev 5ce033e10c Support URL: https://inbuxa.org in the IMAP ID response and README 2026-09-18 11:03:30 -07:00
jcoffey-dev 189f688768 Sign-in page: show the logo when the page is opened without redirect_uri
The early return for a missing redirect_uri came before the logo loader,
so the logo stayed hidden behind data-loading. An upstream bug, surfaced by
the rebrand check.
2026-09-18 10:58:39 -07:00
jcoffey-dev 68523374d3 Strip report for the v0.16.22 re-import 2026-09-18 10:52:56 -07:00
jcoffey-dev 8ca487ed36 Merge upstream v0.16.22 re-import (build-script feature flags) 2026-09-18 10:52:56 -07:00
jcoffey-dev 1c6640e4b3 Strip tooling: remove the enterprise feature from build scripts' --features lists
Upstream's Dockerfiles and CI pass --features "... enterprise" on the cargo
command line, which the manifest edits don't reach. 11 occurrences at
v0.16.22. Dockerfile.build and Dockerfile.fdb are now scanned too, whatever
their extension.
2026-09-18 10:52:34 -07:00
jcoffey-dev 89665decfa Rebrand to INBUXA: product name, protocol greetings, sign-in page, calendar templates, logos, README
One branding module (types::brand!) supplies the name to every protocol
greeting, the HTTP realm, the startup banner, Received headers, the
user agent, the IMAP ID response and calendar PRODIDs. The sign-in and
calendar pages take ihasmail's palette and font and the INBUXA logo, and
calendar emails embed the INBUXA lockup. Default calendar and address book
names follow. Protocol identifiers (urn:stalwart:jmap, vnd.stalwart Sieve
extensions) and upstream copyright notices are unchanged.
2026-09-18 10:52:34 -07:00
jcoffey-dev 6e73d284d8 First boot of the stripped v0.16.22: record results and what to fix 2026-09-18 10:33:42 -07:00
jcoffey-dev 0db5ab9155 Gate integration tests of unrebuilt Enterprise features behind pending-rebuild; record what v0.16.22 proved 2026-09-18 10:29:20 -07:00
jcoffey-dev 0c6b3dffc0 Strip report for v0.16.22; document the layout and the upstream workflows 2026-09-18 10:22:19 -07:00
jcoffey-dev ace3064fbb Merge upstream v0.16.22 (stripped) into main 2026-09-18 10:22:18 -07:00
jcoffey-dev dedcc0fe94 Move the specs under docs/spec, ahead of the source import 2026-09-18 10:21:43 -07:00
jcoffey-dev 826e8bc97f Strip tooling: remove dangling mod declarations left by ossify.py 2026-09-18 10:20:18 -07:00
jcoffey-dev e79036d097 Strip tooling: wrap upstream's ossify.py with export, marker checks, Cargo edits and verification 2026-09-18 10:11:57 -07:00
jcoffey-dev 81db1433d2 Undelete: record what INBUXA's Enterprise server actually does 2026-09-18 10:02:59 -07:00
jcoffey-dev e73744e30b Undelete feature spec 2026-09-18 09:42:38 -07:00
jcoffey-dev f9c19a153b Masked email: INBUXA most likely holds no masks 2026-09-18 09:39:58 -07:00
jcoffey-dev 4e3a147de1 Masked email: record the operator's estimate of existing masks 2026-09-18 09:39:31 -07:00
jcoffey-dev 057491e33c Masked email: acceptance test 8 matches the observed error 2026-09-18 09:32:03 -07:00
jcoffey-dev dc1ddb2893 Masked email: record what INBUXA's Enterprise server actually does 2026-09-18 09:31:56 -07:00
jcoffey-dev 0fbf6b7d47 Masked email feature spec 2026-09-18 08:16:26 -07:00
jcoffey-dev c984cb7afa Point provenance at the Observed section 2026-09-18 07:03:56 -07:00
jcoffey-dev 490d11b04a Multi-tenancy: record what INBUXA's Enterprise server actually does 2026-09-18 07:03:50 -07:00
jcoffey-dev c6b3674ccd Multi-tenancy feature spec; use upstream's ossify.py and UI schema 2026-09-18 00:36:30 -07:00
jcoffey-dev 68163e9dbd Name the project inbuxa-server 2026-09-18 00:33:07 -07:00
jcoffey-dev 6b069f740a Draft the fork specification 2026-09-18 00:30:59 -07:00
532 changed files with 70130 additions and 3176 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
+19
View File
@@ -0,0 +1,19 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "cargo" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
# Enable version updates for GitHub Actions
- package-ecosystem: "github-actions"
# Workflow files stored in the default location of `.github/workflows`
# You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.
directory: "/"
schedule:
interval: "weekly"
+567
View File
@@ -0,0 +1,567 @@
name: "CI"
on:
workflow_dispatch:
inputs:
Docker:
required: false
default: false
type: boolean
Release:
required: false
default: false
type: boolean
push:
tags: ["v*.*.*"]
env:
SCCACHE_GHA_ENABLED: true
RUSTC_WRAPPER: sccache
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
CARGO_NET_GIT_FETCH_WITH_CLI: true
AWS_LC_SYS_PREBUILT_NASM: 1
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
multiarch:
strategy:
fail-fast: false
matrix:
include:
- variant: gnu
- variant: musl
name: Merge image / ${{matrix.variant}}
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
attestations: write
packages: write
needs: [linux]
if: github.event_name == 'push' || inputs.Docker
steps:
- name: Install Cosign
uses: sigstore/[email protected]
- name: Log In to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{github.token}}
- name: Log In to DockerHub
uses: docker/login-action@v4
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_TOKEN}}
- name: Download ${{matrix.variant}} meta bake definition
uses: actions/download-artifact@v8
with:
name: bake-meta-${{matrix.variant}}
path: ${{ runner.temp }}/${{matrix.variant}}
- name: Download ${{matrix.variant}} digests
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/${{matrix.variant}}/digests
pattern: digests-${{matrix.variant}}-*
merge-multiple: true
- name: Create ${{matrix.variant}} manifest list and push
working-directory: ${{ runner.temp }}/${{matrix.variant}}/digests
run: |
docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \
$(printf 'ghcr.io/${{github.repository}}@sha256:%s ' *)
docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \
$(printf 'index.docker.io/${{github.repository}}@sha256:%s ' *)
- name: Inspect ${{matrix.variant}} image
id: manifest-digest
run: |
docker buildx imagetools inspect --format '{{json .Manifest}}' ghcr.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > GHCR_DIGEST_SHA
echo "GHCR_DIGEST_SHA=$(cat GHCR_DIGEST_SHA)" | tee -a "${GITHUB_ENV}"
docker buildx imagetools inspect --format '{{json .Manifest}}' index.docker.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > DOCKERHUB_DIGEST_SHA
echo "DOCKERHUB_DIGEST_SHA=$(cat DOCKERHUB_DIGEST_SHA)" | tee -a "${GITHUB_ENV}"
cosign sign --yes $(jq --arg GHCR_DIGEST_SHA "$(cat GHCR_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | . + "@" + $GHCR_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json)
cosign sign --yes $(jq --arg DOCKERHUB_DIGEST_SHA "$(cat DOCKERHUB_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | . + "@" + $DOCKERHUB_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json)
- name: Attest GHCR
uses: actions/attest-build-provenance@v4
with:
subject-name: ghcr.io/${{github.repository}}
subject-digest: ${{ env.GHCR_DIGEST_SHA }}
push-to-registry: true
- name: Attest Dockerhub
uses: actions/attest-build-provenance@v4
with:
subject-name: index.docker.io/${{github.repository}}
subject-digest: ${{ env.DOCKERHUB_DIGEST_SHA }}
push-to-registry: true
linux:
permissions:
id-token: write
contents: write
attestations: write
packages: write
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
platform: linux/amd64
suffix: ""
build_env: ""
- target: x86_64-unknown-linux-musl
platform: linux/amd64
suffix: "-alpine"
build_env: ""
- target: aarch64-unknown-linux-gnu
platform: linux/arm64
suffix: ""
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
- target: aarch64-unknown-linux-musl
platform: linux/arm64
suffix: "-alpine"
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
- target: armv7-unknown-linux-gnueabihf
platform: linux/arm/v7
suffix: ""
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
- target: armv7-unknown-linux-musleabihf
platform: linux/arm/v7
suffix: "-alpine"
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
- target: arm-unknown-linux-gnueabihf
platform: linux/arm/v6
suffix: ""
build_env: ""
- target: arm-unknown-linux-musleabihf
platform: linux/arm/v6
suffix: "-alpine"
build_env: ""
name: Build / ${{matrix.target}}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Free disk space (heavy ARM targets)
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64')
run: |
df -h /mnt /
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/.ghcup /usr/local/share/powershell /usr/share/swift /opt/hostedtoolcache/CodeQL
sudo docker image prune --all --force || true
df -h /mnt /
- name: Add swap (heavy ARM targets)
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64')
run: |
mnt_avail=$(df --output=avail -k /mnt | tail -1)
if [ "$mnt_avail" -lt 18874368 ]; then
echo "Insufficient space on /mnt (${mnt_avail}K available), aborting swap setup"
exit 1
fi
sudo fallocate -l 16G /mnt/swapfile
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
sudo sysctl vm.swappiness=80
free -h
swapon --show
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: "arm64,arm"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["https://mirror.gcr.io"]
driver-opts: |
network=host
- name: Log In to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{github.token}}
- name: Log In to DockerHub
uses: docker/login-action@v4
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_TOKEN}}
- name: Calculate shasum of external deps
id: cal-dep-shasum
run: |
echo "checksum=$(yq -p toml -oy '.package[] | select((.source | contains("")) or (.checksum | contains("")))' Cargo.lock | sha256sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- name: Cache apt
uses: actions/[email protected]
id: apt-cache
with:
path: |
var-cache-apt
var-lib-apt
key: apt-cache-${{ hashFiles('Dockerfile.build') }}
- name: Cache Cargo
uses: actions/[email protected]
id: cargo-cache
with:
path: |
usr-local-cargo-registry
usr-local-cargo-git
key: cargo-cache-${{ steps.cal-dep-shasum.outputs.checksum }}
- name: Inject cache into docker
uses: reproducible-containers/[email protected]
with:
cache-map: |
{
"var-cache-apt": "/var/cache/apt",
"var-lib-apt": "/var/lib/apt",
"usr-local-cargo-registry": "/usr/local/cargo/registry",
"usr-local-cargo-git": "/usr/local/cargo/git"
}
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }} && ${{ steps.apt-cache.outputs.cache-hit }}
- name: Extract Metadata for Docker
uses: docker/metadata-action@v6
id: meta
with:
images: |
index.docker.io/${{github.repository}}
ghcr.io/${{github.repository}}
flavor: |
suffix=${{matrix.suffix}},onlatest=true
tags: |
type=ref,event=tag
type=ref,event=branch,prefix=branch-
type=edge,branch=main
type=semver,pattern=v{{major}}.{{minor}}
- name: Build Artifact
id: bake
uses: docker/bake-action@v7
env:
DOCKER_BUILD_RECORD_UPLOAD: false
TARGET: ${{matrix.target}}
GHCR_REPO: ghcr.io/${{github.repository}}
BUILD_ENV: ${{matrix.build_env}}
DOCKER_PLATFORM: ${{matrix.platform}}
SUFFIX: ${{matrix.suffix}}
with:
source: .
set: |
*.tags=
image.output=type=image,"name=ghcr.io/${{github.repository}},index.docker.io/${{github.repository}}",push-by-digest=true,name-canonical=true,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
files: |
docker-bake.hcl
${{ steps.meta.outputs.bake-file }}
targets: ${{(github.event_name == 'push' || inputs.Docker) && 'build,image' || 'build'}}
- name: Upload Artifacts
uses: actions/[email protected]
with:
name: artifact-${{matrix.target}}
path: |
artifact
!artifact/*.json
- name: Export digest & Rename meta bake definition file
if: github.event_name == 'push' || inputs.Docker
run: |
mv "${{ steps.meta.outputs.bake-file }}" "${{ runner.temp }}/bake-meta.json"
mkdir -p ${{ runner.temp }}/digests
digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
if: github.event_name == 'push' || inputs.Docker
uses: actions/[email protected]
with:
name: digests-${{matrix.suffix == '' && 'gnu' || 'musl'}}-${{ matrix.target }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
- name: Upload GNU meta bake definition
uses: actions/[email protected]
if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'gnu') && startsWith(matrix.target,'x86')
with:
name: bake-meta-gnu
path: ${{ runner.temp }}/bake-meta.json
if-no-files-found: error
retention-days: 1
- name: Upload musl meta bake definition
uses: actions/[email protected]
if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'musl') && startsWith(matrix.target,'x86')
with:
name: bake-meta-musl
path: ${{ runner.temp }}/bake-meta.json
if-no-files-found: error
retention-days: 1
windows:
name: Build / ${{matrix.target}}
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
# - target: aarch64-pc-windows-msvc
- target: x86_64-pc-windows-msvc
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Run sccache-cache
uses: mozilla-actions/[email protected]
with:
disable_annotations: true
- name: Build
run: |
rustup target add ${{matrix.target}}
cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
mkdir -p artifacts
mv ./target/${{matrix.target}}/release/stalwart.exe ./artifacts/stalwart.exe
- name: Upload Artifacts
uses: actions/[email protected]
with:
name: artifact-${{matrix.target}}
path: artifacts
macos:
name: Build / ${{matrix.target}}
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
- target: x86_64-apple-darwin
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Run sccache-cache
uses: mozilla-actions/[email protected]
with:
disable_annotations: true
#- name: Build FoundationDB Edition
# env:
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# run: |
# rustup target add ${{matrix.target}}
# # Pin FoundationDB 7.4.x (Apple publishes these as prereleases)
# curl --retry 5 -Lso foundationdb.pkg "$(gh api -X GET /repos/apple/foundationdb/releases --jq '[.[] | select(.tag_name | startswith("7.4."))] | sort_by(.tag_name | split(".") | map(tonumber)) | reverse | .[0].assets[] | select(.name | test("${{startsWith(matrix.target, 'x86') && 'x86_64' || 'arm64'}}" + ".pkg$")) | .browser_download_url')"
# echo "=== Package contents ==="
# pkgutil --payload-files foundationdb.pkg || true
# sudo installer -allowUntrusted -verbose -dumplog -pkg foundationdb.pkg -target /
# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "foundationdb s3 redis nats"
# mkdir -p artifacts
# mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart-foundationdb
- name: Build
run: |
rustup target add ${{matrix.target}}
cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
mkdir -p artifacts
mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart
- name: Upload Artifacts
uses: actions/[email protected]
with:
name: artifact-${{matrix.target}}
path: artifacts
freebsd:
name: Build / ${{matrix.target}}
runs-on: ubuntu-latest
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-freebsd
arch: x86_64
# - target: aarch64-unknown-freebsd
# arch: aarch64
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Build in FreeBSD VM
uses: vmactions/freebsd-vm@v1
with:
release: "15.1"
arch: ${{matrix.arch}}
usesh: true
mem: 14336
cpu: 4
sync: rsync
copyback: true
# gmake: required by jemalloc-sys on BSD hosts
# llvm: provides libclang for bindgen (librocksdb-sys)
# rust: libsqlite3-sys 0.38 uses cfg_select!, stabilized in Rust
# 1.95. The default 'quarterly' pkg repo still ships rust 1.94, so
# switch to the 'latest' repo (currently 1.96.1). rustup is not an
# option here: aarch64-unknown-freebsd has no rustup toolchains yet.
prepare: |
set -e
mkdir -p /usr/local/etc/pkg/repos
echo 'FreeBSD: { url: "pkg+https://pkg.freebsd.org/${ABI}/latest", mirror_type: "srv" }' > /usr/local/etc/pkg/repos/FreeBSD.conf
pkg update -f
env ASSUME_ALWAYS_YES=yes pkg bootstrap -f
pkg update -f
pkg install -y rust gmake llvm rocksdb
rustc --version
run: |
set -e
export CARGO_TARGET_DIR=/tmp/target
export CARGO_TERM_COLOR=always
export CARGO_NET_RETRY=10
cargo build --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
mkdir -p artifacts
cp /tmp/target/release/stalwart artifacts/stalwart
- name: Upload Artifacts
uses: actions/[email protected]
with:
name: artifact-${{matrix.target}}
path: artifacts
release:
name: Release
permissions:
id-token: write
contents: write
attestations: write
if: github.event_name == 'push' || inputs.Release
needs: [linux, windows, macos, freebsd]
runs-on: ubuntu-latest
steps:
# Must run before artifacts are downloaded — checkout cleans the workspace.
- name: Checkout (for CHANGELOG)
if: startsWith(github.ref, 'refs/tags/')
uses: actions/checkout@v7
- name: Download Artifacts
uses: actions/download-artifact@v8
with:
path: archive
pattern: artifact-*
- name: Compress
run: |
set -eux
BASE_DIR="$(pwd)/archive"
compress_files() {
local dir="$1"
local archive_dir_name="${dir#artifact-}"
cd "$dir"
# Process each file in the directory
for file in `ls`; do
filename="${file%.*}"
extension="${file##*.}"
if [ "$extension" = "exe" ]; then
7z a -tzip "${filename}-${archive_dir_name}.zip" "$file" > /dev/null
else
tar -czf "${filename}-${archive_dir_name}.tar.gz" "$file"
fi
done
cd $BASE_DIR
}
cd $BASE_DIR
for arch_dir in `ls`; do
dir_name=$(basename "$arch_dir")
compress_files "$dir_name"
done
- name: Attest binary
id: attest
uses: actions/attest-build-provenance@v4
with:
subject-path: |
archive/**/*.tar.gz
archive/**/*.zip
- name: Use cosign to sign existing artifacts
uses: sigstore/[email protected]
with:
inputs: |
archive/**/*.tar.gz
archive/**/*.zip
- name: Build release body
run: |
if [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then
awk '/^## \[/{c++} c==1' CHANGELOG.md > release_body.md
echo "" >> release_body.md
else
: > release_body.md
fi
cat >> release_body.md <<EOF
<hr />
### Check binary attestation [here](${{ steps.attest.outputs.attestation-url }})
EOF
- name: Release
uses: softprops/action-gh-release@v3
with:
files: |
archive/**/*.tar.gz
archive/**/*.zip
archive/**/*.sigstore.json
prerelease: ${{!startsWith(github.ref, 'refs/tags/') || null}}
tag_name: ${{!startsWith(github.ref, 'refs/tags/') && 'nightly' || null}}
# Tag-push releases are created as drafts; the `publish` job un-drafts
# them only after all build jobs succeed, so watcher notifications
# don't fire on broken builds.
draft: ${{ startsWith(github.ref, 'refs/tags/') || null }}
body_path: release_body.md
publish:
name: Publish release
needs: [linux, windows, macos, freebsd, multiarch, release]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Un-draft release
env:
GH_TOKEN: ${{ github.token }}
run: gh release edit "${{ github.ref_name }}" --draft=false --latest --repo "${{ github.repository }}"
cleanup:
name: Cleanup failed release
needs: [linux, windows, macos, freebsd, multiarch, release]
if: failure() && startsWith(github.ref, 'refs/tags/') && github.run_attempt >= 3
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Delete draft release and tag
env:
GH_TOKEN: ${{ github.token }}
run: gh release delete "${{ github.ref_name }}" --yes --cleanup-tag --repo "${{ github.repository }}" || true
+4
View File
@@ -0,0 +1,4 @@
# Funding platforms shown behind the repository's Sponsor button.
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: jcoffey-dev
+43
View File
@@ -0,0 +1,43 @@
<!--
Thanks for contributing to INBUXA. CONTRIBUTING.md has the full guide; this
is the short version. Delete any section that does not apply.
-->
## Summary
<!-- What changes, and why. The why is the part that is hard to recover later. -->
## Related issues
<!-- e.g. Closes #123. Leave blank if there are none. -->
## Upstream files
<!--
Does this touch files that came from Stalwart? If so: is the change as small
as it can be, and is it marked with an `inbuxa:` comment saying which
requirement it serves? Every edit to an upstream file is a conflict waiting
at the next import, so it should be worth one.
-->
## Clean room
<!--
Only for changes to the rebuilt features in `crates/features`, or to the
hooks that serve them.
Confirm one:
- [ ] I have not read Stalwart's Enterprise-licensed source, and worked from
the specification in `docs/spec/features/`.
- [ ] I have read it. (Say so -- the change will be reviewed with that in
mind, or declined for the parts it touches. The project's claim of
independent creation is a record, and the record has to be true.)
-->
## Testing
<!--
What you ran. `cargo test -p tests` covers what needs nothing but a store;
say so if you ran any of the `#[ignore]`d suites from
docs/spec/container-tests.md, and which.
-->
+38 -15
View File
@@ -1,19 +1,42 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2 version: 2
updates: updates:
- package-ecosystem: "cargo" # See documentation for possible values # Cargo. One entry: the workspace has a single lockfile at the root, and
directory: "/" # Location of package manifests # ~30 manifests that upstream bumps on every release -- pointing entries at
schedule: # individual crates would find manifests with no lockfile beside them.
interval: "weekly" #
# Minor and patch arrive as one pull request a week. Majors are left out of
# Enable version updates for GitHub Actions # the group on purpose: they are migrations rather than bumps, and each one
- package-ecosystem: "github-actions" # deserves its own pull request and its own CI run.
# Workflow files stored in the default location of `.github/workflows` - package-ecosystem: cargo
# You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
open-pull-requests-limit: 5
groups:
minor-and-patch:
update-types:
- minor
- patch
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
groups:
actions:
patterns:
- "*"
# The Dockerfiles pin their base images, so this is what keeps a published
# image off a stale base between releases.
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
day: tuesday
time: "09:00"
timezone: Etc/UTC
+42 -558
View File
@@ -1,567 +1,51 @@
name: "CI" # What CI can check without a mail server's worth of infrastructure.
#
# The build, and that every test target compiles. It deliberately does not
# *run* the test suites: the unit tests only build with the integration crate
# in the graph, because that is what switches on the `test_mode` features they
# rely on (docs/spec/SPEC.md 2.2b), and the integration suites need a `STORE`,
# fixed ports, and in most cases a container apiece (docs/spec/
# container-tests.md). Running them here would mean either a green tick that
# skipped everything, or a red one that means "the runner has no Redis".
#
# So this catches what it can honestly catch -- code that does not compile,
# including test code -- and the suites are run by hand, one at a time, as
# that page describes. If that changes, it changes because someone made the
# suites runnable unattended, not because CI started ignoring failures.
name: CI
on: on:
workflow_dispatch:
inputs:
Docker:
required: false
default: false
type: boolean
Release:
required: false
default: false
type: boolean
push: push:
tags: ["v*.*.*"] branches: [main]
pull_request:
env: # Lets CI be run by hand against any ref, including one that predates a CI
SCCACHE_GHA_ENABLED: true # change, without pushing an empty commit to move it.
RUSTC_WRAPPER: sccache workflow_dispatch:
CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
CARGO_NET_GIT_FETCH_WITH_CLI: true
AWS_LC_SYS_PREBUILT_NASM: 1
# A second push to a branch cancels the run still going for the first: the
# older run's answer is about code nobody is looking at any more.
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
multiarch: build:
strategy:
fail-fast: false
matrix:
include:
- variant: gnu
- variant: musl
name: Merge image / ${{matrix.variant}}
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
attestations: write
packages: write
needs: [linux]
if: github.event_name == 'push' || inputs.Docker
steps:
- name: Install Cosign
uses: sigstore/[email protected]
- name: Log In to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{github.token}}
- name: Log In to DockerHub
uses: docker/login-action@v4
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_TOKEN}}
- name: Download ${{matrix.variant}} meta bake definition
uses: actions/download-artifact@v8
with:
name: bake-meta-${{matrix.variant}}
path: ${{ runner.temp }}/${{matrix.variant}}
- name: Download ${{matrix.variant}} digests
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/${{matrix.variant}}/digests
pattern: digests-${{matrix.variant}}-*
merge-multiple: true
- name: Create ${{matrix.variant}} manifest list and push
working-directory: ${{ runner.temp }}/${{matrix.variant}}/digests
run: |
docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \
$(printf 'ghcr.io/${{github.repository}}@sha256:%s ' *)
docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \
$(printf 'index.docker.io/${{github.repository}}@sha256:%s ' *)
- name: Inspect ${{matrix.variant}} image
id: manifest-digest
run: |
docker buildx imagetools inspect --format '{{json .Manifest}}' ghcr.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > GHCR_DIGEST_SHA
echo "GHCR_DIGEST_SHA=$(cat GHCR_DIGEST_SHA)" | tee -a "${GITHUB_ENV}"
docker buildx imagetools inspect --format '{{json .Manifest}}' index.docker.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > DOCKERHUB_DIGEST_SHA
echo "DOCKERHUB_DIGEST_SHA=$(cat DOCKERHUB_DIGEST_SHA)" | tee -a "${GITHUB_ENV}"
cosign sign --yes $(jq --arg GHCR_DIGEST_SHA "$(cat GHCR_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | . + "@" + $GHCR_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json)
cosign sign --yes $(jq --arg DOCKERHUB_DIGEST_SHA "$(cat DOCKERHUB_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | . + "@" + $DOCKERHUB_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json)
- name: Attest GHCR
uses: actions/attest-build-provenance@v4
with:
subject-name: ghcr.io/${{github.repository}}
subject-digest: ${{ env.GHCR_DIGEST_SHA }}
push-to-registry: true
- name: Attest Dockerhub
uses: actions/attest-build-provenance@v4
with:
subject-name: index.docker.io/${{github.repository}}
subject-digest: ${{ env.DOCKERHUB_DIGEST_SHA }}
push-to-registry: true
linux:
permissions:
id-token: write
contents: write
attestations: write
packages: write
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
platform: linux/amd64
suffix: ""
build_env: ""
- target: x86_64-unknown-linux-musl
platform: linux/amd64
suffix: "-alpine"
build_env: ""
- target: aarch64-unknown-linux-gnu
platform: linux/arm64
suffix: ""
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
- target: aarch64-unknown-linux-musl
platform: linux/arm64
suffix: "-alpine"
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
- target: armv7-unknown-linux-gnueabihf
platform: linux/arm/v7
suffix: ""
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
- target: armv7-unknown-linux-musleabihf
platform: linux/arm/v7
suffix: "-alpine"
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
- target: arm-unknown-linux-gnueabihf
platform: linux/arm/v6
suffix: ""
build_env: ""
- target: arm-unknown-linux-musleabihf
platform: linux/arm/v6
suffix: "-alpine"
build_env: ""
name: Build / ${{matrix.target}}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout # Every `uses:` here is pinned to a full commit SHA, with the release it
uses: actions/checkout@v7 # belongs to in the trailing comment. A tag is a mutable pointer, so
# trusting `@v7` is trusting every future version of that action,
- name: Free disk space (heavy ARM targets) # including one pushed by whoever compromises the account. Dependabot
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64') # updates both halves together -- do not "simplify" a pin back to a tag.
run: | - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
df -h /mnt / - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/.ghcup /usr/local/share/powershell /usr/share/swift /opt/hostedtoolcache/CodeQL - name: System dependencies
sudo docker image prune --all --force || true # foundationdb and the search backends are off by default, but the
df -h /mnt / # default feature set still links against the system's C libraries.
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang
- name: Add swap (heavy ARM targets) - name: Build the server
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64') run: cargo build -p inbuxa --locked
run: | - name: Compile every test target
mnt_avail=$(df --output=avail -k /mnt | tail -1) # `--no-run` is the point: it builds the unit tests and the integration
if [ "$mnt_avail" -lt 18874368 ]; then # crate together, which is the combination that resolves the test
echo "Insufficient space on /mnt (${mnt_avail}K available), aborting swap setup" # features, and stops short of running anything that wants a store.
exit 1 run: cargo test --workspace --locked --no-run
fi
sudo fallocate -l 16G /mnt/swapfile
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
sudo sysctl vm.swappiness=80
free -h
swapon --show
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: "arm64,arm"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["https://mirror.gcr.io"]
driver-opts: |
network=host
- name: Log In to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{github.token}}
- name: Log In to DockerHub
uses: docker/login-action@v4
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
password: ${{secrets.DOCKERHUB_TOKEN}}
- name: Calculate shasum of external deps
id: cal-dep-shasum
run: |
echo "checksum=$(yq -p toml -oy '.package[] | select((.source | contains("")) or (.checksum | contains("")))' Cargo.lock | sha256sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- name: Cache apt
uses: actions/[email protected]
id: apt-cache
with:
path: |
var-cache-apt
var-lib-apt
key: apt-cache-${{ hashFiles('Dockerfile.build') }}
- name: Cache Cargo
uses: actions/[email protected]
id: cargo-cache
with:
path: |
usr-local-cargo-registry
usr-local-cargo-git
key: cargo-cache-${{ steps.cal-dep-shasum.outputs.checksum }}
- name: Inject cache into docker
uses: reproducible-containers/[email protected]
with:
cache-map: |
{
"var-cache-apt": "/var/cache/apt",
"var-lib-apt": "/var/lib/apt",
"usr-local-cargo-registry": "/usr/local/cargo/registry",
"usr-local-cargo-git": "/usr/local/cargo/git"
}
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }} && ${{ steps.apt-cache.outputs.cache-hit }}
- name: Extract Metadata for Docker
uses: docker/metadata-action@v6
id: meta
with:
images: |
index.docker.io/${{github.repository}}
ghcr.io/${{github.repository}}
flavor: |
suffix=${{matrix.suffix}},onlatest=true
tags: |
type=ref,event=tag
type=ref,event=branch,prefix=branch-
type=edge,branch=main
type=semver,pattern=v{{major}}.{{minor}}
- name: Build Artifact
id: bake
uses: docker/bake-action@v7
env:
DOCKER_BUILD_RECORD_UPLOAD: false
TARGET: ${{matrix.target}}
GHCR_REPO: ghcr.io/${{github.repository}}
BUILD_ENV: ${{matrix.build_env}}
DOCKER_PLATFORM: ${{matrix.platform}}
SUFFIX: ${{matrix.suffix}}
with:
source: .
set: |
*.tags=
image.output=type=image,"name=ghcr.io/${{github.repository}},index.docker.io/${{github.repository}}",push-by-digest=true,name-canonical=true,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
files: |
docker-bake.hcl
${{ steps.meta.outputs.bake-file }}
targets: ${{(github.event_name == 'push' || inputs.Docker) && 'build,image' || 'build'}}
- name: Upload Artifacts
uses: actions/[email protected]
with:
name: artifact-${{matrix.target}}
path: |
artifact
!artifact/*.json
- name: Export digest & Rename meta bake definition file
if: github.event_name == 'push' || inputs.Docker
run: |
mv "${{ steps.meta.outputs.bake-file }}" "${{ runner.temp }}/bake-meta.json"
mkdir -p ${{ runner.temp }}/digests
digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
if: github.event_name == 'push' || inputs.Docker
uses: actions/[email protected]
with:
name: digests-${{matrix.suffix == '' && 'gnu' || 'musl'}}-${{ matrix.target }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
- name: Upload GNU meta bake definition
uses: actions/[email protected]
if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'gnu') && startsWith(matrix.target,'x86')
with:
name: bake-meta-gnu
path: ${{ runner.temp }}/bake-meta.json
if-no-files-found: error
retention-days: 1
- name: Upload musl meta bake definition
uses: actions/[email protected]
if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'musl') && startsWith(matrix.target,'x86')
with:
name: bake-meta-musl
path: ${{ runner.temp }}/bake-meta.json
if-no-files-found: error
retention-days: 1
windows:
name: Build / ${{matrix.target}}
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
# - target: aarch64-pc-windows-msvc
- target: x86_64-pc-windows-msvc
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Run sccache-cache
uses: mozilla-actions/[email protected]
with:
disable_annotations: true
- name: Build
run: |
rustup target add ${{matrix.target}}
cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
mkdir -p artifacts
mv ./target/${{matrix.target}}/release/stalwart.exe ./artifacts/stalwart.exe
- name: Upload Artifacts
uses: actions/[email protected]
with:
name: artifact-${{matrix.target}}
path: artifacts
macos:
name: Build / ${{matrix.target}}
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
- target: x86_64-apple-darwin
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Run sccache-cache
uses: mozilla-actions/[email protected]
with:
disable_annotations: true
#- name: Build FoundationDB Edition
# env:
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# run: |
# rustup target add ${{matrix.target}}
# # Pin FoundationDB 7.4.x (Apple publishes these as prereleases)
# curl --retry 5 -Lso foundationdb.pkg "$(gh api -X GET /repos/apple/foundationdb/releases --jq '[.[] | select(.tag_name | startswith("7.4."))] | sort_by(.tag_name | split(".") | map(tonumber)) | reverse | .[0].assets[] | select(.name | test("${{startsWith(matrix.target, 'x86') && 'x86_64' || 'arm64'}}" + ".pkg$")) | .browser_download_url')"
# echo "=== Package contents ==="
# pkgutil --payload-files foundationdb.pkg || true
# sudo installer -allowUntrusted -verbose -dumplog -pkg foundationdb.pkg -target /
# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "foundationdb s3 redis nats"
# mkdir -p artifacts
# mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart-foundationdb
- name: Build
run: |
rustup target add ${{matrix.target}}
cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
mkdir -p artifacts
mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart
- name: Upload Artifacts
uses: actions/[email protected]
with:
name: artifact-${{matrix.target}}
path: artifacts
freebsd:
name: Build / ${{matrix.target}}
runs-on: ubuntu-latest
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-freebsd
arch: x86_64
# - target: aarch64-unknown-freebsd
# arch: aarch64
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Build in FreeBSD VM
uses: vmactions/freebsd-vm@v1
with:
release: "15.1"
arch: ${{matrix.arch}}
usesh: true
mem: 14336
cpu: 4
sync: rsync
copyback: true
# gmake: required by jemalloc-sys on BSD hosts
# llvm: provides libclang for bindgen (librocksdb-sys)
# rust: libsqlite3-sys 0.38 uses cfg_select!, stabilized in Rust
# 1.95. The default 'quarterly' pkg repo still ships rust 1.94, so
# switch to the 'latest' repo (currently 1.96.1). rustup is not an
# option here: aarch64-unknown-freebsd has no rustup toolchains yet.
prepare: |
set -e
mkdir -p /usr/local/etc/pkg/repos
echo 'FreeBSD: { url: "pkg+https://pkg.freebsd.org/${ABI}/latest", mirror_type: "srv" }' > /usr/local/etc/pkg/repos/FreeBSD.conf
pkg update -f
env ASSUME_ALWAYS_YES=yes pkg bootstrap -f
pkg update -f
pkg install -y rust gmake llvm rocksdb
rustc --version
run: |
set -e
export CARGO_TARGET_DIR=/tmp/target
export CARGO_TERM_COLOR=always
export CARGO_NET_RETRY=10
cargo build --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
mkdir -p artifacts
cp /tmp/target/release/stalwart artifacts/stalwart
- name: Upload Artifacts
uses: actions/[email protected]
with:
name: artifact-${{matrix.target}}
path: artifacts
release:
name: Release
permissions:
id-token: write
contents: write
attestations: write
if: github.event_name == 'push' || inputs.Release
needs: [linux, windows, macos, freebsd]
runs-on: ubuntu-latest
steps:
# Must run before artifacts are downloaded — checkout cleans the workspace.
- name: Checkout (for CHANGELOG)
if: startsWith(github.ref, 'refs/tags/')
uses: actions/checkout@v7
- name: Download Artifacts
uses: actions/download-artifact@v8
with:
path: archive
pattern: artifact-*
- name: Compress
run: |
set -eux
BASE_DIR="$(pwd)/archive"
compress_files() {
local dir="$1"
local archive_dir_name="${dir#artifact-}"
cd "$dir"
# Process each file in the directory
for file in `ls`; do
filename="${file%.*}"
extension="${file##*.}"
if [ "$extension" = "exe" ]; then
7z a -tzip "${filename}-${archive_dir_name}.zip" "$file" > /dev/null
else
tar -czf "${filename}-${archive_dir_name}.tar.gz" "$file"
fi
done
cd $BASE_DIR
}
cd $BASE_DIR
for arch_dir in `ls`; do
dir_name=$(basename "$arch_dir")
compress_files "$dir_name"
done
- name: Attest binary
id: attest
uses: actions/attest-build-provenance@v4
with:
subject-path: |
archive/**/*.tar.gz
archive/**/*.zip
- name: Use cosign to sign existing artifacts
uses: sigstore/[email protected]
with:
inputs: |
archive/**/*.tar.gz
archive/**/*.zip
- name: Build release body
run: |
if [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then
awk '/^## \[/{c++} c==1' CHANGELOG.md > release_body.md
echo "" >> release_body.md
else
: > release_body.md
fi
cat >> release_body.md <<EOF
<hr />
### Check binary attestation [here](${{ steps.attest.outputs.attestation-url }})
EOF
- name: Release
uses: softprops/action-gh-release@v3
with:
files: |
archive/**/*.tar.gz
archive/**/*.zip
archive/**/*.sigstore.json
prerelease: ${{!startsWith(github.ref, 'refs/tags/') || null}}
tag_name: ${{!startsWith(github.ref, 'refs/tags/') && 'nightly' || null}}
# Tag-push releases are created as drafts; the `publish` job un-drafts
# them only after all build jobs succeed, so watcher notifications
# don't fire on broken builds.
draft: ${{ startsWith(github.ref, 'refs/tags/') || null }}
body_path: release_body.md
publish:
name: Publish release
needs: [linux, windows, macos, freebsd, multiarch, release]
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Un-draft release
env:
GH_TOKEN: ${{ github.token }}
run: gh release edit "${{ github.ref_name }}" --draft=false --latest --repo "${{ github.repository }}"
cleanup:
name: Cleanup failed release
needs: [linux, windows, macos, freebsd, multiarch, release]
if: failure() && startsWith(github.ref, 'refs/tags/') && github.run_attempt >= 3
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Delete draft release and tag
env:
GH_TOKEN: ${{ github.token }}
run: gh release delete "${{ github.ref_name }}" --yes --cleanup-tag --repo "${{ github.repository }}" || true
+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
+11
View File
@@ -1,4 +1,7 @@
/target /target
# Release binaries built by hand: ~100 MB each, and a public repository is
# the wrong place for them.
/artifact
*.failed *.failed
*_failed *_failed
run.sh run.sh
@@ -6,4 +9,12 @@ 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.
tools/fork/cutover-rehearsal/__pycache__/
tools/fork/cutover-rehearsal/before.json
tools/fork/cutover-rehearsal/phase2_notes.json
tools/fork/__pycache__/
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
**johnellisATlinuxDOTcom**.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+62 -39
View File
@@ -1,61 +1,84 @@
# Contributing # Contributing
Thank you for your interest in contributing to Stalwart. We appreciate the support and enthusiasm of the open-source community. To keep the project maintainable and the review process sustainable, contributions are subject to the policies described below. Please read them in full before opening a pull request. Patches, bug reports and questions are welcome.
## Vouched Contributors Only ## Before a pull request
Due to the high volume of low-quality, AI-generated submissions, pull requests are limited to a list of vouched contributors. Pull requests opened by anyone who is not on this list are closed automatically. **Open an issue first for anything substantial.** A feature or a refactor is
worth agreeing on before it is written, because this is a fork that tracks
upstream: a change that moves code around costs a conflict on every import,
and it should be worth that.
To be added as a vouched contributor, post a message at [support.stalw.art](https://support.stalw.art) explaining the code changes you would like to submit, and include a link to the proposed change (a branch, diff, or draft). Once a maintainer has reviewed your request and vouched for you, you will be able to open pull requests directly. Small fixes — a bug, a typo, a test — need no ceremony. Send them.
This policy lets us focus limited review capacity on contributions from people who have taken the time to understand the codebase and discuss their changes first. ## How a change lands
## What Contributions Are Accepted `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.
At this stage of the project we accept a narrow set of contributions: 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.
- **Bug fixes.** Corrections to existing, incorrect behavior are welcome. Please include steps to reproduce the bug and describe the fix. Releases are cut weekly from `main` by `.github/workflows/release.yml`, on
- **Translations.** Additions and corrections to existing translations are welcome. 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.
New features are generally **not** accepted, unless they involve only a few lines of code. Larger features fall outside the scope of what we can review and integrate while the architecture is still evolving. ## What this repository is
If you would like to see a new feature, please request it at [support.stalw.art](https://support.stalw.art) under the **Feature Ideas** category rather than opening a pull request. This lets the community discuss and prioritize ideas before any code is written. INBUXA is a fork of Stalwart, taken under the AGPL-3.0-only half of its dual
licence, with nine features rebuilt independently. Two things follow:
## No AI-Generated Code - **The clean room is real.** The rebuilt features in `crates/features` were
written from specifications in `docs/spec/features/`, by people who had not
read Stalwart's Enterprise source. If you have read it, say so in the pull
request and it will be reviewed with that in mind, or declined for the parts
it touches. Nothing about this is personal: the project's defence of
independent creation is a record, and the record has to be true.
- **Upstream files stay recognisable.** Changes to files that came from
upstream are kept small and marked with an `inbuxa:` comment saying which
requirement they serve, so the next import merges cleanly and a reader can
tell fork from base. New work belongs in the fork's own crates where it can.
AI-generated code is not accepted in this project. ## Licence and provenance
Even the most advanced models write inefficient Rust code. Beyond raw performance, AI creates technical debt by generating large amounts of code that not even the authors who submitted it can fully understand or maintain. Reviewing and untangling such contributions costs the maintainers far more time than it saves. Contributions are under AGPL-3.0-only. Keep upstream's copyright headers where
they are; if you change a file that came from upstream, leave its "Modified by
Coffey Labs" line in place. New files carry:
Using AI as a fancy autocomplete is perfectly fine. What matters is that every line generated by a model is read, understood, and reviewed by a human before it is submitted. You are responsible for every line in your pull request, regardless of how it was produced. If you cannot explain why a change is written the way it is, it is not ready to be submitted. ```
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
```
## Pull Request Process If you bring in code from another project, it stays under its own licence and
its notice goes in `THIRD-PARTY.md`. `tools/fork/strip.py` reports any file
that is missing from there on every import.
Once you are a vouched contributor: ## Running the tests
1. Keep each pull request small and focused on a single logical change. `cargo test -p tests` runs what needs nothing but a store on disk. The rest
2. Match the style and conventions of the surrounding code. need containers, a particular backend, or a copy of real data, and are
3. Make sure the project builds and the test suite passes before opening the pull request. `#[ignore]`d:
4. In the pull request description, explain what the change does and why, and link to the [support.stalw.art](https://support.stalw.art) discussion where the change was vouched.
## Code of Conduct - `docs/spec/container-tests.md` — the suites that need containers, with the
`STORE` each one wants and what a plain regression leaves failing.
- `docs/spec/compat-tests.md` — the compatibility set, which needs a copy of a
real server's data.
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. Run one suite at a time. They bind fixed ports, and the timing checks flake if
two run at once.
You can read the full Code of Conduct [here](https://github.com/stalwartlabs/.github/blob/main/CODE_OF_CONDUCT.md). ## Commit messages
## Licensing Say what changed and why, in prose, wrapped at 72 characters or so. The why is
the part that is hard to recover later. No tool trailers.
This project is licensed under the Affero General Public License (AGPL) version 3.0. By contributing to this project, you agree that your contributions will be licensed under the AGPL-3.0 license.
## Fiduciary Contributor License Agreement
Before making any contributions, all contributors are required to sign the Fiduciary Contributor License Agreement (FLA). The FLA is a legal agreement that assigns the copyright of contributions to a designated fiduciary, who manages these rights on behalf of the project. This arrangement ensures that the software remains free and open, even as contributors come and go.
Key points of the FLA:
- Ensures the software remains free and open source
- Protects the project from potential copyright issues
- Includes a reversion clause: if the fiduciary violates Free Software principles, rights revert to the original contributors
For more details about the FLA, please refer to the [FLA FAQ](https://fsfe.org/activities/fla/fla.en.html).
Generated
+79 -41
View File
@@ -1324,6 +1324,7 @@ dependencies = [
"hyper", "hyper",
"idna", "idna",
"imagesize", "imagesize",
"inbuxa-features",
"infer 0.22.0", "infer 0.22.0",
"jmap_proto", "jmap_proto",
"jsonwebtoken", "jsonwebtoken",
@@ -1980,11 +1981,10 @@ dependencies = [
[[package]] [[package]]
name = "decancer" name = "decancer"
version = "3.3.3" version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d" checksum = "453589e364ce786381e7bcbf0d659088ff7e4d3e77f948dd1cf861344acf7a86"
dependencies = [ dependencies = [
"lazy_static",
"regex", "regex",
] ]
@@ -2385,6 +2385,7 @@ dependencies = [
"common", "common",
"groupware", "groupware",
"hashify", "hashify",
"inbuxa-features",
"jmap-tools", "jmap-tools",
"jmap_proto", "jmap_proto",
"mail-builder 1.0.0", "mail-builder 1.0.0",
@@ -3024,6 +3025,7 @@ dependencies = [
"icu_locale_core", "icu_locale_core",
"icu_plurals", "icu_plurals",
"icu_provider", "icu_provider",
"inbuxa-features",
"indexmap 2.14.2", "indexmap 2.14.2",
"nlp", "nlp",
"percent-encoding", "percent-encoding",
@@ -3317,6 +3319,7 @@ dependencies = [
"http_proto", "http_proto",
"hyper", "hyper",
"hyper-util", "hyper-util",
"inbuxa-features",
"jmap", "jmap",
"jmap_proto", "jmap_proto",
"mail-auth", "mail-auth",
@@ -3327,6 +3330,7 @@ dependencies = [
"registry", "registry",
"rkyv", "rkyv",
"scim", "scim",
"scim-proto",
"serde", "serde",
"serde_json", "serde_json",
"services", "services",
@@ -3888,6 +3892,7 @@ dependencies = [
"directory", "directory",
"email", "email",
"imap_proto", "imap_proto",
"inbuxa-features",
"mail-parser", "mail-parser",
"md5", "md5",
"nlp", "nlp",
@@ -3917,6 +3922,55 @@ dependencies = [
"utils", "utils",
] ]
[[package]]
name = "inbuxa"
version = "0.16.23"
dependencies = [
"common",
"coordinator",
"dav",
"directory",
"email",
"groupware",
"http 0.16.23",
"http_proto",
"imap",
"jmap",
"managesieve",
"migration",
"pop3",
"registry",
"rustls",
"scim",
"services",
"smtp",
"smtp-proto",
"spam-filter",
"store",
"tikv-jemallocator",
"tokio",
"trc",
"types",
"utils",
]
[[package]]
name = "inbuxa-features"
version = "0.16.22"
dependencies = [
"ahash",
"base64 0.23.1",
"jmap_proto",
"registry",
"serde",
"serde_json",
"store",
"tokio",
"trc",
"types",
"utils",
]
[[package]] [[package]]
name = "include-flate" name = "include-flate"
version = "0.3.4" version = "0.3.4"
@@ -4173,6 +4227,7 @@ dependencies = [
"http_proto", "http_proto",
"hyper", "hyper",
"hyper-util", "hyper-util",
"inbuxa-features",
"jmap-tools", "jmap-tools",
"jmap_proto", "jmap_proto",
"mail-auth", "mail-auth",
@@ -4553,9 +4608,9 @@ dependencies = [
[[package]] [[package]]
name = "librocksdb-sys" name = "librocksdb-sys"
version = "0.17.3+10.4.2" version = "0.19.0+11.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" checksum = "4f45e86edad8e88efe97dbf384b4e48e1ff0f111eabf154c7b09d7a1e5fb573c"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"bzip2-sys", "bzip2-sys",
@@ -4563,6 +4618,7 @@ dependencies = [
"libc", "libc",
"libz-sys", "libz-sys",
"lz4-sys", "lz4-sys",
"rustflags",
"zstd-sys", "zstd-sys",
] ]
@@ -7007,9 +7063,9 @@ dependencies = [
[[package]] [[package]]
name = "rocksdb" name = "rocksdb"
version = "0.24.0" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f" checksum = "d8d90add70d1d420ee487bce4a1449880a8d147451c6051b2ee5f8354553dcbf"
dependencies = [ dependencies = [
"libc", "libc",
"librocksdb-sys", "librocksdb-sys",
@@ -7150,6 +7206,12 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustflags"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a39e0e9135d7a7208ee80aa4e3e4b88f0f5ad7be92153ed70686c38a03db2e63"
[[package]] [[package]]
name = "rusticata-macros" name = "rusticata-macros"
version = "4.1.0" version = "4.1.0"
@@ -7355,8 +7417,11 @@ dependencies = [
name = "scim" name = "scim"
version = "0.16.23" version = "0.16.23"
dependencies = [ dependencies = [
"ahash",
"base64 0.23.1",
"common", "common",
"directory", "directory",
"hmac 0.13.0",
"http_proto", "http_proto",
"hyper", "hyper",
"icu_locale", "icu_locale",
@@ -7366,6 +7431,7 @@ dependencies = [
"scim-proto", "scim-proto",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"store", "store",
"trc", "trc",
"types", "types",
@@ -7712,11 +7778,13 @@ dependencies = [
"email", "email",
"groupware", "groupware",
"hkdf 0.13.0", "hkdf 0.13.0",
"inbuxa-features",
"jmap-tools", "jmap-tools",
"jmap_proto", "jmap_proto",
"mail-builder 1.0.0", "mail-builder 1.0.0",
"mail-parser", "mail-parser",
"memory-stats", "memory-stats",
"nlp",
"p256", "p256",
"psl", "psl",
"registry", "registry",
@@ -7890,8 +7958,6 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]] [[package]]
name = "sieve-rs" name = "sieve-rs"
version = "0.7.3" version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd00a548fde57bd0c8e7c13ae65fc5fe30bd923ff8655c81e010f3f02a90997b"
dependencies = [ dependencies = [
"ahash", "ahash",
"arc-swap", "arc-swap",
@@ -8024,6 +8090,7 @@ dependencies = [
"directory", "directory",
"email", "email",
"hashify", "hashify",
"inbuxa-features",
"mail-auth", "mail-auth",
"mail-builder 1.0.0", "mail-builder 1.0.0",
"mail-parser", "mail-parser",
@@ -8112,6 +8179,7 @@ dependencies = [
"hashify", "hashify",
"hyper", "hyper",
"idna", "idna",
"inbuxa-features",
"infer 0.22.0", "infer 0.22.0",
"mail-auth", "mail-auth",
"mail-parser", "mail-parser",
@@ -8215,38 +8283,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stalwart"
version = "0.16.23"
dependencies = [
"common",
"coordinator",
"dav",
"directory",
"email",
"groupware",
"http 0.16.23",
"http_proto",
"imap",
"jmap",
"managesieve",
"migration",
"pop3",
"registry",
"rustls",
"scim",
"services",
"smtp",
"smtp-proto",
"spam-filter",
"store",
"tikv-jemallocator",
"tokio",
"trc",
"types",
"utils",
]
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@@ -8543,6 +8579,7 @@ dependencies = [
"hyper-util", "hyper-util",
"imap", "imap",
"imap_proto", "imap_proto",
"inbuxa-features",
"jmap", "jmap",
"jmap-client", "jmap-client",
"jmap-tools", "jmap-tools",
@@ -8551,6 +8588,7 @@ dependencies = [
"mail-builder 1.0.0", "mail-builder 1.0.0",
"mail-parser", "mail-parser",
"managesieve", "managesieve",
"migration",
"nlp", "nlp",
"pop3", "pop3",
"quick-xml 0.41.0", "quick-xml 0.41.0",
+10
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",
@@ -29,6 +31,7 @@ members = [
"crates/common", "crates/common",
"crates/trc", "crates/trc",
"crates/migration", "crates/migration",
"crates/features",
"tests", "tests",
] ]
@@ -77,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" }
+15 -15
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
@@ -21,7 +21,7 @@ RUN rustup target add "$(cat /target.txt)"
COPY --from=planner /recipe.json /recipe.json COPY --from=planner /recipe.json /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 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 stalwart --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"
RUN mv "/build/target/$(cat /target.txt)/release" "/output" RUN mv "/build/target/$(cat /target.txt)/release" "/output"
FROM docker.io/debian:trixie-slim FROM docker.io/debian:trixie-slim
@@ -29,18 +29,18 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
apt-get update && \ apt-get update && \
apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \ apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
groupadd -r -g 2000 stalwart && \ groupadd -r -g 2000 inbuxa && \
useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart && \ useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M inbuxa && \
mkdir -p /etc/stalwart /var/lib/stalwart && \ mkdir -p /etc/inbuxa /var/lib/inbuxa && \
chown stalwart:stalwart /etc/stalwart /var/lib/stalwart chown inbuxa:inbuxa /etc/inbuxa /var/lib/inbuxa
COPY --from=builder --chmod=0755 /output/stalwart /usr/local/bin/stalwart COPY --from=builder --chmod=0755 /output/inbuxa /usr/local/bin/inbuxa
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbuxa
USER stalwart USER inbuxa
WORKDIR /var/lib/stalwart WORKDIR /var/lib/inbuxa
VOLUME ["/etc/stalwart", "/var/lib/stalwart"] VOLUME ["/etc/inbuxa", "/var/lib/inbuxa"]
EXPOSE 443 25 110 587 465 143 993 995 4190 8080 EXPOSE 443 25 110 587 465 143 993 995 4190 8080
ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live ENV INBUXA_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1 CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$INBUXA_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
ENTRYPOINT ["/usr/local/bin/stalwart"] ENTRYPOINT ["/usr/local/bin/inbuxa"]
CMD ["--config", "/etc/stalwart/config.json"] CMD ["--config", "/etc/inbuxa/config.json"]
+32 -32
View File
@@ -108,7 +108,7 @@ RUN \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
source /env-cargo && \ source /env-cargo && \
if [ ! -z "${FDB_ARCH}" ]; then \ if [ ! -z "${FDB_ARCH}" ]; then \
RUSTFLAGS="-L /usr/lib" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats"; \ RUSTFLAGS="-L /usr/lib" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p inbuxa --no-default-features --features "foundationdb s3 redis nats"; \
fi fi
RUN \ RUN \
--mount=type=secret,id=ACTIONS_RESULTS_URL,env=ACTIONS_RESULTS_URL \ --mount=type=secret,id=ACTIONS_RESULTS_URL,env=ACTIONS_RESULTS_URL \
@@ -116,7 +116,7 @@ RUN \
--mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
source /env-cargo && \ source /env-cargo && \
cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
# Copy the source code # Copy the source code
COPY . . COPY . .
ENV RUSTC_WRAPPER="sccache" \ ENV RUSTC_WRAPPER="sccache" \
@@ -129,8 +129,8 @@ RUN \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
source /env-cargo && \ source /env-cargo && \
if [ ! -z "${FDB_ARCH}" ]; then \ if [ ! -z "${FDB_ARCH}" ]; then \
RUSTFLAGS="-L /usr/lib" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats" && \ RUSTFLAGS="-L /usr/lib" cargo zigbuild --release --target ${TARGET} -p inbuxa --no-default-features --features "foundationdb s3 redis nats" && \
mv /app/target/${TARGET}/release/stalwart /app/artifact/stalwart-foundationdb; \ mv /app/target/${TARGET}/release/inbuxa /app/artifact/inbuxa-foundationdb; \
fi fi
# Build generic version # Build generic version
RUN \ RUN \
@@ -139,8 +139,8 @@ RUN \
--mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
source /env-cargo && \ source /env-cargo && \
cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" && \ cargo zigbuild --release --target ${TARGET} -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" && \
mv /app/target/${TARGET}/release/stalwart /app/artifact/stalwart mv /app/target/${TARGET}/release/inbuxa /app/artifact/inbuxa
# ***************** # *****************
# Binary stage # Binary stage
@@ -156,21 +156,21 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
apt-get update && \ apt-get update && \
apt-get install -yq --no-install-recommends ca-certificates curl tzdata libcap2-bin && \ apt-get install -yq --no-install-recommends ca-certificates curl tzdata libcap2-bin && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
groupadd -r -g 2000 stalwart && \ groupadd -r -g 2000 inbuxa && \
useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart && \ useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M inbuxa && \
mkdir -p /etc/stalwart /var/lib/stalwart && \ mkdir -p /etc/inbuxa /var/lib/inbuxa && \
chown stalwart:stalwart /etc/stalwart /var/lib/stalwart chown inbuxa:inbuxa /etc/inbuxa /var/lib/inbuxa
COPY --from=builder --chmod=0755 /app/artifact/stalwart /usr/local/bin/stalwart COPY --from=builder --chmod=0755 /app/artifact/inbuxa /usr/local/bin/inbuxa
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbuxa
USER stalwart USER inbuxa
WORKDIR /var/lib/stalwart WORKDIR /var/lib/inbuxa
VOLUME ["/etc/stalwart", "/var/lib/stalwart"] VOLUME ["/etc/inbuxa", "/var/lib/inbuxa"]
EXPOSE 443 25 110 587 465 143 993 995 4190 8080 EXPOSE 443 25 110 587 465 143 993 995 4190 8080
ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live ENV INBUXA_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1 CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$INBUXA_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
ENTRYPOINT ["/usr/local/bin/stalwart"] ENTRYPOINT ["/usr/local/bin/inbuxa"]
CMD ["--config", "/etc/stalwart/config.json"] CMD ["--config", "/etc/inbuxa/config.json"]
# ***************** # *****************
# Runtime image for musl targets # Runtime image for musl targets
@@ -178,18 +178,18 @@ CMD ["--config", "/etc/stalwart/config.json"]
FROM --platform=$TARGETPLATFORM alpine AS musl FROM --platform=$TARGETPLATFORM alpine AS musl
RUN apk add --update --no-cache ca-certificates curl tzdata libcap && \ RUN apk add --update --no-cache ca-certificates curl tzdata libcap && \
rm -rf /var/cache/apk/* && \ rm -rf /var/cache/apk/* && \
addgroup -S -g 2000 stalwart && \ addgroup -S -g 2000 inbuxa && \
adduser -S -D -H -u 2000 -G stalwart -s /sbin/nologin stalwart && \ adduser -S -D -H -u 2000 -G inbuxa -s /sbin/nologin inbuxa && \
mkdir -p /etc/stalwart /var/lib/stalwart && \ mkdir -p /etc/inbuxa /var/lib/inbuxa && \
chown stalwart:stalwart /etc/stalwart /var/lib/stalwart chown inbuxa:inbuxa /etc/inbuxa /var/lib/inbuxa
COPY --from=builder --chmod=0755 /app/artifact/stalwart /usr/local/bin/stalwart COPY --from=builder --chmod=0755 /app/artifact/inbuxa /usr/local/bin/inbuxa
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbuxa
USER stalwart USER inbuxa
WORKDIR /var/lib/stalwart WORKDIR /var/lib/inbuxa
VOLUME ["/etc/stalwart", "/var/lib/stalwart"] VOLUME ["/etc/inbuxa", "/var/lib/inbuxa"]
EXPOSE 443 25 110 587 465 143 993 995 4190 8080 EXPOSE 443 25 110 587 465 143 993 995 4190 8080
ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live ENV INBUXA_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1 CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$INBUXA_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
ENTRYPOINT ["/usr/local/bin/stalwart"] ENTRYPOINT ["/usr/local/bin/inbuxa"]
CMD ["--config", "/etc/stalwart/config.json"] CMD ["--config", "/etc/inbuxa/config.json"]
+14 -14
View File
@@ -53,28 +53,28 @@ COPY Cargo.lock .
COPY crates/ crates/ COPY crates/ crates/
COPY resources/ resources/ COPY resources/ resources/
COPY tests/ tests/ COPY tests/ tests/
RUN cargo build -p stalwart --no-default-features --features "foundationdb s3 redis azure nats" --release RUN cargo build -p inbuxa --no-default-features --features "foundationdb s3 redis azure nats" --release
FROM debian:trixie-slim AS runtime FROM debian:trixie-slim AS runtime
COPY --from=builder --chmod=0755 /app/target/release/stalwart /usr/local/bin/stalwart COPY --from=builder --chmod=0755 /app/target/release/inbuxa /usr/local/bin/inbuxa
COPY --from=builder /usr/lib/libfdb_c.so /usr/lib/libfdb_c.so COPY --from=builder /usr/lib/libfdb_c.so /usr/lib/libfdb_c.so
RUN export DEBIAN_FRONTEND=noninteractive && \ RUN export DEBIAN_FRONTEND=noninteractive && \
apt-get update && \ apt-get update && \
apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \ apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \
rm -rf /var/lib/apt/lists/* && \ rm -rf /var/lib/apt/lists/* && \
groupadd -r -g 2000 stalwart && \ groupadd -r -g 2000 inbuxa && \
useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart && \ useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M inbuxa && \
mkdir -p /etc/stalwart /var/lib/stalwart && \ mkdir -p /etc/inbuxa /var/lib/inbuxa && \
chown stalwart:stalwart /etc/stalwart /var/lib/stalwart && \ chown inbuxa:inbuxa /etc/inbuxa /var/lib/inbuxa && \
setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbuxa
USER stalwart USER inbuxa
WORKDIR /var/lib/stalwart WORKDIR /var/lib/inbuxa
VOLUME ["/etc/stalwart", "/var/lib/stalwart"] VOLUME ["/etc/inbuxa", "/var/lib/inbuxa"]
EXPOSE 443 25 110 587 465 143 993 995 4190 8080 EXPOSE 443 25 110 587 465 143 993 995 4190 8080
ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live ENV INBUXA_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1 CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$INBUXA_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
ENTRYPOINT ["/usr/local/bin/stalwart"] ENTRYPOINT ["/usr/local/bin/inbuxa"]
CMD ["--config", "/etc/stalwart/config.json"] CMD ["--config", "/etc/inbuxa/config.json"]
+52 -167
View File
@@ -1,186 +1,71 @@
<p align="center"> <p align="center">
<a href="https://stalw.art"> <img src="./img/brand/inbuxa-lockup-light.svg" alt="inbuxa" height="140">
<img src="./img/logo-red.svg" height="150">
</a>
</p> </p>
<h3 align="center"> <h3 align="center">
Secure, scalable mail & collaboration server with comprehensive protocol support 🛡️ <br/>(IMAP, JMAP, SMTP, CalDAV, CardDAV, WebDAV) A complete mail and collaboration server, every feature included, under the AGPL
</h3> </h3>
<br> ---
<p align="center"> **inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
<a href="https://github.com/stalwartlabs/stalwart/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/stalwartlabs/stalwart/ci.yml?style=flat-square" alt="continuous integration"></a> CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
&nbsp; end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
<a href="https://www.gnu.org/licenses/agpl-3.0"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?label=license&style=flat-square" alt="License: AGPL v3"></a> Project site: [inbuxa.org](https://inbuxa.org). Documentation: [docs.inbuxa.org](https://docs.inbuxa.org).
&nbsp;
<a href="https://stalw.art/docs/install/get-started"><img src="https://img.shields.io/badge/read_the-docs-red?style=flat-square" alt="Documentation"></a>
&nbsp;
<a href="https://github.com/stalwartlabs/stalwart/releases"><img src="https://img.shields.io/github/downloads/stalwartlabs/stalwart/total?style=flat-square" alt="downloads"></a
</p>
<p align="center">
<a href="https://mastodon.social/@stalwartlabs"><img src="https://img.shields.io/mastodon/follow/109929667531941122?style=flat-square&logo=mastodon&color=%236364ff&label=Mastodon" alt="Mastodon"></a>
&nbsp;
<a href="https://twitter.com/stalwartlabs"><img src="https://img.shields.io/twitter/follow/stalwartlabs?style=flat-square&logo=x&label=Twitter" alt="Twitter"></a>
<a href="https://discord.gg/vhqRgdhguq"><img src="https://img.shields.io/discord/923615863037390889?label=Discord&logo=discord&style=flat-square" alt="Discord"></a>
&nbsp;
<a href="https://www.reddit.com/r/stalwartlabs/"><img src="https://img.shields.io/reddit/subreddit-subscribers/stalwartlabs?label=%2Fr%2Fstalwartlabs&logo=reddit&style=flat-square" alt="Reddit"></a>
</p>
## Features Stalwart ships some features only in a paid Enterprise Edition: multi-tenancy,
masked email, undelete and others. **inbuxa** ships everything to everybody under
the AGPL-3.0, rebuilding those features independently and without using any
of Stalwart's Enterprise code.
**Stalwart** is an open-source mail & collaboration server with JMAP, IMAP4, POP3, SMTP, CalDAV, CardDAV and WebDAV support and a wide range of modern features. It is written in Rust and designed to be secure, fast, robust and scalable. ## What's different from Stalwart
Key features: - **Every feature, one edition.** No license key, no edition checks, no
upsell. See `docs/spec/SPEC.md` §4 for the features being rebuilt, and
`docs/spec/features/` for each one's specification.
- **Webmail and administration by ihasmail,** as a separate service that can
run beside the server or elsewhere. Stalwart's own web interface is removed,
so there's no web front end on the mail host.
- **Clean-room rebuilds.** Enterprise-only code is stripped from every
upstream release before it's imported. The rebuilt features are written
from specifications that use only public sources (`docs/spec/SPEC.md` §3).
- **Email** server with complete protocol support: ## How the fork is kept
- JMAP:
* [JMAP for Mail](https://datatracker.ietf.org/doc/html/rfc8621) server.
* [JMAP for Sieve Scripts](https://www.ietf.org/archive/id/draft-ietf-jmap-sieve-22.html).
* [WebSocket](https://datatracker.ietf.org/doc/html/rfc8887), [Blob Management](https://www.rfc-editor.org/rfc/rfc9404.html) and [Quotas](https://www.rfc-editor.org/rfc/rfc9425.html) extensions.
- IMAP:
* [IMAP4rev2](https://datatracker.ietf.org/doc/html/rfc9051) and [IMAP4rev1](https://datatracker.ietf.org/doc/html/rfc3501) server.
* [ManageSieve](https://datatracker.ietf.org/doc/html/rfc5804) server.
* Numerous [extensions](https://stalw.art/docs/development/rfcs#imap4-and-extensions) supported.
- POP3:
- [POP3](https://datatracker.ietf.org/doc/html/rfc1939) server.
- [STLS](https://datatracker.ietf.org/doc/html/rfc2595) and [SASL](https://datatracker.ietf.org/doc/html/rfc5034) support as well as other [extensions](https://datatracker.ietf.org/doc/html/rfc2449).
- SMTP:
* SMTP server with built-in [DMARC](https://datatracker.ietf.org/doc/html/rfc7489), [DKIMv2](https://datatracker.ietf.org/doc/draft-ietf-dkim-dkim2-spec/), [DKIMv1](https://datatracker.ietf.org/doc/html/rfc6376), [SPF](https://datatracker.ietf.org/doc/html/rfc7208) and [ARC](https://datatracker.ietf.org/doc/html/rfc8617) support for message authentication.
* Strong transport security through [DANE](https://datatracker.ietf.org/doc/html/rfc6698), [MTA-STS](https://datatracker.ietf.org/doc/html/rfc8461) and [SMTP TLS](https://datatracker.ietf.org/doc/html/rfc8460) reporting.
* Automated DKIM key rotation and management.
* Inbound throttling and filtering with granular configuration rules, sieve scripting, MTA hooks and milter integration.
* Distributed virtual queues with delayed delivery, priority delivery, quotas, routing rules and throttling support.
* Envelope rewriting and message modification.
- **Collaboration** server:
- Calendaring and scheduling:
- [CalDAV](https://datatracker.ietf.org/doc/html/rfc4791) and [CalDAV Scheduling](https://datatracker.ietf.org/doc/html/rfc6638) support.
- [JMAP for Calendars](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-calendars-24) support.
- Contact management:
- [CardDAV](https://datatracker.ietf.org/doc/html/rfc6352) support.
- [JMAP for Contacts](https://datatracker.ietf.org/doc/html/rfc9610) support.
- File storage:
- [WebDAV](https://datatracker.ietf.org/doc/html/rfc4918) support.
- [JMAP for File Storage](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-filenode-03) support.
- Sharing with fine-grained access controls:
- [WebDAV ACL](https://datatracker.ietf.org/doc/html/rfc3744) support.
- [JMAP Sharing](https://datatracker.ietf.org/doc/html/rfc9670) support.
- **Spam** and **Phishing** built-in filter:
- Comprehensive set of filtering **rules** on par with popular solutions.
- LLM-driven spam filtering and message analysis.
- Statistical **spam classifier** with collaborative filtering, automatic training capabilities and address book integration.
- DNS Blocklists (**DNSBLs**) checking of IP addresses, domains, and hashes.
- Collaborative digest-based spam filtering with **Pyzor**.
- **Phishing** protection against homographic URL attacks, sender spoofing and other techniques.
- Trusted **reply** tracking to recognize and prioritize genuine e-mail replies.
- Sender **reputation** monitoring by IP address, ASN, domain and email address.
- **Greylisting** to temporarily defer unknown senders.
- **Spam traps** to set up decoy email addresses that catch and analyze spam.
- **Flexible**:
- Pluggable storage backends with **RocksDB**, **FoundationDB**, **PostgreSQL**, **mySQL**, **SQLite**, **S3-Compatible**, **Azure** and **Redis** support.
- Full-text search available in 17 languages using the built-in search engine or via **Meilisearch**, **ElasticSearch**, **OpenSearch**, **PostgreSQL** or **mySQL** backends.
- Sieve scripting language with support for all [registered extensions](https://www.iana.org/assignments/sieve-extensions/sieve-extensions.xhtml).
- Email aliases, mailing lists, subaddressing and catch-all addresses support.
- Automated DNS management.
- Automatic account configuration and discovery with [PACC](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-pacc/), [autoconfig](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/) and [autodiscover](https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019).
- Multi-tenancy support with domain and tenant isolation.
- Disk quotas per user and tenant.
- **Secure and robust**:
- Encryption at rest with **S/MIME** or **OpenPGP**.
- Automatic TLS certificate provisioning with [ACME](https://datatracker.ietf.org/doc/html/rfc8555) using `TLS-ALPN-01`, `DNS-01`, `DNS-PERSIST-01` or `HTTP-01` challenges.
- Automated blocking of IP addresses that attack, abuse or scan the server for exploits.
- Rate limiting.
- Security audited (read the [report](https://stalw.art/blog/security-audit)).
- Memory safe (thanks to Rust).
- **Scalable and fault-tolerant**:
- Designed to handle growth seamlessly, from small setups to large-scale deployments of thousands of nodes.
- Built with **fault tolerance** and **high availability** in mind, recovers from hardware or software failures with minimal operational impact.
- Peer-to-peer cluster coordination or with **Kafka**, **Redpanda**, **NATS** or **Redis**.
- **Kubernetes**, **Apache Mesos** and **Docker Swarm** support for automated scaling and container orchestration.
- Read replicas, sharded blob storage and in-memory data stores for high performance and low latency.
- **Authentication and Authorization**:
- **OpenID Connect** authentication.
- OAuth 2.0 authorization with [authorization code](https://www.rfc-editor.org/rfc/rfc8628) and [device authorization](https://www.rfc-editor.org/rfc/rfc8628) flows.
- **LDAP**, **OIDC**, **SQL** or built-in authentication backend support.
- System for Cross-domain Identity Management ([SCIM](https://www.rfc-editor.org/info/rfc7643/)) v2 for automated provisioning.
- Two-factor authentication with Time-based One-Time Passwords (`2FA-TOTP`)
- Application passwords (App Passwords).
- Roles and permissions.
- Access Control Lists (ACLs).
- **Observability**:
- Logging and tracing with **OpenTelemetry**, journald, log files and console support.
- Metrics with **OpenTelemetry** and **Prometheus** integration.
- Webhooks for event-driven automation.
- Alerts with email and webhook notifications.
- Live tracing and metrics.
- **Web-based administration**:
- Dashboard with real-time statistics and monitoring.
- Account, domain, group and mailing list management.
- SMTP queue management for messages and outbound DMARC and TLS reports.
- Report visualization interface for received DMARC, TLS-RPT and Failure (ARF) reports.
- Configuration of every aspect of the mail server.
- Log viewer with search and filtering capabilities.
- Self-service portal for password reset and encryption-at-rest key management.
## Screenshots Upstream releases arrive as stripped snapshots, never with upstream's git
history, which contains Enterprise code. `tools/fork/strip.py` builds each
snapshot on top of upstream's own `ossify.py`, then verifies it independently.
The report for every import is in `docs/fork/strip-reports/`. See
`docs/spec/SPEC.md` §2.
<img src="./img/demo.gif"> ## Building
## Presentation ```bash
cargo build --release -p inbuxa # the binary is target/release/inbuxa
docker build -t inbuxa . # or the container image
```
**Want a deeper dive?** Need to explain to your boss why Stalwart is the perfect fit? Whether you're evaluating options, making a case to your team, or simply curious about how it all works under the hood, these slides walk you through the key features, architecture, and benefits of Stalwart. Browse the [slides](https://stalw.art/slides) to see what makes it stand out. Settings are read from `INBUXA_*` environment variables. An existing Stalwart
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
`/var/log/inbuxa`. Existing installs keep the paths their configuration
already names, so none of their data moves.
## Get Started ## License and credits
Install Stalwart on your server by following the instructions for your platform: **inbuxa** is free software under the [GNU Affero General Public License,
version 3](./LICENSES/AGPL-3.0-only.txt).
- [Linux / MacOS / FreeBSD](https://stalw.art/docs/install/platform/linux) It is a fork of Stalwart, copyright © Stalwart Labs LLC, **modified by
- [Windows](https://stalw.art/docs/install/platform/windows) Coffey Labs in 2026**. Upstream's copyright notices are kept on every file
- [Docker](https://stalw.art/docs/install/platform/docker) 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
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
under MIT or BSD licenses, which stays under those licenses;
[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
Labs.
All documentation is available at [stalw.art/docs](https://stalw.art/docs/install/get-started). The **inbuxa** mark reuses ihasmail's cat-and-envelope artwork.
## Support
If you are having problems running Stalwart, found a bug, or just have a question, please head to the [Stalwart Support Portal](https://support.stalw.art) at [support.stalw.art](https://support.stalw.art).
Additionally, you may purchase an [Enterprise License](https://stalw.art/enterprise) to obtain priority support from Stalwart Labs LLC, including response-time commitments and a private Priority Support area on the portal.
## Contributing
We welcome contributions, but to keep the project maintainable there are a few things to know before opening a pull request. Because of the high volume of low-quality, AI-generated submissions, pull requests are limited to a list of vouched contributors; to be added, post at [support.stalw.art](https://support.stalw.art) describing the change you would like to submit, together with a link to the proposed change. At this stage only bug fixes and translations are accepted, and new features are not, unless they involve just a few lines of code.
For the full guidelines, please read [CONTRIBUTING.md](CONTRIBUTING.md).
## Roadmap
Stalwart has reached an exciting point in its journey, its now **feature complete**. All the core functionality and open standard email and collaboration protocols that we set out to support are in place. In other words, Stalwart already does everything youd expect from a modern, standards-compliant mail and collaboration platform.
The next major milestone is all about refinement: finalizing the database schema and focusing on performance optimizations to ensure everything runs as efficiently and reliably as possible. Once thats done, well be ready to roll out version **1.0**.
Of course, development doesnt stop there. The community has contributed hundreds of great ideas for improvements and new features, everything from subtle usability tweaks to entirely new integrations. You can see the full list of proposals over on our [GitHub issues](https://github.com/stalwartlabs/stalwart/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3Aenhancement). If theres something youd like to see prioritized, just give it a thumbs up as we plan to implement enhancements based on the communitys votes.
## Sponsorship
Your support is crucial in helping us continue to improve the project, add new features, and maintain the highest level of quality. By [becoming a sponsor](https://opencollective.com/stalwart), you help fund the development and future of Stalwart. As a thank-you, sponsors who contribute $5 per month or more will automatically receive a [Enterprise edition](https://stalw.art/enterprise/) license. And, sponsors who contribute $30 per month or more, also have access to [Premium Support](https://stalw.art/support) from Stalwart Labs.
## Funding
Part of the development of this project was funded through:
- [NGI0 Entrust Fund](https://nlnet.nl/entrust), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu/) programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101069594.
- [NGI Zero Core](https://nlnet.nl/NGI0/), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101092990.
If you find the project useful you can help by [becoming a sponsor](https://opencollective.com/stalwart). Thank you!
## License
This project is dual-licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0; as published by the Free Software Foundation) and the **Stalwart Enterprise License v2 (SELv2)**:
- The [GNU Affero General Public License v3.0](./LICENSES/AGPL-3.0-only.txt) is a free software license that ensures your freedom to use, modify, and distribute the software, with the condition that any modified versions of the software must also be distributed under the same license.
- The [Stalwart Enterprise License v2 (SELv2)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements.
Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory.
## Copyright
Copyright (C) 2020, Stalwart Labs LLC
+27 -139
View File
@@ -1,154 +1,42 @@
# Security Policy for Stalwart # Security policy
## Supported Versions ## Supported versions
We provide security updates for the following versions of Stalwart: INBUXA is developed on `main`, and security fixes are applied there and in
the latest release. Older tags are not backported.
| Version | Supported | End of Support | | Version | Supported |
| ------- | ------------------ | -------------- | | --- | --- |
| 0.16.x | :white_check_mark: | TBD | | `main` and the latest release | :white_check_mark: |
| 0.15.x | :white_check_mark: | 2026-12-01 | | Older releases | :x: |
| < 0.14 | :x: | Ended |
**Note**: We typically support the current major version and one previous major version. Users are strongly encouraged to upgrade to the latest version for the best security posture. ## Reporting a vulnerability
## Reporting a Vulnerability **Please don't open a public issue for a security problem.** An issue is
visible to everyone, including whoever would use it, before there is a fix.
We take the security of Stalwart very seriously. If you believe you've found a security vulnerability, we encourage you to inform us responsibly through coordinated disclosure. Report it privately by email to:
### How to Report **johnellisATlinuxDOTcom**
**Do not report security vulnerabilities through public GitHub issues, discussions, or social media.** Include as much as you can of:
Instead, please use one of these secure channels: - what the vulnerability is, and what it lets someone do;
- how to reproduce it, or a proof of concept;
- the version or commit affected;
- anything about the deployment that matters — backend, front ends, whether
it needs an authenticated account.
1. **Email** (preferred): Send details to `[email protected]` You'll get an acknowledgement within a few days. If a report turns out to
2. **GitHub Security Advisories**: Use the "Report a vulnerability" button in the Security tab affect upstream Stalwart rather than this fork's own code, it will be passed
3. **Backup contact**: If no response within 48 hours, email `[email protected]` to Stalwart Labs with credit to you, and you'll be told that has happened.
### What to Include
To help us understand and address the issue quickly, please include:
**Required Information:**
- Brief description of the vulnerability type
- Affected version(s) and components
- Steps to reproduce the issue
- Impact assessment (what could an attacker achieve?)
**Helpful Additional Details:**
- Full paths of affected source files
- Specific commit/branch where the issue exists
- Required configuration to reproduce
- Proof-of-concept code (if available)
- Suggested mitigation or fix (if you have ideas)
### Our Response Process
**Timeline Commitments:**
- **Initial acknowledgment**: Within 24 hours
- **Detailed response**: Within 72 hours
- **Status updates**: Every 7 days until resolved
- **Resolution target**: 90 days for most issues
**What We'll Do:**
1. Acknowledge your report and assign a tracking ID
2. Assess the vulnerability and determine severity
3. Develop and test a fix
4. Coordinate disclosure timeline with you
5. Release security update and publish advisory
6. Credit you in our security advisory (if desired)
## Disclosure Policy
We follow responsible disclosure principles:
- **Coordinated disclosure**: We'll work with you to determine appropriate disclosure timing
- **Typical timeline**: 90 days from report to public disclosure
- **Early disclosure**: May occur if issue is being actively exploited
- **Delayed disclosure**: May be necessary for complex issues requiring significant changes
## Scope ## Scope
This security policy applies to: This repository is the mail server. The web front ends have their own:
**In Scope:** - [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
- Stalwart (all supported versions) - [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa)
- Official Docker images
- Documentation that could lead to insecure configurations
- Dependencies with security implications
**Out of Scope:**
- Third-party integrations or plugins
- Issues requiring physical access to the server
- Social engineering attacks
- Attacks requiring compromised credentials (unless the vulnerability enables credential compromise)
- Theoretical vulnerabilities without practical exploitation
## Security Measures
**Our Commitments:**
- Regular security audits of dependencies using `cargo audit`
- Automated security scanning in CI/CD pipeline
- Following Rust security best practices
- Prompt security updates for critical dependencies
- Security-focused code review process
**User Responsibilities:**
- Keep Stalwart updated to supported versions
- Follow security configuration guidelines
- Implement proper network security (firewalls, TLS, etc.)
- Regular security monitoring and logging
- Secure credential management
## Legal Safe Harbor
We support security research conducted in good faith. If you follow these guidelines:
**We will NOT:**
- Initiate legal action against you
- Contact law enforcement about your research
- Suspend or terminate your access to Stalwart services
**You must:**
- Only test against your own Stalwart installations
- Not access, modify, or delete user data
- Not perform testing that could degrade service availability
- Not publicly disclose the issue before coordinated disclosure
- Act in good faith and not for malicious purposes
## Recognition
We believe in recognizing security researchers who help keep Stalwart secure:
- **Security Advisory Credits**: We'll credit you in our GitHub Security Advisories (unless you prefer to remain anonymous)
- **Hall of Fame**: Significant contributors may be listed in our security acknowledgments
- **Swag**: We may send Stalwart merchandise for notable contributions
## Security Updates
**Stay Informed:**
- Subscribe to our [GitHub releases](https://github.com/stalwartlabs/stalwart/releases) for security updates
- Join our community channels for security announcements
- Enable GitHub notifications for security advisories
**Update Process:**
- Security updates are published as patch releases (e.g., 0.12.1 → 0.12.2)
- Critical vulnerabilities may receive out-of-band releases
- Docker images are updated simultaneously with releases
- Security advisories are published through GitHub Security Advisories
## Contact Information
- **Security reports**: security@stalw.art
- **General inquiries**: hello@stalw.art
- **PGP Key**: Available upon request for sensitive communications
## Additional Resources
- [Stalwart Security Incident Response Process](SECURITY_PROCESS.md)
- [Security Configuration Guide](https://stalw.art/docs/install/security)
- [Rust Security Advisory Database](https://rustsec.org/)
*This security policy is effective as of June 20, 2025 and may be updated periodically. Check back regularly for updates.*
Upstream's own security documents are kept in `.github-upstream/` for
reference. They describe Stalwart Labs' process, not this project's.
+93
View File
@@ -0,0 +1,93 @@
# Third-party code
INBUXA is a fork of Stalwart. Stalwart is original work by Stalwart Labs LLC,
not a fork of anything, but a few of its files carry code, adapted or ported,
from other projects under permissive licenses. Those parts stay under their own
licenses, not the AGPL, and their notices are reproduced here as the licenses
require. Where a project offers MIT or Apache-2.0, INBUXA takes it under MIT.
`tools/fork/strip.py` lists every such file on each upstream import and names
any this page doesn't cover yet (docs/spec/SPEC.md §2.2). The fork's own code,
and Rust crates pulled in as dependencies, aren't listed here: dependencies
carry their own license files.
## Under the MIT license
| Where | From | Notice |
|---|---|---|
| `crates/common/src/scripts/functions/text.rs` | [levenshtein-rs](https://github.com/wooorm/levenshtein-rs) | Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com> |
| `crates/common/src/telemetry/tracers/journald.rs` | the journald snippet | Copyright (c) 2018 Benjamin Saunders <ben.e.saunders@gmail.com> |
| `crates/jmap/src/registry/mapping/log.rs` | [rev_lines](https://github.com/mikeycgto/rev_lines) | Copyright (c) 2017 Michael Coyne <mjc@hey.com> |
| `crates/imap-proto/src/utf7.rs` | [MailKit](https://github.com/jstedfast/MailKit), by Jeffrey Stedfast | Copyright (C) 2013-2026 .NET Foundation and Contributors |
| `crates/nlp/src/tokenizers/japanese.rs` | [rust-tinysegmenter](https://github.com/woxtu/rust-tinysegmenter) | Copyright (c) 2015 woxtu |
| `crates/store/src/backend/postgres/tls.rs` | [tokio-postgres-rustls](https://github.com/jbg/tokio-postgres-rustls) | Copyright (c) 2019 Jasper Hugo |
| `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/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:
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal
> in the Software without restriction, including without limitation the rights
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
> copies of the Software, and to permit persons to whom the Software is
> furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in
> all copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
> SOFTWARE.
## Under the BSD 3-Clause license
| Where | From | Notice |
|---|---|---|
| `crates/jmap-proto/src/types/date.rs`, `crates/registry/src/types/datetime.rs` | [upb](https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c), the date parsing marked in each file | Copyright (c) 2009-2011, Google Inc. All rights reserved. |
```text
Copyright (c) 2009-2011, Google Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of Google Inc. nor the names of any other
contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY GOOGLE INC. ``AS IS'' AND ANY EXPRESS OR IMPLIED
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
EVENT SHALL GOOGLE INC. BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
```
## Credited algorithms
These files implement published algorithms and credit their source. No code
is copied, so there's no notice to carry. They're listed so the strip report
doesn't flag them as new.
- `crates/jmap-proto/src/types/date.rs`, `crates/registry/src/types/datetime.rs`:
`civil_from_days`, from Howard Hinnant's
[date algorithms](http://howardhinnant.github.io/date_algorithms.html)
- `crates/utils/src/glob.rs`: Russ Cox's
[glob matching](https://research.swtch.com/glob)
+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 -1
View File
@@ -13,6 +13,7 @@ directory = { path = "../directory" }
coordinator = { path = "../coordinator" } coordinator = { path = "../coordinator" }
types = { path = "../types" } types = { path = "../types" }
registry = { path = "../registry" } registry = { path = "../registry" }
inbuxa-features = { path = "../features" }
jmap_proto = { path = "../jmap-proto" } jmap_proto = { path = "../jmap-proto" }
sieve-rs = { version = "0.7", features = ["rkyv", "serde"] } sieve-rs = { version = "0.7", features = ["rkyv", "serde"] }
mail-parser = { version = "0.11", features = ["full_encoding"] } mail-parser = { version = "0.11", features = ["full_encoding"] }
@@ -53,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"] }
+10
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::AccessToken; use super::AccessToken;
@@ -796,6 +798,14 @@ impl AccessToken {
} }
impl AccessTokenInner { impl AccessTokenInner {
/// inbuxa: SCIM-27: the account's own effective permission, from its
/// roles, its own settings and its tenant, before a credential narrows it
pub fn account_has_permission(&self, permission: Permission) -> bool {
self.scopes
.first()
.is_some_and(|scope| scope.permissions.get(permission as usize))
}
pub fn from_id(account_id: u32) -> Self { pub fn from_id(account_id: u32) -> Self {
Self { Self {
account_id, account_id,
+105 -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 crate::{ use crate::{
@@ -185,7 +187,7 @@ impl Server {
}; };
is_alias_login = directory_account.email != auth_as_address; is_alias_login = directory_account.email != auth_as_address;
self.build_directory_token(directory_account, req.remote_ip) self.build_directory_token(directory, directory_account, req.remote_ip)
.await .await
} else if let Some(account_id) = } else if let Some(account_id) =
self.account_id_from_parts(auth_as_local, domain.id).await? self.account_id_from_parts(auth_as_local, domain.id).await?
@@ -335,7 +337,38 @@ impl Server {
{ {
match directory.authenticate(&req.credentials).await { match directory.authenticate(&req.credentials).await {
Ok(result) => { Ok(result) => {
return self.build_directory_token(result, req.remote_ip).await; // inbuxa: DIR-7: the token must be the named user's, or
// the named address an alias it may sign in with
let named = username
.as_deref()
.map(|name| UsernameParts::new(name).auth_as().address().to_lowercase());
let is_alias = match &named {
Some(named) if !named.eq_ignore_ascii_case(&result.email) => {
if !result
.email_aliases
.iter()
.any(|alias| alias.eq_ignore_ascii_case(named))
{
return Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, named.clone())
.details(result.email.clone())
.reason("The token belongs to a different user"));
}
true
}
_ => false,
};
let token = self
.build_directory_token(directory, result, req.remote_ip)
.await?;
if is_alias && !token.has_permission(Permission::AuthenticateWithAlias) {
return Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountId, token.account_id())
.reason("Authenticated using an email alias but account does not have AuthenticateAlias permission"));
}
return Ok(token);
} }
Err(err) => { Err(err) => {
external_error = Some(err); external_error = Some(err);
@@ -376,6 +409,8 @@ impl Server {
&& let Some(directory) = self.get_directory_for_cached_domain(&domain_cache) && let Some(directory) = self.get_directory_for_cached_domain(&domain_cache)
&& let Recipient::Account(account) = directory.recipient(address).await? && let Recipient::Account(account) = directory.recipient(address).await?
{ {
// inbuxa: DIR-6
self.assert_directory_serves(directory, &account.email).await?;
return Ok(Some(Box::pin(self.synchronize_account(account)).await?.id)); return Ok(Some(Box::pin(self.synchronize_account(account)).await?.id));
} }
@@ -497,21 +532,29 @@ impl Server {
async fn build_directory_token( async fn build_directory_token(
&self, &self,
directory: &Arc<Directory>,
account: directory::Account, account: directory::Account,
remote_ip: IpAddr, remote_ip: IpAddr,
) -> trc::Result<AccessToken> { ) -> trc::Result<AccessToken> {
// inbuxa: DIR-6
self.assert_directory_serves(directory, &account.email).await?;
let account = Box::pin(self.synchronize_account(account)).await?; let account = Box::pin(self.synchronize_account(account)).await?;
self.access_token_from_account(account.id, account.account) self.access_token_from_account(account.id, account.account)
.await .await
.and_then(|token| AccessToken::new(token, remote_ip)) .and_then(|token| AccessToken::new(token, remote_ip))
} }
/// inbuxa: DIR-1, DIR-5: the directory a domain signs in against: its
/// own, else the server default, else the internal one (`None`). An
/// unknown domain gets the server default.
pub async fn get_directory_for_domain( pub async fn get_directory_for_domain(
&self, &self,
domain_name: &str, domain_name: &str,
) -> trc::Result<Option<&Arc<Directory>>> { ) -> trc::Result<Option<&Arc<Directory>>> {
Ok(match self.domain(domain_name).await? {
Ok(self.get_default_directory()) Some(domain) => self.get_directory_for_cached_domain(&domain),
None => self.get_default_directory(),
})
} }
async fn get_directory_for_token(&self, token: &str) -> trc::Result<Option<&Arc<Directory>>> { async fn get_directory_for_token(&self, token: &str) -> trc::Result<Option<&Arc<Directory>>> {
@@ -531,15 +574,69 @@ impl Server {
} }
} }
fn get_directory_for_issuer(&self, issuer: &str) -> Option<&Arc<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 None
} }
/// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A
/// `directoryId` naming no directory the server built is unavailable,
/// never the internal directory.
pub fn get_directory_for_cached_domain(&self, domain: &DomainCache) -> Option<&Arc<Directory>> { pub fn get_directory_for_cached_domain(&self, domain: &DomainCache) -> Option<&Arc<Directory>> {
match domain.id_directory {
self.get_default_directory() Some(directory_id) => Some(
self.core
.storage
.directories
.get(&directory_id)
.unwrap_or_else(|| {
trc::event!(
Auth(trc::AuthEvent::Warning),
Domain = domain.name().to_string(),
Id = directory_id,
Reason = "The domain's directory doesn't exist; sign-in fails",
);
unavailable_directory()
}),
),
None => self.get_default_directory(),
} }
}
/// inbuxa: DIR-6: a directory speaks only for the domains it serves.
pub async fn assert_directory_serves(
&self,
directory: &Arc<Directory>,
address: &str,
) -> trc::Result<()> {
let serves = match address.rsplit_once('@') {
Some((_, domain)) => self
.get_directory_for_domain(domain)
.await?
.is_some_and(|effective| Arc::ptr_eq(effective, directory)),
None => false,
};
if serves {
Ok(())
} else {
Err(trc::AuthEvent::Failed
.into_err()
.ctx(trc::Key::AccountName, address.to_string())
.reason("The directory returned an account on a domain it doesn't serve"))
}
}
}
/// inbuxa: DIR-5: what a dangling `directoryId` resolves to.
pub fn unavailable_directory() -> &'static Arc<Directory> {
static UNAVAILABLE: std::sync::OnceLock<Arc<Directory>> = std::sync::OnceLock::new();
UNAVAILABLE.get_or_init(|| {
Arc::new(Directory::Unavailable(directory::UnavailableDirectory::new(
registry::schema::enums::DirectoryType::Ldap,
"The directory named by the domain doesn't exist",
)))
})
} }
#[derive(Deserialize)] #[derive(Deserialize)]
+8 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -69,7 +71,8 @@ pub struct DomainCache {
pub const DOMAIN_FLAG_RELAY: u8 = 1; pub const DOMAIN_FLAG_RELAY: u8 = 1;
pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1; pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1;
// inbuxa: SCIM-15, SCIM-58
pub const DOMAIN_FLAG_SCIM: u8 = 1 << 2;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct AccountCache { pub struct AccountCache {
@@ -329,4 +332,8 @@ impl DomainCache {
self.names.first().map(|s| s.as_ref()).unwrap_or_default() self.names.first().map(|s| s.as_ref()).unwrap_or_default()
} }
// inbuxa: SCIM-15, SCIM-58
pub fn allows_scim(&self) -> bool {
self.flags & DOMAIN_FLAG_SCIM != 0
}
} }
+45
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -64,10 +66,48 @@ impl Server {
.caused_by(trc::location!())? .caused_by(trc::location!())?
} }
// inbuxa: MT-13, MT-14, MT-15: cut down to what the tenant allows
if let Some(tenant_id) = tenant_id {
self.apply_tenant_ceiling(&mut permissions, tenant_id)
.await
.caused_by(trc::location!())?;
}
Ok(permissions) Ok(permissions)
} }
/// inbuxa: MT-13. The tenant's roles give the base; its own permission
/// lists adjust it (`inbuxa_features::tenancy::ceiling`).
async fn apply_tenant_ceiling(
&self,
permissions: &mut PermissionsGroup,
tenant_id: u32,
) -> trc::Result<()> {
use inbuxa_features::tenancy::ceiling::{Policy, ceiling};
let tenant = self.tenant(tenant_id).await?;
let base = self
.add_role_permissions(PermissionsGroup::default(), tenant.id_roles.iter().copied())
.await?
.finalize();
let policy = match tenant.permissions.as_deref() {
None => Policy::Inherit,
Some(list) if list.merge => Policy::Merge {
enabled: &list.enabled,
disabled: &list.disabled,
},
Some(list) => Policy::Replace {
enabled: &list.enabled,
disabled: &list.disabled,
},
};
ceiling(base, policy).apply(&mut permissions.enabled, &mut permissions.disabled);
// inbuxa: MT-1, MT-15: impersonation would reach beyond the tenant
permissions.disabled.set(Permission::Impersonate as usize);
Ok(())
}
pub async fn can_set_permissions( pub async fn can_set_permissions(
&self, &self,
access_token: &AccessToken, access_token: &AccessToken,
@@ -224,6 +264,11 @@ impl Default for DefaultPermissions {
default.superuser.push(permission); default.superuser.push(permission);
default.tenant.push(permission); default.tenant.push(permission);
} }
// inbuxa: MT-12: a tenant administrator reads its own tenant
Permission::SysTenantGet | Permission::SysTenantQuery => {
default.superuser.push(permission);
default.tenant.push(permission);
}
permission => { permission => {
let name = permission.as_str(); let name = permission.as_str();
if name.starts_with("jmap") if name.starts_with("jmap")
+100
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, auth::DomainCache, cache::invalidate::CacheInvalidationBuilder}; use crate::{Server, auth::DomainCache, cache::invalidate::CacheInvalidationBuilder};
@@ -51,6 +53,14 @@ impl Server {
.ctx(trc::Key::AccountId, account_id) .ctx(trc::Key::AccountId, account_id)
})?; })?;
// inbuxa: SCIM-58: SCIM is authoritative; sign-in changes nothing
if domain.allows_scim() {
return Ok(AccountWithId {
id: account_id,
account: Account::from(current_account),
});
}
let mut updated_account = Account::from(current_account.clone()) let mut updated_account = Account::from(current_account.clone())
.into_user() .into_user()
.ok_or_else(|| { .ok_or_else(|| {
@@ -79,6 +89,7 @@ impl Server {
for alias in account.email_aliases { for alias in account.email_aliases {
if let Some((local, alias_domain)) = self.validate_alias(&alias).await? if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
&& alias_domain.id_tenant == domain.id_tenant && alias_domain.id_tenant == domain.id_tenant
&& self.same_directory(&domain, &alias).await?
&& self && self
.rcpt_id_from_parts(local, alias_domain.id) .rcpt_id_from_parts(local, alias_domain.id)
.await? .await?
@@ -96,6 +107,12 @@ impl Server {
if let Some(groups) = account.groups { if let Some(groups) = account.groups {
let mut member_group_ids = Vec::with_capacity(groups.len()); let mut member_group_ids = Vec::with_capacity(groups.len());
for email in groups { for email in groups {
// inbuxa: SCIM-58: no group comes from a claim on a SCIM domain
if self.is_scim_address(&email).await?
|| !self.same_directory(&domain, &email).await?
{
continue;
}
member_group_ids.push( member_group_ids.push(
self.synchronize_group(directory::Group { self.synchronize_group(directory::Group {
email, email,
@@ -155,11 +172,19 @@ impl Server {
} }
} }
None => { None => {
// inbuxa: SCIM-58: accounts on this domain come from SCIM only
if domain.allows_scim() {
return Err(trc::AuthEvent::Failed
.into_err()
.details("The account isn't provisioned: its domain is managed by SCIM")
.ctx(trc::Key::AccountName, account.email));
}
let mut aliases = Vec::with_capacity(account.email_aliases.len()); let mut aliases = Vec::with_capacity(account.email_aliases.len());
for alias in account.email_aliases { for alias in account.email_aliases {
if let Some((local, alias_domain)) = self.validate_alias(&alias).await? if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
&& alias_domain.id_tenant == domain.id_tenant && alias_domain.id_tenant == domain.id_tenant
&& self.same_directory(&domain, &alias).await?
&& self && self
.rcpt_id_from_parts(local, alias_domain.id) .rcpt_id_from_parts(local, alias_domain.id)
.await? .await?
@@ -175,6 +200,12 @@ impl Server {
} }
let mut member_group_ids = Vec::new(); let mut member_group_ids = Vec::new();
for email in account.groups.unwrap_or_default() { for email in account.groups.unwrap_or_default() {
// inbuxa: SCIM-58: no group comes from a claim on a SCIM domain
if self.is_scim_address(&email).await?
|| !self.same_directory(&domain, &email).await?
{
continue;
}
member_group_ids.push( member_group_ids.push(
self.synchronize_group(directory::Group { self.synchronize_group(directory::Group {
email, email,
@@ -205,6 +236,8 @@ impl Server {
})); }));
// inbuxa: DIR-15
self.check_tenant_limits(&account).await?;
match self match self
.registry() .registry()
.write(RegistryWrite::insert(&account)) .write(RegistryWrite::insert(&account))
@@ -255,6 +288,11 @@ impl Server {
.ctx(trc::Key::AccountId, account_id) .ctx(trc::Key::AccountId, account_id)
})?; })?;
// inbuxa: SCIM-58: SCIM is authoritative; sign-in changes nothing
if domain.allows_scim() {
return Ok(account_id);
}
let mut updated_account = Account::from(current_account.clone()) let mut updated_account = Account::from(current_account.clone())
.into_group() .into_group()
.ok_or_else(|| { .ok_or_else(|| {
@@ -275,6 +313,7 @@ impl Server {
for alias in group.email_aliases { for alias in group.email_aliases {
if let Some((local, alias_domain)) = self.validate_alias(&alias).await? if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
&& alias_domain.id_tenant == domain.id_tenant && alias_domain.id_tenant == domain.id_tenant
&& self.same_directory(&domain, &alias).await?
&& self && self
.rcpt_id_from_parts(local, alias_domain.id) .rcpt_id_from_parts(local, alias_domain.id)
.await? .await?
@@ -322,11 +361,19 @@ impl Server {
} }
} }
None => { None => {
// inbuxa: SCIM-58: groups on this domain come from SCIM only
if domain.allows_scim() {
return Err(trc::AuthEvent::Error
.into_err()
.details("The group isn't provisioned: its domain is managed by SCIM")
.ctx(trc::Key::AccountName, group.email));
}
let mut aliases = Vec::with_capacity(group.email_aliases.len()); let mut aliases = Vec::with_capacity(group.email_aliases.len());
for alias in group.email_aliases { for alias in group.email_aliases {
if let Some((local, alias_domain)) = self.validate_alias(&alias).await? if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
&& alias_domain.id_tenant == domain.id_tenant && alias_domain.id_tenant == domain.id_tenant
&& self.same_directory(&domain, &alias).await?
&& self && self
.rcpt_id_from_parts(local, alias_domain.id) .rcpt_id_from_parts(local, alias_domain.id)
.await? .await?
@@ -353,6 +400,8 @@ impl Server {
})); }));
// inbuxa: DIR-15
self.check_tenant_limits(&account).await?;
match self match self
.registry() .registry()
.write(RegistryWrite::insert(&account)) .write(RegistryWrite::insert(&account))
@@ -378,6 +427,57 @@ impl Server {
} }
} }
/// inbuxa: DIR-6: whether an address is on a domain served by the same
/// directory as `domain`; a warning when it isn't.
async fn same_directory(&self, domain: &DomainCache, address: &str) -> trc::Result<bool> {
let Some((_, other)) = address.rsplit_once('@') else {
return Ok(true);
};
let Some(other) = self.domain(other).await? else {
return Ok(true);
};
let same = match (
self.get_directory_for_cached_domain(domain),
self.get_directory_for_cached_domain(&other),
) {
(None, None) => true,
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
_ => false,
};
if !same {
trc::event!(
Auth(trc::AuthEvent::Warning),
AccountName = address.to_string(),
Domain = other.name().to_string(),
Reason = "Dropped: the address is on a domain served by another directory",
);
}
Ok(same)
}
/// inbuxa: DIR-15, MT-3, MT-17: an object created from a directory
/// passes the same tenant checks as one created over JMAP.
async fn check_tenant_limits(&self, object: &Object) -> trc::Result<()> {
match inbuxa_features::tenancy::writes::check(self.registry(), None, None, object).await? {
Ok(_) => Ok(()),
Err(err) => Err(trc::AuthEvent::Failed
.into_err()
.details(err.description().unwrap_or("A tenant limit is reached").to_string())
.reason("The directory's account can't be created")),
}
}
/// inbuxa: SCIM-58: whether an address is on a domain SCIM manages.
async fn is_scim_address(&self, address: &str) -> trc::Result<bool> {
Ok(match address.rsplit_once('@') {
Some((_, domain)) => self
.domain(domain)
.await?
.is_some_and(|domain| domain.allows_scim()),
None => false,
})
}
async fn validate_address<'x>( async fn validate_address<'x>(
&self, &self,
email: &'x str, email: &'x str,
+31 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -17,7 +19,6 @@ use registry::{
}, },
types::id::ObjectId, types::id::ObjectId,
}; };
use store::{registry::RegistryQuery, roaring::RoaringBitmap};
use types::id::Id; use types::id::Id;
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -120,6 +121,10 @@ impl CacheInvalidationBuilder {
|| (current.sub_addressing != new.sub_addressing) || (current.sub_addressing != new.sub_addressing)
|| (current.allow_relaying != new.allow_relaying) || (current.allow_relaying != new.allow_relaying)
|| (current.is_enabled != new.is_enabled) || (current.is_enabled != new.is_enabled)
// inbuxa: SCIM-60, the flag is cached as DOMAIN_FLAG_SCIM,
// so turning SCIM's authority on or off has to take effect
// without a restart
|| (current.allow_scim_provisioning != new.allow_scim_provisioning)
{ {
self.invalidate(CacheInvalidation::Domain(id)); self.invalidate(CacheInvalidation::Domain(id));
} }
@@ -281,6 +286,15 @@ impl Server {
.registry() .registry()
.linked_objects(ObjectId::new(ObjectType::Role, role_id.into())) .linked_objects(ObjectId::new(ObjectType::Role, role_id.into()))
.await?; .await?;
// inbuxa: MT-16: a role a tenant holds sets its ceiling
for tenant_id in inbuxa_features::tenancy::members::tenants_using_role(
self.registry(),
&linked_objects,
)
.await?
{
changes.insert(CacheInvalidation::Tenant(tenant_id));
}
for linked_object in linked_objects { for linked_object in linked_objects {
match linked_object.object() { match linked_object.object() {
ObjectType::Account => { ObjectType::Account => {
@@ -298,6 +312,22 @@ impl Server {
} }
} }
// inbuxa: MT-16: a tenant's change reaches its people on their next request
let tenant_ids = changes
.iter()
.filter_map(|change| match change {
CacheInvalidation::Tenant(tenant_id) => Some(*tenant_id),
_ => None,
})
.collect::<Vec<_>>();
for tenant_id in tenant_ids {
for account_id in
inbuxa_features::tenancy::members::accounts(self.registry(), tenant_id).await?
{
changes.insert(CacheInvalidation::AccessToken(account_id));
}
}
let changes = changes.into_iter().collect::<Vec<_>>(); let changes = changes.into_iter().collect::<Vec<_>>();
self.invalidate_local_caches(&changes).await; self.invalidate_local_caches(&changes).await;
self.cluster_broadcast(BroadcastEvent::CacheInvalidate(changes)) self.cluster_broadcast(BroadcastEvent::CacheInvalidate(changes))
+32 -3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -28,7 +30,7 @@ use registry::{
enums::{DkimRotationStage, Locale, StorageQuota, TenantStorageQuota}, enums::{DkimRotationStage, Locale, StorageQuota, TenantStorageQuota},
prelude::{ObjectType, Property}, prelude::{ObjectType, Property},
structs::{ structs::{
Account, DkimSignature, Domain, EncryptionAtRest, MailingList, MaskedEmail, Account, DkimSignature, Domain, EncryptionAtRest, MailingList,
Permissions, PublicKey, Role, SubAddressing, Tenant, Permissions, PublicKey, Role, SubAddressing, Tenant,
}, },
}, },
@@ -38,7 +40,7 @@ use std::{borrow::Cow, sync::Arc};
use store::{ use store::{
U64_LEN, U64_LEN,
registry::{RegistryQuery, bootstrap::Bootstrap}, registry::{RegistryQuery, bootstrap::Bootstrap},
write::{key::KeySerializer, now}, write::key::KeySerializer,
}; };
use trc::{AddContext, StoreEvent}; use trc::{AddContext, StoreEvent};
use types::id::Id; use types::id::Id;
@@ -158,7 +160,11 @@ impl Server {
if domain.allow_relaying { if domain.allow_relaying {
flags |= DOMAIN_FLAG_RELAY; flags |= DOMAIN_FLAG_RELAY;
} }
// inbuxa: SCIM-15, SCIM-58: the domain is open to SCIM, and SCIM is
// authoritative for its accounts
if domain.allow_scim_provisioning {
flags |= crate::auth::DOMAIN_FLAG_SCIM;
}
let sub_addressing_custom = match domain.sub_addressing { let sub_addressing_custom = match domain.sub_addressing {
SubAddressing::Enabled => { SubAddressing::Enabled => {
@@ -293,6 +299,29 @@ impl Server {
Ok(Some(result)) Ok(Some(result))
} else { } else {
// inbuxa: ME-4, ME-6: a live masked address reaches its
// owner; a refused one isn't cached, as it can come back
if let Some(domain) = self.domain_by_id(domain_id).await?
&& let Some(name) = domain.names.first()
{
use inbuxa_features::masked_email::ops::{Lookup, lookup};
match lookup(
&self.core.storage.data,
self.registry(),
&format!("{local_part}@{name}"),
)
.await?
{
Lookup::Accepts(mask) => {
return Ok(Some(EmailCache::Account(
mask.object.account_id.document_id(),
)));
}
Lookup::Refuses => return Ok(None),
Lookup::Unknown => {}
}
}
// Cache negative result // Cache negative result
emails_negative.insert( emails_negative.insert(
EmailAddress::new(local_part, domain_id), EmailAddress::new(local_part, domain_id),
+3 -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 crate::{ use crate::{
@@ -130,8 +132,7 @@ impl Server {
// Update tracers // Update tracers
#[cfg(not(feature = "enterprise"))] tracers.update();
tracers.update(false);
// Reload queue settings // Reload queue settings
self.inner self.inner
+13
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 calcard::vcard::VCardVersion; use calcard::vcard::VCardVersion;
@@ -100,6 +102,17 @@ impl GroupwareConfig {
let dr = bp.setting_infallible::<DataRetention>().await; let dr = bp.setting_infallible::<DataRetention>().await;
let system = bp.setting_infallible::<SystemSettings>().await; let system = bp.setting_infallible::<SystemSettings>().await;
// inbuxa: BT-19: a stored template that doesn't parse is reported at
// start and on each reload; the built-in is used meanwhile
inbuxa_features::branding::templates::warn_unusable::<CalendarTemplateVariable>(
"CalendarAlarm.template",
alarm.template.as_deref(),
);
inbuxa_features::branding::templates::warn_unusable::<CalendarTemplateVariable>(
"CalendarScheduling.emailTemplate",
sched.email_template.as_deref(),
);
GroupwareConfig { GroupwareConfig {
max_request_size: dav.request_max_size as usize, max_request_size: dav.request_max_size as usize,
dead_property_size: dav.dead_property_max_size.map(|v| v as usize), dead_property_size: dav.dead_property_max_size.map(|v| v as usize),
+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(),
+26 -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 crate::{ use crate::{
@@ -90,7 +92,7 @@ impl Scripting {
.with_protected_headers(untrusted.protected_headers) .with_protected_headers(untrusted.protected_headers)
.with_vacation_default_subject(untrusted.default_subject) .with_vacation_default_subject(untrusted.default_subject)
.with_vacation_subject_prefix(untrusted.default_subject_prefix) .with_vacation_subject_prefix(untrusted.default_subject_prefix)
.with_env_variable("name", "Stalwart Server") .with_env_variable("name", types::brand_server!())
.with_env_variable("version", VERSION_PUBLIC) .with_env_variable("version", VERSION_PUBLIC)
.with_env_variable("location", "MS") .with_env_variable("location", "MS")
.with_env_variable("phase", "during"); .with_env_variable("phase", "during");
@@ -141,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);
@@ -277,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);
}
}
}
@@ -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::expr::{ use crate::expr::{
@@ -241,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())
@@ -426,13 +429,24 @@ impl SpamFilterLists {
&tag.tag, &tag.tag,
SpamFilterAction::Allow(tag.score.into_inner() as f32), SpamFilterAction::Allow(tag.score.into_inner() as f32),
), ),
SpamTag::Discard(tag) => lists SpamTag::Discard(tag) => {
warn_llm_refusal(&tag.tag);
lists
.scores .scores
.insert_pattern(&tag.tag, SpamFilterAction::Discard), .insert_pattern(&tag.tag, SpamFilterAction::Discard)
SpamTag::Reject(tag) => lists
.scores
.insert_pattern(&tag.tag, SpamFilterAction::Reject),
} }
SpamTag::Reject(tag) => {
warn_llm_refusal(&tag.tag);
lists
.scores
.insert_pattern(&tag.tag, SpamFilterAction::Reject)
}
}
}
// inbuxa: AI-2: at start and each reload, models off this network are flagged
for model in bp.list_infallible::<registry::schema::structs::AiModel>().await {
crate::enterprise::llm::warn_if_remote(&model.object).await;
} }
for ext in bp.list_infallible::<SpamFileExtension>().await { for ext in bp.list_infallible::<SpamFileExtension>().await {
@@ -740,3 +754,16 @@ mod tests {
)); ));
} }
} }
// inbuxa: AI-13: a Discard or Reject on the model's tag counts as no entry
fn warn_llm_refusal(tag: &str) {
if inbuxa_features::ai::answer::is_llm_tag(tag) {
trc::event!(
Registry(trc::RegistryEvent::BuildWarning),
Details = format!(
"Spam tag {tag} discards or rejects, which the language model's opinion alone \
may not do: it scores 0 (AI-13)"
),
);
}
}
+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 std::io::Cursor; use std::io::Cursor;
@@ -38,7 +40,7 @@ pub mod storage;
pub mod telemetry; pub mod telemetry;
impl Core { impl Core {
pub async fn parse(bp: &mut Bootstrap, mut storage: Storage) -> Self { pub async fn parse(bp: &mut Bootstrap, storage: Storage) -> Self {
Self { Self {
sieve: Scripting::parse(bp).await, sieve: Scripting::parse(bp).await,
+41 -5
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::*;
@@ -45,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>,
} }
@@ -62,6 +67,9 @@ pub struct Http {
pub url_https: String, pub url_https: String,
pub allowed_endpoint: IfBlock, pub allowed_endpoint: IfBlock,
pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>, pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>,
/// inbuxa: origins allowed cross-origin access (contract C-14). Empty when
/// CORS is permissive (bootstrap, recovery, or `usePermissiveCors`).
pub cors_origins: Vec<hyper::header::HeaderValue>,
pub use_forwarded: bool, pub use_forwarded: bool,
pub redirect_root: Option<String>, pub redirect_root: Option<String>,
} }
@@ -190,7 +198,7 @@ impl Network {
}), }),
info: Info { info: Info {
provider: Provider { provider: Provider {
name: "Stalwart".into(), name: types::brand!().into(),
..Default::default() ..Default::default()
}, },
..Default::default() ..Default::default()
@@ -315,11 +323,26 @@ impl Network {
} }
} }
let (prefix, suffix) = serde_json::to_string(&pacc) let split = |pacc: &Configuration| {
serde_json::to_string(pacc)
.unwrap_or_default() .unwrap_or_default()
.rsplit_once(SPLIT_HERE) .rsplit_once(SPLIT_HERE)
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) .map(|(prefix, suffix)| Pacc {
.unwrap(); 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(),
@@ -334,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,
}, },
}; };
@@ -408,6 +432,17 @@ impl Http {
#[cfg(not(feature = "dev_mode"))] #[cfg(not(feature = "dev_mode"))]
let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode(); let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode();
// inbuxa: otherwise only the front ends' origins get cross-origin
// access, echoed per request (contract C-14)
let cors_origins = if use_permissive_cors {
Vec::new()
} else {
crate::manager::first_party::front_end_origins()
.into_iter()
.filter_map(|origin| hyper::header::HeaderValue::from_str(&origin).ok())
.collect()
};
if use_permissive_cors { if use_permissive_cors {
http_headers.push(( http_headers.push((
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN, hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
@@ -464,6 +499,7 @@ impl Http {
http.rate_limit_anonymous http.rate_limit_anonymous
}, },
response_headers: http_headers, response_headers: http_headers,
cors_origins,
use_forwarded: http.use_x_forwarded, use_forwarded: http.use_x_forwarded,
redirect_root: http.redirect_root, redirect_root: http.redirect_root,
} }
+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::{ use super::{
@@ -69,7 +71,7 @@ impl Listeners {
bind: Map::new(vec![ bind: Map::new(vec![
SocketAddr::from_str(&format!( SocketAddr::from_str(&format!(
"[::]:{}", "[::]:{}",
std::env::var("STALWART_RECOVERY_MODE_PORT") types::branding::env_var("RECOVERY_MODE_PORT")
.ok() .ok()
.and_then(|p| p.parse::<u16>().ok()) .and_then(|p| p.parse::<u16>().ok())
.unwrap_or(8080) .unwrap_or(8080)
+10 -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 coordinator::Coordinator; use coordinator::Coordinator;
@@ -41,12 +43,19 @@ impl Storage {
); );
} }
let coordinator = Coordinator::build(bp, &memory).await.unwrap_or_default();
// inbuxa: ST-7: with more than one node, read replicas share
// high-water marks through the in-memory store
if !matches!(coordinator, Coordinator::None) {
bp.data_store.share_marks(&memory);
}
Storage { Storage {
registry: bp.registry.clone(), registry: bp.registry.clone(),
data: bp.data_store.clone(), data: bp.data_store.clone(),
blob: BlobStore::build(bp).await.unwrap_or_default(), blob: BlobStore::build(bp).await.unwrap_or_default(),
search, search,
coordinator: Coordinator::build(bp, &memory).await.unwrap_or_default(), coordinator,
memory, memory,
tracing: Store::build_tracing(bp).await.unwrap_or_default(), tracing: Store::build_tracing(bp).await.unwrap_or_default(),
metrics: Store::build_metrics(bp).await.unwrap_or_default(), metrics: Store::build_metrics(bp).await.unwrap_or_default(),
+47 -5
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::config::storage::Storage; use crate::config::storage::Storage;
@@ -40,6 +42,15 @@ pub enum TelemetrySubscriberType {
Webhook(WebhookTracer), Webhook(WebhookTracer),
#[cfg(unix)] #[cfg(unix)]
JournalTracer(crate::telemetry::tracers::journald::Subscriber), JournalTracer(crate::telemetry::tracers::journald::Subscriber),
// inbuxa: MON-10: trace history
StoreTracer(StoreTracer),
}
/// Where trace history goes: traces to `tracing`, index tasks to `data`.
#[derive(Debug)]
pub struct StoreTracer {
pub tracing: store::Store,
pub data: store::Store,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -386,6 +397,7 @@ impl Tracers {
TelemetrySubscriberType::JournalTracer(_) => { TelemetrySubscriberType::JournalTracer(_) => {
EventType::Telemetry(TelemetryEvent::JournalError).into() EventType::Telemetry(TelemetryEvent::JournalError).into()
} }
TelemetrySubscriberType::StoreTracer(_) => None,
}; };
// Parse disabled events // Parse disabled events
@@ -477,6 +489,36 @@ impl Tracers {
} }
} }
// inbuxa: MON-10 to MON-12: trace history, when a tracing store is set:
// info and above, the span edges and MAIL FROM, never raw I/O
if !storage.tracing.is_none() {
let mut interests = Interests::default();
for event_type in EventType::variants() {
let event_level = custom_levels
.get(event_type)
.copied()
.unwrap_or(event_type.level());
if !event_type.is_raw_io()
&& (Level::Info.is_contained(event_level)
|| event_type.is_span_start()
|| event_type.is_span_end()
|| event_type.as_str().starts_with("smtp.mail-from"))
{
interests.set(event_type.to_id() as usize);
global_interests.set(event_type.to_id() as usize);
}
}
tracers.push(TelemetrySubscriber {
id: "trace-history".to_string(),
interests,
typ: TelemetrySubscriberType::StoreTracer(StoreTracer {
tracing: storage.tracing.clone(),
data: storage.data.clone(),
}),
lossy: true,
});
}
#[cfg(feature = "dev_mode")] #[cfg(feature = "dev_mode")]
if let Ok(level) = std::env::var("LOG") { if let Ok(level) = std::env::var("LOG") {
let level = Level::from_str(&level).expect("Invalid LOG level"); let level = Level::from_str(&level).expect("Invalid LOG level");
@@ -503,7 +545,7 @@ impl Tracers {
} }
} else { } else {
// Add default tracer if none were found // Add default tracer if none were found
let level = std::env::var("STALWART_RECOVERY_MODE_LOG_LEVEL") let level = types::branding::env_var("RECOVERY_MODE_LOG_LEVEL")
.ok() .ok()
.and_then(|level| Level::from_str(&level).ok()) .and_then(|level| Level::from_str(&level).ok())
.unwrap_or(Level::Info); .unwrap_or(Level::Info);
@@ -541,11 +583,11 @@ 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, env!("CARGO_PKG_VERSION"))) .with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
.build(); .build();
let instrumentation = InstrumentationScope::builder("stalwart") let instrumentation = InstrumentationScope::builder("inbuxa")
.with_version(env!("CARGO_PKG_VERSION")) .with_version(types::brand_version_full!())
.build(); .build();
Metrics { Metrics {
+355
View File
@@ -0,0 +1,355 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Calling the operator's model (AI spam classification spec, AI-5 to AI-11,
//! AI-21 to AI-25). The rules live in `inbuxa_features::ai`; this makes the
//! HTTP request. The wire types are the OpenAI-compatible chat completions
//! shapes local model servers speak.
use crate::Server;
use inbuxa_features::ai::{
gate::{Gate, Refused, Transition},
limits::{self, AiLimits},
locality,
request::{self, Kind, MAX_RESPONSE_BYTES},
};
use registry::schema::{
enums::AiModelType,
prelude::ObjectType,
structs::AiModel,
};
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use store::registry::RegistryQuery;
use trc::AiEvent;
use types::id::Id;
/// A chat completions request.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChatCompletionRequest {
pub model: String,
pub messages: Vec<Message>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(default)]
pub stream: bool,
}
/// One chat message.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Message {
pub role: String,
pub content: String,
}
/// A chat completions response.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChatCompletionResponse {
pub created: i64,
pub object: String,
pub id: String,
pub model: String,
pub choices: Vec<ChatCompletionChoice>,
}
/// One choice in a response.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChatCompletionChoice {
pub index: u32,
pub finish_reason: String,
pub message: Message,
}
/// Why a call produced no answer. Every one leaves mail flowing (AI-9).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Failure {
Refused(Refused),
Timeout,
Http(String),
Status(u16),
BadAnswer,
}
/// One call to make.
pub struct Call<'x> {
pub model_id: Id,
pub model: &'x AiModel,
/// Set for an account's own script (AI-24, AI-25).
pub account_id: Option<u32>,
pub system: Option<&'x str>,
pub user: &'x str,
pub temperature: f64,
pub max_tokens: u32,
pub timeout: Duration,
}
fn kind(model: &AiModel) -> Kind {
match model.model_type {
AiModelType::Chat => Kind::Chat,
AiModelType::Text => Kind::Text,
}
}
impl Server {
/// The fork's limits, as stored now.
pub async fn ai_limits(&self) -> AiLimits {
limits::get(&self.core.storage.data)
.await
.unwrap_or_default()
}
/// A model by its id.
pub async fn ai_model_by_id(&self, id: Id) -> Option<AiModel> {
self.registry().object::<AiModel>(id).await.ok().flatten()
}
/// A model by name, or failing that by id (AI-20).
pub async fn ai_model_by_name(&self, name: &str) -> Option<(Id, AiModel)> {
let ids = self
.registry()
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::AiModel))
.await
.ok()?;
let mut by_id = None;
for id in ids {
if let Some(model) = self.ai_model_by_id(id).await {
if model.name == name {
return Some((id, model));
}
if id.to_string() == name {
by_id = Some((id, model));
}
}
}
by_id
}
/// Makes one call. The answer, or why there is none; either way the
/// outcome is logged, with no message content and no secret (AI-5).
pub async fn ai_call(&self, call: Call<'_>) -> Result<String, Failure> {
let limits = self.ai_limits().await;
let gate = Gate::global();
let permit = match gate.try_start(call.model_id.id(), call.account_id, limits.gate()) {
Ok(permit) => permit,
Err(refused) => {
trc::event!(
Ai(AiEvent::ApiError),
Details = call.model.name.clone(),
AccountId = call.account_id,
Reason = format!("{refused:?}"),
);
return Err(Failure::Refused(refused));
}
};
let started = Instant::now();
let result = tokio::time::timeout(call.timeout, self.ai_request(&call)).await;
let result = match result {
Ok(result) => result,
Err(_) => Err(Failure::Timeout),
};
let transition = permit.finish(result.is_ok(), limits.failure_backoff.into_inner());
match &transition {
Some(Transition::Paused) => trc::event!(
Ai(AiEvent::ApiError),
Details = call.model.name.clone(),
Reason = format!(
"Paused for {}s after repeated failures",
limits.failure_backoff.into_inner().as_secs()
),
),
Some(Transition::Resumed) => trc::event!(
Ai(AiEvent::LlmResponse),
Details = call.model.name.clone(),
Reason = "Resumed after a pause",
),
None => {}
}
match &result {
Ok(answer) => trc::event!(
Ai(AiEvent::LlmResponse),
Details = call.model.name.clone(),
AccountId = call.account_id,
Elapsed = started.elapsed(),
Result = request::cut(answer, 1024),
),
Err(failure) => trc::event!(
Ai(AiEvent::ApiError),
Details = call.model.name.clone(),
AccountId = call.account_id,
Elapsed = started.elapsed(),
Code = match failure {
Failure::Status(code) => *code as u64,
_ => 0,
},
Reason = format!("{failure:?}"),
),
}
result
}
async fn ai_request(&self, call: &Call<'_>) -> Result<String, Failure> {
let model = call.model;
let kind = kind(model);
let body = request::body(
kind,
&model.model,
call.system,
call.user,
call.temperature,
call.max_tokens,
);
// Secrets are read now, from their source (AI-8)
let headers = model
.http_auth
.build_headers(model.http_headers.clone(), Some("application/json"))
.await
.map_err(Failure::Http)?;
let client = utils::http::http_client_builder(model.allow_invalid_certs)
// A redirect would send content to a host nobody named (AI-8)
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(call.timeout)
.timeout(call.timeout)
.default_headers(headers)
.build()
.map_err(|err| Failure::Http(err.to_string()))?;
let mut response = client
.post(&model.url)
.body(body.to_string())
.send()
.await
.map_err(|err| {
if err.is_timeout() {
Failure::Timeout
} else {
Failure::Http(err.without_url().to_string())
}
})?;
let status = response.status().as_u16();
if status != 200 {
return Err(Failure::Status(status));
}
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|err| Failure::Http(err.without_url().to_string()))?
{
bytes.extend_from_slice(&chunk);
if bytes.len() > MAX_RESPONSE_BYTES {
return Err(Failure::BadAnswer);
}
}
request::answer(kind, &bytes).ok_or(Failure::BadAnswer)
}
/// AI-2: warns when a model's endpoint isn't on this network.
pub async fn ai_warn_if_remote(&self, model: &AiModel) {
warn_if_remote(model).await
}
}
/// AI-2: warns when a model's endpoint isn't on this network. Names are
/// resolved; any address outside the local ranges counts.
pub async fn warn_if_remote(model: &AiModel) {
let local = match locality::classify(&model.url) {
Some(local) => local,
None => {
let host = locality::host(&model.url).unwrap_or_default().to_string();
match tokio::net::lookup_host((host.as_str(), 443)).await {
Ok(addrs) => {
let addrs = addrs.map(|a| a.ip()).collect::<Vec<_>>();
!addrs.is_empty()
&& addrs.into_iter().all(locality::is_local_ip)
}
Err(_) => false,
}
}
};
if !local {
trc::event!(
Registry(trc::RegistryEvent::BuildWarning),
Details = locality::warning(&model.name, &model.url),
);
}
}
/// The most of a script's prompt sent (AI-23).
const MAX_PROMPT_BYTES: usize = 32 * 1024;
/// The most of an answer a script gets back (AI-22).
const MAX_SCRIPT_ANSWER_BYTES: usize = 8 * 1024;
/// The longest an account's own script waits (AI-23).
const ACCOUNT_SCRIPT_CEILING: Duration = Duration::from_secs(60);
/// `llm_prompt(model, prompt, temperature)` (AI-20 to AI-25). The answer as
/// plain text, or `None`, which the script sees as `false`.
pub async fn sieve_prompt(
ctx: crate::scripts::plugins::PluginContext<'_>,
) -> Option<String> {
use registry::schema::enums::Permission;
use sieve::runtime::Variable;
let server = ctx.server;
let name = ctx.arguments.first()?.to_string();
let prompt = ctx.arguments.get(1)?.to_string();
let temperature = match ctx.arguments.get(2) {
Some(Variable::Float(t)) => Some(*t),
Some(Variable::Integer(t)) => Some(*t as f64),
_ => None,
};
// AI-23: trusted system scripts always; an account's own with interactAi
let account_id = match ctx.access_token {
Some(token) if !token.has_permission(Permission::InteractAi) => {
trc::event!(
Ai(AiEvent::ApiError),
SpanId = ctx.session_id,
AccountId = token.account_id(),
Reason = "The account may not call AI models",
);
return None;
}
Some(token) => Some(token.account_id()),
None => None,
};
let Some((model_id, model)) = server.ai_model_by_name(name.as_ref()).await else {
trc::event!(
Ai(AiEvent::ApiError),
SpanId = ctx.session_id,
AccountId = account_id,
Reason = format!("No AI model named {name:?}"),
);
return None;
};
let limits = server.ai_limits().await;
let timeout = match account_id {
Some(_) => model.timeout.into_inner().min(ACCOUNT_SCRIPT_CEILING),
None => model
.timeout
.into_inner()
.min(limits.spam_call_ceiling.into_inner()),
};
let prompt = request::cut(&prompt, MAX_PROMPT_BYTES);
let answer = server
.ai_call(Call {
model_id,
model: &model,
account_id,
system: None,
user: &prompt,
temperature: temperature.unwrap_or_else(|| model.temperature.into_inner()),
max_tokens: request::PROMPT_MAX_TOKENS,
timeout,
})
.await
.ok()?;
// Plain data: never evaluated (AI-22)
Some(request::cut(answer.trim(), MAX_SCRIPT_ANSWER_BYTES))
}
+10
View File
@@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Rebuilt features that sit on the server itself, at the paths the shared
//! tests name. The rules live in `inbuxa_features`.
pub mod llm;
+7
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::config::smtp::{ use crate::config::smtp::{
@@ -43,6 +45,11 @@ pub enum PushEvent {
account_id: u32, account_id: u32,
broadcast: bool, broadcast: bool,
}, },
// inbuxa: SCIM-52: ends the push subscriptions the account itself holds
// (IMAP IDLE, JMAP event streams and WebSockets) on this node
Revoke {
account_id: u32,
},
Stop, Stop,
} }
+24 -59
View File
@@ -2,8 +2,14 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
// inbuxa: composite stores (sharded members, read replicas) nest store
// futures deeply enough to pass rustc's default query depth
#![recursion_limit = "512"]
#![warn(clippy::large_futures)] #![warn(clippy::large_futures)]
use crate::auth::{AccessTokenInner, EmailAddress}; use crate::auth::{AccessTokenInner, EmailAddress};
@@ -67,6 +73,7 @@ pub mod i18n;
pub mod ipc; pub mod ipc;
pub mod manager; pub mod manager;
pub mod network; pub mod network;
pub mod enterprise; // inbuxa: rebuilt features (AI spam classification)
pub mod scripts; pub mod scripts;
pub mod sharing; pub mod sharing;
pub mod storage; pub mod storage;
@@ -78,9 +85,9 @@ pub use psl;
pub static VERSION_PRIVATE: &str = env!("CARGO_PKG_VERSION"); pub static VERSION_PRIVATE: &str = env!("CARGO_PKG_VERSION");
pub static VERSION_PUBLIC: &str = "1.0.0"; pub static VERSION_PUBLIC: &str = "1.0.0";
pub static USER_AGENT: &str = "Stalwart/1.0.0"; pub static USER_AGENT: &str = concat!(types::brand!(), "/1.0.0");
pub static DAEMON_NAME: &str = concat!("Stalwart v", env!("CARGO_PKG_VERSION"),); pub static DAEMON_NAME: &str = concat!(types::brand!(), " v", types::brand_version!(),);
pub static PROD_ID: &str = "-//Stalwart Labs LLC//Stalwart Server//EN"; pub static PROD_ID: &str = types::brand_prodid!();
/* /*
@@ -143,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,
@@ -161,6 +172,9 @@ pub struct Data {
pub struct LogoCache { pub struct LogoCache {
domain_id: u32, domain_id: u32,
tenant_id: Option<u32>, tenant_id: Option<u32>,
// inbuxa: read again when the /logo endpoint (per-tenant and per-domain
// branding) is rebuilt; docs/spec/features/multi-tenancy.md MT-22.
#[allow(dead_code)]
data: Option<Resource<Vec<u8>>>, data: Option<Resource<Vec<u8>>>,
} }
@@ -415,59 +429,10 @@ pub struct ThrottleKeyHasher {
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct ThrottleKeyHasherBuilder {} pub struct ThrottleKeyHasherBuilder {}
pub const DEFAULT_LOGO_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAMgAAAAnCAMAAAB9lPf7AAABOFBMVEUAAAAAADoAAEkPDkIPDkIQ\r\n\ /// The logo embedded in calendar emails when no custom logo is set: INBUXA's
DkIQDkLcLVTYMVTbLVTbLVQPDkLWM2YQDkIPD0IODEHaJlPbLVQWDT8MC0IQDkIQDkIPDkIODkEQ\r\n\ /// compact lockup at 380x80, twice its 180-pixel display width. Base64 with
D0ITDEAQDkIPDkIODkIPDkIPDkIRDUIQDkIQDUIPDkLcLVQQDkIQDkIQDkIQDULbLVQQDUIPDkIQ\r\n\ /// CRLF line breaks, ready for a base64 MIME part.
EEIPDkIPD0EPDkHcLlUPDkLbLFQPDULbLFPbLVQQDkLbLFPcK1TbLVTbLVQQDUIPDkIPDkIPDULc\r\n\ pub const DEFAULT_LOGO_BASE64: &str = include_str!(concat!(
LVTcLVQQDkLbLFTcLFTbLFQPDkIQDkIQDULbLVUSEkPcLVTbLVQQDULbLFTcLVTbLVTbLVPbLFQP\r\n\ env!("CARGO_MANIFEST_DIR"),
DUHbLVMPDkPbLVTdLFXbLFQNDULcLVTcLlMRD0cQDkIQDkXpMFnrMFrtMFvlL1jjLlfdLVThLlYS\r\n\ "/../../resources/branding/email-logo.png.b64"
EErnL1gRD0n0Ml2YjG1wAAAAWnRSTlMABAb59/379wr7/lMF9HgoBvQJF/BtZSQwGu7JNulAO95x\r\n\ ));
WD3TsF1M3b6kH5MRiRe5saujyH9oHezk4tjGmn9fwkc1vrVqYA8N19DPqnFQkYh1V0a4LSITmSiJ\r\n\
LN30AAAKZUlEQVRYw91ZCVvbRhCVrcuWD7AxYLANtrEx5opDIBCOQBLO5qTQ0uyubyD//x/0za4O\r\n\
RIG0/dLvazu0tbTaWc3bOd6sqv2PJen9/LcF9o++fnf58r8OBcb/vNBttzvrc0Ck/VcFMN6+a/fs\r\n\
uG23+x9+fcopkUiE/ks/35ew2j8ucMCz3wbteNwZtIcZp2O/jj3mlWjo+t8nHzNdx3ac/sLrTDee\r\n\
GXRf7cMpD+OYnF9dO6zvnc/gOuLv93dBRScTkz/KWrwu+mBUfTrrwhHtod1b0J696N/E7T5S5UEo\r\n\
xbrBlIidyp90CvCO1cu56you/jFBAP1y2evZdq/z4suwv3CiaXPr7X7Gbg9fbP4xvtKccSNlGEYq\r\n\
ZQpWG3GRTM2ki49biUkjgpks8aOAZCcT6ZX7ME4+Izkyw85Pn7T9m+FCjMb2X3UHSJXM62TIKxFt\r\n\
lXHdEPAG/jUNnelFQjJVM8T1BK4e98hIiqd+DBCskcgZjE/dW+zlFiVHd+sjXfcICEnsS7wTtwfd\r\n\
hZ8DJFBsMNPirLxabSQqTSFMQ5RWyErBDTH/NBCdGz8MyPm1wUtjocXe/NQZZuLtwednMN4FkpSO\r\n\
+vqhh1Tp3bx7qwXyXuhcLLsLNHJAwvZg/6TBLRYGEoE8AOTplAqrPx6nR8wQ01N3Fht90evF4+2b\r\n\
y1+k7QEQLTkqQfbt+M3wi29NUZgWO8RSJLg1ucVTY6hj5j0geCh//pZHAvX7s+nNtOr4fSCbw0Hc\r\n\
cdbd8ElKIIESMf3AsftbSW8nJphusqqWdTMOGQNgVRdINUQvkZWrLP24Ix6Q74qn46kHEtwqIKda\r\n\
IJtbDgQ4YpoLxMk89+Xy+W/rAyc+OPOBpBki6wIX7v2s4CZblUBMXjtstgoNLYK/6PxarVQqt8Zh\r\n\
zEThEKMKSCPaah4WKporK/Vm6yCP+SqDC61ma0paW10k9SYVpirUq3Jqcw23F8tr0K8wg+sHrSbN\r\n\
l/LMadvxwfDyKyKL/vn4DQSipNdG19XtO3G7+8rf6Ap5pKJlPZ9H8/l8ccoFQrXsukIoG2Vcco6R\r\n\
UkNrXfPrI83zSA135YhXOm6hseHt8yEe1Wh4puapT89rixjdQF3kGFvKHtA7FskOeow/RbGKCG2n\r\n\
7Xw5kdG1+bzXaUvp9uIkjiRGH0iCWSafzitmDUhdATFNpP4RZm0wYRm6EFTKxHxBpMSEC6QKE1I6\r\n\
B+NItWWWMsSupmRsmudok7QjqoaGVOdsoiVS7Fw+tfhx4dpI5cSyAoL3cWPyTmvScWx0JC9din+h\r\n\
5POH+MBx4rJVCWTKgLrQV4tZt7QADWxSQCAWgCCATcsSorxbKDGMUUELgOQt/KSBltR3hMW5vuRG\r\n\
Fowz84glMJUl2PbuLtRNqOssDSA5WJ7CdeAREssDEqNmcQg+7Ldp42PJoCwPHLgq/iWG0WDrl29T\r\n\
WJzx7cONxEjUTz9KdpFeQqCNaXm80WKFGWBdmS8JHdABxE/2XWGwA68GcuBgx4CFu0WWYvDOEqw1\r\n\
We0C6qeJMtR9ILRVopmeqMxQsvPczCTeB9UACrXvGftGNu8xd6SD0PJHAokc3ILPLcoHxsuL1RUM\r\n\
uUC8klRnusXqXryUsekhIGkGnyxBi0JQJycuyiWy20JavMcMizW9TgS8FQJSfaRqPfuEFB+VB6qB\r\n\
HVfNu+sjGz3LG8DA0093XBJdRIaZOgmhKW1g0OeRLHEjDBZlTJSGSP+EgOTJdtXN7FJkmWI7S+l3\r\n\
IWBoXpvKcZ26haxSh39MH4iOZEFqRh7ikcE7UGGMjrh2x3Gb949bXVx2tl5iGI/mzohHgkI+u1gS\r\n\
sqpYhoFYLqxo0YAQI0Q1iJagQi8y/S6QCJoDg61pEQlZ7BSELmaI5pZhW0GT5QSGZz31VWZ5QHRA\r\n\
VlXCAxIJPJLpqOYEmF4M2zZS5d16Fz1Lx/l8Iod//TDsnmlhOb3YqO+kBMBYpkFxfRfIHkuJ3NSd\r\n\
7iwMJIuAMtSWTyApNiZuU2yV7N4RBhm8qlI+YKogRwwZhA8D2cygXfS3fl12JNTQ954rR8Veo3V0\r\n\
FkbvdkBu2V1qrL5HncTy46EW5RDm1QLQ8lEABFPyVPgasL3ODFacFJbYpcTnkBGNHCi2Tz0TI1R2\r\n\
fY/IaveYR7po4LuUDKNu827b6j6WdJv5+Dd4JCwemEZJwKhCCMgBgBz4M5UFodBCbKXYsqZdlbjY\r\n\
jmo1IclgA2oEqA4gO8GLqAYEQMYfBYIjleuBrzBdNu/tNjX0SSpdOF5RMcPxypVssUgFNjhrFok/\r\n\
SlfaiOUDacKiwl3qSYWBRMhmUctqDYHOmWLJYMdIfKhRaqwBSPlOr3la+nNAvENuB9wuoXzd3/dy\r\n\
BgfeTMDrpL9kCo5AjfqOgdk6zy3dBbIGM8ungQ5VqRAQFUVFmd3owWYo92kB1CwA2aMFx4LQWjL4\r\n\
nwIympSfHRS3K0dIGMnX7uA+BZ3vEbCCKAdNaJQ2kE+PBUAitMMwM+h6j8LJTuM12XXsCF46pTW5\r\n\
KMnEL+BGcrZoBMleFeb3gYS5XW1+LBaTvNIhN4V5XUWwicYp6582doCshhxxgeCv4R22ICojrDCQ\r\n\
qIymwxGhUxWKkg9EHrROqeyeeOq4UrUFLrceBxINkv1jUkv63N6TTC7v+rbP9JgUC5pGHXVqlq6j\r\n\
tEqF9mtZJrvOqpIQT7cFtfowEUJJbPH7QGaJBfdAIChiWFMYYrEMXlJFt0bq85qm1I+Y+Vho5a5k\r\n\
BCjZ7C/c5/ZRfKgL8fp+JqP5sssMU5jpK1WQVgU3qWhSJFOkS0lLsG6/soGyFgaimkWTQ6ZX5KFk\r\n\
mq5l8dMUoeLhsVI/4vxBIJjExXHoYNXuXXqUobh94VWY19fbnbNk0K8bwtA5m24try63cozrqrhH\r\n\
y7SP7+v11h7CDWC5aI03EukaQ3m4D4RYXPaCa3QXkXkWkIR2INUPKonEeUFA/SEgM7Q/VrNeb07g\r\n\
joDEbfrwcBJwu90P83q/ZzuvRoMzbDHHcFjgjISncIHAIj6/zYFRmLgFM0xOM90wGROMERuDskNA\r\n\
lB2cYlG1vccEhCIroihwG+oW1AWDZr0mjHtAVIeZMjm19Mtq6GSBPgXJ7deSHrcrXh9VTsInoW/v\r\n\
7n7AWGoJOjfRNzpYnhpXWzRVuuVwj8Fr5Lb3DCwH0QXbI4aUQAw/tKQDLaqyEBmXpi4K/hvGcAz0\r\n\
1NdQX1J+izLu15AqY1DSU2LVHXr22ekPB+1vZ59wI7m815M8j7uXW996g2Evg5Y4EKhdrJWE8sjO\r\n\
xpRf79emucAmluWU8RqXE94nNDqr3tJRlwt+W/WOhtecX9e9NZu3uEsH3KEdF6S62DnWaOo1AdGh\r\n\
XgnqVKNg4H3c8wjk69zc3Nu3b+ZG3c+Oc3SVJG+9efP2LR5u/vFDxkqxOn5+lMhTwPujK/nZ2dmZ\r\n\
vDslX61U5vMS4szsDPY+S0+vvL7lAoNeZ4kZeHQaesNIYvz8uCg7AzUzWpwNNJRWkVZceur/vSUf\r\n\
GAtDCZrI0KAvoeG/Lt9Xx5MfIhFiicj9QSmhGd649zg098nPik+rB+/T/k/yO9A7bEvKcQkCAAAA\r\n\
AElFTkSuQmCC";
+8 -6
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};
@@ -512,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}");
@@ -539,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>");
@@ -574,7 +576,7 @@ mod tests {
} }
async fn fixture(name: &str, client_id: Option<&str>) -> WebApplications { 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.create().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
@@ -679,7 +681,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn missing_parent_directories_are_created() { async fn missing_parent_directories_are_created() {
let base = std::env::temp_dir().join("stalwart-app-nested"); let base = std::env::temp_dir().join("inbuxa-app-nested");
let _ = tokio::fs::remove_dir_all(&base).await; let _ = tokio::fs::remove_dir_all(&base).await;
let dir = TempDir::new(base.join("webui").join("0")); let dir = TempDir::new(base.join("webui").join("0"));
@@ -712,7 +714,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn sweeping_orphans_spares_the_current_generation() { async fn sweeping_orphans_spares_the_current_generation() {
let base = std::env::temp_dir().join("stalwart-app-sweep"); let base = std::env::temp_dir().join("inbuxa-app-sweep");
let _ = tokio::fs::remove_dir_all(&base).await; let _ = tokio::fs::remove_dir_all(&base).await;
let current = TempDir::new(base.join("1")); let current = TempDir::new(base.join("1"));
+3
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::Core; use crate::Core;
@@ -321,6 +323,7 @@ impl Family {
SUBSPACE_REGISTRY_IDX, SUBSPACE_REGISTRY_IDX,
SUBSPACE_REGISTRY_PK, SUBSPACE_REGISTRY_PK,
SUBSPACE_DIRECTORY, SUBSPACE_DIRECTORY,
store::SUBSPACE_INBUXA, // inbuxa: masked email
], ],
Family::Changelog => &[SUBSPACE_LOGS], Family::Changelog => &[SUBSPACE_LOGS],
Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT], Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT],
+13 -11
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::{backup::BackupParams, console::store_console}; use super::{backup::BackupParams, console::store_console};
@@ -38,11 +40,12 @@ pub struct IpcReceivers {
} }
const HELP: &str = concat!( const HELP: &str = concat!(
"Stalwart Server v", types::brand_server!(),
env!("CARGO_PKG_VERSION"), " ",
types::brand_version_full!(),
r#" r#"
Usage: stalwart [OPTIONS] Usage: inbuxa [OPTIONS]
Options: Options:
-c, --config <PATH> Start server with the specified configuration file -c, --config <PATH> Start server with the specified configuration file
@@ -87,7 +90,7 @@ impl BootManager {
std::process::exit(0); std::process::exit(0);
} }
("version" | "V", _) => { ("version" | "V", _) => {
println!("{}", env!("CARGO_PKG_VERSION")); println!("{}", types::brand_version_full!());
std::process::exit(0); std::process::exit(0);
} }
("config" | "c", Some(value)) => { ("config" | "c", Some(value)) => {
@@ -156,8 +159,7 @@ impl BootManager {
// Enable telemetry // Enable telemetry
#[cfg(not(feature = "enterprise"))] telemetry.enable();
telemetry.enable(false);
if bootstrap.registry.is_bootstrap_mode() { if bootstrap.registry.is_bootstrap_mode() {
trc::event!( trc::event!(
@@ -165,20 +167,20 @@ impl BootManager {
Hostname = bootstrap.registry.local_hostname().to_string(), Hostname = bootstrap.registry.local_hostname().to_string(),
Details = Details =
"No configuration file was found. Port 8080 is open for initial setup.", "No configuration file was found. Port 8080 is open for initial setup.",
Version = env!("CARGO_PKG_VERSION"), Version = types::brand_version_full!(),
); );
} else if bootstrap.registry.is_recovery_mode() { } else if bootstrap.registry.is_recovery_mode() {
trc::event!( trc::event!(
Server(trc::ServerEvent::RecoveryMode), Server(trc::ServerEvent::RecoveryMode),
Details = "Port 8080 is open for troubleshooting and recovery.", Details = "Port 8080 is open for troubleshooting and recovery.",
Hostname = bootstrap.registry.local_hostname().to_string(), Hostname = bootstrap.registry.local_hostname().to_string(),
Version = env!("CARGO_PKG_VERSION"), Version = types::brand_version_full!(),
); );
} else { } else {
trc::event!( trc::event!(
Server(trc::ServerEvent::Startup), Server(trc::ServerEvent::Startup),
Hostname = bootstrap.registry.local_hostname().to_string(), Hostname = bootstrap.registry.local_hostname().to_string(),
Version = env!("CARGO_PKG_VERSION"), Version = types::brand_version_full!(),
); );
} }
@@ -240,7 +242,7 @@ impl BootManager {
} }
StoreOp::Export(path) => { StoreOp::Export(path) => {
// Enable telemetry // Enable telemetry
telemetry.enable(false); telemetry.enable();
// Parse settings and backup // Parse settings and backup
Box::pin(Core::parse(&mut bootstrap, storage)) Box::pin(Core::parse(&mut bootstrap, storage))
@@ -251,7 +253,7 @@ impl BootManager {
} }
StoreOp::Import(path) => { StoreOp::Import(path) => {
// Enable telemetry // Enable telemetry
telemetry.enable(false); telemetry.enable();
// Parse settings and restore // Parse settings and restore
Box::pin(Core::parse(&mut bootstrap, storage)) Box::pin(Core::parse(&mut bootstrap, storage))
+5 -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 base64::Engine; use base64::Engine;
@@ -12,8 +14,9 @@ use store::write::{AnyClass, AnyKey, BatchBuilder, ValueClass};
use store::{Deserialize, IterateParams, SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX, Store}; use store::{Deserialize, IterateParams, SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX, Store};
const HELP: &str = concat!( const HELP: &str = concat!(
"Stalwart Server v", types::brand_server!(),
env!("CARGO_PKG_VERSION"), " ",
types::brand_version_full!(),
r#" Data Store CLI r#" Data Store CLI
Enter commands (type 'help' for available commands). Enter commands (type 'help' for available commands).
+26 -29
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::auth::permissions::DefaultPermissions; use crate::auth::permissions::DefaultPermissions;
@@ -54,28 +56,10 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
let is_recovery_mode = bp.registry.is_recovery_mode(); let is_recovery_mode = bp.registry.is_recovery_mode();
let is_bootstrap_mode = bp.registry.is_bootstrap_mode(); let is_bootstrap_mode = bp.registry.is_bootstrap_mode();
#[cfg(not(feature = "test_mode"))] // inbuxa: no web interface is installed on the mail host, and nothing is
if bp.registry.count_object(ObjectType::Application).await? == 0 { // downloaded for one (docs/spec/SPEC.md §5.3). Administration is INBUXA
bp.registry // Admin and webmail is ihasmail, both deployed separately. An install
.write(RegistryWrite::insert( // upgraded from Stalwart keeps any web application it already has.
&Application {
auto_update_frequency: Duration::from_millis(30 * 24 * 60 * 60 * 1000),
description: "Stalwart Web Interface".to_string(),
enabled: true,
#[cfg(not(feature = "dev_mode"))]
resource_url:
"https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip"
.into(),
#[cfg(feature = "dev_mode")]
resource_url: "file:///Users/me/code/webui/.ignore/webui.zip".into(),
unpack_directory: None,
oauth_client_id: None,
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
}
.into(),
))
.await?;
}
if is_bootstrap_mode { if is_bootstrap_mode {
#[cfg(not(any(feature = "dev_mode", feature = "test_mode")))] #[cfg(not(any(feature = "dev_mode", feature = "test_mode")))]
@@ -98,6 +82,10 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
return Ok(()); return Ok(());
} }
// inbuxa: registration is required (contract C-5), so the first-party
// front ends are registered on every start (C-6)
super::first_party::ensure_first_party_clients(bp).await?;
if bp.registry.count_object(ObjectType::MtaQueueQuota).await? == 0 { if bp.registry.count_object(ObjectType::MtaQueueQuota).await? == 0 {
bp.registry bp.registry
.write(RegistryWrite::insert( .write(RegistryWrite::insert(
@@ -527,9 +515,9 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
&Tracer::Log(TracerLog { &Tracer::Log(TracerLog {
enable: true, enable: true,
ansi: false, ansi: false,
prefix: "stalwart.log".into(), prefix: "inbuxa.log".into(),
rotate: LogRotateFrequency::Daily, rotate: LogRotateFrequency::Daily,
path: "/var/log/stalwart".into(), path: "/var/log/inbuxa".into(),
..Default::default() ..Default::default()
}) })
.into(), .into(),
@@ -542,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,
+429
View File
@@ -0,0 +1,429 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! First-party OAuth clients (docs/spec/contract.md C-6).
//!
//! INBUXA requires OAuth clients to be registered (C-5), so the front ends
//! that ship with it are registered for it, on every start:
//!
//! - the web interface the server serves itself (`Application`, `/admin` and
//! `/account`), as its OAuth client id, `inbuxa-webui` unless the
//! application names another;
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
//! is set;
//! - ihasmail-inbuxa, as the confidential client `ihasmail-inbuxa`, when
//! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set.
//!
//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until
//! that object exists; the installer and INBUXA Admin's setup wizard will set
//! it instead.
//!
//! A missing client is created. An existing one gains any redirect URI it
//! lacks and, for ihasmail-inbuxa, the configured secret; nothing an operator
//! added is removed.
use directory::core::secret::{hash_secret, verify_secret_hash};
use registry::{
schema::{
enums::{PasswordHashAlgorithm, ServiceProtocol},
prelude::{Object, ObjectInner, ObjectType, Property, UTCDateTime},
structs::{Application, OAuthClient, SystemSettings},
},
types::map::Map,
};
use store::registry::{
bootstrap::Bootstrap,
write::{RegistryWrite, RegistryWriteResult},
};
/// The client id the upstream web interface uses when its application names none.
pub const WEB_INTERFACE_CLIENT_ID: &str = "inbuxa-webui";
pub const ADMIN_CLIENT_ID: &str = "inbuxa-admin";
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)]
pub struct FirstPartyClient {
pub client_id: String,
pub description: String,
pub redirect_uris: Vec<String>,
pub secret: Option<String>,
}
/// The first-party clients this server should have, from its applications and
/// the front-end addresses it was given.
pub fn first_party_clients(
base_url: &str,
applications: &[Application],
admin_url: Option<&str>,
webmail: Option<(&str, &str)>,
) -> Vec<FirstPartyClient> {
let base_url = base_url.trim_end_matches('/');
let mut clients: Vec<FirstPartyClient> = Vec::new();
for app in applications.iter().filter(|app| app.enabled) {
let client_id = app
.oauth_client_id
.as_deref()
.filter(|id| !id.is_empty())
.unwrap_or(WEB_INTERFACE_CLIENT_ID);
let redirect_uris = app
.url_prefix
.iter()
.map(|prefix| {
format!(
"{base_url}/{}/oauth/callback",
prefix.trim_matches('/')
)
})
.collect::<Vec<_>>();
if redirect_uris.is_empty() {
continue;
}
if let Some(client) = clients.iter_mut().find(|c| c.client_id == client_id) {
for uri in redirect_uris {
if !client.redirect_uris.contains(&uri) {
client.redirect_uris.push(uri);
}
}
} else {
clients.push(FirstPartyClient {
client_id: client_id.to_string(),
description: format!("{} (served by this server)", app.description),
redirect_uris,
secret: None,
});
}
}
if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) {
clients.push(FirstPartyClient {
client_id: ADMIN_CLIENT_ID.to_string(),
description: "inbuxa Admin".to_string(),
redirect_uris: vec![format!("{url}/oauth/callback")],
secret: None,
});
}
if let Some((url, secret)) = webmail {
let url = url.trim().trim_end_matches('/');
if !url.is_empty() && !secret.is_empty() {
clients.push(FirstPartyClient {
client_id: WEBMAIL_CLIENT_ID.to_string(),
description: "ihasmail webmail".to_string(),
redirect_uris: vec![format!("{url}/api/auth/callback")],
secret: Some(secret.to_string()),
});
}
}
clients
}
/// The address the server's own pages are served from, as `Http` works it out.
fn base_url(bp: &Bootstrap, system: &SystemSettings) -> String {
if let Some(url) = bp.registry.public_url() {
return url.to_string();
}
let default_hostname = if !system.default_hostname.is_empty() {
system.default_hostname.as_str()
} else {
bp.registry.local_hostname()
};
let host = system
.services
.iter()
.find(|(service, _)| matches!(service, ServiceProtocol::Jmap))
.and_then(|(_, details)| details.hostname.as_deref())
.unwrap_or(default_hostname);
format!("https://{host}")
}
/// The origin (`scheme://host[:port]`) of a front end's address, lowercased,
/// without a default port. `None` if it isn't an `http` or `https` URL.
pub fn origin_of(url: &str) -> Option<String> {
let uri = url.trim().parse::<hyper::Uri>().ok()?;
let scheme = uri.scheme_str()?.to_ascii_lowercase();
let default_port = match scheme.as_str() {
"https" => 443,
"http" => 80,
_ => return None,
};
let host = uri.host()?.to_ascii_lowercase();
if host.is_empty() {
return None;
}
Some(match uri.port_u16() {
Some(port) if port != default_port => format!("{scheme}://{host}:{port}"),
_ => format!("{scheme}://{host}"),
})
}
/// The origins allowed to make cross-origin requests (contract C-14): INBUXA
/// Admin's, the webmail's, and `INBUXA_CORS_EXTRA_ORIGINS` (comma-separated).
///
/// inbuxa: read from the environment until `x:FrontEnds` exists (C-4).
pub fn front_end_origins() -> Vec<String> {
let mut origins = Vec::new();
for url in [env("ADMIN_URL"), env("WEBMAIL_URL")].into_iter().flatten() {
origins.extend(origin_of(&url));
}
if let Some(extra) = env("CORS_EXTRA_ORIGINS") {
origins.extend(extra.split(',').filter_map(origin_of));
}
origins.sort();
origins.dedup();
origins
}
fn env(name: &str) -> Option<String> {
types::branding::env_var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
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 base_url = base_url(bp, &system);
let applications = bp
.list_infallible::<Application>()
.await
.into_iter()
.map(|app| app.object)
.collect::<Vec<_>>();
let admin_url = env("ADMIN_URL");
let webmail_url = env("WEBMAIL_URL");
let webmail_secret = env("WEBMAIL_CLIENT_SECRET");
if webmail_url.is_some() && webmail_secret.is_none() {
trc::event!(
Auth(trc::AuthEvent::Error),
Details = "INBUXA_WEBMAIL_URL is set without INBUXA_WEBMAIL_CLIENT_SECRET; the webmail client was not registered."
);
}
let webmail = webmail_url.as_deref().zip(webmail_secret.as_deref());
for client in first_party_clients(&base_url, &applications, admin_url.as_deref(), webmail) {
ensure_client(bp, client).await?;
}
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<()> {
let existing = match bp
.registry
.primary_key(
ObjectType::OAuthClient.into(),
Property::ClientId,
client.client_id.as_bytes().to_vec(),
)
.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
.registry
.get(object_id)
.await?
.map(|object| (object_id.id(), object.revision, OAuthClient::from(object))),
None => None,
};
let result = if let Some((id, revision, current)) = existing {
let mut updated = current.clone();
for uri in &client.redirect_uris {
if !updated.redirect_uris.contains(uri) {
updated.redirect_uris.push(uri.clone());
}
}
if let Some(secret) = &client.secret {
let matches = match updated.secret.as_deref() {
Some(hash) if !hash.is_empty() => {
verify_secret_hash(hash, secret.as_bytes()).await?
}
_ => false,
};
if !matches {
updated.secret = Some(
hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec())
.await?,
);
}
}
if updated == current {
return Ok(());
}
let current = Object::with_revision(ObjectInner::from(current), revision);
bp.registry
.write(RegistryWrite::update(id, &updated.into(), &current))
.await?
} else {
let secret = match &client.secret {
Some(secret) => Some(
hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec()).await?,
),
None => None,
};
bp.registry
.write(RegistryWrite::insert(
&OAuthClient {
client_id: client.client_id.clone(),
description: Some(client.description),
redirect_uris: Map::new(client.redirect_uris),
secret,
created_at: UTCDateTime::now(),
..Default::default()
}
.into(),
))
.await?
};
if !matches!(result, RegistryWriteResult::Success(_)) {
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to register a first-party OAuth client.")
.ctx(trc::Key::Id, client.client_id)
.reason(result.to_string())
.caused_by(trc::location!()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn web_interface() -> Application {
Application {
description: "inbuxa Web Interface".to_string(),
enabled: true,
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
..Default::default()
}
}
#[test]
fn web_interface_gets_one_uri_per_prefix() {
let clients = first_party_clients("https://mail.example.org/", &[web_interface()], None, None);
assert_eq!(
clients,
vec![FirstPartyClient {
client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
description: "inbuxa Web Interface (served by this server)".to_string(),
redirect_uris: vec![
"https://mail.example.org/admin/oauth/callback".to_string(),
"https://mail.example.org/account/oauth/callback".to_string(),
],
secret: None,
}]
);
}
#[test]
fn disabled_applications_and_named_clients() {
let mut disabled = web_interface();
disabled.enabled = false;
let mut named = web_interface();
named.oauth_client_id = Some("custom".to_string());
named.url_prefix = Map::new(vec!["portal".into()]);
let clients = first_party_clients("https://h", &[disabled, named], None, None);
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].client_id, "custom");
assert_eq!(clients[0].redirect_uris, vec!["https://h/portal/oauth/callback"]);
}
#[test]
fn front_ends_from_their_addresses() {
let clients = first_party_clients(
"https://h",
&[],
Some("https://admin.example.org/"),
Some(("https://webmail.example.org", "s3cret")),
);
assert_eq!(clients.len(), 2);
assert_eq!(clients[0].client_id, ADMIN_CLIENT_ID);
assert_eq!(clients[0].redirect_uris, vec!["https://admin.example.org/oauth/callback"]);
assert_eq!(clients[0].secret, None);
assert_eq!(clients[1].client_id, WEBMAIL_CLIENT_ID);
assert_eq!(clients[1].redirect_uris, vec!["https://webmail.example.org/api/auth/callback"]);
assert_eq!(clients[1].secret.as_deref(), Some("s3cret"));
}
#[test]
fn origins() {
assert_eq!(origin_of("https://Admin.Example.org/"), Some("https://admin.example.org".into()));
assert_eq!(origin_of("https://admin.example.org:443/x"), Some("https://admin.example.org".into()));
assert_eq!(origin_of("http://localhost:5173"), Some("http://localhost:5173".into()));
assert_eq!(origin_of("https://h:8443/app"), Some("https://h:8443".into()));
assert_eq!(origin_of("ftp://h"), None);
assert_eq!(origin_of("not a url"), None);
assert_eq!(origin_of(""), None);
}
#[test]
fn webmail_needs_a_secret() {
let clients = first_party_clients("https://h", &[], Some(" "), Some(("https://w", "")));
assert!(clients.is_empty());
}
}
+6 -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 crate::USER_AGENT; use crate::USER_AGENT;
@@ -18,10 +20,12 @@ pub mod backup;
pub mod boot; pub mod boot;
pub mod console; pub mod console;
pub mod defaults; pub mod defaults;
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,
+4 -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::backup::MAGIC_MARKER; use super::backup::MAGIC_MARKER;
@@ -52,9 +54,9 @@ impl Core {
if !conflicts.is_empty() { if !conflicts.is_empty() {
eprintln!( eprintln!(
"Cannot import: the target database already contains data in the key ranges being \ "Cannot import: the target database already contains data in the key ranges being \
imported. This usually means Stalwart was started before the import ran, which \ imported. This usually means the server was started before the import ran, which \
can create duplicate entries. Import into a fresh, empty database and do not \ can create duplicate entries. Import into a fresh, empty database and do not \
start Stalwart before importing. Conflicting dumps:" start the server before importing. Conflicting dumps:"
); );
for path in conflicts { for path in conflicts {
eprintln!(" {}", path.display()); eprintln!(" {}", path.display());
+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());
}
}
+12 -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.
*/ */
// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0. // Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
@@ -267,7 +269,7 @@ impl AcmeRequestBuilder {
let mut retry_after = response.retry_after; let mut retry_after = response.retry_after;
let auth = response.body; let auth = response.body;
let (domain, challenge_url) = match auth.status { let domain = match auth.status {
AuthStatus::Pending => { AuthStatus::Pending => {
let Identifier::Dns(domain) = auth.identifier; let Identifier::Dns(domain) = auth.identifier;
@@ -361,7 +363,7 @@ impl AcmeRequestBuilder {
} }
self.challenge(&challenge.url).await?; self.challenge(&challenge.url).await?;
(domain, challenge.url.clone()) domain
} }
AuthStatus::Valid => return Ok(()), AuthStatus::Valid => return Ok(()),
_ => { _ => {
@@ -388,14 +390,20 @@ impl AcmeRequestBuilder {
match response.body.status { match response.body.status {
AuthStatus::Pending => { AuthStatus::Pending => {
// inbuxa: keep polling, don't post the challenge again.
// RFC 8555 section 7.5.1 has the client post a challenge
// once to say it's ready and then poll the authorization,
// which stays pending while validation runs. Posting it
// again is refused once the server has moved the
// challenge to "processing" (pebble answers 400
// malformed, "Cannot update challenge with status
// processing"), and that refusal failed the renewal.
trc::event!( trc::event!(
Acme(AcmeEvent::AuthPending), Acme(AcmeEvent::AuthPending),
Hostname = domain.to_string(), Hostname = domain.to_string(),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Total = i, Total = i,
); );
self.challenge(&challenge_url).await?
} }
AuthStatus::Valid => { AuthStatus::Valid => {
trc::event!( trc::event!(
@@ -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))
}) })
} }
} }
+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;
+36 -3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -24,7 +26,7 @@ use crate::{
use ahash::AHashSet; use ahash::AHashSet;
use directory::Recipient; use directory::Recipient;
use mail_auth::IpLookupStrategy; use mail_auth::IpLookupStrategy;
use registry::schema::{enums::ExpressionVariable, structs::MaskedEmail}; use registry::schema::enums::ExpressionVariable;
use sieve::Sieve; use sieve::Sieve;
use std::{ use std::{
borrow::Cow, borrow::Cow,
@@ -33,10 +35,9 @@ use std::{
}; };
use store::{ use store::{
Deserialize, IterateParams, ValueKey, Deserialize, IterateParams, ValueKey,
write::{AlignedBytes, Archive, QueueClass, ValueClass, now}, write::{AlignedBytes, Archive, QueueClass, ValueClass},
}; };
use trc::{AddContext, SpamEvent}; use trc::{AddContext, SpamEvent};
use types::id::Id;
use utils::DomainPart; use utils::DomainPart;
impl Server { impl Server {
@@ -75,6 +76,27 @@ impl Server {
} }
} }
// inbuxa: ME-4, ME-9: a live masked address is rewritten to its
// owner's, which keeps the mask as the original recipient
if let inbuxa_features::masked_email::ops::Lookup::Accepts(mask) =
inbuxa_features::masked_email::ops::lookup(
&self.core.storage.data,
self.registry(),
&format!("{local_part}@{domain_part}"),
)
.await?
{
let owner = self.account(mask.object.account_id.document_id()).await?;
if let Some(address) = owner.addresses.first()
&& let Some(owner_domain) = self.domain_by_id(address.domain_id).await?
&& let Some(owner_domain) = owner_domain.names.first()
{
return Ok(RcptResolution::Rewrite(format!(
"{}@{}",
address.local_part, owner_domain
)));
}
}
// Obtain external directory, if configured // Obtain external directory, if configured
let directory = self let directory = self
@@ -88,6 +110,17 @@ impl Server {
Cow::Borrowed(rcpt) Cow::Borrowed(rcpt)
}; };
match directory.recipient(address.as_ref()).await? { match directory.recipient(address.as_ref()).await? {
// inbuxa: DIR-6: an answer for another directory's domain is no answer
Recipient::Account(account)
if self
.assert_directory_serves(directory, &account.email)
.await
.is_err() => {}
Recipient::Group(group)
if self
.assert_directory_serves(directory, &group.email)
.await
.is_err() => {}
Recipient::Account(account) => { Recipient::Account(account) => {
Box::pin(self.synchronize_account(account)).await?; Box::pin(self.synchronize_account(account)).await?;
return Ok(if is_subaddressed { return Ok(if is_subaddressed {
+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,11 +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 sieve::{FunctionMap, compiler::Number, runtime::Variable}; use sieve::{FunctionMap, runtime::Variable};
use std::time::Instant;
use trc::{AiEvent, SecurityEvent};
use super::PluginContext; use super::PluginContext;
@@ -14,7 +14,9 @@ pub fn register(plugin_id: u32, fnc_map: &mut FunctionMap) {
fnc_map.set_external_function("llm_prompt", plugin_id, 3); fnc_map.set_external_function("llm_prompt", plugin_id, 3);
} }
// inbuxa: AI-20 to AI-25, `llm_prompt(model, prompt, temperature)`
pub async fn exec(ctx: PluginContext<'_>) -> trc::Result<Variable> { pub async fn exec(ctx: PluginContext<'_>) -> trc::Result<Variable> {
Ok(crate::enterprise::llm::sieve_prompt(ctx)
Ok(false.into()) .await
.map_or(Variable::from(false), Variable::from))
} }
+60
View File
@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Which logo applies to a domain name (branding spec BT-1, BT-2). The rules
//! live in `inbuxa_features::branding::logo`; this finds the domain through
//! the server's domain cache and reads the three levels from the registry
//! each time, so a change shows at once on every node (BT-10).
use crate::Server;
use inbuxa_features::branding::logo::{self, Logo, Source};
use registry::schema::structs::{Domain, Enterprise, Tenant};
use types::id::Id;
impl Server {
/// The logos that apply to a domain name, most specific first. An unknown
/// name gets what a known domain with no logo of its own gets (BT-6).
pub async fn logos_for(&self, name: &str) -> trc::Result<Vec<Logo>> {
let mut domain = None;
for candidate in logo::lookup_names(name) {
if let Some(found) = self.domain(&candidate).await? {
domain = Some(found);
break;
}
}
let registry = self.registry();
let domain_logo = match &domain {
Some(domain) => registry
.object::<Domain>(Id::from(domain.id))
.await?
.and_then(|d| d.logo),
None => None,
};
let tenant_id = domain.as_ref().and_then(|d| d.id_tenant);
let tenant_logo = match tenant_id {
Some(tenant_id) => registry
.object::<Tenant>(Id::from(tenant_id))
.await?
.and_then(|t| t.logo),
None => None,
};
let server_logo = registry
.object::<Enterprise>(Id::singleton())
.await?
.and_then(|e| e.logo_url);
Ok(logo::chain([
(
Source::Domain(domain.as_ref().map_or(u32::MAX, |d| d.id)),
domain_logo.as_deref(),
),
(
Source::Tenant(tenant_id.unwrap_or(u32::MAX)),
tenant_logo.as_deref(),
),
(Source::Server, server_logo.as_deref()),
]))
}
}
+21 -3
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; use crate::Server;
@@ -18,6 +20,7 @@ use store::{BlobStore, InMemoryStore, RegistryStore, SearchStore, Store};
pub mod archive; pub mod archive;
pub mod blob; pub mod blob;
pub mod branding; // inbuxa: branding BT-1, BT-2
pub mod dav; pub mod dav;
pub mod document; pub mod document;
pub mod encryption; pub mod encryption;
@@ -95,11 +98,26 @@ impl Server {
self.registry().count_object(ObjectType::Domain).await self.registry().count_object(ObjectType::Domain).await
} }
#[cfg(not(feature = "enterprise"))] // inbuxa: BT-9: the first logo mail can carry inline; none leaves the
// built-in INBUXA logo
pub async fn logo_resource( pub async fn logo_resource(
&self, &self,
_: &str, domain: &str,
) -> trc::Result<Option<crate::manager::application::Resource<Vec<u8>>>> { ) -> trc::Result<Option<crate::manager::application::Resource<Vec<u8>>>> {
Ok(None) Ok(self
.logos_for(domain)
.await?
.into_iter()
.find(|logo| logo.is_embeddable())
.and_then(|logo| match logo {
inbuxa_features::branding::logo::Logo::Image {
content_type,
bytes,
} => Some(crate::manager::application::Resource::new(
content_type,
bytes,
)),
inbuxa_features::branding::logo::Logo::Url(_) => None,
}))
} }
} }
+20 -3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -31,9 +33,9 @@ impl Server {
.add_context(|err| err.caused_by(trc::location!()).account_id(account_id)) .add_context(|err| err.caused_by(trc::location!()).account_id(account_id))
} }
#[cfg(not(feature = "enterprise"))] // inbuxa: MT-20: storage used by all a tenant's members together
pub async fn get_used_quota_tenant(&self, _tenant_id: u32) -> trc::Result<i64> { pub async fn get_used_quota_tenant(&self, tenant_id: u32) -> trc::Result<i64> {
Ok(0) inbuxa_features::tenancy::quota::used(&self.core.storage.data, tenant_id).await
} }
pub async fn has_available_quota( pub async fn has_available_quota(
@@ -52,6 +54,21 @@ impl Server {
} }
} }
// inbuxa: MT-19: the tenant's limit applies too, whichever is reached first
if let Some(tenant_id) = account.id_tenant {
let tenant = self.tenant(tenant_id).await?;
if tenant.quota_disk != 0 {
let used_quota = self.get_used_quota_tenant(tenant_id).await?.max(0) as u64;
if used_quota + item_size > tenant.quota_disk {
return Err(trc::LimitEvent::TenantQuota
.into_err()
.ctx(trc::Key::Id, tenant_id)
.ctx(trc::Key::Limit, tenant.quota_disk)
.ctx(trc::Key::Size, used_quota));
}
}
}
Ok(()) Ok(())
} }
+265
View File
@@ -0,0 +1,265 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Alerts (monitoring spec MON-25 to MON-30). Each enabled `x:Alert` is read
//! from the registry at evaluation, so a change needs no reload (MON-3), and
//! fires when its condition goes from false to true (MON-26).
use crate::{
Server,
expr::{functions::EmptyResolver, if_block::BootstrapExprExt},
};
use ahash::AHashSet;
use mail_builder::{
MessageBuilder,
headers::{HeaderType, address::Address},
};
use registry::{
schema::{
prelude::{ExpressionContext, ObjectType},
structs::{Alert, AlertEmail, AlertEvent, Expression, ExpressionMatch},
},
types::{id::ObjectId, list::List},
};
use std::sync::Mutex;
use store::registry::{RegistryQuery, bootstrap::Bootstrap};
use trc::{Collector, MetricType, TelemetryEvent};
use types::id::Id;
/// An alert email, ready to queue.
#[derive(Debug, Clone)]
pub struct AlertMessage {
pub from: String,
pub to: Vec<String>,
pub body: Vec<u8>,
}
/// The alerts whose condition held at the last evaluation (MON-26). In
/// memory, so a restart while a condition holds fires once more.
static FIRING: Mutex<Option<AHashSet<u64>>> = Mutex::new(None);
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
/// The metric an underscore name stands for (`queue_count`), MON-25.
fn underscore_metric(name: &str) -> Option<MetricType> {
static NAMES: std::sync::OnceLock<ahash::AHashMap<String, MetricType>> =
std::sync::OnceLock::new();
if !name.contains('_') {
return None;
}
NAMES
.get_or_init(|| {
(0..=u16::MAX)
.filter_map(MetricType::from_id)
.map(|metric| (metric.as_str().replace(['.', '-'], "_"), metric))
.collect()
})
.get(name)
.copied()
}
/// Rewrites bare underscore metric names into `metric('dotted.name')`,
/// leaving quoted text and function names alone (MON-25).
pub fn rewrite(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let chars = text.chars().collect::<Vec<_>>();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '"' || c == '\'' {
let quote = c;
out.push(c);
i += 1;
while i < chars.len() {
out.push(chars[i]);
if chars[i] == quote {
break;
}
i += 1;
}
i += 1;
} else if c.is_ascii_alphabetic() || c == '_' {
let start = i;
while i < chars.len() && is_ident_char(chars[i]) {
i += 1;
}
let word = chars[start..i].iter().collect::<String>();
let is_call = chars[i..].iter().find(|c| !c.is_whitespace()) == Some(&'(');
match underscore_metric(&word) {
Some(metric) if !is_call => {
out.push_str(&format!("metric('{}')", metric.as_str()));
}
_ => out.push_str(&word),
}
} else {
out.push(c);
i += 1;
}
}
out
}
/// An alert condition with underscore names rewritten.
pub fn rewrite_condition(condition: &Expression) -> Expression {
Expression {
match_: List::from_iter(condition.match_.iter().map(|m| ExpressionMatch {
if_: rewrite(&m.if_),
then: rewrite(&m.then),
})),
else_: rewrite(&condition.else_),
}
}
/// `%{metric.name}%` replaced by the metric's value: whole numbers without
/// decimals, others with at most two (MON-27). Unknown names stay.
pub fn render(template: &str) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(start) = rest.find("%{") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
match after.find("}%") {
Some(end) => {
let name = &after[..end];
match MetricType::parse(name) {
Some(metric) => {
let value = Collector::read_metric(metric);
if value.fract() == 0.0 {
out.push_str(&format!("{}", value as i64));
} else {
let text = format!("{value:.2}");
out.push_str(text.trim_end_matches('0').trim_end_matches('.'));
}
}
None => out.push_str(&rest[start..start + 2 + end + 2]),
}
rest = &after[end + 2..];
}
None => {
out.push_str(&rest[start..]);
rest = "";
}
}
}
out.push_str(rest);
out
}
fn build_email(email: &registry::schema::structs::AlertEmailProperties) -> AlertMessage {
let from = match &email.from_name {
Some(name) => Address::new_address(Some(name.clone()), email.from_address.clone()),
None => Address::new_address(None::<String>, email.from_address.clone()),
};
let to = email.to.iter().cloned().collect::<Vec<_>>();
let body = MessageBuilder::new()
.from(from)
.to(to
.iter()
.map(|addr| Address::new_address(None::<String>, addr.clone()))
.collect::<Vec<_>>())
.subject(render(&email.subject))
.header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
.text_body(render(&email.body))
.write_to_vec()
.unwrap_or_default();
AlertMessage {
from: email.from_address.clone(),
to,
body,
}
}
impl Server {
/// Evaluates every enabled alert once (MON-25, MON-26), emits the event
/// of each that fires (MON-28), and returns the emails to queue
/// (MON-29). A failing alert is logged and skipped (MON-37).
pub async fn process_alerts(&self) -> trc::Result<Vec<AlertMessage>> {
let registry = self.registry();
let ids = registry
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Alert))
.await?;
let mut messages = Vec::new();
let mut holding = AHashSet::new();
for id in ids {
let Some(alert) = registry.object::<Alert>(id).await? else {
continue;
};
if !alert.enable {
continue;
}
let condition = rewrite_condition(&alert.condition);
let mut bp = Bootstrap::new_uninitialized(registry.clone());
let if_block = bp.compile_expr(
ObjectId::new(ObjectType::Alert, id),
&ExpressionContext {
expr: &condition,
..alert.ctx_condition()
},
);
if !bp.errors.is_empty() || if_block.is_empty() {
trc::event!(
Registry(trc::RegistryEvent::BuildWarning),
Id = id.id(),
Details = "The alert's condition can't be evaluated",
);
continue;
}
let holds = self
.eval_if::<bool, _>(&if_block, &EmptyResolver, 0)
.await
.unwrap_or(false);
if !holds {
continue;
}
holding.insert(id.id());
let was_firing = FIRING
.lock()
.unwrap()
.as_ref()
.is_some_and(|firing| firing.contains(&id.id()));
if was_firing {
continue;
}
if let AlertEvent::Enabled(event) = &alert.event_alert {
trc::event!(
Telemetry(TelemetryEvent::AlertEvent),
Id = id.id(),
Details = render(event.event_message.as_deref().unwrap_or("Alert triggered")),
);
}
if let AlertEmail::Enabled(email) = &alert.email_alert {
messages.push(build_email(email));
}
}
*FIRING.lock().unwrap() = Some(holding);
Ok(messages)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rewrites_underscore_names() {
assert_eq!(rewrite("domain_count > 1"), "metric('domain.count') > 1");
assert_eq!(
rewrite("metric('queue.count') > 5 && queue_count < 9"),
"metric('queue.count') > 5 && metric('queue.count') < 9"
);
assert_eq!(rewrite("'domain_count' == x"), "'domain_count' == x");
assert_eq!(rewrite("unknown_thing > 1"), "unknown_thing > 1");
}
#[test]
fn renders_placeholders() {
assert_eq!(render("no placeholders"), "no placeholders");
assert_eq!(render("%{no.such-metric}% left"), "%{no.such-metric}% left");
}
}
@@ -2,9 +2,12 @@
* 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.
*/ */
pub mod otel; pub mod otel;
pub mod prometheus; pub mod prometheus;
pub mod store; // inbuxa: monitoring history (MON-4 to MON-9)
+6 -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 crate::config::telemetry::OtelMetrics; use crate::config::telemetry::OtelMetrics;
@@ -17,12 +19,12 @@ use std::time::SystemTime;
use trc::{Collector, TelemetryEvent}; use trc::{Collector, TelemetryEvent};
impl OtelMetrics { impl OtelMetrics {
pub async fn push_metrics(&self, is_enterprise: bool, start_time: SystemTime) { pub async fn push_metrics(&self, start_time: SystemTime) {
let mut metrics = Vec::with_capacity(256); let mut metrics = Vec::with_capacity(256);
let time = SystemTime::now(); let time = SystemTime::now();
// Add counters // Add counters
for counter in Collector::collect_counters(is_enterprise) { for counter in Collector::collect_counters() {
metrics.push(Metric::new( metrics.push(Metric::new(
counter.id().as_str(), counter.id().as_str(),
counter.id().description(), counter.id().description(),
@@ -38,7 +40,7 @@ impl OtelMetrics {
} }
// Add gauges // Add gauges
for gauge in Collector::collect_gauges(is_enterprise) { for gauge in Collector::collect_gauges() {
metrics.push(Metric::new( metrics.push(Metric::new(
gauge.id().as_str(), gauge.id().as_str(),
gauge.id().description(), gauge.id().description(),
@@ -52,7 +54,7 @@ impl OtelMetrics {
} }
// Add histograms // Add histograms
for histogram in Collector::collect_histograms(is_enterprise) { for histogram in Collector::collect_histograms() {
metrics.push(Metric::new( metrics.push(Metric::new(
histogram.id().as_str(), histogram.id().as_str(),
histogram.id().description(), histogram.id().description(),
@@ -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 prometheus::{ use prometheus::{
@@ -16,12 +18,8 @@ impl Server {
pub async fn export_prometheus_metrics(&self) -> trc::Result<String> { pub async fn export_prometheus_metrics(&self) -> trc::Result<String> {
let mut metrics = Vec::new(); let mut metrics = Vec::new();
#[cfg(not(feature = "enterprise"))]
let is_enterprise = false;
// Add counters // Add counters
for counter in Collector::collect_counters(is_enterprise) { for counter in Collector::collect_counters() {
let mut metric = MetricFamily::default(); let mut metric = MetricFamily::default();
metric.set_name(metric_name(counter.id().as_str())); metric.set_name(metric_name(counter.id().as_str()));
metric.set_help(counter.id().description().into()); metric.set_help(counter.id().description().into());
@@ -31,7 +29,7 @@ impl Server {
} }
// Add gauges // Add gauges
for gauge in Collector::collect_gauges(is_enterprise) { for gauge in Collector::collect_gauges() {
let mut metric = MetricFamily::default(); let mut metric = MetricFamily::default();
metric.set_name(metric_name(gauge.id().as_str())); metric.set_name(metric_name(gauge.id().as_str()));
metric.set_help(gauge.id().description().into()); metric.set_help(gauge.id().description().into());
@@ -41,7 +39,7 @@ impl Server {
} }
// Add histograms // Add histograms
for histogram in Collector::collect_histograms(is_enterprise) { for histogram in Collector::collect_histograms() {
let mut metric = MetricFamily::default(); let mut metric = MetricFamily::default();
metric.set_name(metric_name(histogram.id().as_str())); metric.set_name(metric_name(histogram.id().as_str()));
metric.set_help(histogram.id().description().into()); metric.set_help(histogram.id().description().into());
@@ -0,0 +1,267 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Metric history (monitoring spec MON-4 to MON-9, MON-17). Each sample is
//! stored under `TelemetryClass::Metric(id)`, as an `x:Metric` in the
//! registry's own encoding. The id is a snowflake of the tick's time, so key
//! order is time order and the timestamp is read from the id.
use crate::Server;
use ahash::AHashMap;
use registry::{
pickle::PickledStream,
schema::{
prelude::{ObjectInner, ObjectType},
structs::{DataRetention, Metric, MetricCount, MetricSum},
},
};
use std::{future::Future, sync::Mutex, time::Duration};
use store::{
IterateParams, Store, ValueKey,
write::{BatchBuilder, TelemetryClass, ValueClass, key::DeserializeBigEndian, now},
};
use trc::{AddContext, Collector, MetricType, TelemetryEvent};
use types::id::Id;
use utils::snowflake::SnowflakeIdGenerator;
pub trait MetricsStore: Sync + Send {
/// Writes one tick's samples, all at `timestamp`.
fn write_metrics(
&self,
samples: Vec<Metric>,
timestamp: u64,
) -> impl Future<Output = trc::Result<()>> + Send;
/// Deletes samples older than `keep` (MON-17).
fn purge_metrics(&self, keep: Duration) -> impl Future<Output = trc::Result<()>> + Send;
}
impl MetricsStore for Store {
async fn write_metrics(&self, samples: Vec<Metric>, timestamp: u64) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
for sample in samples {
let Some(id) = SnowflakeIdGenerator::global_id_from_timestamp(timestamp) else {
continue;
};
batch.set(
ValueClass::Telemetry(TelemetryClass::Metric(id)),
ObjectInner::Metric(sample).to_pickled_vec(),
);
if batch.is_large_batch() {
self.write(batch.build_all()).await?;
batch = BatchBuilder::new();
}
}
if !batch.is_empty() {
self.write(batch.build_all()).await?;
}
Ok(())
}
async fn purge_metrics(&self, keep: Duration) -> trc::Result<()> {
let Some(until) = SnowflakeIdGenerator::from_duration(keep) else {
return Ok(());
};
self.delete_range(
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(0))),
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(until))),
)
.await
.caused_by(trc::location!())
}
}
/// Decodes a stored sample. Records in any other encoding (INBUXA's history
/// from before the fork) read as `None` and are skipped.
pub fn decode_metric(bytes: &[u8]) -> Option<Metric> {
PickledStream::new(bytes)
.and_then(|mut stream| ObjectInner::unpickle(ObjectType::Metric, &mut stream))
.and_then(|inner| match inner {
ObjectInner::Metric(metric) => Some(metric),
_ => None,
})
}
/// A stored sample as read by key: `None` when it can't be decoded.
pub struct MaybeMetric(pub Option<Metric>);
impl store::Deserialize for MaybeMetric {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
Ok(MaybeMetric(decode_metric(bytes)))
}
}
/// A stored sample with its id.
pub struct StoredMetric {
pub id: u64,
pub metric: Metric,
}
impl StoredMetric {
pub fn timestamp(&self) -> u64 {
SnowflakeIdGenerator::to_timestamp(self.id)
}
}
/// What the node wrote last, so counters and histograms are written as
/// changes (MON-4). Per process: a restart counts from the start.
static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None);
/// One tick's samples (MON-4 to MON-6).
pub fn sample() -> Vec<Metric> {
let mut last_guard = LAST.lock().unwrap();
let last = last_guard.get_or_insert_with(AHashMap::new);
let mut samples = Vec::new();
// Counters: the increase since the previous sample; none if unchanged
for counter in Collector::collect_counters() {
let Some(metric) = MetricType::parse(counter.id().as_str()) else {
continue;
};
let total = counter.value();
let previous = last.insert(metric, (total, 0)).map_or(0, |(count, _)| count);
let increase = total.saturating_sub(previous);
if increase > 0 {
samples.push(Metric::Counter(MetricCount {
count: increase,
metric,
}));
}
}
// Gauges: the reading, always (MON-5)
for gauge in Collector::collect_gauges() {
samples.push(Metric::Gauge(MetricCount {
count: gauge.get(),
metric: gauge.id(),
}));
}
// Histograms: totals, when changed (MON-4 Decision)
for histogram in Collector::collect_histograms() {
let metric = histogram.id();
let current = (histogram.count(), histogram.sum());
if last.insert(metric, current) != Some(current) {
samples.push(Metric::Histogram(MetricSum {
count: current.0,
sum: current.1,
metric,
}));
}
}
samples
}
/// The retention settings in force now (MON-3 Decision: no reload needed).
pub async fn retention(server: &Server) -> DataRetention {
server
.registry()
.object::<DataRetention>(Id::singleton())
.await
.ok()
.flatten()
.unwrap_or_default()
}
impl Server {
/// Writes one tick of metric history, if it's on (MON-4, MON-9). Never
/// fails loudly: history is lost, mail isn't (MON-35).
pub async fn store_metrics(&self) {
let store = self.metrics_store();
if store.is_none() {
return;
}
let samples = sample();
let count = samples.len();
let started = std::time::Instant::now();
match store.write_metrics(samples, now()).await {
Ok(()) => trc::event!(
Telemetry(TelemetryEvent::MetricsStored),
Total = count,
Elapsed = started.elapsed(),
),
Err(err) => {
trc::error!(err.details("Failed to store metric history"));
}
}
}
/// The stored samples between two ids, in key order, skipping any that
/// can't be decoded or are past `holdMetricsFor` (MON-17).
pub async fn read_metrics(
&self,
from_id: u64,
to_id: u64,
ascending: bool,
mut accept: impl FnMut(&StoredMetric) -> bool + Send + Sync,
) -> trc::Result<Vec<StoredMetric>> {
let store = self.metrics_store();
let mut out = Vec::new();
if store.is_none() {
return Ok(out);
}
let floor = match retention(self).await.hold_metrics_for {
Some(keep) => SnowflakeIdGenerator::from_duration(keep.into_inner()).unwrap_or(0),
None => 0,
};
let from_id = from_id.max(floor);
if from_id > to_id {
return Ok(out);
}
let params = IterateParams::new(
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(from_id))),
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(to_id))),
);
let params = if ascending {
params.ascending()
} else {
params.descending()
};
store
.iterate(params, |key, value| {
let id = key.deserialize_be_u64(0)?;
if let Some(metric) = decode_metric(value) {
let sample = StoredMetric { id, metric };
if accept(&sample) {
out.push(sample);
}
}
Ok(true)
})
.await
.caused_by(trc::location!())?;
Ok(out)
}
/// Test data for the shared metrics suite: 90 days of hourly ticks, a
/// counter, a gauge and a histogram each.
#[cfg(feature = "test_mode")]
pub async fn insert_test_metrics(&self) {
let now = now();
for hour in (0..90 * 24u64).rev() {
let samples = vec![
Metric::Counter(MetricCount {
count: 1 + hour % 7,
metric: MetricType::AuthSuccess,
}),
Metric::Gauge(MetricCount {
count: 20 + hour % 11,
metric: MetricType::QueueCount,
}),
Metric::Histogram(MetricSum {
count: 100 + hour,
sum: 1000 + hour * 10,
metric: MetricType::DeliveryTotalTime,
}),
];
self.metrics_store()
.write_metrics(samples, now - hour * 3600)
.await
.unwrap();
}
}
}
+10 -5
View File
@@ -2,8 +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.
*/ */
pub mod alerts; // inbuxa: monitoring (MON-25 to MON-30)
pub mod metrics; pub mod metrics;
pub mod tracers; pub mod tracers;
pub mod webhooks; pub mod webhooks;
@@ -17,14 +20,13 @@ use webhooks::spawn_webhook_tracer;
use crate::config::telemetry::{Telemetry, TelemetrySubscriberType}; use crate::config::telemetry::{Telemetry, TelemetrySubscriberType};
impl Telemetry { impl Telemetry {
pub fn enable(self, is_enterprise: bool) { pub fn enable(self) {
// Spawn tracers // Spawn tracers
for tracer in self.tracers.subscribers { for tracer in self.tracers.subscribers {
tracer.typ.spawn( tracer.typ.spawn(
SubscriberBuilder::new(tracer.id) SubscriberBuilder::new(tracer.id)
.with_interests(tracer.interests) .with_interests(tracer.interests)
.with_lossy(tracer.lossy), .with_lossy(tracer.lossy),
is_enterprise,
); );
} }
@@ -35,7 +37,7 @@ impl Telemetry {
Collector::reload(); Collector::reload();
} }
pub fn update(self, is_enterprise: bool) { pub fn update(self) {
// Remove tracers that are no longer active // Remove tracers that are no longer active
let active_subscribers = Collector::get_subscribers(); let active_subscribers = Collector::get_subscribers();
for subscribed_id in &active_subscribers { for subscribed_id in &active_subscribers {
@@ -58,7 +60,6 @@ impl Telemetry {
SubscriberBuilder::new(tracer.id) SubscriberBuilder::new(tracer.id)
.with_interests(tracer.interests) .with_interests(tracer.interests)
.with_lossy(tracer.lossy), .with_lossy(tracer.lossy),
is_enterprise,
); );
} }
} }
@@ -96,7 +97,7 @@ impl Telemetry {
} }
impl TelemetrySubscriberType { impl TelemetrySubscriberType {
pub fn spawn(self, builder: SubscriberBuilder, is_enterprise: bool) { pub fn spawn(self, builder: SubscriberBuilder) {
match self { match self {
TelemetrySubscriberType::ConsoleTracer(settings) => { TelemetrySubscriberType::ConsoleTracer(settings) => {
spawn_console_tracer(builder, settings) spawn_console_tracer(builder, settings)
@@ -104,6 +105,10 @@ impl TelemetrySubscriberType {
TelemetrySubscriberType::LogTracer(settings) => spawn_log_tracer(builder, settings), TelemetrySubscriberType::LogTracer(settings) => spawn_log_tracer(builder, settings),
TelemetrySubscriberType::Webhook(settings) => spawn_webhook_tracer(builder, settings), TelemetrySubscriberType::Webhook(settings) => spawn_webhook_tracer(builder, settings),
TelemetrySubscriberType::OtelTracer(settings) => spawn_otel_tracer(builder, settings), TelemetrySubscriberType::OtelTracer(settings) => spawn_otel_tracer(builder, settings),
// inbuxa: MON-10: trace history
TelemetrySubscriberType::StoreTracer(settings) => {
tracers::store::spawn_store_tracer(builder, settings.tracing, settings.data)
}
#[cfg(unix)] #[cfg(unix)]
TelemetrySubscriberType::JournalTracer(subscriber) => { TelemetrySubscriberType::JournalTracer(subscriber) => {
tracers::journald::spawn_journald_tracer(builder, subscriber) tracers::journald::spawn_journald_tracer(builder, subscriber)
@@ -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.
*/ */
#[cfg(unix)] #[cfg(unix)]
@@ -9,6 +11,7 @@ pub mod journald;
pub mod log; pub mod log;
pub mod otel; pub mod otel;
pub mod stdout; pub mod stdout;
pub mod store; // inbuxa: monitoring history (MON-10 to MON-17)
use registry::{ use registry::{
+6 -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 crate::{LONG_1Y_SLUMBER, config::telemetry::OtelTracer}; use crate::{LONG_1Y_SLUMBER, config::telemetry::OtelTracer};
@@ -27,12 +29,12 @@ 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, env!("CARGO_PKG_VERSION"))) .with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
.build(); .build();
let instrumentation = InstrumentationScope::builder("stalwart") let instrumentation = InstrumentationScope::builder("inbuxa")
.with_version(env!("CARGO_PKG_VERSION")) .with_version(types::brand_version_full!())
.build(); .build();
otel.log_exporter.set_resource(&resource); otel.log_exporter.set_resource(&resource);
@@ -0,0 +1,228 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Trace history (monitoring spec MON-10 to MON-17, MON-34). A lossy
//! collector subscriber gathers each inbound SMTP session and delivery
//! attempt, and writes it once, when the span closes, as an `x:Trace` in the
//! registry's own encoding under `TelemetryClass::Span(span_id)`.
use crate::telemetry::tracers::TraceEvents;
use ahash::AHashMap;
use registry::{
pickle::PickledStream,
schema::{
prelude::{ObjectInner, ObjectType},
structs::{
Task, TaskIndexTrace, TaskStatus, Trace, TraceKeyValue, TraceValue,
TraceValueString, TraceValueUnsignedInt,
},
},
};
use std::{future::Future, sync::Arc, time::Duration};
use store::{
SearchStore, Store, ValueKey,
search::{SearchFilter, SearchQuery},
write::{BatchBuilder, SearchIndex, TelemetryClass, ValueClass, now},
};
use trc::{
AddContext, DeliveryEvent, Event, EventDetails, EventType, Key, Level, SmtpEvent,
ipc::subscriber::SubscriberBuilder,
};
use utils::snowflake::SnowflakeIdGenerator;
/// Events kept per trace (MON-15).
pub const MAX_EVENTS: usize = 1000;
/// The longest string value kept (MON-15).
pub const MAX_STRING: usize = 4096;
/// A span still open after this is dropped (MON-13).
const SPAN_MAX_HOLD: u64 = 86_400;
pub trait TracingStore: Sync + Send {
/// Deletes traces older than `keep`, and their search documents
/// (MON-17).
fn purge_spans(
&self,
keep: Duration,
search: Option<&SearchStore>,
) -> impl Future<Output = trc::Result<()>> + Send;
}
impl TracingStore for Store {
async fn purge_spans(&self, keep: Duration, search: Option<&SearchStore>) -> trc::Result<()> {
let Some(until) = SnowflakeIdGenerator::from_duration(keep) else {
return Ok(());
};
self.delete_range(
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span(0))),
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span(until))),
)
.await
.caused_by(trc::location!())?;
if let Some(search) = search {
search
.unindex(
SearchQuery::new(SearchIndex::Tracing)
.with_filter(SearchFilter::lt(store::search::SearchField::Id, until)),
)
.await
.caused_by(trc::location!())?;
}
Ok(())
}
}
/// Decodes a stored trace; `None` for records in any other encoding.
pub fn decode_trace(bytes: &[u8]) -> Option<Trace> {
PickledStream::new(bytes)
.and_then(|mut stream| ObjectInner::unpickle(ObjectType::Trace, &mut stream))
.and_then(|inner| match inner {
ObjectInner::Trace(trace) => Some(trace),
_ => None,
})
}
/// A stored trace as read by key: `None` when it can't be decoded.
pub struct MaybeTrace(pub Option<Trace>);
impl store::Deserialize for MaybeTrace {
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
Ok(MaybeTrace(decode_trace(bytes)))
}
}
fn is_stored_span(event: EventType) -> bool {
matches!(
event,
EventType::Smtp(SmtpEvent::ConnectionStart) | EventType::Delivery(DeliveryEvent::AttemptStart)
)
}
fn is_mail_from(event: EventType) -> bool {
event.as_str().starts_with("smtp.mail-from") || event == EventType::Smtp(SmtpEvent::MultipleMailFrom)
}
struct Span {
started: u64,
is_smtp: bool,
has_mail_from: bool,
events: Vec<Arc<Event<EventDetails>>>,
cut: usize,
}
fn truncate_values_list(values: &mut registry::types::list::List<TraceKeyValue>) {
for kv in values.values_mut() {
match &mut kv.value {
TraceValue::String(TraceValueString { value }) if value.len() > MAX_STRING => {
let mut end = MAX_STRING;
while !value.is_char_boundary(end) {
end -= 1;
}
value.truncate(end);
}
TraceValue::Event(event) => truncate_values_list(&mut event.value),
_ => {}
}
}
}
/// The trace a closed span leaves (MON-12, MON-15).
fn build_trace(span: &Span) -> Trace {
let mut trace = Trace::from_events(span.events.iter().map(|e| e.as_ref()), span.events.len());
for event in trace.events.values_mut() {
truncate_values_list(&mut event.key_values);
}
if span.cut > 0
&& let Some(last) = trace.events.values_mut().last()
{
// The count of events cut rides on the closing event
last.key_values.push(TraceKeyValue {
key: Key::Total,
value: TraceValue::UnsignedInt(TraceValueUnsignedInt {
value: span.cut as u64,
}),
});
}
trace
}
/// Starts the subscriber that stores traces in `tracing`, scheduling their
/// indexing in `data` (MON-16). Lossy: a slow store loses history, never
/// delays mail (MON-34, MON-35).
pub(crate) fn spawn_store_tracer(builder: SubscriberBuilder, tracing: Store, data: Store) {
let (_, mut rx) = builder.register();
tokio::spawn(async move {
let mut spans: AHashMap<u64, Span> = AHashMap::new();
while let Some(events) = rx.recv().await {
let mut closed = Vec::new();
for event in events {
let typ = event.inner.typ;
let Some(span_id) = event.span_id() else {
continue;
};
if is_stored_span(typ) {
spans.insert(
span_id,
Span {
started: event.inner.timestamp,
is_smtp: matches!(typ, EventType::Smtp(_)),
has_mail_from: false,
events: vec![event],
cut: 0,
},
);
continue;
}
let Some(span) = spans.get_mut(&span_id) else {
continue;
};
if is_mail_from(typ) {
span.has_mail_from = true;
}
let is_end = typ.is_span_end();
// MON-12: info and above, never raw I/O
if !typ.is_raw_io() && (is_end || event.inner.level as usize >= Level::Info as usize) {
if span.events.len() < MAX_EVENTS - 1 || is_end {
span.events.push(event);
} else {
span.cut += 1;
}
}
if is_end && let Some(span) = spans.remove(&span_id) {
// MON-11: a session that never reached MAIL FROM isn't kept
if !span.is_smtp || span.has_mail_from {
closed.push((span_id, span));
}
}
}
if !closed.is_empty() {
let mut batch = BatchBuilder::new();
let mut tasks = BatchBuilder::new();
for (span_id, span) in &closed {
batch.set(
ValueClass::Telemetry(TelemetryClass::Span(*span_id)),
ObjectInner::Trace(build_trace(span)).to_pickled_vec(),
);
tasks.schedule_task(Task::IndexTrace(TaskIndexTrace {
trace_id: (*span_id).into(),
status: TaskStatus::now(),
}));
}
if let Err(err) = tracing.write(batch.build_all()).await {
trc::error!(err.details("Failed to store trace history"));
} else if let Err(err) = data.write(tasks.build_all()).await {
trc::error!(err.details("Failed to schedule trace indexing"));
}
}
// MON-13: spans open for over a day are dropped
if spans.len() > 1000 {
let now = now();
spans.retain(|_, span| now.saturating_sub(span.started) < SPAN_MAX_HOLD);
}
}
});
}
+6
View File
@@ -2,8 +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.
*/ */
// inbuxa: composite stores (sharded members, read replicas) nest store
// futures deeply enough to pass rustc's default query depth
#![recursion_limit = "512"]
#![warn(clippy::large_futures)] #![warn(clippy::large_futures)]
#[allow(unused_imports)] #[allow(unused_imports)]
+22
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 super::ArchivedResource; use super::ArchivedResource;
@@ -137,6 +139,26 @@ impl DavAclHandler for Server {
.validate_and_map_aces(access_token, request, collection) .validate_and_map_aces(access_token, request, collection)
.await?; .await?;
// inbuxa: MT-3: grants stay within the owner's tenant
let tenant_id = self
.try_account(account_id)
.await
.caused_by(trc::location!())?
.and_then(|owner| owner.id_tenant);
for grant in &grants {
if self
.try_account(grant.account_id)
.await
.caused_by(trc::location!())?
.is_none_or(|grantee| grantee.id_tenant != tenant_id)
{
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::FORBIDDEN,
BaseCondition::AllowedPrincipal,
)));
}
}
if grants.len() != acls.len() || acls.iter().zip(grants.iter()).any(|(a, b)| a != b) { if grants.len() != acls.len() || acls.iter().zip(grants.iter()).any(|(a, b)| a != b) {
// Refresh ACLs // Refresh ACLs
self.refresh_archived_acls(&grants, acls) self.refresh_archived_acls(&grants, acls)
+3 -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 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}")
} }
} }
} }
+6
View File
@@ -2,7 +2,13 @@
* 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.
*/ */
// inbuxa: composite stores (sharded members, read replicas) nest store
// futures deeply enough to pass rustc's default query depth
#![recursion_limit = "512"]
#![warn(clippy::large_futures)] #![warn(clippy::large_futures)]
pub mod calendar; pub mod calendar;
+39
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::{ use crate::{
@@ -74,9 +76,19 @@ pub(crate) trait DavRequestDispatcher: Sync + Send {
method: DavMethod, method: DavMethod,
body: Vec<u8>, body: Vec<u8>,
) -> impl Future<Output = crate::Result<HttpResponse>> + Send; ) -> impl Future<Output = crate::Result<HttpResponse>> + Send;
fn dispatch_dav_inner(
&self,
headers: &RequestHeaders<'_>,
access_token: AccessToken,
resource: DavResourceName,
method: DavMethod,
body: Vec<u8>,
) -> impl Future<Output = crate::Result<HttpResponse>> + Send;
} }
impl DavRequestDispatcher for Server { impl DavRequestDispatcher for Server {
// inbuxa: ST-6: GET, PROPFIND and REPORT may be served by a read replica
async fn dispatch_dav_request( async fn dispatch_dav_request(
&self, &self,
headers: &RequestHeaders<'_>, headers: &RequestHeaders<'_>,
@@ -84,6 +96,33 @@ impl DavRequestDispatcher for Server {
resource: DavResourceName, resource: DavResourceName,
method: DavMethod, method: DavMethod,
body: Vec<u8>, body: Vec<u8>,
) -> crate::Result<HttpResponse> {
if matches!(
method,
DavMethod::GET | DavMethod::HEAD | DavMethod::PROPFIND | DavMethod::REPORT
) {
let accounts = access_token
.all_ids()
.map(|account_id| (account_id, 0))
.collect::<Vec<_>>();
store::backend::scaleout::replica::replica_read(
accounts,
self.dispatch_dav_inner(headers, access_token, resource, method, body),
)
.await
} else {
self.dispatch_dav_inner(headers, access_token, resource, method, body)
.await
}
}
async fn dispatch_dav_inner(
&self,
headers: &RequestHeaders<'_>,
access_token: AccessToken,
resource: DavResourceName,
method: DavMethod,
body: Vec<u8>,
) -> crate::Result<HttpResponse> { ) -> crate::Result<HttpResponse> {
// Dispatch // Dispatch
match method { match method {
+3 -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::Directory; use crate::Directory;
@@ -38,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("Stalwart/1.0") .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}")))?;

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