Compare commits
65
Commits
c240946248
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59e631eded | ||
|
|
716800d681 | ||
|
|
4b85113262 | ||
|
|
9cc9951428 | ||
|
|
5dde9793eb | ||
|
|
1a7859a8cc | ||
|
|
b90a7f173e | ||
|
|
e00978c0b4 | ||
|
|
ad58c35f39 | ||
|
|
181ab1c140 | ||
|
|
08f29926d4 | ||
|
|
fde43774b4 | ||
|
|
d86e7639ac | ||
|
|
fcef4b1c3f | ||
|
|
89860aa5cc | ||
|
|
6e50ba25a9 | ||
|
|
127ef5701d | ||
|
|
1543ea5a9e | ||
|
|
4cb42f28f3 | ||
|
|
2c684be5c9 | ||
|
|
19eb25a426 | ||
|
|
999ae12cc7 | ||
|
|
5853831bad | ||
|
|
639a415a4f | ||
|
|
9311c1a38b | ||
|
|
24be4a1b85 | ||
|
|
95f0445d83 | ||
|
|
c974a0918e | ||
|
|
7c80a12d75 | ||
|
|
22d8ad8572 | ||
|
|
7109e67f07 | ||
|
|
52b5a5f909 | ||
|
|
9232662913 | ||
|
|
57d1c5b074 | ||
|
|
ca3abf40f0 | ||
|
|
499e4d7810 | ||
|
|
212cd77cd3 | ||
|
|
7735780807 | ||
|
|
e223f7d327 | ||
|
|
30df055e39 | ||
|
|
5393c4405a | ||
|
|
cc532b914c | ||
|
|
c09eff2214 | ||
|
|
eba4c7a32e | ||
|
|
17426f6d60 | ||
|
|
d7c9416713 | ||
|
|
238079da66 | ||
|
|
0d8caaa514 | ||
|
|
ce6882fe93 | ||
|
|
3df042e7d4 | ||
|
|
64385007c1 | ||
|
|
4d794c6a65 | ||
|
|
a404ca89f0 | ||
|
|
79f54add2f | ||
|
|
86bf2432a2 | ||
|
|
5be578ba3c | ||
|
|
f32992ca36 | ||
|
|
a993f9ab01 | ||
|
|
99096cdc9b | ||
|
|
96ac70ad28 | ||
|
|
835b278e66 | ||
|
|
cc6f1eb298 | ||
|
|
674ae5d037 | ||
|
|
4799d191a0 | ||
|
|
c5bf67f1bf |
+8
-2
@@ -1,10 +1,16 @@
|
|||||||
// Ignore everything
|
# Ignore everything
|
||||||
*
|
*
|
||||||
|
|
||||||
// Allow what is needed
|
# Allow what is needed
|
||||||
!crates
|
!crates
|
||||||
!tests
|
!tests
|
||||||
!resources
|
!resources
|
||||||
|
|
||||||
|
# The patched dependency Cargo.toml's [patch.crates-io] points at. Without
|
||||||
|
# it the build context has no vendor/, and `cargo chef cook` fails on
|
||||||
|
# "failed to load source for dependency sieve-rs" -- which CI cannot see,
|
||||||
|
# because CI builds from a checkout and only the image build has a context.
|
||||||
|
!vendor
|
||||||
|
|
||||||
!Cargo.lock
|
!Cargo.lock
|
||||||
!Cargo.toml
|
!Cargo.toml
|
||||||
|
|||||||
+24
-3
@@ -20,15 +20,26 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# The upstream name in a new string literal, typically brought in by an
|
# What an upstream merge can bring in or leave behind without a conflict:
|
||||||
# upstream merge. Seconds, and needs no toolchain. tools/fork/name-check.py.
|
# the upstream name in a new string literal, and a changed upstream file
|
||||||
name-check:
|
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
||||||
|
# check diffs against the upstream snapshot branch, hence the full fetch.
|
||||||
|
fork-checks:
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
steps:
|
steps:
|
||||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- run: python3 tools/fork/name-check.py
|
- run: python3 tools/fork/name-check.py
|
||||||
|
- if: always()
|
||||||
|
run: python3 tools/fork/notice-check.py
|
||||||
|
# Cargo can patch a dependency to a directory in this repository, and
|
||||||
|
# the image builds from a context .dockerignore prunes to almost
|
||||||
|
# nothing. CI never sees the difference; a release does.
|
||||||
|
- if: always()
|
||||||
|
run: python3 tools/fork/context-check.py
|
||||||
|
|
||||||
build:
|
build:
|
||||||
# Either runner (host1 or host2): the build needs no docker socket.
|
# Either runner (host1 or host2): the build needs no docker socket.
|
||||||
@@ -61,6 +72,16 @@ jobs:
|
|||||||
# --no-run: the workflow compiled every test target without running them,
|
# --no-run: the workflow compiled every test target without running them,
|
||||||
# which catches a test that no longer builds without paying for the suite.
|
# which catches a test that no longer builds without paying for the suite.
|
||||||
- run: cargo test --workspace --locked --no-run
|
- run: cargo test --workspace --locked --no-run
|
||||||
|
# The release profile, on main only. It is the profile the image is
|
||||||
|
# built with, and it fails in ways the dev profile does not: v2026.9.24
|
||||||
|
# was tagged on a commit whose CI was green and whose release build
|
||||||
|
# could not compile the scim crate at all. A few minutes per merge is
|
||||||
|
# cheaper than finding that out from a tag, which throws away a
|
||||||
|
# multi-architecture build and leaves a version half-cut.
|
||||||
|
#
|
||||||
|
# Pull requests stay on the dev profile, where the wait is worth less.
|
||||||
|
- if: github.event_name == 'push'
|
||||||
|
run: cargo build -p inbuxa --locked --release
|
||||||
# Keep the cache from growing without bound: past 60 GB the target dir
|
# Keep the cache from growing without bound: past 60 GB the target dir
|
||||||
# is dropped and the next build starts cold. The download cache stays.
|
# is dropped and the next build starts cold. The download cache stays.
|
||||||
# Two builds (dev + test profiles) already fill ~22 GB, so the limit
|
# Two builds (dev + test profiles) already fill ~22 GB, so the limit
|
||||||
|
|||||||
+167
-18
@@ -3,19 +3,39 @@
|
|||||||
# whether a person pushed it or weekly-release.yml created it through the
|
# whether a person pushed it or weekly-release.yml created it through the
|
||||||
# releases API.
|
# releases API.
|
||||||
#
|
#
|
||||||
# The image is multi-arch (linux/amd64, linux/arm64) as before, but built in
|
# The image is multi-arch (linux/amd64, linux/arm64), built by two jobs on
|
||||||
# one buildx run on host1 instead of one native runner per architecture: the
|
# the image-build runner rather than one buildx run for both. The Dockerfile's
|
||||||
# Dockerfile's builder stage runs on the build platform and cross-compiles
|
# builder stage runs on the build platform and cross-compiles with an aarch64
|
||||||
# with an aarch64 linker, so only the small final stage (apt, setcap) goes
|
# linker, so only the small final stage (apt, setcap) goes through QEMU for
|
||||||
# through QEMU for arm64. No digest-joining job is needed.
|
# arm64 -- but two release builds (LTO, one codegen unit) side by side on one
|
||||||
|
# machine each take twice as long. Production runs amd64, so amd64 goes first
|
||||||
|
# and on its own:
|
||||||
|
# * publish-amd64 pushes :<version>-amd64 and :<version>, a plain amd64
|
||||||
|
# image, as soon as its build is done. A deploy can start from it.
|
||||||
|
# * publish-arm64 then builds arm64, pushes :<version>-arm64, and replaces
|
||||||
|
# :<version> with the two-platform index. :latest moves only here, so it
|
||||||
|
# never names an image without arm64.
|
||||||
|
#
|
||||||
|
# Both jobs use one BuildKit builder, `gitea-builder`, whose container
|
||||||
|
# (buildx_buildkit_gitea-builder0) and state volume stay on the runner's host
|
||||||
|
# between jobs: a job container's `buildx create` finds the existing container
|
||||||
|
# and reuses it and its cache. The dependency build (`cargo chef cook`) is
|
||||||
|
# keyed on the recipe, which only a dependency change alters, so a release
|
||||||
|
# normally compiles just the workspace. Removing that container or its volume
|
||||||
|
# costs the next release a cold build, nothing more. The planner and dependency
|
||||||
|
# layers for the build platform are shared, so arm64 also reuses what amd64
|
||||||
|
# just did where it can.
|
||||||
#
|
#
|
||||||
# Two guards before anything is pushed:
|
# Two guards before anything is pushed:
|
||||||
# * the tag must be v<brand_version!>. The version is a string in
|
# * the tag must be v<brand_version!>. The version is a string in
|
||||||
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
|
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
|
||||||
# with it, so a tag beside an unbumped macro would publish an image that
|
# with it, so a tag beside an unbumped macro would publish an image that
|
||||||
# reports a different version from its tag.
|
# reports a different version from its tag.
|
||||||
# * the tag must be on main, so an image never describes code that was never
|
# * the tag must be on main or on a release/* branch, so an image never
|
||||||
# reviewed onto the default branch.
|
# describes code that was never reviewed onto one of them. A release/*
|
||||||
|
# branch carries a hotfix: it starts at an earlier release tag, takes
|
||||||
|
# fixes through pull requests into it, and is tagged there, so production
|
||||||
|
# can get a fix without everything that has landed on main since.
|
||||||
#
|
#
|
||||||
# :latest moves with every published tag: tags are cut by the weekly release
|
# :latest moves with every published tag: tags are cut by the weekly release
|
||||||
# (or by hand for a real release); there are no prerelease tags here.
|
# (or by hand for a real release); there are no prerelease tags here.
|
||||||
@@ -57,12 +77,17 @@ jobs:
|
|||||||
echo "Refusing to publish an image that would report the wrong version." >&2
|
echo "Refusing to publish an image that would report the wrong version." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|
commit="$(git rev-parse "${TAG}^{commit}")"
|
||||||
|| { echo "$TAG is not on main" >&2; exit 1; }
|
on=""
|
||||||
|
for ref in origin/main $(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/*'); do
|
||||||
|
if git merge-base --is-ancestor "$commit" "$ref"; then on="$ref"; break; fi
|
||||||
|
done
|
||||||
|
[ -n "$on" ] || { echo "$TAG is not on main or a release/* branch" >&2; exit 1; }
|
||||||
|
echo "$TAG is on $on"
|
||||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||||
echo "version $V"
|
echo "version $V"
|
||||||
|
|
||||||
publish:
|
publish-amd64:
|
||||||
needs: [version]
|
needs: [version]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -81,16 +106,15 @@ jobs:
|
|||||||
test -n "$REGISTRY" && test -n "$VERSION"
|
test -n "$REGISTRY" && test -n "$VERSION"
|
||||||
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
|
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
|
||||||
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
|
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
|
||||||
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
|
||||||
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
|
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
|
||||||
# Attestations off, as before: they add manifests of their own to the
|
# Attestations off, as before: they add manifests of their own, and the
|
||||||
# index, and the index should hold the two images and nothing else.
|
# index should hold the two images and nothing else.
|
||||||
- run: |
|
- run: |
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
--platform linux/amd64,linux/arm64 \
|
--platform linux/amd64 \
|
||||||
--provenance=false --sbom=false \
|
--provenance=false --sbom=false \
|
||||||
|
--tag "$IMAGE:$VERSION-amd64" \
|
||||||
--tag "$IMAGE:$VERSION" \
|
--tag "$IMAGE:$VERSION" \
|
||||||
--tag "$IMAGE:latest" \
|
|
||||||
--push .
|
--push .
|
||||||
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||||
# Gitea keeps a container package on its owner; linking it shows it on
|
# Gitea keeps a container package on its owner; linking it shows it on
|
||||||
@@ -103,11 +127,47 @@ jobs:
|
|||||||
- if: always()
|
- if: always()
|
||||||
run: docker logout "$REGISTRY" || true
|
run: docker logout "$REGISTRY" || true
|
||||||
|
|
||||||
|
publish-arm64:
|
||||||
|
needs: [version, publish-amd64]
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
- run: |
|
||||||
|
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||||
|
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
|
||||||
|
# The index is built from the two per-architecture tags rather than from
|
||||||
|
# :<version>, which by now is the amd64 image and would be read as such.
|
||||||
|
- run: |
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--provenance=false --sbom=false \
|
||||||
|
--tag "$IMAGE:$VERSION-arm64" \
|
||||||
|
--push .
|
||||||
|
docker buildx imagetools create \
|
||||||
|
--tag "$IMAGE:$VERSION" \
|
||||||
|
--tag "$IMAGE:latest" \
|
||||||
|
"$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64"
|
||||||
|
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||||
|
- if: always()
|
||||||
|
run: docker logout "$REGISTRY" || true
|
||||||
|
|
||||||
# The weekly release creates its Release (and so the tag) first; a tag
|
# The weekly release creates its Release (and so the tag) first; a tag
|
||||||
# pushed by hand has none. Either way the tag ends up with exactly one
|
# pushed by hand has none. Either way the tag ends up with exactly one
|
||||||
# Release, created after the image exists so its pull instructions work.
|
# Release, created once the amd64 image exists so its pull instructions
|
||||||
|
# work; arm64 and the binaries follow.
|
||||||
release:
|
release:
|
||||||
needs: [version, publish]
|
needs: [version, publish-amd64]
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
@@ -131,8 +191,97 @@ jobs:
|
|||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
if e.code != 404: raise
|
if e.code != 404: raise
|
||||||
image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}"
|
image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}"
|
||||||
body = f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`."
|
body = (f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`. "
|
||||||
|
"amd64 is published first; arm64 is added to the same tag when its build "
|
||||||
|
"finishes, and `:latest` moves then.\n\n"
|
||||||
|
"Binaries for a host install are attached: `inbuxa-linux-amd64.tar.gz` and "
|
||||||
|
"`inbuxa-linux-arm64.tar.gz`, with `SHA256SUMS`. Each is the binary out of this "
|
||||||
|
"release's image for that architecture, so it is the same build. The image "
|
||||||
|
"grants it `cap_net_bind_service`; a host install has to grant that itself "
|
||||||
|
"(`setcap`, or `AmbientCapabilities` in the unit) to bind port 25.")
|
||||||
data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body}).encode()
|
data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body}).encode()
|
||||||
r = json.load(urllib.request.urlopen(urllib.request.Request(f"{api}/releases", data=data, headers=h)))
|
r = json.load(urllib.request.urlopen(urllib.request.Request(f"{api}/releases", data=data, headers=h)))
|
||||||
print(f"created release {r['tag_name']}")
|
print(f"created release {r['tag_name']}")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
|
# The binaries for a host install, taken out of the image that was just
|
||||||
|
# pushed rather than compiled again.
|
||||||
|
#
|
||||||
|
# Building them separately would mean a second Rust build per architecture
|
||||||
|
# -- the slowest thing this pipeline does -- and would leave two artifacts
|
||||||
|
# that are supposed to be the same build but only probably are. Extracting
|
||||||
|
# them makes that identity a fact: the binary in the tarball is the file
|
||||||
|
# the image runs.
|
||||||
|
#
|
||||||
|
# `docker create` does not start anything, so pulling an arm64 image on an
|
||||||
|
# amd64 runner and copying a file out of it needs no emulation.
|
||||||
|
binaries:
|
||||||
|
needs: [version, publish-arm64, release]
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: take the binaries out of the image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
|
||||||
|
mkdir -p /out && cd /out
|
||||||
|
for arch in amd64 arm64; do
|
||||||
|
docker pull -q --platform "linux/$arch" "$IMAGE:$VERSION"
|
||||||
|
id="$(docker create --platform "linux/$arch" "$IMAGE:$VERSION")"
|
||||||
|
docker cp "$id:/usr/local/bin/inbuxa" "inbuxa"
|
||||||
|
docker rm -f "$id" >/dev/null
|
||||||
|
chmod 0755 inbuxa
|
||||||
|
tar -czf "inbuxa-linux-$arch.tar.gz" inbuxa
|
||||||
|
rm inbuxa
|
||||||
|
done
|
||||||
|
sha256sum inbuxa-linux-*.tar.gz > SHA256SUMS
|
||||||
|
cat SHA256SUMS
|
||||||
|
- name: attach them to the release
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
apk add --no-cache -q python3
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, urllib.request, urllib.error, uuid, pathlib
|
||||||
|
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
|
||||||
|
tok = {"Authorization": f"token {os.environ['TOKEN']}"}
|
||||||
|
tag = os.environ["TAG"]
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
return json.load(urllib.request.urlopen(urllib.request.Request(api + path, headers=tok)))
|
||||||
|
|
||||||
|
rel = get(f"/releases/tags/{tag}")
|
||||||
|
assets = {a["name"]: a["id"] for a in get(f"/releases/{rel['id']}/assets")}
|
||||||
|
|
||||||
|
for path in ["/out/inbuxa-linux-amd64.tar.gz", "/out/inbuxa-linux-arm64.tar.gz", "/out/SHA256SUMS"]:
|
||||||
|
name = os.path.basename(path)
|
||||||
|
# A re-run of a tag replaces its assets rather than leaving two
|
||||||
|
# files with the same name and different contents.
|
||||||
|
if name in assets:
|
||||||
|
urllib.request.urlopen(urllib.request.Request(
|
||||||
|
f"{api}/releases/{rel['id']}/assets/{assets[name]}", headers=tok, method="DELETE"))
|
||||||
|
boundary = uuid.uuid4().hex
|
||||||
|
body = b"".join([
|
||||||
|
f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n".encode(),
|
||||||
|
b"Content-Type: application/octet-stream\r\n\r\n",
|
||||||
|
pathlib.Path(path).read_bytes(),
|
||||||
|
f"\r\n--{boundary}--\r\n".encode(),
|
||||||
|
])
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{api}/releases/{rel['id']}/assets?name={name}", data=body, method="POST",
|
||||||
|
headers={**tok, "Content-Type": f"multipart/form-data; boundary={boundary}"})
|
||||||
|
urllib.request.urlopen(req)
|
||||||
|
print("attached", name)
|
||||||
|
PY
|
||||||
|
- if: always()
|
||||||
|
run: docker logout "$REGISTRY" || true
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
# An issue is opened once per release: an existing one with the same title,
|
# An issue is opened once per release: an existing one with the same title,
|
||||||
# open or closed, stops a second.
|
# open or closed, stops a second.
|
||||||
#
|
#
|
||||||
|
# It also watches spam-filter, whose rules the server bundles
|
||||||
|
# (resources/spam-filter/), and opens an issue for a newer release.
|
||||||
|
#
|
||||||
# Daily 06:17 UTC; run it by hand with workflow_dispatch.
|
# Daily 06:17 UTC; run it by hand with workflow_dispatch.
|
||||||
name: upstream-watch
|
name: upstream-watch
|
||||||
|
|
||||||
@@ -64,7 +67,7 @@ jobs:
|
|||||||
and key(r["tag_name"]) > key(base)),
|
and key(r["tag_name"]) > key(base)),
|
||||||
key=lambda r: key(r["tag_name"]))
|
key=lambda r: key(r["tag_name"]))
|
||||||
if not newer:
|
if not newer:
|
||||||
print(f"Up to date: {base} is the newest upstream release."); sys.exit(0)
|
print(f"Up to date: {base} is the newest upstream release.")
|
||||||
|
|
||||||
# Titles and bodies stay free of the upstream project's name, as the
|
# Titles and bodies stay free of the upstream project's name, as the
|
||||||
# rest of the fork's user-visible text does.
|
# rest of the fork's user-visible text does.
|
||||||
@@ -85,4 +88,35 @@ jobs:
|
|||||||
"add any new third-party notices to `THIRD-PARTY.md`, then merge `upstream` into `main`.")
|
"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})
|
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
|
||||||
print(f"{tag}: opened #{issue['number']}.")
|
print(f"{tag}: opened #{issue['number']}.")
|
||||||
|
|
||||||
|
# The spam filter rules bundled with the server (resources/spam-filter/):
|
||||||
|
# an issue when spam-filter publishes a newer release than the one
|
||||||
|
# BUNDLED_SPAM_RULES_VERSION names on main.
|
||||||
|
src = call("GET", f"{api}/contents/crates/common/src/manager/spam_rules.rs?ref=main")
|
||||||
|
import base64
|
||||||
|
text = base64.b64decode(src["content"]).decode()
|
||||||
|
m = re.search(r'BUNDLED_SPAM_RULES_VERSION: &str = "(\d+\.\d+\.\d+)"', text)
|
||||||
|
if not m:
|
||||||
|
print("Can't read BUNDLED_SPAM_RULES_VERSION from spam_rules.rs", file=sys.stderr); sys.exit(1)
|
||||||
|
bundled = "v" + m.group(1)
|
||||||
|
rels = call("GET", "https://api.github.com/repos/stalwartlabs/spam-filter/releases?per_page=30", token=None)
|
||||||
|
newer = sorted((r for r in rels
|
||||||
|
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
|
||||||
|
and key(r["tag_name"]) > key(bundled)),
|
||||||
|
key=lambda r: key(r["tag_name"]))
|
||||||
|
if not newer:
|
||||||
|
print(f"Up to date: the bundled spam rules are {bundled}, the newest release."); sys.exit(0)
|
||||||
|
latest = newer[-1]
|
||||||
|
tag = latest["tag_name"]
|
||||||
|
title = f"Update the bundled spam rules to {tag}"
|
||||||
|
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=bundled+spam+rules&limit=50")}
|
||||||
|
if title in existing:
|
||||||
|
print(f"spam rules {tag}: issue already exists."); sys.exit(0)
|
||||||
|
body = (f"spam-filter published {tag} on {latest['published_at'][:10]}. "
|
||||||
|
f"The server bundles {bundled}.\n\n"
|
||||||
|
"Update it as resources/spam-filter/README.md describes: take the rules file "
|
||||||
|
f"from the {tag} release (by tag, not `latest`), set BUNDLED_SPAM_RULES_VERSION, "
|
||||||
|
"and run the antispam test.")
|
||||||
|
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
|
||||||
|
print(f"spam rules {tag}: opened #{issue['number']}.")
|
||||||
PY
|
PY
|
||||||
|
|||||||
Generated
+1
-2
@@ -7958,8 +7958,6 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "sieve-rs"
|
name = "sieve-rs"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bd00a548fde57bd0c8e7c13ae65fc5fe30bd923ff8655c81e010f3f02a90997b"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -8590,6 +8588,7 @@ dependencies = [
|
|||||||
"mail-builder 1.0.0",
|
"mail-builder 1.0.0",
|
||||||
"mail-parser",
|
"mail-parser",
|
||||||
"managesieve",
|
"managesieve",
|
||||||
|
"migration",
|
||||||
"nlp",
|
"nlp",
|
||||||
"pop3",
|
"pop3",
|
||||||
"quick-xml 0.41.0",
|
"quick-xml 0.41.0",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
# Vendored crates are patched in below, not built as members.
|
||||||
|
exclude = ["vendor"]
|
||||||
members = [
|
members = [
|
||||||
"crates/main",
|
"crates/main",
|
||||||
"crates/types",
|
"crates/types",
|
||||||
@@ -78,3 +80,10 @@ incremental = false
|
|||||||
debug-assertions = false
|
debug-assertions = false
|
||||||
overflow-checks = false
|
overflow-checks = false
|
||||||
rpath = false
|
rpath = false
|
||||||
|
|
||||||
|
# inbuxa: sieve-rs spells upstream's name into its Sieve extension names
|
||||||
|
# (vnd.stalwart.*), which scripts `require` and ManageSieve advertises.
|
||||||
|
# vendor/sieve-rs is the published 0.7.3 with those renamed; see its
|
||||||
|
# VENDORED.md. Re-vendor when the version in Cargo.lock moves.
|
||||||
|
[patch.crates-io]
|
||||||
|
sieve-rs = { path = "vendor/sieve-rs" }
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
|
|||||||
g++-x86-64-linux-gnu binutils-x86-64-linux-gnu
|
g++-x86-64-linux-gnu binutils-x86-64-linux-gnu
|
||||||
RUN rustup target add "$(cat /target.txt)"
|
RUN rustup target add "$(cat /target.txt)"
|
||||||
COPY --from=planner /recipe.json /recipe.json
|
COPY --from=planner /recipe.json /recipe.json
|
||||||
|
# inbuxa: [patch.crates-io] points sieve-rs at vendor/, and the recipe only
|
||||||
|
# carries the workspace's own manifests, so cooking the dependencies needs the
|
||||||
|
# vendored crate itself (the context allows it since #27; this puts it here).
|
||||||
|
COPY vendor/ vendor/
|
||||||
RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json
|
RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**INBUXA** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
|
**inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
|
||||||
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
|
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
|
||||||
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
|
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
|
||||||
Project site: [inbuxa.org](https://inbuxa.org). Documentation: [docs.inbuxa.org](https://docs.inbuxa.org).
|
Project site: [inbuxa.org](https://inbuxa.org). Documentation: [docs.inbuxa.org](https://docs.inbuxa.org).
|
||||||
|
|
||||||
Stalwart ships some features only in a paid Enterprise Edition: multi-tenancy,
|
Stalwart ships some features only in a paid Enterprise Edition: multi-tenancy,
|
||||||
masked email, undelete and others. INBUXA ships everything to everybody under
|
masked email, undelete and others. **inbuxa** ships everything to everybody under
|
||||||
the AGPL-3.0, rebuilding those features independently and without using any
|
the AGPL-3.0, rebuilding those features independently and without using any
|
||||||
of Stalwart's Enterprise code.
|
of Stalwart's Enterprise code.
|
||||||
|
|
||||||
@@ -46,25 +46,26 @@ docker build -t inbuxa . # or the container image
|
|||||||
```
|
```
|
||||||
|
|
||||||
Settings are read from `INBUXA_*` environment variables. An existing Stalwart
|
Settings are read from `INBUXA_*` environment variables. An existing Stalwart
|
||||||
install's `STALWART_*` variables still work, with a warning to rename them.
|
install's `STALWART_*` variables aren't read: the server stops at startup and
|
||||||
|
names each one to rename.
|
||||||
New installs keep their data in `/var/lib/inbuxa` and logs in
|
New installs keep their data in `/var/lib/inbuxa` and logs in
|
||||||
`/var/log/inbuxa`. Existing installs keep the paths their configuration
|
`/var/log/inbuxa`. Existing installs keep the paths their configuration
|
||||||
already names, so none of their data moves.
|
already names, so none of their data moves.
|
||||||
|
|
||||||
## License and credits
|
## License and credits
|
||||||
|
|
||||||
INBUXA is free software under the [GNU Affero General Public License,
|
**inbuxa** is free software under the [GNU Affero General Public License,
|
||||||
version 3](./LICENSES/AGPL-3.0-only.txt).
|
version 3](./LICENSES/AGPL-3.0-only.txt).
|
||||||
|
|
||||||
It is a fork of Stalwart, copyright © Stalwart Labs LLC, **modified by
|
It is a fork of Stalwart, copyright © Stalwart Labs LLC, **modified by
|
||||||
Coffey Labs in 2026**. Upstream's copyright notices are kept on every file
|
Coffey Labs in 2026**. Upstream's copyright notices are kept on every file
|
||||||
they cover, and every upstream file this fork changed says so in its header,
|
they cover, and every upstream file this fork changed says so in its header,
|
||||||
under the notice it came with. Stalwart's files are dual-licensed
|
under the notice it came with. Stalwart's files are dual-licensed
|
||||||
AGPL-3.0-only or Stalwart's Enterprise License, and INBUXA takes them under
|
AGPL-3.0-only or Stalwart's Enterprise License, and **inbuxa** takes them under
|
||||||
the AGPL-3.0 only. A few of those files also carry code from other projects
|
the AGPL-3.0 only. A few of those files also carry code from other projects
|
||||||
under MIT or BSD licenses, which stays under those licenses;
|
under MIT or BSD licenses, which stays under those licenses;
|
||||||
[THIRD-PARTY.md](./THIRD-PARTY.md) lists it with its notices. "Stalwart" is
|
[THIRD-PARTY.md](./THIRD-PARTY.md) lists it with its notices. "Stalwart" is
|
||||||
Stalwart Labs' name. INBUXA isn't affiliated with or endorsed by Stalwart
|
Stalwart Labs' name. **inbuxa** isn't affiliated with or endorsed by Stalwart
|
||||||
Labs.
|
Labs.
|
||||||
|
|
||||||
The INBUXA mark reuses ihasmail's cat-and-envelope artwork.
|
The **inbuxa** mark reuses ihasmail's cat-and-envelope artwork.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ carry their own license files.
|
|||||||
| `crates/common/src/network/acme/directory.rs`, `crates/common/src/network/acme/jose.rs`, `crates/common/src/network/acme/order.rs` | [rustls-acme](https://github.com/FlorianUekermann/rustls-acme) (MIT or Apache-2.0) | Copyright (c) Florian Uekermann |
|
| `crates/common/src/network/acme/directory.rs`, `crates/common/src/network/acme/jose.rs`, `crates/common/src/network/acme/order.rs` | [rustls-acme](https://github.com/FlorianUekermann/rustls-acme) (MIT or Apache-2.0) | Copyright (c) Florian Uekermann |
|
||||||
| `crates/types/src/id.rs` | [crockford](https://github.com/archer884/crockford) (MIT or Apache-2.0) | Copyright (c) 2017 J/A <archer884@gmail.com> |
|
| `crates/types/src/id.rs` | [crockford](https://github.com/archer884/crockford) (MIT or Apache-2.0) | Copyright (c) 2017 J/A <archer884@gmail.com> |
|
||||||
| `crates/nlp/src/tokenizers/types.rs` | test cases from [linkify](https://github.com/robinst/linkify) (MIT or Apache-2.0) | Copyright (c) 2017 Robin Stocker |
|
| `crates/nlp/src/tokenizers/types.rs` | test cases from [linkify](https://github.com/robinst/linkify) (MIT or Apache-2.0) | Copyright (c) 2017 Robin Stocker |
|
||||||
|
| `resources/spam-filter/spam-filter-rules.json.gz` | the published rules of [spam-filter](https://github.com/stalwartlabs/spam-filter) v3.0.2, unmodified, built into the server as its default spam rules (MIT or Apache-2.0) | Copyright (C) 2024, Stalwart Labs LLC |
|
||||||
|
|
||||||
Each notice above applies with this permission notice:
|
Each notice above applies with this permission notice:
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -1,8 +1,8 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Stalwart Management API
|
title: inbuxa Management API
|
||||||
description: |
|
description: |
|
||||||
REST Management API for Stalwart server. These endpoints are helpers
|
REST Management API for the inbuxa server. These endpoints are helpers
|
||||||
that complement the JMAP API — most of the server's configuration and data
|
that complement the JMAP API — most of the server's configuration and data
|
||||||
is managed via JMAP (see `POST /jmap/`). The endpoints documented here cover
|
is managed via JMAP (see `POST /jmap/`). The endpoints documented here cover
|
||||||
interactive login, account introspection, configuration schema retrieval and
|
interactive login, account introspection, configuration schema retrieval and
|
||||||
@@ -12,11 +12,11 @@ info:
|
|||||||
name: AGPL-3.0-only OR LicenseRef-SEL
|
name: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
servers:
|
servers:
|
||||||
- url: https://{host}
|
- url: https://{host}
|
||||||
description: Stalwart server
|
description: inbuxa server
|
||||||
variables:
|
variables:
|
||||||
host:
|
host:
|
||||||
default: mail.example.com
|
default: mail.example.com
|
||||||
description: The hostname of Stalwart server
|
description: The hostname of the inbuxa server
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
- basicAuth: []
|
- basicAuth: []
|
||||||
@@ -154,7 +154,7 @@ paths:
|
|||||||
operationId: getSchema
|
operationId: getSchema
|
||||||
summary: Return the configuration schema at a specific hash
|
summary: Return the configuration schema at a specific hash
|
||||||
description: |
|
description: |
|
||||||
Returns the JSON Schema describing the full Stalwart configuration tree.
|
Returns the JSON Schema describing the full inbuxa configuration tree.
|
||||||
The response is always gzip-encoded (`Content-Encoding: gzip`) and served
|
The response is always gzip-encoded (`Content-Encoding: gzip`) and served
|
||||||
with an immutable cache policy — the schema for a given hash never
|
with an immutable cache policy — the schema for a given hash never
|
||||||
changes. If the hash does not match the server's current schema, the
|
changes. If the hash does not match the server's current schema, the
|
||||||
@@ -183,7 +183,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
description: JSON Schema document describing Stalwart config
|
description: JSON Schema document describing inbuxa config
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
'302':
|
'302':
|
||||||
description: Redirect to the current schema URL when the hash is stale
|
description: Redirect to the current schema URL when the hash is stale
|
||||||
@@ -395,7 +395,7 @@ components:
|
|||||||
WWW-Authenticate:
|
WWW-Authenticate:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
example: Bearer realm="Stalwart Server"
|
example: Bearer realm="inbuxa Server"
|
||||||
content:
|
content:
|
||||||
application/problem+json:
|
application/problem+json:
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
Vendored
+289
-29
@@ -13,20 +13,27 @@ use crate::{
|
|||||||
storage::Storage,
|
storage::Storage,
|
||||||
telemetry::Telemetry,
|
telemetry::Telemetry,
|
||||||
},
|
},
|
||||||
ipc::{QueueEvent, RegistryChange},
|
ipc::{BroadcastEvent, QueueEvent, RegistryChange},
|
||||||
network::security::{BlockedIps, IpWithTtl},
|
network::security::{BlockedIps, IpWithTtl},
|
||||||
};
|
};
|
||||||
use ahash::AHashMap;
|
use ahash::AHashMap;
|
||||||
use directory::Directories;
|
use directory::Directories;
|
||||||
use registry::{
|
use registry::{
|
||||||
schema::{prelude::ObjectType, structs::BlockedIp},
|
schema::{prelude::ObjectType, structs::BlockedIp},
|
||||||
types::error::{Error, Warning},
|
types::{
|
||||||
|
error::{Error, Warning},
|
||||||
|
id::ObjectId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use store::{LookupStores, registry::bootstrap::Bootstrap, write::now};
|
use store::{LookupStores, registry::bootstrap::Bootstrap, write::now};
|
||||||
|
|
||||||
pub struct ReloadResult {
|
pub struct ReloadResult {
|
||||||
|
/// Errors that kept the reload from being applied.
|
||||||
pub errors: Vec<Error>,
|
pub errors: Vec<Error>,
|
||||||
|
/// inbuxa: errors in objects that already failed when the running
|
||||||
|
/// settings were built; logged, but they don't refuse a reload.
|
||||||
|
pub known_errors: Vec<Error>,
|
||||||
pub warnings: Vec<Warning>,
|
pub warnings: Vec<Warning>,
|
||||||
pub replaced_core: bool,
|
pub replaced_core: bool,
|
||||||
}
|
}
|
||||||
@@ -114,42 +121,66 @@ impl Server {
|
|||||||
directories: directory.directories,
|
directories: directory.directories,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse tracers
|
// inbuxa: upstream swapped the core only when the whole build
|
||||||
|
// was free of errors, while boot runs with whatever built. So one
|
||||||
|
// object that failed (a DNS lookup that timed out, say) refused
|
||||||
|
// every later reload, cluster-wide when the reload came from
|
||||||
|
// ReloadSettings, and the running settings went stale. Now a
|
||||||
|
// reload is refused only for errors in objects that built when
|
||||||
|
// the running settings were built: those would be lost by
|
||||||
|
// applying it. Objects that already failed then are missing
|
||||||
|
// from the running settings anyway, as at boot, so their
|
||||||
|
// errors are reported but don't hold the reload back.
|
||||||
let tracers = Telemetry::parse(&mut bootstrap, &storage).await;
|
let tracers = Telemetry::parse(&mut bootstrap, &storage).await;
|
||||||
|
let core = Box::pin(Core::parse(&mut bootstrap, storage)).await;
|
||||||
|
let mut servers = Listeners::parse(&mut bootstrap).await;
|
||||||
|
|
||||||
if bootstrap.errors.is_empty() {
|
if !self.has_new_build_errors(&bootstrap.errors) {
|
||||||
let core = Box::pin(Core::parse(&mut bootstrap, storage)).await;
|
servers
|
||||||
|
.parse_tcp_acceptors(&mut bootstrap, self.inner.clone())
|
||||||
|
.await;
|
||||||
|
|
||||||
if bootstrap.errors.is_empty() {
|
if !self.has_new_build_errors(&bootstrap.errors) {
|
||||||
let mut servers = Listeners::parse(&mut bootstrap).await;
|
// Update core
|
||||||
servers
|
self.inner.shared_core.store(core.into());
|
||||||
.parse_tcp_acceptors(&mut bootstrap, self.inner.clone())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if bootstrap.errors.is_empty() {
|
// Update tracers
|
||||||
// Update core
|
tracers.update();
|
||||||
self.inner.shared_core.store(core.into());
|
|
||||||
|
|
||||||
// Update tracers
|
// Reload queue settings
|
||||||
|
self.inner
|
||||||
|
.ipc
|
||||||
|
.queue_tx
|
||||||
|
.send(QueueEvent::ReloadSettings)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
tracers.update();
|
// inbuxa: the task manager reads the node's role on
|
||||||
|
// every scan; scan now, so a role that gained task
|
||||||
|
// types starts claiming them without waiting out the
|
||||||
|
// refresh interval
|
||||||
|
self.inner.ipc.task_tx.notify_one();
|
||||||
|
|
||||||
// Reload queue settings
|
self.record_build_errors(&bootstrap.errors);
|
||||||
self.inner
|
|
||||||
.ipc
|
|
||||||
.queue_tx
|
|
||||||
.send(QueueEvent::ReloadSettings)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
return Ok(ReloadResult {
|
return Ok(ReloadResult {
|
||||||
errors: bootstrap.errors,
|
errors: Vec::new(),
|
||||||
warnings: bootstrap.warnings,
|
known_errors: bootstrap.errors,
|
||||||
replaced_core: true,
|
warnings: bootstrap.warnings,
|
||||||
});
|
replaced_core: true,
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let (known_errors, errors) = std::mem::take(&mut bootstrap.errors)
|
||||||
|
.into_iter()
|
||||||
|
.partition(|error| self.is_known_build_error(error));
|
||||||
|
return Ok(ReloadResult {
|
||||||
|
errors,
|
||||||
|
known_errors,
|
||||||
|
warnings: bootstrap.warnings,
|
||||||
|
replaced_core: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +194,7 @@ impl ReloadResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn log(&self) {
|
pub fn log(&self) {
|
||||||
for error in &self.errors {
|
for error in self.errors.iter().chain(&self.known_errors) {
|
||||||
error.log();
|
error.log();
|
||||||
}
|
}
|
||||||
for warning in &self.warnings {
|
for warning in &self.warnings {
|
||||||
@@ -176,8 +207,237 @@ impl From<Bootstrap> for ReloadResult {
|
|||||||
fn from(bootstrap: Bootstrap) -> Self {
|
fn from(bootstrap: Bootstrap) -> Self {
|
||||||
Self {
|
Self {
|
||||||
errors: bootstrap.errors,
|
errors: bootstrap.errors,
|
||||||
|
known_errors: Vec::new(),
|
||||||
warnings: bootstrap.warnings,
|
warnings: bootstrap.warnings,
|
||||||
replaced_core: false,
|
replaced_core: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: which objects failed to build for the running settings
|
||||||
|
impl Server {
|
||||||
|
/// Records the objects that failed to build for the settings now running.
|
||||||
|
pub fn record_build_errors(&self, errors: &[Error]) {
|
||||||
|
*self.inner.data.build_errors.lock() = errors.iter().filter_map(error_object).collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_known_build_error(&self, error: &Error) -> bool {
|
||||||
|
error_object(error).is_some_and(|id| self.inner.data.build_errors.lock().contains(&id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_new_build_errors(&self, errors: &[Error]) -> bool {
|
||||||
|
errors.iter().any(|error| !self.is_known_build_error(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_object(error: &Error) -> Option<ObjectId> {
|
||||||
|
match error {
|
||||||
|
Error::Validation { object_id, .. }
|
||||||
|
| Error::Build { object_id, .. }
|
||||||
|
| Error::NotFound { object_id } => Some(*object_id),
|
||||||
|
Error::Internal { object_id, .. } => *object_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbuxa: upstream applied a registry write to the running settings only on
|
||||||
|
// an explicit x:Action ReloadSettings (Directory and Authentication aside), so
|
||||||
|
// a new MtaDeliverySchedule, say, stayed unknown ("Queue strategy not found")
|
||||||
|
// until someone reloaded. Writes to objects the settings are built from now
|
||||||
|
// reload them, here and across the cluster, as ReloadSettings does.
|
||||||
|
|
||||||
|
/// Coalesces the full reloads that registry writes trigger: a write waits for
|
||||||
|
/// a reload that started after it was stored, and joins one if it can, so a
|
||||||
|
/// burst of writes costs a reload or two rather than one each.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SettingsReloadGate {
|
||||||
|
requested: std::sync::atomic::AtomicU64,
|
||||||
|
state: tokio::sync::Mutex<SettingsReloadState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SettingsReloadState {
|
||||||
|
completed: u64,
|
||||||
|
refused: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reload a write to `object` calls for: the object to reload, or None
|
||||||
|
/// when the running settings don't hold that object (accounts, domains and
|
||||||
|
/// other data read as needed, stores, which take a restart, and objects with
|
||||||
|
/// reload actions of their own, such as applications). Blocked IPs have a
|
||||||
|
/// reload of their own; allowed IPs take the full one.
|
||||||
|
pub fn write_reload_target(object: ObjectType) -> Option<ObjectType> {
|
||||||
|
match object {
|
||||||
|
ObjectType::Certificate => Some(ObjectType::Certificate),
|
||||||
|
ObjectType::MemoryLookupKey
|
||||||
|
| ObjectType::MemoryLookupKeyValue
|
||||||
|
| ObjectType::HttpLookup
|
||||||
|
| ObjectType::StoreLookup => Some(ObjectType::StoreLookup),
|
||||||
|
ObjectType::BlockedIp => Some(ObjectType::BlockedIp),
|
||||||
|
// Allowed IPs are part of the core's security settings
|
||||||
|
// (Security::parse), which only a full reload rebuilds; the blocked-IP
|
||||||
|
// reload doesn't touch them
|
||||||
|
ObjectType::AllowedIp
|
||||||
|
| ObjectType::AcmeProvider
|
||||||
|
| ObjectType::AddressBook
|
||||||
|
| ObjectType::AiModel
|
||||||
|
| ObjectType::Asn
|
||||||
|
| ObjectType::Authentication
|
||||||
|
| ObjectType::Cache
|
||||||
|
| ObjectType::Calendar
|
||||||
|
| ObjectType::CalendarAlarm
|
||||||
|
| ObjectType::CalendarScheduling
|
||||||
|
| ObjectType::ClusterRole
|
||||||
|
| ObjectType::DataRetention
|
||||||
|
| ObjectType::Directory
|
||||||
|
| ObjectType::DkimReportSettings
|
||||||
|
| ObjectType::DmarcReportSettings
|
||||||
|
| ObjectType::DnsResolver
|
||||||
|
| ObjectType::DsnReportSettings
|
||||||
|
| ObjectType::Email
|
||||||
|
| ObjectType::EventTracingLevel
|
||||||
|
| ObjectType::FileStorage
|
||||||
|
| ObjectType::Http
|
||||||
|
| ObjectType::HttpForm
|
||||||
|
| ObjectType::Imap
|
||||||
|
| ObjectType::Jmap
|
||||||
|
| ObjectType::Metrics
|
||||||
|
| ObjectType::MtaConnectionStrategy
|
||||||
|
| ObjectType::MtaDeliverySchedule
|
||||||
|
| ObjectType::MtaExtensions
|
||||||
|
| ObjectType::MtaHook
|
||||||
|
| ObjectType::MtaInboundSession
|
||||||
|
| ObjectType::MtaInboundThrottle
|
||||||
|
| ObjectType::MtaMilter
|
||||||
|
| ObjectType::MtaOutboundStrategy
|
||||||
|
| ObjectType::MtaOutboundThrottle
|
||||||
|
| ObjectType::MtaQueueQuota
|
||||||
|
| ObjectType::MtaRoute
|
||||||
|
| ObjectType::MtaStageAuth
|
||||||
|
| ObjectType::MtaStageConnect
|
||||||
|
| ObjectType::MtaStageData
|
||||||
|
| ObjectType::MtaStageEhlo
|
||||||
|
| ObjectType::MtaStageMail
|
||||||
|
| ObjectType::MtaStageRcpt
|
||||||
|
| ObjectType::MtaSts
|
||||||
|
| ObjectType::MtaTlsStrategy
|
||||||
|
| ObjectType::MtaVirtualQueue
|
||||||
|
| ObjectType::NetworkListener
|
||||||
|
| ObjectType::OidcProvider
|
||||||
|
| ObjectType::ReportSettings
|
||||||
|
| ObjectType::Search
|
||||||
|
| ObjectType::Security
|
||||||
|
| ObjectType::SenderAuth
|
||||||
|
| ObjectType::Sharing
|
||||||
|
| ObjectType::SieveSystemInterpreter
|
||||||
|
| ObjectType::SieveSystemScript
|
||||||
|
| ObjectType::SieveUserInterpreter
|
||||||
|
| ObjectType::SieveUserScript
|
||||||
|
| ObjectType::SpamClassifier
|
||||||
|
| ObjectType::SpamDnsblServer
|
||||||
|
| ObjectType::SpamDnsblSettings
|
||||||
|
| ObjectType::SpamFileExtension
|
||||||
|
| ObjectType::SpamPyzor
|
||||||
|
| ObjectType::SpamRule
|
||||||
|
| ObjectType::SpamSettings
|
||||||
|
| ObjectType::SpamTag
|
||||||
|
| ObjectType::SpfReportSettings
|
||||||
|
| ObjectType::SystemSettings
|
||||||
|
| ObjectType::TaskManager
|
||||||
|
| ObjectType::TlsReportSettings
|
||||||
|
| ObjectType::Tracer
|
||||||
|
| ObjectType::WebDav
|
||||||
|
| ObjectType::WebHook => Some(object),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// Applies a stored registry write to `object` to the running settings,
|
||||||
|
/// and on success tells the other nodes to do the same. Returns None when
|
||||||
|
/// the write needs no reload, Some(Ok(())) when it was applied, and
|
||||||
|
/// Some(Err(reason)) when the reload was refused (the write stays stored;
|
||||||
|
/// ReloadSettings reports the same errors).
|
||||||
|
pub async fn reload_after_write(&self, object: ObjectType) -> Option<Result<(), String>> {
|
||||||
|
let target = write_reload_target(object)?;
|
||||||
|
let change = RegistryChange::Reload(target);
|
||||||
|
|
||||||
|
if matches!(
|
||||||
|
target,
|
||||||
|
ObjectType::Certificate | ObjectType::StoreLookup | ObjectType::BlockedIp
|
||||||
|
) {
|
||||||
|
// Cheap, and limited to their own objects
|
||||||
|
let result = self.reload_and_broadcast(change).await;
|
||||||
|
return Some(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
let gate = &self.inner.data.settings_reload;
|
||||||
|
let ticket = gate
|
||||||
|
.requested
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
|
||||||
|
+ 1;
|
||||||
|
let mut state = gate.state.lock().await;
|
||||||
|
if state.completed >= ticket {
|
||||||
|
// A reload that started after this write was stored has run
|
||||||
|
return Some(state.refused.clone().map_or(Ok(()), Err));
|
||||||
|
}
|
||||||
|
let covers = gate.requested.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let result = self.reload_and_broadcast(change).await;
|
||||||
|
state.completed = covers;
|
||||||
|
state.refused = result.clone().err();
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reload_and_broadcast(&self, change: RegistryChange) -> Result<(), String> {
|
||||||
|
match Box::pin(self.reload_registry(change)).await {
|
||||||
|
Ok(reload) if !reload.has_errors() => {
|
||||||
|
reload.log();
|
||||||
|
self.cluster_broadcast(BroadcastEvent::RegistryChange(change))
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Ok(reload) => {
|
||||||
|
reload.log();
|
||||||
|
let reason = describe_reload_errors(&reload.errors);
|
||||||
|
trc::event!(
|
||||||
|
Registry(trc::RegistryEvent::BuildWarning),
|
||||||
|
Details = "Settings didn't reload after a registry write",
|
||||||
|
Reason = reason.clone(),
|
||||||
|
);
|
||||||
|
Err(reason)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let reason = err.to_string();
|
||||||
|
trc::error!(err.details("Failed to reload settings after a registry write"));
|
||||||
|
Err(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: a refused reload's errors in a sentence: the first one, naming its
|
||||||
|
/// object, and how many more there are.
|
||||||
|
pub fn describe_reload_errors(errors: &[Error]) -> String {
|
||||||
|
let mut description = match errors.first() {
|
||||||
|
Some(Error::Build { object_id, message }) => format!("{object_id}: {message}"),
|
||||||
|
Some(Error::Validation { object_id, errors }) => format!(
|
||||||
|
"{object_id}: {}",
|
||||||
|
errors
|
||||||
|
.iter()
|
||||||
|
.map(|err| err.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ")
|
||||||
|
),
|
||||||
|
Some(Error::Internal {
|
||||||
|
object_id: Some(object_id),
|
||||||
|
error,
|
||||||
|
}) => format!("{object_id}: {error}"),
|
||||||
|
Some(Error::Internal { error, .. }) => error.to_string(),
|
||||||
|
Some(Error::NotFound { object_id }) => format!("{object_id} was not found"),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
let more = errors.len().saturating_sub(1);
|
||||||
|
if more > 0 {
|
||||||
|
description.push_str(&format!(" ({more} more in the server log.)"));
|
||||||
|
}
|
||||||
|
description
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,9 +93,12 @@ impl Data {
|
|||||||
registry_id_gen: id_generator.clone(),
|
registry_id_gen: id_generator.clone(),
|
||||||
span_id_gen: id_generator,
|
span_id_gen: id_generator,
|
||||||
queue_status: true.into(),
|
queue_status: true.into(),
|
||||||
|
settings_reload: Default::default(),
|
||||||
|
store_health: Default::default(),
|
||||||
applications,
|
applications,
|
||||||
logos: Default::default(),
|
logos: Default::default(),
|
||||||
smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"),
|
smtp_connectors: TlsConnectors::try_new().failed("Failed to build TLS connectors"),
|
||||||
|
build_errors: Default::default(),
|
||||||
asn_geo_data: Default::default(),
|
asn_geo_data: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,9 +237,12 @@ impl Default for Data {
|
|||||||
span_id_gen: Default::default(),
|
span_id_gen: Default::default(),
|
||||||
registry_id_gen: Default::default(),
|
registry_id_gen: Default::default(),
|
||||||
queue_status: true.into(),
|
queue_status: true.into(),
|
||||||
|
settings_reload: Default::default(),
|
||||||
|
store_health: Default::default(),
|
||||||
applications: WebApplications::new(),
|
applications: WebApplications::new(),
|
||||||
logos: Default::default(),
|
logos: Default::default(),
|
||||||
smtp_connectors: TlsConnectors::try_new().unwrap(),
|
smtp_connectors: TlsConnectors::try_new().unwrap(),
|
||||||
|
build_errors: Default::default(),
|
||||||
asn_geo_data: Default::default(),
|
asn_geo_data: Default::default(),
|
||||||
lookup_stores: Default::default(),
|
lookup_stores: Default::default(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,9 @@ impl Scripting {
|
|||||||
.with_cpu_limit(trusted.max_cpu_cycles as usize)
|
.with_cpu_limit(trusted.max_cpu_cycles as usize)
|
||||||
.with_max_nested_includes(trusted.max_nested_includes as usize)
|
.with_max_nested_includes(trusted.max_nested_includes as usize)
|
||||||
.with_max_received_headers(trusted.max_received_headers as usize)
|
.with_max_received_headers(trusted.max_received_headers as usize)
|
||||||
.with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs());
|
.with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs())
|
||||||
|
// inbuxa: without it, `environment "name"` answers sieve-rs's default
|
||||||
|
.with_env_variable("name", types::brand_server!());
|
||||||
trusted_runtime.set_local_hostname(local_hostname.clone());
|
trusted_runtime.set_local_hostname(local_hostname.clone());
|
||||||
untrusted_runtime.set_local_hostname(local_hostname);
|
untrusted_runtime.set_local_hostname(local_hostname);
|
||||||
|
|
||||||
@@ -279,3 +281,23 @@ impl Clone for Scripting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use sieve::compiler::grammar::Capability;
|
||||||
|
|
||||||
|
// inbuxa: sieve-rs is vendored (vendor/sieve-rs) to carry the fork's
|
||||||
|
// name in its Sieve extensions. If Cargo.lock moves sieve-rs past the
|
||||||
|
// vendored version, Cargo drops the patch with only a warning and
|
||||||
|
// upstream's spelling comes back; this fails instead.
|
||||||
|
#[test]
|
||||||
|
fn sieve_extensions_carry_the_fork_name() {
|
||||||
|
for (capability, name) in [
|
||||||
|
(Capability::While, "vnd.inbuxa.while"),
|
||||||
|
(Capability::Expressions, "vnd.inbuxa.expressions"),
|
||||||
|
] {
|
||||||
|
assert_eq!(capability.to_string(), name);
|
||||||
|
assert_eq!(Capability::parse(name), capability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use mail_auth::common::resolver::ToReverseName;
|
|||||||
use nlp::classifier::model::{CcfhClassifier, FhClassifier};
|
use nlp::classifier::model::{CcfhClassifier, FhClassifier};
|
||||||
use registry::schema::{
|
use registry::schema::{
|
||||||
enums::{ExpressionVariable, ModelSize},
|
enums::{ExpressionVariable, ModelSize},
|
||||||
prelude::ObjectType,
|
|
||||||
structs::{
|
structs::{
|
||||||
self, SpamDnsblServer, SpamDnsblSettings, SpamFileExtension, SpamPyzor, SpamRule,
|
self, SpamDnsblServer, SpamDnsblSettings, SpamFileExtension, SpamPyzor, SpamRule,
|
||||||
SpamSettings, SpamTag,
|
SpamSettings, SpamTag,
|
||||||
@@ -25,10 +24,10 @@ use registry::schema::{
|
|||||||
use sieve::SpamStatus;
|
use sieve::SpamStatus;
|
||||||
use std::{
|
use std::{
|
||||||
net::{IpAddr, SocketAddr},
|
net::{IpAddr, SocketAddr},
|
||||||
time::Duration,
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
use store::registry::{RegistryObject, bootstrap::Bootstrap};
|
use store::registry::{RegistryObject, bootstrap::Bootstrap};
|
||||||
use tokio::net::lookup_host;
|
|
||||||
use utils::{cache::CacheItemWeight, glob::GlobMap};
|
use utils::{cache::CacheItemWeight, glob::GlobMap};
|
||||||
|
|
||||||
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)]
|
#[derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize, Debug, Default)]
|
||||||
@@ -157,7 +156,11 @@ pub struct FtrlParameters {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PyzorConfig {
|
pub struct PyzorConfig {
|
||||||
pub address: SocketAddr,
|
// inbuxa: the server is resolved when a message is checked, not while the
|
||||||
|
// settings are built (see PyzorConfig::address)
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub resolved: Arc<parking_lot::Mutex<Option<(SocketAddr, Instant)>>>,
|
||||||
pub timeout: Duration,
|
pub timeout: Duration,
|
||||||
pub min_count: u64,
|
pub min_count: u64,
|
||||||
pub min_wl_count: u64,
|
pub min_wl_count: u64,
|
||||||
@@ -243,7 +246,8 @@ impl SpamFilterConfig {
|
|||||||
spam_threshold: spam.score_spam.into_inner() as f32,
|
spam_threshold: spam.score_spam.into_inner() as f32,
|
||||||
},
|
},
|
||||||
grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()),
|
grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()),
|
||||||
spam_rules_url: spam.spam_filter_rules_url,
|
// inbuxa: unset, empty or upstream's old default means the bundled rules
|
||||||
|
spam_rules_url: crate::manager::spam_rules::rules_url(spam.spam_filter_rules_url),
|
||||||
url_client: utils::http::http_client_builder(true)
|
url_client: utils::http::http_client_builder(true)
|
||||||
.pool_max_idle_per_host(0)
|
.pool_max_idle_per_host(0)
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
@@ -473,31 +477,15 @@ impl PyzorConfig {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = pyzor.port;
|
// inbuxa: upstream resolved the host here and reported a failed lookup
|
||||||
let host = pyzor.host;
|
// as a build error, so a DNS hiccup on one node refused every settings
|
||||||
let address = match lookup_host(format!("{host}:{port}"))
|
// reload on it (and, from the node that ran ReloadSettings, across the
|
||||||
.await
|
// cluster). The lookup now happens when a message is checked; a
|
||||||
.map(|mut a| a.next())
|
// failure there is logged as a Pyzor error for that message.
|
||||||
{
|
|
||||||
Ok(Some(address)) => address,
|
|
||||||
Ok(None) => {
|
|
||||||
bp.build_error(
|
|
||||||
ObjectType::SpamPyzor.singleton(),
|
|
||||||
"Invalid address: No addresses found.",
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
bp.build_error(
|
|
||||||
ObjectType::SpamPyzor.singleton(),
|
|
||||||
format!("Invalid address: {}", err),
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
PyzorConfig {
|
PyzorConfig {
|
||||||
address,
|
host: pyzor.host,
|
||||||
|
port: pyzor.port as u16,
|
||||||
|
resolved: Default::default(),
|
||||||
timeout: pyzor.timeout.into_inner(),
|
timeout: pyzor.timeout.into_inner(),
|
||||||
min_count: pyzor.block_count,
|
min_count: pyzor.block_count,
|
||||||
min_wl_count: pyzor.allow_count,
|
min_wl_count: pyzor.allow_count,
|
||||||
@@ -507,6 +495,35 @@ impl PyzorConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: how long a resolved Pyzor address is reused
|
||||||
|
const PYZOR_RESOLVE_TTL: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
impl PyzorConfig {
|
||||||
|
/// The server's address: the host itself when it is an IP address,
|
||||||
|
/// otherwise the first address it resolves to, reused for five minutes.
|
||||||
|
pub async fn address(&self) -> std::io::Result<SocketAddr> {
|
||||||
|
if let Ok(ip) = self.host.parse::<IpAddr>() {
|
||||||
|
return Ok(SocketAddr::new(ip, self.port));
|
||||||
|
}
|
||||||
|
if let Some((address, resolved_at)) = *self.resolved.lock()
|
||||||
|
&& resolved_at.elapsed() < PYZOR_RESOLVE_TTL
|
||||||
|
{
|
||||||
|
return Ok(address);
|
||||||
|
}
|
||||||
|
let address = tokio::net::lookup_host((self.host.as_str(), self.port))
|
||||||
|
.await?
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
format!("{} has no addresses", self.host),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
*self.resolved.lock() = Some((address, Instant::now()));
|
||||||
|
Ok(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ClassifierConfig {
|
impl ClassifierConfig {
|
||||||
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
|
pub async fn parse(bp: &mut Bootstrap) -> Option<Self> {
|
||||||
let classifier = bp.setting_infallible::<structs::SpamClassifier>().await;
|
let classifier = bp.setting_infallible::<structs::SpamClassifier>().await;
|
||||||
|
|||||||
@@ -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::resolver::Policy;
|
use self::resolver::Policy;
|
||||||
@@ -22,7 +24,7 @@ use registry::schema::{
|
|||||||
};
|
};
|
||||||
use smtp_proto::*;
|
use smtp_proto::*;
|
||||||
use std::{
|
use std::{
|
||||||
net::{SocketAddr, ToSocketAddrs},
|
net::{IpAddr, SocketAddr},
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
@@ -384,19 +386,16 @@ impl SessionConfig {
|
|||||||
Some(Milter {
|
Some(Milter {
|
||||||
enable: bp.compile_expr(id, &milter.ctx_enable()),
|
enable: bp.compile_expr(id, &milter.ctx_enable()),
|
||||||
id,
|
id,
|
||||||
addrs: format!("{}:{}", milter.hostname, milter.port)
|
// inbuxa: upstream resolved the hostname here (a
|
||||||
.to_socket_addrs()
|
// blocking lookup) and made a failure a build error,
|
||||||
.map_err(|err| {
|
// which refused the whole settings reload. An IP
|
||||||
bp.build_error(
|
// address is kept as is; a name is resolved on each
|
||||||
id,
|
// connection (MilterClient::connect).
|
||||||
format!(
|
addrs: milter
|
||||||
"Unable to resolve milter hostname {}: {}",
|
.hostname
|
||||||
milter.hostname, err
|
.parse::<IpAddr>()
|
||||||
),
|
.map(|ip| vec![SocketAddr::new(ip, milter.port as u16)])
|
||||||
)
|
.unwrap_or_default(),
|
||||||
})
|
|
||||||
.ok()?
|
|
||||||
.collect(),
|
|
||||||
hostname: milter.hostname,
|
hostname: milter.hostname,
|
||||||
port: milter.port as u16,
|
port: milter.port as u16,
|
||||||
timeout_connect: milter.timeout_connect.into_inner(),
|
timeout_connect: milter.timeout_connect.into_inner(),
|
||||||
|
|||||||
@@ -335,3 +335,72 @@ impl EmailPush {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the task locks this node holds, so a graceful stop can hand them
|
||||||
|
/// back instead of leaving the tasks blocked until the locks expire.
|
||||||
|
pub struct TaskLocks {
|
||||||
|
held: parking_lot::Mutex<ahash::AHashSet<u64>>,
|
||||||
|
stopping: AtomicBool,
|
||||||
|
expiry: std::sync::atomic::AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TaskLocks {
|
||||||
|
/// How long a task lock lasts, in seconds, unless it is released first
|
||||||
|
/// or renewed. inbuxa: upstream held a lock for an hour, so a killed
|
||||||
|
/// node's tasks waited that long; the lock is now a five-minute lease
|
||||||
|
/// that the task manager renews every third of it while the task runs
|
||||||
|
/// (renew_task_locks), so a dead node's tasks run elsewhere within
|
||||||
|
/// minutes.
|
||||||
|
pub const DEFAULT_EXPIRY: u64 = 5 * 60;
|
||||||
|
|
||||||
|
pub fn is_stopping(&self) -> bool {
|
||||||
|
self.stopping.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops new claims and returns the ids of every lock still held.
|
||||||
|
pub fn stop(&self) -> Vec<u64> {
|
||||||
|
self.stopping.store(true, Ordering::Release);
|
||||||
|
self.held.lock().drain().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&self, id: u64) {
|
||||||
|
self.held.lock().insert(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&self, id: u64) {
|
||||||
|
self.held.lock().remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn held(&self) -> usize {
|
||||||
|
self.held.lock().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the tasks this node holds, to renew their locks.
|
||||||
|
pub fn held_ids(&self) -> Vec<u64> {
|
||||||
|
self.held.lock().iter().copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: whether this node holds (and is running) the task.
|
||||||
|
pub fn is_held(&self, id: u64) -> bool {
|
||||||
|
self.held.lock().contains(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expiry(&self) -> u64 {
|
||||||
|
self.expiry.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes the lock lifetime; the tests shorten it.
|
||||||
|
pub fn set_expiry(&self, seconds: u64) {
|
||||||
|
self.expiry.store(seconds.max(1), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TaskLocks {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
held: Default::default(),
|
||||||
|
stopping: AtomicBool::new(false),
|
||||||
|
expiry: std::sync::atomic::AtomicU64::new(Self::DEFAULT_EXPIRY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,11 +161,19 @@ pub struct Data {
|
|||||||
pub span_id_gen: SnowflakeIdGenerator,
|
pub span_id_gen: SnowflakeIdGenerator,
|
||||||
pub registry_id_gen: SnowflakeIdGenerator,
|
pub registry_id_gen: SnowflakeIdGenerator,
|
||||||
pub queue_status: AtomicBool,
|
pub queue_status: AtomicBool,
|
||||||
|
// inbuxa: coalesces the settings reloads registry writes trigger
|
||||||
|
pub settings_reload: cache::reload::SettingsReloadGate,
|
||||||
|
// inbuxa: the readiness probe's cached answer
|
||||||
|
pub store_health: storage::ready::StoreHealth,
|
||||||
|
|
||||||
pub applications: WebApplications,
|
pub applications: WebApplications,
|
||||||
pub logos: Mutex<AHashMap<Box<str>, LogoCache>>,
|
pub logos: Mutex<AHashMap<Box<str>, LogoCache>>,
|
||||||
|
|
||||||
pub smtp_connectors: TlsConnectors,
|
pub smtp_connectors: TlsConnectors,
|
||||||
|
|
||||||
|
// inbuxa: the objects that failed to build when the running settings
|
||||||
|
// were built, at boot or by the last applied reload (see reload_registry)
|
||||||
|
pub build_errors: Mutex<AHashSet<registry::types::id::ObjectId>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -279,6 +287,8 @@ pub struct HttpAuthCache {
|
|||||||
pub struct Ipc {
|
pub struct Ipc {
|
||||||
pub push_tx: mpsc::Sender<PushEvent>,
|
pub push_tx: mpsc::Sender<PushEvent>,
|
||||||
pub task_tx: Arc<Notify>,
|
pub task_tx: Arc<Notify>,
|
||||||
|
// inbuxa: task locks held by this node, released on a graceful stop
|
||||||
|
pub task_locks: Arc<crate::ipc::TaskLocks>,
|
||||||
pub queue_tx: mpsc::Sender<QueueEvent>,
|
pub queue_tx: mpsc::Sender<QueueEvent>,
|
||||||
pub report_tx: mpsc::Sender<ReportingEvent>,
|
pub report_tx: mpsc::Sender<ReportingEvent>,
|
||||||
pub broadcast_tx: Option<mpsc::Sender<BroadcastEvent>>,
|
pub broadcast_tx: Option<mpsc::Sender<BroadcastEvent>>,
|
||||||
|
|||||||
@@ -514,12 +514,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn index_is_rewritten_with_the_prefix_and_client_id() {
|
fn index_is_rewritten_with_the_prefix_and_client_id() {
|
||||||
let meta = oauth_client_id_meta("stalwart-webui");
|
let meta = oauth_client_id_meta("inbuxa-webui");
|
||||||
let html = String::from_utf8(rewrite_index(INDEX, "admin", Some(&meta))).unwrap();
|
let html = String::from_utf8(rewrite_index(INDEX, "admin", Some(&meta))).unwrap();
|
||||||
|
|
||||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||||
assert!(
|
assert!(
|
||||||
html.contains("<meta name=\"oauth-client-id\" content=\"stalwart-webui\" />"),
|
html.contains("<meta name=\"oauth-client-id\" content=\"inbuxa-webui\" />"),
|
||||||
"{html}"
|
"{html}"
|
||||||
);
|
);
|
||||||
assert!(html.contains("<title>Portal</title>"), "{html}");
|
assert!(html.contains("<title>Portal</title>"), "{html}");
|
||||||
@@ -541,7 +541,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn index_without_a_placeholder_is_left_alone() {
|
fn index_without_a_placeholder_is_left_alone() {
|
||||||
let bundle = "<head>\n <base href=\"/\" />\n</head>";
|
let bundle = "<head>\n <base href=\"/\" />\n</head>";
|
||||||
let meta = oauth_client_id_meta("stalwart-webui");
|
let meta = oauth_client_id_meta("inbuxa-webui");
|
||||||
let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap();
|
let html = String::from_utf8(rewrite_index(bundle, "admin", Some(&meta))).unwrap();
|
||||||
|
|
||||||
assert_eq!(html, "<head>\n <base href=\"/admin/\" />\n</head>");
|
assert_eq!(html, "<head>\n <base href=\"/admin/\" />\n</head>");
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ use utils::{UnwrapFailure, codec::leb128::Leb128_};
|
|||||||
|
|
||||||
pub(super) const MAGIC_MARKER: u8 = 123;
|
pub(super) const MAGIC_MARKER: u8 = 123;
|
||||||
|
|
||||||
|
// inbuxa: blobs kept under a fixed name instead of a content hash. Nothing
|
||||||
|
// links to them, so the export names them outright.
|
||||||
|
const NAMED_BLOBS: &[&[u8]] = &[
|
||||||
|
crate::manager::SPAM_CLASSIFIER_KEY,
|
||||||
|
crate::manager::SPAM_TRAINER_KEY,
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
|
||||||
pub(super) enum Family {
|
pub(super) enum Family {
|
||||||
Data = 0,
|
Data = 0,
|
||||||
@@ -143,15 +150,21 @@ impl Core {
|
|||||||
.await
|
.await
|
||||||
.failed("Failed to iterate over data store");
|
.failed("Failed to iterate over data store");
|
||||||
|
|
||||||
for hash in blobs {
|
// inbuxa: the trained spam classifier and its trainer state are
|
||||||
|
// blobs stored under fixed names with no blob link, so the walk
|
||||||
|
// over links above never reaches them.
|
||||||
|
let named = NAMED_BLOBS.iter().map(|key| key.to_vec());
|
||||||
|
for key in blobs
|
||||||
|
.into_iter()
|
||||||
|
.map(|hash| hash.as_slice().to_vec())
|
||||||
|
.chain(named)
|
||||||
|
{
|
||||||
if let Some(blob) = blob_store
|
if let Some(blob) = blob_store
|
||||||
.get_blob(hash.as_slice(), 0..usize::MAX)
|
.get_blob(&key, 0..usize::MAX)
|
||||||
.await
|
.await
|
||||||
.failed("Failed to get blob")
|
.failed("Failed to get blob")
|
||||||
{
|
{
|
||||||
writer
|
writer.send((key, blob)).failed("Failed to send key");
|
||||||
.send((hash.as_slice().to_vec(), blob))
|
|
||||||
.failed("Failed to send key");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -323,7 +336,13 @@ impl Family {
|
|||||||
SUBSPACE_REGISTRY_IDX,
|
SUBSPACE_REGISTRY_IDX,
|
||||||
SUBSPACE_REGISTRY_PK,
|
SUBSPACE_REGISTRY_PK,
|
||||||
SUBSPACE_DIRECTORY,
|
SUBSPACE_DIRECTORY,
|
||||||
store::SUBSPACE_INBUXA, // inbuxa: masked email
|
// inbuxa: registry objects the upstream list left out, so an
|
||||||
|
// export dropped them: archived items (undelete) and spam
|
||||||
|
// training samples. Their indexes and id counters already
|
||||||
|
// travel in this family and in `data`, so they ride along.
|
||||||
|
SUBSPACE_DELETED_ITEMS,
|
||||||
|
SUBSPACE_SPAM_SAMPLES,
|
||||||
|
store::SUBSPACE_INBUXA, // inbuxa: the fork's own data (masked email, undelete, policies)
|
||||||
],
|
],
|
||||||
Family::Changelog => &[SUBSPACE_LOGS],
|
Family::Changelog => &[SUBSPACE_LOGS],
|
||||||
Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT],
|
Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT],
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ Options:
|
|||||||
-o, --console Open the store console
|
-o, --console Open the store console
|
||||||
-h, --help Print help
|
-h, --help Print help
|
||||||
-V, --version Print version
|
-V, --version Print version
|
||||||
|
|
||||||
|
An export holds everything in the data and blob stores except short-lived
|
||||||
|
in-memory state (rate limits, locks, greylisting) and the full-text search
|
||||||
|
index, which belongs to one search backend. An import into an empty store
|
||||||
|
queues the index to be rebuilt when the server next starts. EXPORT_TYPES
|
||||||
|
limits an export to some of: data, registry, blob, changelog, queue, report,
|
||||||
|
telemetry, tasks.
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -233,6 +240,9 @@ impl BootManager {
|
|||||||
.parse_tcp_acceptors(&mut bootstrap, inner.clone())
|
.parse_tcp_acceptors(&mut bootstrap, inner.clone())
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// inbuxa: a reload isn't refused over objects that failed here
|
||||||
|
inner.build_server().record_build_errors(&bootstrap.errors);
|
||||||
|
|
||||||
BootManager {
|
BootManager {
|
||||||
inner,
|
inner,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
@@ -256,10 +266,10 @@ impl BootManager {
|
|||||||
telemetry.enable();
|
telemetry.enable();
|
||||||
|
|
||||||
// Parse settings and restore
|
// Parse settings and restore
|
||||||
Box::pin(Core::parse(&mut bootstrap, storage))
|
let core = Box::pin(Core::parse(&mut bootstrap, storage)).await;
|
||||||
.await
|
let imported = core.restore(path).await;
|
||||||
.restore(path)
|
// inbuxa: the search index isn't exported; rebuild it
|
||||||
.await;
|
core.queue_reindex(&imported).await;
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
StoreOp::Console => {
|
StoreOp::Console => {
|
||||||
@@ -290,6 +300,7 @@ pub fn build_ipc(has_pubsub: bool) -> (Ipc, IpcReceivers) {
|
|||||||
report_tx,
|
report_tx,
|
||||||
broadcast_tx: has_pubsub.then_some(broadcast_tx),
|
broadcast_tx: has_pubsub.then_some(broadcast_tx),
|
||||||
task_tx: Arc::new(Notify::new()),
|
task_tx: Arc::new(Notify::new()),
|
||||||
|
task_locks: Arc::new(crate::ipc::TaskLocks::default()),
|
||||||
train_task_controller: Arc::new(TrainTaskController::default()),
|
train_task_controller: Arc::new(TrainTaskController::default()),
|
||||||
},
|
},
|
||||||
IpcReceivers {
|
IpcReceivers {
|
||||||
|
|||||||
@@ -530,13 +530,22 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
|||||||
use store::write::BatchBuilder;
|
use store::write::BatchBuilder;
|
||||||
use types::id::Id;
|
use types::id::Id;
|
||||||
|
|
||||||
if bp.registry.count_object(ObjectType::SpamRule).await? == 0
|
// inbuxa: rules are always to hand, since a copy ships with the server
|
||||||
&& bp
|
// (spam_rules). They load on first boot, and again when the bundled
|
||||||
.registry
|
// version differs from the one last loaded, which only adds what's
|
||||||
|
// missing: new tags and rules, never a changed score.
|
||||||
|
let rules_url = super::spam_rules::rules_url(
|
||||||
|
bp.registry
|
||||||
.object::<SpamSettings>(Id::singleton())
|
.object::<SpamSettings>(Id::singleton())
|
||||||
.await?
|
.await?
|
||||||
.is_none_or(|spam| spam.spam_filter_rules_url.is_some())
|
.and_then(|spam| spam.spam_filter_rules_url),
|
||||||
{
|
);
|
||||||
|
let bundled_is_new = rules_url.is_none()
|
||||||
|
&& super::spam_rules::applied_version(&bp.data_store)
|
||||||
|
.await?
|
||||||
|
.as_deref()
|
||||||
|
!= Some(super::spam_rules::BUNDLED_SPAM_RULES_VERSION);
|
||||||
|
if bp.registry.count_object(ObjectType::SpamRule).await? == 0 || bundled_is_new {
|
||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
|
batch.schedule_task(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
|
||||||
maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules,
|
maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
//! that ship with it are registered for it, on every start:
|
//! that ship with it are registered for it, on every start:
|
||||||
//!
|
//!
|
||||||
//! - the web interface the server serves itself (`Application`, `/admin` and
|
//! - the web interface the server serves itself (`Application`, `/admin` and
|
||||||
//! `/account`), as its OAuth client id, `stalwart-webui` unless the
|
//! `/account`), as its OAuth client id, `inbuxa-webui` unless the
|
||||||
//! application names another;
|
//! application names another;
|
||||||
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
|
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
|
||||||
//! is set;
|
//! is set;
|
||||||
@@ -29,7 +29,7 @@ use directory::core::secret::{hash_secret, verify_secret_hash};
|
|||||||
use registry::{
|
use registry::{
|
||||||
schema::{
|
schema::{
|
||||||
enums::{PasswordHashAlgorithm, ServiceProtocol},
|
enums::{PasswordHashAlgorithm, ServiceProtocol},
|
||||||
prelude::{ObjectType, Property, UTCDateTime},
|
prelude::{Object, ObjectInner, ObjectType, Property, UTCDateTime},
|
||||||
structs::{Application, OAuthClient, SystemSettings},
|
structs::{Application, OAuthClient, SystemSettings},
|
||||||
},
|
},
|
||||||
types::map::Map,
|
types::map::Map,
|
||||||
@@ -40,9 +40,12 @@ use store::registry::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// The client id the upstream web interface uses when its application names none.
|
/// The client id the upstream web interface uses when its application names none.
|
||||||
pub const WEB_INTERFACE_CLIENT_ID: &str = "stalwart-webui";
|
pub const WEB_INTERFACE_CLIENT_ID: &str = "inbuxa-webui";
|
||||||
pub const ADMIN_CLIENT_ID: &str = "inbuxa-admin";
|
pub const ADMIN_CLIENT_ID: &str = "inbuxa-admin";
|
||||||
pub const WEBMAIL_CLIENT_ID: &str = "ihasmail-inbuxa";
|
pub const WEBMAIL_CLIENT_ID: &str = "ihasmail-inbuxa";
|
||||||
|
/// The web interface's client id before the fork renamed it (SPEC §2.4).
|
||||||
|
/// Only ever read to retire it.
|
||||||
|
const LEGACY_WEB_INTERFACE_CLIENT_ID: &str = "stalwart-webui";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct FirstPartyClient {
|
pub struct FirstPartyClient {
|
||||||
@@ -102,7 +105,7 @@ pub fn first_party_clients(
|
|||||||
if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) {
|
if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) {
|
||||||
clients.push(FirstPartyClient {
|
clients.push(FirstPartyClient {
|
||||||
client_id: ADMIN_CLIENT_ID.to_string(),
|
client_id: ADMIN_CLIENT_ID.to_string(),
|
||||||
description: "INBUXA Admin".to_string(),
|
description: "inbuxa Admin".to_string(),
|
||||||
redirect_uris: vec![format!("{url}/oauth/callback")],
|
redirect_uris: vec![format!("{url}/oauth/callback")],
|
||||||
secret: None,
|
secret: None,
|
||||||
});
|
});
|
||||||
@@ -187,6 +190,7 @@ fn env(name: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> {
|
pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||||
|
retire_legacy_web_interface_client(bp).await?;
|
||||||
let system = bp.setting_infallible::<SystemSettings>().await;
|
let system = bp.setting_infallible::<SystemSettings>().await;
|
||||||
let base_url = base_url(bp, &system);
|
let base_url = base_url(bp, &system);
|
||||||
let applications = bp
|
let applications = bp
|
||||||
@@ -213,6 +217,56 @@ pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Resul
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An install from before the rename, upstream's or this fork's, has the web
|
||||||
|
/// interface registered as `stalwart-webui`, and may
|
||||||
|
/// have an application naming it. The application is moved to the current id
|
||||||
|
/// and the old client removed, so the old id stops working rather than
|
||||||
|
/// living on as an alias; anyone signed in to the web interface signs in
|
||||||
|
/// again. Runs on every start and does nothing once both are gone.
|
||||||
|
async fn retire_legacy_web_interface_client(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||||
|
for app in bp.list_infallible::<Application>().await {
|
||||||
|
if app.object.oauth_client_id.as_deref() != Some(LEGACY_WEB_INTERFACE_CLIENT_ID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut updated = app.object.clone();
|
||||||
|
updated.oauth_client_id = Some(WEB_INTERFACE_CLIENT_ID.to_string());
|
||||||
|
// The old object carries its revision: the write asserts on it.
|
||||||
|
let current = Object::with_revision(ObjectInner::from(app.object), app.revision);
|
||||||
|
let result = bp
|
||||||
|
.registry
|
||||||
|
.write(RegistryWrite::update(app.id.id(), &updated.into(), ¤t))
|
||||||
|
.await?;
|
||||||
|
if !matches!(result, RegistryWriteResult::Success(_)) {
|
||||||
|
return Err(trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to move an application to the renamed web interface client.")
|
||||||
|
.reason(result.to_string())
|
||||||
|
.caused_by(trc::location!()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(object_id) = bp
|
||||||
|
.registry
|
||||||
|
.primary_key(
|
||||||
|
ObjectType::OAuthClient.into(),
|
||||||
|
Property::ClientId,
|
||||||
|
LEGACY_WEB_INTERFACE_CLIENT_ID.as_bytes().to_vec(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
let result = bp.registry.write(RegistryWrite::delete(object_id)).await?;
|
||||||
|
if !matches!(result, RegistryWriteResult::Success(_)) {
|
||||||
|
return Err(trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to remove the web interface's pre-rename OAuth client.")
|
||||||
|
.reason(result.to_string())
|
||||||
|
.caused_by(trc::location!()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Result<()> {
|
async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Result<()> {
|
||||||
let existing = match bp
|
let existing = match bp
|
||||||
.registry
|
.registry
|
||||||
@@ -223,15 +277,18 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
|
// inbuxa: read as an Object, keeping the revision the update below
|
||||||
|
// asserts on (a bare OAuthClient converts back with revision 0, which
|
||||||
|
// never matches, so any update failed start-up).
|
||||||
Some(object_id) => bp
|
Some(object_id) => bp
|
||||||
.registry
|
.registry
|
||||||
.object::<OAuthClient>(object_id.id())
|
.get(object_id)
|
||||||
.await?
|
.await?
|
||||||
.map(|object| (object_id.id(), object)),
|
.map(|object| (object_id.id(), object.revision, OAuthClient::from(object))),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = if let Some((id, current)) = existing {
|
let result = if let Some((id, revision, current)) = existing {
|
||||||
let mut updated = current.clone();
|
let mut updated = current.clone();
|
||||||
for uri in &client.redirect_uris {
|
for uri in &client.redirect_uris {
|
||||||
if !updated.redirect_uris.contains(uri) {
|
if !updated.redirect_uris.contains(uri) {
|
||||||
@@ -255,8 +312,9 @@ async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Res
|
|||||||
if updated == current {
|
if updated == current {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let current = Object::with_revision(ObjectInner::from(current), revision);
|
||||||
bp.registry
|
bp.registry
|
||||||
.write(RegistryWrite::update(id, &updated.into(), ¤t.into()))
|
.write(RegistryWrite::update(id, &updated.into(), ¤t))
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
let secret = match &client.secret {
|
let secret = match &client.secret {
|
||||||
@@ -298,7 +356,7 @@ mod tests {
|
|||||||
|
|
||||||
fn web_interface() -> Application {
|
fn web_interface() -> Application {
|
||||||
Application {
|
Application {
|
||||||
description: "INBUXA Web Interface".to_string(),
|
description: "inbuxa Web Interface".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
|
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -312,7 +370,7 @@ mod tests {
|
|||||||
clients,
|
clients,
|
||||||
vec![FirstPartyClient {
|
vec![FirstPartyClient {
|
||||||
client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
|
client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
|
||||||
description: "INBUXA 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(),
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ pub mod console;
|
|||||||
pub mod defaults;
|
pub mod defaults;
|
||||||
pub mod first_party;
|
pub mod first_party;
|
||||||
pub mod restore;
|
pub mod restore;
|
||||||
|
pub mod spam_rules; // inbuxa: rules bundled with the server
|
||||||
|
|
||||||
pub const SPAM_TRAINER_KEY: &[u8] = "STALWART_SPAM_TRAIN_DATA.lz4".as_bytes();
|
pub const SPAM_TRAINER_KEY: &[u8] = "INBUXA_SPAM_TRAIN_DATA.lz4".as_bytes();
|
||||||
pub const SPAM_CLASSIFIER_KEY: &[u8] = "STALWART_SPAM_CLASSIFIER_MODEL.lz4".as_bytes();
|
pub const SPAM_CLASSIFIER_KEY: &[u8] = "INBUXA_SPAM_CLASSIFIER_MODEL.lz4".as_bytes();
|
||||||
|
|
||||||
pub async fn fetch_resource(
|
pub async fn fetch_resource(
|
||||||
url: &str,
|
url: &str,
|
||||||
|
|||||||
@@ -9,15 +9,22 @@
|
|||||||
use super::backup::MAGIC_MARKER;
|
use super::backup::MAGIC_MARKER;
|
||||||
use crate::{Core, DATABASE_SCHEMA_VERSION};
|
use crate::{Core, DATABASE_SCHEMA_VERSION};
|
||||||
use lz4_flex::frame::FrameDecoder;
|
use lz4_flex::frame::FrameDecoder;
|
||||||
use registry::schema::enums::CompressionAlgo;
|
use registry::{
|
||||||
|
schema::{
|
||||||
|
enums::{CompressionAlgo, TaskStoreMaintenanceType},
|
||||||
|
structs::{Task, TaskStatus, TaskStoreMaintenance},
|
||||||
|
},
|
||||||
|
types::EnumImpl,
|
||||||
|
};
|
||||||
use std::{
|
use std::{
|
||||||
fs::File,
|
fs::File,
|
||||||
io::{BufReader, ErrorKind, Read},
|
io::{BufReader, ErrorKind, Read},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
use store::{
|
use store::{
|
||||||
BlobStore, IterateParams, SUBSPACE_BLOBS, SUBSPACE_COUNTER, SUBSPACE_INDEXES, SUBSPACE_QUOTA,
|
BlobStore, IterateParams, SUBSPACE_BLOBS, SUBSPACE_COUNTER, SUBSPACE_INDEXES,
|
||||||
SUBSPACE_REGISTRY_PK, Store, U32_LEN,
|
SUBSPACE_PROPERTY, SUBSPACE_QUOTA, SUBSPACE_REGISTRY_PK, SUBSPACE_TELEMETRY_SPAN, Store,
|
||||||
|
U32_LEN,
|
||||||
write::{
|
write::{
|
||||||
AnyClass, AnyKey, BatchBuilder, ValueClass,
|
AnyClass, AnyKey, BatchBuilder, ValueClass,
|
||||||
key::{DeserializeBigEndian, is_node_id_key},
|
key::{DeserializeBigEndian, is_node_id_key},
|
||||||
@@ -27,7 +34,9 @@ use types::{collection::Collection, field::Field};
|
|||||||
use utils::{UnwrapFailure, failed};
|
use utils::{UnwrapFailure, failed};
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
pub async fn restore(&self, src: PathBuf) {
|
/// Imports an export into an empty store and returns the subspaces it
|
||||||
|
/// wrote. inbuxa: the caller hands them to [`Core::queue_reindex`].
|
||||||
|
pub async fn restore(&self, src: PathBuf) -> Vec<u8> {
|
||||||
// Backup the core
|
// Backup the core
|
||||||
let paths = if src.is_dir() {
|
let paths = if src.is_dir() {
|
||||||
let mut paths = Vec::new();
|
let mut paths = Vec::new();
|
||||||
@@ -64,6 +73,13 @@ impl Core {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut imported = paths
|
||||||
|
.iter()
|
||||||
|
.map(|path| KeyValueReader::new(path).subspace)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
imported.sort_unstable();
|
||||||
|
imported.dedup();
|
||||||
|
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
for path in paths {
|
for path in paths {
|
||||||
let storage = self.storage.clone();
|
let storage = self.storage.clone();
|
||||||
@@ -76,6 +92,54 @@ impl Core {
|
|||||||
for task in tasks {
|
for task in tasks {
|
||||||
task.await.failed("Failed to wait for task");
|
task.await.failed("Failed to wait for task");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imported
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: an export never carries the full-text index. It is built by
|
||||||
|
/// and for one search backend (the SQL stores index into their own
|
||||||
|
/// tables, the key-value stores into a subspace, external engines keep it
|
||||||
|
/// themselves), so it would be wrong or unreadable after a move to
|
||||||
|
/// another one. Instead, an import queues the same reindex tasks an
|
||||||
|
/// administrator can queue by hand (`reindexAccounts` and
|
||||||
|
/// `reindexTelemetry` store maintenance), and the server rebuilds the
|
||||||
|
/// index for whatever search store it is configured with once it starts.
|
||||||
|
pub async fn queue_reindex(&self, imported: &[u8]) -> Vec<TaskStoreMaintenanceType> {
|
||||||
|
let mut queued = Vec::new();
|
||||||
|
if imported.contains(&SUBSPACE_PROPERTY) {
|
||||||
|
queued.push(TaskStoreMaintenanceType::ReindexAccounts);
|
||||||
|
}
|
||||||
|
if imported.contains(&SUBSPACE_TELEMETRY_SPAN) {
|
||||||
|
queued.push(TaskStoreMaintenanceType::ReindexTelemetry);
|
||||||
|
}
|
||||||
|
if queued.is_empty() {
|
||||||
|
return queued;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
for maintenance_type in &queued {
|
||||||
|
batch.schedule_task(Task::StoreMaintenance(TaskStoreMaintenance {
|
||||||
|
maintenance_type: *maintenance_type,
|
||||||
|
status: TaskStatus::now(),
|
||||||
|
shard_index: None,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
self.storage
|
||||||
|
.data
|
||||||
|
.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.failed("Failed to queue the reindex tasks");
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Queued {} to rebuild the search index; it runs when the server starts.",
|
||||||
|
queued
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" and ")
|
||||||
|
);
|
||||||
|
|
||||||
|
queued
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,17 +189,22 @@ async fn restore_file(store: Store, blob_store: BlobStore, path: &Path) {
|
|||||||
}
|
}
|
||||||
SUBSPACE_COUNTER | SUBSPACE_QUOTA => {
|
SUBSPACE_COUNTER | SUBSPACE_QUOTA => {
|
||||||
while let Some((key, value)) = reader.next() {
|
while let Some((key, value)) = reader.next() {
|
||||||
batch.add(
|
let class = ValueClass::Any(AnyClass {
|
||||||
ValueClass::Any(AnyClass {
|
subspace: reader.subspace,
|
||||||
subspace: reader.subspace,
|
key,
|
||||||
key,
|
});
|
||||||
}),
|
let value = u64::from_le_bytes(
|
||||||
u64::from_le_bytes(
|
value
|
||||||
value
|
.try_into()
|
||||||
.try_into()
|
.expect("Failed to deserialize counter/quota"),
|
||||||
.expect("Failed to deserialize counter/quota"),
|
) as i64;
|
||||||
) as i64,
|
// inbuxa: the SQL stores add a negative amount with an UPDATE,
|
||||||
);
|
// which does nothing to a row that isn't there yet, so a
|
||||||
|
// negative counter vanished on import. Create the row first.
|
||||||
|
if value < 0 {
|
||||||
|
batch.add(class.clone(), 0);
|
||||||
|
}
|
||||||
|
batch.add(class, value);
|
||||||
if batch.is_large_batch() {
|
if batch.is_large_batch() {
|
||||||
store
|
store
|
||||||
.write(batch.build_all())
|
.write(batch.build_all())
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! inbuxa: the spam filter rules that ship with the server.
|
||||||
|
//!
|
||||||
|
//! Upstream fetches its latest published rules from GitHub at run time, so
|
||||||
|
//! scoring changes with a release nobody here tested and depends on reaching
|
||||||
|
//! it. The fork embeds a pinned copy (resources/spam-filter/, with its version
|
||||||
|
//! and license) and uses it whenever no other source is configured. The rules
|
||||||
|
//! URL remains an operator override (`https://` or `file://`).
|
||||||
|
//!
|
||||||
|
//! Loading rules only ever adds what's missing, never changes an existing rule
|
||||||
|
//! or score. They load on first boot, and again whenever the bundled version
|
||||||
|
//! differs from the one last applied, so an upgrade brings new tags (the AI
|
||||||
|
//! classifier's `LLM_*` scores, say) to an install that already had rules.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
use store::{
|
||||||
|
SUBSPACE_INBUXA, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
/// The version of spam-filter the embedded rules come from.
|
||||||
|
pub const BUNDLED_SPAM_RULES_VERSION: &str = "3.0.2";
|
||||||
|
|
||||||
|
static BUNDLED_SPAM_RULES: &[u8] =
|
||||||
|
include_bytes!("../../../../resources/spam-filter/spam-filter-rules.json.gz");
|
||||||
|
|
||||||
|
/// Upstream's default rules source, the value every install created before
|
||||||
|
/// the rules were bundled has saved. Read only to treat it as unset.
|
||||||
|
const LEGACY_DEFAULT_URL: &str =
|
||||||
|
"https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz";
|
||||||
|
|
||||||
|
/// The URL to fetch rules from, or `None` for the bundled rules. An empty
|
||||||
|
/// setting and upstream's old default both mean the bundled rules.
|
||||||
|
pub fn rules_url(configured: Option<String>) -> Option<String> {
|
||||||
|
configured.filter(|url| !url.trim().is_empty() && url != LEGACY_DEFAULT_URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bundled rules, uncompressed: the same JSON the rules URL serves.
|
||||||
|
pub fn bundled_rules() -> Result<Vec<u8>, String> {
|
||||||
|
let mut json = Vec::new();
|
||||||
|
mail_auth::flate2::read::GzDecoder::new(BUNDLED_SPAM_RULES)
|
||||||
|
.read_to_end(&mut json)
|
||||||
|
.map_err(|err| format!("Failed to decompress the bundled spam rules: {err}"))?;
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn applied_key() -> ValueClass {
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key: b"Sr".to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bundled version last loaded into the registry, if any.
|
||||||
|
pub async fn applied_version(data: &Store) -> trc::Result<Option<String>> {
|
||||||
|
data.get_value::<String>(ValueKey::from(applied_key()))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records that the bundled rules of this version have been loaded.
|
||||||
|
pub async fn set_applied_version(data: &Store, version: &str) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(applied_key(), version.as_bytes().to_vec());
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upstream_default_and_empty_mean_bundled() {
|
||||||
|
assert_eq!(rules_url(None), None);
|
||||||
|
assert_eq!(rules_url(Some(String::new())), None);
|
||||||
|
assert_eq!(rules_url(Some(" ".into())), None);
|
||||||
|
assert_eq!(rules_url(Some(LEGACY_DEFAULT_URL.into())), None);
|
||||||
|
assert_eq!(
|
||||||
|
rules_url(Some("file:///srv/rules.json.gz".into())).as_deref(),
|
||||||
|
Some("file:///srv/rules.json.gz")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bundled_rules_parse_and_score_the_ai_tags() {
|
||||||
|
let rules: serde_json::Value = serde_json::from_slice(&bundled_rules().unwrap()).unwrap();
|
||||||
|
let tags = rules["SpamTag"].as_array().unwrap();
|
||||||
|
for (tag, score) in [("LLM_UNSOLICITED_HIGH", 3.0), ("LLM_LEGITIMATE_HIGH", -3.0)] {
|
||||||
|
let found = tags.iter().find(|t| t["tag"] == tag).unwrap();
|
||||||
|
assert_eq!(found["score"].as_f64(), Some(score), "{tag}");
|
||||||
|
}
|
||||||
|
assert!(!rules["SpamRule"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,28 +299,28 @@ impl LegacyProtocol {
|
|||||||
pub fn refusal(&self, scope: RefusalScope) -> &'static str {
|
pub fn refusal(&self, scope: RefusalScope) -> &'static str {
|
||||||
match (scope, self) {
|
match (scope, self) {
|
||||||
(RefusalScope::Server, LegacyProtocol::Imap) => {
|
(RefusalScope::Server, LegacyProtocol::Imap) => {
|
||||||
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
"This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
|
||||||
}
|
}
|
||||||
(RefusalScope::Server, LegacyProtocol::Pop3) => {
|
(RefusalScope::Server, LegacyProtocol::Pop3) => {
|
||||||
"[AUTH] This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
"[AUTH] This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
|
||||||
}
|
}
|
||||||
(RefusalScope::Server, LegacyProtocol::ManageSieve) => {
|
(RefusalScope::Server, LegacyProtocol::ManageSieve) => {
|
||||||
"This server allows only INBUXA webmail and JMAP apps."
|
"This server allows only inbuxa webmail and JMAP apps."
|
||||||
}
|
}
|
||||||
(RefusalScope::Server, LegacyProtocol::Submission) => {
|
(RefusalScope::Server, LegacyProtocol::Submission) => {
|
||||||
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
"535 5.7.0 This server allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
|
||||||
}
|
}
|
||||||
(RefusalScope::Tenant(_), LegacyProtocol::Imap) => {
|
(RefusalScope::Tenant(_), LegacyProtocol::Imap) => {
|
||||||
"Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
"Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
|
||||||
}
|
}
|
||||||
(RefusalScope::Tenant(_), LegacyProtocol::Pop3) => {
|
(RefusalScope::Tenant(_), LegacyProtocol::Pop3) => {
|
||||||
"[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
"[AUTH] Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
|
||||||
}
|
}
|
||||||
(RefusalScope::Tenant(_), LegacyProtocol::ManageSieve) => {
|
(RefusalScope::Tenant(_), LegacyProtocol::ManageSieve) => {
|
||||||
"Your organization allows only INBUXA webmail and JMAP apps."
|
"Your organization allows only inbuxa webmail and JMAP apps."
|
||||||
}
|
}
|
||||||
(RefusalScope::Tenant(_), LegacyProtocol::Submission) => {
|
(RefusalScope::Tenant(_), LegacyProtocol::Submission) => {
|
||||||
"535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
"535 5.7.0 Your organization allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -541,7 +541,7 @@ mod tests {
|
|||||||
let server = RefusalScope::Server;
|
let server = RefusalScope::Server;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
LegacyProtocol::Imap.refusal(server),
|
LegacyProtocol::Imap.refusal(server),
|
||||||
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
"This server allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
LegacyProtocol::Pop3
|
LegacyProtocol::Pop3
|
||||||
@@ -550,11 +550,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
LegacyProtocol::ManageSieve.refusal(server),
|
LegacyProtocol::ManageSieve.refusal(server),
|
||||||
"This server allows only INBUXA webmail and JMAP apps."
|
"This server allows only inbuxa webmail and JMAP apps."
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
LegacyProtocol::Submission.refusal(server),
|
LegacyProtocol::Submission.refusal(server),
|
||||||
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
"535 5.7.0 This server allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,19 +564,19 @@ mod tests {
|
|||||||
let tenant = RefusalScope::Tenant(7);
|
let tenant = RefusalScope::Tenant(7);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
LegacyProtocol::Imap.refusal(tenant),
|
LegacyProtocol::Imap.refusal(tenant),
|
||||||
"Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
"Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
LegacyProtocol::Pop3.refusal(tenant),
|
LegacyProtocol::Pop3.refusal(tenant),
|
||||||
"[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
"[AUTH] Your organization allows only inbuxa webmail and JMAP apps. This mail app can't sign in."
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
LegacyProtocol::ManageSieve.refusal(tenant),
|
LegacyProtocol::ManageSieve.refusal(tenant),
|
||||||
"Your organization allows only INBUXA webmail and JMAP apps."
|
"Your organization allows only inbuxa webmail and JMAP apps."
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
LegacyProtocol::Submission.refusal(tenant),
|
LegacyProtocol::Submission.refusal(tenant),
|
||||||
"535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
"535 5.7.0 Your organization allows only inbuxa webmail and JMAP apps. This mail app can't send.\r\n"
|
||||||
);
|
);
|
||||||
let err = LegacyProtocol::Imap.refused(tenant, Some("example.org".into()));
|
let err = LegacyProtocol::Imap.refused(tenant, Some("example.org".into()));
|
||||||
assert_eq!(err.value_as_str(trc::Key::Policy), Some("tenant"));
|
assert_eq!(err.value_as_str(trc::Key::Policy), Some("tenant"));
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
@@ -313,16 +315,16 @@ B4yDfR2rGOd2H6Kv3fQNHPj9Nu5Tks8QYMLzrX8ONCNoFnNUQl9S0r0QS6phVqD0
|
|||||||
#[test]
|
#[test]
|
||||||
fn contact_is_normalized_to_a_uri() {
|
fn contact_is_normalized_to_a_uri() {
|
||||||
for (input, expected) in [
|
for (input, expected) in [
|
||||||
("hello@stalw.art", Some("mailto:hello@stalw.art")),
|
("hello@example.org", Some("mailto:hello@example.org")),
|
||||||
(" hello@stalw.art ", Some("mailto:hello@stalw.art")),
|
(" hello@example.org ", Some("mailto:hello@example.org")),
|
||||||
("mailto:hello@stalw.art", Some("mailto:hello@stalw.art")),
|
("mailto:hello@example.org", Some("mailto:hello@example.org")),
|
||||||
("MAILTO:hello@stalw.art", Some("MAILTO:hello@stalw.art")),
|
("MAILTO:hello@example.org", Some("MAILTO:hello@example.org")),
|
||||||
(
|
(
|
||||||
"https://stalw.art/contact",
|
"https://example.org/contact",
|
||||||
Some("https://stalw.art/contact"),
|
Some("https://example.org/contact"),
|
||||||
),
|
),
|
||||||
("stalw.art", None),
|
("example.org", None),
|
||||||
("http://stalw.art", None),
|
("http://example.org", None),
|
||||||
("tel:+123456789", None),
|
("tel:+123456789", None),
|
||||||
("", None),
|
("", None),
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub mod document;
|
|||||||
pub mod encryption;
|
pub mod encryption;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod quota;
|
pub mod quota;
|
||||||
|
pub mod ready; // inbuxa: readiness follows the data store
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod transaction;
|
pub mod transaction;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Readiness that reflects the data store.
|
||||||
|
//!
|
||||||
|
//! /healthz/ready used to answer 200 whenever a data store was configured,
|
||||||
|
//! so a load balancer kept sending traffic to a node through a database
|
||||||
|
//! outage. It now reads one key from the data store, with a short time
|
||||||
|
//! limit, and caches the answer for a couple of seconds so probes can't load
|
||||||
|
//! the database. Liveness stays 200: restarting a node doesn't bring its
|
||||||
|
//! database back, and an orchestrator that restarts on failed liveness would
|
||||||
|
//! otherwise restart every node at once.
|
||||||
|
|
||||||
|
use crate::Server;
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use std::{
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use store::{ValueKey, write::ValueClass};
|
||||||
|
|
||||||
|
/// How long a probe's answer is reused.
|
||||||
|
pub const READY_CACHE: Duration = Duration::from_secs(2);
|
||||||
|
/// How long a probe waits for the data store.
|
||||||
|
pub const READY_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct StoreHealth {
|
||||||
|
last: Mutex<Option<(Instant, bool)>>,
|
||||||
|
probing: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the probing flag even when the request is dropped mid-probe.
|
||||||
|
struct ProbeGuard<'x>(&'x AtomicBool);
|
||||||
|
|
||||||
|
impl Drop for ProbeGuard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// Whether the data store answers: a cached result younger than
|
||||||
|
/// READY_CACHE, or a fresh read bounded by READY_PROBE_TIMEOUT. While
|
||||||
|
/// one probe is running, other callers get the last answer.
|
||||||
|
pub async fn is_data_store_ready(&self) -> bool {
|
||||||
|
let store = &self.core.storage.data;
|
||||||
|
if store.is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let health = &self.inner.data.store_health;
|
||||||
|
let last = *health.last.lock();
|
||||||
|
if let Some((at, ready)) = last
|
||||||
|
&& at.elapsed() < READY_CACHE
|
||||||
|
{
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
if health.probing.swap(true, Ordering::AcqRel) {
|
||||||
|
return last.is_none_or(|(_, ready)| ready);
|
||||||
|
}
|
||||||
|
let _guard = ProbeGuard(&health.probing);
|
||||||
|
|
||||||
|
let ready = tokio::time::timeout(
|
||||||
|
READY_PROBE_TIMEOUT,
|
||||||
|
store.get_value::<u64>(ValueKey::from(ValueClass::Property(0))),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_ok_and(|result| result.is_ok());
|
||||||
|
// Say so once per outage, not on every probe
|
||||||
|
if !ready && last.is_none_or(|(_, ready)| ready) {
|
||||||
|
trc::event!(
|
||||||
|
Store(trc::StoreEvent::UnexpectedError),
|
||||||
|
Details = "Readiness probe: the data store didn't answer",
|
||||||
|
Limit = READY_PROBE_TIMEOUT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*health.last.lock() = Some((Instant::now(), ready));
|
||||||
|
ready
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ store = { path = "../store" }
|
|||||||
registry = { path = "../registry" }
|
registry = { path = "../registry" }
|
||||||
trc = { path = "../trc" }
|
trc = { path = "../trc" }
|
||||||
futures = { version = "0.3", optional = true }
|
futures = { version = "0.3", optional = true }
|
||||||
tokio = { version = "1.53", features = ["sync", "fs", "io-util"] }
|
tokio = { version = "1.53", features = ["sync", "fs", "io-util", "rt", "time"] }
|
||||||
async-nats = { version = "0.50", default-features = false, features = ["server_2_10", "server_2_11", "aws-lc-rs"], optional = true }
|
async-nats = { version = "0.50", default-features = false, features = ["server_2_10", "server_2_11", "aws-lc-rs"], optional = true }
|
||||||
zenoh = { version = "1.10.0", default-features = false, features = ["auth_pubkey", "transport_multilink", "transport_compression", "transport_quic", "transport_tcp", "transport_tls", "transport_udp"], optional = true }
|
zenoh = { version = "1.10.0", default-features = false, features = ["auth_pubkey", "transport_multilink", "transport_compression", "transport_quic", "transport_tcp", "transport_tls", "transport_udp"], optional = true }
|
||||||
rdkafka = { version = "0.39", features = ["cmake-build"], optional = true }
|
rdkafka = { version = "0.39", features = ["cmake-build"], optional = true }
|
||||||
|
|||||||
@@ -2,13 +2,22 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::{
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::Coordinator;
|
use crate::Coordinator;
|
||||||
use async_nats::Client;
|
use async_nats::Client;
|
||||||
use registry::schema::structs::NatsCoordinator;
|
use registry::schema::structs::NatsCoordinator;
|
||||||
|
use trc::ClusterEvent;
|
||||||
|
|
||||||
pub mod pubsub;
|
pub mod pubsub;
|
||||||
|
|
||||||
@@ -47,9 +56,116 @@ impl NatsPubSub {
|
|||||||
opts = opts.token(credentials);
|
opts = opts.token(credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: connect in the background and keep trying, so a node that
|
||||||
|
// starts while NATS is down still joins the cluster once NATS is
|
||||||
|
// back, instead of running without a coordinator until restarted;
|
||||||
|
// and report the connection going and coming back
|
||||||
|
let reporter = Arc::new(Reporter::default());
|
||||||
|
opts = opts.retry_on_initial_connect().event_callback({
|
||||||
|
let reporter = reporter.clone();
|
||||||
|
move |event| {
|
||||||
|
let reporter = reporter.clone();
|
||||||
|
async move { reporter.report(event) }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let connection_timeout = config.timeout_connection.into_inner();
|
||||||
|
|
||||||
async_nats::connect_with_options(config.addresses.into_inner(), opts)
|
async_nats::connect_with_options(config.addresses.into_inner(), opts)
|
||||||
.await
|
.await
|
||||||
.map(|client| Coordinator::Nats(Arc::new(NatsPubSub { client })))
|
.map(|client| {
|
||||||
|
reporter.watch_first_connection(client.clone(), connection_timeout);
|
||||||
|
Coordinator::Nats(Arc::new(NatsPubSub { client }))
|
||||||
|
})
|
||||||
.map_err(|err| format!("Failed to connect to Nats: {}", err))
|
.map_err(|err| format!("Failed to connect to Nats: {}", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: whether the client is connected to a NATS server right now.
|
||||||
|
pub fn is_connected(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.client.connection_state(),
|
||||||
|
async_nats::connection::State::Connected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: reports the client's connection events as the server's own.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Reporter {
|
||||||
|
connected_once: AtomicBool,
|
||||||
|
// A failed attempt raises an error each time the client retries, every
|
||||||
|
// few seconds while NATS is down: report the first after each change
|
||||||
|
error_reported: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reporter {
|
||||||
|
fn report(&self, event: async_nats::Event) {
|
||||||
|
match event {
|
||||||
|
async_nats::Event::Connected => {
|
||||||
|
self.connected_once.store(true, Ordering::Relaxed);
|
||||||
|
self.error_reported.store(false, Ordering::Relaxed);
|
||||||
|
trc::event!(Cluster(ClusterEvent::CoordinatorConnected), Type = "nats");
|
||||||
|
}
|
||||||
|
async_nats::Event::Disconnected => {
|
||||||
|
self.error_reported.store(false, Ordering::Relaxed);
|
||||||
|
trc::event!(
|
||||||
|
Cluster(ClusterEvent::CoordinatorDisconnected),
|
||||||
|
Type = "nats",
|
||||||
|
Details = "Connection lost; reconnecting in the background",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async_nats::Event::Closed => {
|
||||||
|
trc::event!(
|
||||||
|
Cluster(ClusterEvent::CoordinatorDisconnected),
|
||||||
|
Type = "nats",
|
||||||
|
Details = "Connection closed; no further attempts will be made",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async_nats::Event::ClientError(async_nats::ClientError::MaxReconnects) => {
|
||||||
|
trc::event!(
|
||||||
|
Cluster(ClusterEvent::CoordinatorDisconnected),
|
||||||
|
Type = "nats",
|
||||||
|
Details = "Gave up reconnecting (maxReconnects reached)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async_nats::Event::ClientError(err) => {
|
||||||
|
if !self.error_reported.swap(true, Ordering::Relaxed) {
|
||||||
|
trc::event!(
|
||||||
|
Cluster(ClusterEvent::CoordinatorError),
|
||||||
|
Type = "nats",
|
||||||
|
Details = "Connection attempt failed; retrying",
|
||||||
|
Reason = err.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event => {
|
||||||
|
trc::event!(
|
||||||
|
Cluster(ClusterEvent::CoordinatorError),
|
||||||
|
Type = "nats",
|
||||||
|
Details = event.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first connection is made in the background, so say so when it
|
||||||
|
/// hasn't been made within the connection timeout. The client keeps
|
||||||
|
/// trying, and reports the connection when it comes.
|
||||||
|
fn watch_first_connection(self: &Arc<Self>, client: Client, timeout: Duration) {
|
||||||
|
let reporter = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(timeout).await;
|
||||||
|
if !reporter.connected_once.load(Ordering::Relaxed)
|
||||||
|
&& !matches!(
|
||||||
|
client.connection_state(),
|
||||||
|
async_nats::connection::State::Connected
|
||||||
|
)
|
||||||
|
{
|
||||||
|
trc::event!(
|
||||||
|
Cluster(ClusterEvent::CoordinatorDisconnected),
|
||||||
|
Type = "nats",
|
||||||
|
Details = "Not connected at startup; retrying in the background",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::{Coordinator, Msg, PubSubStream};
|
use crate::{Coordinator, Msg, PubSubStream};
|
||||||
@@ -43,6 +45,17 @@ impl Coordinator {
|
|||||||
pub fn is_none(&self) -> bool {
|
pub fn is_none(&self) -> bool {
|
||||||
matches!(self, Coordinator::None)
|
matches!(self, Coordinator::None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: whether the coordinator is connected right now, for the
|
||||||
|
/// backends that track it (NATS); `None` for the others and when no
|
||||||
|
/// coordinator is configured.
|
||||||
|
pub fn is_connected(&self) -> Option<bool> {
|
||||||
|
match self {
|
||||||
|
#[cfg(feature = "nats")]
|
||||||
|
Coordinator::Nats(store) => Some(store.is_connected()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PubSubStream {
|
impl PubSubStream {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::ETag;
|
use super::ETag;
|
||||||
@@ -490,7 +492,7 @@ impl LockRequestHandler for Server {
|
|||||||
for cond in &if_.list {
|
for cond in &if_.list {
|
||||||
match cond {
|
match cond {
|
||||||
Condition::StateToken { token, .. } => {
|
Condition::StateToken { token, .. } => {
|
||||||
if token.starts_with("urn:stalwart:davsync:") {
|
if token.starts_with("urn:inbuxa:davsync:") {
|
||||||
needs_sync_token = true;
|
needs_sync_token = true;
|
||||||
} else {
|
} else {
|
||||||
needs_lock_token = true;
|
needs_lock_token = true;
|
||||||
|
|||||||
@@ -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::{DavError, DavResourceName};
|
use crate::{DavError, DavResourceName};
|
||||||
@@ -181,12 +183,12 @@ impl OwnedUri<'_> {
|
|||||||
impl Urn {
|
impl Urn {
|
||||||
pub fn try_extract_sync_id(token: &str) -> Option<&str> {
|
pub fn try_extract_sync_id(token: &str) -> Option<&str> {
|
||||||
token
|
token
|
||||||
.strip_prefix("urn:stalwart:davsync:")
|
.strip_prefix("urn:inbuxa:davsync:")
|
||||||
.map(|x| x.split_once(':').map(|(x, _)| x).unwrap_or(x))
|
.map(|x| x.split_once(':').map(|(x, _)| x).unwrap_or(x))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse(input: &str) -> Option<Self> {
|
pub fn parse(input: &str) -> Option<Self> {
|
||||||
let inbox = input.strip_prefix("urn:stalwart:")?;
|
let inbox = input.strip_prefix("urn:inbuxa:")?;
|
||||||
let (kind, id) = inbox.split_once(':')?;
|
let (kind, id) = inbox.split_once(':')?;
|
||||||
match kind {
|
match kind {
|
||||||
"davlock" => u64::from_str_radix(id, 16).ok().map(Urn::Lock),
|
"davlock" => u64::from_str_radix(id, 16).ok().map(Urn::Lock),
|
||||||
@@ -223,12 +225,12 @@ impl Urn {
|
|||||||
impl Display for Urn {
|
impl Display for Urn {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Urn::Lock(id) => write!(f, "urn:stalwart:davlock:{id:x}",),
|
Urn::Lock(id) => write!(f, "urn:inbuxa:davlock:{id:x}",),
|
||||||
Urn::Sync { id, seq } => {
|
Urn::Sync { id, seq } => {
|
||||||
if *seq == 0 {
|
if *seq == 0 {
|
||||||
write!(f, "urn:stalwart:davsync:{id:x}")
|
write!(f, "urn:inbuxa:davsync:{id:x}")
|
||||||
} else {
|
} else {
|
||||||
write!(f, "urn:stalwart:davsync:{id:x}:{seq:x}")
|
write!(f, "urn:inbuxa:davsync:{id:x}:{seq:x}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ impl OpenIdDirectory {
|
|||||||
|
|
||||||
pub async fn new(config: OidcConfig) -> Result<Self, OidcError> {
|
pub async fn new(config: OidcConfig) -> Result<Self, OidcError> {
|
||||||
let http = utils::http::http_client_builder(false)
|
let http = utils::http::http_client_builder(false)
|
||||||
.user_agent("INBUXA/1.0") // types::brand!(); this crate does not depend on types
|
.user_agent("inbuxa/1.0") // types::brand!(); this crate does not depend on types
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?;
|
.map_err(|e| OidcError::Network(format!("HTTP client build failed: {e}")))?;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "inbuxa-features"
|
name = "inbuxa-features"
|
||||||
description = "INBUXA's rebuilt features: behavior Stalwart ships only in its Enterprise Edition, rebuilt clean-room"
|
description = "inbuxa's rebuilt features: behavior Stalwart ships only in its Enterprise Edition, rebuilt clean-room"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
version = "0.16.22"
|
version = "0.16.22"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|||||||
@@ -553,8 +553,10 @@ impl ParseHttp for Server {
|
|||||||
return Ok(JsonProblemResponse(StatusCode::OK).into_http_response());
|
return Ok(JsonProblemResponse(StatusCode::OK).into_http_response());
|
||||||
}
|
}
|
||||||
"ready" => {
|
"ready" => {
|
||||||
|
// inbuxa: ready only while the data store answers
|
||||||
|
// (a cached, time-limited read); liveness stays 200
|
||||||
return Ok(JsonProblemResponse({
|
return Ok(JsonProblemResponse({
|
||||||
if !self.core.storage.data.is_none() {
|
if self.is_data_store_ready().await {
|
||||||
StatusCode::OK
|
StatusCode::OK
|
||||||
} else {
|
} else {
|
||||||
StatusCode::SERVICE_UNAVAILABLE
|
StatusCode::SERVICE_UNAVAILABLE
|
||||||
@@ -562,6 +564,27 @@ impl ParseHttp for Server {
|
|||||||
})
|
})
|
||||||
.into_http_response());
|
.into_http_response());
|
||||||
}
|
}
|
||||||
|
// inbuxa: the cluster coordinator's connection, for
|
||||||
|
// monitoring. It stays out of live and ready on purpose:
|
||||||
|
// a node without its coordinator still serves mail, and
|
||||||
|
// failing those would have an orchestrator restart, or
|
||||||
|
// take out of service, every node at once when the
|
||||||
|
// coordinator goes down
|
||||||
|
"cluster" => {
|
||||||
|
let coordinator = &self.core.storage.coordinator;
|
||||||
|
let (status, state) = match coordinator.is_connected() {
|
||||||
|
Some(true) => (StatusCode::OK, "connected"),
|
||||||
|
Some(false) => (StatusCode::SERVICE_UNAVAILABLE, "disconnected"),
|
||||||
|
None if coordinator.is_none() => (StatusCode::OK, "none"),
|
||||||
|
None => (StatusCode::OK, "unknown"),
|
||||||
|
};
|
||||||
|
return Ok(http_proto::JsonResponse::with_status(
|
||||||
|
status,
|
||||||
|
serde_json::json!({ "coordinator": state }),
|
||||||
|
)
|
||||||
|
.no_cache()
|
||||||
|
.into_http_response());
|
||||||
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::ahash_is_empty;
|
use super::ahash_is_empty;
|
||||||
@@ -71,6 +73,23 @@ pub struct SetResponse<T: JmapObject> {
|
|||||||
#[serde(rename = "notDestroyed")]
|
#[serde(rename = "notDestroyed")]
|
||||||
#[serde(skip_serializing_if = "VecMap::is_empty")]
|
#[serde(skip_serializing_if = "VecMap::is_empty")]
|
||||||
pub not_destroyed: VecMap<MaybeInvalid<Id>, SetError<T::Property>>,
|
pub not_destroyed: VecMap<MaybeInvalid<Id>, SetError<T::Property>>,
|
||||||
|
|
||||||
|
// inbuxa: on a registry write that changes the running settings, whether
|
||||||
|
// the server applied it
|
||||||
|
#[serde(rename = "x:settingsReload")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub settings_reload: Option<SettingsReload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the settings reload that followed a registry write.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct SettingsReload {
|
||||||
|
/// The running settings (here and, through the cluster, on every node)
|
||||||
|
/// include the write.
|
||||||
|
pub applied: bool,
|
||||||
|
/// Why they don't, when they don't.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de, T: JmapObject> DeserializeArguments<'de> for SetRequest<'de, T> {
|
impl<'de, T: JmapObject> DeserializeArguments<'de> for SetRequest<'de, T> {
|
||||||
@@ -199,6 +218,7 @@ impl<T: JmapObject> SetResponse<T> {
|
|||||||
not_created: VecMap::new(),
|
not_created: VecMap::new(),
|
||||||
not_updated: VecMap::new(),
|
not_updated: VecMap::new(),
|
||||||
not_destroyed: VecMap::new(),
|
not_destroyed: VecMap::new(),
|
||||||
|
settings_reload: None,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(trc::JmapEvent::RequestTooLarge.into_err())
|
Err(trc::JmapEvent::RequestTooLarge.into_err())
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ pub enum Capability {
|
|||||||
FileNode = 1 << 15,
|
FileNode = 1 << 15,
|
||||||
#[serde(rename(serialize = "urn:ietf:params:jmap:mail:share"))]
|
#[serde(rename(serialize = "urn:ietf:params:jmap:mail:share"))]
|
||||||
MailShare = 1 << 16,
|
MailShare = 1 << 16,
|
||||||
#[serde(rename(serialize = "urn:stalwart:jmap"))]
|
#[serde(rename(serialize = "urn:inbuxa:jmap:registry"))]
|
||||||
Stalwart = 1 << 17,
|
Stalwart = 1 << 17,
|
||||||
#[serde(rename(serialize = "urn:ietf:params:jmap:webpush-vapid"))]
|
#[serde(rename(serialize = "urn:ietf:params:jmap:webpush-vapid"))]
|
||||||
WebPushVapid = 1 << 18,
|
WebPushVapid = 1 << 18,
|
||||||
@@ -353,7 +353,7 @@ impl Capability {
|
|||||||
Capability::PrincipalsAvailability => "urn:ietf:params:jmap:principals:availability",
|
Capability::PrincipalsAvailability => "urn:ietf:params:jmap:principals:availability",
|
||||||
Capability::FileNode => "urn:ietf:params:jmap:filenode",
|
Capability::FileNode => "urn:ietf:params:jmap:filenode",
|
||||||
Capability::MailShare => "urn:ietf:params:jmap:mail:share",
|
Capability::MailShare => "urn:ietf:params:jmap:mail:share",
|
||||||
Capability::Stalwart => "urn:stalwart:jmap",
|
Capability::Stalwart => "urn:inbuxa:jmap:registry",
|
||||||
Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid",
|
Capability::WebPushVapid => "urn:ietf:params:jmap:webpush-vapid",
|
||||||
Capability::EmailPush => "urn:ietf:params:jmap:emailpush",
|
Capability::EmailPush => "urn:ietf:params:jmap:emailpush",
|
||||||
Capability::Inbuxa => "urn:inbuxa:jmap",
|
Capability::Inbuxa => "urn:inbuxa:jmap",
|
||||||
@@ -501,7 +501,7 @@ impl Capability {
|
|||||||
"urn:ietf:params:jmap:contacts:parse" => Capability::ContactsParse,
|
"urn:ietf:params:jmap:contacts:parse" => Capability::ContactsParse,
|
||||||
"urn:ietf:params:jmap:calendars:parse" => Capability::CalendarsParse,
|
"urn:ietf:params:jmap:calendars:parse" => Capability::CalendarsParse,
|
||||||
"urn:ietf:params:jmap:mail:share" => Capability::MailShare,
|
"urn:ietf:params:jmap:mail:share" => Capability::MailShare,
|
||||||
"urn:stalwart:jmap" => Capability::Stalwart,
|
"urn:inbuxa:jmap:registry" => Capability::Stalwart,
|
||||||
"urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid,
|
"urn:ietf:params:jmap:webpush-vapid" => Capability::WebPushVapid,
|
||||||
"urn:ietf:params:jmap:emailpush" => Capability::EmailPush,
|
"urn:ietf:params:jmap:emailpush" => Capability::EmailPush,
|
||||||
"urn:inbuxa:jmap" => Capability::Inbuxa,
|
"urn:inbuxa:jmap" => Capability::Inbuxa,
|
||||||
|
|||||||
@@ -427,9 +427,24 @@ pub(crate) async fn trace_query(
|
|||||||
}
|
}
|
||||||
None => false,
|
None => false,
|
||||||
},
|
},
|
||||||
Property::QueueId => match value.as_str() {
|
// The queue id column is an integer on every search backend, and
|
||||||
|
// holds a trace's first queue id; the keywords carry all of them
|
||||||
|
Property::QueueId => match value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||||
|
.or_else(|| value.as_u64())
|
||||||
|
{
|
||||||
Some(queue_id) => {
|
Some(queue_id) => {
|
||||||
search.push(SearchFilter::eq(TracingSearchField::QueueId, queue_id.to_string()));
|
search.extend([
|
||||||
|
SearchFilter::Or,
|
||||||
|
SearchFilter::eq(TracingSearchField::QueueId, queue_id),
|
||||||
|
SearchFilter::has_text(
|
||||||
|
TracingSearchField::Keywords,
|
||||||
|
queue_id.to_string(),
|
||||||
|
nlp::language::Language::None,
|
||||||
|
),
|
||||||
|
SearchFilter::End,
|
||||||
|
]);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
None => false,
|
None => false,
|
||||||
|
|||||||
@@ -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::registry::mapping::{RegistrySetResponse, map_bootstrap_error};
|
use crate::registry::mapping::{RegistrySetResponse, map_bootstrap_error};
|
||||||
@@ -99,7 +101,7 @@ pub(crate) async fn action_set(
|
|||||||
} else {
|
} else {
|
||||||
set.response
|
set.response
|
||||||
.not_created
|
.not_created
|
||||||
.append(id, map_bootstrap_error(result.errors));
|
.append(id, reload_refused(result.errors));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::InvalidateCaches => {
|
Action::InvalidateCaches => {
|
||||||
@@ -573,3 +575,14 @@ async fn dmarc_troubleshoot(
|
|||||||
|
|
||||||
Some(request)
|
Some(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: a refused reload names the object that stopped it and says the
|
||||||
|
/// settings weren't applied; upstream passed on the first error's bare message
|
||||||
|
/// ("Invalid address: ..."), which read like a problem with the request.
|
||||||
|
fn reload_refused(errors: Vec<registry::types::error::Error>) -> SetError<Property> {
|
||||||
|
let description = format!(
|
||||||
|
"Settings were not reloaded. {}",
|
||||||
|
common::cache::reload::describe_reload_errors(&errors)
|
||||||
|
);
|
||||||
|
map_bootstrap_error(errors).with_description(description)
|
||||||
|
}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ pub(crate) async fn bootstrap_set(
|
|||||||
.with_description(concat!(
|
.with_description(concat!(
|
||||||
"The selected data store contains information from an older version. ",
|
"The selected data store contains information from an older version. ",
|
||||||
"Please follow the upgrade instructions at ",
|
"Please follow the upgrade instructions at ",
|
||||||
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
|
"https://docs.inbuxa.org/install/migrating/"
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -679,7 +679,7 @@ fn build_default_bootstrap(server: &Server) -> Bootstrap {
|
|||||||
directory: DirectoryBootstrap::Internal,
|
directory: DirectoryBootstrap::Internal,
|
||||||
tracer: Tracer::Log(TracerLog {
|
tracer: Tracer::Log(TracerLog {
|
||||||
path: "/var/log/inbuxa/".to_string(),
|
path: "/var/log/inbuxa/".to_string(),
|
||||||
prefix: "stalwart".to_string(),
|
prefix: "inbuxa".to_string(),
|
||||||
ansi: true,
|
ansi: true,
|
||||||
enable: true,
|
enable: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -15,22 +17,42 @@ use jmap_proto::{error::set::SetError, types::state::State};
|
|||||||
use jmap_tools::{Key, Value};
|
use jmap_tools::{Key, Value};
|
||||||
use registry::{
|
use registry::{
|
||||||
jmap::IntoValue,
|
jmap::IntoValue,
|
||||||
schema::prelude::{Object, ObjectInner, ObjectType, Property},
|
schema::{
|
||||||
|
prelude::{Object, ObjectInner, ObjectType, Property},
|
||||||
|
structs::Task,
|
||||||
|
},
|
||||||
types::{EnumImpl, datetime::UTCDateTime},
|
types::{EnumImpl, datetime::UTCDateTime},
|
||||||
};
|
};
|
||||||
|
use services::task_manager::lock::TaskLockManager;
|
||||||
use smtp::reporting::index::{ExternalReportIndex, InternalReportIndex};
|
use smtp::reporting::index::{ExternalReportIndex, InternalReportIndex};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use store::{
|
use store::{
|
||||||
U64_LEN, ValueKey,
|
U64_LEN, ValueKey,
|
||||||
registry::{RegistryFilter, RegistryFilterValue, RegistryQuery},
|
registry::{RegistryFilter, RegistryFilterValue, RegistryQuery},
|
||||||
write::{BatchBuilder, RegistryClass, ValueClass, key::KeySerializer},
|
write::{BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, key::KeySerializer},
|
||||||
};
|
};
|
||||||
use trc::AddContext;
|
use trc::AddContext;
|
||||||
use types::id::Id;
|
use types::id::Id;
|
||||||
|
|
||||||
pub(crate) async fn report_set(
|
pub(crate) async fn report_set(
|
||||||
mut set: RegistrySetResponse<'_>,
|
set: RegistrySetResponse<'_>,
|
||||||
) -> trc::Result<RegistrySetResponse<'_>> {
|
) -> trc::Result<RegistrySetResponse<'_>> {
|
||||||
|
// inbuxa: task locks taken to reschedule reports are released however
|
||||||
|
// the request ends; a held lock is renewed, so a leaked one would keep
|
||||||
|
// the report's task from ever running
|
||||||
|
let server = set.server;
|
||||||
|
let mut locked_tasks = Vec::new();
|
||||||
|
let result = report_set_locked(set, &mut locked_tasks).await;
|
||||||
|
for task_id in locked_tasks {
|
||||||
|
server.remove_index_lock(task_id).await;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn report_set_locked<'x>(
|
||||||
|
mut set: RegistrySetResponse<'x>,
|
||||||
|
locked_tasks: &mut Vec<u64>,
|
||||||
|
) -> trc::Result<RegistrySetResponse<'x>> {
|
||||||
let object_id = set.object_type.to_id();
|
let object_id = set.object_type.to_id();
|
||||||
|
|
||||||
// Reports cannot be created
|
// Reports cannot be created
|
||||||
@@ -89,12 +111,45 @@ pub(crate) async fn report_set(
|
|||||||
.get_value::<Object>(ValueKey::from(key.clone()))
|
.get_value::<Object>(ValueKey::from(key.clone()))
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
|
// inbuxa: the report's task shares its id. Hold the task
|
||||||
|
// while its queue rows move, as x:Task/set does, and move the
|
||||||
|
// row the task is actually queued under
|
||||||
|
if !set.server.try_lock_task(item_id).await {
|
||||||
|
set.response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description(
|
||||||
|
"The report is being sent and cannot be rescheduled".to_string(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
locked_tasks.push(item_id);
|
||||||
|
let queued = set
|
||||||
|
.server
|
||||||
|
.store()
|
||||||
|
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
||||||
|
TaskQueueClass::Task { id: item_id },
|
||||||
|
)))
|
||||||
|
.await?;
|
||||||
|
|
||||||
match &mut report_obj.inner {
|
match &mut report_obj.inner {
|
||||||
ObjectInner::DmarcInternalReport(report) => {
|
ObjectInner::DmarcInternalReport(report) => {
|
||||||
report.reschedule_ops(&mut batch, item_id, report_obj.revision, deliver_at);
|
report.reschedule_ops(
|
||||||
|
&mut batch,
|
||||||
|
item_id,
|
||||||
|
report_obj.revision,
|
||||||
|
deliver_at,
|
||||||
|
queued.as_ref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ObjectInner::TlsInternalReport(report) => {
|
ObjectInner::TlsInternalReport(report) => {
|
||||||
report.reschedule_ops(&mut batch, item_id, report_obj.revision, deliver_at);
|
report.reschedule_ops(
|
||||||
|
&mut batch,
|
||||||
|
item_id,
|
||||||
|
report_obj.revision,
|
||||||
|
deliver_at,
|
||||||
|
queued.as_ref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -156,6 +211,9 @@ pub(crate) async fn report_set(
|
|||||||
.write(batch.build_all())
|
.write(batch.build_all())
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?;
|
.caused_by(trc::location!())?;
|
||||||
|
// inbuxa: a rescheduled report may now be due sooner than the task
|
||||||
|
// manager's next scan
|
||||||
|
set.server.notify_task_queue();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(set)
|
Ok(set)
|
||||||
|
|||||||
@@ -463,15 +463,10 @@ pub(crate) async fn task_query(
|
|||||||
.set_values(typ.is_some()),
|
.set_values(typ.is_some()),
|
||||||
|key, value| {
|
|key, value| {
|
||||||
if let Some(typ) = typ {
|
if let Some(typ) = typ {
|
||||||
let task_type =
|
// inbuxa: a row whose type can't be read matches no type
|
||||||
TaskType::from_id(value.deserialize_be_u16(0)?).ok_or_else(|| {
|
// filter; the task manager logs and repairs it
|
||||||
trc::StoreEvent::DataCorruption
|
let task_type = value.deserialize_be_u16(0).ok().and_then(TaskType::from_id);
|
||||||
.into_err()
|
if task_type != Some(typ) {
|
||||||
.ctx(trc::Key::Key, key.to_vec())
|
|
||||||
.ctx(trc::Key::Value, value.to_vec())
|
|
||||||
.caused_by(trc::location!())
|
|
||||||
})?;
|
|
||||||
if task_type != typ {
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ use directory::core::secret::{hash_secret, is_password_hash};
|
|||||||
use http_proto::HttpSessionData;
|
use http_proto::HttpSessionData;
|
||||||
use jmap_proto::{
|
use jmap_proto::{
|
||||||
error::set::{SetError, SetErrorType},
|
error::set::{SetError, SetErrorType},
|
||||||
method::set::{SetRequest, SetResponse},
|
method::set::{SetRequest, SetResponse, SettingsReload},
|
||||||
object::registry::Registry,
|
object::registry::Registry,
|
||||||
references::resolve::ResolveCreatedReference,
|
references::resolve::ResolveCreatedReference,
|
||||||
request::{IntoValid, MaybeInvalid},
|
request::{IntoValid, MaybeInvalid},
|
||||||
@@ -931,30 +931,28 @@ impl RegistrySet for Server {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// inbuxa: DIR-17: a directory or the server default applies on the
|
// inbuxa: a write to an object the running settings are built from
|
||||||
// next request, here and on every node
|
// applies at once, here and on every node (DIR-17 did this for
|
||||||
if matches!(
|
// directories and the server default; now it covers every such object)
|
||||||
object_type,
|
let mut result = result;
|
||||||
ObjectType::Directory | ObjectType::Authentication
|
if let Ok(response) = &mut result
|
||||||
) && let Ok(response) = &result
|
|
||||||
&& (!response.created.is_empty()
|
&& (!response.created.is_empty()
|
||||||
|| !response.updated.is_empty()
|
|| !response.updated.is_empty()
|
||||||
|| !response.destroyed.is_empty())
|
|| !response.destroyed.is_empty())
|
||||||
|
&& let Some(reload) = self.reload_after_write(object_type).await
|
||||||
{
|
{
|
||||||
let change = common::ipc::RegistryChange::Reload(ObjectType::Directory);
|
response.settings_reload = Some(match reload {
|
||||||
match Box::pin(self.reload_registry(change)).await {
|
Ok(()) => SettingsReload {
|
||||||
Ok(reload) if !reload.has_errors() => {
|
applied: true,
|
||||||
self.cluster_broadcast(common::ipc::BroadcastEvent::RegistryChange(change))
|
description: None,
|
||||||
.await;
|
},
|
||||||
}
|
Err(reason) => SettingsReload {
|
||||||
Ok(_) => trc::event!(
|
applied: false,
|
||||||
Registry(trc::RegistryEvent::BuildWarning),
|
description: Some(format!(
|
||||||
Details = "Settings didn't reload after a directory change",
|
"Saved, but the running settings were not reloaded. {reason}"
|
||||||
),
|
)),
|
||||||
Err(err) => {
|
},
|
||||||
trc::error!(err.details("Failed to reload directories"));
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "inbuxa"
|
name = "inbuxa"
|
||||||
description = "INBUXA Mail and Collaboration Server, a fork of Stalwart"
|
description = "inbuxa mail and collaboration server, a fork of Stalwart"
|
||||||
authors = [ "Stalwart Labs LLC <[email protected]>"]
|
authors = [ "Stalwart Labs LLC <[email protected]>"]
|
||||||
homepage = "https://inbuxa.org"
|
homepage = "https://inbuxa.org"
|
||||||
keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
|
keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
|
||||||
categories = ["email"]
|
categories = ["email"]
|
||||||
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; INBUXA takes the AGPL only.
|
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; inbuxa takes the AGPL only.
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
version = "0.16.23"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ async fn main() -> std::io::Result<()> {
|
|||||||
// Wait for shutdown signal
|
// Wait for shutdown signal
|
||||||
wait_for_shutdown().await;
|
wait_for_shutdown().await;
|
||||||
|
|
||||||
|
// inbuxa: hand back the task locks this node holds, so other nodes can
|
||||||
|
// run those tasks now rather than when the locks expire
|
||||||
|
services::task_manager::lock::release_task_locks(&inner.build_server()).await;
|
||||||
|
|
||||||
// Shutdown collector
|
// Shutdown collector
|
||||||
Collector::shutdown();
|
Collector::shutdown();
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -19,6 +21,10 @@ pub mod destroy;
|
|||||||
pub mod v016;
|
pub mod v016;
|
||||||
|
|
||||||
pub async fn try_migrate(server: &Server) -> trc::Result<()> {
|
pub async fn try_migrate(server: &Server) -> trc::Result<()> {
|
||||||
|
// inbuxa: before the version check, which returns early on a current
|
||||||
|
// store, and before migrate_v0_16, which reads the renamed key.
|
||||||
|
rename_spam_blobs(server).await?;
|
||||||
|
|
||||||
match server
|
match server
|
||||||
.store()
|
.store()
|
||||||
.get_value::<u32>(AnyKey {
|
.get_value::<u32>(AnyKey {
|
||||||
@@ -36,14 +42,14 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> {
|
|||||||
Some(0..=4) => {
|
Some(0..=4) => {
|
||||||
abort(concat!(
|
abort(concat!(
|
||||||
"You must first upgrade to version 0.15, please read ",
|
"You must first upgrade to version 0.15, please read ",
|
||||||
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
|
"https://docs.inbuxa.org/install/migrating/"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Some(5) => {
|
Some(5) => {
|
||||||
if !server.registry().is_recovery_mode() {
|
if !server.registry().is_recovery_mode() {
|
||||||
abort(concat!(
|
abort(concat!(
|
||||||
"Upgrading to version 0.16 is a multi-step process, please read ",
|
"Upgrading to version 0.16 is a multi-step process, please read ",
|
||||||
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
|
"https://docs.inbuxa.org/install/migrating/"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +67,7 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
abort(concat!(
|
abort(concat!(
|
||||||
"You must first upgrade to version 0.15, please read ",
|
"You must first upgrade to version 0.15, please read ",
|
||||||
"https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md"
|
"https://docs.inbuxa.org/install/migrating/"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,3 +140,47 @@ async fn is_new_install(server: &Server) -> trc::Result<bool> {
|
|||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the spam filter's trainer and model blobs, under the names they
|
||||||
|
/// had before the fork renamed them (SPEC §2.4), paired with the current ones.
|
||||||
|
const RENAMED_SPAM_BLOBS: [(&[u8], &[u8]); 2] = [
|
||||||
|
(b"STALWART_SPAM_TRAIN_DATA.lz4", common::manager::SPAM_TRAINER_KEY),
|
||||||
|
(
|
||||||
|
b"STALWART_SPAM_CLASSIFIER_MODEL.lz4",
|
||||||
|
common::manager::SPAM_CLASSIFIER_KEY,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Moves each spam blob from its pre-rename key to the current one, so a
|
||||||
|
/// trained model survives the rename. A blob already under the current key
|
||||||
|
/// wins and the old one is just removed; with neither, nothing happens.
|
||||||
|
async fn rename_spam_blobs(server: &Server) -> trc::Result<()> {
|
||||||
|
let blobs = server.blob_store();
|
||||||
|
for (old, new) in RENAMED_SPAM_BLOBS {
|
||||||
|
let Some(data) = blobs
|
||||||
|
.get_blob(old, 0..usize::MAX)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if blobs
|
||||||
|
.get_blob(new, 0..usize::MAX)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
blobs
|
||||||
|
.put_blob(new, &data, server.core.email.compression)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
blobs.delete_blob(old).await.caused_by(trc::location!())?;
|
||||||
|
trc::event!(
|
||||||
|
Server(trc::ServerEvent::Startup),
|
||||||
|
Details = "Moved a spam filter blob to its renamed key",
|
||||||
|
Key = new,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// This file is auto-generated. Do not edit directly.
|
// This file is auto-generated. Do not edit directly.
|
||||||
@@ -10372,8 +10374,8 @@ impl EnumImpl for SieveCapability {
|
|||||||
b"spamtest" => SieveCapability::Spamtest,
|
b"spamtest" => SieveCapability::Spamtest,
|
||||||
b"spamtestplus" => SieveCapability::Spamtestplus,
|
b"spamtestplus" => SieveCapability::Spamtestplus,
|
||||||
b"virustest" => SieveCapability::Virustest,
|
b"virustest" => SieveCapability::Virustest,
|
||||||
b"vnd.stalwart.while" => SieveCapability::VndStalwartWhile,
|
b"vnd.inbuxa.while" => SieveCapability::VndStalwartWhile,
|
||||||
b"vnd.stalwart.expressions" => SieveCapability::VndStalwartExpressions,
|
b"vnd.inbuxa.expressions" => SieveCapability::VndStalwartExpressions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10426,8 +10428,8 @@ impl EnumImpl for SieveCapability {
|
|||||||
SieveCapability::Spamtest => "spamtest",
|
SieveCapability::Spamtest => "spamtest",
|
||||||
SieveCapability::Spamtestplus => "spamtestplus",
|
SieveCapability::Spamtestplus => "spamtestplus",
|
||||||
SieveCapability::Virustest => "virustest",
|
SieveCapability::Virustest => "virustest",
|
||||||
SieveCapability::VndStalwartWhile => "vnd.stalwart.while",
|
SieveCapability::VndStalwartWhile => "vnd.inbuxa.while",
|
||||||
SieveCapability::VndStalwartExpressions => "vnd.stalwart.expressions",
|
SieveCapability::VndStalwartExpressions => "vnd.inbuxa.expressions",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -732,7 +732,7 @@ impl Pickle for AddressBook {
|
|||||||
impl Default for AddressBook {
|
impl Default for AddressBook {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
default_display_name: Some("INBUXA Address Book".to_string()),
|
default_display_name: Some("inbuxa Address Book".to_string()),
|
||||||
default_href_name: Some("default".to_string()),
|
default_href_name: Some("default".to_string()),
|
||||||
max_v_card_size: 524288u64,
|
max_v_card_size: 524288u64,
|
||||||
max_address_books: Some(250u64),
|
max_address_books: Some(250u64),
|
||||||
@@ -4597,7 +4597,7 @@ impl Pickle for Calendar {
|
|||||||
impl Default for Calendar {
|
impl Default for Calendar {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
default_display_name: Some("INBUXA Calendar".to_string()),
|
default_display_name: Some("inbuxa Calendar".to_string()),
|
||||||
default_href_name: Some("default".to_string()),
|
default_href_name: Some("default".to_string()),
|
||||||
max_attendees: 20u64,
|
max_attendees: 20u64,
|
||||||
max_recurrence_expansions: 3000u64,
|
max_recurrence_expansions: 3000u64,
|
||||||
@@ -4734,7 +4734,7 @@ impl Default for CalendarAlarm {
|
|||||||
allow_external_rcpts: false,
|
allow_external_rcpts: false,
|
||||||
enable: true,
|
enable: true,
|
||||||
from_email: Default::default(),
|
from_email: Default::default(),
|
||||||
from_name: "INBUXA Calendar".to_string(),
|
from_name: "inbuxa Calendar".to_string(),
|
||||||
min_trigger_interval: Duration::from_millis(3600000),
|
min_trigger_interval: Duration::from_millis(3600000),
|
||||||
template: Default::default(),
|
template: Default::default(),
|
||||||
}
|
}
|
||||||
@@ -28310,7 +28310,7 @@ impl MtaStageConnect {
|
|||||||
ExpressionContext {
|
ExpressionContext {
|
||||||
expr: &self.smtp_greeting,
|
expr: &self.smtp_greeting,
|
||||||
default: Some(Expression {
|
default: Some(Expression {
|
||||||
else_: "system('hostname') + ' INBUXA ESMTP at your service'".to_string(),
|
else_: "system('hostname') + ' inbuxa ESMTP at your service'".to_string(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
property: Property::SmtpGreeting,
|
property: Property::SmtpGreeting,
|
||||||
@@ -28374,7 +28374,7 @@ impl Default for MtaStageConnect {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
smtp_greeting: Expression {
|
smtp_greeting: Expression {
|
||||||
else_: "system('hostname') + ' INBUXA ESMTP at your service'".to_string(),
|
else_: "system('hostname') + ' inbuxa ESMTP at your service'".to_string(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
hostname: Expression {
|
hostname: Expression {
|
||||||
@@ -29622,8 +29622,8 @@ impl Default for MySqlSettings {
|
|||||||
Self {
|
Self {
|
||||||
host: Default::default(),
|
host: Default::default(),
|
||||||
port: 3306u64,
|
port: 3306u64,
|
||||||
database: "stalwart".to_string(),
|
database: "inbuxa".to_string(),
|
||||||
auth_username: Some("stalwart".to_string()),
|
auth_username: Some("inbuxa".to_string()),
|
||||||
auth_secret: Default::default(),
|
auth_secret: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29780,8 +29780,8 @@ impl Default for MySqlStore {
|
|||||||
read_replicas: Default::default(),
|
read_replicas: Default::default(),
|
||||||
host: Default::default(),
|
host: Default::default(),
|
||||||
port: 3306u64,
|
port: 3306u64,
|
||||||
database: "stalwart".to_string(),
|
database: "inbuxa".to_string(),
|
||||||
auth_username: Some("stalwart".to_string()),
|
auth_username: Some("inbuxa".to_string()),
|
||||||
auth_secret: Default::default(),
|
auth_secret: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29942,7 +29942,7 @@ impl Default for NatsCoordinator {
|
|||||||
no_echo: true,
|
no_echo: true,
|
||||||
use_tls: false,
|
use_tls: false,
|
||||||
auth_secret: Default::default(),
|
auth_secret: Default::default(),
|
||||||
auth_username: Some("stalwart".to_string()),
|
auth_username: Some("inbuxa".to_string()),
|
||||||
credentials: Default::default(),
|
credentials: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31125,8 +31125,8 @@ impl Default for PostgreSqlSettings {
|
|||||||
Self {
|
Self {
|
||||||
host: Default::default(),
|
host: Default::default(),
|
||||||
port: 5432u64,
|
port: 5432u64,
|
||||||
database: "stalwart".to_string(),
|
database: "inbuxa".to_string(),
|
||||||
auth_username: Some("stalwart".to_string()),
|
auth_username: Some("inbuxa".to_string()),
|
||||||
auth_secret: Default::default(),
|
auth_secret: Default::default(),
|
||||||
options: Default::default(),
|
options: Default::default(),
|
||||||
}
|
}
|
||||||
@@ -31270,8 +31270,8 @@ impl Default for PostgreSqlStore {
|
|||||||
read_replicas: Default::default(),
|
read_replicas: Default::default(),
|
||||||
host: Default::default(),
|
host: Default::default(),
|
||||||
port: 5432u64,
|
port: 5432u64,
|
||||||
database: "stalwart".to_string(),
|
database: "inbuxa".to_string(),
|
||||||
auth_username: Some("stalwart".to_string()),
|
auth_username: Some("inbuxa".to_string()),
|
||||||
auth_secret: Default::default(),
|
auth_secret: Default::default(),
|
||||||
options: Default::default(),
|
options: Default::default(),
|
||||||
}
|
}
|
||||||
@@ -32576,7 +32576,7 @@ impl Default for RedisClusterStore {
|
|||||||
Self {
|
Self {
|
||||||
urls: Map::new(vec!["redis://127.0.0.1".to_string()]),
|
urls: Map::new(vec!["redis://127.0.0.1".to_string()]),
|
||||||
timeout: Duration::from_millis(10000),
|
timeout: Duration::from_millis(10000),
|
||||||
auth_username: Some("stalwart".to_string()),
|
auth_username: Some("inbuxa".to_string()),
|
||||||
auth_secret: Default::default(),
|
auth_secret: Default::default(),
|
||||||
max_retry_wait: Default::default(),
|
max_retry_wait: Default::default(),
|
||||||
min_retry_wait: Default::default(),
|
min_retry_wait: Default::default(),
|
||||||
@@ -32743,7 +32743,7 @@ impl Default for RedisSentinelStore {
|
|||||||
urls: Map::new(vec!["redis://127.0.0.1:26379".to_string()]),
|
urls: Map::new(vec!["redis://127.0.0.1:26379".to_string()]),
|
||||||
service_name: "mymaster".to_string(),
|
service_name: "mymaster".to_string(),
|
||||||
timeout: Duration::from_millis(10000),
|
timeout: Duration::from_millis(10000),
|
||||||
auth_username: Some("stalwart".to_string()),
|
auth_username: Some("inbuxa".to_string()),
|
||||||
auth_secret: Default::default(),
|
auth_secret: Default::default(),
|
||||||
sentinel_username: Default::default(),
|
sentinel_username: Default::default(),
|
||||||
sentinel_secret: Default::default(),
|
sentinel_secret: Default::default(),
|
||||||
@@ -40215,7 +40215,7 @@ impl Default for SpamSettings {
|
|||||||
score_reject: Float::new(0.0f64),
|
score_reject: Float::new(0.0f64),
|
||||||
score_spam: Float::new(5.0f64),
|
score_spam: Float::new(5.0f64),
|
||||||
trust_replies: true,
|
trust_replies: true,
|
||||||
spam_filter_rules_url: Some("https://github.com/stalwartlabs/spam-filter/releases/latest/download/spam-filter-rules.json.gz".to_string()),
|
spam_filter_rules_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46308,7 +46308,7 @@ impl Default for TracerLog {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
path: Default::default(),
|
path: Default::default(),
|
||||||
prefix: "stalwart".to_string(),
|
prefix: "inbuxa".to_string(),
|
||||||
rotate: LogRotateFrequency::Daily,
|
rotate: LogRotateFrequency::Daily,
|
||||||
ansi: true,
|
ansi: true,
|
||||||
multiline: false,
|
multiline: false,
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// The release profile computes the layout of this crate's async fn bodies in
|
||||||
|
// one go, and the deepest of them -- writable_domain, which awaits through
|
||||||
|
// the directory, the store and the JMAP registry -- takes rustc past its
|
||||||
|
// default query depth. The dev profile does not get that far, so the failure
|
||||||
|
// only appears in a release build: CI was green and the tag that started a
|
||||||
|
// release was not.
|
||||||
|
#![recursion_limit = "256"]
|
||||||
|
|
||||||
//! SCIM 2.0 provisioning (`docs/spec/features/scim.md`). inbuxa-server is the
|
//! SCIM 2.0 provisioning (`docs/spec/features/scim.md`). inbuxa-server is the
|
||||||
//! service provider: an identity provider pushes users and groups to
|
//! service provider: an identity provider pushes users and groups to
|
||||||
//! `/scim/v2`, and each request becomes the same `x:Account` reads and
|
//! `/scim/v2`, and each request becomes the same `x:Account` reads and
|
||||||
@@ -205,7 +213,7 @@ impl ScimResponse {
|
|||||||
if error.status == 401 {
|
if error.status == 401 {
|
||||||
response.headers.push((
|
response.headers.push((
|
||||||
"WWW-Authenticate",
|
"WWW-Authenticate",
|
||||||
"Bearer realm=\"INBUXA SCIM\"".to_string(),
|
"Bearer realm=\"inbuxa SCIM\"".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
response
|
response
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
|
|||||||
};
|
};
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut retry_count = 0;
|
let mut retry_count: u32 = 0;
|
||||||
|
|
||||||
trc::event!(Cluster(ClusterEvent::SubscriberStart));
|
trc::event!(Cluster(ClusterEvent::SubscriberStart));
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
|
|||||||
);
|
);
|
||||||
|
|
||||||
match tokio::time::timeout(
|
match tokio::time::timeout(
|
||||||
Duration::from_secs(1 << retry_count.max(6)),
|
subscribe_retry_delay(retry_count),
|
||||||
shutdown_rx.changed(),
|
shutdown_rx.changed(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -62,7 +62,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
retry_count += 1;
|
retry_count = retry_count.saturating_add(1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,6 +234,11 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delay before the next subscribe attempt: 1 s, 2 s, 4 s ... capped at 64 s.
|
||||||
|
fn subscribe_retry_delay(retry_count: u32) -> Duration {
|
||||||
|
Duration::from_secs(1u64 << retry_count.min(6))
|
||||||
|
}
|
||||||
|
|
||||||
fn log_event(event: &BroadcastEvent) -> trc::Value {
|
fn log_event(event: &BroadcastEvent) -> trc::Value {
|
||||||
match event {
|
match event {
|
||||||
BroadcastEvent::PushNotification(notification) => match notification {
|
BroadcastEvent::PushNotification(notification) => match notification {
|
||||||
@@ -296,3 +301,19 @@ fn log_event(event: &BroadcastEvent) -> trc::Value {
|
|||||||
BroadcastEvent::QueueRefresh => "QueueRefresh".into(),
|
BroadcastEvent::QueueRefresh => "QueueRefresh".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::subscribe_retry_delay;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subscribe_retry_backoff_grows_then_caps() {
|
||||||
|
let schedule: Vec<u64> = (0..10)
|
||||||
|
.map(|n| subscribe_retry_delay(n).as_secs())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(schedule, vec![1, 2, 4, 8, 16, 32, 64, 64, 64, 64]);
|
||||||
|
// No shift overflow at the top of the range.
|
||||||
|
assert_eq!(subscribe_retry_delay(u32::MAX), Duration::from_secs(64));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,7 +91,15 @@ impl SearchIndexTask for Server {
|
|||||||
build_contact_document(self, account_id, document_id).await
|
build_contact_document(self, account_id, document_id).await
|
||||||
}
|
}
|
||||||
IndexDocumentType::File => {
|
IndexDocumentType::File => {
|
||||||
// File indexing not implemented yet
|
// File indexing not implemented yet. inbuxa: still
|
||||||
|
// one result per task: update_tasks pairs them by
|
||||||
|
// position, and a missing one shifts every result
|
||||||
|
// after it onto the wrong task
|
||||||
|
results.push(IndexTaskResult {
|
||||||
|
task_type: TaskType::Insert,
|
||||||
|
index: task.document_type,
|
||||||
|
result: TaskResult::Ignored,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -567,20 +575,14 @@ async fn build_contact_document(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// inbuxa: MON-16: a trace's search document, when trace search is on:
|
// inbuxa: MON-16: a trace's search document, when trace search is on
|
||||||
// its event types, queue ids, and addresses, their domains, hosts, IPs,
|
|
||||||
// message ids and account names as keywords
|
|
||||||
async fn build_tracing_span_document(
|
async fn build_tracing_span_document(
|
||||||
server: &Server,
|
server: &Server,
|
||||||
span_id: u64,
|
span_id: u64,
|
||||||
) -> trc::Result<Option<IndexDocument>> {
|
) -> trc::Result<Option<IndexDocument>> {
|
||||||
use common::telemetry::tracers::store::MaybeTrace;
|
use common::telemetry::tracers::store::MaybeTrace;
|
||||||
use registry::schema::{enums::SearchTracingField, structs::Search};
|
use registry::schema::structs::Search;
|
||||||
use store::{
|
use store::write::{TelemetryClass, ValueClass};
|
||||||
search::TracingSearchField,
|
|
||||||
write::{TelemetryClass, ValueClass},
|
|
||||||
};
|
|
||||||
use trc::Key;
|
|
||||||
|
|
||||||
let settings = server
|
let settings = server
|
||||||
.registry()
|
.registry()
|
||||||
@@ -590,7 +592,6 @@ async fn build_tracing_span_document(
|
|||||||
if !settings.index_telemetry {
|
if !settings.index_telemetry {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let wants = |field: SearchTracingField| settings.index_tracing_fields.iter().any(|f| *f == field);
|
|
||||||
let Some(MaybeTrace(Some(trace))) = server
|
let Some(MaybeTrace(Some(trace))) = server
|
||||||
.tracing_store()
|
.tracing_store()
|
||||||
.get_value::<MaybeTrace>(ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span(
|
.get_value::<MaybeTrace>(ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span(
|
||||||
@@ -601,23 +602,67 @@ async fn build_tracing_span_document(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Ok(Some(trace_search_document(
|
||||||
|
span_id,
|
||||||
|
&trace,
|
||||||
|
&settings
|
||||||
|
.index_tracing_fields
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MON-16: the search document for a stored trace.
|
||||||
|
///
|
||||||
|
/// The event type and queue id columns are integers on every search backend
|
||||||
|
/// (BIGINT on PostgreSQL and MySQL, long on Elasticsearch), and each holds a
|
||||||
|
/// single value per trace: the event type is the trace's opening event, the
|
||||||
|
/// one `x:Trace/query` filters on, and the queue id is the first queue id the
|
||||||
|
/// trace mentions. Every queue id also goes into the keywords, so a session
|
||||||
|
/// that queued several messages is found by any of them.
|
||||||
|
pub fn trace_search_document(
|
||||||
|
span_id: u64,
|
||||||
|
trace: ®istry::schema::structs::Trace,
|
||||||
|
fields: &[registry::schema::enums::SearchTracingField],
|
||||||
|
) -> IndexDocument {
|
||||||
|
use registry::schema::{enums::SearchTracingField, structs::TraceValue};
|
||||||
|
use store::search::TracingSearchField;
|
||||||
|
use trc::Key;
|
||||||
|
|
||||||
|
let wants = |field: SearchTracingField| fields.contains(&field);
|
||||||
let mut document = IndexDocument::new(SearchIndex::Tracing).with_id(span_id);
|
let mut document = IndexDocument::new(SearchIndex::Tracing).with_id(span_id);
|
||||||
|
if wants(SearchTracingField::EventType)
|
||||||
|
&& let Some(first) = trace.events.iter().next()
|
||||||
|
{
|
||||||
|
document.index_unsigned(TracingSearchField::EventType, first.event.to_id() as u64);
|
||||||
|
}
|
||||||
|
|
||||||
let mut seen = store::ahash::AHashSet::new();
|
let mut seen = store::ahash::AHashSet::new();
|
||||||
|
let mut queue_id_indexed = false;
|
||||||
for event in trace.events.iter() {
|
for event in trace.events.iter() {
|
||||||
if wants(SearchTracingField::EventType) && seen.insert(event.event.as_str().to_string()) {
|
|
||||||
document.index_keyword(TracingSearchField::EventType, event.event.as_str());
|
|
||||||
}
|
|
||||||
for kv in event.key_values.iter() {
|
for kv in event.key_values.iter() {
|
||||||
let text = match &kv.value {
|
let text = match &kv.value {
|
||||||
registry::schema::structs::TraceValue::String(v) => v.value.clone(),
|
TraceValue::String(v) => v.value.clone(),
|
||||||
registry::schema::structs::TraceValue::UnsignedInt(v) => v.value.to_string(),
|
TraceValue::UnsignedInt(v) => v.value.to_string(),
|
||||||
registry::schema::structs::TraceValue::IpAddr(v) => v.value.to_string(),
|
TraceValue::IpAddr(v) => v.value.to_string(),
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
match kv.key {
|
match kv.key {
|
||||||
Key::QueueId if wants(SearchTracingField::QueueId) => {
|
Key::QueueId => {
|
||||||
if seen.insert(format!("q:{text}")) {
|
let Ok(queue_id) = text.parse::<u64>() else {
|
||||||
document.index_keyword(TracingSearchField::QueueId, &text);
|
continue;
|
||||||
|
};
|
||||||
|
if wants(SearchTracingField::QueueId) && !queue_id_indexed {
|
||||||
|
document.index_unsigned(TracingSearchField::QueueId, queue_id);
|
||||||
|
queue_id_indexed = true;
|
||||||
|
}
|
||||||
|
if wants(SearchTracingField::Keywords) && seen.insert(format!("k:{text}")) {
|
||||||
|
document.index_text(
|
||||||
|
TracingSearchField::Keywords,
|
||||||
|
&text,
|
||||||
|
nlp::language::Language::None,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Key::From
|
Key::From
|
||||||
@@ -648,7 +693,7 @@ async fn build_tracing_span_document(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Some(document))
|
document
|
||||||
}
|
}
|
||||||
|
|
||||||
// inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at
|
// inbuxa: UD-1, UD-4: archives a deleted file, event or contact noted at
|
||||||
|
|||||||
@@ -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::task_manager::*;
|
use crate::task_manager::*;
|
||||||
@@ -13,13 +15,21 @@ pub trait TaskLockManager: Sync + Send {
|
|||||||
|
|
||||||
impl TaskLockManager for Server {
|
impl TaskLockManager for Server {
|
||||||
async fn try_lock_task(&self, id: u64) -> bool {
|
async fn try_lock_task(&self, id: u64) -> bool {
|
||||||
|
// inbuxa: a node that is stopping claims nothing new
|
||||||
|
let locks = &self.inner.ipc.task_locks;
|
||||||
|
if locks.is_stopping() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
match self
|
match self
|
||||||
.in_memory_store()
|
.in_memory_store()
|
||||||
.try_lock(KV_LOCK_TASK, &id.to_be_bytes(), DEFAULT_LOCK_EXPIRY)
|
.try_lock(KV_LOCK_TASK, &id.to_be_bytes(), locks.expiry())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
if !result {
|
if result {
|
||||||
|
locks.insert(id);
|
||||||
|
} else {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
TaskManager(TaskManagerEvent::TaskLocked),
|
TaskManager(TaskManagerEvent::TaskLocked),
|
||||||
Id = id,
|
Id = id,
|
||||||
@@ -48,5 +58,66 @@ impl TaskLockManager for Server {
|
|||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
self.inner.ipc.task_locks.remove(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: on a graceful stop, stops claiming tasks and releases every task
|
||||||
|
/// lock this node holds, so the rest of the cluster can pick the tasks up at
|
||||||
|
/// once instead of after the lock expires. Returns how many were released.
|
||||||
|
pub async fn release_task_locks(server: &Server) -> usize {
|
||||||
|
let ids = server.inner.ipc.task_locks.stop();
|
||||||
|
for id in &ids {
|
||||||
|
if let Err(err) = server
|
||||||
|
.in_memory_store()
|
||||||
|
.remove_lock(KV_LOCK_TASK, &id.to_be_bytes())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
trc::error!(
|
||||||
|
err.details("Failed to release task lock on shutdown")
|
||||||
|
.ctx(trc::Key::Id, *id)
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: renews the lease on every task this node is running, so it stays
|
||||||
|
/// claimed for as long as it runs while a node that dies loses its claims
|
||||||
|
/// within one lock lifetime. Returns how many leases were renewed and how
|
||||||
|
/// many were found lost (expired, perhaps taken by another node).
|
||||||
|
pub async fn renew_task_locks(server: &Server) -> (usize, usize) {
|
||||||
|
let locks = &server.inner.ipc.task_locks;
|
||||||
|
let expiry = locks.expiry();
|
||||||
|
let (mut renewed, mut lost) = (0, 0);
|
||||||
|
for id in locks.held_ids() {
|
||||||
|
match server
|
||||||
|
.in_memory_store()
|
||||||
|
.renew_lock(KV_LOCK_TASK, &id.to_be_bytes(), expiry)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(true) => renewed += 1,
|
||||||
|
Ok(false) => {
|
||||||
|
// Still held here as far as this node knows; the task
|
||||||
|
// finishes and its lock is removed as usual
|
||||||
|
if locks.is_held(id) {
|
||||||
|
lost += 1;
|
||||||
|
trc::event!(
|
||||||
|
TaskManager(TaskManagerEvent::TaskLocked),
|
||||||
|
Id = id,
|
||||||
|
Details = "Task lock expired while the task was running",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
trc::error!(
|
||||||
|
err.details("Failed to renew task lock")
|
||||||
|
.ctx(trc::Key::Id, id)
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(renewed, lost)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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::task_manager::acme::AcmeTask;
|
use crate::task_manager::acme::AcmeTask;
|
||||||
@@ -11,22 +13,24 @@ use crate::task_manager::dkim::DkimManagementTask;
|
|||||||
use crate::task_manager::dns::DnsManagementTask;
|
use crate::task_manager::dns::DnsManagementTask;
|
||||||
use crate::task_manager::imip::SendImipTask;
|
use crate::task_manager::imip::SendImipTask;
|
||||||
use crate::task_manager::index::SearchIndexTask;
|
use crate::task_manager::index::SearchIndexTask;
|
||||||
use crate::task_manager::lock::TaskLockManager;
|
use crate::task_manager::lock::{TaskLockManager, renew_task_locks};
|
||||||
use crate::task_manager::maintenance::MaintenanceTask;
|
use crate::task_manager::maintenance::MaintenanceTask;
|
||||||
use crate::task_manager::merge_threads::MergeThreadsTask;
|
use crate::task_manager::merge_threads::MergeThreadsTask;
|
||||||
use crate::task_manager::report::{self, SubmitReportTask};
|
use crate::task_manager::report::{self, SubmitReportTask};
|
||||||
use crate::task_manager::restore_item::RestoreItemTask;
|
use crate::task_manager::restore_item::RestoreItemTask;
|
||||||
use crate::task_manager::spam_classifier::SpamFilterMaintenanceTask;
|
use crate::task_manager::spam_classifier::SpamFilterMaintenanceTask;
|
||||||
use crate::task_manager::{
|
use crate::task_manager::{
|
||||||
DEFAULT_LOCK_EXPIRY, Locked, QUEUE_REFRESH_INTERVAL, TaskDetails, TaskFailureType, TaskInfo,
|
CLAIM_RECHECK_INTERVAL, Locked, QUEUE_REFRESH_INTERVAL, TaskDetails, TaskFailureType, TaskInfo,
|
||||||
TaskJob, TaskManagerIpc, TaskResult,
|
TaskJob, TaskManagerIpc, TaskResult,
|
||||||
};
|
};
|
||||||
use common::BuildServer;
|
use common::BuildServer;
|
||||||
|
use common::config::network::ClusterRoles;
|
||||||
use common::config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol};
|
use common::config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol};
|
||||||
use common::network::limiter::ConcurrencyLimiter;
|
use common::network::limiter::ConcurrencyLimiter;
|
||||||
use common::network::{ServerInstance, TcpAcceptor};
|
use common::network::{ServerInstance, TcpAcceptor};
|
||||||
use common::{Inner, Server};
|
use common::{Inner, Server};
|
||||||
use registry::schema::enums::TaskType;
|
use registry::schema::enums::TaskType;
|
||||||
|
use registry::schema::prelude::ObjectType;
|
||||||
use registry::schema::structs::{
|
use registry::schema::structs::{
|
||||||
Task, TaskManager, TaskRetryStrategy, TaskStatus, TaskStatusFailed, TaskStatusRetry,
|
Task, TaskManager, TaskRetryStrategy, TaskStatus, TaskStatusFailed, TaskStatusRetry,
|
||||||
};
|
};
|
||||||
@@ -52,24 +56,37 @@ const PERPETUAL_RETRY_MIN_DELAY: u64 = 3600;
|
|||||||
const PERPETUAL_RETRY_MAX_DELAY: u64 = 21600;
|
const PERPETUAL_RETRY_MAX_DELAY: u64 = 21600;
|
||||||
|
|
||||||
pub fn spawn_task_manager(inner: Arc<Inner>) {
|
pub fn spawn_task_manager(inner: Arc<Inner>) {
|
||||||
let is_clustered = {
|
// inbuxa: upstream didn't start the task manager on a node whose role
|
||||||
let server = inner.build_server();
|
// had no task types at boot, so adding one later did nothing until a
|
||||||
let roles = &server.core.network.roles;
|
// restart. It now always runs and reads the role on every scan and
|
||||||
|
// before every job (task_enabled), so a role change applies at the next
|
||||||
if !roles.account_maintenance
|
// settings reload.
|
||||||
&& !roles.store_maintenance
|
let is_clustered = inner.build_server().core.storage.coordinator.is_enabled();
|
||||||
&& !roles.search_indexing
|
|
||||||
&& !roles.spam_training
|
|
||||||
&& !roles.task_manager
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
server.core.storage.coordinator.is_enabled()
|
|
||||||
};
|
|
||||||
|
|
||||||
trc::event!(TaskManager(TaskManagerEvent::ManagerStarted));
|
trc::event!(TaskManager(TaskManagerEvent::ManagerStarted));
|
||||||
|
|
||||||
|
// inbuxa: keep the leases of running tasks alive, every third of a lock
|
||||||
|
// lifetime, until the node stops
|
||||||
|
{
|
||||||
|
let inner = inner.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut renewed_at = Instant::now();
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
let locks = &inner.ipc.task_locks;
|
||||||
|
if locks.is_stopping() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if renewed_at.elapsed() >= Duration::from_secs((locks.expiry() / 3).max(1)) {
|
||||||
|
renewed_at = Instant::now();
|
||||||
|
if locks.held() > 0 {
|
||||||
|
renew_task_locks(&inner.build_server()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create dummy server instance for alarms
|
// Create dummy server instance for alarms
|
||||||
let server_instance = Arc::new(ServerInstance {
|
let server_instance = Arc::new(ServerInstance {
|
||||||
id: "_local".to_string(),
|
id: "_local".to_string(),
|
||||||
@@ -124,72 +141,50 @@ pub fn spawn_task_manager(inner: Arc<Inner>) {
|
|||||||
let server = inner.build_server();
|
let server = inner.build_server();
|
||||||
let batch_size = server.core.email.index_batch_size;
|
let batch_size = server.core.email.index_batch_size;
|
||||||
let mut batch = Vec::with_capacity(batch_size);
|
let mut batch = Vec::with_capacity(batch_size);
|
||||||
match server
|
if let Some(task) = fetch_enabled_task(&server, job).await {
|
||||||
.store()
|
batch.push(task);
|
||||||
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
|
||||||
TaskQueueClass::Task { id: job.id },
|
|
||||||
)))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(task)) => {
|
|
||||||
batch.push(TaskDetails { task, info: job });
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
trc::event!(
|
|
||||||
TaskManager(TaskManagerEvent::TaskIgnored),
|
|
||||||
Id = job.id,
|
|
||||||
Reason = "Task not found in store, likely already processed.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
trc::error!(
|
|
||||||
err.id(job.id)
|
|
||||||
.details("Failed to retrieve task details.")
|
|
||||||
.caused_by(trc::location!())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
while batch.len() < batch_size {
|
while batch.len() < batch_size {
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(job) => {
|
Ok(job) => {
|
||||||
match server
|
if let Some(task) = fetch_enabled_task(&server, job).await {
|
||||||
.store()
|
batch.push(task);
|
||||||
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
|
||||||
TaskQueueClass::Task { id: job.id },
|
|
||||||
)))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(task)) => {
|
|
||||||
batch.push(TaskDetails { task, info: job });
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
trc::event!(
|
|
||||||
TaskManager(TaskManagerEvent::TaskIgnored),
|
|
||||||
Id = job.id,
|
|
||||||
Reason = "Task not found in store, likely already processed.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
trc::error!(
|
|
||||||
err.id(job.id)
|
|
||||||
.details("Failed to retrieve task details.")
|
|
||||||
.caused_by(trc::location!())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if batch.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Dispatch
|
// Dispatch. inbuxa: on a task of its own, so a panic
|
||||||
|
// releases the batch's locks and leaves this worker
|
||||||
|
// running; a dead worker would keep claiming tasks it
|
||||||
|
// can never run
|
||||||
let mut refresh_queue = false;
|
let mut refresh_queue = false;
|
||||||
let results = server.index(&batch).await.into_iter().map(|r| {
|
let ids = batch.iter().map(|task| task.info.id).collect::<Vec<_>>();
|
||||||
refresh_queue |= r.result.is_retry();
|
let run = {
|
||||||
r.result
|
let server = server.clone();
|
||||||
});
|
tokio::spawn(async move {
|
||||||
update_tasks(&server, &mut batch, results).await;
|
let results = server.index(&batch).await;
|
||||||
|
(batch, results)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
match run.await {
|
||||||
|
Ok((mut batch, results)) => {
|
||||||
|
let results = results.into_iter().map(|r| {
|
||||||
|
refresh_queue |= r.result.is_retry();
|
||||||
|
r.result
|
||||||
|
});
|
||||||
|
update_tasks(&server, &mut batch, results).await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
worker_failed(&server, &ids, err).await;
|
||||||
|
refresh_queue = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if refresh_queue || rx.is_empty() {
|
if refresh_queue || rx.is_empty() {
|
||||||
server.notify_task_queue();
|
server.notify_task_queue();
|
||||||
@@ -203,83 +198,32 @@ pub fn spawn_task_manager(inner: Arc<Inner>) {
|
|||||||
let server = inner.build_server();
|
let server = inner.build_server();
|
||||||
let mut refresh_queue = false;
|
let mut refresh_queue = false;
|
||||||
|
|
||||||
match server
|
if let Some(TaskDetails { task, info }) = fetch_enabled_task(&server, job).await
|
||||||
.store()
|
|
||||||
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
|
||||||
TaskQueueClass::Task { id: job.id },
|
|
||||||
)))
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(Some(task)) => {
|
// inbuxa: on a task of its own, as above
|
||||||
let result = match &task {
|
let run = {
|
||||||
Task::CalendarAlarmEmail(task) => {
|
let server = server.clone();
|
||||||
server.send_email_alarm(task, server_instance.clone()).await
|
let server_instance = server_instance.clone();
|
||||||
}
|
tokio::spawn(async move {
|
||||||
Task::CalendarAlarmNotification(task) => {
|
let result = run_task(&server, &task, server_instance).await;
|
||||||
server.send_display_alarm(task).await
|
(task, result)
|
||||||
}
|
})
|
||||||
Task::CalendarItipMessage(task) => {
|
};
|
||||||
server.send_imip(task, server_instance.clone()).await
|
match run.await {
|
||||||
}
|
Ok((task, result)) => {
|
||||||
Task::MergeThreads(task) => server.merge_threads(task).await,
|
refresh_queue = result.is_retry();
|
||||||
Task::DmarcReport(task) => {
|
|
||||||
server
|
|
||||||
.submit_report(report::ReportId::Dmarc(task.report_id.id()))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Task::TlsReport(task) => {
|
|
||||||
server
|
|
||||||
.submit_report(report::ReportId::Tls(task.report_id.id()))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
Task::RestoreArchivedItem(task) => server.restore_item(task).await,
|
|
||||||
Task::DestroyAccount(task) => server.destroy_account(task).await,
|
|
||||||
Task::AccountMaintenance(task) => {
|
|
||||||
server.account_maintenance(task).await
|
|
||||||
}
|
|
||||||
Task::TenantMaintenance(task) => {
|
|
||||||
server.tenant_maintenance(task).await
|
|
||||||
}
|
|
||||||
Task::StoreMaintenance(task) => {
|
|
||||||
server.store_maintenance(task).await
|
|
||||||
}
|
|
||||||
Task::SpamFilterMaintenance(task) => {
|
|
||||||
Box::pin(server.spam_filter_maintenance(task)).await
|
|
||||||
}
|
|
||||||
Task::AcmeRenewal(task) => server.acme_management(task).await,
|
|
||||||
Task::DkimManagement(task_dkim_rotation) => {
|
|
||||||
server.dkim_management(task_dkim_rotation).await
|
|
||||||
}
|
|
||||||
Task::DnsManagement(task_dns_management) => {
|
|
||||||
server.dns_management(task_dns_management).await
|
|
||||||
}
|
|
||||||
Task::IndexDocument(_)
|
|
||||||
| Task::UnindexDocument(_)
|
|
||||||
| Task::IndexTrace(_) => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
refresh_queue = result.is_retry();
|
update_tasks(
|
||||||
|
&server,
|
||||||
update_tasks(
|
&mut [TaskDetails { task, info }],
|
||||||
&server,
|
vec![result],
|
||||||
&mut [TaskDetails { task, info: job }],
|
)
|
||||||
vec![result],
|
.await;
|
||||||
)
|
}
|
||||||
.await;
|
Err(err) => {
|
||||||
}
|
worker_failed(&server, &[info.id], err).await;
|
||||||
Ok(None) => {
|
refresh_queue = true;
|
||||||
trc::event!(
|
}
|
||||||
TaskManager(TaskManagerEvent::TaskIgnored),
|
|
||||||
Id = job.id,
|
|
||||||
Reason = "Task not found in store, likely already processed.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
trc::error!(
|
|
||||||
err.id(job.id)
|
|
||||||
.details("Failed to retrieve task details.")
|
|
||||||
.caused_by(trc::location!())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +262,24 @@ pub(crate) trait TaskQueueManager: Sync + Send {
|
|||||||
|
|
||||||
impl TaskQueueManager for Server {
|
impl TaskQueueManager for Server {
|
||||||
async fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> Duration {
|
async fn process_tasks(&self, ipc: &mut TaskManagerIpc) -> Duration {
|
||||||
|
// inbuxa: a node that is stopping has released its locks and claims
|
||||||
|
// nothing new
|
||||||
|
let task_locks = &self.inner.ipc.task_locks;
|
||||||
|
if task_locks.is_stopping() {
|
||||||
|
return Duration::from_secs(QUEUE_REFRESH_INTERVAL);
|
||||||
|
}
|
||||||
|
// inbuxa: with no task type enabled by this node's role there is
|
||||||
|
// nothing to claim; a settings reload wakes the manager when that
|
||||||
|
// changes
|
||||||
|
let roles = &self.core.network.roles;
|
||||||
|
if !(0..TaskType::COUNT as u16)
|
||||||
|
.filter_map(TaskType::from_id)
|
||||||
|
.any(|task_type| task_enabled(roles, task_type))
|
||||||
|
{
|
||||||
|
ipc.locked.clear();
|
||||||
|
return Duration::from_secs(QUEUE_REFRESH_INTERVAL);
|
||||||
|
}
|
||||||
|
let lock_expiry = task_locks.expiry();
|
||||||
let now_timestamp = now();
|
let now_timestamp = now();
|
||||||
let from_key = ValueKey::<ValueClass> {
|
let from_key = ValueKey::<ValueClass> {
|
||||||
account_id: 0,
|
account_id: 0,
|
||||||
@@ -337,9 +299,9 @@ impl TaskQueueManager for Server {
|
|||||||
|
|
||||||
// Retrieve tasks pending to be processed
|
// Retrieve tasks pending to be processed
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
|
let mut unreadable = Vec::new();
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut next_event = None;
|
let mut next_event = None;
|
||||||
let roles = &self.core.network.roles;
|
|
||||||
ipc.revision += 1;
|
ipc.revision += 1;
|
||||||
let _ = self
|
let _ = self
|
||||||
.store()
|
.store()
|
||||||
@@ -351,32 +313,28 @@ impl TaskQueueManager for Server {
|
|||||||
let task_id = key.deserialize_be_u64(U64_LEN)?;
|
let task_id = key.deserialize_be_u64(U64_LEN)?;
|
||||||
|
|
||||||
if task_due <= now_timestamp {
|
if task_due <= now_timestamp {
|
||||||
let task_type_idx = value.deserialize_be_u16(0)?;
|
// inbuxa: a row whose task type can't be read is
|
||||||
let task_type = TaskType::from_id(task_type_idx).ok_or_else(|| {
|
// set aside, not allowed to end the scan: every
|
||||||
trc::StoreEvent::DataCorruption
|
// task due after it would wait behind it
|
||||||
.caused_by(trc::location!())
|
let Some((task_type_idx, task_type)) = value
|
||||||
.ctx(trc::Key::Value, value)
|
.deserialize_be_u16(0)
|
||||||
})?;
|
.ok()
|
||||||
let enabled = match task_type {
|
.and_then(|idx| TaskType::from_id(idx).map(|typ| (idx, typ)))
|
||||||
TaskType::IndexDocument
|
else {
|
||||||
| TaskType::UnindexDocument
|
unreadable.push(UnreadableDueRow {
|
||||||
| TaskType::IndexTrace => roles.search_indexing,
|
due: task_due,
|
||||||
TaskType::AccountMaintenance
|
id: task_id,
|
||||||
| TaskType::TenantMaintenance
|
value: value.to_vec(),
|
||||||
| TaskType::DestroyAccount => roles.account_maintenance,
|
});
|
||||||
TaskType::StoreMaintenance => roles.store_maintenance,
|
return Ok(true);
|
||||||
TaskType::SpamFilterMaintenance => roles.spam_training,
|
|
||||||
TaskType::CalendarAlarmEmail
|
|
||||||
| TaskType::CalendarAlarmNotification
|
|
||||||
| TaskType::CalendarItipMessage
|
|
||||||
| TaskType::MergeThreads
|
|
||||||
| TaskType::DmarcReport
|
|
||||||
| TaskType::TlsReport
|
|
||||||
| TaskType::RestoreArchivedItem
|
|
||||||
| TaskType::AcmeRenewal
|
|
||||||
| TaskType::DkimManagement
|
|
||||||
| TaskType::DnsManagement => true,
|
|
||||||
};
|
};
|
||||||
|
// inbuxa: running here under a lease this node
|
||||||
|
// renews; don't hand it to a worker again
|
||||||
|
if task_locks.is_held(task_id) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let enabled = task_enabled(roles, task_type);
|
||||||
|
|
||||||
if !enabled {
|
if !enabled {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
@@ -393,9 +351,7 @@ impl TaskQueueManager for Server {
|
|||||||
let locked = entry.get_mut();
|
let locked = entry.get_mut();
|
||||||
if locked.expires <= now || locked.due < task_due {
|
if locked.expires <= now || locked.due < task_due {
|
||||||
locked.expires = Instant::now()
|
locked.expires = Instant::now()
|
||||||
+ std::time::Duration::from_secs(
|
+ std::time::Duration::from_secs(lock_expiry + 1);
|
||||||
DEFAULT_LOCK_EXPIRY + 1,
|
|
||||||
);
|
|
||||||
locked.due = task_due;
|
locked.due = task_due;
|
||||||
tasks.push((
|
tasks.push((
|
||||||
TaskJob {
|
TaskJob {
|
||||||
@@ -411,9 +367,7 @@ impl TaskQueueManager for Server {
|
|||||||
Entry::Vacant(entry) => {
|
Entry::Vacant(entry) => {
|
||||||
entry.insert(Locked {
|
entry.insert(Locked {
|
||||||
expires: Instant::now()
|
expires: Instant::now()
|
||||||
+ std::time::Duration::from_secs(
|
+ std::time::Duration::from_secs(lock_expiry + 1),
|
||||||
DEFAULT_LOCK_EXPIRY + 1,
|
|
||||||
),
|
|
||||||
due: task_due,
|
due: task_due,
|
||||||
revision: ipc.revision,
|
revision: ipc.revision,
|
||||||
});
|
});
|
||||||
@@ -446,6 +400,11 @@ impl TaskQueueManager for Server {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if !unreadable.is_empty() && repair_due_rows(self, unreadable).await {
|
||||||
|
// Look again at once for the rows that were rewritten
|
||||||
|
self.notify_task_queue();
|
||||||
|
}
|
||||||
|
|
||||||
if !tasks.is_empty() {
|
if !tasks.is_empty() {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
TaskManager(TaskManagerEvent::TaskAcquired),
|
TaskManager(TaskManagerEvent::TaskAcquired),
|
||||||
@@ -464,12 +423,26 @@ impl TaskQueueManager for Server {
|
|||||||
let tx = &ipc.txs[task_type_idx as usize];
|
let tx = &ipc.txs[task_type_idx as usize];
|
||||||
|
|
||||||
if tx.capacity() > 0 {
|
if tx.capacity() > 0 {
|
||||||
if self.try_lock_task(task_job.id).await && tx.send(task_job).await.is_err() {
|
let id = task_job.id;
|
||||||
|
if !self.try_lock_task(id).await {
|
||||||
|
// inbuxa: another node holds the task. Look again after a
|
||||||
|
// short while rather than a full lock lifetime from now:
|
||||||
|
// the holder may have claimed it after this scan began,
|
||||||
|
// or run on a clock ahead of this one, and waiting the
|
||||||
|
// whole lifetime again would leave the task stuck for
|
||||||
|
// another hour past its lock if that holder died
|
||||||
|
if let Some(locked) = ipc.locked.get_mut(&id) {
|
||||||
|
locked.expires =
|
||||||
|
Instant::now() + Duration::from_secs(claim_recheck_interval(lock_expiry));
|
||||||
|
}
|
||||||
|
} else if tx.send(task_job).await.is_err() {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Server(trc::ServerEvent::ThreadError),
|
Server(trc::ServerEvent::ThreadError),
|
||||||
Details = "Error sending task.",
|
Details = "Error sending task.",
|
||||||
CausedBy = trc::location!()
|
CausedBy = trc::location!()
|
||||||
);
|
);
|
||||||
|
// inbuxa: nothing will run it here, so don't hold it
|
||||||
|
self.remove_index_lock(id).await;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If the channel is full, release the lock so it can be picked up in the next iteration
|
// If the channel is full, release the lock so it can be picked up in the next iteration
|
||||||
@@ -481,9 +454,178 @@ impl TaskQueueManager for Server {
|
|||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
ipc.locked
|
ipc.locked
|
||||||
.retain(|_, locked| locked.expires > now && locked.revision == ipc.revision);
|
.retain(|_, locked| locked.expires > now && locked.revision == ipc.revision);
|
||||||
Duration::from_secs(next_event.map_or(QUEUE_REFRESH_INTERVAL, |timestamp| {
|
let sleep_for = Duration::from_secs(next_event.map_or(QUEUE_REFRESH_INTERVAL, |timestamp| {
|
||||||
timestamp.saturating_sub(store::write::now())
|
timestamp.saturating_sub(store::write::now())
|
||||||
}))
|
}));
|
||||||
|
|
||||||
|
// inbuxa: wake up when a claim held elsewhere is due to be tried
|
||||||
|
// again, rather than only on the next task or refresh
|
||||||
|
ipc.locked
|
||||||
|
.values()
|
||||||
|
.map(|locked| locked.expires.saturating_duration_since(now))
|
||||||
|
.min()
|
||||||
|
.map_or(sleep_for, |recheck| sleep_for.min(recheck.max(Duration::from_secs(1))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: whether this node's cluster role lets it run a task type. Upstream
|
||||||
|
/// checked the dedicated roles (search indexing, account and store
|
||||||
|
/// maintenance, spam training) and let every node with a task manager run
|
||||||
|
/// the rest, whatever its taskQueueProcessing setting. Every task type now
|
||||||
|
/// answers to one ClusterTaskType:
|
||||||
|
///
|
||||||
|
/// - IndexDocument, UnindexDocument, IndexTrace: searchIndexing
|
||||||
|
/// - AccountMaintenance, TenantMaintenance, DestroyAccount: accountMaintenance
|
||||||
|
/// - StoreMaintenance: storeMaintenance
|
||||||
|
/// - SpamFilterMaintenance: spamClassifierTraining
|
||||||
|
/// - DmarcReport, TlsReport: outboundMta. They build and send reports to
|
||||||
|
/// other domains (TLS reports can go straight to an HTTPS endpoint), which
|
||||||
|
/// is the outbound MTA's business.
|
||||||
|
/// - CalendarAlarmEmail, CalendarAlarmNotification, CalendarItipMessage,
|
||||||
|
/// MergeThreads, RestoreArchivedItem, AcmeRenewal, DkimManagement,
|
||||||
|
/// DnsManagement: taskQueueProcessing, the role for queue tasks with no
|
||||||
|
/// role of their own.
|
||||||
|
///
|
||||||
|
/// A node that may not run a task leaves it unclaimed, so a node that may
|
||||||
|
/// picks it up.
|
||||||
|
pub fn task_enabled(roles: &ClusterRoles, task_type: TaskType) -> bool {
|
||||||
|
match task_type {
|
||||||
|
TaskType::IndexDocument | TaskType::UnindexDocument | TaskType::IndexTrace => {
|
||||||
|
roles.search_indexing
|
||||||
|
}
|
||||||
|
TaskType::AccountMaintenance | TaskType::TenantMaintenance | TaskType::DestroyAccount => {
|
||||||
|
roles.account_maintenance
|
||||||
|
}
|
||||||
|
TaskType::StoreMaintenance => roles.store_maintenance,
|
||||||
|
TaskType::SpamFilterMaintenance => roles.spam_training,
|
||||||
|
TaskType::DmarcReport | TaskType::TlsReport => roles.outbound_mta,
|
||||||
|
TaskType::CalendarAlarmEmail
|
||||||
|
| TaskType::CalendarAlarmNotification
|
||||||
|
| TaskType::CalendarItipMessage
|
||||||
|
| TaskType::MergeThreads
|
||||||
|
| TaskType::RestoreArchivedItem
|
||||||
|
| TaskType::AcmeRenewal
|
||||||
|
| TaskType::DkimManagement
|
||||||
|
| TaskType::DnsManagement => roles.task_manager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_task(
|
||||||
|
server: &Server,
|
||||||
|
task: &Task,
|
||||||
|
server_instance: Arc<ServerInstance>,
|
||||||
|
) -> TaskResult {
|
||||||
|
match task {
|
||||||
|
Task::CalendarAlarmEmail(task) => {
|
||||||
|
server.send_email_alarm(task, server_instance.clone()).await
|
||||||
|
}
|
||||||
|
Task::CalendarAlarmNotification(task) => {
|
||||||
|
server.send_display_alarm(task).await
|
||||||
|
}
|
||||||
|
Task::CalendarItipMessage(task) => {
|
||||||
|
server.send_imip(task, server_instance.clone()).await
|
||||||
|
}
|
||||||
|
Task::MergeThreads(task) => server.merge_threads(task).await,
|
||||||
|
Task::DmarcReport(task) => {
|
||||||
|
server
|
||||||
|
.submit_report(report::ReportId::Dmarc(task.report_id.id()))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Task::TlsReport(task) => {
|
||||||
|
server
|
||||||
|
.submit_report(report::ReportId::Tls(task.report_id.id()))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Task::RestoreArchivedItem(task) => server.restore_item(task).await,
|
||||||
|
Task::DestroyAccount(task) => server.destroy_account(task).await,
|
||||||
|
Task::AccountMaintenance(task) => {
|
||||||
|
server.account_maintenance(task).await
|
||||||
|
}
|
||||||
|
Task::TenantMaintenance(task) => {
|
||||||
|
server.tenant_maintenance(task).await
|
||||||
|
}
|
||||||
|
Task::StoreMaintenance(task) => {
|
||||||
|
server.store_maintenance(task).await
|
||||||
|
}
|
||||||
|
Task::SpamFilterMaintenance(task) => {
|
||||||
|
Box::pin(server.spam_filter_maintenance(task)).await
|
||||||
|
}
|
||||||
|
Task::AcmeRenewal(task) => server.acme_management(task).await,
|
||||||
|
Task::DkimManagement(task_dkim_rotation) => {
|
||||||
|
server.dkim_management(task_dkim_rotation).await
|
||||||
|
}
|
||||||
|
Task::DnsManagement(task_dns_management) => {
|
||||||
|
server.dns_management(task_dns_management).await
|
||||||
|
}
|
||||||
|
Task::IndexDocument(_)
|
||||||
|
| Task::UnindexDocument(_)
|
||||||
|
| Task::IndexTrace(_) => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: reads a claimed task when this node's role still allows its type.
|
||||||
|
/// The role may have changed since the task was claimed (a settings reload in
|
||||||
|
/// between); the claim is then handed back at once for a node that may run
|
||||||
|
/// it, rather than held until the lease runs out.
|
||||||
|
async fn fetch_enabled_task(server: &Server, job: TaskJob) -> Option<TaskDetails> {
|
||||||
|
if task_enabled(&server.core.network.roles, job.typ) {
|
||||||
|
fetch_task(server, job).await
|
||||||
|
} else {
|
||||||
|
trc::event!(
|
||||||
|
TaskManager(TaskManagerEvent::TaskIgnored),
|
||||||
|
Id = job.id,
|
||||||
|
Details = job.typ.as_str(),
|
||||||
|
Reason = "Task type was disabled by cluster roles after it was claimed.",
|
||||||
|
);
|
||||||
|
server.remove_index_lock(job.id).await;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a claimed task. When it is gone or can't be read, the claim is
|
||||||
|
/// released: inbuxa: holding it would block the task, everywhere, until
|
||||||
|
/// the lock expired.
|
||||||
|
async fn fetch_task(server: &Server, job: TaskJob) -> Option<TaskDetails> {
|
||||||
|
match server
|
||||||
|
.store()
|
||||||
|
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(TaskQueueClass::Task {
|
||||||
|
id: job.id,
|
||||||
|
})))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(task)) => Some(TaskDetails { task, info: job }),
|
||||||
|
Ok(None) => {
|
||||||
|
trc::event!(
|
||||||
|
TaskManager(TaskManagerEvent::TaskIgnored),
|
||||||
|
Id = job.id,
|
||||||
|
Reason = "Task not found in store, likely already processed.",
|
||||||
|
);
|
||||||
|
server.remove_index_lock(job.id).await;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
trc::error!(
|
||||||
|
err.id(job.id)
|
||||||
|
.details("Failed to retrieve task details.")
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
);
|
||||||
|
server.remove_index_lock(job.id).await;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: a task panicked: its locks are released so it runs again, here or
|
||||||
|
/// on another node, and the worker carries on.
|
||||||
|
async fn worker_failed(server: &Server, ids: &[u64], err: tokio::task::JoinError) {
|
||||||
|
trc::event!(
|
||||||
|
Server(trc::ServerEvent::ThreadError),
|
||||||
|
Details = "Task worker failed",
|
||||||
|
Reason = err.to_string(),
|
||||||
|
CausedBy = trc::location!()
|
||||||
|
);
|
||||||
|
for id in ids {
|
||||||
|
server.remove_index_lock(*id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +756,13 @@ async fn update_tasks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: how long to wait before trying again to claim a task another node
|
||||||
|
/// holds: a twelfth of the lock lifetime, so five minutes for the one-hour
|
||||||
|
/// lock, never more than that and never under a second.
|
||||||
|
pub(crate) fn claim_recheck_interval(lock_expiry: u64) -> u64 {
|
||||||
|
(lock_expiry / 12).clamp(1, CLAIM_RECHECK_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn perpetual_retry_time(typ: TaskType, attempt: u64) -> Option<u64> {
|
pub fn perpetual_retry_time(typ: TaskType, attempt: u64) -> Option<u64> {
|
||||||
matches!(
|
matches!(
|
||||||
typ,
|
typ,
|
||||||
@@ -686,3 +835,114 @@ impl TaskResult {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: a task queue row whose task type could not be read.
|
||||||
|
struct UnreadableDueRow {
|
||||||
|
due: u64,
|
||||||
|
id: u64,
|
||||||
|
value: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: logs each unreadable queue row and repairs it from the task it
|
||||||
|
/// schedules. The task row says what the task is, so the queue row is
|
||||||
|
/// rewritten with that task's type; a row with no task behind it is removed.
|
||||||
|
///
|
||||||
|
/// Rescheduling an internal DMARC or TLS report wrote the report's object
|
||||||
|
/// type into the queue row instead of the task type. Such a row is the time
|
||||||
|
/// an administrator chose, so the task is moved to it as the reschedule
|
||||||
|
/// meant to do: the task row takes that due, and a queue row left at the
|
||||||
|
/// task's previous due is removed. Returns whether any row was repaired.
|
||||||
|
async fn repair_due_rows(server: &Server, rows: Vec<UnreadableDueRow>) -> bool {
|
||||||
|
let mut repaired = false;
|
||||||
|
for row in rows {
|
||||||
|
let UnreadableDueRow { due, id, value } = row;
|
||||||
|
trc::error!(
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.into_err()
|
||||||
|
.id(id)
|
||||||
|
.ctx(trc::Key::Due, trc::Value::Timestamp(due))
|
||||||
|
.ctx(
|
||||||
|
trc::Key::Key,
|
||||||
|
[due.to_be_bytes(), id.to_be_bytes()].concat()
|
||||||
|
)
|
||||||
|
.ctx(trc::Key::Value, value.clone())
|
||||||
|
.details("Unreadable task queue row skipped")
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
);
|
||||||
|
|
||||||
|
let task_key = ValueClass::TaskQueue(TaskQueueClass::Task { id });
|
||||||
|
let due_key = ValueClass::TaskQueue(TaskQueueClass::Due { id, due });
|
||||||
|
let task = match server
|
||||||
|
.store()
|
||||||
|
.get_value::<Task>(ValueKey::from(task_key.clone()))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(task) => task,
|
||||||
|
Err(err) => {
|
||||||
|
trc::error!(
|
||||||
|
err.id(id)
|
||||||
|
.details("Failed to read the task of an unreadable queue row.")
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
let action = if let Some(mut task) = task {
|
||||||
|
let task_type = task.object_type();
|
||||||
|
batch.assert_value(task_key.clone(), AssertValue::Some);
|
||||||
|
if rescheduled_report_type(&value) == Some(task_type) {
|
||||||
|
let old_due = task.due_timestamp();
|
||||||
|
if old_due != due {
|
||||||
|
batch.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||||
|
id,
|
||||||
|
due: old_due,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
task.set_status(TaskStatus::at(due as i64));
|
||||||
|
}
|
||||||
|
batch
|
||||||
|
.set(due_key, task_type.to_id().serialize())
|
||||||
|
.set(task_key, task.to_pickled_vec());
|
||||||
|
"Rewrote the queue row from its task."
|
||||||
|
} else {
|
||||||
|
batch.clear(due_key);
|
||||||
|
"Removed a queue row with no task."
|
||||||
|
};
|
||||||
|
|
||||||
|
match server.store().write(batch.build_all()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
repaired = true;
|
||||||
|
trc::event!(
|
||||||
|
TaskManager(TaskManagerEvent::TaskIgnored),
|
||||||
|
Id = id,
|
||||||
|
Due = trc::Value::Timestamp(due),
|
||||||
|
Reason = action,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) if err.matches(trc::EventType::Store(trc::StoreEvent::AssertValueFailed)) => {
|
||||||
|
// The task went away meanwhile; the next scan looks again
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
trc::error!(
|
||||||
|
err.id(id)
|
||||||
|
.details("Failed to repair an unreadable queue row.")
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repaired
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the task type a report reschedule meant, when a queue row holds
|
||||||
|
/// an internal report's object type (the value that reschedule wrote).
|
||||||
|
fn rescheduled_report_type(value: &[u8]) -> Option<TaskType> {
|
||||||
|
let id = u16::from_be_bytes(value.get(..2)?.try_into().ok()?);
|
||||||
|
match ObjectType::from_id(id)? {
|
||||||
|
ObjectType::DmarcInternalReport => Some(TaskType::DmarcReport),
|
||||||
|
ObjectType::TlsInternalReport => Some(TaskType::TlsReport),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ pub mod scheduler;
|
|||||||
pub mod spam_classifier;
|
pub mod spam_classifier;
|
||||||
|
|
||||||
const QUEUE_REFRESH_INTERVAL: u64 = 60 * 5; // 5 minutes
|
const QUEUE_REFRESH_INTERVAL: u64 = 60 * 5; // 5 minutes
|
||||||
const DEFAULT_LOCK_EXPIRY: u64 = 60 * 60; // 1 hour
|
// inbuxa: the lock lifetime (one hour) lives in common::ipc::TaskLocks, per
|
||||||
|
// server, so a graceful stop can release the locks and the tests can shorten it
|
||||||
|
const CLAIM_RECHECK_INTERVAL: u64 = 60 * 5; // 5 minutes
|
||||||
|
|
||||||
pub(crate) struct TaskManagerIpc {
|
pub(crate) struct TaskManagerIpc {
|
||||||
txs: [mpsc::Sender<TaskJob>; TaskType::COUNT],
|
txs: [mpsc::Sender<TaskJob>; TaskType::COUNT],
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
* 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::task_manager::{TaskFailureType, TaskResult};
|
use crate::task_manager::{TaskFailureType, TaskResult};
|
||||||
use common::{
|
use common::{
|
||||||
Server,
|
Server,
|
||||||
ipc::{BroadcastEvent, RegistryChange},
|
ipc::{BroadcastEvent, RegistryChange},
|
||||||
manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource},
|
manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource, spam_rules},
|
||||||
};
|
};
|
||||||
use registry::{
|
use registry::{
|
||||||
schema::{
|
schema::{
|
||||||
@@ -106,6 +108,7 @@ struct RuleUpdateResult {
|
|||||||
|
|
||||||
async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
|
async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
|
let bundled = server.core.spam.spam_rules_url.is_none();
|
||||||
let rules = match fetch_spam_rules(server).await {
|
let rules = match fetch_spam_rules(server).await {
|
||||||
Ok(rules) => rules,
|
Ok(rules) => rules,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -289,29 +292,36 @@ async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
|
|||||||
Elapsed = started.elapsed(),
|
Elapsed = started.elapsed(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// inbuxa: so the next start knows these bundled rules are in
|
||||||
|
if bundled {
|
||||||
|
spam_rules::set_applied_version(server.store(), spam_rules::BUNDLED_SPAM_RULES_VERSION)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(TaskResult::Success(vec![]))
|
Ok(TaskResult::Success(vec![]))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_spam_rules(server: &Server) -> Result<Rules, RuleUpdateError> {
|
async fn fetch_spam_rules(server: &Server) -> Result<Rules, RuleUpdateError> {
|
||||||
let Some(rules_url) = server.core.spam.spam_rules_url.as_ref() else {
|
// inbuxa: no URL means the rules bundled with the server
|
||||||
return Err(RuleUpdateError {
|
let bytes = match server.core.spam.spam_rules_url.as_ref() {
|
||||||
typ: TaskFailureType::Permanent,
|
Some(rules_url) => fetch_resource(rules_url, None, Duration::from_secs(60), 1024 * 500)
|
||||||
reason: "Spam rules resource URL not configured".to_string(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
let rules_json: AHashMap<String, Vec<serde_json::Value>> =
|
|
||||||
fetch_resource(rules_url, None, Duration::from_secs(60), 1024 * 500)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|reason| RuleUpdateError {
|
.map_err(|reason| RuleUpdateError {
|
||||||
typ: TaskFailureType::Temporary,
|
typ: TaskFailureType::Temporary,
|
||||||
reason,
|
reason,
|
||||||
|
}),
|
||||||
|
None => spam_rules::bundled_rules().map_err(|reason| RuleUpdateError {
|
||||||
|
typ: TaskFailureType::Permanent,
|
||||||
|
reason,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let rules_json: AHashMap<String, Vec<serde_json::Value>> =
|
||||||
|
bytes.and_then(|bytes| {
|
||||||
|
serde_json::from_slice(&bytes).map_err(|err| RuleUpdateError {
|
||||||
|
typ: TaskFailureType::Permanent,
|
||||||
|
reason: format!("Failed to parse spam rules JSON: {err}"),
|
||||||
})
|
})
|
||||||
.and_then(|bytes| {
|
})?;
|
||||||
serde_json::from_slice(&bytes).map_err(|err| RuleUpdateError {
|
|
||||||
typ: TaskFailureType::Permanent,
|
|
||||||
reason: format!("Failed to parse spam rules JSON: {err}"),
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut rules = Rules::default();
|
let mut rules = Rules::default();
|
||||||
for (object_type, values) in rules_json {
|
for (object_type, values) in rules_json {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "smtp"
|
name = "smtp"
|
||||||
description = "Stalwart SMTP Server"
|
description = "inbuxa SMTP server"
|
||||||
authors = [ "Stalwart Labs LLC <[email protected]>"]
|
authors = [ "Stalwart Labs LLC <[email protected]>"]
|
||||||
repository = "https://github.com/stalwartlabs/smtp-server"
|
homepage = "https://inbuxa.org"
|
||||||
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"
|
||||||
|
|||||||
@@ -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::config::smtp::session::Milter;
|
use common::config::smtp::session::Milter;
|
||||||
@@ -25,7 +27,19 @@ impl MilterClient<TcpStream> {
|
|||||||
pub async fn connect(config: &Milter, session_id: u64) -> Result<Self> {
|
pub async fn connect(config: &Milter, session_id: u64) -> Result<Self> {
|
||||||
tokio::time::timeout(config.timeout_command, async {
|
tokio::time::timeout(config.timeout_command, async {
|
||||||
let mut last_err = Error::Disconnected;
|
let mut last_err = Error::Disconnected;
|
||||||
for addr in &config.addrs {
|
// inbuxa: a hostname is resolved here, per connection, rather
|
||||||
|
// than while the settings are built
|
||||||
|
let resolved;
|
||||||
|
let addrs = if config.addrs.is_empty() {
|
||||||
|
resolved = tokio::net::lookup_host((config.hostname.as_str(), config.port))
|
||||||
|
.await
|
||||||
|
.map_err(Error::Io)?
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
&resolved
|
||||||
|
} else {
|
||||||
|
&config.addrs
|
||||||
|
};
|
||||||
|
for addr in addrs {
|
||||||
match TcpStream::connect(addr).await {
|
match TcpStream::connect(addr).await {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
return Ok(MilterClient {
|
return Ok(MilterClient {
|
||||||
|
|||||||
+10
-1
@@ -44,7 +44,16 @@ impl StartQueueManager for BootManager {
|
|||||||
impl SpawnQueueManager for IpcReceivers {
|
impl SpawnQueueManager for IpcReceivers {
|
||||||
fn spawn_queue_manager(&mut self, inner: Arc<Inner>) {
|
fn spawn_queue_manager(&mut self, inner: Arc<Inner>) {
|
||||||
let core = inner.shared_core.load();
|
let core = inner.shared_core.load();
|
||||||
if !core.storage.registry.is_recovery_mode() && core.network.roles.outbound_mta {
|
// inbuxa: upstream started these only when the node's role included
|
||||||
|
// outboundMta at boot, so turning the role on later did nothing and
|
||||||
|
// turning it off left them delivering until a restart. They now run
|
||||||
|
// on every node: the queue follows the role live (see Queue::start),
|
||||||
|
// and the report scheduler records DMARC and TLS results on every
|
||||||
|
// node, whatever its role (see reporting/scheduler.rs). This also
|
||||||
|
// drains the queue channel on nodes
|
||||||
|
// without the role, where every queued message's refresh used to sit
|
||||||
|
// in a channel nobody read until it filled and queueing blocked.
|
||||||
|
if !core.storage.registry.is_recovery_mode() {
|
||||||
// Spawn queue manager
|
// Spawn queue manager
|
||||||
self.queue_rx.take().unwrap().spawn(inner.clone());
|
self.queue_rx.take().unwrap().spawn(inner.clone());
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{Message, QueueId, Status, spool::SmtpSpool};
|
use super::{Message, QueueId, Status, spool::SmtpSpool};
|
||||||
@@ -39,6 +41,9 @@ pub struct Queue {
|
|||||||
pub urgent_refresh: bool,
|
pub urgent_refresh: bool,
|
||||||
pub last_scan: Instant,
|
pub last_scan: Instant,
|
||||||
pub last_full_scan: Instant,
|
pub last_full_scan: Instant,
|
||||||
|
/// inbuxa: whether this node's role included outboundMta when last
|
||||||
|
/// checked (None before the first check)
|
||||||
|
pub role_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -67,6 +72,9 @@ impl SpawnQueue for mpsc::Receiver<QueueEvent> {
|
|||||||
const BACK_PRESSURE_WARN_INTERVAL: Duration = Duration::from_secs(60);
|
const BACK_PRESSURE_WARN_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
const MIN_SCAN_INTERVAL: Duration = Duration::from_millis(100);
|
const MIN_SCAN_INTERVAL: Duration = Duration::from_millis(100);
|
||||||
const FULL_SCAN_INTERVAL: Duration = Duration::from_secs(QUEUE_REFRESH / 2);
|
const FULL_SCAN_INTERVAL: Duration = Duration::from_secs(QUEUE_REFRESH / 2);
|
||||||
|
/// inbuxa: how often a node without the outbound MTA role looks at its role
|
||||||
|
/// again when nothing else wakes it (a settings reload does)
|
||||||
|
const ROLE_RECHECK_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
impl Queue {
|
impl Queue {
|
||||||
pub fn new(core: Arc<Inner>, rx: mpsc::Receiver<QueueEvent>) -> Self {
|
pub fn new(core: Arc<Inner>, rx: mpsc::Receiver<QueueEvent>) -> Self {
|
||||||
@@ -87,6 +95,7 @@ impl Queue {
|
|||||||
urgent_refresh: false,
|
urgent_refresh: false,
|
||||||
last_scan: now.checked_sub(MIN_SCAN_INTERVAL).unwrap_or(now),
|
last_scan: now.checked_sub(MIN_SCAN_INTERVAL).unwrap_or(now),
|
||||||
last_full_scan: now,
|
last_full_scan: now,
|
||||||
|
role_enabled: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +132,27 @@ impl Queue {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: follow the node's role live. Without outboundMta the
|
||||||
|
// queue claims nothing new; deliveries already running finish
|
||||||
|
// and report back as usual, releasing their locks. When the role
|
||||||
|
// comes back, the whole queue is scanned at once.
|
||||||
|
let role_enabled = self.core.shared_core.load().network.roles.outbound_mta;
|
||||||
|
if self.role_enabled.replace(role_enabled) == Some(false) && role_enabled {
|
||||||
|
trc::event!(
|
||||||
|
Queue(trc::QueueEvent::Started),
|
||||||
|
Details = "This node's cluster role now includes outboundMta",
|
||||||
|
);
|
||||||
|
self.scan_from = 0;
|
||||||
|
self.pending_refresh = true;
|
||||||
|
self.urgent_refresh = true;
|
||||||
|
}
|
||||||
|
if !role_enabled {
|
||||||
|
self.pending_refresh = false;
|
||||||
|
self.urgent_refresh = false;
|
||||||
|
self.next_refresh = Instant::now() + ROLE_RECHECK_INTERVAL;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
self.pending_refresh |= refresh_queue;
|
self.pending_refresh |= refresh_queue;
|
||||||
if !self.pending_refresh && self.next_refresh > Instant::now() {
|
if !self.pending_refresh && self.next_refresh > Instant::now() {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::AggregateTimestamp;
|
use super::AggregateTimestamp;
|
||||||
|
use super::shared::{MAX_WRITE_RETRIES, Revisioned, write_retry_pause};
|
||||||
use crate::{
|
use crate::{
|
||||||
core::Session,
|
core::Session,
|
||||||
queue::RecipientDomain,
|
queue::RecipientDomain,
|
||||||
@@ -349,29 +352,43 @@ impl DmarcReporting for Server {
|
|||||||
let object_id = ObjectType::DmarcInternalReport.to_id();
|
let object_id = ObjectType::DmarcInternalReport.to_id();
|
||||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||||
|
|
||||||
let Some(report) = self
|
// Delete report. inbuxa: only the version read here, so a record
|
||||||
.store()
|
// another node appends meanwhile is sent with it rather than lost
|
||||||
.get_value::<DmarcInternalReport>(ValueKey::from(key.clone()))
|
let mut attempt = 0;
|
||||||
.await
|
let report = loop {
|
||||||
.caused_by(trc::location!())?
|
let Some(Revisioned {
|
||||||
else {
|
revision,
|
||||||
return Ok(());
|
value: report,
|
||||||
};
|
}) = self
|
||||||
|
.store()
|
||||||
|
.get_value::<Revisioned<DmarcInternalReport>>(ValueKey::from(key.clone()))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
// Delete report
|
let mut batch = BatchBuilder::new();
|
||||||
let mut batch = BatchBuilder::new();
|
batch
|
||||||
batch.clear(key).clear(RegistryClass::PrimaryKey {
|
.assert_value(key.clone(), AssertValue::Hash(revision))
|
||||||
object_id: object_id.into(),
|
.clear(key.clone())
|
||||||
index_id: Property::Domain.to_id(),
|
.clear(RegistryClass::PrimaryKey {
|
||||||
key: KeySerializer::new(report.domain.len() + U64_LEN)
|
object_id: object_id.into(),
|
||||||
.write(&report.domain)
|
index_id: Property::Domain.to_id(),
|
||||||
.write(report.policy_identifier)
|
key: KeySerializer::new(report.domain.len() + U64_LEN)
|
||||||
.finalize(),
|
.write(&report.domain)
|
||||||
});
|
.write(report.policy_identifier)
|
||||||
self.store()
|
.finalize(),
|
||||||
.write(batch.build_all())
|
});
|
||||||
.await
|
match self.store().write(batch.build_all()).await {
|
||||||
.caused_by(trc::location!())?;
|
Ok(_) => break report,
|
||||||
|
Err(err) if err.is_assertion_failure() && attempt < MAX_WRITE_RETRIES => {
|
||||||
|
attempt += 1;
|
||||||
|
write_retry_pause(attempt).await;
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let span_id = self.inner.data.span_id_gen.generate();
|
let span_id = self.inner.data.span_id_gen.generate();
|
||||||
let event_from = report.report.date_range_begin.timestamp() as u64;
|
let event_from = report.report.date_range_begin.timestamp() as u64;
|
||||||
@@ -676,8 +693,11 @@ impl DmarcReporting for Server {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if err.is_assertion_failure() && rety_count < 3 {
|
// inbuxa: another node appended first; try again
|
||||||
|
// after a short pause
|
||||||
|
if err.is_assertion_failure() && rety_count < MAX_WRITE_RETRIES {
|
||||||
rety_count += 1;
|
rety_count += 1;
|
||||||
|
write_retry_pause(rety_count).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
trc::error!(
|
trc::error!(
|
||||||
|
|||||||
@@ -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 registry::{
|
use registry::{
|
||||||
@@ -40,35 +42,49 @@ pub trait InternalReportIndex: ObjectImpl {
|
|||||||
|
|
||||||
fn primary_key(&self) -> ValueClass;
|
fn primary_key(&self) -> ValueClass;
|
||||||
|
|
||||||
|
/// Moves the report's delivery, and its queued task, to `at`.
|
||||||
|
///
|
||||||
|
/// inbuxa: the new queue row carries the task's type, as
|
||||||
|
/// `schedule_task_with_id` writes it, and the task row gets the new due
|
||||||
|
/// too. `queued` is the task as stored: its due, not the report's
|
||||||
|
/// `deliverAt`, is the queue row that exists (they differ once the task
|
||||||
|
/// has been retried).
|
||||||
fn reschedule_ops(
|
fn reschedule_ops(
|
||||||
&mut self,
|
&mut self,
|
||||||
batch: &mut BatchBuilder,
|
batch: &mut BatchBuilder,
|
||||||
item_id: u64,
|
item_id: u64,
|
||||||
revision: u64,
|
revision: u64,
|
||||||
at: UTCDateTime,
|
at: UTCDateTime,
|
||||||
|
queued: Option<&Task>,
|
||||||
) {
|
) {
|
||||||
let current_deliver_at = self.deliver_at();
|
let current_deliver_at = self.deliver_at();
|
||||||
|
let current_due = current_deliver_at.timestamp() as u64;
|
||||||
|
let queued_due = queued.map_or(current_due, |task| task.due_timestamp());
|
||||||
|
let new_due = at.timestamp() as u64;
|
||||||
|
|
||||||
if current_deliver_at != at {
|
if current_deliver_at != at || queued_due != new_due {
|
||||||
let object = Self::OBJECT;
|
let object = Self::OBJECT;
|
||||||
let object_id = object.to_id();
|
let object_id = object.to_id();
|
||||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||||
|
|
||||||
self.set_deliver_at(at);
|
self.set_deliver_at(at);
|
||||||
|
|
||||||
batch
|
batch.assert_value(key.clone(), AssertValue::Hash(revision));
|
||||||
.assert_value(key.clone(), AssertValue::Hash(revision))
|
if queued_due != new_due {
|
||||||
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
|
batch.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||||
id: item_id,
|
id: item_id,
|
||||||
due: current_deliver_at.timestamp() as u64,
|
due: queued_due,
|
||||||
}))
|
}));
|
||||||
.set(
|
}
|
||||||
ValueClass::TaskQueue(TaskQueueClass::Due {
|
// A row an earlier reschedule left at the report's deliverAt
|
||||||
id: item_id,
|
if current_due != new_due && current_due != queued_due {
|
||||||
due: at.timestamp() as u64,
|
batch.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||||
}),
|
id: item_id,
|
||||||
object_id.serialize(),
|
due: current_due,
|
||||||
)
|
}));
|
||||||
|
}
|
||||||
|
batch
|
||||||
|
.schedule_task_with_id(item_id, self.task(item_id))
|
||||||
.set(key, self.to_pickled_vec());
|
.set(key, self.to_pickled_vec());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::config::smtp::report::AggregateFrequency;
|
use common::config::smtp::report::AggregateFrequency;
|
||||||
@@ -15,6 +17,7 @@ pub mod inbound;
|
|||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod scheduler;
|
pub mod scheduler;
|
||||||
pub mod send;
|
pub mod send;
|
||||||
|
pub mod shared; // inbuxa: reports written by every node
|
||||||
pub mod spf;
|
pub mod spf;
|
||||||
pub mod tls;
|
pub mod tls;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{dmarc::DmarcReporting, tls::TlsReporting};
|
use super::{dmarc::DmarcReporting, tls::TlsReporting};
|
||||||
@@ -18,6 +20,17 @@ impl SpawnReport for mpsc::Receiver<ReportingEvent> {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(event) = self.recv().await {
|
while let Some(event) = self.recv().await {
|
||||||
let server = inner.build_server();
|
let server = inner.build_server();
|
||||||
|
// inbuxa: every node records what it received, whatever its
|
||||||
|
// role. An aggregate report covers all of a domain's mail,
|
||||||
|
// whichever node took it, and recording is a store write
|
||||||
|
// that nodes already share: the report's primary key is
|
||||||
|
// versioned, so concurrent appends from several nodes retry
|
||||||
|
// rather than overwrite. Only building and sending the
|
||||||
|
// report (the DmarcReport and TlsReport tasks) belongs to
|
||||||
|
// the outbound MTA; the task manager keeps those to nodes
|
||||||
|
// with that role. Upstream ran this only on outbound MTA
|
||||||
|
// nodes, so mail received anywhere else never reached a
|
||||||
|
// report.
|
||||||
match event {
|
match event {
|
||||||
ReportingEvent::Dmarc(event) => server.schedule_dmarc(event).await,
|
ReportingEvent::Dmarc(event) => server.schedule_dmarc(event).await,
|
||||||
ReportingEvent::Tls(event) => server.schedule_tls(event).await,
|
ReportingEvent::Tls(event) => server.schedule_tls(event).await,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! inbuxa: internal DMARC and TLS reports are shared by every node. Any node
|
||||||
|
//! that receives mail appends to them, so several nodes can write one report
|
||||||
|
//! at once, and the node that sends it may do so while another is appending.
|
||||||
|
//! Appends already guard the report's versioned primary key and retry when
|
||||||
|
//! another writer got there first; these helpers give those retries room and
|
||||||
|
//! let the sender delete exactly the report it read.
|
||||||
|
|
||||||
|
use rand::RngExt;
|
||||||
|
use std::time::Duration;
|
||||||
|
use store::{Deserialize, xxhash_rust::xxh3::xxh3_64};
|
||||||
|
|
||||||
|
/// How many times a report write that lost to another writer is retried.
|
||||||
|
/// Upstream retried three times, when only outbound MTA nodes wrote.
|
||||||
|
pub(crate) const MAX_WRITE_RETRIES: u32 = 10;
|
||||||
|
|
||||||
|
/// A short random pause, longer on each attempt, before retrying a report
|
||||||
|
/// write that lost to another node, so the writers spread out instead of
|
||||||
|
/// colliding again.
|
||||||
|
pub(crate) async fn write_retry_pause(attempt: u32) {
|
||||||
|
let ms = rand::rng().random_range(5..=25u64) * u64::from(attempt.max(1));
|
||||||
|
tokio::time::sleep(Duration::from_millis(ms)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stored value with the hash of the bytes it was read from, for
|
||||||
|
/// `AssertValue::Hash`: a write asserting it fails if anyone changed the
|
||||||
|
/// value since.
|
||||||
|
pub(crate) struct Revisioned<T> {
|
||||||
|
pub revision: u64,
|
||||||
|
pub value: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Deserialize> Deserialize for Revisioned<T> {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
Ok(Revisioned {
|
||||||
|
revision: xxh3_64(bytes),
|
||||||
|
value: T::deserialize(bytes)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::AggregateTimestamp;
|
use super::AggregateTimestamp;
|
||||||
|
use super::shared::{MAX_WRITE_RETRIES, Revisioned, write_retry_pause};
|
||||||
use crate::{
|
use crate::{
|
||||||
queue::RecipientDomain,
|
queue::RecipientDomain,
|
||||||
reporting::{index::InternalReportIndex, send::MtaReportSend},
|
reporting::{index::InternalReportIndex, send::MtaReportSend},
|
||||||
@@ -70,28 +73,40 @@ impl TlsReporting for Server {
|
|||||||
let object_id = ObjectType::TlsInternalReport.to_id();
|
let object_id = ObjectType::TlsInternalReport.to_id();
|
||||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||||
|
|
||||||
let Some(report) = self
|
// Delete report. inbuxa: only the version read here, so a result
|
||||||
.store()
|
// another node appends meanwhile is sent with it rather than lost
|
||||||
.get_value::<TlsInternalReport>(ValueKey::from(key.clone()))
|
let mut attempt = 0;
|
||||||
.await
|
let report = loop {
|
||||||
.caused_by(trc::location!())?
|
let Some(Revisioned {
|
||||||
else {
|
revision,
|
||||||
return Ok(());
|
value: report,
|
||||||
};
|
}) = self
|
||||||
|
.store()
|
||||||
|
.get_value::<Revisioned<TlsInternalReport>>(ValueKey::from(key.clone()))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
// Delete report
|
let mut batch = BatchBuilder::new();
|
||||||
let mut batch = BatchBuilder::new();
|
batch
|
||||||
batch.clear(key).clear(RegistryClass::PrimaryKey {
|
.assert_value(key.clone(), AssertValue::Hash(revision))
|
||||||
object_id: object_id.into(),
|
.clear(key.clone())
|
||||||
index_id: Property::Domain.to_id(),
|
.clear(RegistryClass::PrimaryKey {
|
||||||
key: report.domain.as_bytes().to_vec(),
|
object_id: object_id.into(),
|
||||||
});
|
index_id: Property::Domain.to_id(),
|
||||||
self.core
|
key: report.domain.as_bytes().to_vec(),
|
||||||
.storage
|
});
|
||||||
.data
|
match self.core.storage.data.write(batch.build_all()).await {
|
||||||
.write(batch.build_all())
|
Ok(_) => break report,
|
||||||
.await
|
Err(err) if err.is_assertion_failure() && attempt < MAX_WRITE_RETRIES => {
|
||||||
.caused_by(trc::location!())?;
|
attempt += 1;
|
||||||
|
write_retry_pause(attempt).await;
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let domain_name = report.domain.as_str();
|
let domain_name = report.domain.as_str();
|
||||||
let event_from = report.report.date_range_start.timestamp() as u64;
|
let event_from = report.report.date_range_start.timestamp() as u64;
|
||||||
@@ -477,8 +492,11 @@ impl TlsReporting for Server {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if err.is_assertion_failure() && rety_count < 3 {
|
// inbuxa: another node appended first; try again
|
||||||
|
// after a short pause
|
||||||
|
if err.is_assertion_failure() && rety_count < MAX_WRITE_RETRIES {
|
||||||
rety_count += 1;
|
rety_count += 1;
|
||||||
|
write_retry_pause(rety_count).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
trc::error!(
|
trc::error!(
|
||||||
|
|||||||
@@ -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::config::mailstore::spamfilter::PyzorConfig;
|
use common::config::mailstore::spamfilter::PyzorConfig;
|
||||||
@@ -43,45 +45,55 @@ pub(crate) async fn pyzor_check(
|
|||||||
// Hash message
|
// Hash message
|
||||||
let request = message.pyzor_check_message();
|
let request = message.pyzor_check_message();
|
||||||
|
|
||||||
|
// Send message to address. inbuxa: in tests, a fixed table answers
|
||||||
|
// instead of a public server (test_response).
|
||||||
|
#[cfg(not(feature = "test_mode"))]
|
||||||
|
let response = match tokio::time::timeout(config.timeout, config.address()).await {
|
||||||
|
Ok(Ok(address)) => pyzor_send_message(address, config.timeout, &request).await,
|
||||||
|
Ok(Err(err)) => Err(err),
|
||||||
|
Err(_) => Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::TimedOut,
|
||||||
|
"Timed out resolving the Pyzor server",
|
||||||
|
)),
|
||||||
|
};
|
||||||
#[cfg(feature = "test_mode")]
|
#[cfg(feature = "test_mode")]
|
||||||
{
|
let response = std::io::Result::Ok(test_response(&request));
|
||||||
if request.contains("b5b476f0b5ba6e1c038361d3ded5818dd39c90a2") {
|
|
||||||
return Ok(PyzorResponse {
|
|
||||||
code: 200,
|
|
||||||
count: 1000,
|
|
||||||
wl_count: 0,
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
} else if request.contains("d67d4b8bfc3860449e3418bb6017e2612f3e2a99") {
|
|
||||||
return Ok(PyzorResponse {
|
|
||||||
code: 200,
|
|
||||||
count: 60,
|
|
||||||
wl_count: 10,
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
} else if request.contains("81763547012b75e57a20d18ce0b93014208cdfdb") {
|
|
||||||
return Ok(PyzorResponse {
|
|
||||||
code: 200,
|
|
||||||
count: 50,
|
|
||||||
wl_count: 20,
|
|
||||||
}
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send message to address
|
response.map(Into::into).map_err(|err| {
|
||||||
pyzor_send_message(config.address, config.timeout, &request)
|
trc::SpamEvent::PyzorError
|
||||||
.await
|
.into_err()
|
||||||
.map(Into::into)
|
.ctx(trc::Key::Url, format!("{}:{}", config.host, config.port))
|
||||||
.map_err(|err| {
|
.reason(err)
|
||||||
trc::SpamEvent::PyzorError
|
.details("Pyzor failed")
|
||||||
.into_err()
|
})
|
||||||
.ctx(trc::Key::Url, config.address.to_string())
|
|
||||||
.reason(err)
|
|
||||||
.details("Pyzor failed")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the answers tests get, by digest, instead of a public server's,
|
||||||
|
/// whose counts change and which a test may not be able to reach. Upstream
|
||||||
|
/// answered the first three here and sent every other digest to the network.
|
||||||
|
#[cfg(feature = "test_mode")]
|
||||||
|
fn test_response(request: &str) -> PyzorResponse {
|
||||||
|
let (count, wl_count) = if request.contains("b5b476f0b5ba6e1c038361d3ded5818dd39c90a2")
|
||||||
|
// The digest of an empty body, as an HTML-only message with no text
|
||||||
|
// to hash produces; public servers report it widely.
|
||||||
|
|| request.contains("da39a3ee5e6b4b0d3255bfef95601890afd80709")
|
||||||
|
{
|
||||||
|
(1000, 0)
|
||||||
|
} else if request.contains("d67d4b8bfc3860449e3418bb6017e2612f3e2a99") {
|
||||||
|
(60, 10)
|
||||||
|
} else if request.contains("81763547012b75e57a20d18ce0b93014208cdfdb") {
|
||||||
|
(50, 20)
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
};
|
||||||
|
PyzorResponse {
|
||||||
|
code: 200,
|
||||||
|
count,
|
||||||
|
wl_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "test_mode", allow(dead_code))]
|
||||||
async fn pyzor_send_message(
|
async fn pyzor_send_message(
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ pub mod s3;
|
|||||||
pub mod sqlite;
|
pub mod sqlite;
|
||||||
// inbuxa: scale-out storage (sharded stores)
|
// inbuxa: scale-out storage (sharded stores)
|
||||||
pub mod scaleout;
|
pub mod scaleout;
|
||||||
|
// inbuxa: client-side SQL query limits
|
||||||
|
#[cfg(any(feature = "postgres", feature = "mysql"))]
|
||||||
|
pub mod query_timeout;
|
||||||
|
|
||||||
|
|
||||||
pub const MAX_TOKEN_LENGTH: usize = (u8::MAX >> 1) as usize;
|
pub const MAX_TOKEN_LENGTH: usize = (u8::MAX >> 1) as usize;
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
* 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::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use mysql_async::prelude::Queryable;
|
use mysql_async::prelude::Queryable;
|
||||||
|
|
||||||
use super::{MysqlStore, into_error};
|
use super::{MysqlStore, bounded, into_error};
|
||||||
|
|
||||||
impl MysqlStore {
|
impl MysqlStore {
|
||||||
pub(crate) async fn get_blob(
|
pub(crate) async fn get_blob(
|
||||||
@@ -16,49 +18,64 @@ impl MysqlStore {
|
|||||||
key: &[u8],
|
key: &[u8],
|
||||||
range: Range<usize>,
|
range: Range<usize>,
|
||||||
) -> trc::Result<Option<Vec<u8>>> {
|
) -> trc::Result<Option<Vec<u8>>> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prep("SELECT v FROM t WHERE k = ?")
|
let result = tokio::time::timeout(limit, async {
|
||||||
.await
|
let s = conn
|
||||||
.map_err(into_error)?;
|
.prep("SELECT v FROM t WHERE k = ?")
|
||||||
conn.exec_first::<Vec<u8>, _, _>(&s, (key,))
|
.await
|
||||||
.await
|
.map_err(into_error)?;
|
||||||
.map(|bytes| {
|
conn.exec_first::<Vec<u8>, _, _>(&s, (key,))
|
||||||
if range.start == 0 && range.end == usize::MAX {
|
.await
|
||||||
bytes
|
.map(|bytes| {
|
||||||
} else {
|
if range.start == 0 && range.end == usize::MAX {
|
||||||
bytes.map(|bytes| {
|
|
||||||
bytes
|
bytes
|
||||||
.get(range.start..std::cmp::min(bytes.len(), range.end))
|
} else {
|
||||||
.unwrap_or_default()
|
bytes.map(|bytes| {
|
||||||
.to_vec()
|
bytes
|
||||||
})
|
.get(range.start..std::cmp::min(bytes.len(), range.end))
|
||||||
}
|
.unwrap_or_default()
|
||||||
})
|
.to_vec()
|
||||||
.map_err(into_error)
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(into_error)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> {
|
pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prep("INSERT INTO t (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)")
|
let result = tokio::time::timeout(limit, async {
|
||||||
.await
|
let s = conn
|
||||||
.map_err(into_error)?;
|
.prep("INSERT INTO t (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)")
|
||||||
conn.exec_drop(&s, (key, data))
|
.await
|
||||||
.await
|
.map_err(into_error)?;
|
||||||
.map_err(into_error)
|
conn.exec_drop(&s, (key, data))
|
||||||
.map(|_| ())
|
.await
|
||||||
|
.map_err(into_error)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result<bool> {
|
pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result<bool> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prep("DELETE FROM t WHERE k = ?")
|
let result = tokio::time::timeout(limit, async {
|
||||||
.await
|
let s = conn
|
||||||
.map_err(into_error)?;
|
.prep("DELETE FROM t WHERE k = ?")
|
||||||
conn.exec_iter(&s, (key,))
|
.await
|
||||||
.await
|
.map_err(into_error)?;
|
||||||
.map_err(into_error)
|
conn.exec_iter(&s, (key,))
|
||||||
.map(|hits| hits.affected_rows() > 0)
|
.await
|
||||||
|
.map_err(into_error)
|
||||||
|
.map(|hits| hits.affected_rows() > 0)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
* 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 mysql_async::{Params, Row, prelude::Queryable};
|
use mysql_async::{Params, Row, prelude::Queryable};
|
||||||
|
|
||||||
use crate::{IntoRows, QueryResult, QueryType, Value};
|
use crate::{IntoRows, QueryResult, QueryType, Value};
|
||||||
|
|
||||||
use super::{MysqlStore, into_error};
|
use super::{MysqlStore, bounded, into_error};
|
||||||
|
|
||||||
impl MysqlStore {
|
impl MysqlStore {
|
||||||
pub(crate) async fn sql_query<T: QueryResult>(
|
pub(crate) async fn sql_query<T: QueryResult>(
|
||||||
@@ -16,28 +18,33 @@ impl MysqlStore {
|
|||||||
query: &str,
|
query: &str,
|
||||||
params: &[Value<'_>],
|
params: &[Value<'_>],
|
||||||
) -> trc::Result<T> {
|
) -> trc::Result<T> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn.prep(query).await.map_err(into_error)?;
|
let limit = self.timeouts.query;
|
||||||
let params = Params::Positional(params.iter().map(Into::into).collect());
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
let s = conn.prep(query).await.map_err(into_error)?;
|
||||||
|
let params = Params::Positional(params.iter().map(Into::into).collect());
|
||||||
|
|
||||||
match T::query_type() {
|
match T::query_type() {
|
||||||
QueryType::Execute => conn.exec_drop(s, params).await.map_or_else(
|
QueryType::Execute => conn.exec_drop(s, params).await.map_or_else(
|
||||||
|e| Err(into_error(e)),
|
|e| Err(into_error(e)),
|
||||||
|_| Ok(T::from_exec(conn.affected_rows() as usize)),
|
|_| Ok(T::from_exec(conn.affected_rows() as usize)),
|
||||||
),
|
),
|
||||||
QueryType::Exists => conn
|
QueryType::Exists => conn
|
||||||
.exec_first::<Row, _, _>(s, params)
|
.exec_first::<Row, _, _>(s, params)
|
||||||
.await
|
.await
|
||||||
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exists(r.is_some()))),
|
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exists(r.is_some()))),
|
||||||
QueryType::QueryOne => conn
|
QueryType::QueryOne => conn
|
||||||
.exec_first::<Row, _, _>(s, params)
|
.exec_first::<Row, _, _>(s, params)
|
||||||
.await
|
.await
|
||||||
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_one(r))),
|
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_one(r))),
|
||||||
QueryType::QueryAll => conn
|
QueryType::QueryAll => conn
|
||||||
.exec::<Row, _, _>(s, params)
|
.exec::<Row, _, _>(s, params)
|
||||||
.await
|
.await
|
||||||
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_all(r))),
|
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_all(r))),
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{MysqlStore, into_error};
|
use super::{MysqlStore, bounded, into_error};
|
||||||
use crate::{
|
use crate::{
|
||||||
backend::mysql::MysqlSearchField,
|
backend::mysql::MysqlSearchField,
|
||||||
search::{
|
search::{
|
||||||
@@ -32,6 +32,9 @@ impl MysqlStore {
|
|||||||
.max_allowed_packet(config.max_allowed_packet.map(|v| v as usize))
|
.max_allowed_packet(config.max_allowed_packet.map(|v| v as usize))
|
||||||
.wait_timeout(config.timeout.map(|t| t.as_secs() as usize))
|
.wait_timeout(config.timeout.map(|t| t.as_secs() as usize))
|
||||||
.client_found_rows(true)
|
.client_found_rows(true)
|
||||||
|
// inbuxa: notice a server that went away without closing the
|
||||||
|
// connection in minutes, not the system default of two hours
|
||||||
|
.tcp_keepalive(Some(super::POOL_KEEPALIVE_IDLE))
|
||||||
.tcp_port(config.port as u16);
|
.tcp_port(config.port as u16);
|
||||||
|
|
||||||
if config.use_tls {
|
if config.use_tls {
|
||||||
@@ -69,6 +72,7 @@ impl MysqlStore {
|
|||||||
.db_name(Some(replica.database.clone()))
|
.db_name(Some(replica.database.clone()))
|
||||||
.tcp_port(replica.port as u16),
|
.tcp_port(replica.port as u16),
|
||||||
),
|
),
|
||||||
|
timeouts: Default::default(),
|
||||||
})),
|
})),
|
||||||
replica.host,
|
replica.host,
|
||||||
replica.port as u16,
|
replica.port as u16,
|
||||||
@@ -78,6 +82,7 @@ impl MysqlStore {
|
|||||||
|
|
||||||
let primary = Store::MySQL(Arc::new(MysqlStore {
|
let primary = Store::MySQL(Arc::new(MysqlStore {
|
||||||
conn_pool: Pool::new(opts),
|
conn_pool: Pool::new(opts),
|
||||||
|
timeouts: Default::default(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ST-1: no replicas, no change
|
// ST-1: no replicas, no change
|
||||||
@@ -95,89 +100,97 @@ impl MysqlStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> {
|
pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
|
let limit = self.timeouts.maintenance;
|
||||||
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
for table in [
|
||||||
|
SUBSPACE_ACL,
|
||||||
|
SUBSPACE_TASK_QUEUE,
|
||||||
|
SUBSPACE_DELETED_ITEMS,
|
||||||
|
SUBSPACE_SPAM_SAMPLES,
|
||||||
|
crate::SUBSPACE_INBUXA, // inbuxa: masked email
|
||||||
|
SUBSPACE_BLOB_LINK,
|
||||||
|
SUBSPACE_IN_MEMORY_VALUE,
|
||||||
|
SUBSPACE_PROPERTY,
|
||||||
|
SUBSPACE_REGISTRY,
|
||||||
|
SUBSPACE_REGISTRY_PK,
|
||||||
|
SUBSPACE_DIRECTORY,
|
||||||
|
SUBSPACE_QUEUE_MESSAGE,
|
||||||
|
SUBSPACE_QUEUE_EVENT,
|
||||||
|
SUBSPACE_REPORT_OUT,
|
||||||
|
SUBSPACE_REPORT_IN,
|
||||||
|
SUBSPACE_LOGS,
|
||||||
|
SUBSPACE_TELEMETRY_SPAN,
|
||||||
|
SUBSPACE_TELEMETRY_METRIC,
|
||||||
|
] {
|
||||||
|
let table = char::from(table);
|
||||||
|
conn.query_drop(format!(
|
||||||
|
"CREATE TABLE IF NOT EXISTS {table} (
|
||||||
|
k VARBINARY(255) NOT NULL,
|
||||||
|
v MEDIUMBLOB NOT NULL,
|
||||||
|
PRIMARY KEY (k)
|
||||||
|
) ENGINE=InnoDB"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(into_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
for table in [
|
|
||||||
SUBSPACE_ACL,
|
|
||||||
SUBSPACE_TASK_QUEUE,
|
|
||||||
SUBSPACE_DELETED_ITEMS,
|
|
||||||
SUBSPACE_SPAM_SAMPLES,
|
|
||||||
crate::SUBSPACE_INBUXA, // inbuxa: masked email
|
|
||||||
SUBSPACE_BLOB_LINK,
|
|
||||||
SUBSPACE_IN_MEMORY_VALUE,
|
|
||||||
SUBSPACE_PROPERTY,
|
|
||||||
SUBSPACE_REGISTRY,
|
|
||||||
SUBSPACE_REGISTRY_PK,
|
|
||||||
SUBSPACE_DIRECTORY,
|
|
||||||
SUBSPACE_QUEUE_MESSAGE,
|
|
||||||
SUBSPACE_QUEUE_EVENT,
|
|
||||||
SUBSPACE_REPORT_OUT,
|
|
||||||
SUBSPACE_REPORT_IN,
|
|
||||||
SUBSPACE_LOGS,
|
|
||||||
SUBSPACE_TELEMETRY_SPAN,
|
|
||||||
SUBSPACE_TELEMETRY_METRIC,
|
|
||||||
] {
|
|
||||||
let table = char::from(table);
|
|
||||||
conn.query_drop(format!(
|
|
||||||
"CREATE TABLE IF NOT EXISTS {table} (
|
|
||||||
k VARBINARY(255) NOT NULL,
|
|
||||||
v MEDIUMBLOB NOT NULL,
|
|
||||||
PRIMARY KEY (k)
|
|
||||||
) ENGINE=InnoDB"
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.map_err(into_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.query_drop(format!(
|
|
||||||
"CREATE TABLE IF NOT EXISTS {} (
|
|
||||||
k VARBINARY(255) NOT NULL,
|
|
||||||
v LONGBLOB NOT NULL,
|
|
||||||
PRIMARY KEY (k)
|
|
||||||
) ENGINE=InnoDB",
|
|
||||||
char::from(SUBSPACE_BLOBS),
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.map_err(into_error)?;
|
|
||||||
|
|
||||||
for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] {
|
|
||||||
let table = char::from(table);
|
|
||||||
conn.query_drop(format!(
|
|
||||||
"CREATE TABLE IF NOT EXISTS {table} (
|
|
||||||
k BLOB,
|
|
||||||
PRIMARY KEY (k(400))
|
|
||||||
) ENGINE=InnoDB"
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.map_err(into_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] {
|
|
||||||
conn.query_drop(format!(
|
conn.query_drop(format!(
|
||||||
"CREATE TABLE IF NOT EXISTS {} (
|
"CREATE TABLE IF NOT EXISTS {} (
|
||||||
k VARBINARY(255) NOT NULL,
|
k VARBINARY(255) NOT NULL,
|
||||||
v BIGINT NOT NULL DEFAULT 0,
|
v LONGBLOB NOT NULL,
|
||||||
PRIMARY KEY (k)
|
PRIMARY KEY (k)
|
||||||
) ENGINE=InnoDB",
|
) ENGINE=InnoDB",
|
||||||
char::from(table)
|
char::from(SUBSPACE_BLOBS),
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.map_err(into_error)?;
|
.map_err(into_error)?;
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] {
|
||||||
|
let table = char::from(table);
|
||||||
|
conn.query_drop(format!(
|
||||||
|
"CREATE TABLE IF NOT EXISTS {table} (
|
||||||
|
k BLOB,
|
||||||
|
PRIMARY KEY (k(400))
|
||||||
|
) ENGINE=InnoDB"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(into_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] {
|
||||||
|
conn.query_drop(format!(
|
||||||
|
"CREATE TABLE IF NOT EXISTS {} (
|
||||||
|
k VARBINARY(255) NOT NULL,
|
||||||
|
v BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (k)
|
||||||
|
) ENGINE=InnoDB",
|
||||||
|
char::from(table)
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(into_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn create_search_tables(&self) -> trc::Result<()> {
|
pub(crate) async fn create_search_tables(&self) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
|
let limit = self.timeouts.maintenance;
|
||||||
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
create_search_tables::<EmailSearchField>(&mut conn).await?;
|
||||||
|
create_search_tables::<CalendarSearchField>(&mut conn).await?;
|
||||||
|
create_search_tables::<ContactSearchField>(&mut conn).await?;
|
||||||
|
//create_search_tables::<FileSearchField>(&mut conn).await?;
|
||||||
|
create_search_tables::<TracingSearchField>(&mut conn).await?;
|
||||||
|
|
||||||
create_search_tables::<EmailSearchField>(&mut conn).await?;
|
Ok(())
|
||||||
create_search_tables::<CalendarSearchField>(&mut conn).await?;
|
})
|
||||||
create_search_tables::<ContactSearchField>(&mut conn).await?;
|
.await;
|
||||||
//create_search_tables::<FileSearchField>(&mut conn).await?;
|
bounded(conn, result, limit)
|
||||||
create_search_tables::<TracingSearchField>(&mut conn).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use crate::backend::query_timeout::QueryTimeouts;
|
||||||
use crate::{
|
use crate::{
|
||||||
search::{
|
search::{
|
||||||
CalendarSearchField, ContactSearchField, EmailSearchField, FileSearchField, SearchField,
|
CalendarSearchField, ContactSearchField, EmailSearchField, FileSearchField, SearchField,
|
||||||
@@ -14,7 +15,7 @@ use crate::{
|
|||||||
write::SearchIndex,
|
write::SearchIndex,
|
||||||
};
|
};
|
||||||
use mysql_async::Pool;
|
use mysql_async::Pool;
|
||||||
use std::fmt::Display;
|
use std::{fmt::Display, time::Duration};
|
||||||
|
|
||||||
pub mod blob;
|
pub mod blob;
|
||||||
pub mod lookup;
|
pub mod lookup;
|
||||||
@@ -25,6 +26,72 @@ pub mod write;
|
|||||||
|
|
||||||
pub struct MysqlStore {
|
pub struct MysqlStore {
|
||||||
pub(crate) conn_pool: Pool,
|
pub(crate) conn_pool: Pool,
|
||||||
|
/// inbuxa: client-side query limits (see backend::query_timeout)
|
||||||
|
pub(crate) timeouts: QueryTimeouts,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: how long a request waits for a pooled connection (including
|
||||||
|
/// opening one). mysql_async's pool has no wait timeout, so upstream waited
|
||||||
|
/// forever when the server stopped answering.
|
||||||
|
pub(crate) const POOL_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
/// inbuxa: idle time before TCP keepalive probes start.
|
||||||
|
pub(crate) const POOL_KEEPALIVE_IDLE: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
|
impl MysqlStore {
|
||||||
|
/// inbuxa: a pooled connection, or an error once POOL_WAIT_TIMEOUT has
|
||||||
|
/// passed without one.
|
||||||
|
pub(crate) async fn conn(&self) -> trc::Result<mysql_async::Conn> {
|
||||||
|
pool_conn(&self.conn_pool, POOL_WAIT_TIMEOUT).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn pool_conn(
|
||||||
|
pool: &Pool,
|
||||||
|
wait: std::time::Duration,
|
||||||
|
) -> trc::Result<mysql_async::Conn> {
|
||||||
|
match tokio::time::timeout(wait, pool.get_conn()).await {
|
||||||
|
Ok(result) => result.map_err(into_error),
|
||||||
|
Err(_) => Err(trc::StoreEvent::MysqlError
|
||||||
|
.reason("Timed out waiting for a database connection")
|
||||||
|
.details(format!("No connection within {} s", wait.as_secs()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the error for an operation that ran past its time limit.
|
||||||
|
pub(crate) fn query_timeout_error(limit: Duration) -> trc::Error {
|
||||||
|
trc::StoreEvent::MysqlError
|
||||||
|
.reason("Query timed out")
|
||||||
|
.details(format!(
|
||||||
|
"No answer from the database within {} s",
|
||||||
|
limit.as_secs()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: ends an operation run on `conn` under `limit`. When it ran out,
|
||||||
|
/// the connection is closed rather than returned to the pool: a query may
|
||||||
|
/// still be in flight on it, or a transaction open. Conn::disconnect marks
|
||||||
|
/// the connection closed before it sends anything, so even when the server
|
||||||
|
/// doesn't answer and the attempt is dropped, the pool discards it instead
|
||||||
|
/// of waiting to clean it up.
|
||||||
|
pub(crate) fn bounded<T>(
|
||||||
|
conn: mysql_async::Conn,
|
||||||
|
result: Result<trc::Result<T>, tokio::time::error::Elapsed>,
|
||||||
|
limit: Duration,
|
||||||
|
) -> trc::Result<T> {
|
||||||
|
match result {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => {
|
||||||
|
discard(conn);
|
||||||
|
Err(query_timeout_error(limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: closes a connection whose state is unknown (see bounded).
|
||||||
|
pub(crate) fn discard(conn: mysql_async::Conn) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tokio::time::timeout(Duration::from_secs(1), conn.disconnect()).await;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{MysqlStore, into_error, is_timeout_error};
|
use super::{MysqlStore, bounded, discard, into_error, is_timeout_error, query_timeout_error};
|
||||||
use crate::{Deserialize, IterateParams, Key, ValueKey, write::ValueClass};
|
use crate::{Deserialize, IterateParams, Key, ValueKey, write::ValueClass};
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
use mysql_async::{Row, prelude::Queryable};
|
use mysql_async::{Row, prelude::Queryable};
|
||||||
@@ -14,41 +16,51 @@ impl MysqlStore {
|
|||||||
where
|
where
|
||||||
U: Deserialize + 'static,
|
U: Deserialize + 'static,
|
||||||
{
|
{
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prep(format!(
|
let result = tokio::time::timeout(limit, async {
|
||||||
"SELECT v FROM {} WHERE k = ?",
|
let s = conn
|
||||||
char::from(key.subspace())
|
.prep(format!(
|
||||||
))
|
"SELECT v FROM {} WHERE k = ?",
|
||||||
.await
|
char::from(key.subspace())
|
||||||
.map_err(into_error)?;
|
))
|
||||||
let key = key.serialize(0);
|
.await
|
||||||
conn.exec_first::<Vec<u8>, _, _>(&s, (&key,))
|
.map_err(into_error)?;
|
||||||
.await
|
let key = key.serialize(0);
|
||||||
.map_err(into_error)
|
conn.exec_first::<Vec<u8>, _, _>(&s, (&key,))
|
||||||
.and_then(|r| {
|
.await
|
||||||
if let Some(r) = r {
|
.map_err(into_error)
|
||||||
Ok(Some(U::deserialize_owned_with_key(&key, r)?))
|
.and_then(|r| {
|
||||||
} else {
|
if let Some(r) = r {
|
||||||
Ok(None)
|
Ok(Some(U::deserialize_owned_with_key(&key, r)?))
|
||||||
}
|
} else {
|
||||||
})
|
Ok(None)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result<bool> {
|
pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result<bool> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prep(format!(
|
let result = tokio::time::timeout(limit, async {
|
||||||
"SELECT 1 FROM {} WHERE k = ?",
|
let s = conn
|
||||||
char::from(key.subspace())
|
.prep(format!(
|
||||||
))
|
"SELECT 1 FROM {} WHERE k = ?",
|
||||||
.await
|
char::from(key.subspace())
|
||||||
.map_err(into_error)?;
|
))
|
||||||
let key = key.serialize(0);
|
.await
|
||||||
conn.exec_first::<u8, _, _>(&s, (&key,))
|
.map_err(into_error)?;
|
||||||
.await
|
let key = key.serialize(0);
|
||||||
.map_err(into_error)
|
conn.exec_first::<u8, _, _>(&s, (&key,))
|
||||||
.map(|r| r.is_some())
|
.await
|
||||||
|
.map_err(into_error)
|
||||||
|
.map(|r| r.is_some())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn iterate<T: Key>(
|
pub(crate) async fn iterate<T: Key>(
|
||||||
@@ -56,34 +68,42 @@ impl MysqlStore {
|
|||||||
params: IterateParams<T>,
|
params: IterateParams<T>,
|
||||||
mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result<bool> + Sync + Send,
|
mut cb: impl for<'x> FnMut(&'x [u8], &'x [u8]) -> trc::Result<bool> + Sync + Send,
|
||||||
) -> trc::Result<()> {
|
) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let table = char::from(params.begin.subspace());
|
let table = char::from(params.begin.subspace());
|
||||||
let begin = params.begin.serialize(0);
|
let begin = params.begin.serialize(0);
|
||||||
let end = params.end.serialize(0);
|
let end = params.end.serialize(0);
|
||||||
let keys = if params.values { "k, v" } else { "k" };
|
let keys = if params.values { "k, v" } else { "k" };
|
||||||
|
|
||||||
let s = conn
|
// inbuxa: a scan may run for hours, so the query limit bounds each
|
||||||
.prep(&match (params.first, params.ascending) {
|
// wait for the database (preparing, the query starting, the next
|
||||||
(true, true) => {
|
// row) rather than the scan. A wait that runs out closes the
|
||||||
format!(
|
// connection.
|
||||||
"SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC LIMIT 1"
|
let limit = self.timeouts.query;
|
||||||
)
|
let query = match (params.first, params.ascending) {
|
||||||
}
|
(true, true) => {
|
||||||
(true, false) => {
|
format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC LIMIT 1")
|
||||||
format!(
|
}
|
||||||
"SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC LIMIT 1"
|
(true, false) => {
|
||||||
)
|
format!(
|
||||||
}
|
"SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC LIMIT 1"
|
||||||
(false, true) => {
|
)
|
||||||
format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC")
|
}
|
||||||
}
|
(false, true) => {
|
||||||
(false, false) => {
|
format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k ASC")
|
||||||
format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC")
|
}
|
||||||
}
|
(false, false) => {
|
||||||
})
|
format!("SELECT {keys} FROM {table} WHERE k >= ? AND k <= ? ORDER BY k DESC")
|
||||||
.await
|
}
|
||||||
.map_err(into_error)?;
|
};
|
||||||
|
let s = match tokio::time::timeout(limit, conn.prep(&query)).await {
|
||||||
|
Ok(s) => s.map_err(into_error)?,
|
||||||
|
Err(_) => {
|
||||||
|
discard(conn);
|
||||||
|
return Err(query_timeout_error(limit));
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut from = begin;
|
let mut from = begin;
|
||||||
|
let mut stalled = false;
|
||||||
let mut to = end;
|
let mut to = end;
|
||||||
let mut resume_key = None;
|
let mut resume_key = None;
|
||||||
|
|
||||||
@@ -92,13 +112,26 @@ impl MysqlStore {
|
|||||||
let mut timed_out = false;
|
let mut timed_out = false;
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut rows = conn
|
let mut rows = match tokio::time::timeout(
|
||||||
.exec_stream::<Row, _, _>(&s, (from.clone(), to.clone()))
|
limit,
|
||||||
.await
|
conn.exec_stream::<Row, _, _>(&s, (from.clone(), to.clone())),
|
||||||
.map_err(into_error)?;
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(rows) => rows.map_err(into_error)?,
|
||||||
|
// Leaves the scan loop for the timeout below
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match rows.try_next().await {
|
let next = match tokio::time::timeout(limit, rows.try_next()).await {
|
||||||
|
Ok(next) => next,
|
||||||
|
Err(_) => {
|
||||||
|
stalled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match next {
|
||||||
Ok(Some(mut row)) => {
|
Ok(Some(mut row)) => {
|
||||||
let value = if params.values {
|
let value = if params.values {
|
||||||
row.take_opt::<Vec<u8>, _>(1)
|
row.take_opt::<Vec<u8>, _>(1)
|
||||||
@@ -134,6 +167,10 @@ impl MysqlStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if stalled {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
match last_key {
|
match last_key {
|
||||||
Some(last_key) if timed_out => {
|
Some(last_key) if timed_out => {
|
||||||
if params.ascending {
|
if params.ascending {
|
||||||
@@ -146,6 +183,9 @@ impl MysqlStore {
|
|||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
discard(conn);
|
||||||
|
Err(query_timeout_error(limit))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn get_counter(
|
pub(crate) async fn get_counter(
|
||||||
@@ -155,15 +195,20 @@ impl MysqlStore {
|
|||||||
let key = key.into();
|
let key = key.into();
|
||||||
let table = char::from(key.subspace());
|
let table = char::from(key.subspace());
|
||||||
let key = key.serialize(0);
|
let key = key.serialize(0);
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prep(format!("SELECT v FROM {table} WHERE k = ?"))
|
let result = tokio::time::timeout(limit, async {
|
||||||
.await
|
let s = conn
|
||||||
.map_err(into_error)?;
|
.prep(format!("SELECT v FROM {table} WHERE k = ?"))
|
||||||
match conn.exec_first::<i64, _, _>(&s, (key,)).await {
|
.await
|
||||||
Ok(Some(num)) => Ok(num),
|
.map_err(into_error)?;
|
||||||
Ok(None) => Ok(0),
|
match conn.exec_first::<i64, _, _>(&s, (key,)).await {
|
||||||
Err(e) => Err(into_error(e)),
|
Ok(Some(num)) => Ok(num),
|
||||||
}
|
Ok(None) => Ok(0),
|
||||||
|
Err(e) => Err(into_error(e)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
backend::{
|
backend::{
|
||||||
MAX_TOKEN_LENGTH,
|
MAX_TOKEN_LENGTH,
|
||||||
mysql::{
|
mysql::{
|
||||||
DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlSearchField, MysqlStore, into_error,
|
DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlSearchField, MysqlStore, bounded,
|
||||||
is_timeout_error,
|
into_error, is_timeout_error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
search::{
|
search::{
|
||||||
@@ -19,63 +21,68 @@ use crate::{
|
|||||||
write::SearchIndex,
|
write::SearchIndex,
|
||||||
};
|
};
|
||||||
use mysql_async::{IsolationLevel, TxOpts, Value, prelude::Queryable};
|
use mysql_async::{IsolationLevel, TxOpts, Value, prelude::Queryable};
|
||||||
use nlp::tokenizers::word::WordTokenizer;
|
use nlp::{language::Language, tokenizers::word::WordTokenizer};
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
impl MysqlStore {
|
impl MysqlStore {
|
||||||
pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> {
|
pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let mut tx_opts = TxOpts::default();
|
let limit = self.timeouts.query;
|
||||||
tx_opts
|
let result = tokio::time::timeout(limit, async {
|
||||||
.with_consistent_snapshot(false)
|
let mut tx_opts = TxOpts::default();
|
||||||
.with_isolation_level(IsolationLevel::ReadCommitted);
|
tx_opts
|
||||||
let mut trx = conn.start_transaction(tx_opts).await.map_err(into_error)?;
|
.with_consistent_snapshot(false)
|
||||||
|
.with_isolation_level(IsolationLevel::ReadCommitted);
|
||||||
|
let mut trx = conn.start_transaction(tx_opts).await.map_err(into_error)?;
|
||||||
|
|
||||||
for document in documents {
|
for document in documents {
|
||||||
let index = document.index;
|
let index = document.index;
|
||||||
let primary_keys = index.primary_keys();
|
let primary_keys = index.primary_keys();
|
||||||
let all_fields = index.all_fields();
|
let all_fields = index.all_fields();
|
||||||
let mut fields = document.fields;
|
let mut fields = document.fields;
|
||||||
let mut values = Vec::with_capacity(fields.len() + 2);
|
let mut values = Vec::with_capacity(fields.len() + 2);
|
||||||
let mut query = format!("INSERT INTO {} (", index.mysql_table());
|
let mut query = format!("INSERT INTO {} (", index.mysql_table());
|
||||||
|
|
||||||
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
|
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
query.push(',');
|
query.push(',');
|
||||||
|
}
|
||||||
|
query.push_str(field.column());
|
||||||
}
|
}
|
||||||
query.push_str(field.column());
|
|
||||||
|
query.push_str(") VALUES (");
|
||||||
|
|
||||||
|
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
query.push(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(value) = fields.remove(field) {
|
||||||
|
query.push('?');
|
||||||
|
values.push(value);
|
||||||
|
} else {
|
||||||
|
query.push_str("NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query.push_str(") ON DUPLICATE KEY UPDATE ");
|
||||||
|
for (i, field) in all_fields.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
query.push(',');
|
||||||
|
}
|
||||||
|
let column = field.column();
|
||||||
|
let _ = write!(&mut query, "{column} = VALUES({column})");
|
||||||
|
}
|
||||||
|
|
||||||
|
let s = trx.prep(&query).await.map_err(into_error)?;
|
||||||
|
|
||||||
|
trx.exec_drop(&s, values).await.map_err(into_error)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
query.push_str(") VALUES (");
|
trx.commit().await.map_err(into_error)
|
||||||
|
})
|
||||||
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
|
.await;
|
||||||
if i > 0 {
|
bounded(conn, result, limit)
|
||||||
query.push(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(value) = fields.remove(field) {
|
|
||||||
query.push('?');
|
|
||||||
values.push(value);
|
|
||||||
} else {
|
|
||||||
query.push_str("NULL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
query.push_str(") ON DUPLICATE KEY UPDATE ");
|
|
||||||
for (i, field) in all_fields.iter().enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
query.push(',');
|
|
||||||
}
|
|
||||||
let column = field.column();
|
|
||||||
let _ = write!(&mut query, "{column} = VALUES({column})");
|
|
||||||
}
|
|
||||||
|
|
||||||
let s = trx.prep(&query).await.map_err(into_error)?;
|
|
||||||
|
|
||||||
trx.exec_drop(&s, values).await.map_err(into_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
trx.commit().await.map_err(into_error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn query<R: SearchDocumentId>(
|
pub async fn query<R: SearchDocumentId>(
|
||||||
@@ -94,13 +101,18 @@ impl MysqlStore {
|
|||||||
build_sort(&mut query, sort);
|
build_sort(&mut query, sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn.prep(query).await.map_err(into_error)?;
|
let limit = self.timeouts.query;
|
||||||
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
let s = conn.prep(query).await.map_err(into_error)?;
|
||||||
|
|
||||||
conn.exec::<i64, _, _>(s, params)
|
conn.exec::<i64, _, _>(s, params)
|
||||||
.await
|
.await
|
||||||
.map(|r| r.into_iter().map(|r| R::from_u64(r as u64)).collect())
|
.map(|r| r.into_iter().map(|r| R::from_u64(r as u64)).collect())
|
||||||
.map_err(into_error)
|
.map_err(into_error)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unindex(&self, filter: SearchQuery) -> trc::Result<u64> {
|
pub async fn unindex(&self, filter: SearchQuery) -> trc::Result<u64> {
|
||||||
@@ -108,44 +120,65 @@ impl MysqlStore {
|
|||||||
let mut query = format!("DELETE FROM {table} ");
|
let mut query = format!("DELETE FROM {table} ");
|
||||||
let params = build_filter(&mut query, &filter.filters);
|
let params = build_filter(&mut query, &filter.filters);
|
||||||
|
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let s = conn.prep(&query).await.map_err(into_error)?;
|
let limit = self.timeouts.maintenance;
|
||||||
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
let s = conn.prep(&query).await.map_err(into_error)?;
|
||||||
|
|
||||||
match conn.exec_drop(s, params.clone()).await {
|
match conn.exec_drop(s, params.clone()).await {
|
||||||
Ok(_) => return Ok(conn.affected_rows()),
|
Ok(_) => return Ok(conn.affected_rows()),
|
||||||
Err(err) if is_timeout_error(&err) => (),
|
Err(err) if is_timeout_error(&err) => (),
|
||||||
Err(err) => return Err(into_error(err)),
|
Err(err) => return Err(into_error(err)),
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut chunk_size = DELETE_CHUNK_SIZE;
|
let mut chunk_size = DELETE_CHUNK_SIZE;
|
||||||
let mut deleted = 0;
|
let mut deleted = 0;
|
||||||
|
|
||||||
loop {
|
|
||||||
let s = conn
|
|
||||||
.prep(format!("{query} LIMIT {chunk_size}"))
|
|
||||||
.await
|
|
||||||
.map_err(into_error)?;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match conn.exec_drop(&s, params.clone()).await {
|
let s = conn
|
||||||
Ok(_) => {
|
.prep(format!("{query} LIMIT {chunk_size}"))
|
||||||
let affected = conn.affected_rows();
|
.await
|
||||||
if affected == 0 {
|
.map_err(into_error)?;
|
||||||
return Ok(deleted);
|
|
||||||
|
loop {
|
||||||
|
match conn.exec_drop(&s, params.clone()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
let affected = conn.affected_rows();
|
||||||
|
if affected == 0 {
|
||||||
|
return Ok(deleted);
|
||||||
|
}
|
||||||
|
deleted += affected;
|
||||||
}
|
}
|
||||||
deleted += affected;
|
Err(err)
|
||||||
|
if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE =>
|
||||||
|
{
|
||||||
|
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => return Err(into_error(err)),
|
||||||
}
|
}
|
||||||
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
|
||||||
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(err) => return Err(into_error(err)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: InnoDB's default full-text stopword list
|
||||||
|
// (INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD) and innodb_ft_min_token_size
|
||||||
|
// default; words outside these are not in a FULLTEXT index.
|
||||||
|
const FT_STOPWORDS: &[&str] = &[
|
||||||
|
"a", "about", "an", "are", "as", "at", "be", "by", "com", "de", "en", "for", "from", "how",
|
||||||
|
"i", "in", "is", "it", "la", "of", "on", "or", "that", "the", "this", "to", "was", "what",
|
||||||
|
"when", "where", "who", "will", "with", "und", "www",
|
||||||
|
];
|
||||||
|
const FT_MIN_TOKEN_SIZE: usize = 3;
|
||||||
|
|
||||||
|
fn is_ft_indexed(word: &str) -> bool {
|
||||||
|
word.chars().count() >= FT_MIN_TOKEN_SIZE && !FT_STOPWORDS.contains(&word)
|
||||||
|
}
|
||||||
|
|
||||||
fn build_filter(query: &mut String, filters: &[SearchFilter]) -> Vec<Value> {
|
fn build_filter(query: &mut String, filters: &[SearchFilter]) -> Vec<Value> {
|
||||||
if filters.is_empty() {
|
if filters.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -171,30 +204,77 @@ fn build_filter(query: &mut String, filters: &[SearchFilter]) -> Vec<Value> {
|
|||||||
|
|
||||||
if field.is_text() && matches!(op, SearchOperator::Equal | SearchOperator::Contains)
|
if field.is_text() && matches!(op, SearchOperator::Equal | SearchOperator::Contains)
|
||||||
{
|
{
|
||||||
let (value, mode) = match (value, op) {
|
let (value, mode, unindexed) = match (value, op) {
|
||||||
(SearchValue::Text { value, .. }, SearchOperator::Equal) => {
|
(SearchValue::Text { value, .. }, SearchOperator::Equal) => (
|
||||||
(Value::Bytes(format!("{value:?}").into_bytes()), "BOOLEAN")
|
Value::Bytes(format!("{value:?}").into_bytes()),
|
||||||
}
|
"BOOLEAN",
|
||||||
(SearchValue::Text { value, .. }, ..) => {
|
Vec::new(),
|
||||||
|
),
|
||||||
|
(SearchValue::Text { value, language }, ..) => {
|
||||||
let mut text_query = String::with_capacity(value.len() + 1);
|
let mut text_query = String::with_capacity(value.len() + 1);
|
||||||
|
let mut unindexed = Vec::new();
|
||||||
|
|
||||||
for item in WordTokenizer::new(value, MAX_TOKEN_LENGTH) {
|
for item in WordTokenizer::new(value, MAX_TOKEN_LENGTH) {
|
||||||
if !text_query.is_empty() {
|
// inbuxa: InnoDB never indexes stopwords ("com",
|
||||||
text_query.push(' ');
|
// "de", "www", ...) or words under
|
||||||
|
// innodb_ft_min_token_size, and a required
|
||||||
|
// (+word) term it has not indexed matches no row,
|
||||||
|
// so "example.com" or "[email protected]" found
|
||||||
|
// nothing. Such words are matched with a
|
||||||
|
// word-boundary REGEXP instead.
|
||||||
|
if is_ft_indexed(&item.word) {
|
||||||
|
if !text_query.is_empty() {
|
||||||
|
text_query.push(' ');
|
||||||
|
}
|
||||||
|
text_query.push('+');
|
||||||
|
text_query.push_str(&item.word);
|
||||||
|
} else {
|
||||||
|
unindexed.push(item.word);
|
||||||
}
|
}
|
||||||
text_query.push('+');
|
|
||||||
text_query.push_str(&item.word);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(Value::Bytes(text_query.into_bytes()), "BOOLEAN")
|
// For language text (bodies, subjects) the unindexed
|
||||||
|
// words are noise words and only checked when nothing
|
||||||
|
// else is left to match; keyword text (addresses,
|
||||||
|
// contact fields) checks every word, as the other
|
||||||
|
// backends do.
|
||||||
|
if !text_query.is_empty() && !matches!(language, Language::None) {
|
||||||
|
unindexed.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
(Value::Bytes(text_query.into_bytes()), "BOOLEAN", unindexed)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
debug_assert!(false, "Invalid search value for text field");
|
debug_assert!(false, "Invalid search value for text field");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = write!(query, "MATCH({}) AGAINST(? IN {mode} MODE)", field.column());
|
if unindexed.is_empty() {
|
||||||
values.push(value);
|
let _ =
|
||||||
|
write!(query, "MATCH({}) AGAINST(? IN {mode} MODE)", field.column());
|
||||||
|
values.push(value);
|
||||||
|
} else {
|
||||||
|
query.push('(');
|
||||||
|
let is_empty = matches!(&value, Value::Bytes(v) if v.is_empty());
|
||||||
|
if !is_empty {
|
||||||
|
let _ = write!(
|
||||||
|
query,
|
||||||
|
"MATCH({}) AGAINST(? IN {mode} MODE) AND ",
|
||||||
|
field.column()
|
||||||
|
);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
for (i, word) in unindexed.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
query.push_str(" AND ");
|
||||||
|
}
|
||||||
|
let _ = write!(query, "{} REGEXP ?", field.column());
|
||||||
|
values.push(Value::Bytes(
|
||||||
|
format!("(^|[^[:alnum:]]){word}([^[:alnum:]]|$)").into_bytes(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
query.push(')');
|
||||||
|
}
|
||||||
} else if let SearchValue::KeyValues(kv) = value {
|
} else if let SearchValue::KeyValues(kv) = value {
|
||||||
let (key, value) = kv.iter().next().unwrap();
|
let (key, value) = kv.iter().next().unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlStore, into_error, is_timeout_error};
|
use super::{
|
||||||
|
DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, MysqlStore, bounded, into_error, is_timeout_error,
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA,
|
IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA,
|
||||||
SUBSPACE_REGISTRY_IDX,
|
SUBSPACE_REGISTRY_IDX,
|
||||||
@@ -29,42 +33,46 @@ impl MysqlStore {
|
|||||||
pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result<AssignedIds> {
|
pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result<AssignedIds> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut retry_count = 0;
|
let mut retry_count = 0;
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
|
let limit = self.timeouts.query;
|
||||||
loop {
|
let result = tokio::time::timeout(limit, async {
|
||||||
let err = match self.write_trx(&mut conn, &mut batch).await {
|
loop {
|
||||||
Ok(result) => {
|
let err = match self.write_trx(&mut conn, &mut batch).await {
|
||||||
return Ok(result);
|
Ok(result) => {
|
||||||
}
|
return Ok(result);
|
||||||
Err(err) => err,
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = conn.query_drop("ROLLBACK;").await;
|
|
||||||
|
|
||||||
match err {
|
|
||||||
CommitError::Mysql(Error::Server(err))
|
|
||||||
if [1062, 1213].contains(&err.code)
|
|
||||||
&& retry_count < MAX_COMMIT_ATTEMPTS
|
|
||||||
&& start.elapsed() < MAX_COMMIT_TIME => {}
|
|
||||||
/*CommitError::Retry => {
|
|
||||||
if retry_count > MAX_COMMIT_ATTEMPTS || start.elapsed() > MAX_COMMIT_TIME {
|
|
||||||
return Err(trc::StoreEvent::AssertValueFailed
|
|
||||||
.into_err()
|
|
||||||
.caused_by(trc::location!()));
|
|
||||||
}
|
}
|
||||||
}*/
|
Err(err) => err,
|
||||||
CommitError::Mysql(err) => {
|
};
|
||||||
return Err(into_error(err));
|
|
||||||
}
|
|
||||||
CommitError::Internal(err) => {
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let backoff = rand::rng().random_range(50..=300);
|
let _ = conn.query_drop("ROLLBACK;").await;
|
||||||
tokio::time::sleep(Duration::from_millis(backoff)).await;
|
|
||||||
retry_count += 1;
|
match err {
|
||||||
}
|
CommitError::Mysql(Error::Server(err))
|
||||||
|
if [1062, 1213].contains(&err.code)
|
||||||
|
&& retry_count < MAX_COMMIT_ATTEMPTS
|
||||||
|
&& start.elapsed() < MAX_COMMIT_TIME => {}
|
||||||
|
/*CommitError::Retry => {
|
||||||
|
if retry_count > MAX_COMMIT_ATTEMPTS || start.elapsed() > MAX_COMMIT_TIME {
|
||||||
|
return Err(trc::StoreEvent::AssertValueFailed
|
||||||
|
.into_err()
|
||||||
|
.caused_by(trc::location!()));
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
CommitError::Mysql(err) => {
|
||||||
|
return Err(into_error(err));
|
||||||
|
}
|
||||||
|
CommitError::Internal(err) => {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let backoff = rand::rng().random_range(50..=300);
|
||||||
|
tokio::time::sleep(Duration::from_millis(backoff)).await;
|
||||||
|
retry_count += 1;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_trx(
|
async fn write_trx(
|
||||||
@@ -382,72 +390,82 @@ impl MysqlStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn purge_store(&self) -> trc::Result<()> {
|
pub(crate) async fn purge_store(&self) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] {
|
let limit = self.timeouts.maintenance;
|
||||||
purge_table(&mut conn, char::from(subspace)).await?;
|
let result = tokio::time::timeout(limit, async {
|
||||||
}
|
for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] {
|
||||||
|
purge_table(&mut conn, char::from(subspace)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> {
|
pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get_conn().await.map_err(into_error)?;
|
let mut conn = self.conn().await?;
|
||||||
let table = char::from(from.subspace());
|
let limit = self.timeouts.maintenance;
|
||||||
let mut from = from.serialize(0);
|
let result = tokio::time::timeout(limit, async {
|
||||||
let to = to.serialize(0);
|
let table = char::from(from.subspace());
|
||||||
|
let mut from = from.serialize(0);
|
||||||
|
let to = to.serialize(0);
|
||||||
|
|
||||||
let delete = conn
|
let delete = conn
|
||||||
.prep(format!("DELETE FROM {table} WHERE k >= ? AND k < ?"))
|
.prep(format!("DELETE FROM {table} WHERE k >= ? AND k < ?"))
|
||||||
.await
|
|
||||||
.map_err(into_error)?;
|
|
||||||
|
|
||||||
match conn.exec_drop(&delete, (&from, &to)).await {
|
|
||||||
Ok(_) => return Ok(()),
|
|
||||||
Err(err) if is_timeout_error(&err) => (),
|
|
||||||
Err(err) => return Err(into_error(err)),
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut chunk_size = DELETE_CHUNK_SIZE;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let boundary = conn
|
|
||||||
.prep(format!(
|
|
||||||
"SELECT k FROM {table} WHERE k >= ? AND k < ? ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}"
|
|
||||||
))
|
|
||||||
.await
|
.await
|
||||||
.map_err(into_error)?;
|
.map_err(into_error)?;
|
||||||
|
|
||||||
|
match conn.exec_drop(&delete, (&from, &to)).await {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(err) if is_timeout_error(&err) => (),
|
||||||
|
Err(err) => return Err(into_error(err)),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunk_size = DELETE_CHUNK_SIZE;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let next = match conn
|
let boundary = conn
|
||||||
.exec_first::<Vec<u8>, _, _>(&boundary, (&from, &to))
|
.prep(format!(
|
||||||
|
"SELECT k FROM {table} WHERE k >= ? AND k < ? ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}"
|
||||||
|
))
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(into_error)?;
|
||||||
Ok(next) => next,
|
|
||||||
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
|
||||||
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(err) => return Err(into_error(err)),
|
|
||||||
};
|
|
||||||
|
|
||||||
match conn
|
loop {
|
||||||
.exec_drop(&delete, (&from, next.as_ref().unwrap_or(&to)))
|
let next = match conn
|
||||||
.await
|
.exec_first::<Vec<u8>, _, _>(&boundary, (&from, &to))
|
||||||
{
|
.await
|
||||||
Ok(_) => (),
|
{
|
||||||
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
Ok(next) => next,
|
||||||
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
||||||
break;
|
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
||||||
}
|
break;
|
||||||
Err(err) => return Err(into_error(err)),
|
}
|
||||||
}
|
Err(err) => return Err(into_error(err)),
|
||||||
|
};
|
||||||
|
|
||||||
match next {
|
match conn
|
||||||
Some(next) => from = next,
|
.exec_drop(&delete, (&from, next.as_ref().unwrap_or(&to)))
|
||||||
None => return Ok(()),
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => (),
|
||||||
|
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
||||||
|
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => return Err(into_error(err)),
|
||||||
|
}
|
||||||
|
|
||||||
|
match next {
|
||||||
|
Some(next) => from = next,
|
||||||
|
None => return Ok(()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
* 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::ops::Range;
|
use std::ops::Range;
|
||||||
|
|
||||||
use crate::backend::postgres::into_pool_error;
|
use crate::backend::postgres::into_pool_error;
|
||||||
|
|
||||||
use super::{PostgresStore, into_error};
|
use super::{PostgresStore, bounded, into_error};
|
||||||
|
|
||||||
impl PostgresStore {
|
impl PostgresStore {
|
||||||
pub(crate) async fn get_blob(
|
pub(crate) async fn get_blob(
|
||||||
@@ -17,53 +19,68 @@ impl PostgresStore {
|
|||||||
range: Range<usize>,
|
range: Range<usize>,
|
||||||
) -> trc::Result<Option<Vec<u8>>> {
|
) -> trc::Result<Option<Vec<u8>>> {
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prepare_cached("SELECT v FROM t WHERE k = $1")
|
let result = tokio::time::timeout(limit, async {
|
||||||
.await
|
let s = conn
|
||||||
.map_err(into_error)?;
|
.prepare_cached("SELECT v FROM t WHERE k = $1")
|
||||||
conn.query_opt(&s, &[&key])
|
.await
|
||||||
.await
|
.map_err(into_error)?;
|
||||||
.and_then(|row| {
|
conn.query_opt(&s, &[&key])
|
||||||
if let Some(row) = row {
|
.await
|
||||||
Ok(Some(if range.start == 0 && range.end == usize::MAX {
|
.and_then(|row| {
|
||||||
row.try_get::<_, Vec<u8>>(0)?
|
if let Some(row) = row {
|
||||||
|
Ok(Some(if range.start == 0 && range.end == usize::MAX {
|
||||||
|
row.try_get::<_, Vec<u8>>(0)?
|
||||||
|
} else {
|
||||||
|
let bytes = row.try_get::<_, &[u8]>(0)?;
|
||||||
|
bytes
|
||||||
|
.get(range.start..std::cmp::min(bytes.len(), range.end))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_vec()
|
||||||
|
}))
|
||||||
} else {
|
} else {
|
||||||
let bytes = row.try_get::<_, &[u8]>(0)?;
|
Ok(None)
|
||||||
bytes
|
}
|
||||||
.get(range.start..std::cmp::min(bytes.len(), range.end))
|
})
|
||||||
.unwrap_or_default()
|
.map_err(into_error)
|
||||||
.to_vec()
|
})
|
||||||
}))
|
.await;
|
||||||
} else {
|
bounded(conn, result, limit)
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map_err(into_error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> {
|
pub(crate) async fn put_blob(&self, key: &[u8], data: &[u8]) -> trc::Result<()> {
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prepare_cached(
|
let result = tokio::time::timeout(limit, async {
|
||||||
"INSERT INTO t (k, v) VALUES ($1, $2) ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v",
|
let s = conn
|
||||||
)
|
.prepare_cached(
|
||||||
.await
|
"INSERT INTO t (k, v) VALUES ($1, $2) ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v",
|
||||||
.map_err(into_error)?;
|
)
|
||||||
conn.execute(&s, &[&key, &data])
|
.await
|
||||||
.await
|
.map_err(into_error)?;
|
||||||
.map_err(into_error)
|
conn.execute(&s, &[&key, &data])
|
||||||
.map(|_| ())
|
.await
|
||||||
|
.map_err(into_error)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result<bool> {
|
pub(crate) async fn delete_blob(&self, key: &[u8]) -> trc::Result<bool> {
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prepare_cached("DELETE FROM t WHERE k = $1")
|
let result = tokio::time::timeout(limit, async {
|
||||||
.await
|
let s = conn
|
||||||
.map_err(into_error)?;
|
.prepare_cached("DELETE FROM t WHERE k = $1")
|
||||||
conn.execute(&s, &[&key])
|
.await
|
||||||
.await
|
.map_err(into_error)?;
|
||||||
.map_err(into_error)
|
conn.execute(&s, &[&key])
|
||||||
.map(|hits| hits > 0)
|
.await
|
||||||
|
.map_err(into_error)
|
||||||
|
.map(|hits| hits > 0)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::{QueryResult, QueryType, backend::postgres::into_pool_error};
|
use crate::{QueryResult, QueryType, backend::postgres::into_pool_error};
|
||||||
@@ -12,7 +14,7 @@ use tokio_postgres::types::{FromSql, ToSql, Type};
|
|||||||
|
|
||||||
use crate::IntoRows;
|
use crate::IntoRows;
|
||||||
|
|
||||||
use super::{PostgresStore, into_error};
|
use super::{PostgresStore, bounded, into_error};
|
||||||
|
|
||||||
impl PostgresStore {
|
impl PostgresStore {
|
||||||
pub(crate) async fn sql_query<T: QueryResult>(
|
pub(crate) async fn sql_query<T: QueryResult>(
|
||||||
@@ -21,33 +23,38 @@ impl PostgresStore {
|
|||||||
params_: &[crate::Value<'_>],
|
params_: &[crate::Value<'_>],
|
||||||
) -> trc::Result<T> {
|
) -> trc::Result<T> {
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let s = conn.prepare_cached(query).await.map_err(into_error)?;
|
let limit = self.timeouts.query;
|
||||||
let params = params_
|
let result = tokio::time::timeout(limit, async {
|
||||||
.iter()
|
let s = conn.prepare_cached(query).await.map_err(into_error)?;
|
||||||
.map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
|
let params = params_
|
||||||
.collect::<Vec<_>>();
|
.iter()
|
||||||
|
.map(|v| v as &(dyn tokio_postgres::types::ToSql + Sync))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
match T::query_type() {
|
match T::query_type() {
|
||||||
QueryType::Execute => conn
|
QueryType::Execute => conn
|
||||||
.execute(&s, params.as_slice())
|
.execute(&s, params.as_slice())
|
||||||
.await
|
|
||||||
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exec(r as usize))),
|
|
||||||
QueryType::Exists => {
|
|
||||||
let rows = conn.query_raw(&s, params).await.map_err(into_error)?;
|
|
||||||
pin_mut!(rows);
|
|
||||||
rows.try_next()
|
|
||||||
.await
|
.await
|
||||||
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exists(r.is_some())))
|
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exec(r as usize))),
|
||||||
|
QueryType::Exists => {
|
||||||
|
let rows = conn.query_raw(&s, params).await.map_err(into_error)?;
|
||||||
|
pin_mut!(rows);
|
||||||
|
rows.try_next()
|
||||||
|
.await
|
||||||
|
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_exists(r.is_some())))
|
||||||
|
}
|
||||||
|
QueryType::QueryOne => conn
|
||||||
|
.query_opt(&s, params.as_slice())
|
||||||
|
.await
|
||||||
|
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_one(r))),
|
||||||
|
QueryType::QueryAll => conn
|
||||||
|
.query(&s, params.as_slice())
|
||||||
|
.await
|
||||||
|
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_all(r))),
|
||||||
}
|
}
|
||||||
QueryType::QueryOne => conn
|
})
|
||||||
.query_opt(&s, params.as_slice())
|
.await;
|
||||||
.await
|
bounded(conn, result, limit)
|
||||||
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_one(r))),
|
|
||||||
QueryType::QueryAll => conn
|
|
||||||
.query(&s, params.as_slice())
|
|
||||||
.await
|
|
||||||
.map_or_else(|e| Err(into_error(e)), |r| Ok(T::from_query_all(r))),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{PostgresStore, into_error};
|
use super::{PostgresStore, bounded, into_error};
|
||||||
use crate::{
|
use crate::{
|
||||||
backend::postgres::{
|
backend::postgres::{
|
||||||
PsqlSearchField, into_pool_error,
|
PsqlSearchField, into_pool_error,
|
||||||
@@ -22,11 +22,34 @@ use crate::{
|
|||||||
use ::registry::schema::{enums::PostgreSqlRecyclingMethod, structs};
|
use ::registry::schema::{enums::PostgreSqlRecyclingMethod, structs};
|
||||||
use ahash::AHashSet;
|
use ahash::AHashSet;
|
||||||
use deadpool_postgres::{
|
use deadpool_postgres::{
|
||||||
Config, ManagerConfig, Object, Pool, PoolConfig, RecyclingMethod, Runtime,
|
Config, ManagerConfig, Object, Pool, PoolConfig, RecyclingMethod, Runtime, Timeouts,
|
||||||
};
|
};
|
||||||
|
use std::time::Duration;
|
||||||
use tokio_postgres::NoTls;
|
use tokio_postgres::NoTls;
|
||||||
use utils::tls::rustls_client_config;
|
use utils::tls::rustls_client_config;
|
||||||
|
|
||||||
|
/// inbuxa: how long a request waits for a pooled connection.
|
||||||
|
pub(crate) const POOL_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
/// inbuxa: how long opening a connection may take when the store sets no
|
||||||
|
/// timeout of its own.
|
||||||
|
pub(crate) const POOL_CREATE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
/// inbuxa: how long checking a pooled connection before reuse may take.
|
||||||
|
pub(crate) const POOL_RECYCLE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
/// inbuxa: idle time before TCP keepalive probes start.
|
||||||
|
pub(crate) const POOL_KEEPALIVE_IDLE: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// inbuxa: the pool's timeouts. Opening a connection is bounded by the
|
||||||
|
/// store's own timeout when it has one; waiting for one covers at least that
|
||||||
|
/// long, so a slow connect isn't cut short by the wait.
|
||||||
|
pub(crate) fn pool_timeouts(connect_timeout: Option<Duration>) -> Timeouts {
|
||||||
|
let create = connect_timeout.unwrap_or(POOL_CREATE_TIMEOUT);
|
||||||
|
Timeouts {
|
||||||
|
wait: POOL_WAIT_TIMEOUT.max(create).into(),
|
||||||
|
create: create.into(),
|
||||||
|
recycle: POOL_RECYCLE_TIMEOUT.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl PostgresStore {
|
impl PostgresStore {
|
||||||
pub async fn open(config: structs::PostgreSqlStore) -> Result<Store, String> {
|
pub async fn open(config: structs::PostgreSqlStore) -> Result<Store, String> {
|
||||||
// inbuxa: ST-15: where the primary is, to tell a replica from it
|
// inbuxa: ST-15: where the primary is, to tell a replica from it
|
||||||
@@ -46,9 +69,20 @@ impl PostgresStore {
|
|||||||
PostgreSqlRecyclingMethod::Clean => RecyclingMethod::Clean,
|
PostgreSqlRecyclingMethod::Clean => RecyclingMethod::Clean,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if let Some(max_conn) = config.pool_max_connections {
|
// inbuxa: upstream set no pool timeouts, so a request waited for a
|
||||||
cfg.pool = PoolConfig::new(max_conn as usize).into();
|
// free connection, or for one to be made or recycled, for as long as
|
||||||
}
|
// it took: forever when the server stopped answering. A worker now
|
||||||
|
// gets an error instead and the task or request is retried.
|
||||||
|
let mut pool = config
|
||||||
|
.pool_max_connections
|
||||||
|
.map(|max_conn| PoolConfig::new(max_conn as usize))
|
||||||
|
.unwrap_or_default();
|
||||||
|
pool.timeouts = pool_timeouts(cfg.connect_timeout);
|
||||||
|
cfg.pool = pool.into();
|
||||||
|
// Notice a server that went away without closing the connection in
|
||||||
|
// minutes rather than the system default of two hours
|
||||||
|
cfg.keepalives = true.into();
|
||||||
|
cfg.keepalives_idle = POOL_KEEPALIVE_IDLE.into();
|
||||||
|
|
||||||
let primary_pool = if config.use_tls {
|
let primary_pool = if config.use_tls {
|
||||||
cfg.create_pool(
|
cfg.create_pool(
|
||||||
@@ -85,6 +119,7 @@ impl PostgresStore {
|
|||||||
Store::PostgreSQL(Arc::new(PostgresStore {
|
Store::PostgreSQL(Arc::new(PostgresStore {
|
||||||
conn_pool: pool,
|
conn_pool: pool,
|
||||||
ts_configs: ts_configs.clone(),
|
ts_configs: ts_configs.clone(),
|
||||||
|
timeouts: Default::default(),
|
||||||
})),
|
})),
|
||||||
replica.host,
|
replica.host,
|
||||||
replica.port as u16,
|
replica.port as u16,
|
||||||
@@ -95,6 +130,7 @@ impl PostgresStore {
|
|||||||
let primary = Store::PostgreSQL(Arc::new(PostgresStore {
|
let primary = Store::PostgreSQL(Arc::new(PostgresStore {
|
||||||
conn_pool: primary_pool,
|
conn_pool: primary_pool,
|
||||||
ts_configs,
|
ts_configs,
|
||||||
|
timeouts: Default::default(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ST-1: no replicas, no change
|
// ST-1: no replicas, no change
|
||||||
@@ -113,84 +149,92 @@ impl PostgresStore {
|
|||||||
|
|
||||||
pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> {
|
pub(crate) async fn create_storage_tables(&self) -> trc::Result<()> {
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
|
let limit = self.timeouts.maintenance;
|
||||||
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
for table in [
|
||||||
|
SUBSPACE_ACL,
|
||||||
|
SUBSPACE_TASK_QUEUE,
|
||||||
|
SUBSPACE_DELETED_ITEMS,
|
||||||
|
SUBSPACE_SPAM_SAMPLES,
|
||||||
|
crate::SUBSPACE_INBUXA, // inbuxa: masked email
|
||||||
|
SUBSPACE_BLOB_LINK,
|
||||||
|
SUBSPACE_IN_MEMORY_VALUE,
|
||||||
|
SUBSPACE_PROPERTY,
|
||||||
|
SUBSPACE_REGISTRY,
|
||||||
|
SUBSPACE_REGISTRY_PK,
|
||||||
|
SUBSPACE_QUEUE_MESSAGE,
|
||||||
|
SUBSPACE_QUEUE_EVENT,
|
||||||
|
SUBSPACE_REPORT_OUT,
|
||||||
|
SUBSPACE_REPORT_IN,
|
||||||
|
SUBSPACE_LOGS,
|
||||||
|
SUBSPACE_BLOBS,
|
||||||
|
SUBSPACE_DIRECTORY,
|
||||||
|
SUBSPACE_TELEMETRY_SPAN,
|
||||||
|
SUBSPACE_TELEMETRY_METRIC,
|
||||||
|
] {
|
||||||
|
let table = char::from(table);
|
||||||
|
conn.execute(
|
||||||
|
&format!(
|
||||||
|
"CREATE TABLE IF NOT EXISTS {table} (
|
||||||
|
k BYTEA PRIMARY KEY,
|
||||||
|
v BYTEA NOT NULL
|
||||||
|
)"
|
||||||
|
),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(into_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
for table in [
|
for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] {
|
||||||
SUBSPACE_ACL,
|
let table = char::from(table);
|
||||||
SUBSPACE_TASK_QUEUE,
|
conn.execute(
|
||||||
SUBSPACE_DELETED_ITEMS,
|
&format!(
|
||||||
SUBSPACE_SPAM_SAMPLES,
|
"CREATE TABLE IF NOT EXISTS {table} (
|
||||||
crate::SUBSPACE_INBUXA, // inbuxa: masked email
|
k BYTEA PRIMARY KEY
|
||||||
SUBSPACE_BLOB_LINK,
|
)"
|
||||||
SUBSPACE_IN_MEMORY_VALUE,
|
),
|
||||||
SUBSPACE_PROPERTY,
|
&[],
|
||||||
SUBSPACE_REGISTRY,
|
)
|
||||||
SUBSPACE_REGISTRY_PK,
|
.await
|
||||||
SUBSPACE_QUEUE_MESSAGE,
|
.map_err(into_error)?;
|
||||||
SUBSPACE_QUEUE_EVENT,
|
}
|
||||||
SUBSPACE_REPORT_OUT,
|
|
||||||
SUBSPACE_REPORT_IN,
|
for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] {
|
||||||
SUBSPACE_LOGS,
|
conn.execute(
|
||||||
SUBSPACE_BLOBS,
|
&format!(
|
||||||
SUBSPACE_DIRECTORY,
|
"CREATE TABLE IF NOT EXISTS {} (
|
||||||
SUBSPACE_TELEMETRY_SPAN,
|
|
||||||
SUBSPACE_TELEMETRY_METRIC,
|
|
||||||
] {
|
|
||||||
let table = char::from(table);
|
|
||||||
conn.execute(
|
|
||||||
&format!(
|
|
||||||
"CREATE TABLE IF NOT EXISTS {table} (
|
|
||||||
k BYTEA PRIMARY KEY,
|
k BYTEA PRIMARY KEY,
|
||||||
v BYTEA NOT NULL
|
v BIGINT NOT NULL DEFAULT 0
|
||||||
)"
|
)",
|
||||||
),
|
char::from(table)
|
||||||
&[],
|
),
|
||||||
)
|
&[],
|
||||||
.await
|
)
|
||||||
.map_err(into_error)?;
|
.await
|
||||||
}
|
.map_err(into_error)?;
|
||||||
|
}
|
||||||
|
|
||||||
for table in [SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX] {
|
Ok(())
|
||||||
let table = char::from(table);
|
})
|
||||||
conn.execute(
|
.await;
|
||||||
&format!(
|
bounded(conn, result, limit)
|
||||||
"CREATE TABLE IF NOT EXISTS {table} (
|
|
||||||
k BYTEA PRIMARY KEY
|
|
||||||
)"
|
|
||||||
),
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(into_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for table in [SUBSPACE_COUNTER, SUBSPACE_QUOTA, SUBSPACE_IN_MEMORY_COUNTER] {
|
|
||||||
conn.execute(
|
|
||||||
&format!(
|
|
||||||
"CREATE TABLE IF NOT EXISTS {} (
|
|
||||||
k BYTEA PRIMARY KEY,
|
|
||||||
v BIGINT NOT NULL DEFAULT 0
|
|
||||||
)",
|
|
||||||
char::from(table)
|
|
||||||
),
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(into_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn create_search_tables(&self) -> trc::Result<()> {
|
pub(crate) async fn create_search_tables(&self) -> trc::Result<()> {
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
|
let limit = self.timeouts.maintenance;
|
||||||
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
create_search_tables::<EmailSearchField>(&conn).await?;
|
||||||
|
create_search_tables::<CalendarSearchField>(&conn).await?;
|
||||||
|
create_search_tables::<ContactSearchField>(&conn).await?;
|
||||||
|
//create_search_tables::<FileSearchField>(&conn).await?;
|
||||||
|
create_search_tables::<TracingSearchField>(&conn).await?;
|
||||||
|
|
||||||
create_search_tables::<EmailSearchField>(&conn).await?;
|
Ok(())
|
||||||
create_search_tables::<CalendarSearchField>(&conn).await?;
|
})
|
||||||
create_search_tables::<ContactSearchField>(&conn).await?;
|
.await;
|
||||||
//create_search_tables::<FileSearchField>(&conn).await?;
|
bounded(conn, result, limit)
|
||||||
create_search_tables::<TracingSearchField>(&conn).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,12 +275,21 @@ async fn create_search_tables<T: SearchableField + PsqlSearchField + 'static>(
|
|||||||
for field in T::all_fields() {
|
for field in T::all_fields() {
|
||||||
if field.is_text() || field.is_json() {
|
if field.is_text() || field.is_json() {
|
||||||
let column_name = field.column();
|
let column_name = field.column();
|
||||||
|
// inbuxa: with GIN's default fastupdate=on, new entries wait in
|
||||||
|
// an unindexed pending list that every search scans in full
|
||||||
|
// until a VACUUM (or 4 MB of backlog) merges it. On a mailbox
|
||||||
|
// taking steady mail that list never drains and searches slow
|
||||||
|
// from milliseconds to hundreds of them. Pay the index update
|
||||||
|
// at insert time instead.
|
||||||
|
let index_name = format!("gin_{table_name}_{column_name}");
|
||||||
let create_index_query = format!(
|
let create_index_query = format!(
|
||||||
"CREATE INDEX IF NOT EXISTS gin_{table_name}_{column_name} ON {table_name} USING GIN({column_name})",
|
"CREATE INDEX IF NOT EXISTS {index_name} ON {table_name} USING GIN({column_name}) WITH (fastupdate = off)",
|
||||||
);
|
);
|
||||||
conn.execute(&create_index_query, &[])
|
conn.execute(&create_index_query, &[])
|
||||||
.await
|
.await
|
||||||
.map_err(into_error)?;
|
.map_err(into_error)?;
|
||||||
|
// Indexes made before this change keep fastupdate=on
|
||||||
|
disable_gin_fastupdate(conn, &index_name).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if field.is_indexed() {
|
if field.is_indexed() {
|
||||||
@@ -253,6 +306,69 @@ async fn create_search_tables<T: SearchableField + PsqlSearchField + 'static>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: turns fastupdate off on a GIN index made with the default and
|
||||||
|
/// merges the pending list it has built up. Idempotent: an index that already
|
||||||
|
/// has the option is left alone, so this costs one catalog read per index at
|
||||||
|
/// startup. A failure is logged and startup goes on, since search still works,
|
||||||
|
/// only slower.
|
||||||
|
async fn disable_gin_fastupdate(conn: &Object, index_name: &str) {
|
||||||
|
if let Err(err) = try_disable_gin_fastupdate(conn, index_name).await {
|
||||||
|
trc::event!(
|
||||||
|
Store(trc::StoreEvent::PostgresqlError),
|
||||||
|
Details = format!("Failed to turn off fastupdate on search index {index_name}"),
|
||||||
|
Reason = err.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_disable_gin_fastupdate(conn: &Object, index_name: &str) -> trc::Result<()> {
|
||||||
|
let options = conn
|
||||||
|
.query_opt(
|
||||||
|
"SELECT COALESCE(reloptions, '{}')::text[] FROM pg_class WHERE oid = to_regclass($1)",
|
||||||
|
&[&index_name],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(into_error)?
|
||||||
|
.map(|row| row.try_get::<_, Vec<String>>(0))
|
||||||
|
.transpose()
|
||||||
|
.map_err(into_error)?;
|
||||||
|
let Some(options) = options else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if gin_fastupdate_is_off(&options) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// SET (fastupdate) takes a SHARE UPDATE EXCLUSIVE lock, which doesn't
|
||||||
|
// block reads or writes. Turning it off stops new entries going to the
|
||||||
|
// pending list but doesn't flush the entries already there.
|
||||||
|
conn.execute(
|
||||||
|
&format!("ALTER INDEX {index_name} SET (fastupdate = off)"),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(into_error)?;
|
||||||
|
conn.query_one(
|
||||||
|
"SELECT gin_clean_pending_list($1::text::regclass)",
|
||||||
|
&[&index_name],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(into_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a relation's reloptions turn GIN's fastupdate off.
|
||||||
|
fn gin_fastupdate_is_off(options: &[String]) -> bool {
|
||||||
|
options.iter().any(|option| {
|
||||||
|
option.split_once('=').is_some_and(|(name, value)| {
|
||||||
|
name.trim().eq_ignore_ascii_case("fastupdate")
|
||||||
|
&& matches!(
|
||||||
|
value.trim().to_ascii_lowercase().as_str(),
|
||||||
|
"off" | "false" | "no" | "0" | "f" | "n"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn discover_ts_configs(pool: &Pool) -> AHashSet<&'static str> {
|
async fn discover_ts_configs(pool: &Pool) -> AHashSet<&'static str> {
|
||||||
let mut ts_configs = AHashSet::from_iter([PG_FALLBACK_LANG, PG_UNSTEMMED_LANG]);
|
let mut ts_configs = AHashSet::from_iter([PG_FALLBACK_LANG, PG_UNSTEMMED_LANG]);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use crate::backend::query_timeout::QueryTimeouts;
|
||||||
use crate::{
|
use crate::{
|
||||||
search::{
|
search::{
|
||||||
CalendarSearchField, ContactSearchField, EmailSearchField, FileSearchField, SearchField,
|
CalendarSearchField, ContactSearchField, EmailSearchField, FileSearchField, SearchField,
|
||||||
@@ -14,7 +15,8 @@ use crate::{
|
|||||||
write::SearchIndex,
|
write::SearchIndex,
|
||||||
};
|
};
|
||||||
use ahash::AHashSet;
|
use ahash::AHashSet;
|
||||||
use deadpool_postgres::Pool;
|
use deadpool_postgres::{Object, Pool};
|
||||||
|
use std::time::Duration;
|
||||||
use tokio_postgres::error::SqlState;
|
use tokio_postgres::error::SqlState;
|
||||||
|
|
||||||
pub mod blob;
|
pub mod blob;
|
||||||
@@ -28,6 +30,8 @@ pub mod write;
|
|||||||
pub struct PostgresStore {
|
pub struct PostgresStore {
|
||||||
pub(crate) conn_pool: Pool,
|
pub(crate) conn_pool: Pool,
|
||||||
pub(crate) ts_configs: AHashSet<&'static str>,
|
pub(crate) ts_configs: AHashSet<&'static str>,
|
||||||
|
/// inbuxa: client-side query limits (see backend::query_timeout)
|
||||||
|
pub(crate) timeouts: QueryTimeouts,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
@@ -72,6 +76,34 @@ pub(crate) fn is_timeout_error(err: &tokio_postgres::Error) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the error for an operation that ran past its time limit.
|
||||||
|
pub(crate) fn query_timeout_error(limit: Duration) -> trc::Error {
|
||||||
|
trc::StoreEvent::PostgresqlError
|
||||||
|
.reason("Query timed out")
|
||||||
|
.details(format!(
|
||||||
|
"No answer from the database within {} s",
|
||||||
|
limit.as_secs()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: ends an operation run on `conn` under `limit`. When it ran out,
|
||||||
|
/// the connection is taken out of the pool and closed: a query may still be
|
||||||
|
/// in flight on it, or a transaction open, so it can't be handed to the
|
||||||
|
/// next caller.
|
||||||
|
pub(crate) fn bounded<T>(
|
||||||
|
conn: Object,
|
||||||
|
result: Result<trc::Result<T>, tokio::time::error::Elapsed>,
|
||||||
|
limit: Duration,
|
||||||
|
) -> trc::Result<T> {
|
||||||
|
match result {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => {
|
||||||
|
drop(Object::take(conn));
|
||||||
|
Err(query_timeout_error(limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub(crate) fn into_pool_error(err: deadpool_postgres::PoolError) -> trc::Error {
|
pub(crate) fn into_pool_error(err: deadpool_postgres::PoolError) -> trc::Error {
|
||||||
match err {
|
match err {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{PostgresStore, into_error, is_timeout_error};
|
use super::{PostgresStore, bounded, into_error, is_timeout_error, query_timeout_error};
|
||||||
use crate::{
|
use crate::{
|
||||||
Deserialize, IterateParams, Key, ValueKey, backend::postgres::into_pool_error,
|
Deserialize, IterateParams, Key, ValueKey, backend::postgres::into_pool_error,
|
||||||
write::ValueClass,
|
write::ValueClass,
|
||||||
@@ -17,40 +19,50 @@ impl PostgresStore {
|
|||||||
U: Deserialize + 'static,
|
U: Deserialize + 'static,
|
||||||
{
|
{
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prepare_cached(&format!(
|
let result = tokio::time::timeout(limit, async {
|
||||||
"SELECT v FROM {} WHERE k = $1",
|
let s = conn
|
||||||
char::from(key.subspace())
|
.prepare_cached(&format!(
|
||||||
))
|
"SELECT v FROM {} WHERE k = $1",
|
||||||
.await
|
char::from(key.subspace())
|
||||||
.map_err(into_error)?;
|
))
|
||||||
let key = key.serialize(0);
|
.await
|
||||||
conn.query_opt(&s, &[&key])
|
.map_err(into_error)?;
|
||||||
.await
|
let key = key.serialize(0);
|
||||||
.map_err(into_error)
|
conn.query_opt(&s, &[&key])
|
||||||
.and_then(|r| {
|
.await
|
||||||
if let Some(r) = r {
|
.map_err(into_error)
|
||||||
Ok(Some(U::deserialize_with_key(&key, r.get(0))?))
|
.and_then(|r| {
|
||||||
} else {
|
if let Some(r) = r {
|
||||||
Ok(None)
|
Ok(Some(U::deserialize_with_key(&key, r.get(0))?))
|
||||||
}
|
} else {
|
||||||
})
|
Ok(None)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result<bool> {
|
pub(crate) async fn key_exists(&self, key: impl Key) -> trc::Result<bool> {
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prepare_cached(&format!(
|
let result = tokio::time::timeout(limit, async {
|
||||||
"SELECT 1 FROM {} WHERE k = $1",
|
let s = conn
|
||||||
char::from(key.subspace())
|
.prepare_cached(&format!(
|
||||||
))
|
"SELECT 1 FROM {} WHERE k = $1",
|
||||||
.await
|
char::from(key.subspace())
|
||||||
.map_err(into_error)?;
|
))
|
||||||
let key = key.serialize(0);
|
.await
|
||||||
conn.query_opt(&s, &[&key])
|
.map_err(into_error)?;
|
||||||
.await
|
let key = key.serialize(0);
|
||||||
.map_err(into_error)
|
conn.query_opt(&s, &[&key])
|
||||||
.map(|r| r.is_some())
|
.await
|
||||||
|
.map_err(into_error)
|
||||||
|
.map(|r| r.is_some())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn iterate<T: Key>(
|
pub(crate) async fn iterate<T: Key>(
|
||||||
@@ -64,44 +76,65 @@ impl PostgresStore {
|
|||||||
let end = params.end.serialize(0);
|
let end = params.end.serialize(0);
|
||||||
let keys = if params.values { "k, v" } else { "k" };
|
let keys = if params.values { "k, v" } else { "k" };
|
||||||
|
|
||||||
let s = conn
|
// inbuxa: a scan may run for hours, so the query limit bounds each
|
||||||
.prepare_cached(&match (params.first, params.ascending) {
|
// wait for the database (preparing, the query starting, the next
|
||||||
(true, true) => {
|
// row) rather than the scan. A wait that runs out closes the
|
||||||
format!(
|
// connection.
|
||||||
"SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC LIMIT 1"
|
let limit = self.timeouts.query;
|
||||||
)
|
let query = match (params.first, params.ascending) {
|
||||||
}
|
(true, true) => {
|
||||||
(true, false) => {
|
format!(
|
||||||
format!(
|
"SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
format!(
|
||||||
"SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k DESC LIMIT 1"
|
"SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k DESC LIMIT 1"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
(false, true) => {
|
(false, true) => {
|
||||||
format!("SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC")
|
format!("SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k ASC")
|
||||||
}
|
}
|
||||||
(false, false) => {
|
(false, false) => {
|
||||||
format!("SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k DESC")
|
format!("SELECT {keys} FROM {table} WHERE k >= $1 AND k <= $2 ORDER BY k DESC")
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
.await.map_err(into_error)?;
|
let s = match tokio::time::timeout(limit, conn.prepare_cached(&query)).await {
|
||||||
|
Ok(s) => s.map_err(into_error)?,
|
||||||
|
Err(_) => {
|
||||||
|
drop(deadpool_postgres::Object::take(conn));
|
||||||
|
return Err(query_timeout_error(limit));
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut from = begin;
|
let mut from = begin;
|
||||||
let mut to = end;
|
let mut to = end;
|
||||||
let mut resume_key: Option<Vec<u8>> = None;
|
let mut resume_key: Option<Vec<u8>> = None;
|
||||||
|
|
||||||
|
let mut stalled = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let mut last_key = None;
|
let mut last_key = None;
|
||||||
let mut timed_out = false;
|
let mut timed_out = false;
|
||||||
|
|
||||||
{
|
{
|
||||||
let rows = conn
|
let rows =
|
||||||
.query_raw(&s, &[&from, &to])
|
match tokio::time::timeout(limit, conn.query_raw(&s, &[&from, &to])).await {
|
||||||
.await
|
Ok(rows) => rows.map_err(into_error)?,
|
||||||
.map_err(into_error)?;
|
// Leaves the scan loop for the timeout below
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
|
||||||
pin_mut!(rows);
|
pin_mut!(rows);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match rows.try_next().await {
|
let next = match tokio::time::timeout(limit, rows.try_next()).await {
|
||||||
|
Ok(next) => next,
|
||||||
|
Err(_) => {
|
||||||
|
stalled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match next {
|
||||||
Ok(Some(row)) => {
|
Ok(Some(row)) => {
|
||||||
let key = row.try_get::<_, &[u8]>(0).map_err(into_error)?;
|
let key = row.try_get::<_, &[u8]>(0).map_err(into_error)?;
|
||||||
let value = if params.values {
|
let value = if params.values {
|
||||||
@@ -132,6 +165,10 @@ impl PostgresStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if stalled {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
match last_key {
|
match last_key {
|
||||||
Some(last_key) if timed_out => {
|
Some(last_key) if timed_out => {
|
||||||
if params.ascending {
|
if params.ascending {
|
||||||
@@ -144,6 +181,9 @@ impl PostgresStore {
|
|||||||
_ => return Ok(()),
|
_ => return Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drop(deadpool_postgres::Object::take(conn));
|
||||||
|
Err(query_timeout_error(limit))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn get_counter(
|
pub(crate) async fn get_counter(
|
||||||
@@ -155,14 +195,19 @@ impl PostgresStore {
|
|||||||
let key = key.serialize(0);
|
let key = key.serialize(0);
|
||||||
|
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let s = conn
|
let limit = self.timeouts.query;
|
||||||
.prepare_cached(&format!("SELECT v FROM {table} WHERE k = $1"))
|
let result = tokio::time::timeout(limit, async {
|
||||||
.await
|
let s = conn
|
||||||
.map_err(into_error)?;
|
.prepare_cached(&format!("SELECT v FROM {table} WHERE k = $1"))
|
||||||
match conn.query_opt(&s, &[&key]).await {
|
.await
|
||||||
Ok(Some(row)) => row.try_get(0).map_err(into_error),
|
.map_err(into_error)?;
|
||||||
Ok(None) => Ok(0),
|
match conn.query_opt(&s, &[&key]).await {
|
||||||
Err(e) => Err(into_error(e)),
|
Ok(Some(row)) => row.try_get(0).map_err(into_error),
|
||||||
}
|
Ok(None) => Ok(0),
|
||||||
|
Err(e) => Err(into_error(e)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,17 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
backend::postgres::{
|
backend::{
|
||||||
DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, PostgresStore, PsqlSearchField, into_error,
|
MAX_TOKEN_LENGTH,
|
||||||
into_pool_error, is_timeout_error,
|
postgres::{
|
||||||
|
DELETE_CHUNK_SIZE, MIN_DELETE_CHUNK_SIZE, PostgresStore, PsqlSearchField, bounded,
|
||||||
|
into_error, into_pool_error, is_timeout_error,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
search::{
|
search::{
|
||||||
IndexDocument, SearchComparator, SearchDocumentId, SearchFilter, SearchOperator,
|
IndexDocument, SearchComparator, SearchDocumentId, SearchFilter, SearchOperator,
|
||||||
@@ -15,7 +20,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
write::SearchIndex,
|
write::SearchIndex,
|
||||||
};
|
};
|
||||||
use nlp::language::Language;
|
use nlp::{language::Language, tokenizers::space::SpaceTokenizer};
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use tokio_postgres::{
|
use tokio_postgres::{
|
||||||
IsolationLevel,
|
IsolationLevel,
|
||||||
@@ -31,99 +36,130 @@ impl PostgresStore {
|
|||||||
|
|
||||||
pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> {
|
pub async fn index(&self, documents: Vec<IndexDocument>) -> trc::Result<()> {
|
||||||
let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let trx = conn
|
let limit = self.timeouts.query;
|
||||||
.build_transaction()
|
let result = tokio::time::timeout(limit, async {
|
||||||
.isolation_level(IsolationLevel::ReadCommitted)
|
let trx = conn
|
||||||
.start()
|
.build_transaction()
|
||||||
.await
|
.isolation_level(IsolationLevel::ReadCommitted)
|
||||||
.map_err(into_error)?;
|
.start()
|
||||||
|
.await
|
||||||
|
.map_err(into_error)?;
|
||||||
|
|
||||||
for document in documents {
|
for document in documents {
|
||||||
let index = document.index;
|
let index = document.index;
|
||||||
let primary_keys = index.primary_keys();
|
let primary_keys = index.primary_keys();
|
||||||
let all_fields = index.all_fields();
|
let all_fields = index.all_fields();
|
||||||
let fields = document.fields;
|
let fields = document.fields;
|
||||||
let mut values = Vec::with_capacity(fields.len() + 2);
|
// inbuxa: keyword text (addresses, contact fields, ...) is split into
|
||||||
let mut query = format!("INSERT INTO {} (", index.psql_table());
|
// words before it reaches the text parser, see keyword_terms().
|
||||||
|
let keywords = primary_keys
|
||||||
|
.iter()
|
||||||
|
.chain(all_fields)
|
||||||
|
.map(|field| match fields.get(field) {
|
||||||
|
Some(SearchValue::Text {
|
||||||
|
value,
|
||||||
|
language: Language::None,
|
||||||
|
}) if field.is_text() => Some(keyword_terms(value)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut values = Vec::with_capacity(fields.len() + 2);
|
||||||
|
let mut query = format!("INSERT INTO {} (", index.psql_table());
|
||||||
|
|
||||||
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
|
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
query.push(',');
|
query.push(',');
|
||||||
}
|
}
|
||||||
query.push_str(field.column());
|
query.push_str(field.column());
|
||||||
|
|
||||||
if let Some(sort_column) = field.sort_column() {
|
if let Some(sort_column) = field.sort_column() {
|
||||||
query.push(',');
|
query.push(',');
|
||||||
query.push_str(sort_column);
|
query.push_str(sort_column);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
query.push_str(") VALUES (");
|
|
||||||
|
|
||||||
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
query.push(',');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(value) = fields.get(field) {
|
query.push_str(") VALUES (");
|
||||||
let value_ref = format!("${}", values.len() + 1);
|
|
||||||
let (text_len, language) = if let SearchValue::Text { value, language } = value
|
|
||||||
{
|
|
||||||
(value.len(), self.ts_config(language))
|
|
||||||
} else {
|
|
||||||
(0, PG_UNSTEMMED_LANG)
|
|
||||||
};
|
|
||||||
|
|
||||||
if field.is_text() {
|
for (i, field) in primary_keys.iter().chain(all_fields).enumerate() {
|
||||||
let _ = write!(&mut query, "to_tsvector('{language}',{value_ref})");
|
if i > 0 {
|
||||||
} else if text_len > 512 {
|
query.push(',');
|
||||||
query.push_str("left(");
|
|
||||||
query.push_str(&value_ref);
|
|
||||||
query.push_str(",512)");
|
|
||||||
} else {
|
|
||||||
query.push_str(&value_ref);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if field.sort_column().is_some() {
|
if let Some(value) = fields.get(field) {
|
||||||
if text_len > 255 {
|
let value_ref = format!("${}", values.len() + 1);
|
||||||
query.push_str(",left(");
|
let (text_len, language) =
|
||||||
|
if let SearchValue::Text { value, language } = value {
|
||||||
|
(value.len(), self.ts_config(language))
|
||||||
|
} else {
|
||||||
|
(0, PG_UNSTEMMED_LANG)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(keywords) = &keywords[i] {
|
||||||
|
let _ = write!(&mut query, "to_tsvector('{language}',{value_ref})");
|
||||||
|
values.push(keywords as &(dyn ToSql + Sync));
|
||||||
|
if field.sort_column().is_some() {
|
||||||
|
let value_ref = format!("${}", values.len() + 1);
|
||||||
|
if text_len > 255 {
|
||||||
|
let _ = write!(&mut query, ",left({value_ref},255)");
|
||||||
|
} else {
|
||||||
|
let _ = write!(&mut query, ",{value_ref}");
|
||||||
|
}
|
||||||
|
values.push(value as &(dyn ToSql + Sync));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
} else if field.is_text() {
|
||||||
|
let _ = write!(&mut query, "to_tsvector('{language}',{value_ref})");
|
||||||
|
} else if text_len > 512 {
|
||||||
|
query.push_str("left(");
|
||||||
query.push_str(&value_ref);
|
query.push_str(&value_ref);
|
||||||
query.push_str(",255)");
|
query.push_str(",512)");
|
||||||
} else {
|
} else {
|
||||||
query.push(',');
|
|
||||||
query.push_str(&value_ref);
|
query.push_str(&value_ref);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
values.push(value as &(dyn ToSql + Sync));
|
if field.sort_column().is_some() {
|
||||||
} else {
|
if text_len > 255 {
|
||||||
query.push_str("NULL");
|
query.push_str(",left(");
|
||||||
if field.sort_column().is_some() {
|
query.push_str(&value_ref);
|
||||||
query.push_str(",NULL");
|
query.push_str(",255)");
|
||||||
|
} else {
|
||||||
|
query.push(',');
|
||||||
|
query.push_str(&value_ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(value as &(dyn ToSql + Sync));
|
||||||
|
} else {
|
||||||
|
query.push_str("NULL");
|
||||||
|
if field.sort_column().is_some() {
|
||||||
|
query.push_str(",NULL");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
query.push_str(") ON CONFLICT (");
|
query.push_str(") ON CONFLICT (");
|
||||||
for (i, pkey) in primary_keys.iter().enumerate() {
|
for (i, pkey) in primary_keys.iter().enumerate() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
query.push(',');
|
query.push(',');
|
||||||
|
}
|
||||||
|
query.push_str(pkey.column());
|
||||||
}
|
}
|
||||||
query.push_str(pkey.column());
|
query.push_str(") DO UPDATE SET ");
|
||||||
}
|
for (i, field) in all_fields.iter().enumerate() {
|
||||||
query.push_str(") DO UPDATE SET ");
|
if i > 0 {
|
||||||
for (i, field) in all_fields.iter().enumerate() {
|
query.push(',');
|
||||||
if i > 0 {
|
}
|
||||||
query.push(',');
|
let column = field.column();
|
||||||
|
let _ = write!(&mut query, "{column} = EXCLUDED.{column}");
|
||||||
}
|
}
|
||||||
let column = field.column();
|
|
||||||
let _ = write!(&mut query, "{column} = EXCLUDED.{column}");
|
trx.execute(&query, &values).await.map_err(into_error)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
trx.execute(&query, &values).await.map_err(into_error)?;
|
trx.commit().await.map_err(into_error)
|
||||||
}
|
})
|
||||||
|
.await;
|
||||||
trx.commit().await.map_err(into_error)
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn query<R: SearchDocumentId>(
|
pub async fn query<R: SearchDocumentId>(
|
||||||
@@ -134,20 +170,26 @@ impl PostgresStore {
|
|||||||
) -> trc::Result<Vec<R>> {
|
) -> trc::Result<Vec<R>> {
|
||||||
let mut query = format!("SELECT {} FROM {}", R::field().column(), index.psql_table());
|
let mut query = format!("SELECT {} FROM {}", R::field().column(), index.psql_table());
|
||||||
let params = self.build_filter(&mut query, filters);
|
let params = self.build_filter(&mut query, filters);
|
||||||
|
let params = params.iter().map(SqlParam::as_sql).collect::<Vec<_>>();
|
||||||
if !sort.is_empty() {
|
if !sort.is_empty() {
|
||||||
build_sort(&mut query, sort);
|
build_sort(&mut query, sort);
|
||||||
}
|
}
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let s = conn.prepare_cached(&query).await.map_err(into_error)?;
|
let limit = self.timeouts.query;
|
||||||
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
let s = conn.prepare_cached(&query).await.map_err(into_error)?;
|
||||||
|
|
||||||
conn.query(&s, params.as_slice())
|
conn.query(&s, params.as_slice())
|
||||||
.await
|
.await
|
||||||
.and_then(|rows| {
|
.and_then(|rows| {
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|row| row.try_get::<_, DocId>(0).map(|v| R::from_u64(v.0)))
|
.map(|row| row.try_get::<_, DocId>(0).map(|v| R::from_u64(v.0)))
|
||||||
.collect::<Result<Vec<R>, _>>()
|
.collect::<Result<Vec<R>, _>>()
|
||||||
})
|
})
|
||||||
.map_err(into_error)
|
.map_err(into_error)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unindex(&self, filter: SearchQuery) -> trc::Result<u64> {
|
pub async fn unindex(&self, filter: SearchQuery) -> trc::Result<u64> {
|
||||||
@@ -155,48 +197,54 @@ impl PostgresStore {
|
|||||||
let table = filter.index.psql_table();
|
let table = filter.index.psql_table();
|
||||||
let mut where_clause = String::new();
|
let mut where_clause = String::new();
|
||||||
let params = self.build_filter(&mut where_clause, &filter.filters);
|
let params = self.build_filter(&mut where_clause, &filter.filters);
|
||||||
|
let params = params.iter().map(SqlParam::as_sql).collect::<Vec<_>>();
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let s = conn
|
let limit = self.timeouts.maintenance;
|
||||||
.prepare_cached(&format!("DELETE FROM {table}{where_clause}"))
|
let result = tokio::time::timeout(limit, async {
|
||||||
.await
|
|
||||||
.map_err(into_error)?;
|
|
||||||
|
|
||||||
match conn.execute(&s, params.as_slice()).await {
|
|
||||||
Ok(deleted) => return Ok(deleted),
|
|
||||||
Err(err) if is_timeout_error(&err) => (),
|
|
||||||
Err(err) => return Err(into_error(err)),
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut chunk_size = DELETE_CHUNK_SIZE;
|
|
||||||
let mut deleted = 0;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let s = conn
|
let s = conn
|
||||||
.prepare_cached(&format!(
|
.prepare_cached(&format!("DELETE FROM {table}{where_clause}"))
|
||||||
"DELETE FROM {table} WHERE ctid IN (SELECT ctid FROM {table}{where_clause} LIMIT {chunk_size})"
|
|
||||||
))
|
|
||||||
.await
|
.await
|
||||||
.map_err(into_error)?;
|
.map_err(into_error)?;
|
||||||
|
|
||||||
|
match conn.execute(&s, params.as_slice()).await {
|
||||||
|
Ok(deleted) => return Ok(deleted),
|
||||||
|
Err(err) if is_timeout_error(&err) => (),
|
||||||
|
Err(err) => return Err(into_error(err)),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunk_size = DELETE_CHUNK_SIZE;
|
||||||
|
let mut deleted = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match conn.execute(&s, params.as_slice()).await {
|
let s = conn
|
||||||
Ok(0) => return Ok(deleted),
|
.prepare_cached(&format!(
|
||||||
Ok(affected) => deleted += affected,
|
"DELETE FROM {table} WHERE ctid IN (SELECT ctid FROM {table}{where_clause} LIMIT {chunk_size})"
|
||||||
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
))
|
||||||
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
.await
|
||||||
break;
|
.map_err(into_error)?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match conn.execute(&s, params.as_slice()).await {
|
||||||
|
Ok(0) => return Ok(deleted),
|
||||||
|
Ok(affected) => deleted += affected,
|
||||||
|
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
||||||
|
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => return Err(into_error(err)),
|
||||||
}
|
}
|
||||||
Err(err) => return Err(into_error(err)),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_filter<'x>(
|
fn build_filter<'x>(
|
||||||
&self,
|
&self,
|
||||||
query: &mut String,
|
query: &mut String,
|
||||||
filters: &'x [SearchFilter],
|
filters: &'x [SearchFilter],
|
||||||
) -> Vec<&'x (dyn ToSql + Sync)> {
|
) -> Vec<SqlParam<'x>> {
|
||||||
if filters.is_empty() {
|
if filters.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
@@ -237,6 +285,10 @@ impl PostgresStore {
|
|||||||
|
|
||||||
if matches!(language, Language::None) {
|
if matches!(language, Language::None) {
|
||||||
let _ = write!(query, "@@ {method}('{config}', ${value_pos})");
|
let _ = write!(query, "@@ {method}('{config}', ${value_pos})");
|
||||||
|
if let SearchValue::Text { value, .. } = value {
|
||||||
|
values.push(SqlParam::Owned(keyword_terms(value)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = write!(query, "@@ ({method}('{config}', ${value_pos})");
|
let _ = write!(query, "@@ ({method}('{config}', ${value_pos})");
|
||||||
for fallback in [PG_FALLBACK_LANG, PG_UNSTEMMED_LANG] {
|
for fallback in [PG_FALLBACK_LANG, PG_UNSTEMMED_LANG] {
|
||||||
@@ -247,18 +299,18 @@ impl PostgresStore {
|
|||||||
}
|
}
|
||||||
query.push(')');
|
query.push(')');
|
||||||
}
|
}
|
||||||
values.push(value as &(dyn ToSql + Sync));
|
values.push(SqlParam::Ref(value));
|
||||||
} else if let SearchValue::KeyValues(kv) = value {
|
} else if let SearchValue::KeyValues(kv) = value {
|
||||||
query.push_str(field.column());
|
query.push_str(field.column());
|
||||||
query.push(' ');
|
query.push(' ');
|
||||||
|
|
||||||
let (key, value) = kv.iter().next().unwrap();
|
let (key, value) = kv.iter().next().unwrap();
|
||||||
values.push(key as &(dyn ToSql + Sync));
|
values.push(SqlParam::Ref(key));
|
||||||
|
|
||||||
if !value.is_empty() {
|
if !value.is_empty() {
|
||||||
let _ = write!(query, "->> ${value_pos} ");
|
let _ = write!(query, "->> ${value_pos} ");
|
||||||
op.write_pqsql(query, values.len() + 1);
|
op.write_pqsql(query, values.len() + 1);
|
||||||
values.push(value as &(dyn ToSql + Sync));
|
values.push(SqlParam::Ref(value));
|
||||||
} else {
|
} else {
|
||||||
let _ = write!(query, " ? ${value_pos}");
|
let _ = write!(query, " ? ${value_pos}");
|
||||||
}
|
}
|
||||||
@@ -267,7 +319,7 @@ impl PostgresStore {
|
|||||||
query.push(' ');
|
query.push(' ');
|
||||||
|
|
||||||
op.write_pqsql(query, value_pos);
|
op.write_pqsql(query, value_pos);
|
||||||
values.push(value as &(dyn ToSql + Sync));
|
values.push(SqlParam::Ref(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SearchFilter::And | SearchFilter::Or => {
|
SearchFilter::And | SearchFilter::Or => {
|
||||||
@@ -321,6 +373,38 @@ impl PostgresStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: PostgreSQL's text parser keeps "[email protected]" (and host names,
|
||||||
|
// URLs, file paths, ...) as a single token, so a search for "user" or
|
||||||
|
// "example.com" never matched an address. Keyword text is split into words the
|
||||||
|
// same way the built-in index splits it (SpaceTokenizer: lowercase runs of
|
||||||
|
// alphanumerics) on both the indexing and the query side, so a full address,
|
||||||
|
// its local part, its domain and the display-name words all match, as they do
|
||||||
|
// on the other backends.
|
||||||
|
pub(crate) fn keyword_terms(value: &str) -> String {
|
||||||
|
let mut terms = String::with_capacity(value.len());
|
||||||
|
for token in SpaceTokenizer::new(value, MAX_TOKEN_LENGTH) {
|
||||||
|
if !terms.is_empty() {
|
||||||
|
terms.push(' ');
|
||||||
|
}
|
||||||
|
terms.push_str(&token);
|
||||||
|
}
|
||||||
|
terms
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) enum SqlParam<'x> {
|
||||||
|
Ref(&'x (dyn ToSql + Sync)),
|
||||||
|
Owned(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqlParam<'_> {
|
||||||
|
fn as_sql(&self) -> &(dyn ToSql + Sync) {
|
||||||
|
match self {
|
||||||
|
SqlParam::Ref(value) => *value,
|
||||||
|
SqlParam::Owned(value) => value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_sort(query: &mut String, sort: &[SearchComparator]) {
|
fn build_sort(query: &mut String, sort: &[SearchComparator]) {
|
||||||
query.push_str(" ORDER BY ");
|
query.push_str(" ORDER BY ");
|
||||||
for (i, comparator) in sort.iter().enumerate() {
|
for (i, comparator) in sort.iter().enumerate() {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{PostgresStore, into_error, is_timeout_error};
|
use super::{PostgresStore, bounded, into_error, is_timeout_error};
|
||||||
use crate::{
|
use crate::{
|
||||||
IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA,
|
IndexKey, Key, LogKey, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER, SUBSPACE_QUOTA,
|
||||||
SUBSPACE_REGISTRY_IDX,
|
SUBSPACE_REGISTRY_IDX,
|
||||||
@@ -30,48 +32,53 @@ enum CommitError {
|
|||||||
impl PostgresStore {
|
impl PostgresStore {
|
||||||
pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result<AssignedIds> {
|
pub(crate) async fn write(&self, mut batch: Batch<'_>) -> trc::Result<AssignedIds> {
|
||||||
let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let mut conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let start = Instant::now();
|
let limit = self.timeouts.query;
|
||||||
let mut retry_count = 0;
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut retry_count = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match self.write_trx(&mut conn, &mut batch).await {
|
match self.write_trx(&mut conn, &mut batch).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
match err {
|
|
||||||
CommitError::Postgres(err) => match err.code() {
|
|
||||||
Some(
|
|
||||||
&SqlState::T_R_SERIALIZATION_FAILURE
|
|
||||||
| &SqlState::T_R_DEADLOCK_DETECTED,
|
|
||||||
) if retry_count < MAX_COMMIT_ATTEMPTS
|
|
||||||
&& start.elapsed() < MAX_COMMIT_TIME => {}
|
|
||||||
Some(&SqlState::UNIQUE_VIOLATION) => {
|
|
||||||
return Err(trc::StoreEvent::AssertValueFailed
|
|
||||||
.into_err()
|
|
||||||
.reason("Unique violation")
|
|
||||||
.caused_by(trc::location!()));
|
|
||||||
}
|
|
||||||
_ => return Err(into_error(err)),
|
|
||||||
},
|
|
||||||
CommitError::Internal(err) => return Err(err),
|
|
||||||
/*CommitError::Retry => {
|
|
||||||
if retry_count > MAX_COMMIT_ATTEMPTS
|
|
||||||
|| start.elapsed() > MAX_COMMIT_TIME
|
|
||||||
{
|
|
||||||
return Err(trc::StoreEvent::AssertValueFailed
|
|
||||||
.into_err()
|
|
||||||
.caused_by(trc::location!()));
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
Err(err) => {
|
||||||
|
match err {
|
||||||
|
CommitError::Postgres(err) => match err.code() {
|
||||||
|
Some(
|
||||||
|
&SqlState::T_R_SERIALIZATION_FAILURE
|
||||||
|
| &SqlState::T_R_DEADLOCK_DETECTED,
|
||||||
|
) if retry_count < MAX_COMMIT_ATTEMPTS
|
||||||
|
&& start.elapsed() < MAX_COMMIT_TIME => {}
|
||||||
|
Some(&SqlState::UNIQUE_VIOLATION) => {
|
||||||
|
return Err(trc::StoreEvent::AssertValueFailed
|
||||||
|
.into_err()
|
||||||
|
.reason("Unique violation")
|
||||||
|
.caused_by(trc::location!()));
|
||||||
|
}
|
||||||
|
_ => return Err(into_error(err)),
|
||||||
|
},
|
||||||
|
CommitError::Internal(err) => return Err(err),
|
||||||
|
/*CommitError::Retry => {
|
||||||
|
if retry_count > MAX_COMMIT_ATTEMPTS
|
||||||
|
|| start.elapsed() > MAX_COMMIT_TIME
|
||||||
|
{
|
||||||
|
return Err(trc::StoreEvent::AssertValueFailed
|
||||||
|
.into_err()
|
||||||
|
.caused_by(trc::location!()));
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
|
||||||
let backoff = rand::rng().random_range(50..=300);
|
let backoff = rand::rng().random_range(50..=300);
|
||||||
tokio::time::sleep(Duration::from_millis(backoff)).await;
|
tokio::time::sleep(Duration::from_millis(backoff)).await;
|
||||||
retry_count += 1;
|
retry_count += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_trx(
|
async fn write_trx(
|
||||||
@@ -393,72 +400,81 @@ impl PostgresStore {
|
|||||||
|
|
||||||
pub(crate) async fn purge_store(&self) -> trc::Result<()> {
|
pub(crate) async fn purge_store(&self) -> trc::Result<()> {
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
|
let limit = self.timeouts.maintenance;
|
||||||
|
let result = tokio::time::timeout(limit, async {
|
||||||
|
for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] {
|
||||||
|
purge_table(&conn, char::from(subspace)).await?;
|
||||||
|
}
|
||||||
|
|
||||||
for subspace in [SUBSPACE_QUOTA, SUBSPACE_COUNTER, SUBSPACE_IN_MEMORY_COUNTER] {
|
Ok(())
|
||||||
purge_table(&conn, char::from(subspace)).await?;
|
})
|
||||||
}
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> {
|
pub(crate) async fn delete_range(&self, from: impl Key, to: impl Key) -> trc::Result<()> {
|
||||||
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
let conn = self.conn_pool.get().await.map_err(into_pool_error)?;
|
||||||
let table = char::from(from.subspace());
|
let limit = self.timeouts.maintenance;
|
||||||
let mut from = from.serialize(0);
|
let result = tokio::time::timeout(limit, async {
|
||||||
let to = to.serialize(0);
|
let table = char::from(from.subspace());
|
||||||
|
let mut from = from.serialize(0);
|
||||||
|
let to = to.serialize(0);
|
||||||
|
|
||||||
let delete = conn
|
let delete = conn
|
||||||
.prepare_cached(&format!("DELETE FROM {table} WHERE k >= $1 AND k < $2"))
|
.prepare_cached(&format!("DELETE FROM {table} WHERE k >= $1 AND k < $2"))
|
||||||
.await
|
|
||||||
.map_err(into_error)?;
|
|
||||||
|
|
||||||
match conn.execute(&delete, &[&from, &to]).await {
|
|
||||||
Ok(_) => return Ok(()),
|
|
||||||
Err(err) if is_timeout_error(&err) => (),
|
|
||||||
Err(err) => return Err(into_error(err)),
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut chunk_size = DELETE_CHUNK_SIZE;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let boundary = conn
|
|
||||||
.prepare_cached(&format!(
|
|
||||||
"SELECT k FROM {table} WHERE k >= $1 AND k < $2 ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}"
|
|
||||||
))
|
|
||||||
.await
|
.await
|
||||||
.map_err(into_error)?;
|
.map_err(into_error)?;
|
||||||
|
|
||||||
|
match conn.execute(&delete, &[&from, &to]).await {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(err) if is_timeout_error(&err) => (),
|
||||||
|
Err(err) => return Err(into_error(err)),
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut chunk_size = DELETE_CHUNK_SIZE;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let next = match conn.query_opt(&boundary, &[&from, &to]).await {
|
let boundary = conn
|
||||||
Ok(next) => match next {
|
.prepare_cached(&format!(
|
||||||
Some(row) => Some(row.try_get::<_, Vec<u8>>(0).map_err(into_error)?),
|
"SELECT k FROM {table} WHERE k >= $1 AND k < $2 ORDER BY k ASC LIMIT 1 OFFSET {chunk_size}"
|
||||||
None => None,
|
))
|
||||||
},
|
|
||||||
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
|
||||||
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(err) => return Err(into_error(err)),
|
|
||||||
};
|
|
||||||
|
|
||||||
match conn
|
|
||||||
.execute(&delete, &[&from, next.as_ref().unwrap_or(&to)])
|
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(into_error)?;
|
||||||
Ok(_) => (),
|
|
||||||
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
|
||||||
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(err) => return Err(into_error(err)),
|
|
||||||
}
|
|
||||||
|
|
||||||
match next {
|
loop {
|
||||||
Some(next) => from = next,
|
let next = match conn.query_opt(&boundary, &[&from, &to]).await {
|
||||||
None => return Ok(()),
|
Ok(next) => match next {
|
||||||
|
Some(row) => Some(row.try_get::<_, Vec<u8>>(0).map_err(into_error)?),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
||||||
|
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => return Err(into_error(err)),
|
||||||
|
};
|
||||||
|
|
||||||
|
match conn
|
||||||
|
.execute(&delete, &[&from, next.as_ref().unwrap_or(&to)])
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => (),
|
||||||
|
Err(err) if is_timeout_error(&err) && chunk_size > MIN_DELETE_CHUNK_SIZE => {
|
||||||
|
chunk_size = (chunk_size / 2).max(MIN_DELETE_CHUNK_SIZE);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => return Err(into_error(err)),
|
||||||
|
}
|
||||||
|
|
||||||
|
match next {
|
||||||
|
Some(next) => from = next,
|
||||||
|
None => return Ok(()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.await;
|
||||||
|
bounded(conn, result, limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Client-side limits on SQL queries.
|
||||||
|
//!
|
||||||
|
//! The pool timeouts bound getting a connection, not using one. A database
|
||||||
|
//! that stops answering while the TCP connection stays up (a paused
|
||||||
|
//! container, a hung server whose kernel still acknowledges keepalives)
|
||||||
|
//! left a query on a checked-out connection waiting for as long as it took.
|
||||||
|
//! A server-side statement_timeout can't help there: the server that would
|
||||||
|
//! enforce it is the one not answering. So each operation on a PostgreSQL
|
||||||
|
//! or MySQL connection runs under a time limit here, and a connection whose
|
||||||
|
//! operation ran out is closed rather than put back in the pool, since its
|
||||||
|
//! protocol state is unknown.
|
||||||
|
//!
|
||||||
|
//! Two limits:
|
||||||
|
//! - `query`, two minutes, for request-path work: reads, writes, blob
|
||||||
|
//! transfers, search queries and document indexing. Those take
|
||||||
|
//! milliseconds; two minutes leaves room for a large blob over a slow
|
||||||
|
//! link and still ends a hang.
|
||||||
|
//! - `maintenance`, thirty minutes, for work that legitimately runs long in
|
||||||
|
//! one statement: range deletes (account removal, purges), unindexing,
|
||||||
|
//! and creating tables and indexes at startup.
|
||||||
|
//!
|
||||||
|
//! Iterating over a range (exports, reindexing, maintenance scans) can run
|
||||||
|
//! for hours, so there the `query` limit applies to each wait for the next
|
||||||
|
//! row instead of the whole scan.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct QueryTimeouts {
|
||||||
|
pub query: Duration,
|
||||||
|
pub maintenance: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryTimeouts {
|
||||||
|
pub const QUERY: Duration = Duration::from_secs(120);
|
||||||
|
pub const MAINTENANCE: Duration = Duration::from_secs(30 * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for QueryTimeouts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
query: Self::QUERY,
|
||||||
|
maintenance: Self::MAINTENANCE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test_mode")]
|
||||||
|
impl crate::Store {
|
||||||
|
/// Sets the query limits of a SQL store that was just built (tests only:
|
||||||
|
/// the limits aren't configurable).
|
||||||
|
pub fn with_query_timeouts(self, timeouts: QueryTimeouts) -> Self {
|
||||||
|
match self {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
crate::Store::PostgreSQL(mut store) => {
|
||||||
|
std::sync::Arc::get_mut(&mut store)
|
||||||
|
.expect("store already shared")
|
||||||
|
.timeouts = timeouts;
|
||||||
|
crate::Store::PostgreSQL(store)
|
||||||
|
}
|
||||||
|
#[cfg(feature = "mysql")]
|
||||||
|
crate::Store::MySQL(mut store) => {
|
||||||
|
std::sync::Arc::get_mut(&mut store)
|
||||||
|
.expect("store already shared")
|
||||||
|
.timeouts = timeouts;
|
||||||
|
crate::Store::MySQL(store)
|
||||||
|
}
|
||||||
|
store => store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{RedisPool, RedisStore, into_error};
|
use super::{RedisPool, RedisStore, into_error};
|
||||||
@@ -79,6 +81,30 @@ impl RedisStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: see InMemoryStore::renew_lock
|
||||||
|
pub async fn renew_lock(&self, key: &[u8], expires: u64) -> trc::Result<bool> {
|
||||||
|
match &self.pool {
|
||||||
|
RedisPool::Single(pool) => {
|
||||||
|
with_conn(pool, async |conn| {
|
||||||
|
Self::renew_lock_(conn, key, expires).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
RedisPool::Cluster(pool) => {
|
||||||
|
with_conn(pool, async |conn| {
|
||||||
|
Self::renew_lock_(conn, key, expires).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
RedisPool::Sentinel(pool) => {
|
||||||
|
with_conn(pool, async |conn| {
|
||||||
|
Self::renew_lock_(conn, key, expires).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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) => {
|
||||||
@@ -226,6 +252,22 @@ impl RedisStore {
|
|||||||
.map(|reply| reply.is_some())
|
.map(|reply| reply.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn renew_lock_(
|
||||||
|
conn: &mut impl AsyncCommands,
|
||||||
|
key: &[u8],
|
||||||
|
expires: u64,
|
||||||
|
) -> RedisResult<bool> {
|
||||||
|
redis::cmd("SET")
|
||||||
|
.arg(key)
|
||||||
|
.arg(now() + expires)
|
||||||
|
.arg("XX")
|
||||||
|
.arg("EX")
|
||||||
|
.arg(expires as i64)
|
||||||
|
.query_async::<Option<String>>(conn)
|
||||||
|
.await
|
||||||
|
.map(|reply| reply.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
async fn key_delete_(conn: &mut impl AsyncCommands, key: &[u8]) -> RedisResult<()> {
|
async fn key_delete_(conn: &mut impl AsyncCommands, key: &[u8]) -> RedisResult<()> {
|
||||||
conn.del(key).await
|
conn.del(key).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ impl RegistryStore {
|
|||||||
// variable, so it's ignored there, and loudly.
|
// variable, so it's ignored there, and loudly.
|
||||||
if !inner.env_recovery_mode && inner.env_recovery_admin.take().is_some() {
|
if !inner.env_recovery_mode && inner.env_recovery_admin.take().is_some() {
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("⚠️ INBUXA_RECOVERY_ADMIN (or STALWART_RECOVERY_ADMIN) is set, but the");
|
eprintln!("⚠️ INBUXA_RECOVERY_ADMIN is set, but the");
|
||||||
eprintln!(" server is configured and not in recovery mode, so it is ignored.");
|
eprintln!(" server is configured and not in recovery mode, so it is ignored.");
|
||||||
eprintln!(" Remove it from the environment. To use it for recovery, also set");
|
eprintln!(" Remove it from the environment. To use it for recovery, also set");
|
||||||
eprintln!(" INBUXA_RECOVERY_MODE=1.");
|
eprintln!(" INBUXA_RECOVERY_MODE=1.");
|
||||||
@@ -47,7 +47,7 @@ impl RegistryStore {
|
|||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("════════════════════════════════════════════════════════════");
|
eprintln!("════════════════════════════════════════════════════════════");
|
||||||
eprintln!("🔑 INBUXA bootstrap mode - temporary administrator account");
|
eprintln!("🔑 inbuxa bootstrap mode - temporary administrator account");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!(" username: admin");
|
eprintln!(" username: admin");
|
||||||
eprintln!(" password: {password}");
|
eprintln!(" password: {password}");
|
||||||
|
|||||||
@@ -401,6 +401,57 @@ impl InMemoryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: extends a lock this node holds to `duration` seconds from now.
|
||||||
|
/// Returns false when the lock is gone or has expired: it may have been
|
||||||
|
/// taken by someone else since, so it is left alone.
|
||||||
|
pub async fn renew_lock(&self, prefix: u8, key: &[u8], duration: u64) -> trc::Result<bool> {
|
||||||
|
match self {
|
||||||
|
InMemoryStore::Store(store) => {
|
||||||
|
let key = KeyValue::<()>::build_key(prefix, key);
|
||||||
|
let key = ValueClass::InMemory(InMemoryClass::Key(key));
|
||||||
|
let Some(lock_expiry) = store
|
||||||
|
.get_value::<u64>(ValueKey::from(key.clone()))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let now = now();
|
||||||
|
if lock_expiry <= now {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.assert_value(key.clone(), AssertValue::U64(lock_expiry));
|
||||||
|
batch.set(key, (now + duration).serialize());
|
||||||
|
match store.write(batch.build_all()).await {
|
||||||
|
Ok(_) => Ok(true),
|
||||||
|
Err(err) if err.is_assertion_failure() => Ok(false),
|
||||||
|
Err(err) => Err(err
|
||||||
|
.details("Failed to renew lock.")
|
||||||
|
.caused_by(trc::location!())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InMemoryStore::Sharded(store) => {
|
||||||
|
Box::pin(
|
||||||
|
store
|
||||||
|
.member(&KeyValue::<()>::build_key(prefix, key))
|
||||||
|
.renew_lock(prefix, key, duration),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
#[cfg(feature = "redis")]
|
||||||
|
InMemoryStore::Redis(store) => {
|
||||||
|
store
|
||||||
|
.renew_lock(&KeyValue::<()>::build_key(prefix, key), duration)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
InMemoryStore::Static(_) | InMemoryStore::Http(_) => {
|
||||||
|
Err(trc::StoreEvent::NotSupported.into_err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn remove_lock(&self, prefix: u8, key: &[u8]) -> trc::Result<()> {
|
pub async fn remove_lock(&self, prefix: u8, key: &[u8]) -> trc::Result<()> {
|
||||||
self.key_delete(KeyValue::<()>::build_key(prefix, key))
|
self.key_delete(KeyValue::<()>::build_key(prefix, key))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
|
|
||||||
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
|
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
|
||||||
// auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is
|
// auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is
|
||||||
// security.legacy-protocols-changed (LP-8)
|
// security.legacy-protocols-changed (LP-8); 644 to 646 are the cluster
|
||||||
pub const TOTAL_EVENT_COUNT: usize = 644;
|
// coordinator's connection events
|
||||||
|
pub const TOTAL_EVENT_COUNT: usize = 647;
|
||||||
pub const TOTAL_METRIC_COUNT: usize = 369;
|
pub const TOTAL_METRIC_COUNT: usize = 369;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@@ -150,6 +151,10 @@ pub enum ClusterEvent {
|
|||||||
MessageSkipped = 47,
|
MessageSkipped = 47,
|
||||||
MessageInvalid = 49,
|
MessageInvalid = 49,
|
||||||
NodeIdRenewed = 275,
|
NodeIdRenewed = 275,
|
||||||
|
// inbuxa: the coordinator's connection
|
||||||
|
CoordinatorConnected = 644,
|
||||||
|
CoordinatorDisconnected = 645,
|
||||||
|
CoordinatorError = 646,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ impl EventType {
|
|||||||
b"cluster.message-skipped" => EventType::Cluster(ClusterEvent::MessageSkipped),
|
b"cluster.message-skipped" => EventType::Cluster(ClusterEvent::MessageSkipped),
|
||||||
b"cluster.message-invalid" => EventType::Cluster(ClusterEvent::MessageInvalid),
|
b"cluster.message-invalid" => EventType::Cluster(ClusterEvent::MessageInvalid),
|
||||||
b"cluster.node-id-renewed" => EventType::Cluster(ClusterEvent::NodeIdRenewed),
|
b"cluster.node-id-renewed" => EventType::Cluster(ClusterEvent::NodeIdRenewed),
|
||||||
|
// inbuxa: coordinator connection
|
||||||
|
b"cluster.coordinator-connected" => EventType::Cluster(ClusterEvent::CoordinatorConnected),
|
||||||
|
b"cluster.coordinator-disconnected" => EventType::Cluster(ClusterEvent::CoordinatorDisconnected),
|
||||||
|
b"cluster.coordinator-error" => EventType::Cluster(ClusterEvent::CoordinatorError),
|
||||||
b"dane.authentication-success" => EventType::Dane(DaneEvent::AuthenticationSuccess),
|
b"dane.authentication-success" => EventType::Dane(DaneEvent::AuthenticationSuccess),
|
||||||
b"dane.authentication-failure" => EventType::Dane(DaneEvent::AuthenticationFailure),
|
b"dane.authentication-failure" => EventType::Dane(DaneEvent::AuthenticationFailure),
|
||||||
b"dane.no-certificates-found" => EventType::Dane(DaneEvent::NoCertificatesFound),
|
b"dane.no-certificates-found" => EventType::Dane(DaneEvent::NoCertificatesFound),
|
||||||
@@ -742,6 +746,14 @@ impl EventType {
|
|||||||
EventType::Cluster(ClusterEvent::MessageSkipped) => "cluster.message-skipped",
|
EventType::Cluster(ClusterEvent::MessageSkipped) => "cluster.message-skipped",
|
||||||
EventType::Cluster(ClusterEvent::MessageInvalid) => "cluster.message-invalid",
|
EventType::Cluster(ClusterEvent::MessageInvalid) => "cluster.message-invalid",
|
||||||
EventType::Cluster(ClusterEvent::NodeIdRenewed) => "cluster.node-id-renewed",
|
EventType::Cluster(ClusterEvent::NodeIdRenewed) => "cluster.node-id-renewed",
|
||||||
|
// inbuxa: coordinator connection
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorConnected) => {
|
||||||
|
"cluster.coordinator-connected"
|
||||||
|
}
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => {
|
||||||
|
"cluster.coordinator-disconnected"
|
||||||
|
}
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorError) => "cluster.coordinator-error",
|
||||||
EventType::Dane(DaneEvent::AuthenticationSuccess) => "dane.authentication-success",
|
EventType::Dane(DaneEvent::AuthenticationSuccess) => "dane.authentication-success",
|
||||||
EventType::Dane(DaneEvent::AuthenticationFailure) => "dane.authentication-failure",
|
EventType::Dane(DaneEvent::AuthenticationFailure) => "dane.authentication-failure",
|
||||||
EventType::Dane(DaneEvent::NoCertificatesFound) => "dane.no-certificates-found",
|
EventType::Dane(DaneEvent::NoCertificatesFound) => "dane.no-certificates-found",
|
||||||
@@ -1524,6 +1536,10 @@ impl EventType {
|
|||||||
EventType::Cluster(ClusterEvent::MessageSkipped) => 47,
|
EventType::Cluster(ClusterEvent::MessageSkipped) => 47,
|
||||||
EventType::Cluster(ClusterEvent::MessageInvalid) => 49,
|
EventType::Cluster(ClusterEvent::MessageInvalid) => 49,
|
||||||
EventType::Cluster(ClusterEvent::NodeIdRenewed) => 275,
|
EventType::Cluster(ClusterEvent::NodeIdRenewed) => 275,
|
||||||
|
// inbuxa: coordinator connection
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorConnected) => 644,
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => 645,
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorError) => 646,
|
||||||
EventType::Dane(DaneEvent::AuthenticationSuccess) => 67,
|
EventType::Dane(DaneEvent::AuthenticationSuccess) => 67,
|
||||||
EventType::Dane(DaneEvent::AuthenticationFailure) => 66,
|
EventType::Dane(DaneEvent::AuthenticationFailure) => 66,
|
||||||
EventType::Dane(DaneEvent::NoCertificatesFound) => 69,
|
EventType::Dane(DaneEvent::NoCertificatesFound) => 69,
|
||||||
@@ -2176,6 +2192,10 @@ impl EventType {
|
|||||||
47 => Some(EventType::Cluster(ClusterEvent::MessageSkipped)),
|
47 => Some(EventType::Cluster(ClusterEvent::MessageSkipped)),
|
||||||
49 => Some(EventType::Cluster(ClusterEvent::MessageInvalid)),
|
49 => Some(EventType::Cluster(ClusterEvent::MessageInvalid)),
|
||||||
275 => Some(EventType::Cluster(ClusterEvent::NodeIdRenewed)),
|
275 => Some(EventType::Cluster(ClusterEvent::NodeIdRenewed)),
|
||||||
|
// inbuxa: coordinator connection
|
||||||
|
644 => Some(EventType::Cluster(ClusterEvent::CoordinatorConnected)),
|
||||||
|
645 => Some(EventType::Cluster(ClusterEvent::CoordinatorDisconnected)),
|
||||||
|
646 => Some(EventType::Cluster(ClusterEvent::CoordinatorError)),
|
||||||
67 => Some(EventType::Dane(DaneEvent::AuthenticationSuccess)),
|
67 => Some(EventType::Dane(DaneEvent::AuthenticationSuccess)),
|
||||||
66 => Some(EventType::Dane(DaneEvent::AuthenticationFailure)),
|
66 => Some(EventType::Dane(DaneEvent::AuthenticationFailure)),
|
||||||
69 => Some(EventType::Dane(DaneEvent::NoCertificatesFound)),
|
69 => Some(EventType::Dane(DaneEvent::NoCertificatesFound)),
|
||||||
@@ -3114,6 +3134,10 @@ impl EventType {
|
|||||||
EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn,
|
EventType::Auth(AuthEvent::TooManyAttempts) => Level::Warn,
|
||||||
EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn,
|
EventType::Calendar(CalendarEvent::AlarmFailed) => Level::Warn,
|
||||||
EventType::Cluster(ClusterEvent::SubscriberDisconnected) => Level::Warn,
|
EventType::Cluster(ClusterEvent::SubscriberDisconnected) => Level::Warn,
|
||||||
|
// inbuxa: coordinator connection
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorConnected) => Level::Info,
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => Level::Warn,
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorError) => Level::Warn,
|
||||||
EventType::Delivery(DeliveryEvent::MissingOutboundHostname) => Level::Warn,
|
EventType::Delivery(DeliveryEvent::MissingOutboundHostname) => Level::Warn,
|
||||||
EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded) => Level::Warn,
|
EventType::Delivery(DeliveryEvent::ConcurrencyLimitExceeded) => Level::Warn,
|
||||||
EventType::Delivery(DeliveryEvent::RateLimitExceeded) => Level::Warn,
|
EventType::Delivery(DeliveryEvent::RateLimitExceeded) => Level::Warn,
|
||||||
@@ -3244,6 +3268,10 @@ impl EventType {
|
|||||||
EventType::Cluster(ClusterEvent::MessageSkipped) => "PubSub message skipped",
|
EventType::Cluster(ClusterEvent::MessageSkipped) => "PubSub message skipped",
|
||||||
EventType::Cluster(ClusterEvent::MessageInvalid) => "Invalid PubSub message",
|
EventType::Cluster(ClusterEvent::MessageInvalid) => "Invalid PubSub message",
|
||||||
EventType::Cluster(ClusterEvent::NodeIdRenewed) => "Node ID renewed",
|
EventType::Cluster(ClusterEvent::NodeIdRenewed) => "Node ID renewed",
|
||||||
|
// inbuxa: coordinator connection
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorConnected) => "Coordinator connected",
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorDisconnected) => "Coordinator unavailable",
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorError) => "Coordinator error",
|
||||||
EventType::Dane(DaneEvent::AuthenticationSuccess) => "DANE authentication successful",
|
EventType::Dane(DaneEvent::AuthenticationSuccess) => "DANE authentication successful",
|
||||||
EventType::Dane(DaneEvent::AuthenticationFailure) => "DANE authentication failed",
|
EventType::Dane(DaneEvent::AuthenticationFailure) => "DANE authentication failed",
|
||||||
EventType::Dane(DaneEvent::NoCertificatesFound) => "No certificates found for DANE",
|
EventType::Dane(DaneEvent::NoCertificatesFound) => "No certificates found for DANE",
|
||||||
@@ -3729,8 +3757,8 @@ impl EventType {
|
|||||||
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
|
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
|
||||||
"Legacy mail protocols switch changed"
|
"Legacy mail protocols switch changed"
|
||||||
}
|
}
|
||||||
EventType::Server(ServerEvent::Startup) => "Starting INBUXA Server",
|
EventType::Server(ServerEvent::Startup) => "Starting inbuxa Server",
|
||||||
EventType::Server(ServerEvent::Shutdown) => "Shutting down INBUXA Server",
|
EventType::Server(ServerEvent::Shutdown) => "Shutting down inbuxa Server",
|
||||||
EventType::Server(ServerEvent::StartupError) => "Server startup error",
|
EventType::Server(ServerEvent::StartupError) => "Server startup error",
|
||||||
EventType::Server(ServerEvent::ThreadError) => "Server thread error",
|
EventType::Server(ServerEvent::ThreadError) => "Server thread error",
|
||||||
EventType::Server(ServerEvent::Licensing) => "Server licensing event",
|
EventType::Server(ServerEvent::Licensing) => "Server licensing event",
|
||||||
@@ -3983,7 +4011,7 @@ impl EventType {
|
|||||||
EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error",
|
EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error",
|
||||||
// inbuxa: legacy-protocols LP-6
|
// inbuxa: legacy-protocols LP-6
|
||||||
EventType::Auth(AuthEvent::LegacyProtocolRefused) => {
|
EventType::Auth(AuthEvent::LegacyProtocolRefused) => {
|
||||||
"This server allows only INBUXA webmail and JMAP apps"
|
"This server allows only inbuxa webmail and JMAP apps"
|
||||||
}
|
}
|
||||||
EventType::Auth(AuthEvent::Error) => "Authentication error",
|
EventType::Auth(AuthEvent::Error) => "Authentication error",
|
||||||
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
||||||
@@ -4322,6 +4350,10 @@ impl EventType {
|
|||||||
EventType::Cluster(ClusterEvent::MessageSkipped),
|
EventType::Cluster(ClusterEvent::MessageSkipped),
|
||||||
EventType::Cluster(ClusterEvent::MessageInvalid),
|
EventType::Cluster(ClusterEvent::MessageInvalid),
|
||||||
EventType::Cluster(ClusterEvent::NodeIdRenewed),
|
EventType::Cluster(ClusterEvent::NodeIdRenewed),
|
||||||
|
// inbuxa: coordinator connection
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorConnected),
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorDisconnected),
|
||||||
|
EventType::Cluster(ClusterEvent::CoordinatorError),
|
||||||
EventType::Dane(DaneEvent::AuthenticationSuccess),
|
EventType::Dane(DaneEvent::AuthenticationSuccess),
|
||||||
EventType::Dane(DaneEvent::AuthenticationFailure),
|
EventType::Dane(DaneEvent::AuthenticationFailure),
|
||||||
EventType::Dane(DaneEvent::NoCertificatesFound),
|
EventType::Dane(DaneEvent::NoCertificatesFound),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! brand {
|
macro_rules! brand {
|
||||||
() => {
|
() => {
|
||||||
"INBUXA"
|
"inbuxa"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,22 +47,32 @@ macro_rules! brand_url {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reads one of the server's environment variables by its unprefixed name,
|
/// Reads one of the server's environment variables by its unprefixed name,
|
||||||
/// such as `RECOVERY_ADMIN`.
|
/// such as `RECOVERY_ADMIN`, from `INBUXA_<name>`.
|
||||||
///
|
///
|
||||||
/// `INBUXA_<name>` wins. `STALWART_<name>` is still read when the new name
|
/// The upstream prefix isn't read (SPEC §2.4). An install moved over from
|
||||||
/// isn't set, so an existing Stalwart install moves over without editing its
|
/// upstream that still sets it stops here with the variable to rename, rather
|
||||||
/// environment, and a warning says which variable to rename.
|
/// than starting on defaults the operator didn't choose.
|
||||||
pub fn env_var(name: &str) -> Result<String, std::env::VarError> {
|
pub fn env_var(name: &str) -> Result<String, std::env::VarError> {
|
||||||
match std::env::var(format!("INBUXA_{name}")) {
|
let found = std::env::var(format!("INBUXA_{name}"));
|
||||||
Err(std::env::VarError::NotPresent) => {
|
if matches!(found, Err(std::env::VarError::NotPresent))
|
||||||
let legacy = std::env::var(format!("STALWART_{name}"));
|
&& let Some(legacy) = legacy_setting(name, |var| std::env::var_os(var).is_some())
|
||||||
if legacy.is_ok() {
|
{
|
||||||
eprintln!("Warning: STALWART_{name} is deprecated; set INBUXA_{name} instead.");
|
eprintln!(
|
||||||
}
|
"Error: {legacy} is set, but inbuxa reads INBUXA_{name}. Rename it and start again \
|
||||||
legacy
|
(https://docs.inbuxa.org/install/migrating/)."
|
||||||
}
|
);
|
||||||
found => found,
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The environment prefix upstream reads. Only ever used to refuse it.
|
||||||
|
const LEGACY_ENV_PREFIX: &str = "STALWART";
|
||||||
|
|
||||||
|
/// The upstream-prefixed variable for `name`, if it's set.
|
||||||
|
fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
||||||
|
let legacy = format!("{LEGACY_ENV_PREFIX}_{name}");
|
||||||
|
is_set(&legacy).then_some(legacy)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// INBUXA's own version, dated like the rest of its family: `YYYY.M.D`, with
|
/// INBUXA's own version, dated like the rest of its family: `YYYY.M.D`, with
|
||||||
@@ -71,7 +81,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.23"
|
"2026.9.24.3"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,3 +100,16 @@ macro_rules! brand_version_full {
|
|||||||
concat!($crate::brand_version!(), " (upstream ", env!("CARGO_PKG_VERSION"), ")")
|
concat!($crate::brand_version!(), " (upstream ", env!("CARGO_PKG_VERSION"), ")")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{LEGACY_ENV_PREFIX, legacy_setting};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_upstream_setting_is_named_for_renaming() {
|
||||||
|
let old = format!("{LEGACY_ENV_PREFIX}_RECOVERY_ADMIN");
|
||||||
|
let set = |var: &str| var == old;
|
||||||
|
assert_eq!(legacy_setting("RECOVERY_ADMIN", set), Some(old.clone()));
|
||||||
|
assert_eq!(legacy_setting("HOSTNAME", set), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ pub fn build_http_client(
|
|||||||
allow_invalid_certs: bool,
|
allow_invalid_certs: bool,
|
||||||
) -> Result<Client, String> {
|
) -> Result<Client, String> {
|
||||||
let mut headers = build_http_headers(raw_headers, username, password, token, content_type)?;
|
let mut headers = build_http_headers(raw_headers, username, password, token, content_type)?;
|
||||||
headers.insert(USER_AGENT, "INBUXA/1.0.0".parse().unwrap()); // types::brand!(); utils does not depend on types
|
headers.insert(USER_AGENT, "inbuxa/1.0.0".parse().unwrap()); // types::brand!(); utils does not depend on types
|
||||||
|
|
||||||
match http_client_builder(allow_invalid_certs)
|
match http_client_builder(allow_invalid_certs)
|
||||||
.connect_timeout(timeout)
|
.connect_timeout(timeout)
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
{
|
||||||
|
"ref": "v0.16.23",
|
||||||
|
"commit": "9d1c75ab68435e4417337f768291e5f947686203",
|
||||||
|
"removed_files": [
|
||||||
|
"crates/common/src/enterprise/alerts.rs",
|
||||||
|
"crates/common/src/enterprise/config.rs",
|
||||||
|
"crates/common/src/enterprise/license.rs",
|
||||||
|
"crates/common/src/enterprise/llm.rs",
|
||||||
|
"crates/common/src/enterprise/masked.rs",
|
||||||
|
"crates/common/src/enterprise/mod.rs",
|
||||||
|
"crates/common/src/telemetry/metrics/store.rs",
|
||||||
|
"crates/common/src/telemetry/metrics/test_data.rs",
|
||||||
|
"crates/common/src/telemetry/tracers/store.rs",
|
||||||
|
"crates/http/src/api/telemetry.rs",
|
||||||
|
"crates/jmap/src/registry/mapping/archived_item.rs",
|
||||||
|
"crates/jmap/src/registry/mapping/masked_email.rs",
|
||||||
|
"crates/jmap/src/registry/mapping/telemetry.rs",
|
||||||
|
"crates/scim-proto/src/attributes.rs",
|
||||||
|
"crates/scim-proto/src/etag.rs",
|
||||||
|
"crates/scim-proto/src/filter.rs",
|
||||||
|
"crates/scim-proto/src/json.rs",
|
||||||
|
"crates/scim-proto/src/lib.rs",
|
||||||
|
"crates/scim-proto/src/message/bulk.rs",
|
||||||
|
"crates/scim-proto/src/message/error.rs",
|
||||||
|
"crates/scim-proto/src/message/list.rs",
|
||||||
|
"crates/scim-proto/src/message/mod.rs",
|
||||||
|
"crates/scim-proto/src/message/patch.rs",
|
||||||
|
"crates/scim-proto/src/message/search.rs",
|
||||||
|
"crates/scim-proto/src/path.rs",
|
||||||
|
"crates/scim-proto/src/schema/group.rs",
|
||||||
|
"crates/scim-proto/src/schema/mod.rs",
|
||||||
|
"crates/scim-proto/src/schema/spc.rs",
|
||||||
|
"crates/scim-proto/src/schema/user.rs",
|
||||||
|
"crates/scim/src/auth.rs",
|
||||||
|
"crates/scim/src/bulk.rs",
|
||||||
|
"crates/scim/src/context.rs",
|
||||||
|
"crates/scim/src/discovery.rs",
|
||||||
|
"crates/scim/src/error.rs",
|
||||||
|
"crates/scim/src/groups/get.rs",
|
||||||
|
"crates/scim/src/groups/mod.rs",
|
||||||
|
"crates/scim/src/groups/patch.rs",
|
||||||
|
"crates/scim/src/groups/set.rs",
|
||||||
|
"crates/scim/src/lib.rs",
|
||||||
|
"crates/scim/src/query/cursor.rs",
|
||||||
|
"crates/scim/src/query/mod.rs",
|
||||||
|
"crates/scim/src/request.rs",
|
||||||
|
"crates/scim/src/users/get.rs",
|
||||||
|
"crates/scim/src/users/mod.rs",
|
||||||
|
"crates/scim/src/users/patch.rs",
|
||||||
|
"crates/scim/src/users/set.rs",
|
||||||
|
"crates/spam-filter/src/analysis/llm.rs",
|
||||||
|
"crates/store/src/backend/composite/mod.rs",
|
||||||
|
"crates/store/src/backend/composite/read_replica.rs",
|
||||||
|
"crates/store/src/backend/composite/sharded_blob.rs",
|
||||||
|
"crates/store/src/backend/composite/sharded_lookup.rs",
|
||||||
|
"crates/trc/src/serializers/binary.rs",
|
||||||
|
"tests/src/directory/oidc.rs",
|
||||||
|
"tests/src/scim/auth.rs",
|
||||||
|
"tests/src/scim/bulk.rs",
|
||||||
|
"tests/src/scim/discovery.rs",
|
||||||
|
"tests/src/scim/groups.rs",
|
||||||
|
"tests/src/scim/limits.rs",
|
||||||
|
"tests/src/scim/mod.rs",
|
||||||
|
"tests/src/scim/query.rs",
|
||||||
|
"tests/src/scim/users.rs",
|
||||||
|
"tests/src/system/archiving.rs",
|
||||||
|
"tests/src/system/tenant.rs"
|
||||||
|
],
|
||||||
|
"removed_snippets": {
|
||||||
|
"crates/common/src/auth/authentication.rs": 3,
|
||||||
|
"crates/common/src/auth/mod.rs": 2,
|
||||||
|
"crates/common/src/auth/permissions.rs": 1,
|
||||||
|
"crates/common/src/cache/directory.rs": 6,
|
||||||
|
"crates/common/src/cache/invalidate.rs": 1,
|
||||||
|
"crates/common/src/cache/principals.rs": 2,
|
||||||
|
"crates/common/src/cache/reload.rs": 1,
|
||||||
|
"crates/common/src/config/mod.rs": 2,
|
||||||
|
"crates/common/src/config/telemetry.rs": 4,
|
||||||
|
"crates/common/src/lib.rs": 2,
|
||||||
|
"crates/common/src/manager/boot.rs": 1,
|
||||||
|
"crates/common/src/network/mta.rs": 1,
|
||||||
|
"crates/common/src/scripts/plugins/llm_prompt.rs": 1,
|
||||||
|
"crates/common/src/storage/quota.rs": 2,
|
||||||
|
"crates/common/src/telemetry/metrics/mod.rs": 1,
|
||||||
|
"crates/common/src/telemetry/metrics/prometheus.rs": 1,
|
||||||
|
"crates/common/src/telemetry/mod.rs": 1,
|
||||||
|
"crates/common/src/telemetry/tracers/mod.rs": 1,
|
||||||
|
"crates/http/src/api/mod.rs": 4,
|
||||||
|
"crates/http/src/auth/permissions.rs": 1,
|
||||||
|
"crates/http/src/request.rs": 4,
|
||||||
|
"crates/jmap/src/registry/get.rs": 1,
|
||||||
|
"crates/jmap/src/registry/mapping/mod.rs": 1,
|
||||||
|
"crates/jmap/src/registry/mapping/principal.rs": 3,
|
||||||
|
"crates/jmap/src/registry/mapping/queued_message.rs": 1,
|
||||||
|
"crates/jmap/src/registry/mapping/task.rs": 1,
|
||||||
|
"crates/jmap/src/registry/mod.rs": 1,
|
||||||
|
"crates/jmap/src/registry/query.rs": 1,
|
||||||
|
"crates/jmap/src/registry/set.rs": 2,
|
||||||
|
"crates/main/src/main.rs": 1,
|
||||||
|
"crates/services/src/task_manager/alarm.rs": 1,
|
||||||
|
"crates/services/src/task_manager/imip.rs": 1,
|
||||||
|
"crates/services/src/task_manager/index.rs": 2,
|
||||||
|
"crates/services/src/task_manager/maintenance.rs": 3,
|
||||||
|
"crates/services/src/task_manager/scheduler.rs": 8,
|
||||||
|
"crates/spam-filter/src/analysis/mod.rs": 1,
|
||||||
|
"crates/spam-filter/src/analysis/score.rs": 2,
|
||||||
|
"crates/store/src/backend/mod.rs": 1,
|
||||||
|
"crates/store/src/backend/mysql/main.rs": 1,
|
||||||
|
"crates/store/src/backend/postgres/main.rs": 1,
|
||||||
|
"crates/store/src/build/blob.rs": 2,
|
||||||
|
"crates/store/src/build/lookup.rs": 1,
|
||||||
|
"crates/store/src/build/memory.rs": 2,
|
||||||
|
"crates/store/src/dispatch/blob.rs": 6,
|
||||||
|
"crates/store/src/dispatch/lookup.rs": 10,
|
||||||
|
"crates/store/src/dispatch/mod.rs": 1,
|
||||||
|
"crates/store/src/dispatch/search.rs": 6,
|
||||||
|
"crates/store/src/dispatch/store.rs": 8,
|
||||||
|
"crates/store/src/lib.rs": 6,
|
||||||
|
"crates/trc/src/serializers/mod.rs": 1
|
||||||
|
},
|
||||||
|
"cargo_edits": [
|
||||||
|
{
|
||||||
|
"file": "crates/main/Cargo.toml",
|
||||||
|
"line": 50,
|
||||||
|
"before": "default = [\"rocks\", \"enterprise\"]",
|
||||||
|
"after": "default = [\"rocks\"]"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 22,
|
||||||
|
"before": "store = { path = \"../crates/store\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "store = { path = \"../crates/store\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 24,
|
||||||
|
"before": "directory = { path = \"../crates/directory\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "directory = { path = \"../crates/directory\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 25,
|
||||||
|
"before": "coordinator = { path = \"../crates/coordinator\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "coordinator = { path = \"../crates/coordinator\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 26,
|
||||||
|
"before": "jmap = { path = \"../crates/jmap\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "jmap = { path = \"../crates/jmap\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 35,
|
||||||
|
"before": "http = { path = \"../crates/http\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "http = { path = \"../crates/http\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 37,
|
||||||
|
"before": "scim = { path = \"../crates/scim\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "scim = { path = \"../crates/scim\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 39,
|
||||||
|
"before": "services = { path = \"../crates/services\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "services = { path = \"../crates/services\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 41,
|
||||||
|
"before": "smtp = { path = \"../crates/smtp\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "smtp = { path = \"../crates/smtp\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 42,
|
||||||
|
"before": "common = { path = \"../crates/common\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "common = { path = \"../crates/common\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 44,
|
||||||
|
"before": "email = { path = \"../crates/email\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "email = { path = \"../crates/email\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 45,
|
||||||
|
"before": "spam-filter = { path = \"../crates/spam-filter\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "spam-filter = { path = \"../crates/spam-filter\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 46,
|
||||||
|
"before": "trc = { path = \"../crates/trc\", features = [\"enterprise\"] }",
|
||||||
|
"after": "trc = { path = \"../crates/trc\", features = [] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/Cargo.toml",
|
||||||
|
"line": 47,
|
||||||
|
"before": "managesieve = { path = \"../crates/managesieve\", features = [\"test_mode\", \"enterprise\"] }",
|
||||||
|
"after": "managesieve = { path = \"../crates/managesieve\", features = [\"test_mode\"] }"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": ".github/workflows/ci.yml",
|
||||||
|
"line": 341,
|
||||||
|
"before": "cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\"",
|
||||||
|
"after": "cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": ".github/workflows/ci.yml",
|
||||||
|
"line": 379,
|
||||||
|
"before": "# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"foundationdb s3 redis nats enterprise\"",
|
||||||
|
"after": "# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"foundationdb s3 redis nats\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": ".github/workflows/ci.yml",
|
||||||
|
"line": 386,
|
||||||
|
"before": "cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\"",
|
||||||
|
"after": "cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": ".github/workflows/ci.yml",
|
||||||
|
"line": 442,
|
||||||
|
"before": "cargo build --release -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\"",
|
||||||
|
"after": "cargo build --release -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "Dockerfile",
|
||||||
|
"line": 22,
|
||||||
|
"before": "RUN RUSTFLAGS=\"$(cat /flags.txt)\" cargo chef cook --target \"$(cat /target.txt)\" --release --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\" --recipe-path /recipe.json",
|
||||||
|
"after": "RUN RUSTFLAGS=\"$(cat /flags.txt)\" cargo chef cook --target \"$(cat /target.txt)\" --release --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\" --recipe-path /recipe.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "Dockerfile",
|
||||||
|
"line": 24,
|
||||||
|
"before": "RUN RUSTFLAGS=\"$(cat /flags.txt)\" cargo build --target \"$(cat /target.txt)\" --release -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\"",
|
||||||
|
"after": "RUN RUSTFLAGS=\"$(cat /flags.txt)\" cargo build --target \"$(cat /target.txt)\" --release -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "Dockerfile.build",
|
||||||
|
"line": 111,
|
||||||
|
"before": "RUSTFLAGS=\"-L /usr/lib\" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"foundationdb s3 redis nats enterprise\"; \\",
|
||||||
|
"after": "RUSTFLAGS=\"-L /usr/lib\" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"foundationdb s3 redis nats\"; \\"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "Dockerfile.build",
|
||||||
|
"line": 119,
|
||||||
|
"before": "cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\"",
|
||||||
|
"after": "cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "Dockerfile.build",
|
||||||
|
"line": 132,
|
||||||
|
"before": "RUSTFLAGS=\"-L /usr/lib\" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"foundationdb s3 redis nats enterprise\" && \\",
|
||||||
|
"after": "RUSTFLAGS=\"-L /usr/lib\" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"foundationdb s3 redis nats\" && \\"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "Dockerfile.build",
|
||||||
|
"line": 142,
|
||||||
|
"before": "cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats enterprise\" && \\",
|
||||||
|
"after": "cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features \"sqlite postgres mysql rocks s3 redis azure nats\" && \\"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "Dockerfile.fdb",
|
||||||
|
"line": 56,
|
||||||
|
"before": "RUN cargo build -p stalwart --no-default-features --features \"foundationdb s3 redis azure nats enterprise\" --release",
|
||||||
|
"after": "RUN cargo build -p stalwart --no-default-features --features \"foundationdb s3 redis azure nats\" --release"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dangling_mods": [
|
||||||
|
{
|
||||||
|
"file": "crates/common/src/telemetry/metrics/mod.rs",
|
||||||
|
"line": 12,
|
||||||
|
"module": "test_data",
|
||||||
|
"lines": [
|
||||||
|
"#[cfg(any(feature = \"dev_mode\", feature = \"test_mode\"))]",
|
||||||
|
"pub mod test_data;"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/src/directory/mod.rs",
|
||||||
|
"line": 11,
|
||||||
|
"module": "oidc",
|
||||||
|
"lines": [
|
||||||
|
"pub mod oidc;"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/src/lib.rs",
|
||||||
|
"line": 27,
|
||||||
|
"module": "scim",
|
||||||
|
"lines": [
|
||||||
|
"#[cfg(test)]",
|
||||||
|
"pub mod scim;"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/src/system/mod.rs",
|
||||||
|
"line": 8,
|
||||||
|
"module": "archiving",
|
||||||
|
"lines": [
|
||||||
|
"pub mod archiving;"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "tests/src/system/mod.rs",
|
||||||
|
"line": 19,
|
||||||
|
"module": "tenant",
|
||||||
|
"lines": [
|
||||||
|
"pub mod tenant;"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"problems": [],
|
||||||
|
"feature_gates": {
|
||||||
|
"crates/common/src/cache/reload.rs": 1,
|
||||||
|
"crates/common/src/manager/boot.rs": 1,
|
||||||
|
"crates/common/src/storage/mod.rs": 1,
|
||||||
|
"crates/common/src/storage/quota.rs": 1,
|
||||||
|
"crates/common/src/telemetry/metrics/prometheus.rs": 1,
|
||||||
|
"crates/http/src/api/mod.rs": 1,
|
||||||
|
"crates/http/src/auth/permissions.rs": 1,
|
||||||
|
"crates/jmap/src/registry/get.rs": 1,
|
||||||
|
"crates/jmap/src/registry/mapping/principal.rs": 2,
|
||||||
|
"crates/jmap/src/registry/mapping/queued_message.rs": 1,
|
||||||
|
"crates/jmap/src/registry/mapping/task.rs": 1,
|
||||||
|
"crates/jmap/src/registry/set.rs": 1,
|
||||||
|
"crates/services/src/task_manager/alarm.rs": 1,
|
||||||
|
"crates/services/src/task_manager/imip.rs": 1,
|
||||||
|
"crates/services/src/task_manager/index.rs": 1,
|
||||||
|
"crates/services/src/task_manager/maintenance.rs": 1,
|
||||||
|
"crates/services/src/task_manager/scheduler.rs": 1,
|
||||||
|
"crates/store/src/lib.rs": 1
|
||||||
|
},
|
||||||
|
"edition_checks": {},
|
||||||
|
"schema": {
|
||||||
|
"objects": [
|
||||||
|
"x:AiModel",
|
||||||
|
"x:Alert",
|
||||||
|
"x:ArchivedItem",
|
||||||
|
"x:MaskedEmail",
|
||||||
|
"x:MetricsStore",
|
||||||
|
"x:SpamLlm",
|
||||||
|
"x:Tenant",
|
||||||
|
"x:Trace",
|
||||||
|
"x:TracingStore"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
"x:AcmeProvider.memberTenantId",
|
||||||
|
"x:ArfExternalReport.memberTenantId",
|
||||||
|
"x:Authentication.defaultTenantRoleIds",
|
||||||
|
"x:CalendarAlarm.template",
|
||||||
|
"x:CalendarScheduling.emailTemplate",
|
||||||
|
"x:CalendarScheduling.httpRsvpTemplate",
|
||||||
|
"x:DataRetention.archiveDeletedAccountsFor",
|
||||||
|
"x:DataRetention.archiveDeletedItemsFor",
|
||||||
|
"x:DataRetention.holdMetricsFor",
|
||||||
|
"x:DataRetention.holdTracesFor",
|
||||||
|
"x:DataRetention.metricsCollectionInterval",
|
||||||
|
"x:Dkim1Signature.memberTenantId",
|
||||||
|
"x:Dkim2Signature.memberTenantId",
|
||||||
|
"x:DmarcExternalReport.memberTenantId",
|
||||||
|
"x:Domain.allowScimProvisioning",
|
||||||
|
"x:Domain.directoryId",
|
||||||
|
"x:Domain.logo",
|
||||||
|
"x:Domain.memberTenantId",
|
||||||
|
"x:Email.maxMaskedAddresses",
|
||||||
|
"x:Enterprise.logoUrl",
|
||||||
|
"x:GroupAccount.externalId",
|
||||||
|
"x:LdapDirectory.memberTenantId",
|
||||||
|
"x:MySqlStore.readReplicas",
|
||||||
|
"x:OidcDirectory.memberTenantId",
|
||||||
|
"x:PostgreSqlStore.readReplicas",
|
||||||
|
"x:Search.indexTelemetry",
|
||||||
|
"x:Search.indexTracingFields",
|
||||||
|
"x:SqlDirectory.memberTenantId",
|
||||||
|
"x:TlsExternalReport.memberTenantId",
|
||||||
|
"x:UserAccount.externalId"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"third_party": {
|
||||||
|
"crates/common/src/network/acme/directory.rs": [
|
||||||
|
{
|
||||||
|
"line": 7,
|
||||||
|
"text": "Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/common/src/network/acme/jose.rs": [
|
||||||
|
{
|
||||||
|
"line": 7,
|
||||||
|
"text": "Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/common/src/network/acme/order.rs": [
|
||||||
|
{
|
||||||
|
"line": 7,
|
||||||
|
"text": "Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/common/src/scripts/functions/text.rs": [
|
||||||
|
{
|
||||||
|
"line": 239,
|
||||||
|
"text": "MIT licensed."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line": 241,
|
||||||
|
"text": "Copyright (c) 2016 Titus Wormer <[email protected]>"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/common/src/telemetry/tracers/journald.rs": [
|
||||||
|
{
|
||||||
|
"line": 70,
|
||||||
|
"text": "SPDX-FileCopyrightText: 2018 Benjamin Saunders <[email protected]>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line": 71,
|
||||||
|
"text": "SPDX-License-Identifier: MIT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/imap-proto/src/utf7.rs": [
|
||||||
|
{
|
||||||
|
"line": 7,
|
||||||
|
"text": "Ported from https://github.com/jstedfast/MailKit/blob/master/MailKit/Net/Imap/ImapEncoding.cs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/jmap/src/registry/mapping/log.rs": [
|
||||||
|
{
|
||||||
|
"line": 341,
|
||||||
|
"text": "SPDX-FileCopyrightText: 2017 Michael Coyne <[email protected]>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line": 343,
|
||||||
|
"text": "SPDX-License-Identifier: MIT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line": 346,
|
||||||
|
"text": "Adapted from https://github.com/mjc-gh/rev_lines/blob/main/src/lib.rs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/jmap-proto/src/types/date.rs": [
|
||||||
|
{
|
||||||
|
"line": 121,
|
||||||
|
"text": "Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line": 158,
|
||||||
|
"text": "Ported from https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c#L982-L992"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/nlp/src/tokenizers/japanese.rs": [
|
||||||
|
{
|
||||||
|
"line": 73,
|
||||||
|
"text": "Ported from https://github.com/woxtu/rust-tinysegmenter, MIT license"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/nlp/src/tokenizers/types.rs": [
|
||||||
|
{
|
||||||
|
"line": 738,
|
||||||
|
"text": "Credits: test suite from linkify crate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/registry/src/types/datetime.rs": [
|
||||||
|
{
|
||||||
|
"line": 150,
|
||||||
|
"text": "Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line": 188,
|
||||||
|
"text": "Ported from https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c#L982-L992"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/store/src/backend/postgres/tls.rs": [
|
||||||
|
{
|
||||||
|
"line": 7,
|
||||||
|
"text": "Credits: https://github.com/jbg/tokio-postgres-rustls"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/types/src/id.rs": [
|
||||||
|
{
|
||||||
|
"line": 69,
|
||||||
|
"text": "From https://github.com/archer884/crockford by J/A <[email protected]>"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"line": 70,
|
||||||
|
"text": "License: MIT/Apache 2.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"crates/utils/src/glob.rs": [
|
||||||
|
{
|
||||||
|
"line": 107,
|
||||||
|
"text": "Credits: Algorithm ported from https://research.swtch.com/glob"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"third_party_unlisted": [],
|
||||||
|
"ossify_log": "$ ossify.py crates\nProcessing Rust files in: /tmp/claude-1000/-run-media-john-PROJECTS/b60a056e-7e44-433c-bbb6-7e1b1fece570/scratchpad/strip-v0.16.23/tree/crates\n\nFound 957 Rust files\n\n\nSummary:\n- 50 files were completely removed\n- 2 crate roots were emptied\n- 118 proprietary snippets were removed from 50 files\n\n$ ossify.py tests\nProcessing Rust files in: /tmp/claude-1000/-run-media-john-PROJECTS/b60a056e-7e44-433c-bbb6-7e1b1fece570/scratchpad/strip-v0.16.23/tree/tests\n\nFound 211 Rust files\n\n\nSummary:\n- 11 files were completely removed\n- 0 crate roots were emptied\n- 0 proprietary snippets were removed from 0 files\n"
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Strip report: upstream v0.16.23 (9d1c75ab6843)
|
||||||
|
|
||||||
|
- Enterprise-only files removed or emptied: **63**
|
||||||
|
- Enterprise-only snippets removed: **118** in 50 files
|
||||||
|
- Cargo edits turning `enterprise` off: **25**
|
||||||
|
- Dangling module declarations removed: **5**
|
||||||
|
- Verification: **clean**
|
||||||
|
- Left for the rebuilt features to replace: 19 `enterprise` feature gates in 18 files; 0 `is_enterprise_edition()` checks in 0 files
|
||||||
|
- Third-party code: 14 files, **0** not in THIRD-PARTY.md
|
||||||
|
- Upstream schema flags 9 objects and 30 fields as Enterprise
|
||||||
|
|
||||||
|
## Removed files
|
||||||
|
|
||||||
|
- `crates/common/src/enterprise/alerts.rs`
|
||||||
|
- `crates/common/src/enterprise/config.rs`
|
||||||
|
- `crates/common/src/enterprise/license.rs`
|
||||||
|
- `crates/common/src/enterprise/llm.rs`
|
||||||
|
- `crates/common/src/enterprise/masked.rs`
|
||||||
|
- `crates/common/src/enterprise/mod.rs`
|
||||||
|
- `crates/common/src/telemetry/metrics/store.rs`
|
||||||
|
- `crates/common/src/telemetry/metrics/test_data.rs`
|
||||||
|
- `crates/common/src/telemetry/tracers/store.rs`
|
||||||
|
- `crates/http/src/api/telemetry.rs`
|
||||||
|
- `crates/jmap/src/registry/mapping/archived_item.rs`
|
||||||
|
- `crates/jmap/src/registry/mapping/masked_email.rs`
|
||||||
|
- `crates/jmap/src/registry/mapping/telemetry.rs`
|
||||||
|
- `crates/scim-proto/src/attributes.rs`
|
||||||
|
- `crates/scim-proto/src/etag.rs`
|
||||||
|
- `crates/scim-proto/src/filter.rs`
|
||||||
|
- `crates/scim-proto/src/json.rs`
|
||||||
|
- `crates/scim-proto/src/lib.rs`
|
||||||
|
- `crates/scim-proto/src/message/bulk.rs`
|
||||||
|
- `crates/scim-proto/src/message/error.rs`
|
||||||
|
- `crates/scim-proto/src/message/list.rs`
|
||||||
|
- `crates/scim-proto/src/message/mod.rs`
|
||||||
|
- `crates/scim-proto/src/message/patch.rs`
|
||||||
|
- `crates/scim-proto/src/message/search.rs`
|
||||||
|
- `crates/scim-proto/src/path.rs`
|
||||||
|
- `crates/scim-proto/src/schema/group.rs`
|
||||||
|
- `crates/scim-proto/src/schema/mod.rs`
|
||||||
|
- `crates/scim-proto/src/schema/spc.rs`
|
||||||
|
- `crates/scim-proto/src/schema/user.rs`
|
||||||
|
- `crates/scim/src/auth.rs`
|
||||||
|
- `crates/scim/src/bulk.rs`
|
||||||
|
- `crates/scim/src/context.rs`
|
||||||
|
- `crates/scim/src/discovery.rs`
|
||||||
|
- `crates/scim/src/error.rs`
|
||||||
|
- `crates/scim/src/groups/get.rs`
|
||||||
|
- `crates/scim/src/groups/mod.rs`
|
||||||
|
- `crates/scim/src/groups/patch.rs`
|
||||||
|
- `crates/scim/src/groups/set.rs`
|
||||||
|
- `crates/scim/src/lib.rs`
|
||||||
|
- `crates/scim/src/query/cursor.rs`
|
||||||
|
- `crates/scim/src/query/mod.rs`
|
||||||
|
- `crates/scim/src/request.rs`
|
||||||
|
- `crates/scim/src/users/get.rs`
|
||||||
|
- `crates/scim/src/users/mod.rs`
|
||||||
|
- `crates/scim/src/users/patch.rs`
|
||||||
|
- `crates/scim/src/users/set.rs`
|
||||||
|
- `crates/spam-filter/src/analysis/llm.rs`
|
||||||
|
- `crates/store/src/backend/composite/mod.rs`
|
||||||
|
- `crates/store/src/backend/composite/read_replica.rs`
|
||||||
|
- `crates/store/src/backend/composite/sharded_blob.rs`
|
||||||
|
- `crates/store/src/backend/composite/sharded_lookup.rs`
|
||||||
|
- `crates/trc/src/serializers/binary.rs`
|
||||||
|
- `tests/src/directory/oidc.rs`
|
||||||
|
- `tests/src/scim/auth.rs`
|
||||||
|
- `tests/src/scim/bulk.rs`
|
||||||
|
- `tests/src/scim/discovery.rs`
|
||||||
|
- `tests/src/scim/groups.rs`
|
||||||
|
- `tests/src/scim/limits.rs`
|
||||||
|
- `tests/src/scim/mod.rs`
|
||||||
|
- `tests/src/scim/query.rs`
|
||||||
|
- `tests/src/scim/users.rs`
|
||||||
|
- `tests/src/system/archiving.rs`
|
||||||
|
- `tests/src/system/tenant.rs`
|
||||||
|
|
||||||
|
## Removed snippets
|
||||||
|
|
||||||
|
- `crates/common/src/auth/authentication.rs`: 3
|
||||||
|
- `crates/common/src/auth/mod.rs`: 2
|
||||||
|
- `crates/common/src/auth/permissions.rs`: 1
|
||||||
|
- `crates/common/src/cache/directory.rs`: 6
|
||||||
|
- `crates/common/src/cache/invalidate.rs`: 1
|
||||||
|
- `crates/common/src/cache/principals.rs`: 2
|
||||||
|
- `crates/common/src/cache/reload.rs`: 1
|
||||||
|
- `crates/common/src/config/mod.rs`: 2
|
||||||
|
- `crates/common/src/config/telemetry.rs`: 4
|
||||||
|
- `crates/common/src/lib.rs`: 2
|
||||||
|
- `crates/common/src/manager/boot.rs`: 1
|
||||||
|
- `crates/common/src/network/mta.rs`: 1
|
||||||
|
- `crates/common/src/scripts/plugins/llm_prompt.rs`: 1
|
||||||
|
- `crates/common/src/storage/quota.rs`: 2
|
||||||
|
- `crates/common/src/telemetry/metrics/mod.rs`: 1
|
||||||
|
- `crates/common/src/telemetry/metrics/prometheus.rs`: 1
|
||||||
|
- `crates/common/src/telemetry/mod.rs`: 1
|
||||||
|
- `crates/common/src/telemetry/tracers/mod.rs`: 1
|
||||||
|
- `crates/http/src/api/mod.rs`: 4
|
||||||
|
- `crates/http/src/auth/permissions.rs`: 1
|
||||||
|
- `crates/http/src/request.rs`: 4
|
||||||
|
- `crates/jmap/src/registry/get.rs`: 1
|
||||||
|
- `crates/jmap/src/registry/mapping/mod.rs`: 1
|
||||||
|
- `crates/jmap/src/registry/mapping/principal.rs`: 3
|
||||||
|
- `crates/jmap/src/registry/mapping/queued_message.rs`: 1
|
||||||
|
- `crates/jmap/src/registry/mapping/task.rs`: 1
|
||||||
|
- `crates/jmap/src/registry/mod.rs`: 1
|
||||||
|
- `crates/jmap/src/registry/query.rs`: 1
|
||||||
|
- `crates/jmap/src/registry/set.rs`: 2
|
||||||
|
- `crates/main/src/main.rs`: 1
|
||||||
|
- `crates/services/src/task_manager/alarm.rs`: 1
|
||||||
|
- `crates/services/src/task_manager/imip.rs`: 1
|
||||||
|
- `crates/services/src/task_manager/index.rs`: 2
|
||||||
|
- `crates/services/src/task_manager/maintenance.rs`: 3
|
||||||
|
- `crates/services/src/task_manager/scheduler.rs`: 8
|
||||||
|
- `crates/spam-filter/src/analysis/mod.rs`: 1
|
||||||
|
- `crates/spam-filter/src/analysis/score.rs`: 2
|
||||||
|
- `crates/store/src/backend/mod.rs`: 1
|
||||||
|
- `crates/store/src/backend/mysql/main.rs`: 1
|
||||||
|
- `crates/store/src/backend/postgres/main.rs`: 1
|
||||||
|
- `crates/store/src/build/blob.rs`: 2
|
||||||
|
- `crates/store/src/build/lookup.rs`: 1
|
||||||
|
- `crates/store/src/build/memory.rs`: 2
|
||||||
|
- `crates/store/src/dispatch/blob.rs`: 6
|
||||||
|
- `crates/store/src/dispatch/lookup.rs`: 10
|
||||||
|
- `crates/store/src/dispatch/mod.rs`: 1
|
||||||
|
- `crates/store/src/dispatch/search.rs`: 6
|
||||||
|
- `crates/store/src/dispatch/store.rs`: 8
|
||||||
|
- `crates/store/src/lib.rs`: 6
|
||||||
|
- `crates/trc/src/serializers/mod.rs`: 1
|
||||||
|
|
||||||
|
## Dangling module declarations removed
|
||||||
|
|
||||||
|
- `crates/common/src/telemetry/metrics/mod.rs:12`: `mod test_data` (#[cfg(any(feature = "dev_mode", feature = "test_mode"))] / pub mod test_data;)
|
||||||
|
- `tests/src/directory/mod.rs:11`: `mod oidc` (pub mod oidc;)
|
||||||
|
- `tests/src/lib.rs:27`: `mod scim` (#[cfg(test)] / pub mod scim;)
|
||||||
|
- `tests/src/system/mod.rs:8`: `mod archiving` (pub mod archiving;)
|
||||||
|
- `tests/src/system/mod.rs:19`: `mod tenant` (pub mod tenant;)
|
||||||
|
|
||||||
|
## Cargo edits
|
||||||
|
|
||||||
|
- `crates/main/Cargo.toml:50`: `default = ["rocks", "enterprise"]` → `default = ["rocks"]`
|
||||||
|
- `tests/Cargo.toml:22`: `store = { path = "../crates/store", features = ["test_mode", "enterprise"] }` → `store = { path = "../crates/store", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:24`: `directory = { path = "../crates/directory", features = ["test_mode", "enterprise"] }` → `directory = { path = "../crates/directory", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:25`: `coordinator = { path = "../crates/coordinator", features = ["test_mode", "enterprise"] }` → `coordinator = { path = "../crates/coordinator", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:26`: `jmap = { path = "../crates/jmap", features = ["test_mode", "enterprise"] }` → `jmap = { path = "../crates/jmap", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:35`: `http = { path = "../crates/http", features = ["test_mode", "enterprise"] }` → `http = { path = "../crates/http", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:37`: `scim = { path = "../crates/scim", features = ["test_mode", "enterprise"] }` → `scim = { path = "../crates/scim", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:39`: `services = { path = "../crates/services", features = ["test_mode", "enterprise"] }` → `services = { path = "../crates/services", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:41`: `smtp = { path = "../crates/smtp", features = ["test_mode", "enterprise"] }` → `smtp = { path = "../crates/smtp", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:42`: `common = { path = "../crates/common", features = ["test_mode", "enterprise"] }` → `common = { path = "../crates/common", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:44`: `email = { path = "../crates/email", features = ["test_mode", "enterprise"] }` → `email = { path = "../crates/email", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:45`: `spam-filter = { path = "../crates/spam-filter", features = ["test_mode", "enterprise"] }` → `spam-filter = { path = "../crates/spam-filter", features = ["test_mode"] }`
|
||||||
|
- `tests/Cargo.toml:46`: `trc = { path = "../crates/trc", features = ["enterprise"] }` → `trc = { path = "../crates/trc", features = [] }`
|
||||||
|
- `tests/Cargo.toml:47`: `managesieve = { path = "../crates/managesieve", features = ["test_mode", "enterprise"] }` → `managesieve = { path = "../crates/managesieve", features = ["test_mode"] }`
|
||||||
|
- `.github/workflows/ci.yml:341`: `cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise"` → `cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"`
|
||||||
|
- `.github/workflows/ci.yml:379`: `# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "foundationdb s3 redis nats enterprise"` → `# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "foundationdb s3 redis nats"`
|
||||||
|
- `.github/workflows/ci.yml:386`: `cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise"` → `cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"`
|
||||||
|
- `.github/workflows/ci.yml:442`: `cargo build --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise"` → `cargo build --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"`
|
||||||
|
- `Dockerfile:22`: `RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" --recipe-path /recipe.json` → `RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json`
|
||||||
|
- `Dockerfile:24`: `RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise"` → `RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"`
|
||||||
|
- `Dockerfile.build:111`: `RUSTFLAGS="-L /usr/lib" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats enterprise"; \` → `RUSTFLAGS="-L /usr/lib" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats"; \`
|
||||||
|
- `Dockerfile.build:119`: `cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise"` → `cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"`
|
||||||
|
- `Dockerfile.build:132`: `RUSTFLAGS="-L /usr/lib" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats enterprise" && \` → `RUSTFLAGS="-L /usr/lib" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats" && \`
|
||||||
|
- `Dockerfile.build:142`: `cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats enterprise" && \` → `cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" && \`
|
||||||
|
- `Dockerfile.fdb:56`: `RUN cargo build -p stalwart --no-default-features --features "foundationdb s3 redis azure nats enterprise" --release` → `RUN cargo build -p stalwart --no-default-features --features "foundationdb s3 redis azure nats" --release`
|
||||||
|
|
||||||
|
## Flagged Enterprise in upstream's schema
|
||||||
|
|
||||||
|
**Objects:** `x:AiModel`, `x:Alert`, `x:ArchivedItem`, `x:MaskedEmail`, `x:MetricsStore`, `x:SpamLlm`, `x:Tenant`, `x:Trace`, `x:TracingStore`
|
||||||
|
|
||||||
|
**Fields:** `x:AcmeProvider.memberTenantId`, `x:ArfExternalReport.memberTenantId`, `x:Authentication.defaultTenantRoleIds`, `x:CalendarAlarm.template`, `x:CalendarScheduling.emailTemplate`, `x:CalendarScheduling.httpRsvpTemplate`, `x:DataRetention.archiveDeletedAccountsFor`, `x:DataRetention.archiveDeletedItemsFor`, `x:DataRetention.holdMetricsFor`, `x:DataRetention.holdTracesFor`, `x:DataRetention.metricsCollectionInterval`, `x:Dkim1Signature.memberTenantId`, `x:Dkim2Signature.memberTenantId`, `x:DmarcExternalReport.memberTenantId`, `x:Domain.allowScimProvisioning`, `x:Domain.directoryId`, `x:Domain.logo`, `x:Domain.memberTenantId`, `x:Email.maxMaskedAddresses`, `x:Enterprise.logoUrl`, `x:GroupAccount.externalId`, `x:LdapDirectory.memberTenantId`, `x:MySqlStore.readReplicas`, `x:OidcDirectory.memberTenantId`, `x:PostgreSqlStore.readReplicas`, `x:Search.indexTelemetry`, `x:Search.indexTracingFields`, `x:SqlDirectory.memberTenantId`, `x:TlsExternalReport.memberTenantId`, `x:UserAccount.externalId`
|
||||||
|
|
||||||
|
## Third-party code
|
||||||
|
|
||||||
|
Comments in the stripped tree that name another copyright holder, another license, or a source the code came from. Files marked **new** aren't in THIRD-PARTY.md yet.
|
||||||
|
|
||||||
|
- `crates/common/src/network/acme/directory.rs`
|
||||||
|
- 7: Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||||
|
- `crates/common/src/network/acme/jose.rs`
|
||||||
|
- 7: Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||||
|
- `crates/common/src/network/acme/order.rs`
|
||||||
|
- 7: Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||||
|
- `crates/common/src/scripts/functions/text.rs`
|
||||||
|
- 239: MIT licensed.
|
||||||
|
- 241: Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com>
|
||||||
|
- `crates/common/src/telemetry/tracers/journald.rs`
|
||||||
|
- 70: SPDX-FileCopyrightText: 2018 Benjamin Saunders <ben.e.saunders@gmail.com>
|
||||||
|
- 71: SPDX-License-Identifier: MIT
|
||||||
|
- `crates/imap-proto/src/utf7.rs`
|
||||||
|
- 7: Ported from https://github.com/jstedfast/MailKit/blob/master/MailKit/Net/Imap/ImapEncoding.cs
|
||||||
|
- `crates/jmap/src/registry/mapping/log.rs`
|
||||||
|
- 341: SPDX-FileCopyrightText: 2017 Michael Coyne <mjc@hey.com>
|
||||||
|
- 343: SPDX-License-Identifier: MIT
|
||||||
|
- 346: Adapted from https://github.com/mjc-gh/rev_lines/blob/main/src/lib.rs
|
||||||
|
- `crates/jmap-proto/src/types/date.rs`
|
||||||
|
- 121: Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days
|
||||||
|
- 158: Ported from https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c#L982-L992
|
||||||
|
- `crates/nlp/src/tokenizers/japanese.rs`
|
||||||
|
- 73: Ported from https://github.com/woxtu/rust-tinysegmenter, MIT license
|
||||||
|
- `crates/nlp/src/tokenizers/types.rs`
|
||||||
|
- 738: Credits: test suite from linkify crate
|
||||||
|
- `crates/registry/src/types/datetime.rs`
|
||||||
|
- 150: Ported from http://howardhinnant.github.io/date_algorithms.html#civil_from_days
|
||||||
|
- 188: Ported from https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c#L982-L992
|
||||||
|
- `crates/store/src/backend/postgres/tls.rs`
|
||||||
|
- 7: Credits: https://github.com/jbg/tokio-postgres-rustls
|
||||||
|
- `crates/types/src/id.rs`
|
||||||
|
- 69: From https://github.com/archer884/crockford by J/A <archer884@gmail.com>
|
||||||
|
- 70: License: MIT/Apache 2.0
|
||||||
|
- `crates/utils/src/glob.rs`
|
||||||
|
- 107: Credits: Algorithm ported from https://research.swtch.com/glob
|
||||||
+53
-20
@@ -91,7 +91,22 @@ The wrapper is `tools/fork/strip.py`. Beyond `ossify.py` it:
|
|||||||
was ported or adapted from elsewhere. Any file `THIRD-PARTY.md` doesn't
|
was ported or adapted from elsewhere. Any file `THIRD-PARTY.md` doesn't
|
||||||
cover yet is flagged as new. It's reported, not a failure: the notice goes
|
cover yet is flagged as new. It's reported, not a failure: the notice goes
|
||||||
into `THIRD-PARTY.md` in the merge that brings the release in, since the
|
into `THIRD-PARTY.md` in the merge that brings the release in, since the
|
||||||
fork redistributes that code and its license requires the notice.
|
fork redistributes that code and its license requires the notice;
|
||||||
|
- renames the upstream name where it's an identifier clients, users or
|
||||||
|
operators meet (wire-protocol names, the web interface's client id, store
|
||||||
|
keys; §2.4), with the same substitutions `main`
|
||||||
|
carries, so those lines arrive purged and never conflict (added
|
||||||
|
2026-09-22);
|
||||||
|
- compiles the stripped tree. A dual-licensed file that only serves an
|
||||||
|
Enterprise feature survives the strip but can't build without it:
|
||||||
|
v0.16.23's `tests/src/directory/issuer.rs` was the first. The build check
|
||||||
|
fails the run on it, and the merge into `main` drops or reworks it (added
|
||||||
|
2026-09-22).
|
||||||
|
|
||||||
|
Two checks in CI cover what a merge can bring in without a conflict:
|
||||||
|
`tools/fork/name-check.py` (the upstream name in a new string literal) and
|
||||||
|
`tools/fork/notice-check.py` (a changed upstream file without its AGPL 5(a)
|
||||||
|
notice).
|
||||||
|
|
||||||
### 2.2a Snapshots, not a git fork
|
### 2.2a Snapshots, not a git fork
|
||||||
|
|
||||||
@@ -197,14 +212,29 @@ depends on `store` and can't be called from it (`features/scale-out-storage.md`)
|
|||||||
- Factual statements are allowed and required: "a fork of Stalwart",
|
- Factual statements are allowed and required: "a fork of Stalwart",
|
||||||
"compatible with Stalwart 0.16 data". Upstream copyright notices stay on
|
"compatible with Stalwart 0.16 data". Upstream copyright notices stay on
|
||||||
every file they cover.
|
every file they cover.
|
||||||
- Protocol identifiers stay as upstream has them, for example the JMAP
|
- **Identifiers people meet carry the fork's name** (changed 2026-09-22;
|
||||||
capability `urn:stalwart:jmap` and the `x:` object names. They're
|
this bullet used to keep upstream's). Upstream's JMAP capability for the
|
||||||
interoperability, not branding, and renaming them breaks every existing
|
registry (`x:`) objects is `urn:inbuxa:jmap:registry`, beside the fork's
|
||||||
client. Anything the fork adds uses its own namespace (open: which one).
|
own `urn:inbuxa:jmap` (contract C-1); WebDAV lock and sync tokens are
|
||||||
- **Version and build metadata are exempt from the first bullet** (added
|
`urn:inbuxa:dav*`, the Sieve extensions are `vnd.inbuxa.while` and `vnd.inbuxa.expressions`, the
|
||||||
2026-09-19). `inbuxa --version`, the startup banner and events,
|
web interface's OAuth client is `inbuxa-webui`, the spam filter's blobs are
|
||||||
OpenTelemetry's `service.version`, the JMAP `implementation` string, release
|
`INBUXA_SPAM_*`, and the SQL stores and log files default to `inbuxa`.
|
||||||
notes and the strip report all name the Stalwart base, as §2.6 requires.
|
There are no aliases: the old names stop working, so front ends move with
|
||||||
|
the server, Sieve scripts that `require` the old extensions are edited,
|
||||||
|
DAV clients resync once, and anyone signed in to the web interface signs in
|
||||||
|
again. Pre-rename data is carried over where it would otherwise be lost
|
||||||
|
(the spam model, the web interface's client). The `x:` object names are
|
||||||
|
unchanged, having no name in them. `tools/fork/renames.py` holds the list,
|
||||||
|
and the strip applies it to every import (§2.2).
|
||||||
|
- **Identifiers nobody sees keep upstream's spelling:** the OAuth
|
||||||
|
key-derivation contexts, whose renaming would invalidate every issued token
|
||||||
|
and client id, and the application blob prefix, which is hashed before use.
|
||||||
|
`tools/fork/name-allowlist.txt` lists them with their reasons.
|
||||||
|
- **Version and build metadata give the base without the name** (added
|
||||||
|
2026-09-19, narrowed 2026-09-22). `inbuxa --version`, the startup banner and
|
||||||
|
events, OpenTelemetry's `service.version` and the JMAP `implementation`
|
||||||
|
string say `2026.9.23 (upstream 0.16.23)`, as §2.6 requires; release notes
|
||||||
|
and the strip report may name the Stalwart base.
|
||||||
That is a factual statement about what was compiled, not a name the product
|
That is a factual statement about what was compiled, not a name the product
|
||||||
calls itself, and the two bullets don't conflict: the first governs
|
calls itself, and the two bullets don't conflict: the first governs
|
||||||
identity, this one governs provenance. A reader who takes "no Stalwart in
|
identity, this one governs provenance. A reader who takes "no Stalwart in
|
||||||
@@ -228,15 +258,18 @@ Done 2026-09-18:
|
|||||||
- The package and binary are `inbuxa` (`cargo build -p inbuxa`). The binary's
|
- The package and binary are `inbuxa` (`cargo build -p inbuxa`). The binary's
|
||||||
help, banner and every protocol greeting say INBUXA (the branding module,
|
help, banner and every protocol greeting say INBUXA (the branding module,
|
||||||
`types::brand!()`).
|
`types::brand!()`).
|
||||||
- Settings come from `INBUXA_*` environment variables. Each still falls back
|
- Settings come from `INBUXA_*` environment variables
|
||||||
to its `STALWART_*` name, with a startup warning to rename it
|
(`types::branding::env_var`): `HOSTNAME`, `RECOVERY_MODE`, `RECOVERY_ADMIN`,
|
||||||
(`types::branding::env_var`). That covers all nine the server reads:
|
`RECOVERY_MODE_PORT`, `RECOVERY_MODE_LOG_LEVEL`, `ROLE`, `PUSH_SHARD`,
|
||||||
`HOSTNAME`, `RECOVERY_MODE`, `RECOVERY_ADMIN`, `RECOVERY_MODE_PORT`,
|
`PUBLIC_URL`, `HTTPS_PORT`, and the front-end variables of contract C-6.
|
||||||
`RECOVERY_MODE_LOG_LEVEL`, `ROLE`, `PUSH_SHARD`, `PUBLIC_URL`, `HTTPS_PORT`.
|
Until 2026-09-22 each fell back to its `STALWART_*` name with a warning.
|
||||||
- **Not renamed, on purpose:** `STALWART_APP_` and the two `STALWART_SPAM_...`
|
Now a `STALWART_*` name that's set where its `INBUXA_*` one isn't stops the
|
||||||
names. They look like environment variables, but they're keys inside the
|
server at startup, naming the variable to rename, so an install moved over
|
||||||
data store, so renaming them would orphan existing installed apps and
|
from upstream never runs on settings it silently dropped.
|
||||||
spam-classifier models.
|
- The spam filter's blobs moved from `STALWART_SPAM_*` to `INBUXA_SPAM_*` on
|
||||||
|
2026-09-22; every start moves any left under the old keys
|
||||||
|
(`migration::try_migrate`), so no trained model is orphaned.
|
||||||
|
`STALWART_APP_` stays: it's hashed into a blob key and never seen.
|
||||||
- New installs default to `/var/lib/inbuxa` for data and `/var/log/inbuxa` for
|
- New installs default to `/var/lib/inbuxa` for data and `/var/log/inbuxa` for
|
||||||
logs. Existing installs keep the paths their configuration names, so no data
|
logs. Existing installs keep the paths their configuration names, so no data
|
||||||
moves.
|
moves.
|
||||||
@@ -406,8 +439,8 @@ Versioned, and advertised in the JMAP session so either side can check it.
|
|||||||
address or network, so an admin credential is useless from anywhere else.
|
address or network, so an admin credential is useless from anywhere else.
|
||||||
- **Push.** Unchanged: JMAP push with VAPID, as ihasmail uses today.
|
- **Push.** Unchanged: JMAP push with VAPID, as ihasmail uses today.
|
||||||
- **INBUXA Admin's client.** INBUXA Admin signs in by OAuth (authorization
|
- **INBUXA Admin's client.** INBUXA Admin signs in by OAuth (authorization
|
||||||
code with PKCE) as upstream's `webui` does, as client `stalwart-webui` for
|
code with PKCE) as upstream's `webui` does, as client `inbuxa-webui`
|
||||||
now. The fork registers a first-party `inbuxa-admin` client with the
|
(upstream's `stalwart-webui` until 2026-09-22) when the server serves it. The fork registers a first-party `inbuxa-admin` client with the
|
||||||
admin's own redirect URIs, and the admin switches to it (its
|
admin's own redirect URIs, and the admin switches to it (its
|
||||||
`<meta name="oauth-client-id">`).
|
`<meta name="oauth-client-id">`).
|
||||||
- **Cross-origin access.** Verified 2026-09-18 against a separate
|
- **Cross-origin access.** Verified 2026-09-18 against a separate
|
||||||
|
|||||||
@@ -106,8 +106,9 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
ihasmail-inbuxa server, authorization code with PKCE S256, redirect URI
|
ihasmail-inbuxa server, authorization code with PKCE S256, redirect URI
|
||||||
`{webmailUrl}/api/auth/callback`.
|
`{webmailUrl}/api/auth/callback`.
|
||||||
INBUXA Admin's `<meta name="oauth-client-id">` is set to `inbuxa-admin`.
|
INBUXA Admin's `<meta name="oauth-client-id">` is set to `inbuxa-admin`.
|
||||||
Until then it keeps upstream's `stalwart-webui`, which only works while
|
Served by the server itself it uses the web interface's client,
|
||||||
registration isn't required.
|
`inbuxa-webui` (upstream's `stalwart-webui` until 2026-09-22, retired on
|
||||||
|
start since).
|
||||||
|
|
||||||
**Built (interim), 2026-09-18.** C-5's defaults are in the server, and
|
**Built (interim), 2026-09-18.** C-5's defaults are in the server, and
|
||||||
`crates/common/src/manager/first_party.rs` registers the clients on every
|
`crates/common/src/manager/first_party.rs` registers the clients on every
|
||||||
@@ -116,7 +117,7 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
`INBUXA_WEBMAIL_CLIENT_SECRET` (the webmail client is registered only when
|
`INBUXA_WEBMAIL_CLIENT_SECRET` (the webmail client is registered only when
|
||||||
both of its variables are set). A web interface the server serves itself
|
both of its variables are set). A web interface the server serves itself
|
||||||
(none on a new install since SPEC.md §5.3; possible on one upgraded from
|
(none on a new install since SPEC.md §5.3; possible on one upgraded from
|
||||||
Stalwart) is registered too, as its application's OAuth client id or `stalwart-webui`, at the
|
Stalwart) is registered too, as its application's OAuth client id or `inbuxa-webui`, at the
|
||||||
server's public URL. A missing client is created. An existing one gains any
|
server's public URL. A missing client is created. An existing one gains any
|
||||||
redirect URI it lacks, and the webmail client gets the configured secret.
|
redirect URI it lacks, and the webmail client gets the configured secret.
|
||||||
Nothing an operator added is removed. Bootstrap and recovery mode skip this:
|
Nothing an operator added is removed. Bootstrap and recovery mode skip this:
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ Permissions: `sysSpamLlmGet`, `sysSpamLlmUpdate`.
|
|||||||
- **Tags and scores.** The classifier's tags are ordinary spam tags, scored
|
- **Tags and scores.** The classifier's tags are ordinary spam tags, scored
|
||||||
by `x:SpamTag` entries like every other tag: `Score` (a number), `Discard`
|
by `x:SpamTag` entries like every other tag: `Score` (a number), `Discard`
|
||||||
or `Reject`. The documented defaults are `LLM_UNSOLICITED_HIGH` 3.0 and
|
or `Reject`. The documented defaults are `LLM_UNSOLICITED_HIGH` 3.0 and
|
||||||
`LLM_LEGITIMATE_HIGH` −3.0. A tag with no entry scores 0.
|
`LLM_LEGITIMATE_HIGH` −3.0. A tag with no entry scores 0. The server ships
|
||||||
|
those entries in its bundled spam rules (`resources/spam-filter/`), loaded
|
||||||
|
on first boot and again when the bundled version changes, so an install
|
||||||
|
that predates them gains them on upgrade (added 2026-09-23).
|
||||||
- **`interactAi`** permission ("Interact with AI models"): lets an account's
|
- **`interactAi`** permission ("Interact with AI models"): lets an account's
|
||||||
own Sieve scripts call `llm_prompt`. This repository's default roles give it
|
own Sieve scripts call `llm_prompt`. This repository's default roles give it
|
||||||
to users, tenant administrators and superusers
|
to users, tenant administrators and superusers
|
||||||
@@ -291,7 +294,7 @@ adds at most 2.
|
|||||||
### The Sieve function `llm_prompt`
|
### The Sieve function `llm_prompt`
|
||||||
|
|
||||||
- **AI-20.** `llm_prompt(model, prompt, temperature)`, available with
|
- **AI-20.** `llm_prompt(model, prompt, temperature)`, available with
|
||||||
`require "vnd.stalwart.expressions"`. `model` names an `x:AiModel` by its
|
`require "vnd.inbuxa.expressions"`. `model` names an `x:AiModel` by its
|
||||||
`name`, or failing that by its id. `prompt` is sent as is. `temperature` is
|
`name`, or failing that by its id. `prompt` is sent as is. `temperature` is
|
||||||
clamped to 0.0–1.0. A value that isn't a number uses the model's own
|
clamped to 0.0–1.0. A value that isn't a number uses the model's own
|
||||||
`temperature`. **Decision** on name first, see open question 4.
|
`temperature`. **Decision** on name first, see open question 4.
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ accepted, which is the fact `enabled` reports.
|
|||||||
|
|
||||||
### Upstream's, unchanged
|
### Upstream's, unchanged
|
||||||
|
|
||||||
`x:MaskedEmail/get`, `/query`, `/set` under `urn:stalwart:jmap`, standard RFC
|
`x:MaskedEmail/get`, `/query`, `/set` under `urn:inbuxa:jmap:registry`, standard RFC
|
||||||
8620 shapes, the record above. Upstream's `/query` accepts only an
|
8620 shapes, the record above. Upstream's `/query` accepts only an
|
||||||
`accountId` filter, and it has no `/changes` (observed 6).
|
`accountId` filter, and it has no `/changes` (observed 6).
|
||||||
|
|
||||||
|
|||||||
@@ -212,10 +212,15 @@ unchanged.
|
|||||||
- **MON-16.** With `indexTelemetry` on, storing a trace schedules an
|
- **MON-16.** With `indexTelemetry` on, storing a trace schedules an
|
||||||
`IndexTrace` task. The task builds one document for `SearchIndex::Tracing`
|
`IndexTrace` task. The task builds one document for `SearchIndex::Tracing`
|
||||||
with the fields named in `indexTracingFields`:
|
with the fields named in `indexTracingFields`:
|
||||||
- `eventType`: every event type in the trace;
|
- `eventType`: the trace's opening event, as its numeric id;
|
||||||
- `queueId`: every `queueId` value;
|
- `queueId`: the first `queueId` value, as an integer;
|
||||||
- `keywords`: every address in `from` and `to`, each address's domain, every
|
- `keywords`: every address in `from` and `to`, each address's domain, every
|
||||||
`domain`, `hostname`, `remoteIp`, `messageId` and `accountName` value.
|
`domain`, `hostname`, `remoteIp`, `messageId` and `accountName` value,
|
||||||
|
and every `queueId` value.
|
||||||
|
The event type and queue id are single integer columns on every search
|
||||||
|
backend (BIGINT on PostgreSQL and MySQL), so the `queueId` filter matches
|
||||||
|
the column or any queue id in the keywords, and a session that queued
|
||||||
|
several messages is found by each of them.
|
||||||
So searching `example.org` finds every trace to or from that domain, as the
|
So searching `example.org` finds every trace to or from that domain, as the
|
||||||
upstream suite expects. With `indexTelemetry` off nothing is indexed, and
|
upstream suite expects. With `indexTelemetry` off nothing is indexed, and
|
||||||
the `text` and `queueId` filters are refused (see "Interfaces").
|
the `text` and `queueId` filters are refused (see "Interfaces").
|
||||||
@@ -374,7 +379,7 @@ unchanged.
|
|||||||
|
|
||||||
## Interfaces
|
## Interfaces
|
||||||
|
|
||||||
- **JMAP, existing names, unchanged.** Over `urn:stalwart:jmap`:
|
- **JMAP, existing names, unchanged.** Over `urn:inbuxa:jmap:registry`:
|
||||||
- `x:Alert/get`, `/query`, `/set`, and the `x:TracingStore`,
|
- `x:Alert/get`, `/query`, `/set`, and the `x:TracingStore`,
|
||||||
`x:MetricsStore`, `x:DataRetention`, `x:Search` singletons.
|
`x:MetricsStore`, `x:DataRetention`, `x:Search` singletons.
|
||||||
- `x:Trace/get` and `/query`. Filters: `text`, `timestamp` (comparison names
|
- `x:Trace/get` and `/query`. Filters: `text`, `timestamp` (comparison names
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user