Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
|
|||||||
+155
-14
@@ -3,11 +3,28 @@
|
|||||||
# 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
|
||||||
@@ -62,7 +79,7 @@ jobs:
|
|||||||
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 +98,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 +119,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 +183,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:
|
||||||
|
|||||||
@@ -143,7 +143,9 @@ impl Scripting {
|
|||||||
.with_cpu_limit(trusted.max_cpu_cycles as usize)
|
.with_cpu_limit(trusted.max_cpu_cycles as usize)
|
||||||
.with_max_nested_includes(trusted.max_nested_includes as usize)
|
.with_max_nested_includes(trusted.max_nested_includes as usize)
|
||||||
.with_max_received_headers(trusted.max_received_headers as usize)
|
.with_max_received_headers(trusted.max_received_headers as usize)
|
||||||
.with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs());
|
.with_default_duplicate_expiry(trusted.duplicate_expiry.into_inner().as_secs())
|
||||||
|
// inbuxa: without it, `environment "name"` answers sieve-rs's default
|
||||||
|
.with_env_variable("name", types::brand_server!());
|
||||||
trusted_runtime.set_local_hostname(local_hostname.clone());
|
trusted_runtime.set_local_hostname(local_hostname.clone());
|
||||||
untrusted_runtime.set_local_hostname(local_hostname);
|
untrusted_runtime.set_local_hostname(local_hostname);
|
||||||
|
|
||||||
@@ -279,3 +281,23 @@ impl Clone for Scripting {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use sieve::compiler::grammar::Capability;
|
||||||
|
|
||||||
|
// inbuxa: sieve-rs is vendored (vendor/sieve-rs) to carry the fork's
|
||||||
|
// name in its Sieve extensions. If Cargo.lock moves sieve-rs past the
|
||||||
|
// vendored version, Cargo drops the patch with only a warning and
|
||||||
|
// upstream's spelling comes back; this fails instead.
|
||||||
|
#[test]
|
||||||
|
fn sieve_extensions_carry_the_fork_name() {
|
||||||
|
for (capability, name) in [
|
||||||
|
(Capability::While, "vnd.inbuxa.while"),
|
||||||
|
(Capability::Expressions, "vnd.inbuxa.expressions"),
|
||||||
|
] {
|
||||||
|
assert_eq!(capability.to_string(), name);
|
||||||
|
assert_eq!(Capability::parse(name), capability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -243,7 +243,8 @@ impl SpamFilterConfig {
|
|||||||
spam_threshold: spam.score_spam.into_inner() as f32,
|
spam_threshold: spam.score_spam.into_inner() as f32,
|
||||||
},
|
},
|
||||||
grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()),
|
grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()),
|
||||||
spam_rules_url: spam.spam_filter_rules_url,
|
// inbuxa: unset, empty or upstream's old default means the bundled rules
|
||||||
|
spam_rules_url: crate::manager::spam_rules::rules_url(spam.spam_filter_rules_url),
|
||||||
url_client: utils::http::http_client_builder(true)
|
url_client: utils::http::http_client_builder(true)
|
||||||
.pool_max_idle_per_host(0)
|
.pool_max_idle_per_host(0)
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
|||||||
@@ -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.
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -256,10 +263,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 => {
|
||||||
|
|||||||
@@ -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),
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -567,20 +567,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 +584,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 +594,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 +685,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,13 +2,15 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::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::mailstore::spamfilter::PyzorConfig;
|
use common::config::mailstore::spamfilter::PyzorConfig;
|
||||||
@@ -43,35 +45,14 @@ 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 = pyzor_send_message(config.address, config.timeout, &request).await;
|
||||||
#[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
|
||||||
pyzor_send_message(config.address, config.timeout, &request)
|
|
||||||
.await
|
|
||||||
.map(Into::into)
|
.map(Into::into)
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
trc::SpamEvent::PyzorError
|
trc::SpamEvent::PyzorError
|
||||||
@@ -82,6 +63,32 @@ pub(crate) async fn pyzor_check(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: the answers tests get, by digest, instead of a public server's,
|
||||||
|
/// whose counts change and which a test may not be able to reach. Upstream
|
||||||
|
/// answered the first three here and sent every other digest to the network.
|
||||||
|
#[cfg(feature = "test_mode")]
|
||||||
|
fn test_response(request: &str) -> PyzorResponse {
|
||||||
|
let (count, wl_count) = if request.contains("b5b476f0b5ba6e1c038361d3ded5818dd39c90a2")
|
||||||
|
// The digest of an empty body, as an HTML-only message with no text
|
||||||
|
// to hash produces; public servers report it widely.
|
||||||
|
|| request.contains("da39a3ee5e6b4b0d3255bfef95601890afd80709")
|
||||||
|
{
|
||||||
|
(1000, 0)
|
||||||
|
} else if request.contains("d67d4b8bfc3860449e3418bb6017e2612f3e2a99") {
|
||||||
|
(60, 10)
|
||||||
|
} else if request.contains("81763547012b75e57a20d18ce0b93014208cdfdb") {
|
||||||
|
(50, 20)
|
||||||
|
} else {
|
||||||
|
(0, 0)
|
||||||
|
};
|
||||||
|
PyzorResponse {
|
||||||
|
code: 200,
|
||||||
|
count,
|
||||||
|
wl_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "test_mode", allow(dead_code))]
|
||||||
async fn pyzor_send_message(
|
async fn pyzor_send_message(
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
@@ -3729,8 +3729,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 +3983,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",
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
|||||||
rLRbZKj15KvcPMVmnisfCDsXXZEKks6BXpMkMpS0mlI
|
VbnFuwCOTBh0s2T-NuRhb2JaJr8Jl5s3LgXv4Pv2sTg
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Bundled spam filter rules
|
||||||
|
|
||||||
|
`spam-filter-rules.json.gz` is the published rules file of
|
||||||
|
[spam-filter](https://github.com/stalwartlabs/spam-filter) **v3.0.2**,
|
||||||
|
unmodified. The server embeds it (`crates/common/src/manager/spam_rules.rs`)
|
||||||
|
and loads it whenever no other rules source is configured, so a release
|
||||||
|
scores mail with the rules it was tested with, offline and with nothing to
|
||||||
|
fetch. The rules URL setting stays an operator override.
|
||||||
|
|
||||||
|
The rules are dual-licensed MIT or Apache-2.0, Copyright (C) 2024, Stalwart
|
||||||
|
Labs LLC; the fork takes them under MIT, with the notice in `THIRD-PARTY.md`.
|
||||||
|
|
||||||
|
They include the scores for the AI classifier's tags (`LLM_*`, 3.0 for the
|
||||||
|
high-confidence spam categories, −3.0 for legitimate), which match
|
||||||
|
`docs/spec/features/ai-spam-classification.md`.
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
The `upstream-watch` workflow opens an issue when spam-filter publishes a
|
||||||
|
newer release. To take it:
|
||||||
|
|
||||||
|
1. Download `spam-filter-rules.json.gz` from that release, pinned by tag
|
||||||
|
(`releases/download/vX.Y.Z/…`, not `latest`), over this file.
|
||||||
|
2. Set `BUNDLED_SPAM_RULES_VERSION` in `spam_rules.rs` and the version in
|
||||||
|
this README and in `THIRD-PARTY.md`.
|
||||||
|
3. Run the antispam test (`STORE=RocksDb RUST_MIN_STACK=16777216 cargo test
|
||||||
|
-p tests --lib -- smtp::inbound::antispam::antispam --exact`) and fix
|
||||||
|
expectations the new rules change, knowingly.
|
||||||
|
|
||||||
|
On the next start each server loads the new version once. Loading only adds
|
||||||
|
rules and tags that are missing; it never changes an existing one, so an
|
||||||
|
operator's own adjustments survive.
|
||||||
Binary file not shown.
@@ -6,7 +6,7 @@
|
|||||||
<key>Label</key>
|
<key>Label</key>
|
||||||
<string>inbuxa.mail</string>
|
<string>inbuxa.mail</string>
|
||||||
<key>ServiceDescription</key>
|
<key>ServiceDescription</key>
|
||||||
<string>INBUXA</string>
|
<string>inbuxa</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>__PATH__/bin/inbuxa</string>
|
<string>__PATH__/bin/inbuxa</string>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=INBUXA Server
|
Description=inbuxa Server
|
||||||
Conflicts=postfix.service sendmail.service exim4.service
|
Conflicts=postfix.service sendmail.service exim4.service
|
||||||
ConditionPathExists=__PATH__/etc/config.json
|
ConditionPathExists=__PATH__/etc/config.json
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ email = { path = "../crates/email", features = ["test_mode"] }
|
|||||||
spam-filter = { path = "../crates/spam-filter", features = ["test_mode"] }
|
spam-filter = { path = "../crates/spam-filter", features = ["test_mode"] }
|
||||||
trc = { path = "../crates/trc", features = [] }
|
trc = { path = "../crates/trc", features = [] }
|
||||||
managesieve = { path = "../crates/managesieve", features = ["test_mode"] }
|
managesieve = { path = "../crates/managesieve", features = ["test_mode"] }
|
||||||
|
migration = { path = "../crates/migration" }
|
||||||
smtp-proto = { version = "0.2" }
|
smtp-proto = { version = "0.2" }
|
||||||
mail-auth = { version = "0.13", features = ["test"] }
|
mail-auth = { version = "0.13", features = ["test"] }
|
||||||
mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] }
|
mail-parser = { version = "0.11", features = ["full_encoding", "rkyv"] }
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ HTTP = "http://127.0.0.1:18080"
|
|||||||
# made to speak.
|
# made to speak.
|
||||||
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025}
|
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025}
|
||||||
TLS_PORTS = {18993, 18995, 18465}
|
TLS_PORTS = {18993, 18995, 18465}
|
||||||
SMTP_REFUSAL = ("535 5.7.0 This server allows only INBUXA webmail and JMAP apps. "
|
SMTP_REFUSAL = ("535 5.7.0 This server allows only inbuxa webmail and JMAP apps. "
|
||||||
"This mail app can't send.")
|
"This mail app can't send.")
|
||||||
INBUXA = "urn:inbuxa:jmap"
|
INBUXA = "urn:inbuxa:jmap"
|
||||||
failures = []
|
failures = []
|
||||||
@@ -105,7 +105,7 @@ def stop():
|
|||||||
docker("rm", "-f", NAME, check_rc=False)
|
docker("rm", "-f", NAME, check_rc=False)
|
||||||
|
|
||||||
|
|
||||||
def jmap(user, password, calls, using=("urn:ietf:params:jmap:core", "urn:stalwart:jmap", INBUXA)):
|
def jmap(user, password, calls, using=("urn:ietf:params:jmap:core", "urn:inbuxa:jmap:registry", INBUXA)):
|
||||||
body = json.dumps({"using": list(using), "methodCalls": calls}).encode()
|
body = json.dumps({"using": list(using), "methodCalls": calls}).encode()
|
||||||
req = urllib.request.Request(f"{HTTP}/jmap/", data=body, method="POST")
|
req = urllib.request.Request(f"{HTTP}/jmap/", data=body, method="POST")
|
||||||
req.add_header("Content-Type", "application/json")
|
req.add_header("Content-Type", "application/json")
|
||||||
@@ -333,7 +333,7 @@ def tenant_checks(admin, admin_pw, account):
|
|||||||
"and still enabled for an account outside the tenant (test 13)")
|
"and still enabled for an account outside the tenant (test 13)")
|
||||||
|
|
||||||
# Refused on the tenant's domain, every way in the same words (tests 6-8).
|
# Refused on the tenant's domain, every way in the same words (tests 6-8).
|
||||||
imap_no = ("NO [ALERT] Your organization allows only INBUXA webmail and JMAP apps. "
|
imap_no = ("NO [ALERT] Your organization allows only inbuxa webmail and JMAP apps. "
|
||||||
"This mail app can't sign in.")
|
"This mail app can't sign in.")
|
||||||
check(imap_login(PORTS["imap"], tu, user_pw) == imap_no,
|
check(imap_login(PORTS["imap"], tu, user_pw) == imap_no,
|
||||||
"the tenant's user is refused over IMAP with the right password (test 6)")
|
"the tenant's user is refused over IMAP with the right password (test 6)")
|
||||||
@@ -341,10 +341,10 @@ def tenant_checks(admin, admin_pw, account):
|
|||||||
check(imap_login(PORTS["imap"], "[email protected]", "x") == imap_no,
|
check(imap_login(PORTS["imap"], "[email protected]", "x") == imap_no,
|
||||||
"and a made-up address on the domain gets the same (test 7)")
|
"and a made-up address on the domain gets the same (test 7)")
|
||||||
check(pop3_login(PORTS["pop3"], tu, user_pw) ==
|
check(pop3_login(PORTS["pop3"], tu, user_pw) ==
|
||||||
"-ERR [AUTH] Your organization allows only INBUXA webmail and JMAP apps. "
|
"-ERR [AUTH] Your organization allows only inbuxa webmail and JMAP apps. "
|
||||||
"This mail app can't sign in.", "POP3 refuses in its own form (test 8)")
|
"This mail app can't sign in.", "POP3 refuses in its own form (test 8)")
|
||||||
check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0] ==
|
check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0] ==
|
||||||
"535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. "
|
"535 5.7.0 Your organization allows only inbuxa webmail and JMAP apps. "
|
||||||
"This mail app can't send.", "submission refuses in its own form (test 8)")
|
"This mail app can't send.", "submission refuses in its own form (test 8)")
|
||||||
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
||||||
"an account on another domain signs in over IMAP normally (test 6)")
|
"an account on another domain signs in over IMAP normally (test 6)")
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
|
|||||||
summary.summary: Text("Meet me maybe")
|
summary.summary: Text("Meet me maybe")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
X-MICROSOFT-CDO-OWNERAPPTID:299828133
|
X-MICROSOFT-CDO-OWNERAPPTID:299828133
|
||||||
@@ -176,7 +176,7 @@ summary.summary: Text("Meet me maybe")
|
|||||||
~summary.description: Text("This is the event description")
|
~summary.description: Text("This is the event description")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
X-MICROSOFT-CDO-OWNERAPPTID:299828133
|
X-MICROSOFT-CDO-OWNERAPPTID:299828133
|
||||||
@@ -224,7 +224,7 @@ END:VCALENDAR
|
|||||||
# Make sure the original alarms are preserved
|
# Make sure the original alarms are preserved
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
X-MICROSOFT-CDO-OWNERAPPTID:299828133
|
X-MICROSOFT-CDO-OWNERAPPTID:299828133
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ summary.location: Text("Conference Room A")
|
|||||||
summary.summary: Text("Review Accounts")
|
summary.summary: Text("Review Accounts")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
LOCATION:Conference Room A
|
LOCATION:Conference Room A
|
||||||
@@ -114,7 +114,7 @@ summary.location: Text("Conference Room A")
|
|||||||
summary.summary: Text("Review Accounts")
|
summary.summary: Text("Review Accounts")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
LOCATION:Conference Room A
|
LOCATION:Conference Room A
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
|
|||||||
summary.summary: Text("Conference")
|
summary.summary: Text("Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -402,7 +402,7 @@ summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
|
|||||||
summary.summary: Text("Conference")
|
summary.summary: Text("Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -450,7 +450,7 @@ summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
|
|||||||
summary.summary: Text("Conference")
|
summary.summary: Text("Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(Partia
|
|||||||
summary.summary: Text("IETF Calendaring Working Group Meeting")
|
summary.summary: Text("IETF Calendaring Working Group Meeting")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
CLASS:PUBLIC
|
CLASS:PUBLIC
|
||||||
@@ -117,7 +117,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(Partia
|
|||||||
summary.summary: Text("IETF Calendaring Working Group Meeting")
|
summary.summary: Text("IETF Calendaring Working Group Meeting")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
CLASS:PUBLIC
|
CLASS:PUBLIC
|
||||||
@@ -207,7 +207,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(Partia
|
|||||||
summary.summary: Text("IETF Calendaring Working Group Meeting")
|
summary.summary: Text("IETF Calendaring Working Group Meeting")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:CANCEL
|
METHOD:CANCEL
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CANCELLED
|
STATUS:CANCELLED
|
||||||
@@ -226,7 +226,7 @@ END:VCALENDAR
|
|||||||
# Make sure B has the cancelled event
|
# Make sure B has the cancelled event
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
CLASS:PUBLIC
|
CLASS:PUBLIC
|
||||||
@@ -358,7 +358,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(Partia
|
|||||||
summary.summary: Text("IETF Calendaring Working Group Meeting")
|
summary.summary: Text("IETF Calendaring Working Group Meeting")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
CLASS:PUBLIC
|
CLASS:PUBLIC
|
||||||
@@ -386,7 +386,7 @@ END:VCALENDAR
|
|||||||
# Make sure B has the complete event including all updates
|
# Make sure B has the complete event including all updates
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
CLASS:PUBLIC
|
CLASS:PUBLIC
|
||||||
@@ -538,7 +538,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: Some(Partia
|
|||||||
summary.summary: Text("IETF Calendaring Working Group Meeting")
|
summary.summary: Text("IETF Calendaring Working Group Meeting")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:CANCEL
|
METHOD:CANCEL
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
DESCRIPTION:IETF-C&S Conference Call
|
DESCRIPTION:IETF-C&S Conference Call
|
||||||
@@ -564,7 +564,7 @@ END:VCALENDAR
|
|||||||
# Make sure all instances in B were deleted
|
# Make sure all instances in B were deleted
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
CLASS:PUBLIC
|
CLASS:PUBLIC
|
||||||
@@ -677,7 +677,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
|
|||||||
summary.summary: Text("Review Accounts")
|
summary.summary: Text("Review Accounts")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
LOCATION:The White Room
|
LOCATION:The White Room
|
||||||
@@ -742,7 +742,7 @@ summary.summary: Text("Review Accounts")
|
|||||||
~summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Tuesday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: Some(Sunday), rscale: None, skip: None })
|
~summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count: None, interval: None, bysecond: [], byminute: [], byhour: [], byday: [ICalendarDay { ordwk: None, weekday: Tuesday }], bymonthday: [], byyearday: [], byweekno: [], bymonth: [], bysetpos: [], wkst: Some(Sunday), rscale: None, skip: None })
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
LOCATION:The White Room
|
LOCATION:The White Room
|
||||||
@@ -770,7 +770,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
|
|||||||
summary.summary: Text("Review Accounts")
|
summary.summary: Text("Review Accounts")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:CANCEL
|
METHOD:CANCEL
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
LOCATION:The Red Room
|
LOCATION:The Red Room
|
||||||
@@ -791,7 +791,7 @@ END:VCALENDAR
|
|||||||
# Make sure B has the updated event
|
# Make sure B has the updated event
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
LOCATION:The White Room
|
LOCATION:The White Room
|
||||||
@@ -812,7 +812,7 @@ END:VCALENDAR
|
|||||||
# Make sure C has the updated event
|
# Make sure C has the updated event
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
LOCATION:The Red Room
|
LOCATION:The Red Room
|
||||||
@@ -853,7 +853,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
|
|||||||
summary.summary: Text("Review Accounts")
|
summary.summary: Text("Review Accounts")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Review Accounts
|
SUMMARY:Review Accounts
|
||||||
@@ -919,7 +919,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
|
|||||||
summary.summary: Text("Weekly Sync")
|
summary.summary: Text("Weekly Sync")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Weekly Sync
|
SUMMARY:Weekly Sync
|
||||||
@@ -967,7 +967,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Weekly, until: None, count:
|
|||||||
summary.summary: Text("Weekly Sync")
|
summary.summary: Text("Weekly Sync")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Weekly Sync
|
SUMMARY:Weekly Sync
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
|
|||||||
summary.summary: Text("Conference")
|
summary.summary: Text("Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -61,7 +61,7 @@ END:VCALENDAR
|
|||||||
# Make sure B receives the request
|
# Make sure B receives the request
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -88,7 +88,7 @@ END:VCALENDAR
|
|||||||
> put [email protected] [email protected]
|
> put [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
DTSTAMP:19970701T200000Z
|
DTSTAMP:19970701T200000Z
|
||||||
SEQUENCE:1
|
SEQUENCE:1
|
||||||
@@ -119,7 +119,7 @@ summary.dtstart: Time(ItipTime { start: 867787200, tz_id: 32768 })
|
|||||||
summary.summary: Text("Conference")
|
summary.summary: Text("Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Conference
|
SUMMARY:Conference
|
||||||
@@ -198,7 +198,7 @@ summary.summary: Text("Phone Conference")
|
|||||||
~summary.summary: Text("Conference")
|
~summary.summary: Text("Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -228,7 +228,7 @@ END:VCALENDAR
|
|||||||
# Make sure C receives the request
|
# Make sure C receives the request
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -256,7 +256,7 @@ END:VCALENDAR
|
|||||||
> put [email protected] [email protected]
|
> put [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
DTSTAMP:19970701T180000Z
|
DTSTAMP:19970701T180000Z
|
||||||
UID:[email protected]
|
UID:[email protected]
|
||||||
@@ -287,7 +287,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
|
|||||||
summary.summary: Text("Phone Conference")
|
summary.summary: Text("Phone Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Phone Conference
|
SUMMARY:Phone Conference
|
||||||
@@ -313,7 +313,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
|
|||||||
summary.summary: Text("Phone Conference")
|
summary.summary: Text("Phone Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -343,7 +343,7 @@ END:VCALENDAR
|
|||||||
# Make sure E receives the request
|
# Make sure E receives the request
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -398,7 +398,7 @@ END:VCALENDAR
|
|||||||
> put [email protected] [email protected]
|
> put [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
DTSTAMP:19970701T180000Z
|
DTSTAMP:19970701T180000Z
|
||||||
SEQUENCE:2
|
SEQUENCE:2
|
||||||
@@ -430,7 +430,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
|
|||||||
summary.summary: Text("Phone Conference")
|
summary.summary: Text("Phone Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Phone Conference
|
SUMMARY:Phone Conference
|
||||||
@@ -479,7 +479,7 @@ END:VCALENDAR
|
|||||||
# Make sure C receives the reply
|
# Make sure C receives the reply
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -508,7 +508,7 @@ END:VCALENDAR
|
|||||||
> put [email protected] [email protected]
|
> put [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
DTSTAMP:19970701T180000Z
|
DTSTAMP:19970701T180000Z
|
||||||
SEQUENCE:2
|
SEQUENCE:2
|
||||||
@@ -540,7 +540,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
|
|||||||
summary.summary: Text("Phone Conference")
|
summary.summary: Text("Phone Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Phone Conference
|
SUMMARY:Phone Conference
|
||||||
@@ -564,7 +564,7 @@ END:VCALENDAR
|
|||||||
# Make sure C receives the reply
|
# Make sure C receives the reply
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -650,7 +650,7 @@ summary.summary: Text("Phone Conference")
|
|||||||
~summary.attendee: Participants([ItipParticipant { email: "[email protected]", name: None, is_organizer: true }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }])
|
~summary.attendee: Participants([ItipParticipant { email: "[email protected]", name: None, is_organizer: true }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }, ItipParticipant { email: "[email protected]", name: Some("Hal"), is_organizer: false }, ItipParticipant { email: "[email protected]", name: None, is_organizer: false }])
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CONFIRMED
|
STATUS:CONFIRMED
|
||||||
@@ -681,7 +681,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
|
|||||||
summary.summary: Text("Phone Conference")
|
summary.summary: Text("Phone Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:CANCEL
|
METHOD:CANCEL
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CANCELLED
|
STATUS:CANCELLED
|
||||||
@@ -702,7 +702,7 @@ END:VCALENDAR
|
|||||||
# Make sure B receives the cancelation
|
# Make sure B receives the cancelation
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CANCELLED
|
STATUS:CANCELLED
|
||||||
@@ -727,7 +727,7 @@ summary.dtstart: Time(ItipTime { start: 867780000, tz_id: 32768 })
|
|||||||
summary.summary: Text("Phone Conference")
|
summary.summary: Text("Phone Conference")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:CANCEL
|
METHOD:CANCEL
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CANCELLED
|
STATUS:CANCELLED
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
|
|||||||
summary.summary: Text("Create the requirements document")
|
summary.summary: Text("Create the requirements document")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VTODO
|
BEGIN:VTODO
|
||||||
PRIORITY:1
|
PRIORITY:1
|
||||||
@@ -57,7 +57,7 @@ END:VCALENDAR
|
|||||||
# Make sure B received the todo
|
# Make sure B received the todo
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VTODO
|
BEGIN:VTODO
|
||||||
PRIORITY:1
|
PRIORITY:1
|
||||||
@@ -103,7 +103,7 @@ summary.attendee: Participants([ItipParticipant { email: "[email protected]", name:
|
|||||||
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
|
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VTODO
|
BEGIN:VTODO
|
||||||
STATUS:IN-PROCESS
|
STATUS:IN-PROCESS
|
||||||
@@ -172,7 +172,7 @@ summary.attendee: Participants([ItipParticipant { email: "[email protected]", name:
|
|||||||
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
|
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VTODO
|
BEGIN:VTODO
|
||||||
PERCENT-COMPLETE:75
|
PERCENT-COMPLETE:75
|
||||||
@@ -241,7 +241,7 @@ summary.attendee: Participants([ItipParticipant { email: "[email protected]", name:
|
|||||||
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
|
summary.dtstart: Time(ItipTime { start: 867776400, tz_id: 32768 })
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VTODO
|
BEGIN:VTODO
|
||||||
DUE:19970722T170000Z
|
DUE:19970722T170000Z
|
||||||
@@ -317,7 +317,7 @@ summary.summary: Text("Send Status Reports to Area Managers")
|
|||||||
~summary.summary: Text("Create the requirements document")
|
~summary.summary: Text("Create the requirements document")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VTODO
|
BEGIN:VTODO
|
||||||
PRIORITY:1
|
PRIORITY:1
|
||||||
@@ -347,7 +347,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: None, count
|
|||||||
summary.summary: Text("Create the requirements document")
|
summary.summary: Text("Create the requirements document")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:CANCEL
|
METHOD:CANCEL
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VTODO
|
BEGIN:VTODO
|
||||||
STATUS:CANCELLED
|
STATUS:CANCELLED
|
||||||
@@ -367,7 +367,7 @@ END:VCALENDAR
|
|||||||
# Make sure "C" received the cancel request
|
# Make sure "C" received the cancel request
|
||||||
> get [email protected] [email protected]
|
> get [email protected] [email protected]
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VTODO
|
BEGIN:VTODO
|
||||||
PRIORITY:1
|
PRIORITY:1
|
||||||
@@ -451,7 +451,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Monthly, until: None, count
|
|||||||
summary.summary: Text("Send Status Reports to Area Managers")
|
summary.summary: Text("Send Status Reports to Area Managers")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VTODO
|
BEGIN:VTODO
|
||||||
PERCENT-COMPLETE:75
|
PERCENT-COMPLETE:75
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Daily, until: None, count:
|
|||||||
summary.summary: Text("Review Internet-Draft")
|
summary.summary: Text("Review Internet-Draft")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Review Internet-Draft
|
SUMMARY:Review Internet-Draft
|
||||||
@@ -89,7 +89,7 @@ END:VCALENDAR
|
|||||||
# Make sure the participant receives the event
|
# Make sure the participant receives the event
|
||||||
> get [email protected] 9263504FD3AD
|
> get [email protected] 9263504FD3AD
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Review Internet-Draft
|
SUMMARY:Review Internet-Draft
|
||||||
@@ -185,7 +185,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Daily, until: None, count:
|
|||||||
summary.summary: Text("Review Internet-Draft")
|
summary.summary: Text("Review Internet-Draft")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Review Internet-Draft
|
SUMMARY:Review Internet-Draft
|
||||||
@@ -343,7 +343,7 @@ summary.rrule: Rrule(ICalendarRecurrenceRule { freq: Daily, until: None, count:
|
|||||||
summary.summary: Text("Review Internet-Draft")
|
summary.summary: Text("Review Internet-Draft")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Review Internet-Draft
|
SUMMARY:Review Internet-Draft
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ summary.dtstart: Time(ItipTime { start: 1243958400, tz_id: 32768 })
|
|||||||
summary.summary: Text("Lunch")
|
summary.summary: Text("Lunch")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Lunch
|
SUMMARY:Lunch
|
||||||
@@ -83,7 +83,7 @@ END:VCALENDAR
|
|||||||
# Make sure the message was received by the attendees
|
# Make sure the message was received by the attendees
|
||||||
> get [email protected] 9263504FD3AD
|
> get [email protected] 9263504FD3AD
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Lunch
|
SUMMARY:Lunch
|
||||||
@@ -170,7 +170,7 @@ summary.dtstart: Time(ItipTime { start: 1243958400, tz_id: 32768 })
|
|||||||
summary.summary: Text("Lunch")
|
summary.summary: Text("Lunch")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Lunch
|
SUMMARY:Lunch
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ summary.dtstart: Time(ItipTime { start: 1794322800, tz_id: 433 })
|
|||||||
summary.summary: Text("Berlin meeting")
|
summary.summary: Text("Berlin meeting")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Berlin meeting
|
SUMMARY:Berlin meeting
|
||||||
@@ -92,7 +92,7 @@ summary.dtstart: Time(ItipTime { start: 1794322800, tz_id: 433 })
|
|||||||
summary.summary: Text("Berlin meeting")
|
summary.summary: Text("Berlin meeting")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REPLY
|
METHOD:REPLY
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Berlin meeting
|
SUMMARY:Berlin meeting
|
||||||
@@ -144,7 +144,7 @@ summary.dtstart: Time(ItipTime { start: 1794322800, tz_id: 433 })
|
|||||||
summary.summary: Text("Berlin meeting")
|
summary.summary: Text("Berlin meeting")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:CANCEL
|
METHOD:CANCEL
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
STATUS:CANCELLED
|
STATUS:CANCELLED
|
||||||
@@ -228,7 +228,7 @@ summary.dtstart: Time(ItipTime { start: 1794322800, tz_id: 433 })
|
|||||||
summary.summary: Text("Berlin meeting")
|
summary.summary: Text("Berlin meeting")
|
||||||
BEGIN:VCALENDAR
|
BEGIN:VCALENDAR
|
||||||
METHOD:REQUEST
|
METHOD:REQUEST
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
BEGIN:VEVENT
|
BEGIN:VEVENT
|
||||||
SUMMARY:Berlin meeting
|
SUMMARY:Berlin meeting
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
require ["fileinto", "mailbox", "mailboxid", "special-use", "ihave", "imap4flags", "vnd.stalwart.expressions"];
|
require ["fileinto", "mailbox", "mailboxid", "special-use", "ihave", "imap4flags", "vnd.inbuxa.expressions"];
|
||||||
|
|
||||||
# SpecialUse extension tests
|
# SpecialUse extension tests
|
||||||
if not specialuse_exists ["inbox", "trash"] {
|
if not specialuse_exists ["inbox", "trash"] {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
CORE = "urn:ietf:params:jmap:core"
|
CORE = "urn:ietf:params:jmap:core"
|
||||||
STALWART = "urn:stalwart:jmap"
|
STALWART = "urn:inbuxa:jmap:registry"
|
||||||
USING = [CORE, STALWART]
|
USING = [CORE, STALWART]
|
||||||
|
|
||||||
DEFAULT_BASE_URL = "https://127.0.0.1"
|
DEFAULT_BASE_URL = "https://127.0.0.1"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
require ["variables", "include", "vnd.stalwart.expressions", "reject"];
|
require ["variables", "include", "vnd.inbuxa.expressions", "reject"];
|
||||||
|
|
||||||
global "score";
|
global "score";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
require ["variables", "include", "vnd.stalwart.expressions", "reject"];
|
require ["variables", "include", "vnd.inbuxa.expressions", "reject"];
|
||||||
|
|
||||||
global "score";
|
global "score";
|
||||||
set "awl_factor" "0.5";
|
set "awl_factor" "0.5";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
require ["variables", "envelope", "reject", "vnd.stalwart.expressions"];
|
require ["variables", "envelope", "reject", "vnd.inbuxa.expressions"];
|
||||||
|
|
||||||
if envelope :localpart :is "from" "spammer" {
|
if envelope :localpart :is "from" "spammer" {
|
||||||
reject "450 4.1.1 Invalid address";
|
reject "450 4.1.1 Invalid address";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
require ["variables", "envelope", "reject", "vnd.stalwart.expressions"];
|
require ["variables", "envelope", "reject", "vnd.inbuxa.expressions"];
|
||||||
|
|
||||||
if envelope :domain :is "to" "foobar.org" {
|
if envelope :domain :is "to" "foobar.org" {
|
||||||
eval "query('sql', 'CREATE TABLE IF NOT EXISTS greylist (addr TEXT PRIMARY KEY)', [])";
|
eval "query('sql', 'CREATE TABLE IF NOT EXISTS greylist (addr TEXT PRIMARY KEY)', [])";
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) {
|
|||||||
imap.send("ID").await;
|
imap.send("ID").await;
|
||||||
imap.assert_read(Type::Tagged, ResponseType::Ok)
|
imap.assert_read(Type::Tagged, ResponseType::Ok)
|
||||||
.await
|
.await
|
||||||
.assert_contains("* ID (\"name\" \"INBUXA\" \"version\" ");
|
.assert_contains("* ID (\"name\" \"inbuxa\" \"version\" ");
|
||||||
|
|
||||||
// Login should be disabled
|
// Login should be disabled
|
||||||
imap.send("LOGIN [email protected] secret").await;
|
imap.send("LOGIN [email protected] secret").await;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ pub async fn test(test: &TestServer) {
|
|||||||
list[0],
|
list[0],
|
||||||
json!({
|
json!({
|
||||||
"id": default_calendar_id,
|
"id": default_calendar_id,
|
||||||
"name": "INBUXA Calendar ([email protected])",
|
"name": "inbuxa Calendar ([email protected])",
|
||||||
"description": null,
|
"description": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"isSubscribed": true,
|
"isSubscribed": true,
|
||||||
@@ -310,7 +310,7 @@ pub async fn test(test: &TestServer) {
|
|||||||
}));
|
}));
|
||||||
response.list()[1].assert_is_equal(json!({
|
response.list()[1].assert_is_equal(json!({
|
||||||
"id": default_calendar_id,
|
"id": default_calendar_id,
|
||||||
"name": "INBUXA Calendar ([email protected])",
|
"name": "inbuxa Calendar ([email protected])",
|
||||||
"description": (),
|
"description": (),
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"isSubscribed": true,
|
"isSubscribed": true,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ pub async fn test(test: &TestServer) {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
list[0],
|
list[0],
|
||||||
json!({
|
json!({
|
||||||
"name": "INBUXA Address Book ([email protected])",
|
"name": "inbuxa Address Book ([email protected])",
|
||||||
"description": (),
|
"description": (),
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"isSubscribed": true,
|
"isSubscribed": true,
|
||||||
@@ -148,7 +148,7 @@ pub async fn test(test: &TestServer) {
|
|||||||
"id": addressbook_id,
|
"id": addressbook_id,
|
||||||
}),
|
}),
|
||||||
json!({
|
json!({
|
||||||
"name": "INBUXA Address Book ([email protected])",
|
"name": "inbuxa Address Book ([email protected])",
|
||||||
"description": (),
|
"description": (),
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"isSubscribed": true,
|
"isSubscribed": true,
|
||||||
|
|||||||
@@ -250,9 +250,10 @@ pub async fn test(test: &TestServer) {
|
|||||||
"webWriteUrlTemplate": null
|
"webWriteUrlTemplate": null
|
||||||
},
|
},
|
||||||
"urn:ietf:params:jmap:mail:share": {},
|
"urn:ietf:params:jmap:mail:share": {},
|
||||||
"urn:stalwart:jmap": {},
|
"urn:inbuxa:jmap:registry": {},
|
||||||
// inbuxa: MT-22, the logo that applies to the account
|
// inbuxa: MT-22, the logo that applies to the account, and
|
||||||
"urn:inbuxa:jmap": { "logo": null },
|
// LP-19, whether the legacy protocols are open to it
|
||||||
|
"urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled" },
|
||||||
"https://www.fastmail.com/dev/maskedemail": {}
|
"https://www.fastmail.com/dev/maskedemail": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,7 +275,7 @@ pub async fn test(test: &TestServer) {
|
|||||||
"urn:ietf:params:jmap:principals:availability": john_id,
|
"urn:ietf:params:jmap:principals:availability": john_id,
|
||||||
"urn:ietf:params:jmap:filenode": john_id,
|
"urn:ietf:params:jmap:filenode": john_id,
|
||||||
"urn:ietf:params:jmap:mail:share": john_id,
|
"urn:ietf:params:jmap:mail:share": john_id,
|
||||||
"urn:stalwart:jmap": john_id,
|
"urn:inbuxa:jmap:registry": john_id,
|
||||||
"https://www.fastmail.com/dev/maskedemail": john_id
|
"https://www.fastmail.com/dev/maskedemail": john_id
|
||||||
},
|
},
|
||||||
"username": "[email protected]",
|
"username": "[email protected]",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ pub mod imap;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod jmap;
|
pub mod jmap;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
pub mod renamed_identifiers; // inbuxa: SPEC.md §2.4
|
||||||
|
#[cfg(test)]
|
||||||
pub mod scim;
|
pub mod scim;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod smtp;
|
pub mod smtp;
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! inbuxa: what an install from before the rename carries over (SPEC.md
|
||||||
|
//! §2.4). The web interface's OAuth client is retired rather than kept as an
|
||||||
|
//! alias, and a trained spam filter moves to its new keys.
|
||||||
|
|
||||||
|
use crate::utils::server::TestServerBuilder;
|
||||||
|
use common::manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, first_party::WEB_INTERFACE_CLIENT_ID};
|
||||||
|
use registry::{
|
||||||
|
schema::{
|
||||||
|
enums::CompressionAlgo,
|
||||||
|
prelude::{ObjectType, Property},
|
||||||
|
structs::{Application, OAuthClient},
|
||||||
|
},
|
||||||
|
types::map::Map,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OLD_CLIENT_ID: &str = "stalwart-webui";
|
||||||
|
const OLD_TRAINER_KEY: &[u8] = b"STALWART_SPAM_TRAIN_DATA.lz4";
|
||||||
|
const OLD_CLASSIFIER_KEY: &[u8] = b"STALWART_SPAM_CLASSIFIER_MODEL.lz4";
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn renamed_identifiers() {
|
||||||
|
// The web interface registered under upstream's client id, and an
|
||||||
|
// application naming it. Disabled, so nothing is fetched for it.
|
||||||
|
let test = TestServerBuilder::new("renamed_identifiers")
|
||||||
|
.await
|
||||||
|
.with_object(OAuthClient {
|
||||||
|
client_id: OLD_CLIENT_ID.to_string(),
|
||||||
|
redirect_uris: Map::new(vec!["https://127.0.0.1/admin/oauth/callback".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.with_object(Application {
|
||||||
|
enabled: false,
|
||||||
|
description: "Web interface".to_string(),
|
||||||
|
resource_url: "https://127.0.0.1/webui.zip".to_string(),
|
||||||
|
url_prefix: Map::new(vec!["/admin".to_string()]),
|
||||||
|
oauth_client_id: Some(OLD_CLIENT_ID.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.disable_services()
|
||||||
|
.build()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
println!("Running renamed identifier tests...");
|
||||||
|
let registry = test.server.registry();
|
||||||
|
assert_eq!(
|
||||||
|
registry
|
||||||
|
.primary_key(
|
||||||
|
ObjectType::OAuthClient.into(),
|
||||||
|
Property::ClientId,
|
||||||
|
OLD_CLIENT_ID.as_bytes().to_vec(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
None,
|
||||||
|
"the pre-rename web interface client is retired on start"
|
||||||
|
);
|
||||||
|
let applications = registry.list::<Application>().await.unwrap();
|
||||||
|
assert_eq!(applications.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
applications[0].object.oauth_client_id.as_deref(),
|
||||||
|
Some(WEB_INTERFACE_CLIENT_ID),
|
||||||
|
"an application naming the old client moves to the new one"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A trained model under the pre-rename keys moves to the new ones.
|
||||||
|
let blobs = test.server.blob_store();
|
||||||
|
for (key, data) in [
|
||||||
|
(OLD_TRAINER_KEY, &b"trainer"[..]),
|
||||||
|
(OLD_CLASSIFIER_KEY, &b"classifier"[..]),
|
||||||
|
] {
|
||||||
|
blobs.put_blob(key, data, CompressionAlgo::None).await.unwrap();
|
||||||
|
}
|
||||||
|
migration::try_migrate(&test.server).await.unwrap();
|
||||||
|
for (old, new, data) in [
|
||||||
|
(OLD_TRAINER_KEY, SPAM_TRAINER_KEY, &b"trainer"[..]),
|
||||||
|
(OLD_CLASSIFIER_KEY, SPAM_CLASSIFIER_KEY, &b"classifier"[..]),
|
||||||
|
] {
|
||||||
|
assert_eq!(blobs.get_blob(new, 0..usize::MAX).await.unwrap().as_deref(), Some(data));
|
||||||
|
assert_eq!(blobs.get_blob(old, 0..usize::MAX).await.unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A blob already under the new key wins over a stale old one.
|
||||||
|
blobs
|
||||||
|
.put_blob(OLD_CLASSIFIER_KEY, b"stale", CompressionAlgo::None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
migration::try_migrate(&test.server).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
blobs
|
||||||
|
.get_blob(SPAM_CLASSIFIER_KEY, 0..usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some(&b"classifier"[..])
|
||||||
|
);
|
||||||
|
assert_eq!(blobs.get_blob(OLD_CLASSIFIER_KEY, 0..usize::MAX).await.unwrap(), None);
|
||||||
|
|
||||||
|
// With nothing left to move, starting again changes nothing.
|
||||||
|
migration::try_migrate(&test.server).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
blobs.get_blob(SPAM_TRAINER_KEY, 0..usize::MAX).await.unwrap().as_deref(),
|
||||||
|
Some(&b"trainer"[..])
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -106,7 +106,7 @@ async fn discovery(scim: &ScimTest) {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!config.body.contains("stalw"),
|
!config.body.contains("stalw"),
|
||||||
"SCIM-4: documentation is INBUXA's own"
|
"SCIM-4: documentation is inbuxa's own"
|
||||||
);
|
);
|
||||||
|
|
||||||
let types = anonymous.get("/ResourceTypes").await;
|
let types = anonymous.get("/ResourceTypes").await;
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ use std::{
|
|||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// inbuxa: its HTTP listener (19048) is shared with the dkim2 and report
|
||||||
|
// tests, which are serial; without this it ran beside them and each got the
|
||||||
|
// other's server.
|
||||||
|
#[serial_test::serial]
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn antispam() {
|
async fn antispam() {
|
||||||
let mut test = TestServerBuilder::new("smtp_antispam_test")
|
let mut test = TestServerBuilder::new("smtp_antispam_test")
|
||||||
@@ -81,11 +85,11 @@ async fn antispam() {
|
|||||||
admin
|
admin
|
||||||
.registry_create_object(SpamSettings {
|
.registry_create_object(SpamSettings {
|
||||||
score_spam: Float::new(5.0),
|
score_spam: Float::new(5.0),
|
||||||
spam_filter_rules_url: std::env::var("SPAM_RULES_URL")
|
// inbuxa: the rules carry the scores the expectations are written
|
||||||
.unwrap_or_else(|_| {
|
// against. Unset, the server uses the rules bundled with it
|
||||||
"file:///Users/me/code/spam-filter/spam-filter-rules.json.gz".to_string()
|
// (resources/spam-filter/), the path production takes;
|
||||||
})
|
// SPAM_RULES_URL tests another set.
|
||||||
.into(),
|
spam_filter_rules_url: std::env::var("SPAM_RULES_URL").ok(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
@@ -156,7 +160,7 @@ async fn antispam() {
|
|||||||
}))
|
}))
|
||||||
.await;
|
.await;
|
||||||
// inbuxa: a rules file that can't be read is retried later; don't wait
|
// inbuxa: a rules file that can't be read is retried later; don't wait
|
||||||
// for that retry (the path above is a developer's own checkout)
|
// for that retry (SPAM_RULES_URL may name one that isn't there)
|
||||||
test.wait_for_tasks_skip_not_due().await;
|
test.wait_for_tasks_skip_not_due().await;
|
||||||
admin.reload_settings().await;
|
admin.reload_settings().await;
|
||||||
admin.reload_lookup_stores().await;
|
admin.reload_lookup_stores().await;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -11,7 +13,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
utils::server::TestServerBuilder,
|
utils::server::TestServerBuilder,
|
||||||
};
|
};
|
||||||
use ahash::AHashSet;
|
use ahash::{AHashMap, AHashSet};
|
||||||
use common::{
|
use common::{
|
||||||
config::smtp::queue::QueueName,
|
config::smtp::queue::QueueName,
|
||||||
ipc::{QueueEvent, QueueEventStatus},
|
ipc::{QueueEvent, QueueEventStatus},
|
||||||
@@ -180,7 +182,15 @@ async fn queue_retry() {
|
|||||||
let attempt = local.expect_message_for_queue_then_deliver("default").await;
|
let attempt = local.expect_message_for_queue_then_deliver("default").await;
|
||||||
let mut dsn = Vec::new();
|
let mut dsn = Vec::new();
|
||||||
let mut retries = Vec::new();
|
let mut retries = Vec::new();
|
||||||
|
// inbuxa: when each attempt started, by test clock. The server sets the
|
||||||
|
// next retry from its clock when the attempt defers, which is at or after
|
||||||
|
// this and under a second later, so due - started is the interval or one
|
||||||
|
// more. Measuring from when the loop next sees the message instead made
|
||||||
|
// the result shrink by however long saving and reporting took, and it
|
||||||
|
// failed whenever that crossed a second boundary (under load, often).
|
||||||
|
let mut started = AHashMap::new();
|
||||||
in_fight.insert(attempt.queue_id);
|
in_fight.insert(attempt.queue_id);
|
||||||
|
started.insert(attempt.queue_id, now());
|
||||||
attempt.try_deliver(local.server.clone());
|
attempt.try_deliver(local.server.clone());
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -226,15 +236,22 @@ async fn queue_retry() {
|
|||||||
.await;
|
.await;
|
||||||
dsn.push(message);
|
dsn.push(message);
|
||||||
} else {
|
} else {
|
||||||
retries.push(event.due.saturating_sub(now));
|
retries.push(event.due.saturating_sub(started[&event.queue_id]));
|
||||||
in_fight.insert(event.queue_id);
|
in_fight.insert(event.queue_id);
|
||||||
|
started.insert(event.queue_id, store::write::now());
|
||||||
event.try_deliver(local.server.clone());
|
event.try_deliver(local.server.clone());
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
local.assert_queue_is_empty().await;
|
local.assert_queue_is_empty().await;
|
||||||
assert_eq!(retries, vec![1, 2, 3]);
|
assert_eq!(retries.len(), 3, "retries: {retries:?}");
|
||||||
|
for (retry, interval) in retries.iter().zip([1, 2, 3]) {
|
||||||
|
assert!(
|
||||||
|
(interval..=interval + 1).contains(retry),
|
||||||
|
"retry after {retry}s where the schedule says {interval}s: {retries:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
assert_eq!(dsn.len(), 4);
|
assert_eq!(dsn.len(), 4);
|
||||||
let mut dsn = dsn.into_iter();
|
let mut dsn = dsn.into_iter();
|
||||||
|
|
||||||
|
|||||||
@@ -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::utils::{
|
use crate::utils::{
|
||||||
@@ -9,14 +11,22 @@ use crate::utils::{
|
|||||||
server::TestServer,
|
server::TestServer,
|
||||||
temp_dir::TempDir,
|
temp_dir::TempDir,
|
||||||
};
|
};
|
||||||
use ::registry::schema::enums::CompressionAlgo;
|
use ::registry::schema::{
|
||||||
|
enums::{CompressionAlgo, TaskStoreMaintenanceType},
|
||||||
|
prelude::ObjectType,
|
||||||
|
structs::Task,
|
||||||
|
};
|
||||||
use ahash::AHashSet;
|
use ahash::AHashSet;
|
||||||
use common::{DATABASE_SCHEMA_VERSION, manager::backup::BackupParams};
|
use common::{
|
||||||
|
DATABASE_SCHEMA_VERSION,
|
||||||
|
manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, backup::BackupParams},
|
||||||
|
};
|
||||||
use store::{
|
use store::{
|
||||||
rand,
|
rand,
|
||||||
write::{
|
write::{
|
||||||
AnyClass, AnyKey, BatchBuilder, BlobLink, BlobOp, Operation, QueueClass, QueueEvent,
|
AnyClass, AnyKey, BatchBuilder, BlobLink, BlobOp, Operation, QueueClass, QueueEvent,
|
||||||
RegistryClass, ValueClass, key::KeySerializer,
|
RegistryClass, TaskQueueClass, ValueClass,
|
||||||
|
key::{DeserializeBigEndian, KeySerializer},
|
||||||
},
|
},
|
||||||
*,
|
*,
|
||||||
};
|
};
|
||||||
@@ -167,6 +177,50 @@ pub async fn test(test: &TestServer) {
|
|||||||
}
|
}
|
||||||
db.write(batch.build_all()).await.unwrap();
|
db.write(batch.build_all()).await.unwrap();
|
||||||
|
|
||||||
|
// inbuxa: registry objects kept outside the registry subspace (archived
|
||||||
|
// items for undelete, spam training samples, directory entries) and the
|
||||||
|
// fork's own subspace. Exports used to leave the first two behind.
|
||||||
|
println!("Creating archived items, spam samples and fork data...");
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
for item_id in [1u64, 2, 3] {
|
||||||
|
for object in [
|
||||||
|
ObjectType::ArchivedItem,
|
||||||
|
ObjectType::SpamTrainingSample,
|
||||||
|
ObjectType::Account,
|
||||||
|
] {
|
||||||
|
batch.set(
|
||||||
|
ValueClass::Registry(RegistryClass::Item {
|
||||||
|
object_id: object as u16,
|
||||||
|
item_id,
|
||||||
|
}),
|
||||||
|
random_bytes(item_id as usize * 64),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
batch.set(
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key: [b'U', b'x']
|
||||||
|
.into_iter()
|
||||||
|
.chain(item_id.to_be_bytes())
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
|
random_bytes(32),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
db.write(batch.build_all()).await.unwrap();
|
||||||
|
|
||||||
|
// inbuxa: the trained spam classifier lives in blobs with fixed names
|
||||||
|
let mut named_blobs = Vec::new();
|
||||||
|
for key in [SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY] {
|
||||||
|
let data = random_bytes(4096);
|
||||||
|
test.server
|
||||||
|
.blob_store()
|
||||||
|
.put_blob(key, &data, CompressionAlgo::Lz4)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
named_blobs.push((key, data));
|
||||||
|
}
|
||||||
|
|
||||||
// Create directory data
|
// Create directory data
|
||||||
println!("Creating directory data...");
|
println!("Creating directory data...");
|
||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
@@ -185,6 +239,17 @@ pub async fn test(test: &TestServer) {
|
|||||||
println!("Calculating store hash...");
|
println!("Calculating store hash...");
|
||||||
let snapshot = Snapshot::new(&db).await;
|
let snapshot = Snapshot::new(&db).await;
|
||||||
assert!(!snapshot.keys.is_empty(), "Store hash counts are empty",);
|
assert!(!snapshot.keys.is_empty(), "Store hash counts are empty",);
|
||||||
|
for subspace in [
|
||||||
|
SUBSPACE_DELETED_ITEMS,
|
||||||
|
SUBSPACE_SPAM_SAMPLES,
|
||||||
|
SUBSPACE_INBUXA,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
snapshot.keys.iter().any(|k| k.subspace == subspace),
|
||||||
|
"No test data in subspace {}",
|
||||||
|
char::from(subspace)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Export store
|
// Export store
|
||||||
println!("Exporting store...");
|
println!("Exporting store...");
|
||||||
@@ -210,22 +275,188 @@ pub async fn test(test: &TestServer) {
|
|||||||
.finalize(),
|
.finalize(),
|
||||||
);
|
);
|
||||||
db.write(batch.build_all()).await.unwrap();
|
db.write(batch.build_all()).await.unwrap();
|
||||||
test.server.core.restore(temp_dir.path.clone()).await;
|
for (key, _) in &named_blobs {
|
||||||
|
test.server.blob_store().delete_blob(key).await.unwrap();
|
||||||
|
}
|
||||||
|
let imported = test.server.core.restore(temp_dir.path.clone()).await;
|
||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
batch.clear(ValueClass::NodeId(0));
|
batch.clear(ValueClass::NodeId(0));
|
||||||
db.write(batch.build_all()).await.unwrap();
|
db.write(batch.build_all()).await.unwrap();
|
||||||
|
for subspace in [
|
||||||
|
SUBSPACE_DELETED_ITEMS,
|
||||||
|
SUBSPACE_SPAM_SAMPLES,
|
||||||
|
SUBSPACE_INBUXA,
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
imported.contains(&subspace),
|
||||||
|
"Subspace {} was not exported",
|
||||||
|
char::from(subspace)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Verify hash
|
// Verify hash
|
||||||
print!("Verifying store hash...");
|
print!("Verifying store hash...");
|
||||||
snapshot.assert_is_eq(&Snapshot::new(&db).await);
|
snapshot.assert_is_eq(&Snapshot::new(&db).await);
|
||||||
|
assert_named_blobs(test.server.blob_store(), &named_blobs).await;
|
||||||
println!(" GREAT SUCCESS!");
|
println!(" GREAT SUCCESS!");
|
||||||
|
|
||||||
|
// inbuxa: import the same export into a fresh store of another backend,
|
||||||
|
// the way a move from one database to another does it
|
||||||
|
#[cfg(all(feature = "rocks", feature = "sqlite"))]
|
||||||
|
cross_backend(test, &db, &temp_dir, &named_blobs).await;
|
||||||
|
|
||||||
// Destroy store
|
// Destroy store
|
||||||
|
for (key, _) in &named_blobs {
|
||||||
|
test.server.blob_store().delete_blob(key).await.unwrap();
|
||||||
|
}
|
||||||
store_destroy(&db).await;
|
store_destroy(&db).await;
|
||||||
store_assert_is_empty(&db, db.clone().into(), true).await;
|
store_assert_is_empty(&db, db.clone().into(), true).await;
|
||||||
temp_dir.delete();
|
temp_dir.delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "rocks", feature = "sqlite"))]
|
||||||
|
async fn cross_backend(
|
||||||
|
test: &TestServer,
|
||||||
|
source: &Store,
|
||||||
|
export: &TempDir,
|
||||||
|
named_blobs: &[(&[u8], Vec<u8>)],
|
||||||
|
) {
|
||||||
|
let source_type = std::env::var("STORE").unwrap();
|
||||||
|
let target_type = if source_type.eq_ignore_ascii_case("sqlite") {
|
||||||
|
"RocksDb"
|
||||||
|
} else {
|
||||||
|
"Sqlite"
|
||||||
|
};
|
||||||
|
println!("Importing the export into a fresh {target_type} store...");
|
||||||
|
|
||||||
|
let target_dir = TempDir::new("art_vandelay_cross_backend", true);
|
||||||
|
let target = Store::build(
|
||||||
|
crate::utils::storage::build_data_store(target_type, &target_dir.path.to_string_lossy())
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
target.create_tables().await.unwrap();
|
||||||
|
store_destroy(&target).await;
|
||||||
|
|
||||||
|
let mut core = test.server.core.as_ref().clone();
|
||||||
|
core.storage.data = target.clone();
|
||||||
|
core.storage.blob = target.clone().into();
|
||||||
|
let imported = core.restore(export.path.clone()).await;
|
||||||
|
|
||||||
|
// Counters are stored differently by the SQL and key-value backends, so
|
||||||
|
// compare their keys here and their values through the counter API.
|
||||||
|
print!("Verifying {target_type} store hash...");
|
||||||
|
Snapshot::new_portable(source)
|
||||||
|
.await
|
||||||
|
.assert_is_eq(&Snapshot::new_portable(&target).await);
|
||||||
|
for subspace in [SUBSPACE_COUNTER, SUBSPACE_QUOTA] {
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
source
|
||||||
|
.iterate(
|
||||||
|
IterateParams::new(
|
||||||
|
AnyKey {
|
||||||
|
subspace,
|
||||||
|
key: vec![0u8],
|
||||||
|
},
|
||||||
|
AnyKey {
|
||||||
|
subspace,
|
||||||
|
key: vec![u8::MAX; 10],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.no_values(),
|
||||||
|
|key, _| {
|
||||||
|
keys.push(key.to_vec());
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
for key in keys {
|
||||||
|
let class = || {
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace,
|
||||||
|
key: key.clone(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
source.get_counter(class()).await.unwrap(),
|
||||||
|
target.get_counter(class()).await.unwrap(),
|
||||||
|
"Counter mismatch in {} for {key:?}",
|
||||||
|
char::from(subspace)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_named_blobs(&core.storage.blob, named_blobs).await;
|
||||||
|
println!(" GREAT SUCCESS!");
|
||||||
|
|
||||||
|
// The search index isn't exported; the import queues its rebuild
|
||||||
|
let queued = core.queue_reindex(&imported).await;
|
||||||
|
let expected = [
|
||||||
|
TaskStoreMaintenanceType::ReindexAccounts,
|
||||||
|
TaskStoreMaintenanceType::ReindexTelemetry,
|
||||||
|
];
|
||||||
|
assert_eq!(queued, expected);
|
||||||
|
let mut task_ids = Vec::new();
|
||||||
|
target
|
||||||
|
.iterate(
|
||||||
|
IterateParams::new(
|
||||||
|
AnyKey {
|
||||||
|
subspace: SUBSPACE_TASK_QUEUE,
|
||||||
|
key: vec![0u8],
|
||||||
|
},
|
||||||
|
AnyKey {
|
||||||
|
subspace: SUBSPACE_TASK_QUEUE,
|
||||||
|
key: vec![u8::MAX; 20],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.no_values(),
|
||||||
|
|key, _| {
|
||||||
|
if key.deserialize_be_u64(0)? == 0 {
|
||||||
|
task_ids.push(key.deserialize_be_u64(U64_LEN)?);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for id in task_ids {
|
||||||
|
match target
|
||||||
|
.get_value::<Task>(ValueKey::from(ValueClass::TaskQueue(
|
||||||
|
TaskQueueClass::Task { id },
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
{
|
||||||
|
Some(Task::StoreMaintenance(task)) => found.push(task.maintenance_type),
|
||||||
|
other => panic!("Unexpected task {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found.sort_by_key(|t| *t as u16);
|
||||||
|
assert_eq!(found, expected, "Queued tasks don't match");
|
||||||
|
|
||||||
|
store_destroy(&target).await;
|
||||||
|
drop(core);
|
||||||
|
drop(target);
|
||||||
|
target_dir.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_named_blobs(blob_store: &BlobStore, named_blobs: &[(&[u8], Vec<u8>)]) {
|
||||||
|
for (key, data) in named_blobs {
|
||||||
|
assert_eq!(
|
||||||
|
blob_store
|
||||||
|
.get_blob(key, 0..usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_ref(),
|
||||||
|
Some(data),
|
||||||
|
"Blob {} was not restored",
|
||||||
|
String::from_utf8_lossy(key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
struct Snapshot {
|
struct Snapshot {
|
||||||
keys: AHashSet<KeyValue>,
|
keys: AHashSet<KeyValue>,
|
||||||
@@ -240,7 +471,19 @@ struct KeyValue {
|
|||||||
|
|
||||||
impl Snapshot {
|
impl Snapshot {
|
||||||
async fn new(db: &Store) -> Self {
|
async fn new(db: &Store) -> Self {
|
||||||
let is_sql = db.is_sql();
|
Self::build(db, !db.is_sql(), true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comparable across backends: no counter values, which the SQL and
|
||||||
|
/// key-value stores encode differently, and no blobs, which only live in
|
||||||
|
/// the data store when it doubles as the blob store.
|
||||||
|
#[cfg(all(feature = "rocks", feature = "sqlite"))]
|
||||||
|
async fn new_portable(db: &Store) -> Self {
|
||||||
|
Self::build(db, false, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build(db: &Store, counter_values: bool, with_blobs: bool) -> Self {
|
||||||
|
let is_sql = !counter_values;
|
||||||
|
|
||||||
let mut keys = AHashSet::new();
|
let mut keys = AHashSet::new();
|
||||||
|
|
||||||
@@ -265,7 +508,12 @@ impl Snapshot {
|
|||||||
(SUBSPACE_QUOTA, !is_sql),
|
(SUBSPACE_QUOTA, !is_sql),
|
||||||
(SUBSPACE_REPORT_OUT, true),
|
(SUBSPACE_REPORT_OUT, true),
|
||||||
(SUBSPACE_REPORT_IN, true),
|
(SUBSPACE_REPORT_IN, true),
|
||||||
|
(SUBSPACE_DIRECTORY, true),
|
||||||
|
(SUBSPACE_INBUXA, true),
|
||||||
] {
|
] {
|
||||||
|
if subspace == SUBSPACE_BLOBS && !with_blobs {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let from_key = AnyKey {
|
let from_key = AnyKey {
|
||||||
subspace,
|
subspace,
|
||||||
key: vec![0u8],
|
key: vec![0u8],
|
||||||
|
|||||||
@@ -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::{store::deflate_test_resource, utils::server::TestServer};
|
use crate::{store::deflate_test_resource, utils::server::TestServer};
|
||||||
@@ -122,6 +124,10 @@ pub async fn test(test: &TestServer) {
|
|||||||
println!("Running global id filtering tests...");
|
println!("Running global id filtering tests...");
|
||||||
test_global(store.clone()).await;
|
test_global(store.clone()).await;
|
||||||
|
|
||||||
|
// inbuxa: trace documents as the index task builds them
|
||||||
|
println!("Running trace document tests...");
|
||||||
|
test_trace_documents(store.clone()).await;
|
||||||
|
|
||||||
// Large document insert test
|
// Large document insert test
|
||||||
println!("Running large document insert tests...");
|
println!("Running large document insert tests...");
|
||||||
let mut large_text = String::with_capacity(20 * 1024 * 1024);
|
let mut large_text = String::with_capacity(20 * 1024 * 1024);
|
||||||
@@ -809,3 +815,160 @@ async fn test_global(store: SearchStore) {
|
|||||||
AHashSet::from_iter([3, 4, 5])
|
AHashSet::from_iter([3, 4, 5])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MON-16: documents built by the index task from stored traces go
|
||||||
|
// into every search backend (the SQL backends type etyp and qid as BIGINT)
|
||||||
|
// and are found again by queue id and keyword.
|
||||||
|
async fn test_trace_documents(store: SearchStore) {
|
||||||
|
use registry::schema::{
|
||||||
|
enums::SearchTracingField,
|
||||||
|
structs::{
|
||||||
|
Trace, TraceEvent, TraceKeyValue, TraceValue, TraceValueString,
|
||||||
|
TraceValueUnsignedInt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use services::task_manager::index::trace_search_document;
|
||||||
|
use trc::{DeliveryEvent, EventType, Key, SmtpEvent};
|
||||||
|
|
||||||
|
let kv_u = |key: Key, value: u64| TraceKeyValue {
|
||||||
|
key,
|
||||||
|
value: TraceValue::UnsignedInt(TraceValueUnsignedInt { value }),
|
||||||
|
};
|
||||||
|
let kv_s = |key: Key, value: &str| TraceKeyValue {
|
||||||
|
key,
|
||||||
|
value: TraceValue::String(TraceValueString {
|
||||||
|
value: value.to_string(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let event = |event: EventType, key_values: Vec<TraceKeyValue>| TraceEvent {
|
||||||
|
event,
|
||||||
|
key_values: key_values.into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let fields = [
|
||||||
|
SearchTracingField::EventType,
|
||||||
|
SearchTracingField::QueueId,
|
||||||
|
SearchTracingField::Keywords,
|
||||||
|
];
|
||||||
|
|
||||||
|
// An SMTP session that queued two messages, and a delivery attempt
|
||||||
|
let session = Trace {
|
||||||
|
events: vec![
|
||||||
|
event(
|
||||||
|
EventType::Smtp(SmtpEvent::ConnectionStart),
|
||||||
|
vec![kv_s(Key::RemoteIp, "192.0.2.7")],
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
EventType::Smtp(SmtpEvent::MailFrom),
|
||||||
|
vec![kv_s(Key::From, "[email protected]")],
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
EventType::Smtp(SmtpEvent::RcptTo),
|
||||||
|
vec![kv_u(Key::QueueId, 9_000_000_001), kv_s(Key::To, "[email protected]")],
|
||||||
|
),
|
||||||
|
event(
|
||||||
|
EventType::Smtp(SmtpEvent::RcptTo),
|
||||||
|
vec![kv_u(Key::QueueId, 9_000_000_002)],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
};
|
||||||
|
let delivery = Trace {
|
||||||
|
events: vec![event(
|
||||||
|
EventType::Delivery(DeliveryEvent::AttemptStart),
|
||||||
|
vec![kv_u(Key::QueueId, 9_000_000_003), kv_s(Key::Hostname, "relay.example.net")],
|
||||||
|
)]
|
||||||
|
.into(),
|
||||||
|
};
|
||||||
|
let documents = vec![
|
||||||
|
trace_search_document(100, &session, &fields),
|
||||||
|
trace_search_document(101, &delivery, &fields),
|
||||||
|
];
|
||||||
|
assert!(
|
||||||
|
documents
|
||||||
|
.iter()
|
||||||
|
.all(|d| d.has_field(&SearchField::Tracing(TracingSearchField::QueueId))
|
||||||
|
&& d.has_field(&SearchField::Tracing(TracingSearchField::EventType))),
|
||||||
|
"trace documents carry a queue id and an event type"
|
||||||
|
);
|
||||||
|
store.index(documents).await.unwrap();
|
||||||
|
if let SearchStore::ElasticSearch(store) = &store {
|
||||||
|
store.refresh_index(SearchIndex::Tracing).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = |filters: Vec<SearchFilter>| {
|
||||||
|
let store = store.clone();
|
||||||
|
async move {
|
||||||
|
store
|
||||||
|
.query_global(
|
||||||
|
SearchQuery::new(SearchIndex::Tracing)
|
||||||
|
.with_filter(SearchFilter::ge(SearchField::Id, 100u64))
|
||||||
|
.with_filters(filters),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.collect::<AHashSet<_>>()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// By queue id, the way x:Trace/query asks: the queue id column, or any
|
||||||
|
// queue id in the keywords
|
||||||
|
let by_queue_id = |queue_id: u64| {
|
||||||
|
vec![
|
||||||
|
SearchFilter::Or,
|
||||||
|
SearchFilter::eq(TracingSearchField::QueueId, queue_id),
|
||||||
|
SearchFilter::has_text(
|
||||||
|
TracingSearchField::Keywords,
|
||||||
|
queue_id.to_string(),
|
||||||
|
Language::None,
|
||||||
|
),
|
||||||
|
SearchFilter::End,
|
||||||
|
]
|
||||||
|
};
|
||||||
|
assert_eq!(query(by_queue_id(9_000_000_001)).await, AHashSet::from_iter([100]));
|
||||||
|
assert_eq!(query(by_queue_id(9_000_000_002)).await, AHashSet::from_iter([100]));
|
||||||
|
assert_eq!(query(by_queue_id(9_000_000_003)).await, AHashSet::from_iter([101]));
|
||||||
|
assert_eq!(query(by_queue_id(9_000_000_004)).await, AHashSet::new());
|
||||||
|
assert_eq!(
|
||||||
|
query(vec![SearchFilter::eq(TracingSearchField::QueueId, 9_000_000_003u64)]).await,
|
||||||
|
AHashSet::from_iter([101])
|
||||||
|
);
|
||||||
|
// By opening event type
|
||||||
|
assert_eq!(
|
||||||
|
query(vec![SearchFilter::eq(
|
||||||
|
TracingSearchField::EventType,
|
||||||
|
EventType::Delivery(DeliveryEvent::AttemptStart).to_id() as u64,
|
||||||
|
)])
|
||||||
|
.await,
|
||||||
|
AHashSet::from_iter([101])
|
||||||
|
);
|
||||||
|
// By keyword: an address, lowercased, and its domain
|
||||||
|
assert_eq!(
|
||||||
|
query(vec![SearchFilter::has_text(
|
||||||
|
TracingSearchField::Keywords,
|
||||||
|
"example.org",
|
||||||
|
Language::None,
|
||||||
|
)])
|
||||||
|
.await,
|
||||||
|
AHashSet::from_iter([100])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
query(vec![SearchFilter::has_text(
|
||||||
|
TracingSearchField::Keywords,
|
||||||
|
"relay.example.net",
|
||||||
|
Language::None,
|
||||||
|
)])
|
||||||
|
.await,
|
||||||
|
AHashSet::from_iter([101])
|
||||||
|
);
|
||||||
|
|
||||||
|
for id in [100u64, 101] {
|
||||||
|
store
|
||||||
|
.unindex(
|
||||||
|
SearchQuery::new(SearchIndex::Tracing)
|
||||||
|
.with_filter(SearchFilter::eq(SearchField::Id, id)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
.await;
|
.await;
|
||||||
stub.set(Mode::Echo);
|
stub.set(Mode::Echo);
|
||||||
user.activate_script(concat!(
|
user.activate_script(concat!(
|
||||||
"require [\"vnd.stalwart.expressions\", \"editheader\", \"variables\"];\n",
|
"require [\"vnd.inbuxa.expressions\", \"editheader\", \"variables\"];\n",
|
||||||
"let \"a\" \"llm_prompt('echo-test', 'hello world', 0.5)\";\n",
|
"let \"a\" \"llm_prompt('echo-test', 'hello world', 0.5)\";\n",
|
||||||
"let \"b\" \"llm_prompt('no-such-model', 'x', 0.5)\";\n",
|
"let \"b\" \"llm_prompt('no-such-model', 'x', 0.5)\";\n",
|
||||||
"addheader \"X-Llm-Echo\" \"${a}\";\n",
|
"addheader \"X-Llm-Echo\" \"${a}\";\n",
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ pub async fn branding_compat() {
|
|||||||
let admin = std::env::var("INBUXA_COMPAT_ADMIN").expect("INBUXA_COMPAT_ADMIN");
|
let admin = std::env::var("INBUXA_COMPAT_ADMIN").expect("INBUXA_COMPAT_ADMIN");
|
||||||
assert!(
|
assert!(
|
||||||
std::env::var("NO_INSERT").is_ok(),
|
std::env::var("NO_INSERT").is_ok(),
|
||||||
"NO_INSERT must be set, or the copy of INBUXA's data is wiped"
|
"NO_INSERT must be set, or the copy of inbuxa's data is wiped"
|
||||||
);
|
);
|
||||||
let test = TestServerBuilder::new("branding_compat")
|
let test = TestServerBuilder::new("branding_compat")
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ pub async fn masked_email_compat() {
|
|||||||
.expect("masks JSON");
|
.expect("masks JSON");
|
||||||
assert!(
|
assert!(
|
||||||
std::env::var("NO_INSERT").is_ok(),
|
std::env::var("NO_INSERT").is_ok(),
|
||||||
"NO_INSERT must be set, or the copy of INBUXA's data is wiped"
|
"NO_INSERT must be set, or the copy of inbuxa's data is wiped"
|
||||||
);
|
);
|
||||||
|
|
||||||
let test = TestServerBuilder::new("masked_email_compat")
|
let test = TestServerBuilder::new("masked_email_compat")
|
||||||
|
|||||||
@@ -148,6 +148,73 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
"test 9: to"
|
"test 9: to"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// MON-16: the queueId filter finds the traces that name a queue id (the
|
||||||
|
// session that queued the message and its delivery attempt) through the
|
||||||
|
// search index, given as a string or a number (the index column is an
|
||||||
|
// integer)
|
||||||
|
fn queue_ids(value: &Value, out: &mut Vec<u64>) {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
if map.get("key").and_then(|k| k.as_str()) == Some("queueId")
|
||||||
|
&& let Some(id) = map
|
||||||
|
.get("value")
|
||||||
|
.and_then(|v| v.get("value").unwrap_or(v).as_u64())
|
||||||
|
{
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
map.values().for_each(|v| queue_ids(v, out));
|
||||||
|
}
|
||||||
|
Value::Array(list) => list.iter().for_each(|v| queue_ids(v, out)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let with_ids = traces
|
||||||
|
.iter()
|
||||||
|
.map(|t| {
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
queue_ids(t, &mut ids);
|
||||||
|
(t["id"].as_str().unwrap().to_string(), ids)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let queue_id = with_ids
|
||||||
|
.iter()
|
||||||
|
.find_map(|(_, ids)| ids.first().copied())
|
||||||
|
.expect("MON-16: a trace with a queue id");
|
||||||
|
let mut expected = with_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, ids)| ids.contains(&queue_id))
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
expected.sort();
|
||||||
|
for filter in [json!(queue_id.to_string()), json!(queue_id)] {
|
||||||
|
let response = admin
|
||||||
|
.jmap_method_call("x:Trace/query", json!({"filter": {"queueId": filter}}))
|
||||||
|
.await;
|
||||||
|
let mut found = response
|
||||||
|
.0
|
||||||
|
.pointer("/methodResponses/0/1/ids")
|
||||||
|
.and_then(|ids| ids.as_array())
|
||||||
|
.map(|ids| {
|
||||||
|
ids.iter()
|
||||||
|
.filter_map(|id| id.as_str().map(str::to_string))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
found.sort();
|
||||||
|
assert_eq!(found, expected, "MON-16: queueId {filter}: {response:?}");
|
||||||
|
}
|
||||||
|
let response = admin
|
||||||
|
.jmap_method_call(
|
||||||
|
"x:Trace/query",
|
||||||
|
json!({"filter": {"queueId": (queue_id ^ 0x5a5a_5a5a).to_string()}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response.0.pointer("/methodResponses/0/1/ids"),
|
||||||
|
Some(&json!([])),
|
||||||
|
"MON-16: an unknown queue id"
|
||||||
|
);
|
||||||
|
|
||||||
// Acceptance test 24: destroy removes a trace; create is refused
|
// Acceptance test 24: destroy removes a trace; create is refused
|
||||||
let trace_id = traces[0]["id"].as_str().unwrap().to_string();
|
let trace_id = traces[0]["id"].as_str().unwrap().to_string();
|
||||||
let response = admin
|
let response = admin
|
||||||
|
|||||||
@@ -908,7 +908,7 @@ pub async fn tenant_compat() {
|
|||||||
.expect("expected JSON");
|
.expect("expected JSON");
|
||||||
assert!(
|
assert!(
|
||||||
std::env::var("NO_INSERT").is_ok(),
|
std::env::var("NO_INSERT").is_ok(),
|
||||||
"NO_INSERT must be set, or the copy of INBUXA's data is wiped"
|
"NO_INSERT must be set, or the copy of inbuxa's data is wiped"
|
||||||
);
|
);
|
||||||
|
|
||||||
let test = TestServerBuilder::new("tenant_compat")
|
let test = TestServerBuilder::new("tenant_compat")
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ pub async fn undelete_compat() {
|
|||||||
.expect("archived items JSON");
|
.expect("archived items JSON");
|
||||||
assert!(
|
assert!(
|
||||||
std::env::var("NO_INSERT").is_ok(),
|
std::env::var("NO_INSERT").is_ok(),
|
||||||
"NO_INSERT must be set, or the copy of INBUXA's data is wiped"
|
"NO_INSERT must be set, or the copy of inbuxa's data is wiped"
|
||||||
);
|
);
|
||||||
|
|
||||||
let test = TestServerBuilder::new("undelete_compat")
|
let test = TestServerBuilder::new("undelete_compat")
|
||||||
|
|||||||
@@ -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::utils::account::Account;
|
use crate::utils::account::Account;
|
||||||
@@ -408,7 +410,7 @@ impl Account {
|
|||||||
"urn:ietf:params:jmap:principals:availability",
|
"urn:ietf:params:jmap:principals:availability",
|
||||||
"urn:ietf:params:jmap:filenode",
|
"urn:ietf:params:jmap:filenode",
|
||||||
"urn:ietf:params:jmap:mail:share",
|
"urn:ietf:params:jmap:mail:share",
|
||||||
"urn:stalwart:jmap"
|
"urn:inbuxa:jmap:registry"
|
||||||
],
|
],
|
||||||
"methodCalls": calls
|
"methodCalls": calls
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -907,7 +907,7 @@ const REPORT_10: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|||||||
|
|
||||||
const REPORT_10_RESPONSE: &str = r#"BEGIN:VCALENDAR
|
const REPORT_10_RESPONSE: &str = r#"BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
BEGIN:VFREEBUSY
|
BEGIN:VFREEBUSY
|
||||||
DTSTART:20060104T140000Z
|
DTSTART:20060104T140000Z
|
||||||
DTEND:20060105T220000Z
|
DTEND:20060105T220000Z
|
||||||
@@ -928,7 +928,7 @@ const REPORT_11: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
|
|||||||
|
|
||||||
const REPORT_11_RESPONSE: &str = r#"BEGIN:VCALENDAR
|
const REPORT_11_RESPONSE: &str = r#"BEGIN:VCALENDAR
|
||||||
VERSION:2.0
|
VERSION:2.0
|
||||||
PRODID:-//INBUXA//INBUXA Server//EN
|
PRODID:-//inbuxa//inbuxa Server//EN
|
||||||
BEGIN:VFREEBUSY
|
BEGIN:VFREEBUSY
|
||||||
DTSTART:20060101T000000Z
|
DTSTART:20060101T000000Z
|
||||||
DTEND:20060104T140000Z
|
DTEND:20060104T140000Z
|
||||||
|
|||||||
@@ -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::utils::{server::TestServer, webdav::GenerateTestDavResource};
|
use crate::utils::{server::TestServer, webdav::GenerateTestDavResource};
|
||||||
@@ -45,7 +47,7 @@ pub async fn test(test: &TestServer) {
|
|||||||
|
|
||||||
// Test 2: Refreshing a lock token with an invalid a lock token should fail
|
// Test 2: Refreshing a lock token with an invalid a lock token should fail
|
||||||
client
|
client
|
||||||
.lock_refresh(&path, "urn:stalwart:davlock:1234", "infinity", "Second-456")
|
.lock_refresh(&path, "urn:inbuxa:davlock:1234", "infinity", "Second-456")
|
||||||
.await
|
.await
|
||||||
.with_status(StatusCode::PRECONDITION_FAILED);
|
.with_status(StatusCode::PRECONDITION_FAILED);
|
||||||
|
|
||||||
@@ -148,7 +150,7 @@ pub async fn test(test: &TestServer) {
|
|||||||
|
|
||||||
// Test 10: Unlock with and without a lock token
|
// Test 10: Unlock with and without a lock token
|
||||||
client
|
client
|
||||||
.unlock(&path, "urn:stalwart:davlock:1234")
|
.unlock(&path, "urn:inbuxa:davlock:1234")
|
||||||
.await
|
.await
|
||||||
.with_status(StatusCode::CONFLICT)
|
.with_status(StatusCode::CONFLICT)
|
||||||
.with_value("D:error.D:lock-token-matches-request-uri", "");
|
.with_value("D:error.D:lock-token-matches-request-uri", "");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user