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

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

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

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

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

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

Left alone deliberately: the OAuth key-derivation contexts (renaming them
would invalidate every sealed token and client id), the migration defaults
that read an upstream installation, links to upstream's upgrade guide, the
wire-protocol identifiers, and upstream's own license and templates.
2026-09-22 13:21:15 -07:00
jcoffey-dev 1f963a9a1c Merge pull request 'Keep the upstream name out of the version string' (#12) from fix/version-string-name into main
ci / build (push) Successful in 4m7s
2026-09-22 20:07:52 +00:00
jcoffey-dev b353f4ad2a Keep the upstream project's name out of the version string
ci / build (pull_request) Successful in 7m9s
brand_version_full! is user-visible -- --version, the startup banner, the
console, telemetry and the JMAP session's implementation field -- and the
name belongs only in copyright notices and the lineage line.
2026-09-22 13:00:13 -07:00
99 changed files with 1759 additions and 673 deletions
+10
View File
@@ -20,6 +20,16 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# The upstream name in a new string literal, typically brought in by an
# upstream merge. Seconds, and needs no toolchain. tools/fork/name-check.py.
name-check:
runs-on: light
container:
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
steps:
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
- run: python3 tools/fork/name-check.py
build: build:
# Either runner (host1 or host2): the build needs no docker socket. # Either runner (host1 or host2): the build needs no docker socket.
runs-on: light runs-on: light
+88
View File
@@ -0,0 +1,88 @@
# 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.
#
# 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."); sys.exit(0)
# 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']}.")
PY
-50
View File
@@ -1,50 +0,0 @@
# CI on the self-hosted GitLab, ported from .github/workflows/ci.yml when the
# GitHub account was suspended on 2026-09-20. The Actions file stays in the
# tree: it is the reference this was written from and works unchanged if the
# appeal succeeds.
#
# The image is pinned by digest, with its tag in the trailing comment. That
# replaces the SHA-pinned `uses:` in the workflow -- GitLab has no action
# allowlist, so the digest is the only thing fixing what actually runs.
#
# Not ported here:
# * cleanup.yml pruned GHCR with dataaxiom/ghcr-cleanup-action. GitLab has
# no equivalent action because it does not need one: the container
# registry has a cleanup policy on the project itself, which is where that
# job's settings now live.
# * publish.yml and release.yml still need doing; they are larger and are
# being handled separately.
stages: [build]
default:
interruptible: true
build:
stage: build
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
# This is a big workspace and a cold build is expensive, so the registry and
# the target directory are cached between runs. Both are kept inside the
# project directory because that is the only path the runner will cache --
# and deliberately not on /tmp, which on this host is a tmpfs that a Rust
# build of this size has filled before.
variables:
CARGO_HOME: "$CI_PROJECT_DIR/.cargo"
CARGO_TARGET_DIR: "$CI_PROJECT_DIR/target"
CARGO_INCREMENTAL: "0"
cache:
key:
files: [Cargo.lock]
paths:
- .cargo/registry/
- target/
before_script:
- apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null
script:
- cargo build -p inbuxa --locked
# --no-run: the workflow compiled every test target without running them,
# which catches a test that no longer builds without paying for the suite.
- cargo test --workspace --locked --no-run
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+33
View File
@@ -2,6 +2,39 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [0.16.23] - 2026-09-21
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
## Added
- Expressions: `bit_and` function.
## Changed
## Fixed
- MTA:
- A mailing list whose recipients include another mailing list is accepted at `RCPT TO` and then rejected at local delivery with `550 5.5.0 Mailbox not found`.
- DMARC aggregate reports carry two `spf` elements per record and the `version` element of a DMARC aggregate report is written as `1` instead of `1.0`.
- DSNs generated for an alias rewrite or a list expansion emit a doubled `addr-type` in `Original-Recipient` (`rfc822;rfc822;[email protected]`).
- DSNs that cannot be written to the store are discarded, the recipients are flagged as notified and the original message is removed from the queue, losing both the bounce and the message.
- POP3:
- `TOP msg n` counts the `n` lines from the first byte of the message instead of from the first byte of the body.
- A message whose very first line begins with `.` is not byte-stuffed.
- Spam filter: Moving or copying a message from one account into another creates no training sample, so the classifier never learns from it.
- Sieve: `envelope "orcpt"` yields the bare address for an `ORCPT` supplied over SMTP. It now carries the `addr-type` prefix in every case, as required by RFC 6009.
- ACME: The `_acme-challenge` TXT records published for a DNS-01 authorization are never removed.
- DNS: The DNSSEC resolver queries a single nameserver at a time, working around a `hickory-resolver` race that cancels the TCP retry when two nameservers return a truncated response in parallel.
- Troubleshoot tool:
- MX records are resolved through the DNSSEC-validating resolver, matching the resolver used by the delivery path.
- A TLSA lookup that fails or returns bogus records stops the delivery attempt for that host, instead of continuing without DANE.
- OIDC: Bearer tokens that carry no `email`, `preferred_username` or `upn` claim are always authenticated against the default directory.
- Meilisearch: A confirmation timeout is treated as a failed write even when `failOnTimeout` is disabled, so an index whose batches take longer than `pollInterval` x `maxRetries` never completes an indexing task and resubmits the same batch indefinitely.
- WebUI: A failed update no longer takes an `Application` offline.
- FoundationDB: The cached read version is invalidated when any broadcast is received from another node.
- Redis:
- On a cluster, the rate limiter and the blob upload quota issue `INCR` and `EXPIRE` as a `MULTI`/`EXEC` transaction, whose `MOVED` redirects collapse into a single `EXECABORT` that never refreshes the slot map.
- A connection that fails because it is addressing the wrong server is returned to the pool and reused, since the recycle check only issues `PING`.
## [0.16.22] - 2026-09-13 ## [0.16.22] - 2026-09-13
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
Generated
+139 -130
View File
@@ -234,7 +234,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
"synstructure", "synstructure 0.13.2",
] ]
[[package]] [[package]]
@@ -277,9 +277,9 @@ dependencies = [
[[package]] [[package]]
name = "async-compression" name = "async-compression"
version = "0.4.46" version = "0.4.48"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f" checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
dependencies = [ dependencies = [
"compression-codecs", "compression-codecs",
"compression-core", "compression-core",
@@ -310,7 +310,7 @@ dependencies = [
"memchr", "memchr",
"pin-project", "pin-project",
"portable-atomic", "portable-atomic",
"rand 0.10.2", "rand 0.10.3",
"regex", "regex",
"rustls-native-certs", "rustls-native-certs",
"rustls-pki-types", "rustls-pki-types",
@@ -369,7 +369,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -874,7 +874,7 @@ dependencies = [
"log", "log",
"num", "num",
"pin-project-lite", "pin-project-lite",
"rand 0.10.2", "rand 0.10.3",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"rustls-pki-types", "rustls-pki-types",
@@ -984,7 +984,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -1110,9 +1110,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.6" version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -1160,9 +1160,9 @@ dependencies = [
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]] [[package]]
name = "cfg_aliases" name = "cfg_aliases"
@@ -1302,7 +1302,7 @@ dependencies = [
[[package]] [[package]]
name = "common" name = "common"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"aes-gcm-siv", "aes-gcm-siv",
"ahash", "ahash",
@@ -1402,9 +1402,9 @@ dependencies = [
[[package]] [[package]]
name = "compression-codecs" name = "compression-codecs"
version = "0.4.41" version = "0.4.43"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8" checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
dependencies = [ dependencies = [
"compression-core", "compression-core",
"flate2", "flate2",
@@ -1487,7 +1487,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]] [[package]]
name = "coordinator" name = "coordinator"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"async-nats", "async-nats",
"futures", "futures",
@@ -1849,7 +1849,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"strsim", "strsim",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -1882,7 +1882,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [ dependencies = [
"darling_core 0.24.1", "darling_core 0.24.1",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -1899,7 +1899,7 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]] [[package]]
name = "dav" name = "dav"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"calcard", "calcard",
"chrono", "chrono",
@@ -1922,7 +1922,7 @@ dependencies = [
[[package]] [[package]]
name = "dav-proto" name = "dav-proto"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"calcard", "calcard",
"chrono", "chrono",
@@ -2135,7 +2135,7 @@ dependencies = [
[[package]] [[package]]
name = "directory" name = "directory"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"argon2 0.6.0", "argon2 0.6.0",
@@ -2192,7 +2192,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2376,7 +2376,7 @@ dependencies = [
[[package]] [[package]]
name = "email" name = "email"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"aes 0.9.3", "aes 0.9.3",
"aes-gcm 0.11.1", "aes-gcm 0.11.1",
@@ -2485,10 +2485,10 @@ dependencies = [
[[package]] [[package]]
name = "event_macro" name = "event_macro"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -2571,7 +2571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4"
dependencies = [ dependencies = [
"portable-atomic", "portable-atomic",
"rand 0.10.2", "rand 0.10.3",
"web-time", "web-time",
] ]
@@ -2594,9 +2594,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.12" version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
[[package]] [[package]]
name = "fixed_decimal" name = "fixed_decimal"
@@ -2710,7 +2710,7 @@ dependencies = [
"foundationdb-sys", "foundationdb-sys",
"foundationdb-tuple", "foundationdb-tuple",
"futures", "futures",
"rand 0.10.2", "rand 0.10.3",
"serde", "serde",
"serde_bytes", "serde_bytes",
"serde_json", "serde_json",
@@ -2842,7 +2842,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -3013,7 +3013,7 @@ dependencies = [
[[package]] [[package]]
name = "groupware" name = "groupware"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"calcard", "calcard",
@@ -3169,7 +3169,7 @@ dependencies = [
"jni", "jni",
"lru-cache", "lru-cache",
"parking_lot", "parking_lot",
"rand 0.10.2", "rand 0.10.3",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"rustls-platform-verifier", "rustls-platform-verifier",
@@ -3196,7 +3196,7 @@ dependencies = [
"jni", "jni",
"once_cell", "once_cell",
"prefix-trie", "prefix-trie",
"rand 0.10.2", "rand 0.10.3",
"ring", "ring",
"rustls-pki-types", "rustls-pki-types",
"thiserror 2.0.20", "thiserror 2.0.20",
@@ -3223,7 +3223,7 @@ dependencies = [
"ndk-context", "ndk-context",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"rand 0.10.2", "rand 0.10.3",
"resolv-conf", "resolv-conf",
"rustls", "rustls",
"smallvec", "smallvec",
@@ -3302,7 +3302,7 @@ dependencies = [
[[package]] [[package]]
name = "http" name = "http"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"base64 0.23.1", "base64 0.23.1",
@@ -3398,7 +3398,7 @@ dependencies = [
[[package]] [[package]]
name = "http_proto" name = "http_proto"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"common", "common",
"compact_str", "compact_str",
@@ -3488,9 +3488,9 @@ dependencies = [
[[package]] [[package]]
name = "hyper-rustls" name = "hyper-rustls"
version = "0.27.9" version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [ dependencies = [
"http 1.5.0", "http 1.5.0",
"hyper", "hyper",
@@ -3533,7 +3533,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2 0.5.10", "socket2 0.6.5",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -3884,7 +3884,7 @@ checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
[[package]] [[package]]
name = "imap" name = "imap"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"common", "common",
@@ -3897,7 +3897,7 @@ dependencies = [
"md5", "md5",
"nlp", "nlp",
"parking_lot", "parking_lot",
"rand 0.10.2", "rand 0.10.3",
"registry", "registry",
"store", "store",
"tokio", "tokio",
@@ -3909,7 +3909,7 @@ dependencies = [
[[package]] [[package]]
name = "imap_proto" name = "imap_proto"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"base64 0.23.1", "base64 0.23.1",
@@ -3924,7 +3924,7 @@ dependencies = [
[[package]] [[package]]
name = "inbuxa" name = "inbuxa"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"common", "common",
"coordinator", "coordinator",
@@ -3932,7 +3932,7 @@ dependencies = [
"directory", "directory",
"email", "email",
"groupware", "groupware",
"http 0.16.22", "http 0.16.23",
"http_proto", "http_proto",
"imap", "imap",
"jmap", "jmap",
@@ -4134,25 +4134,24 @@ checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3"
[[package]] [[package]]
name = "jieba-macros" name = "jieba-macros"
version = "0.10.3" version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34904340bc65749a9e9a02fcc7f3368e675427c18447b9bbe02df52c15c9a36a" checksum = "455f837e9d0255b68a712200db247c68fdad4941b72471b76bfa61c3b0c1f79f"
dependencies = [ dependencies = [
"phf_codegen", "phf_codegen",
] ]
[[package]] [[package]]
name = "jieba-rs" name = "jieba-rs"
version = "0.10.3" version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d" checksum = "b6a8bbb0f77ee810f0689a30b7cec56b875751ef4ec2e74fd995613dc52b3ae1"
dependencies = [ dependencies = [
"bytecount", "bytecount",
"cedarwood", "cedarwood",
"include-flate", "include-flate",
"jieba-macros", "jieba-macros",
"phf 0.13.1", "phf 0.13.1",
"regex",
"rustc-hash", "rustc-hash",
] ]
@@ -4212,7 +4211,7 @@ dependencies = [
[[package]] [[package]]
name = "jmap" name = "jmap"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"base64 0.23.1", "base64 0.23.1",
@@ -4236,7 +4235,7 @@ dependencies = [
"mail-parser", "mail-parser",
"nlp", "nlp",
"p256", "p256",
"rand 0.10.2", "rand 0.10.3",
"registry", "registry",
"reqwest 0.13.5", "reqwest 0.13.5",
"rkyv", "rkyv",
@@ -4294,7 +4293,7 @@ dependencies = [
[[package]] [[package]]
name = "jmap_proto" name = "jmap_proto"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"calcard", "calcard",
@@ -4699,9 +4698,9 @@ dependencies = [
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]] [[package]]
name = "lz4-sys" name = "lz4-sys"
@@ -4742,9 +4741,9 @@ dependencies = [
[[package]] [[package]]
name = "mail-auth" name = "mail-auth"
version = "0.13.2" version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11f19d98aac923fc5b7ee30c3509733a013ef546a226acb959b9202f5ca58f0" checksum = "8505122ba86e1f4adeb664196c1e787c3f29bb6e7c128e4a366d47d209911440"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"flate2", "flate2",
@@ -4757,7 +4756,7 @@ dependencies = [
"mail-parser", "mail-parser",
"memchr", "memchr",
"quick-xml 0.42.0", "quick-xml 0.42.0",
"rand 0.10.2", "rand 0.10.3",
"rkyv", "rkyv",
"rsa", "rsa",
"rustls-pki-types", "rustls-pki-types",
@@ -4800,7 +4799,7 @@ dependencies = [
[[package]] [[package]]
name = "managesieve" name = "managesieve"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"common", "common",
"compact_str", "compact_str",
@@ -4935,7 +4934,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
[[package]] [[package]]
name = "migration" name = "migration"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"common", "common",
"email", "email",
@@ -5066,7 +5065,7 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"rustversion", "rustversion",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -5131,7 +5130,7 @@ dependencies = [
"lru", "lru",
"mysql_common", "mysql_common",
"percent-encoding", "percent-encoding",
"rand 0.10.2", "rand 0.10.3",
"rustls", "rustls",
"serde", "serde",
"socket2 0.6.5", "socket2 0.6.5",
@@ -5206,14 +5205,14 @@ dependencies = [
[[package]] [[package]]
name = "nlp" name = "nlp"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"hashify", "hashify",
"jieba-rs", "jieba-rs",
"maplit", "maplit",
"psl", "psl",
"rand 0.10.2", "rand 0.10.3",
"rkyv", "rkyv",
"rust-stemmers", "rust-stemmers",
"serde", "serde",
@@ -6038,7 +6037,7 @@ dependencies = [
[[package]] [[package]]
name = "pop3" name = "pop3"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"common", "common",
"directory", "directory",
@@ -6082,7 +6081,7 @@ dependencies = [
"hmac 0.13.0", "hmac 0.13.0",
"md-5 0.11.0", "md-5 0.11.0",
"memchr", "memchr",
"rand 0.10.2", "rand 0.10.3",
"sha2 0.11.0", "sha2 0.11.0",
"stringprep", "stringprep",
] ]
@@ -6119,9 +6118,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppmd-rust" name = "ppmd-rust"
version = "1.4.1" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9219bcb9d7aca6b2f63c83cf100cf78bcd619ac46e6ecbd0dd90869a39345d" checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
@@ -6206,7 +6205,7 @@ dependencies = [
"proc-macro-error-attr3", "proc-macro-error-attr3",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -6260,7 +6259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"itertools 0.13.0", "itertools 0.14.0",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 2.0.119",
@@ -6287,9 +6286,9 @@ dependencies = [
[[package]] [[package]]
name = "psl" name = "psl"
version = "2.1.232" version = "2.1.235"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62834e308cc83aea5e30cd8c80b8aa82cdb104a3240c7f210d4f68d46e29f308" checksum = "8319b56ff38ca0522b4e1e40bfa2b5de7f62dc89fc1e9033eac365551ec58e0e"
dependencies = [ dependencies = [
"psl-types", "psl-types",
] ]
@@ -6317,7 +6316,7 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -6388,9 +6387,9 @@ dependencies = [
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.11" version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [ dependencies = [
"bytes", "bytes",
"cfg_aliases", "cfg_aliases",
@@ -6399,7 +6398,7 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2 0.5.10", "socket2 0.6.5",
"thiserror 2.0.20", "thiserror 2.0.20",
"tokio", "tokio",
"tracing", "tracing",
@@ -6408,16 +6407,16 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.17" version = "0.11.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"bytes", "bytes",
"fastbloom", "fastbloom",
"getrandom 0.4.3", "getrandom 0.4.3",
"lru-slab", "lru-slab",
"rand 0.10.2", "rand 0.10.3",
"rand_pcg", "rand_pcg",
"ring", "ring",
"rustc-hash", "rustc-hash",
@@ -6440,7 +6439,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2 0.5.10", "socket2 0.6.5",
"tracing", "tracing",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -6534,9 +6533,9 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.10.2" version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [ dependencies = [
"chacha20", "chacha20",
"getrandom 0.4.3", "getrandom 0.4.3",
@@ -6776,7 +6775,7 @@ dependencies = [
"num-bigint 0.5.1", "num-bigint 0.5.1",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rand 0.10.2", "rand 0.10.3",
"rustls", "rustls",
"rustls-native-certs", "rustls-native-certs",
"ryu", "ryu",
@@ -6800,11 +6799,10 @@ dependencies = [
[[package]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
dependencies = [ dependencies = [
"getrandom 0.2.17",
"libredox", "libredox",
"thiserror 2.0.20", "thiserror 2.0.20",
] ]
@@ -6826,7 +6824,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -6860,7 +6858,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]] [[package]]
name = "registry" name = "registry"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"hashify", "hashify",
@@ -7044,7 +7042,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -7225,9 +7223,9 @@ dependencies = [
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"errno", "errno",
@@ -7412,12 +7410,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"serde_derive_internals", "serde_derive_internals",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
name = "scim" name = "scim"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"base64 0.23.1", "base64 0.23.1",
@@ -7443,7 +7441,7 @@ dependencies = [
[[package]] [[package]]
name = "scim-proto" name = "scim-proto"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"hashify", "hashify",
"serde", "serde",
@@ -7580,7 +7578,7 @@ dependencies = [
"sha2 0.10.9", "sha2 0.10.9",
"sha3 0.10.9", "sha3 0.10.9",
"slh-dsa", "slh-dsa",
"thiserror 1.0.69", "thiserror 2.0.20",
"twofish", "twofish",
"typenum", "typenum",
"x25519-dalek", "x25519-dalek",
@@ -7624,7 +7622,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -7635,7 +7633,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -7671,7 +7669,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -7716,7 +7714,7 @@ dependencies = [
"darling 0.24.1", "darling 0.24.1",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -7764,12 +7762,12 @@ checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
name = "services" name = "services"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"aes-gcm 0.11.1", "aes-gcm 0.11.1",
"aho-corasick", "aho-corasick",
@@ -8084,7 +8082,7 @@ checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]] [[package]]
name = "smtp" name = "smtp"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"base64 0.23.1", "base64 0.23.1",
@@ -8099,7 +8097,7 @@ dependencies = [
"mail-builder 1.0.0", "mail-builder 1.0.0",
"mail-parser", "mail-parser",
"parking_lot", "parking_lot",
"rand 0.10.2", "rand 0.10.3",
"registry", "registry",
"reqwest 0.13.5", "reqwest 0.13.5",
"rkyv", "rkyv",
@@ -8175,7 +8173,7 @@ dependencies = [
[[package]] [[package]]
name = "spam-filter" name = "spam-filter"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"common", "common",
"compact_str", "compact_str",
@@ -8295,7 +8293,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]] [[package]]
name = "store" name = "store"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"arc-swap", "arc-swap",
@@ -8321,7 +8319,7 @@ dependencies = [
"parking_lot", "parking_lot",
"r2d2", "r2d2",
"radsort", "radsort",
"rand 0.10.2", "rand 0.10.3",
"rayon", "rayon",
"redis", "redis",
"registry", "registry",
@@ -8417,9 +8415,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.5" version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -8446,6 +8444,17 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "sysinfo" name = "sysinfo"
version = "0.37.2" version = "0.37.2"
@@ -8544,7 +8553,7 @@ dependencies = [
[[package]] [[package]]
name = "tests" name = "tests"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"aws-lc-rs", "aws-lc-rs",
@@ -8566,7 +8575,7 @@ dependencies = [
"form_urlencoded", "form_urlencoded",
"futures", "futures",
"groupware", "groupware",
"http 0.16.22", "http 0.16.23",
"http_proto", "http_proto",
"hyper", "hyper",
"hyper-util", "hyper-util",
@@ -8652,7 +8661,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -8790,7 +8799,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -8812,7 +8821,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"postgres-protocol", "postgres-protocol",
"postgres-types", "postgres-types",
"rand 0.10.2", "rand 0.10.3",
"socket2 0.6.5", "socket2 0.6.5",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -8997,7 +9006,7 @@ dependencies = [
"constant_time_eq", "constant_time_eq",
"hmac 0.13.0", "hmac 0.13.0",
"percent-encoding", "percent-encoding",
"rand 0.10.2", "rand 0.10.3",
"serde", "serde",
"sha1 0.11.0", "sha1 0.11.0",
"sha2 0.11.0", "sha2 0.11.0",
@@ -9136,7 +9145,7 @@ dependencies = [
[[package]] [[package]]
name = "trc" name = "trc"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"base64 0.23.1", "base64 0.23.1",
@@ -9195,7 +9204,7 @@ dependencies = [
"http 1.5.0", "http 1.5.0",
"httparse", "httparse",
"log", "log",
"rand 0.10.2", "rand 0.10.3",
"sha1 0.11.0", "sha1 0.11.0",
"thiserror 2.0.20", "thiserror 2.0.20",
] ]
@@ -9245,7 +9254,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "types" name = "types"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"blake3", "blake3",
"compact_str", "compact_str",
@@ -9297,9 +9306,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]] [[package]]
name = "unicode-normalization" name = "unicode-normalization"
@@ -9414,7 +9423,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "utils" name = "utils"
version = "0.16.22" version = "0.16.23"
dependencies = [ dependencies = [
"ahash", "ahash",
"arcstr", "arcstr",
@@ -9616,7 +9625,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -10125,14 +10134,14 @@ dependencies = [
[[package]] [[package]]
name = "yoke-derive" name = "yoke-derive"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.6",
"synstructure", "synstructure 0.14.0",
] ]
[[package]] [[package]]
@@ -10655,14 +10664,14 @@ dependencies = [
[[package]] [[package]]
name = "zerofrom-derive" name = "zerofrom-derive"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.119", "syn 3.0.6",
"synstructure", "synstructure 0.14.0",
] ]
[[package]] [[package]]
@@ -10717,7 +10726,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 3.0.5", "syn 3.0.6",
] ]
[[package]] [[package]]
@@ -10749,9 +10758,9 @@ dependencies = [
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.7" version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]] [[package]]
name = "zmij" name = "zmij"
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "common" name = "common"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"
+147 -32
View File
@@ -11,7 +11,7 @@ use crate::{
auth::{ auth::{
AccessToken, AuthRequest, DomainCache, AccessToken, AuthRequest, DomainCache,
credential::{ApiKey, AppPassword}, credential::{ApiKey, AppPassword},
oauth::GrantType, oauth::{GrantType, token::TOKEN_HEADER},
}, },
}; };
use base64::{Engine, engine::general_purpose}; use base64::{Engine, engine::general_purpose};
@@ -23,7 +23,8 @@ use registry::schema::{
enums::Permission, enums::Permission,
structs::{self, Credential}, structs::{self, Credential},
}; };
use std::{net::IpAddr, sync::Arc}; use serde::Deserialize;
use std::{borrow::Cow, net::IpAddr, sync::Arc};
use store::write::now; use store::write::now;
use trc::AddContext; use trc::AddContext;
@@ -321,19 +322,12 @@ impl Server {
// Obtain external directory, if any. When no username is supplied // Obtain external directory, if any. When no username is supplied
// (e.g. HTTP bearer auth), peek at the JWT claims to find the // (e.g. HTTP bearer auth), peek at the JWT claims to find the
// user's domain so per-domain OIDC directories are reachable. // user's domain so per-domain OIDC directories are reachable.
let directory = if let Some(username) = username.as_deref().map(UsernameParts::new) let directory = match username.as_deref().map(UsernameParts::new) {
{ Some(username) => match username.auth_as().domain() {
if let Some(domain_name) = username.auth_as().domain() { Some(domain_name) => self.get_directory_for_domain(domain_name).await?,
self.get_directory_for_domain(domain_name).await? None => self.get_directory_for_token(token).await?,
} else if let Some(domain_name) = extract_jwt_domain(token) { },
self.get_directory_for_domain(&domain_name).await? None => self.get_directory_for_token(token).await?,
} else {
self.get_default_directory()
}
} else if let Some(domain_name) = extract_jwt_domain(token) {
self.get_directory_for_domain(&domain_name).await?
} else {
self.get_default_directory()
}; };
// Try external directory authentication first if supported, then fallback to internal OAuth. // Try external directory authentication first if supported, then fallback to internal OAuth.
@@ -563,6 +557,29 @@ impl Server {
}) })
} }
async fn get_directory_for_token(&self, token: &str) -> trc::Result<Option<&Arc<Directory>>> {
let Some(payload) = JwtClaims::decode_payload(token) else {
return Ok(self.get_default_directory());
};
let Some(claims) = JwtClaims::parse(&payload) else {
return Ok(self.get_default_directory());
};
match (claims.domain(), claims.iss.as_deref()) {
(Some(domain_name), _) => self.get_directory_for_domain(domain_name).await,
(None, Some(issuer)) => Ok(self
.get_directory_for_issuer(issuer)
.or_else(|| self.get_default_directory())),
(None, None) => Ok(self.get_default_directory()),
}
}
/// inbuxa: DIR-2: a token naming no address gets the server default, so
/// no directory is chosen by issuer.
fn get_directory_for_issuer(&self, _issuer: &str) -> Option<&Arc<Directory>> {
None
}
/// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A /// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A
/// `directoryId` naming no directory the server built is unavailable, /// `directoryId` naming no directory the server built is unavailable,
/// never the internal directory. /// never the internal directory.
@@ -622,25 +639,50 @@ pub fn unavailable_directory() -> &'static Arc<Directory> {
}) })
} }
fn extract_jwt_domain(token: &str) -> Option<String> { #[derive(Deserialize)]
let mut parts = token.split('.'); struct JwtClaims<'x> {
let _header = parts.next()?; #[serde(borrow, default)]
let payload = parts.next()?; iss: Option<Cow<'x, str>>,
let _signature = parts.next()?; #[serde(borrow, default)]
if parts.next().is_some() { email: Option<Cow<'x, str>>,
return None; #[serde(borrow, default)]
} preferred_username: Option<Cow<'x, str>>,
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?; #[serde(borrow, default)]
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; upn: Option<Cow<'x, str>>,
for claim in ["email", "preferred_username", "upn"] { }
if let Some(val) = claims.get(claim).and_then(|v| v.as_str())
&& let Some((_, domain)) = val.rsplit_once('@') impl<'x> JwtClaims<'x> {
&& !domain.is_empty() fn decode_payload(token: &str) -> Option<Vec<u8>> {
{ if token.starts_with(TOKEN_HEADER) {
return Some(domain.to_ascii_lowercase()); return None;
} }
let mut parts = token.split('.');
let _header = parts.next()?;
let payload = parts.next()?;
let _signature = parts.next()?;
if parts.next().is_some() {
return None;
}
general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()
}
fn parse(payload: &'x [u8]) -> Option<Self> {
serde_json::from_slice(payload).ok()
}
fn domain(&self) -> Option<&str> {
[&self.email, &self.preferred_username, &self.upn]
.into_iter()
.flatten()
.find_map(|claim| {
claim
.rsplit_once('@')
.map(|(_, domain)| domain)
.filter(|domain| !domain.is_empty())
})
} }
None
} }
impl UsernameParts { impl UsernameParts {
@@ -738,3 +780,76 @@ impl AuthRequest {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn jwt(payload: &str) -> String {
format!(
"eyJhbGciOiJSUzI1NiJ9.{}.c2lnbmF0dXJl",
general_purpose::URL_SAFE_NO_PAD.encode(payload)
)
}
fn hints(token: &str) -> Option<(Option<String>, Option<String>)> {
let payload = JwtClaims::decode_payload(token)?;
let claims = JwtClaims::parse(&payload)?;
Some((
claims.domain().map(str::to_string),
claims.iss.as_deref().map(str::to_string),
))
}
#[test]
fn jwt_claims_are_extracted() {
for (payload, domain, issuer) in [
(
r#"{"iss":"https://idp.example.org","email":"[email protected]"}"#,
Some("Example.ORG"),
Some("https://idp.example.org"),
),
(
r#"{"preferred_username":"[email protected]","upn":"[email protected]"}"#,
Some("example.net"),
None,
),
(
r#"{"email":"broken@","upn":"[email protected]"}"#,
Some("example.com"),
None,
),
(
r#"{"iss":"https://idp.example.org","sub":"5db2d1b6","aud":["a","b"],"scope":"openid"}"#,
None,
Some("https://idp.example.org"),
),
(r#"{"sub":"5db2d1b6"}"#, None, None),
(r#"{"email":"[email protected]"}"#, Some("example.net"), None),
] {
assert_eq!(
hints(&jwt(payload)),
Some((domain.map(str::to_string), issuer.map(str::to_string))),
"Unexpected claims for {payload}"
);
}
}
#[test]
fn non_jwt_tokens_are_ignored() {
for token in [
"sw1.eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLm9yZyJ9",
"sw1.eyJhbGciOiJSUzI1NiJ9",
"opaque-token",
"one.two",
"one.two.three.four",
"",
] {
assert!(
JwtClaims::decode_payload(token).is_none(),
"Token {token:?} was parsed as a JWT"
);
}
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub const FAILED_TO_DECODE_TOKEN: &str = concat!(
"the Authentication object." "the Authentication object."
); );
const TOKEN_HEADER: &str = "sw1."; pub(crate) const TOKEN_HEADER: &str = "sw1.";
const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1"; const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1";
const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000 const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::server::tls::build_self_signed_cert; use super::server::tls::build_self_signed_cert;
@@ -214,6 +214,7 @@ impl Resolvers {
let config_dnssec = resolver_config.clone(); let config_dnssec = resolver_config.clone();
let mut opts_dnssec = opts.clone(); let mut opts_dnssec = opts.clone();
opts_dnssec.validate = true; opts_dnssec.validate = true;
opts_dnssec.num_concurrent_reqs = 1;
let dnssec = DnssecResolver { let dnssec = DnssecResolver {
resolver: TokioResolver::builder_with_config( resolver: TokioResolver::builder_with_config(
@@ -343,6 +344,7 @@ impl Default for Resolvers {
let config_dnssec = config.clone(); let config_dnssec = config.clone();
let mut opts_dnssec = opts.clone(); let mut opts_dnssec = opts.clone();
opts_dnssec.validate = true; opts_dnssec.validate = true;
opts_dnssec.num_concurrent_reqs = 1;
Self { Self {
dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"), dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"),
+2 -2
View File
@@ -583,10 +583,10 @@ impl Metrics {
pub async fn parse(bp: &mut Bootstrap) -> Self { pub async fn parse(bp: &mut Bootstrap) -> Self {
let metrics = bp.setting_infallible::<structs::Metrics>().await; let metrics = bp.setting_infallible::<structs::Metrics>().await;
let resource = Resource::builder() let resource = Resource::builder()
.with_service_name("stalwart") .with_service_name("inbuxa")
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!())) .with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
.build(); .build();
let instrumentation = InstrumentationScope::builder("stalwart") let instrumentation = InstrumentationScope::builder("inbuxa")
.with_version(types::brand_version_full!()) .with_version(types::brand_version_full!())
.build(); .build();
+7
View File
@@ -23,6 +23,13 @@ pub(crate) fn fn_is_number(v: Vec<Variable>) -> Variable {
matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into() matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into()
} }
pub(crate) fn fn_bit_and(v: Vec<Variable>) -> Variable {
match (v[0].to_integer(), v[1].to_integer()) {
(Some(lhs), Some(rhs)) => Variable::Integer(lhs & rhs),
_ => Variable::Integer(0),
}
}
pub(crate) fn fn_is_ip_addr(v: Vec<Variable>) -> Variable { pub(crate) fn fn_is_ip_addr(v: Vec<Variable>) -> Variable {
v[0].to_string() v[0].to_string()
.as_str() .as_str()
+1
View File
@@ -46,6 +46,7 @@ pub(crate) const FUNCTIONS: &[(&str, fn(Vec<Variable>) -> Variable, u32)] = &[
("email_part", email::fn_email_part, 2), ("email_part", email::fn_email_part, 2),
("is_empty", misc::fn_is_empty, 1), ("is_empty", misc::fn_is_empty, 1),
("is_number", misc::fn_is_number, 1), ("is_number", misc::fn_is_number, 1),
("bit_and", misc::fn_bit_and, 2),
("is_ip_addr", misc::fn_is_ip_addr, 1), ("is_ip_addr", misc::fn_is_ip_addr, 1),
("is_ipv4_addr", misc::fn_is_ipv4_addr, 1), ("is_ipv4_addr", misc::fn_is_ipv4_addr, 1),
("is_ipv6_addr", misc::fn_is_ipv6_addr, 1), ("is_ipv6_addr", misc::fn_is_ipv6_addr, 1),
+231 -115
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{Server, manager::fetch_resource}; use crate::{Server, manager::fetch_resource};
@@ -11,8 +13,11 @@ use registry::schema::{enums::CompressionAlgo, structs::Application};
use std::{ use std::{
borrow::Cow, borrow::Cow,
io::{self, Cursor, Read}, io::{self, Cursor, Read},
path::PathBuf, path::{Path, PathBuf},
sync::Arc, sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
time::Duration, time::Duration,
}; };
use store::{ use store::{
@@ -36,16 +41,18 @@ enum IndexEdit<'x> {
pub struct WebApplications { pub struct WebApplications {
applications: ArcSwap<Vec<WebApplicationManager>>, applications: ArcSwap<Vec<WebApplicationManager>>,
routes: ArcSwap<AHashMap<String, Arc<AppRoutes>>>, routes: ArcSwap<AHashMap<String, Arc<AppRoutes>>>,
generation: AtomicU64,
} }
pub struct AppRoutes { pub struct AppRoutes {
resources: AHashMap<String, Resource<PathBuf>>, resources: AHashMap<String, Resource<PathBuf>>,
oauth_client_id_meta: Option<String>, oauth_client_id_meta: Option<String>,
_bundle_dir: TempDir,
} }
#[derive(Clone)] #[derive(Clone)]
pub struct WebApplicationManager { pub struct WebApplicationManager {
bundle_path: TempDir, base_path: PathBuf,
prefixes: Vec<String>, prefixes: Vec<String>,
description: String, description: String,
url: String, url: String,
@@ -79,6 +86,7 @@ impl WebApplications {
Self { Self {
applications: ArcSwap::new(Arc::new(Vec::new())), applications: ArcSwap::new(Arc::new(Vec::new())),
routes: ArcSwap::new(Arc::new(AHashMap::new())), routes: ArcSwap::new(Arc::new(AHashMap::new())),
generation: AtomicU64::new(0),
} }
} }
@@ -128,48 +136,55 @@ impl WebApplications {
} }
pub async fn unpack_all(&self, server: &Server, update: bool) { pub async fn unpack_all(&self, server: &Server, update: bool) {
let mut routes = AHashMap::new(); let previous = self.routes.load_full();
let sweep_orphans = previous.is_empty();
let mut routes = AHashMap::with_capacity(previous.len());
for app in self.applications.load().as_ref() { for app in self.applications.load().as_ref() {
if update && let Err(err) = app.delete(server).await { match app
trc::event!( .unpack(server, self.next_generation(), update, sweep_orphans)
Resource(trc::ResourceEvent::Error), .await
Reason = err, {
Url = app.url.clone(), Ok(app_routes) => {
Details = format!( let app_routes = Arc::new(app_routes);
"Failed to delete application bundle for prefixes: {}",
app.prefixes.join(", ")
)
);
}
match app.unpack(server).await {
Ok(resources) => {
let app_routes = Arc::new(AppRoutes {
resources,
oauth_client_id_meta: app
.oauth_client_id
.as_deref()
.map(oauth_client_id_meta),
});
for prefix in &app.prefixes { for prefix in &app.prefixes {
routes.insert(prefix.clone(), app_routes.clone()); routes.insert(prefix.clone(), app_routes.clone());
} }
} }
Err(err) => { Err(err) => {
let mut is_retained = false;
for prefix in &app.prefixes {
if let Some(app_routes) = previous.get(prefix) {
routes.insert(prefix.clone(), app_routes.clone());
is_retained = true;
}
}
trc::event!( trc::event!(
Resource(trc::ResourceEvent::Error), Resource(trc::ResourceEvent::Error),
Reason = err, Reason = err,
Url = app.url.clone(), Url = app.url.clone(),
Details = format!( Details = format!(
"Failed to unpack application for prefixes: {}", "Failed to unpack application for prefixes: {}, {}",
app.prefixes.join(", ") app.prefixes.join(", "),
if is_retained {
"the previously unpacked bundle remains in service"
} else {
"no bundle is available to serve"
}
) )
); );
} }
} }
} }
self.routes.store(Arc::new(routes)); self.routes.store(Arc::new(routes));
} }
fn next_generation(&self) -> u64 {
self.generation.fetch_add(1, Ordering::Relaxed)
}
} }
impl WebApplicationManager { impl WebApplicationManager {
@@ -182,7 +197,7 @@ impl WebApplicationManager {
.join(app.id.id().to_string()); .join(app.id.id().to_string());
Self { Self {
bundle_path: TempDir::new(base_path), base_path,
blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()), blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()),
url: app.object.resource_url, url: app.object.resource_url,
description: app.object.description, description: app.object.description,
@@ -202,82 +217,43 @@ impl WebApplicationManager {
} }
} }
async fn unpack(&self, server: &Server) -> trc::Result<AHashMap<String, Resource<PathBuf>>> { async fn unpack(
// Delete any existing bundles &self,
self.bundle_path.clean().await.map_err(unpack_error)?; server: &Server,
generation: u64,
// Obtain application bundle force_refresh: bool,
let bundle = if let Some(bundle) = server sweep_orphans: bool,
.blob_store() ) -> trc::Result<AppRoutes> {
.get_blob(self.blob_key.as_slice(), 0..usize::MAX) let cached = if force_refresh {
.await? None
{
bundle
} else { } else {
// Fetch app bundle
let resource = fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
.await
.map_err(|err| {
trc::ResourceEvent::Error
.caused_by(trc::location!())
.ctx(Key::Url, self.url.clone())
.reason(err)
.details("Failed to fetch application bundle")
})?;
// Store in blob store for future use
server server
.blob_store() .blob_store()
.put_blob(self.blob_key.as_slice(), &resource, CompressionAlgo::None) .get_blob(self.blob_key.as_slice(), 0..usize::MAX)
.await .await?
.caused_by(trc::location!())?; };
let is_cached = cached.is_some();
// Schedule expiration let bundle = match cached {
let mut batch = BatchBuilder::new(); Some(bundle) => bundle,
batch None => self.fetch().await?,
.set(
BlobOp::Link {
hash: self.blob_key.clone(),
to: BlobLink::Temporary {
until: now() + self.expiry,
},
},
vec![],
)
.set(
BlobOp::Commit {
hash: self.blob_key.clone(),
},
Vec::new(),
);
server
.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
trc::event!(
Resource(trc::ResourceEvent::ApplicationUpdated),
Url = self.url.clone(),
Details = self.description.clone(),
);
resource
}; };
let staging = TempDir::new(self.base_path.join(format!("{:x}-{generation:x}", now())));
staging.create().await.map_err(unpack_error)?;
let url = self.url.clone(); let url = self.url.clone();
let bundle_path = self.bundle_path.path.clone(); let bundle_path = staging.path.clone();
let routes = tokio::task::spawn_blocking(move || -> trc::Result<_> { let (resources, bundle) = tokio::task::spawn_blocking(move || -> trc::Result<_> {
let mut bundle = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| { let mut archive = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
trc::ResourceEvent::Error trc::ResourceEvent::Error
.caused_by(trc::location!()) .caused_by(trc::location!())
.reason(err) .reason(err)
.ctx(Key::Url, url.clone()) .ctx(Key::Url, url.clone())
.details("Failed to decompress application bundle") .details("Failed to decompress application bundle")
})?; })?;
let mut routes = AHashMap::new(); let mut resources = AHashMap::with_capacity(archive.len());
for i in 0..bundle.len() { for i in 0..archive.len() {
let mut file = bundle.by_index(i).map_err(|err| { let mut file = archive.by_index(i).map_err(|err| {
trc::ResourceEvent::Error trc::ResourceEvent::Error
.caused_by(trc::location!()) .caused_by(trc::location!())
.reason(err) .reason(err)
@@ -315,9 +291,9 @@ impl WebApplicationManager {
contents: path, contents: path,
}; };
routes.insert(file_name, resource); resources.insert(file_name, resource);
} }
Ok(routes) Ok((resources, archive.into_inner().into_inner()))
}) })
.await .await
.map_err(|err| { .map_err(|err| {
@@ -327,21 +303,81 @@ impl WebApplicationManager {
.details("Bundle unpack task panicked") .details("Bundle unpack task panicked")
})??; })??;
if !is_cached && let Err(err) = self.cache(server, &bundle).await {
trc::event!(
Resource(trc::ResourceEvent::Error),
Reason = err,
Url = self.url.clone(),
Details = "Failed to cache application bundle, it will be downloaded again"
);
}
if sweep_orphans {
remove_siblings(&self.base_path, &staging.path).await;
}
trc::event!( trc::event!(
Resource(trc::ResourceEvent::ApplicationUnpacked), Resource(trc::ResourceEvent::ApplicationUnpacked),
Url = self.url.clone(), Url = self.url.clone(),
Path = self.bundle_path.path.to_string_lossy().into_owned(), Path = staging.path.to_string_lossy().into_owned(),
); );
Ok(routes) Ok(AppRoutes {
resources,
oauth_client_id_meta: self.oauth_client_id.as_deref().map(oauth_client_id_meta),
_bundle_dir: staging,
})
} }
async fn delete(&self, server: &Server) -> trc::Result<()> { async fn fetch(&self) -> trc::Result<Vec<u8>> {
fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
.await
.map_err(|err| {
trc::ResourceEvent::Error
.caused_by(trc::location!())
.ctx(Key::Url, self.url.clone())
.reason(err)
.details("Failed to fetch application bundle")
})
}
async fn cache(&self, server: &Server, bundle: &[u8]) -> trc::Result<()> {
server server
.blob_store() .blob_store()
.delete_blob(self.blob_key.as_slice()) .put_blob(self.blob_key.as_slice(), bundle, CompressionAlgo::None)
.await .await
.map(|_| ()) .caused_by(trc::location!())?;
let mut batch = BatchBuilder::new();
batch
.set(
BlobOp::Link {
hash: self.blob_key.clone(),
to: BlobLink::Temporary {
until: now() + self.expiry,
},
},
vec![],
)
.set(
BlobOp::Commit {
hash: self.blob_key.clone(),
},
Vec::new(),
);
server
.store()
.write(batch.build_all())
.await
.caused_by(trc::location!())?;
trc::event!(
Resource(trc::ResourceEvent::ApplicationUpdated),
Url = self.url.clone(),
Details = self.description.clone(),
);
Ok(())
} }
pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> { pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> {
@@ -361,7 +397,6 @@ impl Resource<Vec<u8>> {
} }
} }
#[derive(Clone)]
pub struct TempDir { pub struct TempDir {
pub path: PathBuf, pub path: PathBuf,
} }
@@ -371,11 +406,36 @@ impl TempDir {
TempDir { path } TempDir { path }
} }
pub async fn clean(&self) -> io::Result<()> { pub async fn create(&self) -> io::Result<()> {
if tokio::fs::metadata(&self.path).await.is_ok() { if tokio::fs::metadata(&self.path).await.is_ok() {
let _ = tokio::fs::remove_dir_all(&self.path).await; let _ = tokio::fs::remove_dir_all(&self.path).await;
} }
tokio::fs::create_dir(&self.path).await tokio::fs::create_dir_all(&self.path).await
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
async fn remove_siblings(base_path: &Path, keep: &Path) {
let Ok(mut entries) = tokio::fs::read_dir(base_path).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path == keep {
continue;
}
if matches!(entry.file_type().await, Ok(file_type) if file_type.is_dir()) {
let _ = tokio::fs::remove_dir_all(&path).await;
} else {
let _ = tokio::fs::remove_file(&path).await;
}
} }
} }
@@ -385,12 +445,6 @@ fn unpack_error(err: std::io::Error) -> trc::Error {
.details("Failed to unpack application bundle") .details("Failed to unpack application bundle")
} }
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
impl Default for WebApplications { impl Default for WebApplications {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new()
@@ -521,9 +575,9 @@ mod tests {
); );
} }
async fn fixture(name: &str, client_id: Option<&str>) -> (WebApplications, TempDir) { async fn fixture(name: &str, client_id: Option<&str>) -> WebApplications {
let dir = TempDir::new(std::env::temp_dir().join(format!("stalwart-app-{name}"))); let dir = TempDir::new(std::env::temp_dir().join(format!("inbuxa-app-{name}")));
dir.clean().await.unwrap(); dir.create().await.unwrap();
tokio::fs::write(dir.path.join("index.html"), INDEX) tokio::fs::write(dir.path.join("index.html"), INDEX)
.await .await
.unwrap(); .unwrap();
@@ -544,6 +598,7 @@ mod tests {
let routes = Arc::new(AppRoutes { let routes = Arc::new(AppRoutes {
resources, resources,
oauth_client_id_meta: client_id.map(oauth_client_id_meta), oauth_client_id_meta: client_id.map(oauth_client_id_meta),
_bundle_dir: dir,
}); });
let mut map = AHashMap::new(); let mut map = AHashMap::new();
@@ -553,7 +608,7 @@ mod tests {
let apps = WebApplications::new(); let apps = WebApplications::new();
apps.routes.store(Arc::new(map)); apps.routes.store(Arc::new(map));
(apps, dir) apps
} }
async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String { async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String {
@@ -565,7 +620,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn serving_index_injects_the_prefix_and_client_id() { async fn serving_index_injects_the_prefix_and_client_id() {
let (apps, _dir) = fixture("serve-configured", Some("pocket-id-client")).await; let apps = fixture("serve-configured", Some("pocket-id-client")).await;
let html = serve_html(&apps, "admin", "index.html").await; let html = serve_html(&apps, "admin", "index.html").await;
assert!(html.contains("<base href=\"/admin/\" />"), "{html}"); assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
@@ -584,7 +639,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn unknown_paths_fall_back_to_a_rewritten_index() { async fn unknown_paths_fall_back_to_a_rewritten_index() {
let (apps, _dir) = fixture("serve-fallback", Some("pocket-id-client")).await; let apps = fixture("serve-fallback", Some("pocket-id-client")).await;
let html = serve_html(&apps, "admin", "settings/directory").await; let html = serve_html(&apps, "admin", "settings/directory").await;
assert!(html.contains("<base href=\"/admin/\" />"), "{html}"); assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
@@ -596,7 +651,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn assets_and_unknown_prefixes_are_untouched() { async fn assets_and_unknown_prefixes_are_untouched() {
let (apps, _dir) = fixture("serve-assets", Some("pocket-id-client")).await; let apps = fixture("serve-assets", Some("pocket-id-client")).await;
let served = apps.serve("admin", "app.js").await.unwrap().unwrap(); let served = apps.serve("admin", "app.js").await.unwrap().unwrap();
assert_eq!(served.resource.contents, b"export const x = 1;\n"); assert_eq!(served.resource.contents, b"export const x = 1;\n");
@@ -608,7 +663,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn serving_index_without_a_client_id_keeps_the_placeholder() { async fn serving_index_without_a_client_id_keeps_the_placeholder() {
let (apps, _dir) = fixture("serve-unconfigured", None).await; let apps = fixture("serve-unconfigured", None).await;
let html = serve_html(&apps, "admin", "index.html").await; let html = serve_html(&apps, "admin", "index.html").await;
assert!(html.contains("<base href=\"/admin/\" />"), "{html}"); assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
@@ -624,4 +679,65 @@ mod tests {
assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes()); assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes());
} }
#[tokio::test]
async fn missing_parent_directories_are_created() {
let base = std::env::temp_dir().join("inbuxa-app-nested");
let _ = tokio::fs::remove_dir_all(&base).await;
let dir = TempDir::new(base.join("webui").join("0"));
dir.create().await.unwrap();
assert!(tokio::fs::metadata(&dir.path).await.is_ok());
drop(dir);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[tokio::test]
async fn dropping_the_routes_removes_the_bundle_directory() {
let apps = fixture("drop-guard", None).await;
let path = apps
.routes
.load()
.get("admin")
.unwrap()
._bundle_dir
.path
.clone();
assert!(tokio::fs::metadata(&path).await.is_ok());
apps.routes.store(Arc::new(AHashMap::new()));
assert!(tokio::fs::metadata(&path).await.is_err());
}
#[tokio::test]
async fn sweeping_orphans_spares_the_current_generation() {
let base = std::env::temp_dir().join("inbuxa-app-sweep");
let _ = tokio::fs::remove_dir_all(&base).await;
let current = TempDir::new(base.join("1"));
current.create().await.unwrap();
let orphan = base.join("0");
tokio::fs::create_dir_all(&orphan).await.unwrap();
let stray = base.join("webui.zip");
tokio::fs::write(&stray, b"not a bundle").await.unwrap();
remove_siblings(&base, &current.path).await;
assert!(tokio::fs::metadata(&current.path).await.is_ok());
assert!(tokio::fs::metadata(&orphan).await.is_err());
assert!(tokio::fs::metadata(&stray).await.is_err());
drop(current);
let _ = tokio::fs::remove_dir_all(&base).await;
}
#[test]
fn generations_never_repeat() {
let apps = WebApplications::new();
assert_ne!(apps.next_generation(), apps.next_generation());
}
} }
+2 -2
View File
@@ -298,7 +298,7 @@ mod tests {
fn web_interface() -> Application { fn web_interface() -> Application {
Application { Application {
description: "Stalwart Web Interface".to_string(), description: "INBUXA Web Interface".to_string(),
enabled: true, enabled: true,
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]), url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
..Default::default() ..Default::default()
@@ -312,7 +312,7 @@ mod tests {
clients, clients,
vec![FirstPartyClient { vec![FirstPartyClient {
client_id: WEB_INTERFACE_CLIENT_ID.to_string(), client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
description: "Stalwart Web Interface (served by this server)".to_string(), description: "INBUXA Web Interface (served by this server)".to_string(),
redirect_uris: vec![ redirect_uris: vec![
"https://mail.example.org/admin/oauth/callback".to_string(), "https://mail.example.org/admin/oauth/callback".to_string(),
"https://mail.example.org/account/oauth/callback".to_string(), "https://mail.example.org/account/oauth/callback".to_string(),
+55 -12
View File
@@ -98,7 +98,38 @@ impl AcmeRequestBuilder {
reuse_key_pem: Option<String>, reuse_key_pem: Option<String>,
dns_parameters: Option<AcmeDnsParameters>, dns_parameters: Option<AcmeDnsParameters>,
) -> AcmeResult<PemCert> { ) -> AcmeResult<PemCert> {
let mut params = CertificateParams::new(domains.clone()).map_err(|err| { let mut published = BTreeSet::new();
let result = self
.run_order(
server,
&domains,
reuse_key_pem,
dns_parameters.as_ref(),
&mut published,
)
.await;
if let Some(dns_parameters) = &dns_parameters {
for (zone, challenge_name) in published {
let _ = dns_parameters
.updater
.delete_rrset(&zone, &challenge_name, dns_update::DnsRecordType::TXT)
.await;
}
}
result
}
async fn run_order(
&self,
server: &Server,
domains: &[String],
reuse_key_pem: Option<String>,
dns_parameters: Option<&AcmeDnsParameters>,
published: &mut BTreeSet<(String, String)>,
) -> AcmeResult<PemCert> {
let mut params = CertificateParams::new(domains.to_vec()).map_err(|err| {
AcmeError::Crypto(format!("Failed to create certificate params: {}", err)) AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
})?; })?;
params.distinguished_name = DistinguishedName::new(); params.distinguished_name = DistinguishedName::new();
@@ -110,7 +141,7 @@ impl AcmeRequestBuilder {
AcmeError::Crypto(format!("Failed to generate key pair: {}", err)) AcmeError::Crypto(format!("Failed to generate key pair: {}", err))
})?, })?,
}; };
let response = self.new_order(domains.clone()).await?; let response = self.new_order(domains.to_vec()).await?;
let order_url = response.location; let order_url = response.location;
let mut order = response.body; let mut order = response.body;
let mut retry_after = None; let mut retry_after = None;
@@ -119,7 +150,7 @@ impl AcmeRequestBuilder {
Acme(AcmeEvent::OrderStart), Acme(AcmeEvent::OrderStart),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Details = order_url.to_string(), Details = order_url.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
Type = self.challenge.as_str(), Type = self.challenge.as_str(),
); );
@@ -128,19 +159,20 @@ impl AcmeRequestBuilder {
OrderStatus::Pending => { OrderStatus::Pending => {
if matches!(self.challenge, ChallengeType::Dns01) { if matches!(self.challenge, ChallengeType::Dns01) {
for url in &order.authorizations { for url in &order.authorizations {
self.authorize(server, url, dns_parameters.as_ref()).await?; self.authorize(server, url, dns_parameters, Some(published))
.await?;
} }
} else { } else {
let auth_futures = order let auth_futures = order
.authorizations .authorizations
.iter() .iter()
.map(|url| self.authorize(server, url, dns_parameters.as_ref())); .map(|url| self.authorize(server, url, dns_parameters, None));
try_join_all(auth_futures).await?; try_join_all(auth_futures).await?;
} }
trc::event!( trc::event!(
Acme(AcmeEvent::AuthCompleted), Acme(AcmeEvent::AuthCompleted),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
); );
let response = self.order(&order_url).await?; let response = self.order(&order_url).await?;
order = response.body; order = response.body;
@@ -151,7 +183,7 @@ impl AcmeRequestBuilder {
trc::event!( trc::event!(
Acme(AcmeEvent::OrderProcessing), Acme(AcmeEvent::OrderProcessing),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
Total = i, Total = i,
); );
@@ -179,7 +211,7 @@ impl AcmeRequestBuilder {
trc::event!( trc::event!(
Acme(AcmeEvent::OrderReady), Acme(AcmeEvent::OrderReady),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
); );
let csr = params.serialize_request(&key_pair).map_err(|err| { let csr = params.serialize_request(&key_pair).map_err(|err| {
@@ -192,10 +224,10 @@ impl AcmeRequestBuilder {
trc::event!( trc::event!(
Acme(AcmeEvent::OrderValid), Acme(AcmeEvent::OrderValid),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
); );
let certificate = self.select_certificate(&domains, certificate).await?; let certificate = self.select_certificate(domains, certificate).await?;
return Ok(PemCert { return Ok(PemCert {
certificate, certificate,
@@ -213,7 +245,7 @@ impl AcmeRequestBuilder {
Acme(AcmeEvent::OrderInvalid), Acme(AcmeEvent::OrderInvalid),
Url = self.directory.new_order.to_string(), Url = self.directory.new_order.to_string(),
Details = order_url.to_string(), Details = order_url.to_string(),
Hostname = domains.as_slice(), Hostname = domains,
Reason = reason.clone(), Reason = reason.clone(),
); );
@@ -228,6 +260,7 @@ impl AcmeRequestBuilder {
server: &Server, server: &Server,
url: &String, url: &String,
dns_parameters: Option<&AcmeDnsParameters>, dns_parameters: Option<&AcmeDnsParameters>,
published: Option<&mut BTreeSet<(String, String)>>,
) -> AcmeResult<()> { ) -> AcmeResult<()> {
let response = self let response = self
.auth(url) .auth(url)
@@ -289,7 +322,12 @@ impl AcmeRequestBuilder {
.await?; .await?;
} }
ChallengeType::Dns01 => { ChallengeType::Dns01 => {
let dns_parameters = dns_parameters.unwrap(); let Some(dns_parameters) = dns_parameters else {
return Err(AcmeError::Invalid(
"DNS-01 challenge requested but a DNS provider was not configured"
.to_string(),
));
};
let domain = domain.strip_prefix("*.").unwrap_or(&domain); let domain = domain.strip_prefix("*.").unwrap_or(&domain);
let zone = dns_parameters let zone = dns_parameters
@@ -310,6 +348,11 @@ impl AcmeRequestBuilder {
) )
.await .await
.map_err(AcmeError::Dns)?; .map_err(AcmeError::Dns)?;
if let Some(published) = published {
published.insert((zone.to_string(), challenge_name.clone()));
}
dns_parameters dns_parameters
.updater .updater
.wait_for_txt_propagation(&challenge_name, zone, &proof) .wait_for_txt_propagation(&challenge_name, zone, &proof)
+30
View File
@@ -1150,6 +1150,36 @@ impl DnsUpdater {
Ok(()) Ok(())
} }
pub async fn delete_rrset(
&self,
origin: &str,
name: &str,
record_type: DnsRecordType,
) -> Result<(), String> {
if let Err(err) = self
.updater
.set_rrset(
name,
record_type,
self.ttl.as_secs() as u32,
Vec::new(),
origin,
)
.await
{
trc::event!(
Dns(DnsEvent::RecordDeletionFailed),
Hostname = name.to_string(),
Details = origin.to_string(),
Type = record_type.as_str(),
Reason = err.to_string(),
);
return Err(format!("Failed to delete DNS RRSet: {}", err));
}
Ok(())
}
pub async fn add_to_rrset( pub async fn add_to_rrset(
&self, &self,
origin: &str, origin: &str,
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use super::{ use super::{
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use self::limiter::{ConcurrencyLimiter, InFlight}; use self::limiter::{ConcurrencyLimiter, InFlight};
+56 -1
View File
@@ -23,6 +23,7 @@ use crate::{
manager::SPAM_CLASSIFIER_KEY, manager::SPAM_CLASSIFIER_KEY,
network::RcptResolution, network::RcptResolution,
}; };
use ahash::AHashSet;
use directory::Recipient; use directory::Recipient;
use mail_auth::IpLookupStrategy; use mail_auth::IpLookupStrategy;
use registry::schema::enums::ExpressionVariable; use registry::schema::enums::ExpressionVariable;
@@ -37,6 +38,7 @@ use store::{
write::{AlignedBytes, Archive, QueueClass, ValueClass}, write::{AlignedBytes, Archive, QueueClass, ValueClass},
}; };
use trc::{AddContext, SpamEvent}; use trc::{AddContext, SpamEvent};
use utils::DomainPart;
impl Server { impl Server {
pub async fn rcpt_resolve( pub async fn rcpt_resolve(
@@ -163,7 +165,10 @@ impl Server {
} }
EmailCache::MailingList(id) => { EmailCache::MailingList(id) => {
if let Some(list) = self.try_list(id).await? { if let Some(list) = self.try_list(id).await? {
return Ok(RcptResolution::Expand(list.recipients.clone())); return Ok(RcptResolution::Expand(
self.expand_nested_lists(id, list.recipients.clone())
.await?,
));
} else { } else {
self.inner self.inner
.cache .cache
@@ -195,6 +200,56 @@ impl Server {
} }
} }
async fn expand_nested_lists(
&self,
list_id: u32,
recipients: Arc<[Box<str>]>,
) -> trc::Result<Arc<[Box<str>]>> {
let mut has_nested = false;
for member in recipients.iter() {
if let Some(EmailCache::MailingList(_)) = self.rcpt_id_from_email(member).await? {
has_nested = true;
break;
}
}
if !has_nested {
return Ok(recipients);
}
let mut expanded = Vec::with_capacity(recipients.len());
let mut seen: AHashSet<Box<str>> = AHashSet::with_capacity(recipients.len());
let mut visited = AHashSet::from_iter([list_id]);
let mut pending: Vec<Arc<[Box<str>]>> = Vec::new();
let mut members = recipients;
loop {
for member in members.iter() {
if let Some(EmailCache::MailingList(nested_id)) =
self.rcpt_id_from_email(member).await?
{
if !visited.insert(nested_id) {
continue;
}
if let Some(nested) = self.try_list(nested_id).await? {
pending.push(nested.recipients.clone());
continue;
}
}
if seen.insert(member.to_canonical_address().into()) {
expanded.push(member.clone());
}
}
let Some(next) = pending.pop() else {
break;
};
members = next;
}
Ok(expanded.into())
}
pub async fn get_dkim_signers( pub async fn get_dkim_signers(
&self, &self,
domain: &str, domain: &str,
+2 -2
View File
@@ -29,11 +29,11 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer
let (_, mut rx) = builder.register(); let (_, mut rx) = builder.register();
tokio::spawn(async move { tokio::spawn(async move {
let resource = Resource::builder() let resource = Resource::builder()
.with_service_name("stalwart") .with_service_name("inbuxa")
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!())) .with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
.build(); .build();
let instrumentation = InstrumentationScope::builder("stalwart") let instrumentation = InstrumentationScope::builder("inbuxa")
.with_version(types::brand_version_full!()) .with_version(types::brand_version_full!())
.build(); .build();
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "coordinator" name = "coordinator"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dav-proto" name = "dav-proto"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dav" name = "dav"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "directory" name = "directory"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "email" name = "email"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+8
View File
@@ -22,6 +22,8 @@ use std::{borrow::Cow, future::Future};
use store::ahash::AHashMap; use store::ahash::AHashMap;
use types::blob_hash::BlobHash; use types::blob_hash::BlobHash;
pub const ORCPT_ADDR_TYPE: &str = "rfc822;";
#[derive(Debug)] #[derive(Debug)]
pub struct IngestMessage { pub struct IngestMessage {
pub sender_address: String, pub sender_address: String,
@@ -40,6 +42,12 @@ pub struct IngestRecipient {
} }
impl IngestRecipient { impl IngestRecipient {
pub fn orcpt_parameter(&self) -> Option<String> {
self.orcpt
.as_deref()
.map(|orcpt| format!("{ORCPT_ADDR_TYPE}{orcpt}"))
}
pub fn is_spam(&self) -> bool { pub fn is_spam(&self) -> bool {
self.spam_percentage self.spam_percentage
.is_some_and(|percentage| percentage >= 50) .is_some_and(|percentage| percentage >= 50)
+2 -1
View File
@@ -126,6 +126,7 @@ impl SieveScriptIngest for Server {
.caused_by(trc::location!())?; .caused_by(trc::location!())?;
// Create Sieve instance // Create Sieve instance
let orcpt = envelope_to.orcpt_parameter();
let mut instance = self.core.sieve.untrusted_runtime.filter_parsed(message); let mut instance = self.core.sieve.untrusted_runtime.filter_parsed(message);
// Set account name and email // Set account name and email
@@ -141,7 +142,7 @@ impl SieveScriptIngest for Server {
// Set envelope // Set envelope
instance.set_envelope(Envelope::From, envelope_from); instance.set_envelope(Envelope::From, envelope_from);
instance.set_envelope(Envelope::To, envelope_to.address.as_str()); instance.set_envelope(Envelope::To, envelope_to.address.as_str());
if let Some(orcpt) = &envelope_to.orcpt { if let Some(orcpt) = &orcpt {
instance.set_envelope(Envelope::Orcpt, orcpt.as_str()); instance.set_envelope(Envelope::Orcpt, orcpt.as_str());
} }
instance.set_spam_status(spam_status(envelope_to.spam_percentage)); instance.set_spam_status(spam_status(envelope_to.spam_percentage));
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "groupware" name = "groupware"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "http_proto" name = "http_proto"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use common::manager::application::Resource; use common::manager::application::Resource;
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "http" name = "http"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+6 -10
View File
@@ -230,14 +230,7 @@ async fn delivery_diagnose(
// Lookup MX // Lookup MX
let now = Instant::now(); let now = Instant::now();
let mxs = match server let mxs = match server.mx_lookup(domain.as_str()).await {
.core
.smtp
.resolvers
.dns
.mx_lookup(&domain, Some(&server.inner.cache.dns_mx))
.await
{
Ok(mxs) => mxs, Ok(mxs) => mxs,
Err(err) => { Err(err) => {
tx.send(DeliveryStage::MxLookupError { tx.send(DeliveryStage::MxLookupError {
@@ -419,7 +412,7 @@ async fn delivery_diagnose(
}) })
.await?; .await?;
None continue 'outer;
} }
Ok(TlsaResult::Missing) => { Ok(TlsaResult::Missing) => {
tx.send(DeliveryStage::TlsaNotFound { tx.send(DeliveryStage::TlsaNotFound {
@@ -440,14 +433,17 @@ async fn delivery_diagnose(
reason: "No TLSA records found for MX".to_string(), reason: "No TLSA records found for MX".to_string(),
}) })
.await?; .await?;
None
} else { } else {
tx.send(DeliveryStage::TlsaLookupError { tx.send(DeliveryStage::TlsaLookupError {
elapsed: now.elapsed_ms(), elapsed: now.elapsed_ms(),
reason: err.to_string(), reason: err.to_string(),
}) })
.await?; .await?;
continue 'outer;
} }
None
} }
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "imap_proto" name = "imap_proto"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "imap" name = "imap"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+32
View File
@@ -390,6 +390,16 @@ impl<T: SessionStream> SessionData<T> {
.await .await
.imap_ctx(&arguments.tag, trc::location!())?; .imap_ctx(&arguments.tag, trc::location!())?;
let mut dest_cache = None; let mut dest_cache = None;
let train_spam = if dest_mailbox_id == JUNK_ID {
Some(true)
} else if src_mailbox.id.mailbox_id == JUNK_ID && dest_mailbox_id != TRASH_ID {
Some(false)
} else {
None
};
let mut train_batch = BatchBuilder::new();
let mut did_train = false;
train_batch.with_account_id(src_account_id);
for (id, imap_id) in ids { for (id, imap_id) in ids {
match self match self
.server .server
@@ -515,11 +525,33 @@ impl<T: SessionStream> SessionData<T> {
} }
}; };
if let Some(is_spam) = train_spam {
self.server
.add_account_spam_sample(
&mut train_batch,
src_account_id,
id,
is_spam,
self.session_id,
)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
train_batch.commit_point();
did_train = true;
}
if is_move { if is_move {
destroy_ids.insert(id); destroy_ids.insert(id);
} }
} }
if did_train {
self.server
.commit_batch(train_batch)
.await
.imap_ctx(&arguments.tag, trc::location!())?;
}
// Untag or delete emails // Untag or delete emails
if !destroy_ids.is_empty() { if !destroy_ids.is_empty() {
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jmap_proto" name = "jmap_proto"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "jmap" name = "jmap"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+30 -1
View File
@@ -11,7 +11,11 @@ use crate::{
use common::{Server, auth::AccessToken}; use common::{Server, auth::AccessToken};
use email::{ use email::{
cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess}, cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess},
message::copy::{CopyMessageError, EmailCopy}, mailbox::JUNK_ID,
message::{
copy::{CopyMessageError, EmailCopy},
ingest::EmailIngest,
},
}; };
use http_proto::HttpSessionData; use http_proto::HttpSessionData;
use jmap_proto::{ use jmap_proto::{
@@ -29,6 +33,7 @@ use jmap_proto::{
}; };
use jmap_tools::{Key, Value}; use jmap_tools::{Key, Value};
use std::future::Future; use std::future::Future;
use store::write::BatchBuilder;
use trc::AddContext; use trc::AddContext;
use types::acl::Acl; use types::acl::Acl;
use utils::map::vec_map::VecMap; use utils::map::vec_map::VecMap;
@@ -87,6 +92,9 @@ impl JmapEmailCopy for Server {
}; };
let on_success_delete = request.on_success_destroy_original.unwrap_or(false); let on_success_delete = request.on_success_destroy_original.unwrap_or(false);
let mut destroy_ids = Vec::new(); let mut destroy_ids = Vec::new();
let mut train_batch = BatchBuilder::new();
let mut did_train = false;
train_batch.with_account_id(from_account_id);
'create: for (id, create) in request.create.into_valid() { 'create: for (id, create) in request.create.into_valid() {
let mut from_message_id = None; let mut from_message_id = None;
@@ -208,6 +216,7 @@ impl JmapEmailCopy for Server {
} }
// Add response // Add response
let train_spam = mailboxes.contains(&JUNK_ID);
match self match self
.copy_message( .copy_message(
from_account_id, from_account_id,
@@ -221,6 +230,20 @@ impl JmapEmailCopy for Server {
.await? .await?
{ {
Ok(email) => { Ok(email) => {
if train_spam {
self.add_account_spam_sample(
&mut train_batch,
from_account_id,
from_message_id.document_id(),
true,
session.session_id,
)
.await
.caused_by(trc::location!())?;
train_batch.commit_point();
did_train = true;
}
response response
.created .created
.append(id, ingested_into_object(email).into()); .append(id, ingested_into_object(email).into());
@@ -245,6 +268,12 @@ impl JmapEmailCopy for Server {
} }
} }
if did_train {
self.commit_batch(train_batch)
.await
.caused_by(trc::location!())?;
}
// Update state // Update state
if !response.created.is_empty() { if !response.created.is_empty() {
response.new_state = self.get_cached_messages(account_id).await?.get_state(false); response.new_state = self.get_cached_messages(account_id).await?.get_state(false);
@@ -657,7 +657,7 @@ fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option<registry::schema::s
// FreeBSD keeps variable application data under /var/db (hier(7)) // FreeBSD keeps variable application data under /var/db (hier(7))
// rather than FHS /var/lib. // rather than FHS /var/lib.
const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") { const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") {
"/var/db/stalwart/" "/var/db/inbuxa/"
} else { } else {
"/var/lib/inbuxa/" "/var/lib/inbuxa/"
}; };
+1 -1
View File
@@ -7,7 +7,7 @@ keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
categories = ["email"] categories = ["email"]
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; INBUXA takes the AGPL only. # Upstream offers AGPL-3.0-only OR LicenseRef-SEL; INBUXA takes the AGPL only.
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
+2
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
#![warn(clippy::large_futures)] #![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(), server.inner.data.queue_id_gen.generate(),
QueueName::default(), QueueName::default(),
); );
assert!(qm.save_changes(server, None).await); assert!(qm.save_changes(server, None, None).await);
} }
for report in sample_tls_internal_reports() { for report in sample_tls_internal_reports() {
@@ -163,7 +163,7 @@ fn sample_queued_messages(blob_hashes: Vec<BlobHash>) -> Vec<Message> {
}, },
}), }),
flags: RCPT_DSN_SENT, flags: RCPT_DSN_SENT,
orcpt: Some("rfc822;[email protected]".into()), orcpt: Some("[email protected]".into()),
}, },
], ],
received_from_ip: std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)), received_from_ip: std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "managesieve" name = "managesieve"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "migration" name = "migration"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "nlp" name = "nlp"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "pop3" name = "pop3"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+2 -8
View File
@@ -64,14 +64,8 @@ impl<T: SessionStream> Session<T> {
) )
.get_full_range(); .get_full_range();
self.write_bytes( self.write_bytes(Response::Message::<u32> { bytes, lines }.serialize())
Response::Message::<u32> { .await
bytes,
lines: lines.unwrap_or(0),
}
.serialize(),
)
.await
} else { } else {
Err(trc::Pop3Event::Error Err(trc::Pop3Event::Error
.into_err() .into_err()
+86 -19
View File
@@ -16,7 +16,7 @@ pub enum Response<'x, T> {
List(Vec<T>), List(Vec<T>),
Message { Message {
bytes: SliceRange<'x>, bytes: SliceRange<'x>,
lines: u32, lines: Option<u32>,
}, },
Capability { Capability {
mechanisms: Vec<Mechanism>, mechanisms: Vec<Mechanism>,
@@ -54,40 +54,65 @@ impl<'x, T: Display> Response<'x, T> {
buf buf
} }
Response::Message { bytes, lines } => { Response::Message { bytes, lines } => {
let mut buf = Vec::with_capacity(bytes.len() + 10); let lines = *lines;
buf.extend_from_slice(b"+OK "); let mut message = Vec::with_capacity(bytes.len() + 16);
buf.extend_from_slice(bytes.len().to_string().as_bytes()); let mut octets = 0;
buf.extend_from_slice(b" octets\r\n"); let mut last_byte = b'\n';
let mut in_headers = lines.is_some();
let mut line_count = 0; let mut is_blank_line = true;
let mut last_byte = 0; let mut body_lines = 0;
// Transparency procedure // Transparency procedure
for &byte in bytes.into_iter() { for &byte in bytes.into_iter() {
// POP3 requires that lines end with CRLF, do this check to ensure that // POP3 requires that lines end with CRLF, do this check to ensure that
if byte == b'\n' && last_byte != b'\r' { 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' { if byte == b'.' && last_byte == b'\n' {
buf.push(b'.'); message.push(b'.');
} }
buf.push(byte); message.push(byte);
octets += 1;
last_byte = byte; last_byte = byte;
if *lines > 0 && byte == b'\n' { match byte {
line_count += 1; b'\n' => {
if line_count == *lines { if in_headers {
break; 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' { 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 buf
} }
Response::Capability { mechanisms, stls } => { Response::Capability { mechanisms, stls } => {
@@ -208,9 +233,51 @@ mod tests {
( (
Response::Message { Response::Message {
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"), 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()); assert_eq!(expected, String::from_utf8(cmd.serialize()).unwrap());
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "registry" name = "registry"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "scim-proto" name = "scim-proto"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "scim" name = "scim"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "services" name = "services"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [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 { loop {
match batch.next_event() { match batch.next_event() {
Ok(Some(event)) => { Ok(Some(event)) => {
@@ -174,9 +181,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
.await; .await;
} }
BroadcastEvent::QueueRefresh => { BroadcastEvent::QueueRefresh => {
let core = inner.shared_core.load_full(); if inner.shared_core.load().network.roles.outbound_mta {
if core.network.roles.outbound_mta {
core.storage.data.invalidate_read_snapshot();
let _ = inner let _ = inner
.ipc .ipc
.queue_tx .queue_tx
@@ -185,9 +190,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
} }
} }
BroadcastEvent::RegistryChange(change) => { BroadcastEvent::RegistryChange(change) => {
let server = inner.build_server(); match Box::pin(inner.build_server().reload_registry(change)).await {
server.store().invalidate_read_snapshot();
match Box::pin(server.reload_registry(change)).await {
Ok(result) => { Ok(result) => {
result.log(); result.log();
} }
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * 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 common::Server;
use email::{message::metadata::MessageMetadata, sieve::SieveScript}; use email::{message::metadata::MessageMetadata, sieve::SieveScript};
use groupware::file::FileNode; use groupware::file::FileNode;
@@ -41,7 +41,7 @@ impl DestroyAccountTask for Server {
match destroy_account(self, task).await { match destroy_account(self, task).await {
Ok(result) => result, Ok(result) => result,
Err(err) => { Err(err) => {
let result = TaskResult::temporary(err.to_string()); let result = TaskResult::deferred(deferred_retry_time(&err), err.to_string());
trc::error!( trc::error!(
err.account_id(task.account_id.document_id()) err.account_id(task.account_id.document_id())
.details("Failed to destroy account") .details("Failed to destroy account")
+3 -19
View File
@@ -6,7 +6,7 @@
* Modified by Coffey Labs in 2026 for INBUXA. * 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 common::Server;
use email::{ use email::{
cache::MessageCacheFetch, cache::MessageCacheFetch,
@@ -274,7 +274,7 @@ impl SearchIndexTask for Server {
); );
for r in results.iter_mut() { for r in results.iter_mut() {
if r.task_type == TaskType::Insert && r.result.is_success() { 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; return results;
@@ -331,7 +331,7 @@ impl SearchIndexTask for Server {
for r in results.iter_mut() { for r in results.iter_mut() {
if r.task_type == TaskType::Delete && r.result.is_success() { if r.task_type == TaskType::Delete && r.result.is_success() {
r.result = 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; return results;
@@ -445,22 +445,6 @@ pub(crate) async fn reindex_account(server: &Server, account_id: u32) -> trc::Re
Ok(()) 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 { fn attempt_number(status: &TaskStatus) -> u64 {
match status { match status {
TaskStatus::Pending(_) => 0, TaskStatus::Pending(_) => 0,
@@ -621,6 +621,7 @@ pub fn perpetual_retry_time(typ: TaskType, attempt: u64) -> Option<u64> {
| TaskType::DkimManagement | TaskType::DkimManagement
| TaskType::IndexDocument | TaskType::IndexDocument
| TaskType::UnindexDocument | TaskType::UnindexDocument
| TaskType::DestroyAccount
) )
.then(|| { .then(|| {
now().saturating_add( now().saturating_add(
+16
View File
@@ -137,4 +137,20 @@ impl TaskResult {
max_attempts: None, 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())
} }
+1 -1
View File
@@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp"
keywords = ["smtp", "email", "mail", "server"] keywords = ["smtp", "email", "mail", "server"]
categories = ["email"] categories = ["email"]
license = "AGPL-3.0-only OR LicenseRef-SEL" license = "AGPL-3.0-only OR LicenseRef-SEL"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+8 -4
View File
@@ -11,6 +11,7 @@ use common::{
config::smtp::auth::VerifyStrategy, config::smtp::auth::VerifyStrategy,
network::{ServerInstance, asn::AsnGeoLookupResult}, network::{ServerInstance, asn::AsnGeoLookupResult},
}; };
use email::message::delivery::ORCPT_ADDR_TYPE;
use mail_auth::{IprevOutput, SpfOutput}; use mail_auth::{IprevOutput, SpfOutput};
use smtp_proto::request::receiver::{ use smtp_proto::request::receiver::{
BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver, RequestReceiver, 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 self.dsn_info
.as_ref() .as_deref()
.and_then(|v| v.strip_prefix("rfc822;")) .map(|orcpt| format!("{ORCPT_ADDR_TYPE}{}", orcpt.to_lowercase()))
.unwrap_or(&self.address_lcase)
} }
} }
+1 -1
View File
@@ -443,7 +443,7 @@ impl<T: SessionStream> Session<T> {
if !rc.analysis.forward { if !rc.analysis.forward {
self.data self.data
.rcpt_to .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() { 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); let mut new_addr = SessionAddress::new(address);
if !self.data.rcpt_to.contains(&new_addr) { 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.flags = orig_addr.flags;
new_addr.dsn_info = orig_addr.address_lcase.into();
self.data.rcpt_to.push(new_addr); self.data.rcpt_to.push(new_addr);
} else { } else {
trc::event!( trc::event!(
@@ -353,7 +353,6 @@ impl<T: SessionStream> Session<T> {
// Expand list // Expand list
if let Some(members) = rcpt_members { if let Some(members) = rcpt_members {
let list_addr = self.data.rcpt_to.pop().unwrap(); let list_addr = self.data.rcpt_to.pop().unwrap();
let orcpt = format!("rfc822;{}", list_addr.address_lcase);
for member in members.as_ref() { for member in members.as_ref() {
let member_lcase = member.to_lowercase(); let member_lcase = member.to_lowercase();
let is_local = match self let is_local = match self
@@ -399,7 +398,7 @@ impl<T: SessionStream> Session<T> {
if !self.data.rcpt_to.contains(&member_addr) if !self.data.rcpt_to.contains(&member_addr)
&& member_addr.address_lcase != list_addr.address_lcase && 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; member_addr.flags = list_addr.flags;
self.data.rcpt_to.push(member_addr); 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-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use common::{ use common::{
@@ -211,7 +213,7 @@ impl<T: SessionStream> Session<T> {
Request::Help { .. } => { Request::Help { .. } => {
trc::event!(Smtp(SmtpEvent::Help), SpanId = self.data.session_id,); 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?; .await?;
} }
Request::Helo { host } => { Request::Helo { host } => {
+1 -11
View File
@@ -89,17 +89,7 @@ impl<T: SessionStream> Session<T> {
.iter() .iter()
.map(|r| r.address_lcase.as_str()) .map(|r| r.address_lcase.as_str())
.collect(), .collect(),
env_rcpt_orig_to: self env_rcpt_orig_to: self.data.rcpt_to.iter().map(|r| r.orig_address()).collect(),
.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(),
is_test: false, is_test: false,
is_train: 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::lookup::MtaStsLookup;
use crate::outbound::mta_sts::verify::VerifyPolicy; use crate::outbound::mta_sts::verify::VerifyPolicy;
use crate::outbound::{client::StartTlsResult, dane::verify::TlsaVerify}; use crate::outbound::{client::StartTlsResult, dane::verify::TlsaVerify};
use crate::queue::dsn::SendDsn; use crate::queue::dsn::{DsnStatus, SendDsn};
use crate::queue::spool::SmtpSpool; use crate::queue::spool::{DSN_RETRY, SmtpSpool};
use crate::queue::throttle::IsAllowed; use crate::queue::throttle::IsAllowed;
use crate::queue::{ use crate::queue::{
Error, FROM_REPORT, HostResponse, MessageWrapper, Metadata, QueueEnvelope, QueuedMessage, Error, FROM_REPORT, HostResponse, MessageWrapper, Metadata, QueueEnvelope, QueuedMessage,
@@ -155,7 +155,7 @@ impl QueuedMessage {
let span_id = message.span_id; let span_id = message.span_id;
// Send any due Delivery Status Notifications // 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 { match has_pending_delivery {
PendingDelivery::Yes(true) PendingDelivery::Yes(true)
@@ -163,21 +163,27 @@ impl QueuedMessage {
.message .message
.next_delivery_event(self.queue_name.into()) .next_delivery_event(self.queue_name.into())
.is_some_and(|due| due <= now()) => {} .is_some_and(|due| due <= now()) => {}
PendingDelivery::No => { PendingDelivery::No if dsn_status == DsnStatus::Completed => {
trc::event!( trc::event!(
Delivery(DeliveryEvent::Completed), Delivery(DeliveryEvent::Completed),
SpanId = span_id, SpanId = span_id,
Elapsed = trc::Value::Duration((now() - message.message.created) * 1000) 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; message.remove(&server, self.due.into()).await;
return QueueEventStatus::Completed; 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 // 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; 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; return QueueEventStatus::Deferred;
} }
@@ -1485,7 +1491,7 @@ impl QueuedMessage {
} }
// Send Delivery Status Notifications // Send Delivery Status Notifications
server.send_dsn(&mut message).await; let dsn_status = server.send_dsn(&mut message).await;
// Notify queue manager // Notify queue manager
if message.message.next_event(None).is_some() { if message.message.next_event(None).is_some() {
@@ -1501,7 +1507,13 @@ impl QueuedMessage {
); );
// Save changes to disk // 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 QueueEventStatus::Deferred
} else { } else {
+1 -1
View File
@@ -120,7 +120,7 @@ impl MessageWrapper {
) )
.await; .await;
message let _ = message
.queue( .queue(
QueueParams::new(&autogenerated.message, self.span_id, server) QueueParams::new(&autogenerated.message, self.span_id, server)
.with_dkim_signers(dkim_signers) .with_dkim_signers(dkim_signers)
+46 -12
View File
@@ -10,9 +10,10 @@ use super::{
Recipient, Status, Recipient, Status,
}; };
use crate::inbound::dkim::DkimSign; use crate::inbound::dkim::DkimSign;
use crate::queue::spool::QueueParams; use crate::queue::spool::{DSN_RETRY, QueueParams};
use crate::queue::{MessageWrapper, UnexpectedResponse}; use crate::queue::{MessageWrapper, UnexpectedResponse};
use common::Server; use common::Server;
use email::message::delivery::ORCPT_ADDR_TYPE;
use mail_builder::MessageBuilder; use mail_builder::MessageBuilder;
use mail_builder::headers::HeaderType; use mail_builder::headers::HeaderType;
use mail_builder::headers::content_type::ContentType; use mail_builder::headers::content_type::ContentType;
@@ -25,16 +26,24 @@ use std::fmt::Write;
use std::future::Future; use std::future::Future;
use store::write::now; use store::write::now;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DsnStatus {
Completed,
Deferred,
}
pub trait SendDsn: Sync + Send { 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; fn log_dsn(&self, message: &MessageWrapper) -> impl Future<Output = ()> + Send;
} }
impl SendDsn for Server { impl SendDsn for Server {
async fn send_dsn(&self, message: &mut MessageWrapper) { async fn send_dsn(&self, message: &mut MessageWrapper) -> DsnStatus {
// Send DSN events // Send DSN events
self.log_dsn(message).await; self.log_dsn(message).await;
let mut status = DsnStatus::Completed;
if !message.message.return_path.is_empty() { if !message.message.return_path.is_empty() {
// Build DSN // Build DSN
if let Some(dsn) = message.build_dsn(self).await { if let Some(dsn) = message.build_dsn(self).await {
@@ -51,12 +60,19 @@ impl SendDsn for Server {
message.span_id, message.span_id,
) )
.await; .await;
dsn_message if dsn_message
.queue( .queue(
QueueParams::new(&dsn, message.span_id, self) QueueParams::new(&dsn, message.span_id, self)
.with_dkim_signers(dkim_signers), .with_dkim_signers(dkim_signers),
) )
.await; .await
{
message.mark_dsn_sent();
} else {
status = DsnStatus::Deferred;
}
} else {
message.mark_dsn_sent();
} }
} else { } else {
// Handle double bounce // Handle double bounce
@@ -64,7 +80,9 @@ impl SendDsn for Server {
} }
// Update next DSN notify times // 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) { async fn log_dsn(&self, message: &MessageWrapper) {
@@ -132,7 +150,7 @@ impl SendDsn for Server {
const MAX_HEADER_SIZE: usize = 4096; const MAX_HEADER_SIZE: usize = 4096;
impl MessageWrapper { 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 config = &server.core.smtp.queue;
let now = now(); let now = now();
@@ -141,13 +159,12 @@ impl MessageWrapper {
let mut txt_failed = String::new(); let mut txt_failed = String::new();
let mut dsn = 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) { if rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) {
continue; continue;
} }
match &rcpt.status { match &rcpt.status {
Status::Completed(response) => { Status::Completed(response) => {
rcpt.flags |= RCPT_DSN_SENT;
if !rcpt.has_flag(RCPT_NOTIFY_SUCCESS) { if !rcpt.has_flag(RCPT_NOTIFY_SUCCESS) {
continue; continue;
} }
@@ -164,7 +181,6 @@ impl MessageWrapper {
response.write_dsn_text(&rcpt.address, &mut txt_delay); response.write_dsn_text(&rcpt.address, &mut txt_delay);
} }
Status::PermanentFailure(response) => { Status::PermanentFailure(response) => {
rcpt.flags |= RCPT_DSN_SENT;
if !rcpt.has_flag(RCPT_NOTIFY_FAILURE) { if !rcpt.has_flag(RCPT_NOTIFY_FAILURE) {
continue; continue;
} }
@@ -357,7 +373,7 @@ impl MessageWrapper {
.into() .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 now = now();
let mut notify_changes = Vec::new(); let mut notify_changes = Vec::new();
for (rcpt_idx, rcpt) in self.message.recipients.iter().enumerate() { for (rcpt_idx, rcpt) in self.message.recipients.iter().enumerate() {
@@ -366,6 +382,11 @@ impl MessageWrapper {
Status::TemporaryFailure(_) | Status::Scheduled Status::TemporaryFailure(_) | Status::Scheduled
) && rcpt.notify.due <= now ) && 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 envelope = QueueEnvelope::new(&self.message, rcpt);
let queue_id = server 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) { fn handle_double_bounce(&mut self) {
let mut is_double_bounce = Vec::with_capacity(0); let mut is_double_bounce = Vec::with_capacity(0);
let now = now(); let now = now();
@@ -523,7 +557,7 @@ impl Message {
impl Recipient { impl Recipient {
fn write_dsn(&self, dsn: &mut String) { fn write_dsn(&self, dsn: &mut String) {
if let Some(orcpt) = &self.orcpt { 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); 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 LOCK_EXPIRY: u64 = 10 * 60; // 10 minutes
pub const QUEUE_REFRESH: u64 = 5 * 60; // 5 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 pub(crate) const INFINITE_LOCK: u64 = 60 * 60 * 24 * 365; // 1 year
const CANDIDATE_OVERSCAN: usize = 4; const CANDIDATE_OVERSCAN: usize = 4;
const MAX_PREALLOCATED_CANDIDATES: usize = 1024; const MAX_PREALLOCATED_CANDIDATES: usize = 1024;
@@ -370,6 +371,7 @@ pub(crate) struct QueueParams<'x, 'y> {
} }
impl MessageWrapper { impl MessageWrapper {
#[must_use]
pub(crate) async fn queue<'x, 'y>(mut self, mut params: QueueParams<'x, 'y>) -> bool { pub(crate) async fn queue<'x, 'y>(mut self, mut params: QueueParams<'x, 'y>) -> bool {
// Add DKIM signatures // Add DKIM signatures
let dkim_headers = if params.dkim_signers.is_some() { let dkim_headers = if params.dkim_signers.is_some() {
@@ -669,7 +671,12 @@ impl MessageWrapper {
recipient.queue = queue.virtual_queue; 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 // Release quota for completed deliveries
let mut batch = BatchBuilder::new(); let mut batch = BatchBuilder::new();
self.release_quota(&mut batch); 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( batch.set(
ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent { ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent {
due, due,
-3
View File
@@ -316,9 +316,6 @@ impl<T: SessionStream> Session<T> {
if let Some(dkim2_output) = dkim2_output { if let Some(dkim2_output) = dkim2_output {
report_record = report_record.with_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 { if let Some(spf_mail_from) = &self.data.spf_mail_from {
report_record = report_record.with_spf_output(spf_mail_from, SPFDomainScope::MailFrom); 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 self.data
.rcpt_to .rcpt_to
.iter() .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 let dkim_signers = self
.eval_signers(sign_config, &message.message, parent_session_id) .eval_signers(sign_config, &message.message, parent_session_id)
.await; .await;
message let _ = message
.queue( .queue(
QueueParams::new(&report, parent_session_id, self).with_dkim_signers(dkim_signers), QueueParams::new(&report, parent_session_id, self).with_dkim_signers(dkim_signers),
) )
@@ -130,7 +130,7 @@ impl MtaReportSend for Server {
} else { } else {
None None
}; };
message let _ = message
.queue( .queue(
QueueParams::new(&raw_message, parent_session_id, self) QueueParams::new(&raw_message, parent_session_id, self)
.with_dkim_signers(dkim_signers), .with_dkim_signers(dkim_signers),
+6 -1
View File
@@ -12,6 +12,7 @@ use smtp_proto::{
use utils::DomainPart; use utils::DomainPart;
use crate::core::{SessionAddress, SessionData}; use crate::core::{SessionAddress, SessionData};
use email::message::delivery::ORCPT_ADDR_TYPE;
impl SessionData { impl SessionData {
pub fn apply_envelope_modification(&mut self, envelope: Envelope, value: String) { pub fn apply_envelope_modification(&mut self, envelope: Envelope, value: String) {
@@ -111,7 +112,11 @@ impl SessionData {
} }
Envelope::Orcpt => { Envelope::Orcpt => {
if let Some(rcpt_to) = self.rcpt_to.last_mut() { 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 => { Envelope::Envid => {
+1 -1
View File
@@ -297,7 +297,7 @@ impl RunScript for Server {
None None
}; };
message let _ = message
.queue( .queue(
QueueParams::new(raw_message, session_id, self) QueueParams::new(raw_message, session_id, self)
.with_dkim_signers(dkim_signers) .with_dkim_signers(dkim_signers)
+4 -6
View File
@@ -95,10 +95,8 @@ impl<T: SessionStream> Session<T> {
params params
.envelope .envelope
.push((Envelope::To, rcpt.address_lcase.to_string().into())); .push((Envelope::To, rcpt.address_lcase.to_string().into()));
if let Some(orcpt) = &rcpt.dsn_info { if let Some(orcpt) = rcpt.orcpt_parameter() {
params params.envelope.push((Envelope::Orcpt, orcpt.into()));
.envelope
.push((Envelope::Orcpt, orcpt.as_str().to_lowercase().into()));
} }
} }
} else { } else {
@@ -109,10 +107,10 @@ impl<T: SessionStream> Session<T> {
for rcpt in &self.data.rcpt_to { for rcpt in &self.data.rcpt_to {
recipients.push(Variable::from(rcpt.address_lcase.to_string())); recipients.push(Variable::from(rcpt.address_lcase.to_string()));
orcpts.push(match &rcpt.dsn_info { orcpts.push(match rcpt.orcpt_parameter() {
Some(orcpt) => { Some(orcpt) => {
has_orcpts = true; has_orcpts = true;
Variable::from(orcpt.as_str().to_lowercase()) Variable::from(orcpt)
} }
None => Variable::default(), None => Variable::default(),
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "spam-filter" name = "spam-filter"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::analysis::domain::SpamFilterAnalyzeDomain; use crate::analysis::domain::SpamFilterAnalyzeDomain;
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "store" name = "store"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -71,7 +71,7 @@ impl ReadVersion {
} }
fn expire(&self) { fn expire(&self) {
self.obtained.store(0, Ordering::Release); self.version.store(0, Ordering::Release);
} }
fn try_begin_refresh(&self) -> Option<RefreshGuard<'_>> { fn try_begin_refresh(&self) -> Option<RefreshGuard<'_>> {
+6 -13
View File
@@ -11,14 +11,12 @@ use crate::{
CalendarSearchField, ContactSearchField, EmailSearchField, SearchField, SearchableField, CalendarSearchField, ContactSearchField, EmailSearchField, SearchField, SearchableField,
TracingSearchField, TracingSearchField,
}, },
write::now,
}; };
use registry::schema::structs; use registry::schema::structs;
use reqwest::{Error, Response, Url}; use reqwest::{Error, Response, Url};
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::{sync::Arc, time::Duration}; use std::{sync::Arc, time::Duration};
const UNCONFIRMED_TASK_RECHECK_DELAY: u64 = 600;
pub(crate) const MAX_TOTAL_HITS: u64 = 100_000; pub(crate) const MAX_TOTAL_HITS: u64 = 100_000;
impl MeiliSearchStore { impl MeiliSearchStore {
@@ -300,18 +298,13 @@ impl MeiliSearchStore {
} }
} }
let err = trc::StoreEvent::MeilisearchError if self.task_fail_on_timeout {
.reason("Timed out waiting for Meilisearch task") Err(trc::StoreEvent::MeilisearchError
.id(task_uid); .reason("Timed out waiting for Meilisearch task")
.id(task_uid))
Err(if self.task_fail_on_timeout {
err
} else { } else {
err.ctx( Ok(true)
trc::Key::NextRetry, }
now().saturating_add(UNCONFIRMED_TASK_RECHECK_DELAY),
)
})
} }
} }
+113 -123
View File
@@ -6,36 +6,37 @@
use super::{RedisPool, RedisStore, into_error}; use super::{RedisPool, RedisStore, into_error};
use crate::{Deserialize, write::now}; use crate::{Deserialize, write::now};
use redis::AsyncCommands; use deadpool::managed::{Manager, Object, Pool};
use redis::{AsyncCommands, RedisError, RedisResult, RetryMethod, Script};
use std::sync::LazyLock;
static INCR_EXPIRE: LazyLock<Script> = LazyLock::new(|| {
Script::new(
"redis.call('INCRBY', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
return redis.call('GET', KEYS[1])",
)
});
impl RedisStore { impl RedisStore {
pub async fn key_set(&self, key: &[u8], value: &[u8], expires: Option<u64>) -> trc::Result<()> { pub async fn key_set(&self, key: &[u8], value: &[u8], expires: Option<u64>) -> trc::Result<()> {
match &self.pool { match &self.pool {
RedisPool::Single(pool) => { RedisPool::Single(pool) => {
self.key_set_( with_conn(pool, async |conn| {
pool.get().await.map_err(into_error)?.as_mut(), Self::key_set_(conn, key, value, expires).await
key, })
value,
expires,
)
.await .await
} }
RedisPool::Cluster(pool) => { RedisPool::Cluster(pool) => {
self.key_set_( with_conn(pool, async |conn| {
pool.get().await.map_err(into_error)?.as_mut(), Self::key_set_(conn, key, value, expires).await
key, })
value,
expires,
)
.await .await
} }
RedisPool::Sentinel(pool) => { RedisPool::Sentinel(pool) => {
self.key_set_( with_conn(pool, async |conn| {
pool.get().await.map_err(into_error)?.as_mut(), Self::key_set_(conn, key, value, expires).await
key, })
value,
expires,
)
.await .await
} }
} }
@@ -44,30 +45,21 @@ impl RedisStore {
pub async fn key_incr(&self, key: &[u8], value: i64, expires: Option<u64>) -> trc::Result<i64> { pub async fn key_incr(&self, key: &[u8], value: i64, expires: Option<u64>) -> trc::Result<i64> {
match &self.pool { match &self.pool {
RedisPool::Single(pool) => { RedisPool::Single(pool) => {
self.key_incr_( with_conn(pool, async |conn| {
pool.get().await.map_err(into_error)?.as_mut(), Self::key_incr_(conn, key, value, expires).await
key, })
value,
expires,
)
.await .await
} }
RedisPool::Cluster(pool) => { RedisPool::Cluster(pool) => {
self.key_incr_( with_conn(pool, async |conn| {
pool.get().await.map_err(into_error)?.as_mut(), Self::key_incr_(conn, key, value, expires).await
key, })
value,
expires,
)
.await .await
} }
RedisPool::Sentinel(pool) => { RedisPool::Sentinel(pool) => {
self.key_incr_( with_conn(pool, async |conn| {
pool.get().await.map_err(into_error)?.as_mut(), Self::key_incr_(conn, key, value, expires).await
key, })
value,
expires,
)
.await .await
} }
} }
@@ -76,16 +68,13 @@ impl RedisStore {
pub async fn try_lock(&self, key: &[u8], expires: u64) -> trc::Result<bool> { pub async fn try_lock(&self, key: &[u8], expires: u64) -> trc::Result<bool> {
match &self.pool { match &self.pool {
RedisPool::Single(pool) => { RedisPool::Single(pool) => {
self.try_lock_(pool.get().await.map_err(into_error)?.as_mut(), key, expires) with_conn(pool, async |conn| Self::try_lock_(conn, key, expires).await).await
.await
} }
RedisPool::Cluster(pool) => { RedisPool::Cluster(pool) => {
self.try_lock_(pool.get().await.map_err(into_error)?.as_mut(), key, expires) with_conn(pool, async |conn| Self::try_lock_(conn, key, expires).await).await
.await
} }
RedisPool::Sentinel(pool) => { RedisPool::Sentinel(pool) => {
self.try_lock_(pool.get().await.map_err(into_error)?.as_mut(), key, expires) with_conn(pool, async |conn| Self::try_lock_(conn, key, expires).await).await
.await
} }
} }
} }
@@ -93,16 +82,13 @@ impl RedisStore {
pub async fn key_delete(&self, key: &[u8]) -> trc::Result<()> { pub async fn key_delete(&self, key: &[u8]) -> trc::Result<()> {
match &self.pool { match &self.pool {
RedisPool::Single(pool) => { RedisPool::Single(pool) => {
self.key_delete_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::key_delete_(conn, key).await).await
.await
} }
RedisPool::Cluster(pool) => { RedisPool::Cluster(pool) => {
self.key_delete_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::key_delete_(conn, key).await).await
.await
} }
RedisPool::Sentinel(pool) => { RedisPool::Sentinel(pool) => {
self.key_delete_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::key_delete_(conn, key).await).await
.await
} }
} }
} }
@@ -110,16 +96,22 @@ impl RedisStore {
pub async fn key_delete_prefix(&self, prefix: &[u8]) -> trc::Result<()> { pub async fn key_delete_prefix(&self, prefix: &[u8]) -> trc::Result<()> {
match &self.pool { match &self.pool {
RedisPool::Single(pool) => { RedisPool::Single(pool) => {
self.key_delete_prefix_(pool.get().await.map_err(into_error)?.as_mut(), prefix) with_conn(pool, async |conn| {
.await Self::key_delete_prefix_(conn, prefix).await
})
.await
} }
RedisPool::Cluster(pool) => { RedisPool::Cluster(pool) => {
self.key_delete_prefix_(pool.get().await.map_err(into_error)?.as_mut(), prefix) with_conn(pool, async |conn| {
.await Self::key_delete_prefix_(conn, prefix).await
})
.await
} }
RedisPool::Sentinel(pool) => { RedisPool::Sentinel(pool) => {
self.key_delete_prefix_(pool.get().await.map_err(into_error)?.as_mut(), prefix) with_conn(pool, async |conn| {
.await Self::key_delete_prefix_(conn, prefix).await
})
.await
} }
} }
} }
@@ -128,35 +120,31 @@ impl RedisStore {
&self, &self,
key: &[u8], key: &[u8],
) -> trc::Result<Option<T>> { ) -> trc::Result<Option<T>> {
match &self.pool { let value = match &self.pool {
RedisPool::Single(pool) => { RedisPool::Single(pool) => {
self.key_get_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::key_get_(conn, key).await).await
.await
} }
RedisPool::Cluster(pool) => { RedisPool::Cluster(pool) => {
self.key_get_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::key_get_(conn, key).await).await
.await
} }
RedisPool::Sentinel(pool) => { RedisPool::Sentinel(pool) => {
self.key_get_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::key_get_(conn, key).await).await
.await
} }
} }?;
value.map(T::deserialize_owned).transpose()
} }
pub async fn counter_get(&self, key: &[u8]) -> trc::Result<i64> { pub async fn counter_get(&self, key: &[u8]) -> trc::Result<i64> {
match &self.pool { match &self.pool {
RedisPool::Single(pool) => { RedisPool::Single(pool) => {
self.counter_get_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::counter_get_(conn, key).await).await
.await
} }
RedisPool::Cluster(pool) => { RedisPool::Cluster(pool) => {
self.counter_get_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::counter_get_(conn, key).await).await
.await
} }
RedisPool::Sentinel(pool) => { RedisPool::Sentinel(pool) => {
self.counter_get_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::counter_get_(conn, key).await).await
.await
} }
} }
} }
@@ -164,92 +152,69 @@ impl RedisStore {
pub async fn key_exists(&self, key: &[u8]) -> trc::Result<bool> { pub async fn key_exists(&self, key: &[u8]) -> trc::Result<bool> {
match &self.pool { match &self.pool {
RedisPool::Single(pool) => { RedisPool::Single(pool) => {
self.key_exists_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::key_exists_(conn, key).await).await
.await
} }
RedisPool::Cluster(pool) => { RedisPool::Cluster(pool) => {
self.key_exists_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::key_exists_(conn, key).await).await
.await
} }
RedisPool::Sentinel(pool) => { RedisPool::Sentinel(pool) => {
self.key_exists_(pool.get().await.map_err(into_error)?.as_mut(), key) with_conn(pool, async |conn| Self::key_exists_(conn, key).await).await
.await
} }
} }
} }
async fn key_get_<T: Deserialize + std::fmt::Debug + 'static>( async fn key_get_(conn: &mut impl AsyncCommands, key: &[u8]) -> RedisResult<Option<Vec<u8>>> {
&self, redis::cmd("GET").arg(key).query_async(conn).await
conn: &mut impl AsyncCommands,
key: &[u8],
) -> trc::Result<Option<T>> {
if let Some(value) = redis::cmd("GET")
.arg(key)
.query_async::<Option<Vec<u8>>>(conn)
.await
.map_err(into_error)?
{
T::deserialize_owned(value).map(Some)
} else {
Ok(None)
}
} }
async fn counter_get_(&self, conn: &mut impl AsyncCommands, key: &[u8]) -> trc::Result<i64> { async fn counter_get_(conn: &mut impl AsyncCommands, key: &[u8]) -> RedisResult<i64> {
redis::cmd("GET") redis::cmd("GET")
.arg(key) .arg(key)
.query_async::<Option<i64>>(conn) .query_async::<Option<i64>>(conn)
.await .await
.map(|x| x.unwrap_or(0)) .map(|value| value.unwrap_or(0))
.map_err(into_error)
} }
async fn key_exists_(&self, conn: &mut impl AsyncCommands, key: &[u8]) -> trc::Result<bool> { async fn key_exists_(conn: &mut impl AsyncCommands, key: &[u8]) -> RedisResult<bool> {
conn.exists(key).await.map_err(into_error) conn.exists(key).await
} }
async fn key_set_( async fn key_set_(
&self,
conn: &mut impl AsyncCommands, conn: &mut impl AsyncCommands,
key: &[u8], key: &[u8],
value: &[u8], value: &[u8],
expires: Option<u64>, expires: Option<u64>,
) -> trc::Result<()> { ) -> RedisResult<()> {
if let Some(expires) = expires { if let Some(expires) = expires {
conn.set_ex(key, value, expires).await.map_err(into_error) conn.set_ex(key, value, expires).await
} else { } else {
conn.set(key, value).await.map_err(into_error) conn.set(key, value).await
} }
} }
async fn key_incr_( async fn key_incr_(
&self,
conn: &mut impl AsyncCommands, conn: &mut impl AsyncCommands,
key: &[u8], key: &[u8],
value: i64, value: i64,
expires: Option<u64>, expires: Option<u64>,
) -> trc::Result<i64> { ) -> RedisResult<i64> {
if let Some(expires) = expires { if let Some(expires) = expires {
redis::pipe() INCR_EXPIRE
.atomic() .key(key)
.incr(key, value) .arg(value)
.expire(key, expires as i64) .arg(expires as i64)
.ignore() .invoke_async(conn)
.query_async::<Vec<i64>>(conn)
.await .await
.map_err(into_error)
.map(|v| v.first().copied().unwrap_or(0))
} else { } else {
conn.incr(key, value).await.map_err(into_error) conn.incr(key, value).await
} }
} }
async fn try_lock_( async fn try_lock_(
&self,
conn: &mut impl AsyncCommands, conn: &mut impl AsyncCommands,
key: &[u8], key: &[u8],
expires: u64, expires: u64,
) -> trc::Result<bool> { ) -> RedisResult<bool> {
redis::cmd("SET") redis::cmd("SET")
.arg(key) .arg(key)
.arg(now() + expires) .arg(now() + expires)
@@ -259,18 +224,13 @@ impl RedisStore {
.query_async::<Option<String>>(conn) .query_async::<Option<String>>(conn)
.await .await
.map(|reply| reply.is_some()) .map(|reply| reply.is_some())
.map_err(into_error)
} }
async fn key_delete_(&self, conn: &mut impl AsyncCommands, key: &[u8]) -> trc::Result<()> { async fn key_delete_(conn: &mut impl AsyncCommands, key: &[u8]) -> RedisResult<()> {
conn.del(key).await.map_err(into_error) conn.del(key).await
} }
async fn key_delete_prefix_( async fn key_delete_prefix_(conn: &mut impl AsyncCommands, prefix: &[u8]) -> RedisResult<()> {
&self,
conn: &mut impl AsyncCommands,
prefix: &[u8],
) -> trc::Result<()> {
let mut pattern = Vec::with_capacity(prefix.len() + 1); let mut pattern = Vec::with_capacity(prefix.len() + 1);
pattern.extend_from_slice(prefix); pattern.extend_from_slice(prefix);
pattern.push(b'*'); pattern.push(b'*');
@@ -284,11 +244,10 @@ impl RedisStore {
.arg("COUNT") .arg("COUNT")
.arg(100) .arg(100)
.query_async(conn) .query_async(conn)
.await .await?;
.map_err(into_error)?;
if !keys.is_empty() { if !keys.is_empty() {
conn.del::<_, ()>(&keys).await.map_err(into_error)?; conn.del::<_, ()>(&keys).await?;
} }
if new_cursor != 0 { if new_cursor != 0 {
@@ -299,3 +258,34 @@ impl RedisStore {
} }
} }
} }
async fn with_conn<M, T>(
pool: &Pool<M>,
operation: impl AsyncFnOnce(&mut M::Type) -> RedisResult<T>,
) -> trc::Result<T>
where
M: Manager<Error = trc::Error>,
{
let mut conn = pool.get().await.map_err(into_error)?;
match operation(conn.as_mut()).await {
Ok(value) => Ok(value),
Err(err) => {
if is_stale_connection(&err) {
drop(Object::take(conn));
}
Err(into_error(err))
}
}
}
fn is_stale_connection(err: &RedisError) -> bool {
matches!(
err.retry_method(),
RetryMethod::Reconnect
| RetryMethod::ReconnectFromInitialConnections
| RetryMethod::RefreshSlotsAndRetry
| RetryMethod::MovedRedirect
| RetryMethod::AskRedirect
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "trc" name = "trc"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "event_macro" name = "event_macro"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[lib] [lib]
+3 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use std::{ use std::{
@@ -80,7 +82,7 @@ const SPAN_MAX_HOLD: u64 = 60 * 60 * 24; // 1 day
pub(crate) static COLLECTOR_THREAD: LazyLock<Arc<CollectorThread>> = LazyLock::new(|| { pub(crate) static COLLECTOR_THREAD: LazyLock<Arc<CollectorThread>> = LazyLock::new(|| {
Arc::new( Arc::new(
Builder::new() Builder::new()
.name("stalwart-collector".to_string()) .name("inbuxa-collector".to_string())
.spawn(move || { .spawn(move || {
Collector::default().collect(); Collector::default().collect();
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "types" name = "types"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+6 -2
View File
@@ -71,7 +71,7 @@ pub fn env_var(name: &str) -> Result<String, std::env::VarError> {
#[macro_export] #[macro_export]
macro_rules! brand_version { macro_rules! brand_version {
() => { () => {
"2026.9.22" "2026.9.23"
}; };
} }
@@ -82,7 +82,11 @@ macro_rules! brand_version {
/// this becomes just the version. /// this becomes just the version.
#[macro_export] #[macro_export]
macro_rules! brand_version_full { macro_rules! brand_version_full {
// The upstream crate version, without naming the upstream project: this
// string is user-visible (--version, the startup banner, the console,
// telemetry and the JMAP session's "implementation" field), and the name
// belongs only in copyright notices and the lineage line.
() => { () => {
concat!($crate::brand_version!(), " (Stalwart ", env!("CARGO_PKG_VERSION"), ")") concat!($crate::brand_version!(), " (upstream ", env!("CARGO_PKG_VERSION"), ")")
}; };
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "utils" name = "utils"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "proc_macros" name = "proc_macros"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[lib] [lib]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tests" name = "tests"
version = "0.16.22" version = "0.16.23"
edition = "2024" edition = "2024"
[features] [features]
+35
View File
@@ -133,6 +133,41 @@ pub async fn test(test: &TestServer) {
let samples = account.spam_training_samples().await; let samples = account.spam_training_samples().await;
assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 11); assert_eq!(samples.iter().filter(|x| !x.1.is_spam).count(), 11);
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 10); assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 10);
let support_id = test.account("[email protected]").id();
let jane = test.account("[email protected]");
let jane_id = jane.id();
let mut imap_jane = jane.imap_client().await;
let samples_for = |account_id, is_spam: bool| {
let admin = &admin;
async move {
admin
.spam_training_samples()
.await
.into_iter()
.filter(|(_, sample)| {
sample.account_id == Some(account_id) && sample.is_spam == is_spam
})
.count()
}
};
imap_jane
.append("Shared Folders/[email protected]/Drafts", SPAM[1])
.await;
assert_eq!(samples_for(support_id, true).await, 0);
imap_jane
.send_ok("SELECT \"Shared Folders/[email protected]/Drafts\"")
.await;
imap_jane.send_ok("MOVE * \"Junk Mail\"").await;
assert_eq!(samples_for(support_id, true).await, 1);
imap_jane.send_ok("SELECT \"Junk Mail\"").await;
imap_jane
.send_ok("MOVE * \"Shared Folders/[email protected]/Drafts\"")
.await;
assert_eq!(samples_for(jane_id, false).await, 1);
} }
pub async fn spam_classifier_model(server: &Server) -> SpamTrainer { pub async fn spam_classifier_model(server: &Server) -> SpamTrainer {
+18 -4
View File
@@ -122,18 +122,32 @@ pub async fn test(test: &TestServer) {
pop3.assert_read(ResponseType::Err).await; pop3.assert_read(ResponseType::Err).await;
// TOP // TOP
pop3.send("TOP 1 4").await; pop3.send("TOP 1 0").await;
pop3.assert_read(ResponseType::Multiline) pop3.assert_read(ResponseType::Multiline)
.await .await
.assert_contains("+OK 203 octets")
.assert_contains("Subject: TPS Report 0") .assert_contains("Subject: TPS Report 0")
.assert_contains("X-Spam-Status: No")
.assert_not_contains("I'm going to need those TPS 0 reports ASAP."); .assert_not_contains("I'm going to need those TPS 0 reports ASAP.");
pop3.send("TOP 3 4").await; pop3.send("TOP 1 1").await;
pop3.assert_read(ResponseType::Multiline)
.await
.assert_contains("Subject: TPS Report 0")
.assert_contains("I'm going to need those TPS 0 reports ASAP.")
.assert_not_contains("So, if you could do that, that'd be great.");
pop3.send("TOP 3 0").await;
pop3.assert_read(ResponseType::Multiline)
.await
.assert_contains("Subject: TPS Report 2")
.assert_not_contains("I'm going to need those TPS 2 reports ASAP.");
pop3.send("TOP 3 100").await;
pop3.assert_read(ResponseType::Multiline) pop3.assert_read(ResponseType::Multiline)
.await .await
.assert_contains("+OK 203 octets") .assert_contains("+OK 203 octets")
.assert_contains("Subject: TPS Report 2") .assert_contains("Subject: TPS Report 2")
.assert_not_contains("I'm going to need those TPS 2 reports ASAP."); .assert_contains("I'm going to need those TPS 2 reports ASAP.")
.assert_contains("So, if you could do that, that'd be great.");
pop3.send("TOP 4 1").await;
pop3.assert_read(ResponseType::Err).await;
// DELE + RSET + QUIT (should not delete messages) // DELE + RSET + QUIT (should not delete messages)
pop3.send("DELE 1").await; pop3.send("DELE 1").await;
+4
View File
@@ -37,6 +37,10 @@ const TESTS: &[(&str, &str)] = &[
"is_local_domain('FooBar.org') + '-' + is_local_address('[email protected]') + '-' + is_local_address('[email protected]')", "is_local_domain('FooBar.org') + '-' + is_local_address('[email protected]') + '-' + is_local_address('[email protected]')",
"1-1-1", "1-1-1",
), ),
(
"bit_and(254, 16) + '-' + bit_and(254, 1) + '-' + bit_and(80, 64) + '-' + bit_and(255, 128)",
"16-0-64-128",
),
]; ];
#[tokio::test] #[tokio::test]
+3 -1
View File
@@ -127,7 +127,9 @@ async fn fallback_relay() {
let next_due = now(); let next_due = now();
let queue_id = retry.queue_id; let queue_id = retry.queue_id;
retry.message.recipients[0].retry.due = next_due; retry.message.recipients[0].retry.due = next_due;
retry.save_changes(&local.server, prev_due.into()).await; retry
.save_changes(&local.server, prev_due.into(), None)
.await;
local local
.delivery_attempt(queue_id) .delivery_attempt(queue_id)
.await .await
+3 -1
View File
@@ -144,7 +144,9 @@ async fn starttls_optional() {
let next_due = now(); let next_due = now();
let queue_id = retry.queue_id; let queue_id = retry.queue_id;
retry.message.recipients[0].retry.due = next_due; retry.message.recipients[0].retry.due = next_due;
retry.save_changes(&local.server, prev_due.into()).await; retry
.save_changes(&local.server, prev_due.into(), None)
.await;
local local
.delivery_attempt_for_queue(queue_id, "default") .delivery_attempt_for_queue(queue_id, "default")
.await .await
+61 -4
View File
@@ -8,11 +8,12 @@ use crate::utils::server::{TestServer, TestServerBuilder};
use common::config::smtp::queue::{QueueExpiry, QueueName}; use common::config::smtp::queue::{QueueExpiry, QueueName};
use registry::schema::{ use registry::schema::{
enums::CompressionAlgo, enums::CompressionAlgo,
structs::{DsnReportSettings, Expression, ReportSettings}, structs::{DsnReportSettings, Expression, FileSystemStore, ReportSettings},
}; };
use smtp::queue::{ use smtp::queue::{
Error, ErrorDetails, HostResponse, Message, MessageWrapper, Recipient, Schedule, Status, Error, ErrorDetails, HostResponse, Message, MessageWrapper, RCPT_DSN_SENT, Recipient, Schedule,
UnexpectedResponse, dsn::SendDsn, Status, UnexpectedResponse,
dsn::{DsnStatus, SendDsn},
}; };
use smtp_proto::{RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_SUCCESS, Response}; use smtp_proto::{RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_SUCCESS, Response};
use std::{ use std::{
@@ -21,7 +22,7 @@ use std::{
path::PathBuf, path::PathBuf,
time::SystemTime, time::SystemTime,
}; };
use store::write::now; use store::{BlobStore, backend::fs::FsStore, write::now};
use types::blob_hash::BlobHash; use types::blob_hash::BlobHash;
#[tokio::test] #[tokio::test]
@@ -194,9 +195,65 @@ async fn generate_dsn() {
// Load queue // Load queue
let queue = local.read_queued_messages().await; let queue = local.read_queued_messages().await;
assert_eq!(queue.len(), 4); assert_eq!(queue.len(), 4);
// A DSN that cannot be written is retried rather than marked as sent
message.message.recipients = vec![Recipient {
address: "[email protected]".into(),
status: Status::PermanentFailure(ErrorDetails {
entity: "mx.example.org".into(),
details: Error::UnexpectedResponse(UnexpectedResponse {
command: "RCPT TO:<[email protected]>".into(),
response: Response {
code: 550,
esc: [5, 1, 2],
message: "User does not exist".into(),
},
}),
}),
flags,
orcpt: None,
retry: Schedule::now(),
notify: Schedule::now(),
expires: QueueExpiry::Ttl(10),
queue: QueueName::default(),
}];
let blob_store = local.server.blob_store().clone();
local.set_blob_store(unwritable_blob_store(local.tmp_dir()).await);
assert_eq!(
local.server.send_dsn(&mut message).await,
DsnStatus::Deferred
);
assert_eq!(message.message.recipients[0].flags & RCPT_DSN_SENT, 0);
local.assert_no_events();
assert_eq!(local.read_queued_messages().await.len(), 4);
local.set_blob_store(blob_store);
assert_eq!(
local.server.send_dsn(&mut message).await,
DsnStatus::Completed
);
assert_ne!(message.message.recipients[0].flags & RCPT_DSN_SENT, 0);
local.expect_message().await;
assert_eq!(local.read_queued_messages().await.len(), 5);
}
async fn unwritable_blob_store(tmp_dir: &str) -> BlobStore {
let path = format!("{tmp_dir}/unwritable-blob-store");
fs::write(&path, b"").unwrap();
FsStore::open(FileSystemStore { path, depth: 0 })
.await
.unwrap()
} }
impl TestServer { impl TestServer {
fn set_blob_store(&mut self, blob: BlobStore) {
let mut core = self.server.core.as_ref().clone();
core.storage.blob = blob;
self.server.core = core.into();
}
async fn compare_dsn(&self, message: Message, test: &str) { async fn compare_dsn(&self, message: Message, test: &str) {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("resources"); path.push("resources");
+3 -3
View File
@@ -33,15 +33,15 @@ async fn queue_due() {
let mut message = new_message(0); let mut message = new_message(0);
message.message.recipients.push(build_rcpt("c", 3, 8, 9)); message.message.recipients.push(build_rcpt("c", 3, 8, 9));
message.save_changes(&local.server, 0.into()).await; message.save_changes(&local.server, 0.into(), None).await;
let mut message = new_message(1); let mut message = new_message(1);
message.message.recipients.push(build_rcpt("b", 2, 6, 7)); message.message.recipients.push(build_rcpt("b", 2, 6, 7));
message.save_changes(&local.server, 0.into()).await; message.save_changes(&local.server, 0.into(), None).await;
let mut message = new_message(2); let mut message = new_message(2);
message.message.recipients.push(build_rcpt("a", 1, 4, 5)); message.message.recipients.push(build_rcpt("a", 1, 4, 5));
message.save_changes(&local.server, 0.into()).await; message.save_changes(&local.server, 0.into(), None).await;
for domain in vec!["a", "b", "c"].into_iter() { for domain in vec!["a", "b", "c"].into_iter() {
let now = now(); let now = now();
+123 -15
View File
@@ -592,14 +592,7 @@ END:VCARD
}) })
.await; .await;
let bill_messages = test let bill_messages = inbox_count(&test.server, &bill).await;
.server
.get_cached_messages(bill.id().document_id())
.await
.unwrap()
.emails
.items
.len();
lmtp.ingest( lmtp.ingest(
"[email protected]", "[email protected]",
@@ -617,17 +610,122 @@ END:VCARD
tokio::time::sleep(Duration::from_millis(200)).await; tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!( assert_eq!(
test.server inbox_count(&test.server, &bill).await,
.get_cached_messages(bill.id().document_id())
.await
.unwrap()
.emails
.items
.len(),
bill_messages + 1, bill_messages + 1,
"sub-addressed mailing list member was not delivered" "sub-addressed mailing list member was not delivered"
); );
// Lists nested within lists must be expanded recursively
admin
.registry_create_object(MailingList {
name: "engineering".to_string(),
recipients: Map::new(vec!["[email protected]".to_string()]),
domain_id,
..Default::default()
})
.await;
admin
.registry_create_object(MailingList {
name: "all-staff".to_string(),
recipients: Map::new(vec![
"[email protected]".to_string(),
"[email protected]".to_string(),
]),
domain_id,
..Default::default()
})
.await;
let jane_messages = inbox_count(&test.server, &jane).await;
let bill_messages = inbox_count(&test.server, &bill).await;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: Company picnic\r\n",
"\r\n",
"Bring your own stapler."
),
)
.await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
inbox_count(&test.server, &jane).await,
jane_messages + 1,
"nested mailing list member was not delivered"
);
assert_eq!(
inbox_count(&test.server, &bill).await,
bill_messages + 1,
"direct mailing list member was not delivered"
);
// Lists that reference each other must terminate and deliver exactly once
admin
.registry_create_object(MailingList {
name: "ouroboros-head".to_string(),
recipients: Map::new(vec![
"[email protected]".to_string(),
"[email protected]".to_string(),
"[email protected]".to_string(),
]),
domain_id,
..Default::default()
})
.await;
admin
.registry_create_object(MailingList {
name: "ouroboros-tail".to_string(),
recipients: Map::new(vec![
"[email protected]".to_string(),
"[email protected]".to_string(),
"[email protected]".to_string(),
]),
domain_id,
..Default::default()
})
.await;
let john_messages = inbox_count(&test.server, &john).await;
let jane_messages = inbox_count(&test.server, &jane).await;
let bill_messages = inbox_count(&test.server, &bill).await;
lmtp.ingest(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: Going in circles\r\n",
"\r\n",
"Please advise."
),
)
.await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
inbox_count(&test.server, &john).await,
john_messages + 1,
"cyclic mailing list did not deliver exactly once"
);
assert_eq!(
inbox_count(&test.server, &jane).await,
jane_messages + 1,
"cyclic mailing list did not deliver exactly once"
);
assert_eq!(
inbox_count(&test.server, &bill).await,
bill_messages + 1,
"member shared by two nested lists was delivered more than once"
);
// Remove test data // Remove test data
john.registry_destroy( john.registry_destroy(
ObjectType::MaskedEmail, ObjectType::MaskedEmail,
@@ -708,3 +806,13 @@ async fn message_metadata(server: &Server, account_id: u32, document_id: u32) ->
.deserialize::<MessageMetadata>() .deserialize::<MessageMetadata>()
.unwrap() .unwrap()
} }
async fn inbox_count(server: &Server, account: &Account) -> usize {
server
.get_cached_messages(account.id().document_id())
.await
.unwrap()
.emails
.items
.len()
}
+16
View File
@@ -21,6 +21,22 @@ at the repository root before the import is merged.
It needs Python 3.12+ (for `tarfile`'s `data` filter) and git. It needs Python 3.12+ (for `tarfile`'s `data` filter) and git.
## name-check.py
Fails when the upstream project's name appears in a Rust string literal that
`name-allowlist.txt` doesn't list. CI runs it on every push and pull request,
so an upstream merge can't bring the name back into what users and operators
see. Comments, copyright headers and test directories aren't checked.
```bash
tools/fork/name-check.py # exit 1 on anything new
tools/fork/name-check.py --list # every finding, in allowlist format
```
Rename what it reports. If a string has to stay, such as a key-derivation
context or a wire-protocol identifier, add its `--list` line to the allowlist
under the reason it stays.
## record-compat.py ## record-compat.py
Records what the `*_compat` tests compare against, from the Enterprise Records what the `*_compat` tests compare against, from the Enterprise
+59
View File
@@ -0,0 +1,59 @@
# String literals allowed to keep the upstream project's name.
# Read by tools/fork/name-check.py. One per line: path<TAB>literal as a JSON
# string, as `name-check.py --list` prints it. Each group says why it stays;
# add a line only under a reason, or with a new one.
# Key-derivation contexts. Renaming them invalidates every sealed OAuth token
# and client id already issued.
crates/common/src/auth/oauth/client_id.rs "stalwart-oauth-client-id-sw1"
crates/common/src/auth/oauth/token.rs "stalwart-oauth-token-sw1"
# Keys and prefixes of data already in the store.
crates/common/src/manager/application.rs "STALWART_APP_"
crates/common/src/manager/mod.rs "STALWART_SPAM_CLASSIFIER_MODEL.lz4"
crates/common/src/manager/mod.rs "STALWART_SPAM_TRAIN_DATA.lz4"
# The web interface's OAuth client id, which existing installs and the admin
# front end already use. The id itself, and the tests that check it.
crates/common/src/manager/first_party.rs "stalwart-webui"
crates/common/src/manager/application.rs "stalwart-webui"
crates/common/src/manager/application.rs "<meta name=\\\"oauth-client-id\\\" content=\\\"stalwart-webui\\\" />"
# Wire-protocol identifiers clients already hold or negotiate: WebDAV lock and
# sync tokens, the JMAP capability, Sieve extensions.
crates/dav/src/common/lock.rs "urn:stalwart:davsync:"
crates/dav/src/common/uri.rs "urn:stalwart:"
crates/dav/src/common/uri.rs "urn:stalwart:davlock:{id:x}"
crates/dav/src/common/uri.rs "urn:stalwart:davsync:"
crates/dav/src/common/uri.rs "urn:stalwart:davsync:{id:x}"
crates/dav/src/common/uri.rs "urn:stalwart:davsync:{id:x}:{seq:x}"
crates/jmap-proto/src/request/capability.rs "urn:stalwart:jmap"
crates/registry/src/schema/enums_impl.rs "vnd.stalwart.expressions"
crates/registry/src/schema/enums_impl.rs "vnd.stalwart.while"
# Moving an existing upstream installation over: its environment variables,
# and upstream's upgrade guide for the store conversion.
crates/types/src/branding.rs "STALWART_{name}"
crates/types/src/branding.rs "Warning: STALWART_{name} is deprecated; set INBUXA_{name} instead."
crates/store/src/build/registry.rs "⚠️ INBUXA_RECOVERY_ADMIN (or STALWART_RECOVERY_ADMIN) is set, but the"
crates/jmap/src/registry/mapping/bootstrap.rs "https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
crates/migration/src/lib.rs "https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
# Upstream's published spam-filter rules, fetched at runtime.
crates/registry/src/schema/structs_impl.rs "https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz"
# Test fixtures: web-push contact address parsing.
crates/common/src/network/webpush.rs " [email protected] "
crates/common/src/network/webpush.rs "MAILTO:[email protected]"
crates/common/src/network/webpush.rs "[email protected]"
crates/common/src/network/webpush.rs "http://stalw.art"
crates/common/src/network/webpush.rs "https://stalw.art/contact"
crates/common/src/network/webpush.rs "mailto:[email protected]"
crates/common/src/network/webpush.rs "stalw.art"
# OPEN, not yet decided (2026-09-22): operator-visible defaults. The log file
# prefix (TracerLog, and the bootstrap's tracer) names files stalwart.* in
# /var/log/inbuxa/, and the SQL stores default their database and user to
# "stalwart". Changing the SQL defaults would break an install relying on them.
crates/jmap/src/registry/mapping/bootstrap.rs "stalwart"
crates/registry/src/schema/structs_impl.rs "stalwart"
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Coffey Labs
# SPDX-License-Identifier: AGPL-3.0-only
"""
Fail when the upstream project's name turns up in a new Rust string literal.
tools/fork/name-check.py # check; exit 1 on anything new
tools/fork/name-check.py --list # print every finding, allowlist format
The name belongs only in copyright notices and the lineage line. Everything
else a user or operator can see -- messages, the version string, service
names, descriptions -- carries INBUXA's. Merging an upstream release brings
new strings in with the name, and the merge itself can't tell, so this runs
in CI on every push and pull request.
Scope: string literals in `crates/**/*.rs`, test directories excluded.
Comments are skipped, so copyright headers and doc comments never match.
Some literals have to keep the name -- key-derivation contexts, wire-protocol
identifiers, defaults that read an upstream installation -- and those are
listed in `name-allowlist.txt` beside this script, each under the reason it
stays. A finding is matched by file and literal text, not line number, so
the allowlist survives code moving around.
When the check fails, rename the string. If it genuinely has to stay, add
the line `--list` prints for it to the allowlist under a reason.
"""
import argparse
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
ALLOWLIST = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'name-allowlist.txt')
NAME = re.compile(r'stalwart|stalw\.art', re.IGNORECASE)
SKIP_DIRS = {'tests', 'benches', 'target', '.git'}
CHAR = re.compile(r"'(?:\\u\{[0-9a-fA-F]+\}|\\x[0-9a-fA-F]{2}|\\.|[^\\'\n])'")
RAW = re.compile(r'b?r(#*)"')
def literals(src):
"""Yield the text of every string literal in `src`, comments skipped."""
i, n = 0, len(src)
while i < n:
c = src[i]
if src.startswith('//', i):
i = src.find('\n', i)
if i < 0:
return
elif src.startswith('/*', i):
depth, i = 1, i + 2
while i < n and depth:
if src.startswith('/*', i):
depth, i = depth + 1, i + 2
elif src.startswith('*/', i):
depth, i = depth - 1, i + 2
else:
i += 1
elif c in 'br' and (m := RAW.match(src, i)) and (i == 0 or not (src[i - 1].isalnum() or src[i - 1] == '_')):
end = '"' + m.group(1)
j = src.find(end, m.end())
if j < 0:
return
yield src[m.end():j]
i = j + len(end)
elif c == '"':
j = i + 1
while j < n and src[j] != '"':
j += 2 if src[j] == '\\' else 1
yield src[i + 1:j]
i = j + 1
elif c == "'":
# A char literal, or else a lifetime / label, which is skipped.
m = CHAR.match(src, i)
i = m.end() if m else i + 1
else:
i += 1
def findings():
found = set()
crates = os.path.join(ROOT, 'crates')
for dirpath, dirnames, filenames in os.walk(crates):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
for f in sorted(filenames):
if not f.endswith('.rs'):
continue
path = os.path.join(dirpath, f)
with open(path, encoding='utf-8', errors='replace') as fh:
for lit in literals(fh.read()):
if NAME.search(lit):
found.add((os.path.relpath(path, ROOT), lit))
return found
def fmt(entry):
return f'{entry[0]}\t{json.dumps(entry[1], ensure_ascii=False)}'
def allowlist():
allowed = set()
with open(ALLOWLIST, encoding='utf-8') as fh:
for n, line in enumerate(fh, 1):
line = line.rstrip('\n')
if not line.strip() or line.lstrip().startswith('#'):
continue
path, sep, lit = line.partition('\t')
try:
allowed.add((path, json.loads(lit)))
except (ValueError, TypeError):
sys.exit(f'{ALLOWLIST}:{n}: expected "path<TAB>json string", got {line!r}')
if not sep:
sys.exit(f'{ALLOWLIST}:{n}: expected "path<TAB>json string", got {line!r}')
return allowed
def main():
ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0].strip())
ap.add_argument('--list', action='store_true', help='print every finding in allowlist format and exit')
args = ap.parse_args()
found = findings()
if args.list:
for entry in sorted(found):
print(fmt(entry))
return 0
allowed = allowlist()
new = sorted(found - allowed)
stale = sorted(allowed - found)
for entry in stale:
# Gone from the code: harmless, but the list should shrink with it.
print(f'stale allowlist entry, no longer in the code: {fmt(entry)}')
if new:
print(f'\n{len(new)} string literal(s) carry the upstream name. Rename them, or if one must stay,')
print(f'add its line to {os.path.relpath(ALLOWLIST, ROOT)} under the reason:\n')
for entry in new:
print(fmt(entry))
return 1
print(f'name check: clean ({len(found)} allowlisted literal(s)).')
return 0
if __name__ == '__main__':
sys.exit(main())