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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two changes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two operator-visible defaults are allowlisted as open, pending a decision:
the log file prefix and the SQL stores' default database and user.
2026-09-22 16:12:47 -07:00
jcoffey-dev 39df888412 CI: open an issue when upstream publishes a release not yet imported
ci / build (pull_request) Successful in 7m22s
Reads metadata only: upstream's releases list from GitHub's API and the
head of the upstream branch from Gitea's. Nothing of upstream's is
fetched, so its history can't land here. Daily at 06:17 UTC.
2026-09-22 15:37:34 -07:00
269 changed files with 23984 additions and 929 deletions
+8 -2
View File
@@ -1,10 +1,16 @@
// Ignore everything
# Ignore everything
*
// Allow what is needed
# Allow what is needed
!crates
!tests
!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.toml
+31
View File
@@ -20,6 +20,27 @@ concurrency:
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
@@ -51,6 +72,16 @@ jobs:
# --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
+88 -1
View File
@@ -131,8 +131,95 @@ jobs:
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`."
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
+33
View File
@@ -2,6 +2,39 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [0.16.23] - 2026-09-21
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
## Added
- Expressions: `bit_and` function.
## Changed
## Fixed
- MTA:
- A mailing list whose recipients include another mailing list is accepted at `RCPT TO` and then rejected at local delivery with `550 5.5.0 Mailbox not found`.
- DMARC aggregate reports carry two `spf` elements per record and the `version` element of a DMARC aggregate report is written as `1` instead of `1.0`.
- DSNs generated for an alias rewrite or a list expansion emit a doubled `addr-type` in `Original-Recipient` (`rfc822;rfc822;[email protected]`).
- DSNs that cannot be written to the store are discarded, the recipients are flagged as notified and the original message is removed from the queue, losing both the bounce and the message.
- POP3:
- `TOP msg n` counts the `n` lines from the first byte of the message instead of from the first byte of the body.
- A message whose very first line begins with `.` is not byte-stuffed.
- Spam filter: Moving or copying a message from one account into another creates no training sample, so the classifier never learns from it.
- Sieve: `envelope "orcpt"` yields the bare address for an `ORCPT` supplied over SMTP. It now carries the `addr-type` prefix in every case, as required by RFC 6009.
- ACME: The `_acme-challenge` TXT records published for a DNS-01 authorization are never removed.
- DNS: The DNSSEC resolver queries a single nameserver at a time, working around a `hickory-resolver` race that cancels the TCP retry when two nameservers return a truncated response in parallel.
- Troubleshoot tool:
- MX records are resolved through the DNSSEC-validating resolver, matching the resolver used by the delivery path.
- A TLSA lookup that fails or returns bogus records stops the delivery attempt for that host, instead of continuing without DANE.
- OIDC: Bearer tokens that carry no `email`, `preferred_username` or `upn` claim are always authenticated against the default directory.
- Meilisearch: A confirmation timeout is treated as a failed write even when `failOnTimeout` is disabled, so an index whose batches take longer than `pollInterval` x `maxRetries` never completes an indexing task and resubmits the same batch indefinitely.
- WebUI: A failed update no longer takes an `Application` offline.
- FoundationDB: The cached read version is invalidated when any broadcast is received from another node.
- Redis:
- On a cluster, the rate limiter and the blob upload quota issue `INCR` and `EXPIRE` as a `MULTI`/`EXEC` transaction, whose `MOVED` redirects collapse into a single `EXECABORT` that never refreshes the slot map.
- A connection that fails because it is addressing the wrong server is returned to the pool and reused, since the recycle check only issues `PING`.
## [0.16.22] - 2026-09-13
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
Generated
+140 -132
View File
@@ -234,7 +234,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
"synstructure 0.13.2",
]
[[package]]
@@ -277,9 +277,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.46"
version = "0.4.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f"
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
dependencies = [
"compression-codecs",
"compression-core",
@@ -310,7 +310,7 @@ dependencies = [
"memchr",
"pin-project",
"portable-atomic",
"rand 0.10.2",
"rand 0.10.3",
"regex",
"rustls-native-certs",
"rustls-pki-types",
@@ -369,7 +369,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -874,7 +874,7 @@ dependencies = [
"log",
"num",
"pin-project-lite",
"rand 0.10.2",
"rand 0.10.3",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
@@ -984,7 +984,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -1110,9 +1110,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.4.6"
version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1160,9 +1160,9 @@ dependencies = [
[[package]]
name = "cfg-if"
version = "1.0.4"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
@@ -1302,7 +1302,7 @@ dependencies = [
[[package]]
name = "common"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"aes-gcm-siv",
"ahash",
@@ -1402,9 +1402,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.41"
version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
dependencies = [
"compression-core",
"flate2",
@@ -1487,7 +1487,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "coordinator"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"async-nats",
"futures",
@@ -1849,7 +1849,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -1882,7 +1882,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core 0.24.1",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -1899,7 +1899,7 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dav"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"calcard",
"chrono",
@@ -1922,7 +1922,7 @@ dependencies = [
[[package]]
name = "dav-proto"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"calcard",
"chrono",
@@ -2135,7 +2135,7 @@ dependencies = [
[[package]]
name = "directory"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"argon2 0.6.0",
@@ -2192,7 +2192,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -2376,7 +2376,7 @@ dependencies = [
[[package]]
name = "email"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"aes 0.9.3",
"aes-gcm 0.11.1",
@@ -2485,10 +2485,10 @@ dependencies = [
[[package]]
name = "event_macro"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -2571,7 +2571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4"
dependencies = [
"portable-atomic",
"rand 0.10.2",
"rand 0.10.3",
"web-time",
]
@@ -2594,9 +2594,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
[[package]]
name = "fixed_decimal"
@@ -2710,7 +2710,7 @@ dependencies = [
"foundationdb-sys",
"foundationdb-tuple",
"futures",
"rand 0.10.2",
"rand 0.10.3",
"serde",
"serde_bytes",
"serde_json",
@@ -2842,7 +2842,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -3013,7 +3013,7 @@ dependencies = [
[[package]]
name = "groupware"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"calcard",
@@ -3169,7 +3169,7 @@ dependencies = [
"jni",
"lru-cache",
"parking_lot",
"rand 0.10.2",
"rand 0.10.3",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
@@ -3196,7 +3196,7 @@ dependencies = [
"jni",
"once_cell",
"prefix-trie",
"rand 0.10.2",
"rand 0.10.3",
"ring",
"rustls-pki-types",
"thiserror 2.0.20",
@@ -3223,7 +3223,7 @@ dependencies = [
"ndk-context",
"once_cell",
"parking_lot",
"rand 0.10.2",
"rand 0.10.3",
"resolv-conf",
"rustls",
"smallvec",
@@ -3302,7 +3302,7 @@ dependencies = [
[[package]]
name = "http"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"async-stream",
"base64 0.23.1",
@@ -3398,7 +3398,7 @@ dependencies = [
[[package]]
name = "http_proto"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"common",
"compact_str",
@@ -3488,9 +3488,9 @@ dependencies = [
[[package]]
name = "hyper-rustls"
version = "0.27.9"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http 1.5.0",
"hyper",
@@ -3533,7 +3533,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.5",
"tokio",
"tower-service",
"tracing",
@@ -3884,7 +3884,7 @@ checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]]
name = "imap"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"common",
@@ -3897,7 +3897,7 @@ dependencies = [
"md5",
"nlp",
"parking_lot",
"rand 0.10.2",
"rand 0.10.3",
"registry",
"store",
"tokio",
@@ -3909,7 +3909,7 @@ dependencies = [
[[package]]
name = "imap_proto"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -3924,7 +3924,7 @@ dependencies = [
[[package]]
name = "inbuxa"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"common",
"coordinator",
@@ -3932,7 +3932,7 @@ dependencies = [
"directory",
"email",
"groupware",
"http 0.16.22",
"http 0.16.23",
"http_proto",
"imap",
"jmap",
@@ -4134,25 +4134,24 @@ checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3"
[[package]]
name = "jieba-macros"
version = "0.10.3"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34904340bc65749a9e9a02fcc7f3368e675427c18447b9bbe02df52c15c9a36a"
checksum = "455f837e9d0255b68a712200db247c68fdad4941b72471b76bfa61c3b0c1f79f"
dependencies = [
"phf_codegen",
]
[[package]]
name = "jieba-rs"
version = "0.10.3"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d"
checksum = "b6a8bbb0f77ee810f0689a30b7cec56b875751ef4ec2e74fd995613dc52b3ae1"
dependencies = [
"bytecount",
"cedarwood",
"include-flate",
"jieba-macros",
"phf 0.13.1",
"regex",
"rustc-hash",
]
@@ -4212,7 +4211,7 @@ dependencies = [
[[package]]
name = "jmap"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"async-stream",
"base64 0.23.1",
@@ -4236,7 +4235,7 @@ dependencies = [
"mail-parser",
"nlp",
"p256",
"rand 0.10.2",
"rand 0.10.3",
"registry",
"reqwest 0.13.5",
"rkyv",
@@ -4294,7 +4293,7 @@ dependencies = [
[[package]]
name = "jmap_proto"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"calcard",
@@ -4699,9 +4698,9 @@ dependencies = [
[[package]]
name = "lru-slab"
version = "0.1.2"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "lz4-sys"
@@ -4742,9 +4741,9 @@ dependencies = [
[[package]]
name = "mail-auth"
version = "0.13.2"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11f19d98aac923fc5b7ee30c3509733a013ef546a226acb959b9202f5ca58f0"
checksum = "8505122ba86e1f4adeb664196c1e787c3f29bb6e7c128e4a366d47d209911440"
dependencies = [
"aws-lc-rs",
"flate2",
@@ -4757,7 +4756,7 @@ dependencies = [
"mail-parser",
"memchr",
"quick-xml 0.42.0",
"rand 0.10.2",
"rand 0.10.3",
"rkyv",
"rsa",
"rustls-pki-types",
@@ -4800,7 +4799,7 @@ dependencies = [
[[package]]
name = "managesieve"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"common",
"compact_str",
@@ -4935,7 +4934,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
[[package]]
name = "migration"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"common",
"email",
@@ -5066,7 +5065,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustversion",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -5131,7 +5130,7 @@ dependencies = [
"lru",
"mysql_common",
"percent-encoding",
"rand 0.10.2",
"rand 0.10.3",
"rustls",
"serde",
"socket2 0.6.5",
@@ -5206,14 +5205,14 @@ dependencies = [
[[package]]
name = "nlp"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"hashify",
"jieba-rs",
"maplit",
"psl",
"rand 0.10.2",
"rand 0.10.3",
"rkyv",
"rust-stemmers",
"serde",
@@ -6038,7 +6037,7 @@ dependencies = [
[[package]]
name = "pop3"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"common",
"directory",
@@ -6082,7 +6081,7 @@ dependencies = [
"hmac 0.13.0",
"md-5 0.11.0",
"memchr",
"rand 0.10.2",
"rand 0.10.3",
"sha2 0.11.0",
"stringprep",
]
@@ -6119,9 +6118,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppmd-rust"
version = "1.4.1"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9219bcb9d7aca6b2f63c83cf100cf78bcd619ac46e6ecbd0dd90869a39345d"
checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1"
[[package]]
name = "ppv-lite86"
@@ -6206,7 +6205,7 @@ dependencies = [
"proc-macro-error-attr3",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -6260,7 +6259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools 0.13.0",
"itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -6287,9 +6286,9 @@ dependencies = [
[[package]]
name = "psl"
version = "2.1.232"
version = "2.1.235"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62834e308cc83aea5e30cd8c80b8aa82cdb104a3240c7f210d4f68d46e29f308"
checksum = "8319b56ff38ca0522b4e1e40bfa2b5de7f62dc89fc1e9033eac365551ec58e0e"
dependencies = [
"psl-types",
]
@@ -6317,7 +6316,7 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -6388,9 +6387,9 @@ dependencies = [
[[package]]
name = "quinn"
version = "0.11.11"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
@@ -6399,7 +6398,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.5.10",
"socket2 0.6.5",
"thiserror 2.0.20",
"tokio",
"tracing",
@@ -6408,16 +6407,16 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.17"
version = "0.11.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
dependencies = [
"aws-lc-rs",
"bytes",
"fastbloom",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash",
@@ -6440,7 +6439,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
@@ -6534,9 +6533,9 @@ dependencies = [
[[package]]
name = "rand"
version = "0.10.2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
@@ -6776,7 +6775,7 @@ dependencies = [
"num-bigint 0.5.1",
"percent-encoding",
"pin-project-lite",
"rand 0.10.2",
"rand 0.10.3",
"rustls",
"rustls-native-certs",
"ryu",
@@ -6800,11 +6799,10 @@ dependencies = [
[[package]]
name = "redox_users"
version = "0.5.2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.20",
]
@@ -6826,7 +6824,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -6860,7 +6858,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "registry"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"hashify",
@@ -7044,7 +7042,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -7225,9 +7223,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.4"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags 2.13.2",
"errno",
@@ -7412,12 +7410,12 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
name = "scim"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -7443,7 +7441,7 @@ dependencies = [
[[package]]
name = "scim-proto"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"hashify",
"serde",
@@ -7580,7 +7578,7 @@ dependencies = [
"sha2 0.10.9",
"sha3 0.10.9",
"slh-dsa",
"thiserror 1.0.69",
"thiserror 2.0.20",
"twofish",
"typenum",
"x25519-dalek",
@@ -7624,7 +7622,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -7635,7 +7633,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -7671,7 +7669,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -7716,7 +7714,7 @@ dependencies = [
"darling 0.24.1",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -7764,12 +7762,12 @@ checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
name = "services"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"aes-gcm 0.11.1",
"aho-corasick",
@@ -7960,8 +7958,6 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "sieve-rs"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd00a548fde57bd0c8e7c13ae65fc5fe30bd923ff8655c81e010f3f02a90997b"
dependencies = [
"ahash",
"arc-swap",
@@ -8084,7 +8080,7 @@ checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "smtp"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -8099,7 +8095,7 @@ dependencies = [
"mail-builder 1.0.0",
"mail-parser",
"parking_lot",
"rand 0.10.2",
"rand 0.10.3",
"registry",
"reqwest 0.13.5",
"rkyv",
@@ -8175,7 +8171,7 @@ dependencies = [
[[package]]
name = "spam-filter"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"common",
"compact_str",
@@ -8295,7 +8291,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "store"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"arc-swap",
@@ -8321,7 +8317,7 @@ dependencies = [
"parking_lot",
"r2d2",
"radsort",
"rand 0.10.2",
"rand 0.10.3",
"rayon",
"redis",
"registry",
@@ -8417,9 +8413,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.5"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
@@ -8446,6 +8442,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "sysinfo"
version = "0.37.2"
@@ -8544,7 +8551,7 @@ dependencies = [
[[package]]
name = "tests"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"aws-lc-rs",
@@ -8566,7 +8573,7 @@ dependencies = [
"form_urlencoded",
"futures",
"groupware",
"http 0.16.22",
"http 0.16.23",
"http_proto",
"hyper",
"hyper-util",
@@ -8581,6 +8588,7 @@ dependencies = [
"mail-builder 1.0.0",
"mail-parser",
"managesieve",
"migration",
"nlp",
"pop3",
"quick-xml 0.41.0",
@@ -8652,7 +8660,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -8790,7 +8798,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -8812,7 +8820,7 @@ dependencies = [
"pin-project-lite",
"postgres-protocol",
"postgres-types",
"rand 0.10.2",
"rand 0.10.3",
"socket2 0.6.5",
"tokio",
"tokio-util",
@@ -8997,7 +9005,7 @@ dependencies = [
"constant_time_eq",
"hmac 0.13.0",
"percent-encoding",
"rand 0.10.2",
"rand 0.10.3",
"serde",
"sha1 0.11.0",
"sha2 0.11.0",
@@ -9136,7 +9144,7 @@ dependencies = [
[[package]]
name = "trc"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"base64 0.23.1",
@@ -9195,7 +9203,7 @@ dependencies = [
"http 1.5.0",
"httparse",
"log",
"rand 0.10.2",
"rand 0.10.3",
"sha1 0.11.0",
"thiserror 2.0.20",
]
@@ -9245,7 +9253,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "types"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"blake3",
"compact_str",
@@ -9297,9 +9305,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
version = "1.0.24"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
@@ -9414,7 +9422,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utils"
version = "0.16.22"
version = "0.16.23"
dependencies = [
"ahash",
"arcstr",
@@ -9616,7 +9624,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
"wasm-bindgen-shared",
]
@@ -10125,14 +10133,14 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
"syn 3.0.6",
"synstructure 0.14.0",
]
[[package]]
@@ -10655,14 +10663,14 @@ dependencies = [
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
"syn 3.0.6",
"synstructure 0.14.0",
]
[[package]]
@@ -10717,7 +10725,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
"syn 3.0.6",
]
[[package]]
@@ -10749,9 +10757,9 @@ dependencies = [
[[package]]
name = "zlib-rs"
version = "0.6.7"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
+9
View File
@@ -1,5 +1,7 @@
[workspace]
resolver = "2"
# Vendored crates are patched in below, not built as members.
exclude = ["vendor"]
members = [
"crates/main",
"crates/types",
@@ -78,3 +80,10 @@ incremental = false
debug-assertions = false
overflow-checks = 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" }
+4
View File
@@ -19,6 +19,10 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
g++-x86-64-linux-gnu binutils-x86-64-linux-gnu
RUN rustup target add "$(cat /target.txt)"
COPY --from=planner /recipe.json /recipe.json
# inbuxa: [patch.crates-io] points sieve-rs at vendor/, and the recipe only
# carries the workspace's own manifests, so cooking the dependencies needs the
# vendored crate itself (the context allows it since #27; this puts it here).
COPY vendor/ vendor/
RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json
COPY . .
RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
+8 -7
View File
@@ -8,13 +8,13 @@
---
**INBUXA** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
**inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
Project site: [inbuxa.org](https://inbuxa.org). Documentation: [docs.inbuxa.org](https://docs.inbuxa.org).
Stalwart ships some features only in a paid Enterprise Edition: multi-tenancy,
masked email, undelete and others. INBUXA ships everything to everybody under
masked email, undelete and others. **inbuxa** ships everything to everybody under
the AGPL-3.0, rebuilding those features independently and without using any
of Stalwart's Enterprise code.
@@ -46,25 +46,26 @@ docker build -t inbuxa . # or the container image
```
Settings are read from `INBUXA_*` environment variables. An existing Stalwart
install's `STALWART_*` variables still work, with a warning to rename them.
install's `STALWART_*` variables aren't read: the server stops at startup and
names each one to rename.
New installs keep their data in `/var/lib/inbuxa` and logs in
`/var/log/inbuxa`. Existing installs keep the paths their configuration
already names, so none of their data moves.
## License and credits
INBUXA is free software under the [GNU Affero General Public License,
**inbuxa** is free software under the [GNU Affero General Public License,
version 3](./LICENSES/AGPL-3.0-only.txt).
It is a fork of Stalwart, copyright © Stalwart Labs LLC, **modified by
Coffey Labs in 2026**. Upstream's copyright notices are kept on every file
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
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
Stalwart Labs' name. **inbuxa** isn't affiliated with or endorsed by Stalwart
Labs.
The INBUXA mark reuses ihasmail's cat-and-envelope artwork.
The **inbuxa** mark reuses ihasmail's cat-and-envelope artwork.
+1
View File
@@ -24,6 +24,7 @@ carry their own license files.
| `crates/common/src/network/acme/directory.rs`, `crates/common/src/network/acme/jose.rs`, `crates/common/src/network/acme/order.rs` | [rustls-acme](https://github.com/FlorianUekermann/rustls-acme) (MIT or Apache-2.0) | Copyright (c) Florian Uekermann |
| `crates/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:
+7 -7
View File
@@ -1,8 +1,8 @@
openapi: 3.0.3
info:
title: Stalwart Management API
title: inbuxa Management API
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
is managed via JMAP (see `POST /jmap/`). The endpoints documented here cover
interactive login, account introspection, configuration schema retrieval and
@@ -12,11 +12,11 @@ info:
name: AGPL-3.0-only OR LicenseRef-SEL
servers:
- url: https://{host}
description: Stalwart server
description: inbuxa server
variables:
host:
default: mail.example.com
description: The hostname of Stalwart server
description: The hostname of the inbuxa server
security:
- bearerAuth: []
- basicAuth: []
@@ -154,7 +154,7 @@ paths:
operationId: getSchema
summary: Return the configuration schema at a specific hash
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
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
@@ -183,7 +183,7 @@ paths:
application/json:
schema:
type: object
description: JSON Schema document describing Stalwart config
description: JSON Schema document describing inbuxa config
additionalProperties: true
'302':
description: Redirect to the current schema URL when the hash is stale
@@ -395,7 +395,7 @@ components:
WWW-Authenticate:
schema:
type: string
example: Bearer realm="Stalwart Server"
example: Bearer realm="inbuxa Server"
content:
application/problem+json:
schema:
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "common"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
build = "build.rs"
+147 -32
View File
@@ -11,7 +11,7 @@ use crate::{
auth::{
AccessToken, AuthRequest, DomainCache,
credential::{ApiKey, AppPassword},
oauth::GrantType,
oauth::{GrantType, token::TOKEN_HEADER},
},
};
use base64::{Engine, engine::general_purpose};
@@ -23,7 +23,8 @@ use registry::schema::{
enums::Permission,
structs::{self, Credential},
};
use std::{net::IpAddr, sync::Arc};
use serde::Deserialize;
use std::{borrow::Cow, net::IpAddr, sync::Arc};
use store::write::now;
use trc::AddContext;
@@ -321,19 +322,12 @@ impl Server {
// Obtain external directory, if any. When no username is supplied
// (e.g. HTTP bearer auth), peek at the JWT claims to find the
// user's domain so per-domain OIDC directories are reachable.
let directory = if let Some(username) = username.as_deref().map(UsernameParts::new)
{
if let Some(domain_name) = username.auth_as().domain() {
self.get_directory_for_domain(domain_name).await?
} else if let Some(domain_name) = extract_jwt_domain(token) {
self.get_directory_for_domain(&domain_name).await?
} else {
self.get_default_directory()
}
} else if let Some(domain_name) = extract_jwt_domain(token) {
self.get_directory_for_domain(&domain_name).await?
} else {
self.get_default_directory()
let directory = match username.as_deref().map(UsernameParts::new) {
Some(username) => match username.auth_as().domain() {
Some(domain_name) => self.get_directory_for_domain(domain_name).await?,
None => self.get_directory_for_token(token).await?,
},
None => self.get_directory_for_token(token).await?,
};
// Try external directory authentication first if supported, then fallback to internal OAuth.
@@ -563,6 +557,29 @@ impl Server {
})
}
async fn get_directory_for_token(&self, token: &str) -> trc::Result<Option<&Arc<Directory>>> {
let Some(payload) = JwtClaims::decode_payload(token) else {
return Ok(self.get_default_directory());
};
let Some(claims) = JwtClaims::parse(&payload) else {
return Ok(self.get_default_directory());
};
match (claims.domain(), claims.iss.as_deref()) {
(Some(domain_name), _) => self.get_directory_for_domain(domain_name).await,
(None, Some(issuer)) => Ok(self
.get_directory_for_issuer(issuer)
.or_else(|| self.get_default_directory())),
(None, None) => Ok(self.get_default_directory()),
}
}
/// inbuxa: DIR-2: a token naming no address gets the server default, so
/// no directory is chosen by issuer.
fn get_directory_for_issuer(&self, _issuer: &str) -> Option<&Arc<Directory>> {
None
}
/// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A
/// `directoryId` naming no directory the server built is unavailable,
/// never the internal directory.
@@ -622,25 +639,50 @@ pub fn unavailable_directory() -> &'static Arc<Directory> {
})
}
fn extract_jwt_domain(token: &str) -> Option<String> {
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let _signature = parts.next()?;
if parts.next().is_some() {
return None;
}
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?;
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
for claim in ["email", "preferred_username", "upn"] {
if let Some(val) = claims.get(claim).and_then(|v| v.as_str())
&& let Some((_, domain)) = val.rsplit_once('@')
&& !domain.is_empty()
{
return Some(domain.to_ascii_lowercase());
#[derive(Deserialize)]
struct JwtClaims<'x> {
#[serde(borrow, default)]
iss: Option<Cow<'x, str>>,
#[serde(borrow, default)]
email: Option<Cow<'x, str>>,
#[serde(borrow, default)]
preferred_username: Option<Cow<'x, str>>,
#[serde(borrow, default)]
upn: Option<Cow<'x, str>>,
}
impl<'x> JwtClaims<'x> {
fn decode_payload(token: &str) -> Option<Vec<u8>> {
if token.starts_with(TOKEN_HEADER) {
return None;
}
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let _signature = parts.next()?;
if parts.next().is_some() {
return None;
}
general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()
}
fn parse(payload: &'x [u8]) -> Option<Self> {
serde_json::from_slice(payload).ok()
}
fn domain(&self) -> Option<&str> {
[&self.email, &self.preferred_username, &self.upn]
.into_iter()
.flatten()
.find_map(|claim| {
claim
.rsplit_once('@')
.map(|(_, domain)| domain)
.filter(|domain| !domain.is_empty())
})
}
None
}
impl UsernameParts {
@@ -738,3 +780,76 @@ impl AuthRequest {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn jwt(payload: &str) -> String {
format!(
"eyJhbGciOiJSUzI1NiJ9.{}.c2lnbmF0dXJl",
general_purpose::URL_SAFE_NO_PAD.encode(payload)
)
}
fn hints(token: &str) -> Option<(Option<String>, Option<String>)> {
let payload = JwtClaims::decode_payload(token)?;
let claims = JwtClaims::parse(&payload)?;
Some((
claims.domain().map(str::to_string),
claims.iss.as_deref().map(str::to_string),
))
}
#[test]
fn jwt_claims_are_extracted() {
for (payload, domain, issuer) in [
(
r#"{"iss":"https://idp.example.org","email":"[email protected]"}"#,
Some("Example.ORG"),
Some("https://idp.example.org"),
),
(
r#"{"preferred_username":"[email protected]","upn":"[email protected]"}"#,
Some("example.net"),
None,
),
(
r#"{"email":"broken@","upn":"[email protected]"}"#,
Some("example.com"),
None,
),
(
r#"{"iss":"https://idp.example.org","sub":"5db2d1b6","aud":["a","b"],"scope":"openid"}"#,
None,
Some("https://idp.example.org"),
),
(r#"{"sub":"5db2d1b6"}"#, None, None),
(r#"{"email":"[email protected]"}"#, Some("example.net"), None),
] {
assert_eq!(
hints(&jwt(payload)),
Some((domain.map(str::to_string), issuer.map(str::to_string))),
"Unexpected claims for {payload}"
);
}
}
#[test]
fn non_jwt_tokens_are_ignored() {
for token in [
"sw1.eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLm9yZyJ9",
"sw1.eyJhbGciOiJSUzI1NiJ9",
"opaque-token",
"one.two",
"one.two.three.four",
"",
] {
assert!(
JwtClaims::decode_payload(token).is_none(),
"Token {token:?} was parsed as a JWT"
);
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub const FAILED_TO_DECODE_TOKEN: &str = concat!(
"the Authentication object."
);
const TOKEN_HEADER: &str = "sw1.";
pub(crate) const TOKEN_HEADER: &str = "sw1.";
const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1";
const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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;
+23 -1
View File
@@ -143,7 +143,9 @@ impl Scripting {
.with_cpu_limit(trusted.max_cpu_cycles as usize)
.with_max_nested_includes(trusted.max_nested_includes 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());
untrusted_runtime.set_local_hostname(local_hostname);
@@ -279,3 +281,23 @@ impl Clone for Scripting {
}
}
}
#[cfg(test)]
mod tests {
use sieve::compiler::grammar::Capability;
// inbuxa: sieve-rs is vendored (vendor/sieve-rs) to carry the fork's
// name in its Sieve extensions. If Cargo.lock moves sieve-rs past the
// vendored version, Cargo drops the patch with only a warning and
// upstream's spelling comes back; this fails instead.
#[test]
fn sieve_extensions_carry_the_fork_name() {
for (capability, name) in [
(Capability::While, "vnd.inbuxa.while"),
(Capability::Expressions, "vnd.inbuxa.expressions"),
] {
assert_eq!(capability.to_string(), name);
assert_eq!(Capability::parse(name), capability);
}
}
}
@@ -243,7 +243,8 @@ impl SpamFilterConfig {
spam_threshold: spam.score_spam.into_inner() as f32,
},
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)
.pool_max_idle_per_host(0)
.redirect(reqwest::redirect::Policy::none())
@@ -214,6 +214,7 @@ impl Resolvers {
let config_dnssec = resolver_config.clone();
let mut opts_dnssec = opts.clone();
opts_dnssec.validate = true;
opts_dnssec.num_concurrent_reqs = 1;
let dnssec = DnssecResolver {
resolver: TokioResolver::builder_with_config(
@@ -343,6 +344,7 @@ impl Default for Resolvers {
let config_dnssec = config.clone();
let mut opts_dnssec = opts.clone();
opts_dnssec.validate = true;
opts_dnssec.num_concurrent_reqs = 1;
Self {
dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"),
+7
View File
@@ -23,6 +23,13 @@ pub(crate) fn fn_is_number(v: Vec<Variable>) -> Variable {
matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into()
}
pub(crate) fn fn_bit_and(v: Vec<Variable>) -> Variable {
match (v[0].to_integer(), v[1].to_integer()) {
(Some(lhs), Some(rhs)) => Variable::Integer(lhs & rhs),
_ => Variable::Integer(0),
}
}
pub(crate) fn fn_is_ip_addr(v: Vec<Variable>) -> Variable {
v[0].to_string()
.as_str()
+1
View File
@@ -46,6 +46,7 @@ pub(crate) const FUNCTIONS: &[(&str, fn(Vec<Variable>) -> Variable, u32)] = &[
("email_part", email::fn_email_part, 2),
("is_empty", misc::fn_is_empty, 1),
("is_number", misc::fn_is_number, 1),
("bit_and", misc::fn_bit_and, 2),
("is_ip_addr", misc::fn_is_ip_addr, 1),
("is_ipv4_addr", misc::fn_is_ipv4_addr, 1),
("is_ipv6_addr", misc::fn_is_ipv6_addr, 1),
+233 -117
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{Server, manager::fetch_resource};
@@ -11,8 +13,11 @@ use registry::schema::{enums::CompressionAlgo, structs::Application};
use std::{
borrow::Cow,
io::{self, Cursor, Read},
path::PathBuf,
sync::Arc,
path::{Path, PathBuf},
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::Duration,
};
use store::{
@@ -36,16 +41,18 @@ enum IndexEdit<'x> {
pub struct WebApplications {
applications: ArcSwap<Vec<WebApplicationManager>>,
routes: ArcSwap<AHashMap<String, Arc<AppRoutes>>>,
generation: AtomicU64,
}
pub struct AppRoutes {
resources: AHashMap<String, Resource<PathBuf>>,
oauth_client_id_meta: Option<String>,
_bundle_dir: TempDir,
}
#[derive(Clone)]
pub struct WebApplicationManager {
bundle_path: TempDir,
base_path: PathBuf,
prefixes: Vec<String>,
description: String,
url: String,
@@ -79,6 +86,7 @@ impl WebApplications {
Self {
applications: ArcSwap::new(Arc::new(Vec::new())),
routes: ArcSwap::new(Arc::new(AHashMap::new())),
generation: AtomicU64::new(0),
}
}
@@ -128,48 +136,55 @@ impl WebApplications {
}
pub async fn unpack_all(&self, server: &Server, update: bool) {
let mut routes = AHashMap::new();
let previous = self.routes.load_full();
let sweep_orphans = previous.is_empty();
let mut routes = AHashMap::with_capacity(previous.len());
for app in self.applications.load().as_ref() {
if update && let Err(err) = app.delete(server).await {
trc::event!(
Resource(trc::ResourceEvent::Error),
Reason = err,
Url = app.url.clone(),
Details = format!(
"Failed to delete application bundle for prefixes: {}",
app.prefixes.join(", ")
)
);
}
match app.unpack(server).await {
Ok(resources) => {
let app_routes = Arc::new(AppRoutes {
resources,
oauth_client_id_meta: app
.oauth_client_id
.as_deref()
.map(oauth_client_id_meta),
});
match app
.unpack(server, self.next_generation(), update, sweep_orphans)
.await
{
Ok(app_routes) => {
let app_routes = Arc::new(app_routes);
for prefix in &app.prefixes {
routes.insert(prefix.clone(), app_routes.clone());
}
}
Err(err) => {
let mut is_retained = false;
for prefix in &app.prefixes {
if let Some(app_routes) = previous.get(prefix) {
routes.insert(prefix.clone(), app_routes.clone());
is_retained = true;
}
}
trc::event!(
Resource(trc::ResourceEvent::Error),
Reason = err,
Url = app.url.clone(),
Details = format!(
"Failed to unpack application for prefixes: {}",
app.prefixes.join(", ")
"Failed to unpack application for prefixes: {}, {}",
app.prefixes.join(", "),
if is_retained {
"the previously unpacked bundle remains in service"
} else {
"no bundle is available to serve"
}
)
);
}
}
}
self.routes.store(Arc::new(routes));
}
fn next_generation(&self) -> u64 {
self.generation.fetch_add(1, Ordering::Relaxed)
}
}
impl WebApplicationManager {
@@ -182,7 +197,7 @@ impl WebApplicationManager {
.join(app.id.id().to_string());
Self {
bundle_path: TempDir::new(base_path),
base_path,
blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()),
url: app.object.resource_url,
description: app.object.description,
@@ -202,82 +217,43 @@ impl WebApplicationManager {
}
}
async fn unpack(&self, server: &Server) -> trc::Result<AHashMap<String, Resource<PathBuf>>> {
// Delete any existing bundles
self.bundle_path.clean().await.map_err(unpack_error)?;
// Obtain application bundle
let bundle = if let Some(bundle) = server
.blob_store()
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
.await?
{
bundle
async fn unpack(
&self,
server: &Server,
generation: u64,
force_refresh: bool,
sweep_orphans: bool,
) -> trc::Result<AppRoutes> {
let cached = if force_refresh {
None
} else {
// Fetch app bundle
let resource = fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
.await
.map_err(|err| {
trc::ResourceEvent::Error
.caused_by(trc::location!())
.ctx(Key::Url, self.url.clone())
.reason(err)
.details("Failed to fetch application bundle")
})?;
// Store in blob store for future use
server
.blob_store()
.put_blob(self.blob_key.as_slice(), &resource, CompressionAlgo::None)
.await
.caused_by(trc::location!())?;
// Schedule expiration
let mut batch = BatchBuilder::new();
batch
.set(
BlobOp::Link {
hash: self.blob_key.clone(),
to: BlobLink::Temporary {
until: now() + self.expiry,
},
},
vec![],
)
.set(
BlobOp::Commit {
hash: self.blob_key.clone(),
},
Vec::new(),
);
server
.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
trc::event!(
Resource(trc::ResourceEvent::ApplicationUpdated),
Url = self.url.clone(),
Details = self.description.clone(),
);
resource
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
.await?
};
let is_cached = cached.is_some();
let bundle = match cached {
Some(bundle) => bundle,
None => self.fetch().await?,
};
let staging = TempDir::new(self.base_path.join(format!("{:x}-{generation:x}", now())));
staging.create().await.map_err(unpack_error)?;
let url = self.url.clone();
let bundle_path = self.bundle_path.path.clone();
let routes = tokio::task::spawn_blocking(move || -> trc::Result<_> {
let mut bundle = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
let bundle_path = staging.path.clone();
let (resources, bundle) = tokio::task::spawn_blocking(move || -> trc::Result<_> {
let mut archive = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
trc::ResourceEvent::Error
.caused_by(trc::location!())
.reason(err)
.ctx(Key::Url, url.clone())
.details("Failed to decompress application bundle")
})?;
let mut routes = AHashMap::new();
for i in 0..bundle.len() {
let mut file = bundle.by_index(i).map_err(|err| {
let mut resources = AHashMap::with_capacity(archive.len());
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|err| {
trc::ResourceEvent::Error
.caused_by(trc::location!())
.reason(err)
@@ -315,9 +291,9 @@ impl WebApplicationManager {
contents: path,
};
routes.insert(file_name, resource);
resources.insert(file_name, resource);
}
Ok(routes)
Ok((resources, archive.into_inner().into_inner()))
})
.await
.map_err(|err| {
@@ -327,21 +303,81 @@ impl WebApplicationManager {
.details("Bundle unpack task panicked")
})??;
if !is_cached && let Err(err) = self.cache(server, &bundle).await {
trc::event!(
Resource(trc::ResourceEvent::Error),
Reason = err,
Url = self.url.clone(),
Details = "Failed to cache application bundle, it will be downloaded again"
);
}
if sweep_orphans {
remove_siblings(&self.base_path, &staging.path).await;
}
trc::event!(
Resource(trc::ResourceEvent::ApplicationUnpacked),
Url = self.url.clone(),
Path = self.bundle_path.path.to_string_lossy().into_owned(),
Path = staging.path.to_string_lossy().into_owned(),
);
Ok(routes)
Ok(AppRoutes {
resources,
oauth_client_id_meta: self.oauth_client_id.as_deref().map(oauth_client_id_meta),
_bundle_dir: staging,
})
}
async fn delete(&self, server: &Server) -> trc::Result<()> {
async fn fetch(&self) -> trc::Result<Vec<u8>> {
fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
.await
.map_err(|err| {
trc::ResourceEvent::Error
.caused_by(trc::location!())
.ctx(Key::Url, self.url.clone())
.reason(err)
.details("Failed to fetch application bundle")
})
}
async fn cache(&self, server: &Server, bundle: &[u8]) -> trc::Result<()> {
server
.blob_store()
.delete_blob(self.blob_key.as_slice())
.put_blob(self.blob_key.as_slice(), bundle, CompressionAlgo::None)
.await
.map(|_| ())
.caused_by(trc::location!())?;
let mut batch = BatchBuilder::new();
batch
.set(
BlobOp::Link {
hash: self.blob_key.clone(),
to: BlobLink::Temporary {
until: now() + self.expiry,
},
},
vec![],
)
.set(
BlobOp::Commit {
hash: self.blob_key.clone(),
},
Vec::new(),
);
server
.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
trc::event!(
Resource(trc::ResourceEvent::ApplicationUpdated),
Url = self.url.clone(),
Details = self.description.clone(),
);
Ok(())
}
pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> {
@@ -361,7 +397,6 @@ impl Resource<Vec<u8>> {
}
}
#[derive(Clone)]
pub struct TempDir {
pub path: PathBuf,
}
@@ -371,11 +406,36 @@ impl TempDir {
TempDir { path }
}
pub async fn clean(&self) -> io::Result<()> {
pub async fn create(&self) -> io::Result<()> {
if tokio::fs::metadata(&self.path).await.is_ok() {
let _ = tokio::fs::remove_dir_all(&self.path).await;
}
tokio::fs::create_dir(&self.path).await
tokio::fs::create_dir_all(&self.path).await
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
async fn remove_siblings(base_path: &Path, keep: &Path) {
let Ok(mut entries) = tokio::fs::read_dir(base_path).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path == keep {
continue;
}
if matches!(entry.file_type().await, Ok(file_type) if file_type.is_dir()) {
let _ = tokio::fs::remove_dir_all(&path).await;
} else {
let _ = tokio::fs::remove_file(&path).await;
}
}
}
@@ -385,12 +445,6 @@ fn unpack_error(err: std::io::Error) -> trc::Error {
.details("Failed to unpack application bundle")
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
impl Default for WebApplications {
fn default() -> Self {
Self::new()
@@ -460,12 +514,12 @@ mod tests {
#[test]
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();
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
assert!(
html.contains("<meta name=\"oauth-client-id\" content=\"stalwart-webui\" />"),
html.contains("<meta name=\"oauth-client-id\" content=\"inbuxa-webui\" />"),
"{html}"
);
assert!(html.contains("<title>Portal</title>"), "{html}");
@@ -487,7 +541,7 @@ mod tests {
#[test]
fn index_without_a_placeholder_is_left_alone() {
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();
assert_eq!(html, "<head>\n <base href=\"/admin/\" />\n</head>");
@@ -521,9 +575,9 @@ mod tests {
);
}
async fn fixture(name: &str, client_id: Option<&str>) -> (WebApplications, TempDir) {
async fn fixture(name: &str, client_id: Option<&str>) -> WebApplications {
let dir = TempDir::new(std::env::temp_dir().join(format!("inbuxa-app-{name}")));
dir.clean().await.unwrap();
dir.create().await.unwrap();
tokio::fs::write(dir.path.join("index.html"), INDEX)
.await
.unwrap();
@@ -544,6 +598,7 @@ mod tests {
let routes = Arc::new(AppRoutes {
resources,
oauth_client_id_meta: client_id.map(oauth_client_id_meta),
_bundle_dir: dir,
});
let mut map = AHashMap::new();
@@ -553,7 +608,7 @@ mod tests {
let apps = WebApplications::new();
apps.routes.store(Arc::new(map));
(apps, dir)
apps
}
async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String {
@@ -565,7 +620,7 @@ mod tests {
#[tokio::test]
async fn serving_index_injects_the_prefix_and_client_id() {
let (apps, _dir) = fixture("serve-configured", Some("pocket-id-client")).await;
let apps = fixture("serve-configured", Some("pocket-id-client")).await;
let html = serve_html(&apps, "admin", "index.html").await;
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
@@ -584,7 +639,7 @@ mod tests {
#[tokio::test]
async fn unknown_paths_fall_back_to_a_rewritten_index() {
let (apps, _dir) = fixture("serve-fallback", Some("pocket-id-client")).await;
let apps = fixture("serve-fallback", Some("pocket-id-client")).await;
let html = serve_html(&apps, "admin", "settings/directory").await;
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
@@ -596,7 +651,7 @@ mod tests {
#[tokio::test]
async fn assets_and_unknown_prefixes_are_untouched() {
let (apps, _dir) = fixture("serve-assets", Some("pocket-id-client")).await;
let apps = fixture("serve-assets", Some("pocket-id-client")).await;
let served = apps.serve("admin", "app.js").await.unwrap().unwrap();
assert_eq!(served.resource.contents, b"export const x = 1;\n");
@@ -608,7 +663,7 @@ mod tests {
#[tokio::test]
async fn serving_index_without_a_client_id_keeps_the_placeholder() {
let (apps, _dir) = fixture("serve-unconfigured", None).await;
let apps = fixture("serve-unconfigured", None).await;
let html = serve_html(&apps, "admin", "index.html").await;
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
@@ -624,4 +679,65 @@ mod tests {
assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes());
}
#[tokio::test]
async fn missing_parent_directories_are_created() {
let base = std::env::temp_dir().join("inbuxa-app-nested");
let _ = tokio::fs::remove_dir_all(&base).await;
let dir = TempDir::new(base.join("webui").join("0"));
dir.create().await.unwrap();
assert!(tokio::fs::metadata(&dir.path).await.is_ok());
drop(dir);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn dropping_the_routes_removes_the_bundle_directory() {
let apps = fixture("drop-guard", None).await;
let path = apps
.routes
.load()
.get("admin")
.unwrap()
._bundle_dir
.path
.clone();
assert!(tokio::fs::metadata(&path).await.is_ok());
apps.routes.store(Arc::new(AHashMap::new()));
assert!(tokio::fs::metadata(&path).await.is_err());
}
#[tokio::test]
async fn sweeping_orphans_spares_the_current_generation() {
let base = std::env::temp_dir().join("inbuxa-app-sweep");
let _ = tokio::fs::remove_dir_all(&base).await;
let current = TempDir::new(base.join("1"));
current.create().await.unwrap();
let orphan = base.join("0");
tokio::fs::create_dir_all(&orphan).await.unwrap();
let stray = base.join("webui.zip");
tokio::fs::write(&stray, b"not a bundle").await.unwrap();
remove_siblings(&base, &current.path).await;
assert!(tokio::fs::metadata(&current.path).await.is_ok());
assert!(tokio::fs::metadata(&orphan).await.is_err());
assert!(tokio::fs::metadata(&stray).await.is_err());
drop(current);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[test]
fn generations_never_repeat() {
let apps = WebApplications::new();
assert_ne!(apps.next_generation(), apps.next_generation());
}
}
+14 -5
View File
@@ -530,13 +530,22 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
use store::write::BatchBuilder;
use types::id::Id;
if bp.registry.count_object(ObjectType::SpamRule).await? == 0
&& bp
.registry
// inbuxa: rules are always to hand, since a copy ships with the server
// (spam_rules). They load on first boot, and again when the bundled
// 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())
.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();
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules,
+68 -10
View File
@@ -10,7 +10,7 @@
//! 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, `stalwart-webui` unless the
//! `/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;
@@ -29,7 +29,7 @@ use directory::core::secret::{hash_secret, verify_secret_hash};
use registry::{
schema::{
enums::{PasswordHashAlgorithm, ServiceProtocol},
prelude::{ObjectType, Property, UTCDateTime},
prelude::{Object, ObjectInner, ObjectType, Property, UTCDateTime},
structs::{Application, OAuthClient, SystemSettings},
},
types::map::Map,
@@ -40,9 +40,12 @@ use store::registry::{
};
/// The client id the upstream web interface uses when its application names none.
pub const WEB_INTERFACE_CLIENT_ID: &str = "stalwart-webui";
pub const WEB_INTERFACE_CLIENT_ID: &str = "inbuxa-webui";
pub const ADMIN_CLIENT_ID: &str = "inbuxa-admin";
pub const 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 {
@@ -102,7 +105,7 @@ pub fn first_party_clients(
if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) {
clients.push(FirstPartyClient {
client_id: ADMIN_CLIENT_ID.to_string(),
description: "INBUXA Admin".to_string(),
description: "inbuxa Admin".to_string(),
redirect_uris: vec![format!("{url}/oauth/callback")],
secret: None,
});
@@ -187,6 +190,7 @@ fn env(name: &str) -> Option<String> {
}
pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> {
retire_legacy_web_interface_client(bp).await?;
let system = bp.setting_infallible::<SystemSettings>().await;
let base_url = base_url(bp, &system);
let applications = bp
@@ -213,6 +217,56 @@ pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Resul
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
@@ -223,15 +277,18 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
)
.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
.object::<OAuthClient>(object_id.id())
.get(object_id)
.await?
.map(|object| (object_id.id(), object)),
.map(|object| (object_id.id(), object.revision, OAuthClient::from(object))),
None => None,
};
let result = if let Some((id, current)) = existing {
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) {
@@ -255,8 +312,9 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
if updated == current {
return Ok(());
}
let current = Object::with_revision(ObjectInner::from(current), revision);
bp.registry
.write(RegistryWrite::update(id, &updated.into(), &current.into()))
.write(RegistryWrite::update(id, &updated.into(), &current))
.await?
} else {
let secret = match &client.secret {
@@ -298,7 +356,7 @@ mod tests {
fn web_interface() -> Application {
Application {
description: "INBUXA Web Interface".to_string(),
description: "inbuxa Web Interface".to_string(),
enabled: true,
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
..Default::default()
@@ -312,7 +370,7 @@ mod tests {
clients,
vec![FirstPartyClient {
client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
description: "INBUXA Web Interface (served by this server)".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(),
+3 -2
View File
@@ -22,9 +22,10 @@ pub mod console;
pub mod defaults;
pub mod first_party;
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_CLASSIFIER_KEY: &[u8] = "STALWART_SPAM_CLASSIFIER_MODEL.lz4".as_bytes();
pub const SPAM_TRAINER_KEY: &[u8] = "INBUXA_SPAM_TRAIN_DATA.lz4".as_bytes();
pub const SPAM_CLASSIFIER_KEY: &[u8] = "INBUXA_SPAM_CLASSIFIER_MODEL.lz4".as_bytes();
pub async fn fetch_resource(
url: &str,
+103
View File
@@ -0,0 +1,103 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! inbuxa: the spam filter rules that ship with the server.
//!
//! Upstream fetches its latest published rules from GitHub at run time, so
//! scoring changes with a release nobody here tested and depends on reaching
//! it. The fork embeds a pinned copy (resources/spam-filter/, with its version
//! and license) and uses it whenever no other source is configured. The rules
//! URL remains an operator override (`https://` or `file://`).
//!
//! Loading rules only ever adds what's missing, never changes an existing rule
//! or score. They load on first boot, and again whenever the bundled version
//! differs from the one last applied, so an upgrade brings new tags (the AI
//! classifier's `LLM_*` scores, say) to an install that already had rules.
use std::io::Read;
use store::{
SUBSPACE_INBUXA, Store, ValueKey,
write::{AnyClass, BatchBuilder, ValueClass},
};
use trc::AddContext;
/// The version of spam-filter the embedded rules come from.
pub const BUNDLED_SPAM_RULES_VERSION: &str = "3.0.2";
static BUNDLED_SPAM_RULES: &[u8] =
include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz");
/// Upstream's default rules source, the value every install created before
/// the rules were bundled has saved. Read only to treat it as unset.
const LEGACY_DEFAULT_URL: &str =
"https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
/// The URL to fetch rules from, or `None` for the bundled rules. An empty
/// setting and upstream's old default both mean the bundled rules.
pub fn rules_url(configured: Option<String>) -> Option<String> {
configured.filter(|url| !url.trim().is_empty() && url != LEGACY_DEFAULT_URL)
}
/// The bundled rules, uncompressed: the same JSON the rules URL serves.
pub fn bundled_rules() -> Result<Vec<u8>, String> {
let mut json = Vec::new();
mail_auth::flate2::read::GzDecoder::new(BUNDLED_SPAM_RULES)
.read_to_end(&mut json)
.map_err(|err| format!("Failed to decompress the bundled spam rules: {err}"))?;
Ok(json)
}
fn applied_key() -> ValueClass {
ValueClass::Any(AnyClass {
subspace: SUBSPACE_INBUXA,
key: b"Sr".to_vec(),
})
}
/// The bundled version last loaded into the registry, if any.
pub async fn applied_version(data: &Store) -> trc::Result<Option<String>> {
data.get_value::<String>(ValueKey::from(applied_key()))
.await
.caused_by(trc::location!())
}
/// Records that the bundled rules of this version have been loaded.
pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()> {
let mut batch = BatchBuilder::new();
batch.set(applied_key(), version.as_bytes().to_vec());
data.write(batch.build_all())
.await
.caused_by(trc::location!())
.map(|_| ())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upstream_default_and_empty_mean_bundled() {
assert_eq!(rules_url(None), None);
assert_eq!(rules_url(Some(String::new())), None);
assert_eq!(rules_url(Some(" ".into())), None);
assert_eq!(rules_url(Some(LEGACY_DEFAULT_URL.into())), None);
assert_eq!(
rules_url(Some("file:///srv/rules.json.gz".into())).as_deref(),
Some("file:///srv/rules.json.gz")
);
}
#[test]
fn bundled_rules_parse_and_score_the_ai_tags() {
let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap();
let tags = rules["SpamTag"].as_array().unwrap();
for (tag, score) in [("LLM_UNSOLICITED_HIGH", 3.0), ("LLM_LEGITIMATE_HIGH", -3.0)] {
let found = tags.iter().find(|t| t["tag"] == tag).unwrap();
assert_eq!(found["score"].as_f64(), Some(score), "{tag}");
}
assert!(!rules["SpamRule"].as_array().unwrap().is_empty());
}
}
+55 -12
View File
@@ -98,7 +98,38 @@ impl AcmeRequestBuilder {
reuse_key_pem: Option<String>,
dns_parameters: Option<AcmeDnsParameters>,
) -> AcmeResult<PemCert> {
let mut params = CertificateParams::new(domains.clone()).map_err(|err| {
let mut published = BTreeSet::new();
let result = self
.run_order(
server,
&domains,
reuse_key_pem,
dns_parameters.as_ref(),
&mut published,
)
.await;
if let Some(dns_parameters) = &dns_parameters {
for (zone, challenge_name) in published {
let _ = dns_parameters
.updater
.delete_rrset(&zone, &challenge_name, dns_update::DnsRecordType::TXT)
.await;
}
}
result
}
async fn run_order(
&self,
server: &Server,
domains: &[String],
reuse_key_pem: Option<String>,
dns_parameters: Option<&AcmeDnsParameters>,
published: &mut BTreeSet<(String, String)>,
) -> AcmeResult<PemCert> {
let mut params = CertificateParams::new(domains.to_vec()).map_err(|err| {
AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
})?;
params.distinguished_name = DistinguishedName::new();
@@ -110,7 +141,7 @@ impl AcmeRequestBuilder {
AcmeError::Crypto(format!("Failed to generate key pair: {}", err))
})?,
};
let response = self.new_order(domains.clone()).await?;
let response = self.new_order(domains.to_vec()).await?;
let order_url = response.location;
let mut order = response.body;
let mut retry_after = None;
@@ -119,7 +150,7 @@ impl AcmeRequestBuilder {
Acme(AcmeEvent::OrderStart),
Url = self.directory.new_order.to_string(),
Details = order_url.to_string(),
Hostname = domains.as_slice(),
Hostname = domains,
Type = self.challenge.as_str(),
);
@@ -128,19 +159,20 @@ impl AcmeRequestBuilder {
OrderStatus::Pending => {
if matches!(self.challenge, ChallengeType::Dns01) {
for url in &order.authorizations {
self.authorize(server, url, dns_parameters.as_ref()).await?;
self.authorize(server, url, dns_parameters, Some(published))
.await?;
}
} else {
let auth_futures = order
.authorizations
.iter()
.map(|url| self.authorize(server, url, dns_parameters.as_ref()));
.map(|url| self.authorize(server, url, dns_parameters, None));
try_join_all(auth_futures).await?;
}
trc::event!(
Acme(AcmeEvent::AuthCompleted),
Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(),
Hostname = domains,
);
let response = self.order(&order_url).await?;
order = response.body;
@@ -151,7 +183,7 @@ impl AcmeRequestBuilder {
trc::event!(
Acme(AcmeEvent::OrderProcessing),
Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(),
Hostname = domains,
Total = i,
);
@@ -179,7 +211,7 @@ impl AcmeRequestBuilder {
trc::event!(
Acme(AcmeEvent::OrderReady),
Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(),
Hostname = domains,
);
let csr = params.serialize_request(&key_pair).map_err(|err| {
@@ -192,10 +224,10 @@ impl AcmeRequestBuilder {
trc::event!(
Acme(AcmeEvent::OrderValid),
Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(),
Hostname = domains,
);
let certificate = self.select_certificate(&domains, certificate).await?;
let certificate = self.select_certificate(domains, certificate).await?;
return Ok(PemCert {
certificate,
@@ -213,7 +245,7 @@ impl AcmeRequestBuilder {
Acme(AcmeEvent::OrderInvalid),
Url = self.directory.new_order.to_string(),
Details = order_url.to_string(),
Hostname = domains.as_slice(),
Hostname = domains,
Reason = reason.clone(),
);
@@ -228,6 +260,7 @@ impl AcmeRequestBuilder {
server: &Server,
url: &String,
dns_parameters: Option<&AcmeDnsParameters>,
published: Option<&mut BTreeSet<(String, String)>>,
) -> AcmeResult<()> {
let response = self
.auth(url)
@@ -289,7 +322,12 @@ impl AcmeRequestBuilder {
.await?;
}
ChallengeType::Dns01 => {
let dns_parameters = dns_parameters.unwrap();
let Some(dns_parameters) = dns_parameters else {
return Err(AcmeError::Invalid(
"DNS-01 challenge requested but a DNS provider was not configured"
.to_string(),
));
};
let domain = domain.strip_prefix("*.").unwrap_or(&domain);
let zone = dns_parameters
@@ -310,6 +348,11 @@ impl AcmeRequestBuilder {
)
.await
.map_err(AcmeError::Dns)?;
if let Some(published) = published {
published.insert((zone.to_string(), challenge_name.clone()));
}
dns_parameters
.updater
.wait_for_txt_propagation(&challenge_name, zone, &proof)
+30
View File
@@ -1150,6 +1150,36 @@ impl DnsUpdater {
Ok(())
}
pub async fn delete_rrset(
&self,
origin: &str,
name: &str,
record_type: DnsRecordType,
) -> Result<(), String> {
if let Err(err) = self
.updater
.set_rrset(
name,
record_type,
self.ttl.as_secs() as u32,
Vec::new(),
origin,
)
.await
{
trc::event!(
Dns(DnsEvent::RecordDeletionFailed),
Hostname = name.to_string(),
Details = origin.to_string(),
Type = record_type.as_str(),
Reason = err.to_string(),
);
return Err(format!("Failed to delete DNS RRSet: {}", err));
}
Ok(())
}
pub async fn add_to_rrset(
&self,
origin: &str,
+15 -15
View File
@@ -299,28 +299,28 @@ impl LegacyProtocol {
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."
"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."
"[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."
"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"
"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."
"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."
"[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."
"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"
"535 5.7.0 Your organization allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
}
}
}
@@ -541,7 +541,7 @@ mod tests {
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."
"This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
);
assert!(
LegacyProtocol::Pop3
@@ -550,11 +550,11 @@ mod tests {
);
assert_eq!(
LegacyProtocol::ManageSieve.refusal(server),
"This server allows only INBUXA webmail and JMAP apps."
"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"
"535 5.7.0 This server allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
);
}
@@ -564,19 +564,19 @@ mod tests {
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."
"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."
"[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."
"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"
"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"));
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use super::{
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use self::limiter::{ConcurrencyLimiter, InFlight};
+56 -1
View File
@@ -23,6 +23,7 @@ use crate::{
manager::SPAM_CLASSIFIER_KEY,
network::RcptResolution,
};
use ahash::AHashSet;
use directory::Recipient;
use mail_auth::IpLookupStrategy;
use registry::schema::enums::ExpressionVariable;
@@ -37,6 +38,7 @@ use store::{
write::{AlignedBytes, Archive, QueueClass, ValueClass},
};
use trc::{AddContext, SpamEvent};
use utils::DomainPart;
impl Server {
pub async fn rcpt_resolve(
@@ -163,7 +165,10 @@ impl Server {
}
EmailCache::MailingList(id) => {
if let Some(list) = self.try_list(id).await? {
return Ok(RcptResolution::Expand(list.recipients.clone()));
return Ok(RcptResolution::Expand(
self.expand_nested_lists(id, list.recipients.clone())
.await?,
));
} else {
self.inner
.cache
@@ -195,6 +200,56 @@ impl Server {
}
}
async fn expand_nested_lists(
&self,
list_id: u32,
recipients: Arc<[Box<str>]>,
) -> trc::Result<Arc<[Box<str>]>> {
let mut has_nested = false;
for member in recipients.iter() {
if let Some(EmailCache::MailingList(_)) = self.rcpt_id_from_email(member).await? {
has_nested = true;
break;
}
}
if !has_nested {
return Ok(recipients);
}
let mut expanded = Vec::with_capacity(recipients.len());
let mut seen: AHashSet<Box<str>> = AHashSet::with_capacity(recipients.len());
let mut visited = AHashSet::from_iter([list_id]);
let mut pending: Vec<Arc<[Box<str>]>> = Vec::new();
let mut members = recipients;
loop {
for member in members.iter() {
if let Some(EmailCache::MailingList(nested_id)) =
self.rcpt_id_from_email(member).await?
{
if !visited.insert(nested_id) {
continue;
}
if let Some(nested) = self.try_list(nested_id).await? {
pending.push(nested.recipients.clone());
continue;
}
}
if seen.insert(member.to_canonical_address().into()) {
expanded.push(member.clone());
}
}
let Some(next) = pending.pop() else {
break;
};
members = next;
}
Ok(expanded.into())
}
pub async fn get_dkim_signers(
&self,
domain: &str,
+10 -8
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* 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};
@@ -313,16 +315,16 @@ B4yDfR2rGOd2H6Kv3fQNHPj9Nu5Tks8QYMLzrX8ONCNoFnNUQl9S0r0QS6phVqD0
#[test]
fn contact_is_normalized_to_a_uri() {
for (input, expected) in [
("hello@stalw.art", Some("mailto:hello@stalw.art")),
(" hello@stalw.art ", Some("mailto:hello@stalw.art")),
("mailto:hello@stalw.art", Some("mailto:hello@stalw.art")),
("MAILTO:hello@stalw.art", Some("MAILTO:hello@stalw.art")),
("hello@example.org", Some("mailto:hello@example.org")),
(" hello@example.org ", Some("mailto:hello@example.org")),
("mailto:hello@example.org", Some("mailto:hello@example.org")),
("MAILTO:hello@example.org", Some("MAILTO:hello@example.org")),
(
"https://stalw.art/contact",
Some("https://stalw.art/contact"),
"https://example.org/contact",
Some("https://example.org/contact"),
),
("stalw.art", None),
("http://stalw.art", None),
("example.org", None),
("http://example.org", None),
("tel:+123456789", None),
("", None),
] {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "coordinator"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dav-proto"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dav"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use super::ETag;
@@ -490,7 +492,7 @@ impl LockRequestHandler for Server {
for cond in &if_.list {
match cond {
Condition::StateToken { token, .. } => {
if token.starts_with("urn:stalwart:davsync:") {
if token.starts_with("urn:inbuxa:davsync:") {
needs_sync_token = true;
} else {
needs_lock_token = true;
+7 -5
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::{DavError, DavResourceName};
@@ -181,12 +183,12 @@ impl OwnedUri<'_> {
impl Urn {
pub fn try_extract_sync_id(token: &str) -> Option<&str> {
token
.strip_prefix("urn:stalwart:davsync:")
.strip_prefix("urn:inbuxa:davsync:")
.map(|x| x.split_once(':').map(|(x, _)| x).unwrap_or(x))
}
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(':')?;
match kind {
"davlock" => u64::from_str_radix(id, 16).ok().map(Urn::Lock),
@@ -223,12 +225,12 @@ impl Urn {
impl Display for Urn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
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 } => {
if *seq == 0 {
write!(f, "urn:stalwart:davsync:{id:x}")
write!(f, "urn:inbuxa:davsync:{id:x}")
} else {
write!(f, "urn:stalwart:davsync:{id:x}:{seq:x}")
write!(f, "urn:inbuxa:davsync:{id:x}:{seq:x}")
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "directory"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -40,7 +40,7 @@ impl OpenIdDirectory {
pub async fn new(config: OidcConfig) -> Result<Self, OidcError> {
let http = utils::http::http_client_builder(false)
.user_agent("INBUXA/1.0") // types::brand!(); this crate does not depend on types
.user_agent("inbuxa/1.0") // types::brand!(); this crate does not depend on types
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?;
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "email"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+8
View File
@@ -22,6 +22,8 @@ use std::{borrow::Cow, future::Future};
use store::ahash::AHashMap;
use types::blob_hash::BlobHash;
pub const ORCPT_ADDR_TYPE: &str = "rfc822;";
#[derive(Debug)]
pub struct IngestMessage {
pub sender_address: String,
@@ -40,6 +42,12 @@ pub struct IngestRecipient {
}
impl IngestRecipient {
pub fn orcpt_parameter(&self) -> Option<String> {
self.orcpt
.as_deref()
.map(|orcpt| format!("{ORCPT_ADDR_TYPE}{orcpt}"))
}
pub fn is_spam(&self) -> bool {
self.spam_percentage
.is_some_and(|percentage| percentage >= 50)
+2 -1
View File
@@ -126,6 +126,7 @@ impl SieveScriptIngest for Server {
.caused_by(trc::location!())?;
// Create Sieve instance
let orcpt = envelope_to.orcpt_parameter();
let mut instance = self.core.sieve.untrusted_runtime.filter_parsed(message);
// Set account name and email
@@ -141,7 +142,7 @@ impl SieveScriptIngest for Server {
// Set envelope
instance.set_envelope(Envelope::From, envelope_from);
instance.set_envelope(Envelope::To, envelope_to.address.as_str());
if let Some(orcpt) = &envelope_to.orcpt {
if let Some(orcpt) = &orcpt {
instance.set_envelope(Envelope::Orcpt, orcpt.as_str());
}
instance.set_spam_status(spam_status(envelope_to.spam_percentage));
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "inbuxa-features"
description = "INBUXA's rebuilt features: behavior Stalwart ships only in its Enterprise Edition, rebuilt clean-room"
description = "inbuxa's rebuilt features: behavior Stalwart ships only in its Enterprise Edition, rebuilt clean-room"
license = "AGPL-3.0-only"
version = "0.16.22"
edition = "2024"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "groupware"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "http_proto"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use common::manager::application::Resource;
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "http"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+6 -10
View File
@@ -230,14 +230,7 @@ async fn delivery_diagnose(
// Lookup MX
let now = Instant::now();
let mxs = match server
.core
.smtp
.resolvers
.dns
.mx_lookup(&domain, Some(&server.inner.cache.dns_mx))
.await
{
let mxs = match server.mx_lookup(domain.as_str()).await {
Ok(mxs) => mxs,
Err(err) => {
tx.send(DeliveryStage::MxLookupError {
@@ -419,7 +412,7 @@ async fn delivery_diagnose(
})
.await?;
None
continue 'outer;
}
Ok(TlsaResult::Missing) => {
tx.send(DeliveryStage::TlsaNotFound {
@@ -440,14 +433,17 @@ async fn delivery_diagnose(
reason: "No TLSA records found for MX".to_string(),
})
.await?;
None
} else {
tx.send(DeliveryStage::TlsaLookupError {
elapsed: now.elapsed_ms(),
reason: err.to_string(),
})
.await?;
continue 'outer;
}
None
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "imap_proto"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "imap"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+32
View File
@@ -390,6 +390,16 @@ impl<T: SessionStream> SessionData<T> {
.await
.imap_ctx(&arguments.tag, trc::location!())?;
let mut dest_cache = None;
let train_spam = if dest_mailbox_id == JUNK_ID {
Some(true)
} else if src_mailbox.id.mailbox_id == JUNK_ID && dest_mailbox_id != TRASH_ID {
Some(false)
} else {
None
};
let mut train_batch = BatchBuilder::new();
let mut did_train = false;
train_batch.with_account_id(src_account_id);
for (id, imap_id) in ids {
match self
.server
@@ -515,11 +525,33 @@ impl<T: SessionStream> SessionData<T> {
}
};
if let Some(is_spam) = train_spam {
self.server
.add_account_spam_sample(
&mut train_batch,
src_account_id,
id,
is_spam,
self.session_id,
)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
train_batch.commit_point();
did_train = true;
}
if is_move {
destroy_ids.insert(id);
}
}
if did_train {
self.server
.commit_batch(train_batch)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
}
// Untag or delete emails
if !destroy_ids.is_empty() {
let mut batch = BatchBuilder::new();
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jmap_proto"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+3 -3
View File
@@ -91,7 +91,7 @@ pub enum Capability {
FileNode = 1 << 15,
#[serde(rename(serialize = "urn:ietf:params:jmap:mail:share"))]
MailShare = 1 << 16,
#[serde(rename(serialize = "urn:stalwart:jmap"))]
#[serde(rename(serialize = "urn:inbuxa:jmap:registry"))]
Stalwart = 1 << 17,
#[serde(rename(serialize = "urn:ietf:params:jmap:webpush-vapid"))]
WebPushVapid = 1 << 18,
@@ -353,7 +353,7 @@ impl Capability {
Capability::PrincipalsAvailability => "urn:ietf:params:jmap:principals:availability",
Capability::FileNode => "urn:ietf:params:jmap:filenode",
Capability::MailShare => "urn:ietf:params:jmap:mail:share",
Capability::Stalwart => "urn:stalwart:jmap",
Capability::Stalwart => "urn:inbuxa:jmap:registry",
Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid",
Capability::EmailPush => "urn:ietf:params:jmap:emailpush",
Capability::Inbuxa => "urn:inbuxa:jmap",
@@ -501,7 +501,7 @@ impl Capability {
"urn:ietf:params:jmap:contacts:parse" => Capability::ContactsParse,
"urn:ietf:params:jmap:calendars:parse" => Capability::CalendarsParse,
"urn:ietf:params:jmap:mail:share" => Capability::MailShare,
"urn:stalwart:jmap" => Capability::Stalwart,
"urn:inbuxa:jmap:registry" => Capability::Stalwart,
"urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid,
"urn:ietf:params:jmap:emailpush" => Capability::EmailPush,
"urn:inbuxa:jmap" => Capability::Inbuxa,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jmap"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+30 -1
View File
@@ -11,7 +11,11 @@ use crate::{
use common::{Server, auth::AccessToken};
use email::{
cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess},
message::copy::{CopyMessageError, EmailCopy},
mailbox::JUNK_ID,
message::{
copy::{CopyMessageError, EmailCopy},
ingest::EmailIngest,
},
};
use http_proto::HttpSessionData;
use jmap_proto::{
@@ -29,6 +33,7 @@ use jmap_proto::{
};
use jmap_tools::{Key, Value};
use std::future::Future;
use store::write::BatchBuilder;
use trc::AddContext;
use types::acl::Acl;
use utils::map::vec_map::VecMap;
@@ -87,6 +92,9 @@ impl JmapEmailCopy for Server {
};
let on_success_delete = request.on_success_destroy_original.unwrap_or(false);
let mut destroy_ids = Vec::new();
let mut train_batch = BatchBuilder::new();
let mut did_train = false;
train_batch.with_account_id(from_account_id);
'create: for (id, create) in request.create.into_valid() {
let mut from_message_id = None;
@@ -208,6 +216,7 @@ impl JmapEmailCopy for Server {
}
// Add response
let train_spam = mailboxes.contains(&JUNK_ID);
match self
.copy_message(
from_account_id,
@@ -221,6 +230,20 @@ impl JmapEmailCopy for Server {
.await?
{
Ok(email) => {
if train_spam {
self.add_account_spam_sample(
&mut train_batch,
from_account_id,
from_message_id.document_id(),
true,
session.session_id,
)
.await
.caused_by(trc::location!())?;
train_batch.commit_point();
did_train = true;
}
response
.created
.append(id, ingested_into_object(email).into());
@@ -245,6 +268,12 @@ impl JmapEmailCopy for Server {
}
}
if did_train {
self.commit_batch(train_batch)
.await
.caused_by(trc::location!())?;
}
// Update state
if !response.created.is_empty() {
response.new_state = self.get_cached_messages(account_id).await?.get_state(false);
@@ -208,7 +208,7 @@ pub(crate) async fn bootstrap_set(
.with_description(concat!(
"The selected data store contains information from an older version. ",
"Please follow the upgrade instructions at ",
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
"https://docs.inbuxa.org/install/migrating/"
)),
);
break;
@@ -657,7 +657,7 @@ fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option<registry::schema::s
// FreeBSD keeps variable application data under /var/db (hier(7))
// rather than FHS /var/lib.
const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") {
"/var/db/stalwart/"
"/var/db/inbuxa/"
} else {
"/var/lib/inbuxa/"
};
@@ -679,7 +679,7 @@ fn build_default_bootstrap(server: &Server) -> Bootstrap {
directory: DirectoryBootstrap::Internal,
tracer: Tracer::Log(TracerLog {
path: "/var/log/inbuxa/".to_string(),
prefix: "stalwart".to_string(),
prefix: "inbuxa".to_string(),
ansi: true,
enable: true,
..Default::default()
+3 -3
View File
@@ -1,13 +1,13 @@
[package]
name = "inbuxa"
description = "INBUXA Mail and Collaboration Server, a fork of Stalwart"
description = "inbuxa mail and collaboration server, a fork of Stalwart"
authors = [ "Stalwart Labs LLC <[email protected]>"]
homepage = "https://inbuxa.org"
keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
categories = ["email"]
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; INBUXA takes the AGPL only.
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only.
license = "AGPL-3.0-only"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[[bin]]
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
#![warn(clippy::large_futures)]
+2 -2
View File
@@ -57,7 +57,7 @@ pub async fn insert_test_data(server: &Server) {
server.inner.data.queue_id_gen.generate(),
QueueName::default(),
);
assert!(qm.save_changes(server, None).await);
assert!(qm.save_changes(server, None, None).await);
}
for report in sample_tls_internal_reports() {
@@ -163,7 +163,7 @@ fn sample_queued_messages(blob_hashes: Vec<BlobHash>) -> Vec<Message> {
},
}),
flags: RCPT_DSN_SENT,
orcpt: Some("rfc822;[email protected]".into()),
orcpt: Some("[email protected]".into()),
},
],
received_from_ip: std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "managesieve"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "migration"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+53 -3
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
#![warn(clippy::large_futures)]
@@ -19,6 +21,10 @@ pub mod destroy;
pub mod v016;
pub async fn try_migrate(server: &Server) -> trc::Result<()> {
// inbuxa: before the version check, which returns early on a current
// store, and before migrate_v0_16, which reads the renamed key.
rename_spam_blobs(server).await?;
match server
.store()
.get_value::<u32>(AnyKey {
@@ -36,14 +42,14 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> {
Some(0..=4) => {
abort(concat!(
"You must first upgrade to version 0.15, please read ",
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
"https://docs.inbuxa.org/install/migrating/"
));
}
Some(5) => {
if !server.registry().is_recovery_mode() {
abort(concat!(
"Upgrading to version 0.16 is a multi-step process, please read ",
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
"https://docs.inbuxa.org/install/migrating/"
));
}
}
@@ -61,7 +67,7 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> {
} else {
abort(concat!(
"You must first upgrade to version 0.15, please read ",
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
"https://docs.inbuxa.org/install/migrating/"
));
}
}
@@ -134,3 +140,47 @@ async fn is_new_install(server: &Server) -> trc::Result<bool> {
Ok(true)
}
/// inbuxa: the spam filter's trainer and model blobs, under the names they
/// had before the fork renamed them (SPEC §2.4), paired with the current ones.
const RENAMED_SPAM_BLOBS: [(&[u8], &[u8]); 2] = [
(b"STALWART_SPAM_TRAIN_DATA.lz4", common::manager::SPAM_TRAINER_KEY),
(
b"STALWART_SPAM_CLASSIFIER_MODEL.lz4",
common::manager::SPAM_CLASSIFIER_KEY,
),
];
/// Moves each spam blob from its pre-rename key to the current one, so a
/// trained model survives the rename. A blob already under the current key
/// wins and the old one is just removed; with neither, nothing happens.
async fn rename_spam_blobs(server: &Server) -> trc::Result<()> {
let blobs = server.blob_store();
for (old, new) in RENAMED_SPAM_BLOBS {
let Some(data) = blobs
.get_blob(old, 0..usize::MAX)
.await
.caused_by(trc::location!())?
else {
continue;
};
if blobs
.get_blob(new, 0..usize::MAX)
.await
.caused_by(trc::location!())?
.is_none()
{
blobs
.put_blob(new, &data, server.core.email.compression)
.await
.caused_by(trc::location!())?;
}
blobs.delete_blob(old).await.caused_by(trc::location!())?;
trc::event!(
Server(trc::ServerEvent::Startup),
Details = "Moved a spam filter blob to its renamed key",
Key = new,
);
}
Ok(())
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nlp"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "pop3"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+2 -8
View File
@@ -64,14 +64,8 @@ impl<T: SessionStream> Session<T> {
)
.get_full_range();
self.write_bytes(
Response::Message::<u32> {
bytes,
lines: lines.unwrap_or(0),
}
.serialize(),
)
.await
self.write_bytes(Response::Message::<u32> { bytes, lines }.serialize())
.await
} else {
Err(trc::Pop3Event::Error
.into_err()
+86 -19
View File
@@ -16,7 +16,7 @@ pub enum Response<'x, T> {
List(Vec<T>),
Message {
bytes: SliceRange<'x>,
lines: u32,
lines: Option<u32>,
},
Capability {
mechanisms: Vec<Mechanism>,
@@ -54,40 +54,65 @@ impl<'x, T: Display> Response<'x, T> {
buf
}
Response::Message { bytes, lines } => {
let mut buf = Vec::with_capacity(bytes.len() + 10);
buf.extend_from_slice(b"+OK ");
buf.extend_from_slice(bytes.len().to_string().as_bytes());
buf.extend_from_slice(b" octets\r\n");
let mut line_count = 0;
let mut last_byte = 0;
let lines = *lines;
let mut message = Vec::with_capacity(bytes.len() + 16);
let mut octets = 0;
let mut last_byte = b'\n';
let mut in_headers = lines.is_some();
let mut is_blank_line = true;
let mut body_lines = 0;
// Transparency procedure
for &byte in bytes.into_iter() {
// POP3 requires that lines end with CRLF, do this check to ensure that
if byte == b'\n' && last_byte != b'\r' {
buf.push(b'\r');
message.push(b'\r');
octets += 1;
}
if byte == b'.' && last_byte == b'\n' {
buf.push(b'.');
message.push(b'.');
}
buf.push(byte);
message.push(byte);
octets += 1;
last_byte = byte;
if *lines > 0 && byte == b'\n' {
line_count += 1;
if line_count == *lines {
break;
match byte {
b'\n' => {
if in_headers {
in_headers = !is_blank_line;
} else {
body_lines += 1;
}
if !in_headers && lines.is_some_and(|lines| body_lines >= lines) {
break;
}
is_blank_line = true;
}
b'\r' => {}
_ => {
is_blank_line = false;
}
}
}
if last_byte != b'\n' {
buf.extend_from_slice(b"\r\n");
message.extend_from_slice(b"\r\n");
octets += 2;
}
buf.extend_from_slice(b".\r\n");
if in_headers {
message.extend_from_slice(b"\r\n");
octets += 2;
}
message.extend_from_slice(b".\r\n");
let mut buf = Vec::with_capacity(message.len() + 24);
buf.extend_from_slice(b"+OK ");
buf.extend_from_slice(octets.to_string().as_bytes());
buf.extend_from_slice(b" octets\r\n");
buf.extend_from_slice(&message);
buf
}
Response::Capability { mechanisms, stls } => {
@@ -208,9 +233,51 @@ mod tests {
(
Response::Message {
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
lines: 0,
lines: None,
},
"+OK 35 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n..test\r\na\r\n.\r\n",
"+OK 37 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n..test\r\na\r\n.\r\n",
),
(
Response::Message {
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
lines: Some(0),
},
"+OK 17 octets\r\nSubject: test\r\n\r\n.\r\n",
),
(
Response::Message {
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
lines: Some(2),
},
"+OK 27 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n.\r\n",
),
(
Response::Message {
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
lines: Some(100),
},
"+OK 37 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n..test\r\na\r\n.\r\n",
),
(
Response::Message {
bytes: SliceRange::Single(b"Subject: test\n\nbody\n"),
lines: None,
},
"+OK 23 octets\r\nSubject: test\r\n\r\nbody\r\n.\r\n",
),
(
Response::Message {
bytes: SliceRange::Single(b"Subject: test\n\n.leading dot\n"),
lines: Some(1),
},
"+OK 31 octets\r\nSubject: test\r\n\r\n..leading dot\r\n.\r\n",
),
(
Response::Message {
bytes: SliceRange::Single(b".dot\r\nSubject: test\r\n"),
lines: Some(3),
},
"+OK 23 octets\r\n..dot\r\nSubject: test\r\n\r\n.\r\n",
),
] {
assert_eq!(expected, String::from_utf8(cmd.serialize()).unwrap());
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "registry"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+6 -4
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
// This file is auto-generated. Do not edit directly.
@@ -10372,8 +10374,8 @@ impl EnumImpl for SieveCapability {
b"spamtest" => SieveCapability::Spamtest,
b"spamtestplus" => SieveCapability::Spamtestplus,
b"virustest" => SieveCapability::Virustest,
b"vnd.stalwart.while" => SieveCapability::VndStalwartWhile,
b"vnd.stalwart.expressions" => SieveCapability::VndStalwartExpressions,
b"vnd.inbuxa.while" => SieveCapability::VndStalwartWhile,
b"vnd.inbuxa.expressions" => SieveCapability::VndStalwartExpressions,
}
}
@@ -10426,8 +10428,8 @@ impl EnumImpl for SieveCapability {
SieveCapability::Spamtest => "spamtest",
SieveCapability::Spamtestplus => "spamtestplus",
SieveCapability::Virustest => "virustest",
SieveCapability::VndStalwartWhile => "vnd.stalwart.while",
SieveCapability::VndStalwartExpressions => "vnd.stalwart.expressions",
SieveCapability::VndStalwartWhile => "vnd.inbuxa.while",
SieveCapability::VndStalwartExpressions => "vnd.inbuxa.expressions",
}
}
+18 -18
View File
@@ -732,7 +732,7 @@ impl Pickle for AddressBook {
impl Default for AddressBook {
fn default() -> Self {
Self {
default_display_name: Some("INBUXA Address Book".to_string()),
default_display_name: Some("inbuxa Address Book".to_string()),
default_href_name: Some("default".to_string()),
max_v_card_size: 524288u64,
max_address_books: Some(250u64),
@@ -4597,7 +4597,7 @@ impl Pickle for Calendar {
impl Default for Calendar {
fn default() -> Self {
Self {
default_display_name: Some("INBUXA Calendar".to_string()),
default_display_name: Some("inbuxa Calendar".to_string()),
default_href_name: Some("default".to_string()),
max_attendees: 20u64,
max_recurrence_expansions: 3000u64,
@@ -4734,7 +4734,7 @@ impl Default for CalendarAlarm {
allow_external_rcpts: false,
enable: true,
from_email: Default::default(),
from_name: "INBUXA Calendar".to_string(),
from_name: "inbuxa Calendar".to_string(),
min_trigger_interval: Duration::from_millis(3600000),
template: Default::default(),
}
@@ -28310,7 +28310,7 @@ impl MtaStageConnect {
ExpressionContext {
expr: &self.smtp_greeting,
default: Some(Expression {
else_: "system('hostname') + ' INBUXA ESMTP at your service'".to_string(),
else_: "system('hostname') + ' inbuxa ESMTP at your service'".to_string(),
..Default::default()
}),
property: Property::SmtpGreeting,
@@ -28374,7 +28374,7 @@ impl Default for MtaStageConnect {
fn default() -> Self {
Self {
smtp_greeting: Expression {
else_: "system('hostname') + ' INBUXA ESMTP at your service'".to_string(),
else_: "system('hostname') + ' inbuxa ESMTP at your service'".to_string(),
..Default::default()
},
hostname: Expression {
@@ -29622,8 +29622,8 @@ impl Default for MySqlSettings {
Self {
host: Default::default(),
port: 3306u64,
database: "stalwart".to_string(),
auth_username: Some("stalwart".to_string()),
database: "inbuxa".to_string(),
auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(),
}
}
@@ -29780,8 +29780,8 @@ impl Default for MySqlStore {
read_replicas: Default::default(),
host: Default::default(),
port: 3306u64,
database: "stalwart".to_string(),
auth_username: Some("stalwart".to_string()),
database: "inbuxa".to_string(),
auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(),
}
}
@@ -29942,7 +29942,7 @@ impl Default for NatsCoordinator {
no_echo: true,
use_tls: false,
auth_secret: Default::default(),
auth_username: Some("stalwart".to_string()),
auth_username: Some("inbuxa".to_string()),
credentials: Default::default(),
}
}
@@ -31125,8 +31125,8 @@ impl Default for PostgreSqlSettings {
Self {
host: Default::default(),
port: 5432u64,
database: "stalwart".to_string(),
auth_username: Some("stalwart".to_string()),
database: "inbuxa".to_string(),
auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(),
options: Default::default(),
}
@@ -31270,8 +31270,8 @@ impl Default for PostgreSqlStore {
read_replicas: Default::default(),
host: Default::default(),
port: 5432u64,
database: "stalwart".to_string(),
auth_username: Some("stalwart".to_string()),
database: "inbuxa".to_string(),
auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(),
options: Default::default(),
}
@@ -32576,7 +32576,7 @@ impl Default for RedisClusterStore {
Self {
urls: Map::new(vec!["redis://127.0.0.1".to_string()]),
timeout: Duration::from_millis(10000),
auth_username: Some("stalwart".to_string()),
auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(),
max_retry_wait: Default::default(),
min_retry_wait: Default::default(),
@@ -32743,7 +32743,7 @@ impl Default for RedisSentinelStore {
urls: Map::new(vec!["redis://127.0.0.1:26379".to_string()]),
service_name: "mymaster".to_string(),
timeout: Duration::from_millis(10000),
auth_username: Some("stalwart".to_string()),
auth_username: Some("inbuxa".to_string()),
auth_secret: Default::default(),
sentinel_username: Default::default(),
sentinel_secret: Default::default(),
@@ -40215,7 +40215,7 @@ impl Default for SpamSettings {
score_reject: Float::new(0.0f64),
score_spam: Float::new(5.0f64),
trust_replies: true,
spam_filter_rules_url: Some("https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz".to_string()),
spam_filter_rules_url: None,
}
}
}
@@ -46308,7 +46308,7 @@ impl Default for TracerLog {
fn default() -> Self {
Self {
path: Default::default(),
prefix: "stalwart".to_string(),
prefix: "inbuxa".to_string(),
rotate: LogRotateFrequency::Daily,
ansi: true,
multiline: false,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "scim-proto"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "scim"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+9 -1
View File
@@ -4,6 +4,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
// The release profile computes the layout of this crate's async fn bodies in
// one go, and the deepest of them -- writable_domain, which awaits through
// the directory, the store and the JMAP registry -- takes rustc past its
// default query depth. The dev profile does not get that far, so the failure
// only appears in a release build: CI was green and the tag that started a
// release was not.
#![recursion_limit = "256"]
//! SCIM 2.0 provisioning (`docs/spec/features/scim.md`). inbuxa-server is the
//! service provider: an identity provider pushes users and groups to
//! `/scim/v2`, and each request becomes the same `x:Account` reads and
@@ -205,7 +213,7 @@ impl ScimResponse {
if error.status == 401 {
response.headers.push((
"WWW-Authenticate",
"Bearer realm=\"INBUXA SCIM\"".to_string(),
"Bearer realm=\"inbuxa SCIM\"".to_string(),
));
}
response
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "services"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+9 -6
View File
@@ -96,6 +96,13 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
}
};
inner
.shared_core
.load()
.storage
.data
.invalidate_read_snapshot();
loop {
match batch.next_event() {
Ok(Some(event)) => {
@@ -174,9 +181,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
.await;
}
BroadcastEvent::QueueRefresh => {
let core = inner.shared_core.load_full();
if core.network.roles.outbound_mta {
core.storage.data.invalidate_read_snapshot();
if inner.shared_core.load().network.roles.outbound_mta {
let _ = inner
.ipc
.queue_tx
@@ -185,9 +190,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
}
}
BroadcastEvent::RegistryChange(change) => {
let server = inner.build_server();
server.store().invalidate_read_snapshot();
match Box::pin(server.reload_registry(change)).await {
match Box::pin(inner.build_server().reload_registry(change)).await {
Ok(result) => {
result.log();
}
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::task_manager::TaskResult;
use crate::task_manager::{TaskResult, deferred_retry_time};
use common::Server;
use email::{message::metadata::MessageMetadata, sieve::SieveScript};
use groupware::file::FileNode;
@@ -41,7 +41,7 @@ impl DestroyAccountTask for Server {
match destroy_account(self, task).await {
Ok(result) => result,
Err(err) => {
let result = TaskResult::temporary(err.to_string());
let result = TaskResult::deferred(deferred_retry_time(&err), err.to_string());
trc::error!(
err.account_id(task.account_id.document_id())
.details("Failed to destroy account")
+3 -19
View File
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::task_manager::{Task, TaskDetails, TaskFailureType, TaskResult};
use crate::task_manager::{Task, TaskDetails, TaskFailureType, TaskResult, deferred_retry_time};
use common::Server;
use email::{
cache::MessageCacheFetch,
@@ -274,7 +274,7 @@ impl SearchIndexTask for Server {
);
for r in results.iter_mut() {
if r.task_type == TaskType::Insert && r.result.is_success() {
r.result = search_store_failure(retry_at, "Failed to index documents");
r.result = TaskResult::deferred(retry_at, "Failed to index documents");
}
}
return results;
@@ -331,7 +331,7 @@ impl SearchIndexTask for Server {
for r in results.iter_mut() {
if r.task_type == TaskType::Delete && r.result.is_success() {
r.result =
search_store_failure(retry_at, "Failed to delete documents from index");
TaskResult::deferred(retry_at, "Failed to delete documents from index");
}
}
return results;
@@ -445,22 +445,6 @@ pub(crate) async fn reindex_account(server: &Server, account_id: u32) -> trc::Re
Ok(())
}
fn deferred_retry_time(err: &trc::Error) -> Option<u64> {
err.value(trc::Key::NextRetry)
.and_then(|value| value.to_uint())
}
fn search_store_failure(retry_at: Option<u64>, message: &'static str) -> TaskResult {
match retry_at {
Some(retry_at) => TaskResult::Failure {
typ: TaskFailureType::Retry(retry_at),
message: message.into(),
max_attempts: None,
},
None => TaskResult::temporary(message),
}
}
fn attempt_number(status: &TaskStatus) -> u64 {
match status {
TaskStatus::Pending(_) => 0,
@@ -621,6 +621,7 @@ pub fn perpetual_retry_time(typ: TaskType, attempt: u64) -> Option<u64> {
| TaskType::DkimManagement
| TaskType::IndexDocument
| TaskType::UnindexDocument
| TaskType::DestroyAccount
)
.then(|| {
now().saturating_add(
+16
View File
@@ -137,4 +137,20 @@ impl TaskResult {
max_attempts: None,
}
}
pub fn deferred(retry_at: Option<u64>, message: impl Into<String>) -> Self {
match retry_at {
Some(retry_at) => TaskResult::Failure {
typ: TaskFailureType::Retry(retry_at),
message: message.into(),
max_attempts: None,
},
None => TaskResult::temporary(message),
}
}
}
pub(crate) fn deferred_retry_time(err: &trc::Error) -> Option<u64> {
err.value(trc::Key::NextRetry)
.and_then(|value| value.to_uint())
}
@@ -2,13 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::task_manager::{TaskFailureType, TaskResult};
use common::{
Server,
ipc::{BroadcastEvent, RegistryChange},
manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource},
manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource, spam_rules},
};
use registry::{
schema::{
@@ -106,6 +108,7 @@ struct RuleUpdateResult {
async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
let started = Instant::now();
let bundled = server.core.spam.spam_rules_url.is_none();
let rules = match fetch_spam_rules(server).await {
Ok(rules) => rules,
Err(err) => {
@@ -289,29 +292,36 @@ async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
Elapsed = started.elapsed(),
);
// inbuxa: so the next start knows these bundled rules are in
if bundled {
spam_rules::set_applied_version(server.store(), spam_rules::BUNDLED_SPAM_RULES_VERSION)
.await?;
}
Ok(TaskResult::Success(vec![]))
}
async fn fetch_spam_rules(server: &Server) -> Result<Rules, RuleUpdateError> {
let Some(rules_url) = server.core.spam.spam_rules_url.as_ref() else {
return Err(RuleUpdateError {
typ: TaskFailureType::Permanent,
reason: "Spam rules resource URL not configured".to_string(),
});
};
let rules_json: AHashMap<String, Vec<serde_json::Value>> =
fetch_resource(rules_url, None, Duration::from_secs(60), 1024 * 500)
// inbuxa: no URL means the rules bundled with the server
let bytes = match server.core.spam.spam_rules_url.as_ref() {
Some(rules_url) => fetch_resource(rules_url, None, Duration::from_secs(60), 1024 * 500)
.await
.map_err(|reason| RuleUpdateError {
typ: TaskFailureType::Temporary,
reason,
}),
None => spam_rules::bundled_rules().map_err(|reason| RuleUpdateError {
typ: TaskFailureType::Permanent,
reason,
}),
};
let rules_json: AHashMap<String, Vec<serde_json::Value>> =
bytes.and_then(|bytes| {
serde_json::from_slice(&bytes).map_err(|err| RuleUpdateError {
typ: TaskFailureType::Permanent,
reason: format!("Failed to parse spam rules JSON: {err}"),
})
.and_then(|bytes| {
serde_json::from_slice(&bytes).map_err(|err| RuleUpdateError {
typ: TaskFailureType::Permanent,
reason: format!("Failed to parse spam rules JSON: {err}"),
})
})?;
})?;
let mut rules = Rules::default();
for (object_type, values) in rules_json {
+3 -4
View File
@@ -1,13 +1,12 @@
[package]
name = "smtp"
description = "Stalwart SMTP Server"
description = "inbuxa SMTP server"
authors = [ "Stalwart Labs LLC <[email protected]>"]
repository = "https://github.com/stalwartlabs/smtp-server"
homepage = "https://stalw.art/smtp"
homepage = "https://inbuxa.org"
keywords = ["smtp", "email", "mail", "server"]
categories = ["email"]
license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+8 -4
View File
@@ -11,6 +11,7 @@ use common::{
config::smtp::auth::VerifyStrategy,
network::{ServerInstance, asn::AsnGeoLookupResult},
};
use email::message::delivery::ORCPT_ADDR_TYPE;
use mail_auth::{IprevOutput, SpfOutput};
use smtp_proto::request::receiver::{
BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver, RequestReceiver,
@@ -306,10 +307,13 @@ impl SessionAddress {
}
}
pub fn report_address(&self) -> &str {
pub fn orig_address(&self) -> &str {
self.dsn_info.as_deref().unwrap_or(&self.address_lcase)
}
pub fn orcpt_parameter(&self) -> Option<String> {
self.dsn_info
.as_ref()
.and_then(|v| v.strip_prefix("rfc822;"))
.unwrap_or(&self.address_lcase)
.as_deref()
.map(|orcpt| format!("{ORCPT_ADDR_TYPE}{}", orcpt.to_lowercase()))
}
}
+1 -1
View File
@@ -443,7 +443,7 @@ impl<T: SessionStream> Session<T> {
if !rc.analysis.forward {
self.data
.rcpt_to
.retain(|rcpt| !rc.analysis.is_report_address(rcpt.report_address()));
.retain(|rcpt| !rc.analysis.is_report_address(rcpt.orig_address()));
}
if self.data.rcpt_to.is_empty() {
+2 -3
View File
@@ -202,8 +202,8 @@ impl<T: SessionStream> Session<T> {
let mut new_addr = SessionAddress::new(address);
if !self.data.rcpt_to.contains(&new_addr) {
new_addr.dsn_info = format!("rfc822;{}", orig_addr.address_lcase).into();
new_addr.flags = orig_addr.flags;
new_addr.dsn_info = orig_addr.address_lcase.into();
self.data.rcpt_to.push(new_addr);
} else {
trc::event!(
@@ -353,7 +353,6 @@ impl<T: SessionStream> Session<T> {
// Expand list
if let Some(members) = rcpt_members {
let list_addr = self.data.rcpt_to.pop().unwrap();
let orcpt = format!("rfc822;{}", list_addr.address_lcase);
for member in members.as_ref() {
let member_lcase = member.to_lowercase();
let is_local = match self
@@ -399,7 +398,7 @@ impl<T: SessionStream> Session<T> {
if !self.data.rcpt_to.contains(&member_addr)
&& member_addr.address_lcase != list_addr.address_lcase
{
member_addr.dsn_info = orcpt.clone().into();
member_addr.dsn_info = list_addr.address_lcase.clone().into();
member_addr.flags = list_addr.flags;
self.data.rcpt_to.push(member_addr);
}
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use common::{
@@ -211,7 +213,7 @@ impl<T: SessionStream> Session<T> {
Request::Help { .. } => {
trc::event!(Smtp(SmtpEvent::Help), SpanId = self.data.session_id,);
self.write(b"250 2.0.0 Help can be found at https://stalw.art\r\n")
self.write(concat!("250 2.0.0 Help can be found at ", types::brand_url!(), "\r\n").as_bytes())
.await?;
}
Request::Helo { host } => {
+1 -11
View File
@@ -89,17 +89,7 @@ impl<T: SessionStream> Session<T> {
.iter()
.map(|r| r.address_lcase.as_str())
.collect(),
env_rcpt_orig_to: self
.data
.rcpt_to
.iter()
.map(|r| {
r.dsn_info
.as_deref()
.and_then(|info| info.strip_prefix("rfc822;"))
.unwrap_or(r.address_lcase.as_str())
})
.collect(),
env_rcpt_orig_to: self.data.rcpt_to.iter().map(|r| r.orig_address()).collect(),
is_test: false,
is_train: false,
}
+21 -9
View File
@@ -15,8 +15,8 @@ use crate::outbound::lookup::{DnsLookup, SourceIp};
use crate::outbound::mta_sts::lookup::MtaStsLookup;
use crate::outbound::mta_sts::verify::VerifyPolicy;
use crate::outbound::{client::StartTlsResult, dane::verify::TlsaVerify};
use crate::queue::dsn::SendDsn;
use crate::queue::spool::SmtpSpool;
use crate::queue::dsn::{DsnStatus, SendDsn};
use crate::queue::spool::{DSN_RETRY, SmtpSpool};
use crate::queue::throttle::IsAllowed;
use crate::queue::{
Error, FROM_REPORT, HostResponse, MessageWrapper, Metadata, QueueEnvelope, QueuedMessage,
@@ -155,7 +155,7 @@ impl QueuedMessage {
let span_id = message.span_id;
// Send any due Delivery Status Notifications
server.send_dsn(&mut message).await;
let dsn_status = server.send_dsn(&mut message).await;
match has_pending_delivery {
PendingDelivery::Yes(true)
@@ -163,21 +163,27 @@ impl QueuedMessage {
.message
.next_delivery_event(self.queue_name.into())
.is_some_and(|due| due <= now()) => {}
PendingDelivery::No => {
PendingDelivery::No if dsn_status == DsnStatus::Completed => {
trc::event!(
Delivery(DeliveryEvent::Completed),
SpanId = span_id,
Elapsed = trc::Value::Duration((now() - message.message.created) * 1000)
);
// All message recipients expired, do not re-queue. (DSN has been already sent)
// All message recipients expired, do not re-queue.
message.remove(&server, self.due.into()).await;
return QueueEventStatus::Completed;
}
PendingDelivery::No => {
message
.save_changes(&server, self.due.into(), Some(now() + DSN_RETRY))
.await;
return QueueEventStatus::Deferred;
}
_ => {
// Re-queue the message if its not yet due for delivery
message.save_changes(&server, self.due.into()).await;
message.save_changes(&server, self.due.into(), None).await;
return QueueEventStatus::Deferred;
}
}
@@ -208,7 +214,7 @@ impl QueuedMessage {
}
}
message.save_changes(&server, self.due.into()).await;
message.save_changes(&server, self.due.into(), None).await;
return QueueEventStatus::Deferred;
}
@@ -1485,7 +1491,7 @@ impl QueuedMessage {
}
// Send Delivery Status Notifications
server.send_dsn(&mut message).await;
let dsn_status = server.send_dsn(&mut message).await;
// Notify queue manager
if message.message.next_event(None).is_some() {
@@ -1501,7 +1507,13 @@ impl QueuedMessage {
);
// Save changes to disk
message.save_changes(&server, self.due.into()).await;
message.save_changes(&server, self.due.into(), None).await;
QueueEventStatus::Deferred
} else if dsn_status == DsnStatus::Deferred {
message
.save_changes(&server, self.due.into(), Some(now() + DSN_RETRY))
.await;
QueueEventStatus::Deferred
} else {
+1 -1
View File
@@ -120,7 +120,7 @@ impl MessageWrapper {
)
.await;
message
let _ = message
.queue(
QueueParams::new(&autogenerated.message, self.span_id, server)
.with_dkim_signers(dkim_signers)
+46 -12
View File
@@ -10,9 +10,10 @@ use super::{
Recipient, Status,
};
use crate::inbound::dkim::DkimSign;
use crate::queue::spool::QueueParams;
use crate::queue::spool::{DSN_RETRY, QueueParams};
use crate::queue::{MessageWrapper, UnexpectedResponse};
use common::Server;
use email::message::delivery::ORCPT_ADDR_TYPE;
use mail_builder::MessageBuilder;
use mail_builder::headers::HeaderType;
use mail_builder::headers::content_type::ContentType;
@@ -25,16 +26,24 @@ use std::fmt::Write;
use std::future::Future;
use store::write::now;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DsnStatus {
Completed,
Deferred,
}
pub trait SendDsn: Sync + Send {
fn send_dsn(&self, message: &mut MessageWrapper) -> impl Future<Output = ()> + Send;
fn send_dsn(&self, message: &mut MessageWrapper) -> impl Future<Output = DsnStatus> + Send;
fn log_dsn(&self, message: &MessageWrapper) -> impl Future<Output = ()> + Send;
}
impl SendDsn for Server {
async fn send_dsn(&self, message: &mut MessageWrapper) {
async fn send_dsn(&self, message: &mut MessageWrapper) -> DsnStatus {
// Send DSN events
self.log_dsn(message).await;
let mut status = DsnStatus::Completed;
if !message.message.return_path.is_empty() {
// Build DSN
if let Some(dsn) = message.build_dsn(self).await {
@@ -51,12 +60,19 @@ impl SendDsn for Server {
message.span_id,
)
.await;
dsn_message
if dsn_message
.queue(
QueueParams::new(&dsn, message.span_id, self)
.with_dkim_signers(dkim_signers),
)
.await;
.await
{
message.mark_dsn_sent();
} else {
status = DsnStatus::Deferred;
}
} else {
message.mark_dsn_sent();
}
} else {
// Handle double bounce
@@ -64,7 +80,9 @@ impl SendDsn for Server {
}
// Update next DSN notify times
message.update_next_dsn(self).await;
message.update_next_dsn(self, status).await;
status
}
async fn log_dsn(&self, message: &MessageWrapper) {
@@ -132,7 +150,7 @@ impl SendDsn for Server {
const MAX_HEADER_SIZE: usize = 4096;
impl MessageWrapper {
pub async fn build_dsn(&mut self, server: &Server) -> Option<Vec<u8>> {
pub async fn build_dsn(&self, server: &Server) -> Option<Vec<u8>> {
let config = &server.core.smtp.queue;
let now = now();
@@ -141,13 +159,12 @@ impl MessageWrapper {
let mut txt_failed = String::new();
let mut dsn = String::new();
for rcpt in &mut self.message.recipients {
for rcpt in &self.message.recipients {
if rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) {
continue;
}
match &rcpt.status {
Status::Completed(response) => {
rcpt.flags |= RCPT_DSN_SENT;
if !rcpt.has_flag(RCPT_NOTIFY_SUCCESS) {
continue;
}
@@ -164,7 +181,6 @@ impl MessageWrapper {
response.write_dsn_text(&rcpt.address, &mut txt_delay);
}
Status::PermanentFailure(response) => {
rcpt.flags |= RCPT_DSN_SENT;
if !rcpt.has_flag(RCPT_NOTIFY_FAILURE) {
continue;
}
@@ -357,7 +373,7 @@ impl MessageWrapper {
.into()
}
pub async fn update_next_dsn(&mut self, server: &Server) {
pub async fn update_next_dsn(&mut self, server: &Server, status: DsnStatus) {
let now = now();
let mut notify_changes = Vec::new();
for (rcpt_idx, rcpt) in self.message.recipients.iter().enumerate() {
@@ -366,6 +382,11 @@ impl MessageWrapper {
Status::TemporaryFailure(_) | Status::Scheduled
) && rcpt.notify.due <= now
{
if status == DsnStatus::Deferred {
notify_changes.push((rcpt_idx, 0, now + DSN_RETRY));
continue;
}
let envelope = QueueEnvelope::new(&self.message, rcpt);
let queue_id = server
@@ -391,6 +412,19 @@ impl MessageWrapper {
}
}
fn mark_dsn_sent(&mut self) {
for rcpt in &mut self.message.recipients {
if !rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER)
&& matches!(
rcpt.status,
Status::Completed(_) | Status::PermanentFailure(_)
)
{
rcpt.flags |= RCPT_DSN_SENT;
}
}
}
fn handle_double_bounce(&mut self) {
let mut is_double_bounce = Vec::with_capacity(0);
let now = now();
@@ -523,7 +557,7 @@ impl Message {
impl Recipient {
fn write_dsn(&self, dsn: &mut String) {
if let Some(orcpt) = &self.orcpt {
let _ = write!(dsn, "Original-Recipient: rfc822;{orcpt}\r\n");
let _ = write!(dsn, "Original-Recipient: {ORCPT_ADDR_TYPE}{orcpt}\r\n");
}
let _ = write!(dsn, "Final-Recipient: rfc822;{}\r\n", self.address);
}
+14 -2
View File
@@ -45,6 +45,7 @@ use utils::DomainPart;
pub const LOCK_EXPIRY: u64 = 10 * 60; // 10 minutes
pub const QUEUE_REFRESH: u64 = 5 * 60; // 5 minutes
pub const DSN_RETRY: u64 = 5 * 60; // 5 minutes
pub(crate) const INFINITE_LOCK: u64 = 60 * 60 * 24 * 365; // 1 year
const CANDIDATE_OVERSCAN: usize = 4;
const MAX_PREALLOCATED_CANDIDATES: usize = 1024;
@@ -370,6 +371,7 @@ pub(crate) struct QueueParams<'x, 'y> {
}
impl MessageWrapper {
#[must_use]
pub(crate) async fn queue<'x, 'y>(mut self, mut params: QueueParams<'x, 'y>) -> bool {
// Add DKIM signatures
let dkim_headers = if params.dkim_signers.is_some() {
@@ -669,7 +671,12 @@ impl MessageWrapper {
recipient.queue = queue.virtual_queue;
}
pub async fn save_changes(mut self, server: &Server, prev_event: Option<u64>) -> bool {
pub async fn save_changes(
mut self,
server: &Server,
prev_event: Option<u64>,
retry_at: Option<u64>,
) -> bool {
// Release quota for completed deliveries
let mut batch = BatchBuilder::new();
self.release_quota(&mut batch);
@@ -684,7 +691,12 @@ impl MessageWrapper {
},
)));
}
for (queue_name, due) in self.message.next_events() {
let mut next_events = self.message.next_events();
if let Some(retry_at) = retry_at {
let due = next_events.entry(self.queue_name).or_insert(retry_at);
*due = std::cmp::min(*due, retry_at);
}
for (queue_name, due) in next_events {
batch.set(
ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent {
due,
-3
View File
@@ -316,9 +316,6 @@ impl<T: SessionStream> Session<T> {
if let Some(dkim2_output) = dkim2_output {
report_record = report_record.with_dkim2_output(dkim2_output);
}
if let Some(spf_ehlo) = &self.data.spf_ehlo {
report_record = report_record.with_spf_output(spf_ehlo, SPFDomainScope::Helo);
}
if let Some(spf_mail_from) = &self.data.spf_mail_from {
report_record = report_record.with_spf_output(spf_mail_from, SPFDomainScope::MailFrom);
}
+1 -1
View File
@@ -41,7 +41,7 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
self.data
.rcpt_to
.iter()
.any(|addr| analysis.is_report_address(addr.report_address()))
.any(|addr| analysis.is_report_address(addr.orig_address()))
}
}
+2 -2
View File
@@ -98,7 +98,7 @@ impl MtaReportSend for Server {
let dkim_signers = self
.eval_signers(sign_config, &message.message, parent_session_id)
.await;
message
let _ = message
.queue(
QueueParams::new(&report, parent_session_id, self).with_dkim_signers(dkim_signers),
)
@@ -130,7 +130,7 @@ impl MtaReportSend for Server {
} else {
None
};
message
let _ = message
.queue(
QueueParams::new(&raw_message, parent_session_id, self)
.with_dkim_signers(dkim_signers),
+6 -1
View File
@@ -12,6 +12,7 @@ use smtp_proto::{
use utils::DomainPart;
use crate::core::{SessionAddress, SessionData};
use email::message::delivery::ORCPT_ADDR_TYPE;
impl SessionData {
pub fn apply_envelope_modification(&mut self, envelope: Envelope, value: String) {
@@ -111,7 +112,11 @@ impl SessionData {
}
Envelope::Orcpt => {
if let Some(rcpt_to) = self.rcpt_to.last_mut() {
rcpt_to.dsn_info = value.into();
rcpt_to.dsn_info = value
.strip_prefix(ORCPT_ADDR_TYPE)
.map(str::to_string)
.unwrap_or(value)
.into();
}
}
Envelope::Envid => {
+1 -1
View File
@@ -297,7 +297,7 @@ impl RunScript for Server {
None
};
message
let _ = message
.queue(
QueueParams::new(raw_message, session_id, self)
.with_dkim_signers(dkim_signers)
+4 -6
View File
@@ -95,10 +95,8 @@ impl<T: SessionStream> Session<T> {
params
.envelope
.push((Envelope::To, rcpt.address_lcase.to_string().into()));
if let Some(orcpt) = &rcpt.dsn_info {
params
.envelope
.push((Envelope::Orcpt, orcpt.as_str().to_lowercase().into()));
if let Some(orcpt) = rcpt.orcpt_parameter() {
params.envelope.push((Envelope::Orcpt, orcpt.into()));
}
}
} else {
@@ -109,10 +107,10 @@ impl<T: SessionStream> Session<T> {
for rcpt in &self.data.rcpt_to {
recipients.push(Variable::from(rcpt.address_lcase.to_string()));
orcpts.push(match &rcpt.dsn_info {
orcpts.push(match rcpt.orcpt_parameter() {
Some(orcpt) => {
has_orcpts = true;
Variable::from(orcpt.as_str().to_lowercase())
Variable::from(orcpt)
}
None => Variable::default(),
});
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "spam-filter"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use crate::analysis::domain::SpamFilterAnalyzeDomain;
+34 -27
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use common::config::mailstore::spamfilter::PyzorConfig;
@@ -43,35 +45,14 @@ pub(crate) async fn pyzor_check(
// Hash message
let request = message.pyzor_check_message();
// Send message to address. inbuxa: in tests, a fixed table answers
// instead of a public server (test_response).
#[cfg(not(feature = "test_mode"))]
let response = pyzor_send_message(config.address, config.timeout, &request).await;
#[cfg(feature = "test_mode")]
{
if request.contains("b5b476f0b5ba6e1c038361d3ded5818dd39c90a2") {
return Ok(PyzorResponse {
code: 200,
count: 1000,
wl_count: 0,
}
.into());
} else if request.contains("d67d4b8bfc3860449e3418bb6017e2612f3e2a99") {
return Ok(PyzorResponse {
code: 200,
count: 60,
wl_count: 10,
}
.into());
} else if request.contains("81763547012b75e57a20d18ce0b93014208cdfdb") {
return Ok(PyzorResponse {
code: 200,
count: 50,
wl_count: 20,
}
.into());
}
}
let response = std::io::Result::Ok(test_response(&request));
// Send message to address
pyzor_send_message(config.address, config.timeout, &request)
.await
response
.map(Into::into)
.map_err(|err| {
trc::SpamEvent::PyzorError
@@ -82,6 +63,32 @@ pub(crate) async fn pyzor_check(
})
}
/// inbuxa: the answers tests get, by digest, instead of a public server's,
/// whose counts change and which a test may not be able to reach. Upstream
/// answered the first three here and sent every other digest to the network.
#[cfg(feature = "test_mode")]
fn test_response(request: &str) -> PyzorResponse {
let (count, wl_count) = if request.contains("b5b476f0b5ba6e1c038361d3ded5818dd39c90a2")
// The digest of an empty body, as an HTML-only message with no text
// to hash produces; public servers report it widely.
|| request.contains("da39a3ee5e6b4b0d3255bfef95601890afd80709")
{
(1000, 0)
} else if request.contains("d67d4b8bfc3860449e3418bb6017e2612f3e2a99") {
(60, 10)
} else if request.contains("81763547012b75e57a20d18ce0b93014208cdfdb") {
(50, 20)
} else {
(0, 0)
};
PyzorResponse {
code: 200,
count,
wl_count,
}
}
#[cfg_attr(feature = "test_mode", allow(dead_code))]
async fn pyzor_send_message(
addr: SocketAddr,
timeout: Duration,
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "store"
version = "0.16.22"
version = "0.16.23"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -71,7 +71,7 @@ impl ReadVersion {
}
fn expire(&self) {
self.obtained.store(0, Ordering::Release);
self.version.store(0, Ordering::Release);
}
fn try_begin_refresh(&self) -> Option<RefreshGuard<'_>> {

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