Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba4c7a32e | ||
|
|
17426f6d60 | ||
|
|
0d8caaa514 | ||
|
|
ce6882fe93 | ||
|
|
3df042e7d4 | ||
|
|
64385007c1 | ||
|
|
4d794c6a65 | ||
|
|
a404ca89f0 | ||
|
|
79f54add2f | ||
|
|
86bf2432a2 | ||
|
|
5be578ba3c | ||
|
|
f32992ca36 | ||
|
|
a993f9ab01 | ||
|
|
99096cdc9b | ||
|
|
96ac70ad28 | ||
|
|
835b278e66 | ||
|
|
cc6f1eb298 | ||
|
|
674ae5d037 | ||
|
|
4799d191a0 | ||
|
|
c5bf67f1bf | ||
|
|
c240946248 | ||
|
|
ee4988e00d | ||
|
|
b2ded0a776 | ||
|
|
3a272096c0 | ||
|
|
b6660554e6 | ||
|
|
7bda874230 | ||
|
|
a4b091578d | ||
|
|
39df888412 | ||
|
|
697f647f8b | ||
|
|
14250cee03 | ||
|
|
cea3d53eb0 | ||
|
|
335281f1de | ||
|
|
7f14992e81 | ||
|
|
1f963a9a1c | ||
|
|
b353f4ad2a |
@@ -20,6 +20,22 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# What an upstream merge can bring in or leave behind without a conflict:
|
||||||
|
# the upstream name in a new string literal, and a changed upstream file
|
||||||
|
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
||||||
|
# check diffs against the upstream snapshot branch, hence the full fetch.
|
||||||
|
fork-checks:
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- run: python3 tools/fork/name-check.py
|
||||||
|
- if: always()
|
||||||
|
run: python3 tools/fork/notice-check.py
|
||||||
|
|
||||||
build:
|
build:
|
||||||
# Either runner (host1 or host2): the build needs no docker socket.
|
# Either runner (host1 or host2): the build needs no docker socket.
|
||||||
runs-on: light
|
runs-on: light
|
||||||
@@ -51,6 +67,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
|
||||||
|
|||||||
@@ -131,8 +131,95 @@ 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`.\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, 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
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Watch upstream for releases the fork hasn't imported yet, and open an issue
|
||||||
|
# for each one so it waits in the tracker until someone strips it in.
|
||||||
|
#
|
||||||
|
# Reads metadata only -- the releases list from GitHub's API and the head of
|
||||||
|
# this repo's `upstream` branch from Gitea's. Nothing of upstream's is fetched,
|
||||||
|
# so none of its history (which carries the Enterprise code) can land here.
|
||||||
|
# Importing is still by hand: tools/fork/strip.py onto `upstream`, then merge,
|
||||||
|
# as docs/spec/SPEC.md §2.2 and §2.2a describe.
|
||||||
|
#
|
||||||
|
# The imported base is the tag in the `upstream` branch's head commit subject
|
||||||
|
# ("Import upstream v0.16.22, stripped"). Drafts and pre-releases are ignored.
|
||||||
|
# An issue is opened once per release: an existing one with the same title,
|
||||||
|
# open or closed, stops a second.
|
||||||
|
#
|
||||||
|
# It also watches spam-filter, whose rules the server bundles
|
||||||
|
# (resources/spam-filter/), and opens an issue for a newer release.
|
||||||
|
#
|
||||||
|
# Daily 06:17 UTC; run it by hand with workflow_dispatch.
|
||||||
|
name: upstream-watch
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '17 6 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: upstream-watch
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upstream-watch:
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
steps:
|
||||||
|
- shell: bash
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, re, sys, urllib.request
|
||||||
|
|
||||||
|
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
|
||||||
|
def call(method, url, body=None, token=os.environ["TOKEN"]):
|
||||||
|
headers = {"Content-Type": "application/json", "User-Agent": "inbuxa-upstream-watch"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"token {token}"
|
||||||
|
req = urllib.request.Request(url, method=method, headers=headers,
|
||||||
|
data=json.dumps(body).encode() if body is not None else None)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.load(r)
|
||||||
|
SEMVER = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
||||||
|
def key(tag):
|
||||||
|
return tuple(int(x) for x in SEMVER.match(tag).groups())
|
||||||
|
|
||||||
|
subject = call("GET", f"{api}/branches/upstream")["commit"]["message"].splitlines()[0]
|
||||||
|
m = re.search(r"\bupstream (v\d+\.\d+\.\d+)\b", subject)
|
||||||
|
if not m:
|
||||||
|
print(f"Can't read the imported base from the upstream branch: {subject!r}", file=sys.stderr); sys.exit(1)
|
||||||
|
base = m.group(1)
|
||||||
|
|
||||||
|
# Unauthenticated: a public repo, once a day, well inside the limit.
|
||||||
|
rels = call("GET", "https://api.github.com/repos/stalwartlabs/stalwart/releases?per_page=30", token=None)
|
||||||
|
newer = sorted((r for r in rels
|
||||||
|
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
|
||||||
|
and key(r["tag_name"]) > key(base)),
|
||||||
|
key=lambda r: key(r["tag_name"]))
|
||||||
|
if not newer:
|
||||||
|
print(f"Up to date: {base} is the newest upstream release.")
|
||||||
|
|
||||||
|
# Titles and bodies stay free of the upstream project's name, as the
|
||||||
|
# rest of the fork's user-visible text does.
|
||||||
|
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=Import+upstream&limit=50")}
|
||||||
|
for r in newer:
|
||||||
|
tag = r["tag_name"]
|
||||||
|
title = f"Import upstream {tag}"
|
||||||
|
if title in existing:
|
||||||
|
print(f"{tag}: issue already exists."); continue
|
||||||
|
body = (f"Upstream published {tag} on {r['published_at'][:10]}. "
|
||||||
|
f"The fork's imported base is {base}.\n\n"
|
||||||
|
"Import it as tools/fork/README.md describes:\n\n"
|
||||||
|
"```bash\n"
|
||||||
|
"git -C \"$UPSTREAM_CLONE\" fetch --tags\n"
|
||||||
|
f"tools/fork/strip.py --upstream \"$UPSTREAM_CLONE\" --ref {tag} --out /tmp/strip-{tag}\n"
|
||||||
|
"```\n\n"
|
||||||
|
"Commit the stripped tree to `upstream` with the strip report in the message, "
|
||||||
|
"add any new third-party notices to `THIRD-PARTY.md`, then merge `upstream` into `main`.")
|
||||||
|
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
|
||||||
|
print(f"{tag}: opened #{issue['number']}.")
|
||||||
|
|
||||||
|
# The spam filter rules bundled with the server (resources/spam-filter/):
|
||||||
|
# an issue when spam-filter publishes a newer release than the one
|
||||||
|
# BUNDLED_SPAM_RULES_VERSION names on main.
|
||||||
|
src = call("GET", f"{api}/contents/crates/common/src/manager/spam_rules.rs?ref=main")
|
||||||
|
import base64
|
||||||
|
text = base64.b64decode(src["content"]).decode()
|
||||||
|
m = re.search(r'BUNDLED_SPAM_RULES_VERSION: &str = "(\d+\.\d+\.\d+)"', text)
|
||||||
|
if not m:
|
||||||
|
print("Can't read BUNDLED_SPAM_RULES_VERSION from spam_rules.rs", file=sys.stderr); sys.exit(1)
|
||||||
|
bundled = "v" + m.group(1)
|
||||||
|
rels = call("GET", "https://api.github.com/repos/stalwartlabs/spam-filter/releases?per_page=30", token=None)
|
||||||
|
newer = sorted((r for r in rels
|
||||||
|
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
|
||||||
|
and key(r["tag_name"]) > key(bundled)),
|
||||||
|
key=lambda r: key(r["tag_name"]))
|
||||||
|
if not newer:
|
||||||
|
print(f"Up to date: the bundled spam rules are {bundled}, the newest release."); sys.exit(0)
|
||||||
|
latest = newer[-1]
|
||||||
|
tag = latest["tag_name"]
|
||||||
|
title = f"Update the bundled spam rules to {tag}"
|
||||||
|
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=bundled+spam+rules&limit=50")}
|
||||||
|
if title in existing:
|
||||||
|
print(f"spam rules {tag}: issue already exists."); sys.exit(0)
|
||||||
|
body = (f"spam-filter published {tag} on {latest['published_at'][:10]}. "
|
||||||
|
f"The server bundles {bundled}.\n\n"
|
||||||
|
"Update it as resources/spam-filter/README.md describes: take the rules file "
|
||||||
|
f"from the {tag} release (by tag, not `latest`), set BUNDLED_SPAM_RULES_VERSION, "
|
||||||
|
"and run the antispam test.")
|
||||||
|
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
|
||||||
|
print(f"spam rules {tag}: opened #{issue['number']}.")
|
||||||
|
PY
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# CI on the self-hosted GitLab, ported from .github/workflows/ci.yml when the
|
|
||||||
# GitHub account was suspended on 2026-09-20. The Actions file stays in the
|
|
||||||
# tree: it is the reference this was written from and works unchanged if the
|
|
||||||
# appeal succeeds.
|
|
||||||
#
|
|
||||||
# The image is pinned by digest, with its tag in the trailing comment. That
|
|
||||||
# replaces the SHA-pinned `uses:` in the workflow -- GitLab has no action
|
|
||||||
# allowlist, so the digest is the only thing fixing what actually runs.
|
|
||||||
#
|
|
||||||
# Not ported here:
|
|
||||||
# * cleanup.yml pruned GHCR with dataaxiom/ghcr-cleanup-action. GitLab has
|
|
||||||
# no equivalent action because it does not need one: the container
|
|
||||||
# registry has a cleanup policy on the project itself, which is where that
|
|
||||||
# job's settings now live.
|
|
||||||
# * publish.yml and release.yml still need doing; they are larger and are
|
|
||||||
# being handled separately.
|
|
||||||
|
|
||||||
stages: [build]
|
|
||||||
|
|
||||||
default:
|
|
||||||
interruptible: true
|
|
||||||
|
|
||||||
build:
|
|
||||||
stage: build
|
|
||||||
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
|
|
||||||
# This is a big workspace and a cold build is expensive, so the registry and
|
|
||||||
# the target directory are cached between runs. Both are kept inside the
|
|
||||||
# project directory because that is the only path the runner will cache --
|
|
||||||
# and deliberately not on /tmp, which on this host is a tmpfs that a Rust
|
|
||||||
# build of this size has filled before.
|
|
||||||
variables:
|
|
||||||
CARGO_HOME: "$CI_PROJECT_DIR/.cargo"
|
|
||||||
CARGO_TARGET_DIR: "$CI_PROJECT_DIR/target"
|
|
||||||
CARGO_INCREMENTAL: "0"
|
|
||||||
cache:
|
|
||||||
key:
|
|
||||||
files: [Cargo.lock]
|
|
||||||
paths:
|
|
||||||
- .cargo/registry/
|
|
||||||
- target/
|
|
||||||
before_script:
|
|
||||||
- apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null
|
|
||||||
script:
|
|
||||||
- cargo build -p inbuxa --locked
|
|
||||||
# --no-run: the workflow compiled every test target without running them,
|
|
||||||
# which catches a test that no longer builds without paying for the suite.
|
|
||||||
- cargo test --workspace --locked --no-run
|
|
||||||
rules:
|
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
||||||
@@ -2,6 +2,39 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [0.16.23] - 2026-09-21
|
||||||
|
|
||||||
|
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
- Expressions: `bit_and` function.
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
- MTA:
|
||||||
|
- A mailing list whose recipients include another mailing list is accepted at `RCPT TO` and then rejected at local delivery with `550 5.5.0 Mailbox not found`.
|
||||||
|
- DMARC aggregate reports carry two `spf` elements per record and the `version` element of a DMARC aggregate report is written as `1` instead of `1.0`.
|
||||||
|
- DSNs generated for an alias rewrite or a list expansion emit a doubled `addr-type` in `Original-Recipient` (`rfc822;rfc822;[email protected]`).
|
||||||
|
- DSNs that cannot be written to the store are discarded, the recipients are flagged as notified and the original message is removed from the queue, losing both the bounce and the message.
|
||||||
|
- POP3:
|
||||||
|
- `TOP msg n` counts the `n` lines from the first byte of the message instead of from the first byte of the body.
|
||||||
|
- A message whose very first line begins with `.` is not byte-stuffed.
|
||||||
|
- Spam filter: Moving or copying a message from one account into another creates no training sample, so the classifier never learns from it.
|
||||||
|
- Sieve: `envelope "orcpt"` yields the bare address for an `ORCPT` supplied over SMTP. It now carries the `addr-type` prefix in every case, as required by RFC 6009.
|
||||||
|
- ACME: The `_acme-challenge` TXT records published for a DNS-01 authorization are never removed.
|
||||||
|
- DNS: The DNSSEC resolver queries a single nameserver at a time, working around a `hickory-resolver` race that cancels the TCP retry when two nameservers return a truncated response in parallel.
|
||||||
|
- Troubleshoot tool:
|
||||||
|
- MX records are resolved through the DNSSEC-validating resolver, matching the resolver used by the delivery path.
|
||||||
|
- A TLSA lookup that fails or returns bogus records stops the delivery attempt for that host, instead of continuing without DANE.
|
||||||
|
- OIDC: Bearer tokens that carry no `email`, `preferred_username` or `upn` claim are always authenticated against the default directory.
|
||||||
|
- Meilisearch: A confirmation timeout is treated as a failed write even when `failOnTimeout` is disabled, so an index whose batches take longer than `pollInterval` x `maxRetries` never completes an indexing task and resubmits the same batch indefinitely.
|
||||||
|
- WebUI: A failed update no longer takes an `Application` offline.
|
||||||
|
- FoundationDB: The cached read version is invalidated when any broadcast is received from another node.
|
||||||
|
- Redis:
|
||||||
|
- On a cluster, the rate limiter and the blob upload quota issue `INCR` and `EXPIRE` as a `MULTI`/`EXEC` transaction, whose `MOVED` redirects collapse into a single `EXECABORT` that never refreshes the slot map.
|
||||||
|
- A connection that fails because it is addressing the wrong server is returned to the pool and reused, since the recycle check only issues `PING`.
|
||||||
|
|
||||||
## [0.16.22] - 2026-09-13
|
## [0.16.22] - 2026-09-13
|
||||||
|
|
||||||
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
||||||
|
|||||||
Generated
+140
-132
@@ -234,7 +234,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"synstructure",
|
"synstructure 0.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -277,9 +277,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.46"
|
version = "0.4.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f"
|
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-codecs",
|
"compression-codecs",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -310,7 +310,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"regex",
|
"regex",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -369,7 +369,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -874,7 +874,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"num",
|
"num",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -984,7 +984,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1110,9 +1110,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.4.6"
|
version = "1.4.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
|
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -1160,9 +1160,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg_aliases"
|
name = "cfg_aliases"
|
||||||
@@ -1302,7 +1302,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm-siv",
|
"aes-gcm-siv",
|
||||||
"ahash",
|
"ahash",
|
||||||
@@ -1402,9 +1402,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.41"
|
version = "0.4.43"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
|
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-core",
|
"compression-core",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -1487,7 +1487,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coordinator"
|
name = "coordinator"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -1849,7 +1849,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strsim",
|
"strsim",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1882,7 +1882,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core 0.24.1",
|
"darling_core 0.24.1",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1899,7 +1899,7 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dav"
|
name = "dav"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"calcard",
|
"calcard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1922,7 +1922,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dav-proto"
|
name = "dav-proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"calcard",
|
"calcard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -2135,7 +2135,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "directory"
|
name = "directory"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"argon2 0.6.0",
|
"argon2 0.6.0",
|
||||||
@@ -2192,7 +2192,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2376,7 +2376,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "email"
|
name = "email"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.3",
|
"aes 0.9.3",
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
@@ -2485,10 +2485,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event_macro"
|
name = "event_macro"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2571,7 +2571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4"
|
checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"web-time",
|
"web-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2594,9 +2594,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.12"
|
version = "0.1.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fixed_decimal"
|
name = "fixed_decimal"
|
||||||
@@ -2710,7 +2710,7 @@ dependencies = [
|
|||||||
"foundationdb-sys",
|
"foundationdb-sys",
|
||||||
"foundationdb-tuple",
|
"foundationdb-tuple",
|
||||||
"futures",
|
"futures",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_bytes",
|
"serde_bytes",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2842,7 +2842,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3013,7 +3013,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "groupware"
|
name = "groupware"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"calcard",
|
"calcard",
|
||||||
@@ -3169,7 +3169,7 @@ dependencies = [
|
|||||||
"jni",
|
"jni",
|
||||||
"lru-cache",
|
"lru-cache",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
@@ -3196,7 +3196,7 @@ dependencies = [
|
|||||||
"jni",
|
"jni",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"prefix-trie",
|
"prefix-trie",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
@@ -3223,7 +3223,7 @@ dependencies = [
|
|||||||
"ndk-context",
|
"ndk-context",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"resolv-conf",
|
"resolv-conf",
|
||||||
"rustls",
|
"rustls",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
@@ -3302,7 +3302,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3398,7 +3398,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http_proto"
|
name = "http_proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -3488,9 +3488,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-rustls"
|
name = "hyper-rustls"
|
||||||
version = "0.27.9"
|
version = "0.27.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
"hyper",
|
"hyper",
|
||||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.5",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3884,7 +3884,7 @@ checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "imap"
|
name = "imap"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"common",
|
"common",
|
||||||
@@ -3897,7 +3897,7 @@ dependencies = [
|
|||||||
"md5",
|
"md5",
|
||||||
"nlp",
|
"nlp",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"registry",
|
"registry",
|
||||||
"store",
|
"store",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -3909,7 +3909,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "imap_proto"
|
name = "imap_proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3924,7 +3924,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inbuxa"
|
name = "inbuxa"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"coordinator",
|
"coordinator",
|
||||||
@@ -3932,7 +3932,7 @@ dependencies = [
|
|||||||
"directory",
|
"directory",
|
||||||
"email",
|
"email",
|
||||||
"groupware",
|
"groupware",
|
||||||
"http 0.16.22",
|
"http 0.16.23",
|
||||||
"http_proto",
|
"http_proto",
|
||||||
"imap",
|
"imap",
|
||||||
"jmap",
|
"jmap",
|
||||||
@@ -4134,25 +4134,24 @@ checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jieba-macros"
|
name = "jieba-macros"
|
||||||
version = "0.10.3"
|
version = "0.10.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34904340bc65749a9e9a02fcc7f3368e675427c18447b9bbe02df52c15c9a36a"
|
checksum = "455f837e9d0255b68a712200db247c68fdad4941b72471b76bfa61c3b0c1f79f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_codegen",
|
"phf_codegen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jieba-rs"
|
name = "jieba-rs"
|
||||||
version = "0.10.3"
|
version = "0.10.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d"
|
checksum = "b6a8bbb0f77ee810f0689a30b7cec56b875751ef4ec2e74fd995613dc52b3ae1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytecount",
|
"bytecount",
|
||||||
"cedarwood",
|
"cedarwood",
|
||||||
"include-flate",
|
"include-flate",
|
||||||
"jieba-macros",
|
"jieba-macros",
|
||||||
"phf 0.13.1",
|
"phf 0.13.1",
|
||||||
"regex",
|
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4212,7 +4211,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jmap"
|
name = "jmap"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -4236,7 +4235,7 @@ dependencies = [
|
|||||||
"mail-parser",
|
"mail-parser",
|
||||||
"nlp",
|
"nlp",
|
||||||
"p256",
|
"p256",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"registry",
|
"registry",
|
||||||
"reqwest 0.13.5",
|
"reqwest 0.13.5",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
@@ -4294,7 +4293,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jmap_proto"
|
name = "jmap_proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"calcard",
|
"calcard",
|
||||||
@@ -4699,9 +4698,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lz4-sys"
|
name = "lz4-sys"
|
||||||
@@ -4742,9 +4741,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mail-auth"
|
name = "mail-auth"
|
||||||
version = "0.13.2"
|
version = "0.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e11f19d98aac923fc5b7ee30c3509733a013ef546a226acb959b9202f5ca58f0"
|
checksum = "8505122ba86e1f4adeb664196c1e787c3f29bb6e7c128e4a366d47d209911440"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -4757,7 +4756,7 @@ dependencies = [
|
|||||||
"mail-parser",
|
"mail-parser",
|
||||||
"memchr",
|
"memchr",
|
||||||
"quick-xml 0.42.0",
|
"quick-xml 0.42.0",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"rsa",
|
"rsa",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -4800,7 +4799,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "managesieve"
|
name = "managesieve"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -4935,7 +4934,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "migration"
|
name = "migration"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"email",
|
"email",
|
||||||
@@ -5066,7 +5065,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5131,7 +5130,7 @@ dependencies = [
|
|||||||
"lru",
|
"lru",
|
||||||
"mysql_common",
|
"mysql_common",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"socket2 0.6.5",
|
"socket2 0.6.5",
|
||||||
@@ -5206,14 +5205,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nlp"
|
name = "nlp"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"hashify",
|
"hashify",
|
||||||
"jieba-rs",
|
"jieba-rs",
|
||||||
"maplit",
|
"maplit",
|
||||||
"psl",
|
"psl",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"rust-stemmers",
|
"rust-stemmers",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6038,7 +6037,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pop3"
|
name = "pop3"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"directory",
|
"directory",
|
||||||
@@ -6082,7 +6081,7 @@ dependencies = [
|
|||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"md-5 0.11.0",
|
"md-5 0.11.0",
|
||||||
"memchr",
|
"memchr",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
]
|
]
|
||||||
@@ -6119,9 +6118,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppmd-rust"
|
name = "ppmd-rust"
|
||||||
version = "1.4.1"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e9219bcb9d7aca6b2f63c83cf100cf78bcd619ac46e6ecbd0dd90869a39345d"
|
checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
@@ -6206,7 +6205,7 @@ dependencies = [
|
|||||||
"proc-macro-error-attr3",
|
"proc-macro-error-attr3",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6260,7 +6259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"itertools 0.13.0",
|
"itertools 0.14.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
@@ -6287,9 +6286,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psl"
|
name = "psl"
|
||||||
version = "2.1.232"
|
version = "2.1.235"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "62834e308cc83aea5e30cd8c80b8aa82cdb104a3240c7f210d4f68d46e29f308"
|
checksum = "8319b56ff38ca0522b4e1e40bfa2b5de7f62dc89fc1e9033eac365551ec58e0e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"psl-types",
|
"psl-types",
|
||||||
]
|
]
|
||||||
@@ -6317,7 +6316,7 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6388,9 +6387,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.11"
|
version = "0.11.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
@@ -6399,7 +6398,7 @@ dependencies = [
|
|||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.5",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6408,16 +6407,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.17"
|
version = "0.11.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"bytes",
|
"bytes",
|
||||||
"fastbloom",
|
"fastbloom",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rand_pcg",
|
"rand_pcg",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
@@ -6440,7 +6439,7 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.5",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -6534,9 +6533,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.10.2"
|
version = "0.10.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chacha20",
|
"chacha20",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -6776,7 +6775,7 @@ dependencies = [
|
|||||||
"num-bigint 0.5.1",
|
"num-bigint 0.5.1",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"ryu",
|
"ryu",
|
||||||
@@ -6800,11 +6799,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
@@ -6826,7 +6824,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6860,7 +6858,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "registry"
|
name = "registry"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"hashify",
|
"hashify",
|
||||||
@@ -7044,7 +7042,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7225,9 +7223,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"errno",
|
"errno",
|
||||||
@@ -7412,12 +7410,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"serde_derive_internals",
|
"serde_derive_internals",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scim"
|
name = "scim"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -7443,7 +7441,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scim-proto"
|
name = "scim-proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashify",
|
"hashify",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7580,7 +7578,7 @@ dependencies = [
|
|||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"sha3 0.10.9",
|
"sha3 0.10.9",
|
||||||
"slh-dsa",
|
"slh-dsa",
|
||||||
"thiserror 1.0.69",
|
"thiserror 2.0.20",
|
||||||
"twofish",
|
"twofish",
|
||||||
"typenum",
|
"typenum",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
@@ -7624,7 +7622,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7635,7 +7633,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7671,7 +7669,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7716,7 +7714,7 @@ dependencies = [
|
|||||||
"darling 0.24.1",
|
"darling 0.24.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7764,12 +7762,12 @@ checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "services"
|
name = "services"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
@@ -7960,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",
|
||||||
@@ -8084,7 +8080,7 @@ checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smtp"
|
name = "smtp"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -8099,7 +8095,7 @@ dependencies = [
|
|||||||
"mail-builder 1.0.0",
|
"mail-builder 1.0.0",
|
||||||
"mail-parser",
|
"mail-parser",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"registry",
|
"registry",
|
||||||
"reqwest 0.13.5",
|
"reqwest 0.13.5",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
@@ -8175,7 +8171,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spam-filter"
|
name = "spam-filter"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -8295,7 +8291,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "store"
|
name = "store"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -8321,7 +8317,7 @@ dependencies = [
|
|||||||
"parking_lot",
|
"parking_lot",
|
||||||
"r2d2",
|
"r2d2",
|
||||||
"radsort",
|
"radsort",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rayon",
|
"rayon",
|
||||||
"redis",
|
"redis",
|
||||||
"registry",
|
"registry",
|
||||||
@@ -8417,9 +8413,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.5"
|
version = "3.0.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -8446,6 +8442,17 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synstructure"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sysinfo"
|
name = "sysinfo"
|
||||||
version = "0.37.2"
|
version = "0.37.2"
|
||||||
@@ -8544,7 +8551,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tests"
|
name = "tests"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
@@ -8566,7 +8573,7 @@ dependencies = [
|
|||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures",
|
"futures",
|
||||||
"groupware",
|
"groupware",
|
||||||
"http 0.16.22",
|
"http 0.16.23",
|
||||||
"http_proto",
|
"http_proto",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
@@ -8581,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",
|
||||||
@@ -8652,7 +8660,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8790,7 +8798,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8812,7 +8820,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"postgres-protocol",
|
"postgres-protocol",
|
||||||
"postgres-types",
|
"postgres-types",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"socket2 0.6.5",
|
"socket2 0.6.5",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
@@ -8997,7 +9005,7 @@ dependencies = [
|
|||||||
"constant_time_eq",
|
"constant_time_eq",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1 0.11.0",
|
"sha1 0.11.0",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
@@ -9136,7 +9144,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trc"
|
name = "trc"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -9195,7 +9203,7 @@ dependencies = [
|
|||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"sha1 0.11.0",
|
"sha1 0.11.0",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
@@ -9245,7 +9253,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types"
|
name = "types"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -9297,9 +9305,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.26"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-normalization"
|
name = "unicode-normalization"
|
||||||
@@ -9414,7 +9422,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utils"
|
name = "utils"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arcstr",
|
"arcstr",
|
||||||
@@ -9616,7 +9624,7 @@ dependencies = [
|
|||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -10125,14 +10133,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke-derive"
|
name = "yoke-derive"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
"synstructure",
|
"synstructure 0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10655,14 +10663,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom-derive"
|
name = "zerofrom-derive"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
"synstructure",
|
"synstructure 0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10717,7 +10725,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10749,9 +10757,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.7"
|
version = "0.6.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
|
|||||||
@@ -1,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" }
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::{
|
|||||||
auth::{
|
auth::{
|
||||||
AccessToken, AuthRequest, DomainCache,
|
AccessToken, AuthRequest, DomainCache,
|
||||||
credential::{ApiKey, AppPassword},
|
credential::{ApiKey, AppPassword},
|
||||||
oauth::GrantType,
|
oauth::{GrantType, token::TOKEN_HEADER},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use base64::{Engine, engine::general_purpose};
|
use base64::{Engine, engine::general_purpose};
|
||||||
@@ -23,7 +23,8 @@ use registry::schema::{
|
|||||||
enums::Permission,
|
enums::Permission,
|
||||||
structs::{self, Credential},
|
structs::{self, Credential},
|
||||||
};
|
};
|
||||||
use std::{net::IpAddr, sync::Arc};
|
use serde::Deserialize;
|
||||||
|
use std::{borrow::Cow, net::IpAddr, sync::Arc};
|
||||||
use store::write::now;
|
use store::write::now;
|
||||||
use trc::AddContext;
|
use trc::AddContext;
|
||||||
|
|
||||||
@@ -321,19 +322,12 @@ impl Server {
|
|||||||
// Obtain external directory, if any. When no username is supplied
|
// Obtain external directory, if any. When no username is supplied
|
||||||
// (e.g. HTTP bearer auth), peek at the JWT claims to find the
|
// (e.g. HTTP bearer auth), peek at the JWT claims to find the
|
||||||
// user's domain so per-domain OIDC directories are reachable.
|
// user's domain so per-domain OIDC directories are reachable.
|
||||||
let directory = if let Some(username) = username.as_deref().map(UsernameParts::new)
|
let directory = match username.as_deref().map(UsernameParts::new) {
|
||||||
{
|
Some(username) => match username.auth_as().domain() {
|
||||||
if let Some(domain_name) = username.auth_as().domain() {
|
Some(domain_name) => self.get_directory_for_domain(domain_name).await?,
|
||||||
self.get_directory_for_domain(domain_name).await?
|
None => self.get_directory_for_token(token).await?,
|
||||||
} else if let Some(domain_name) = extract_jwt_domain(token) {
|
},
|
||||||
self.get_directory_for_domain(&domain_name).await?
|
None => self.get_directory_for_token(token).await?,
|
||||||
} else {
|
|
||||||
self.get_default_directory()
|
|
||||||
}
|
|
||||||
} else if let Some(domain_name) = extract_jwt_domain(token) {
|
|
||||||
self.get_directory_for_domain(&domain_name).await?
|
|
||||||
} else {
|
|
||||||
self.get_default_directory()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try external directory authentication first if supported, then fallback to internal OAuth.
|
// Try external directory authentication first if supported, then fallback to internal OAuth.
|
||||||
@@ -563,6 +557,29 @@ impl Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_directory_for_token(&self, token: &str) -> trc::Result<Option<&Arc<Directory>>> {
|
||||||
|
let Some(payload) = JwtClaims::decode_payload(token) else {
|
||||||
|
return Ok(self.get_default_directory());
|
||||||
|
};
|
||||||
|
let Some(claims) = JwtClaims::parse(&payload) else {
|
||||||
|
return Ok(self.get_default_directory());
|
||||||
|
};
|
||||||
|
|
||||||
|
match (claims.domain(), claims.iss.as_deref()) {
|
||||||
|
(Some(domain_name), _) => self.get_directory_for_domain(domain_name).await,
|
||||||
|
(None, Some(issuer)) => Ok(self
|
||||||
|
.get_directory_for_issuer(issuer)
|
||||||
|
.or_else(|| self.get_default_directory())),
|
||||||
|
(None, None) => Ok(self.get_default_directory()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: DIR-2: a token naming no address gets the server default, so
|
||||||
|
/// no directory is chosen by issuer.
|
||||||
|
fn get_directory_for_issuer(&self, _issuer: &str) -> Option<&Arc<Directory>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A
|
/// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A
|
||||||
/// `directoryId` naming no directory the server built is unavailable,
|
/// `directoryId` naming no directory the server built is unavailable,
|
||||||
/// never the internal directory.
|
/// never the internal directory.
|
||||||
@@ -622,7 +639,24 @@ pub fn unavailable_directory() -> &'static Arc<Directory> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_jwt_domain(token: &str) -> Option<String> {
|
#[derive(Deserialize)]
|
||||||
|
struct JwtClaims<'x> {
|
||||||
|
#[serde(borrow, default)]
|
||||||
|
iss: Option<Cow<'x, str>>,
|
||||||
|
#[serde(borrow, default)]
|
||||||
|
email: Option<Cow<'x, str>>,
|
||||||
|
#[serde(borrow, default)]
|
||||||
|
preferred_username: Option<Cow<'x, str>>,
|
||||||
|
#[serde(borrow, default)]
|
||||||
|
upn: Option<Cow<'x, str>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> JwtClaims<'x> {
|
||||||
|
fn decode_payload(token: &str) -> Option<Vec<u8>> {
|
||||||
|
if token.starts_with(TOKEN_HEADER) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let mut parts = token.split('.');
|
let mut parts = token.split('.');
|
||||||
let _header = parts.next()?;
|
let _header = parts.next()?;
|
||||||
let payload = parts.next()?;
|
let payload = parts.next()?;
|
||||||
@@ -630,17 +664,25 @@ fn extract_jwt_domain(token: &str) -> Option<String> {
|
|||||||
if parts.next().is_some() {
|
if parts.next().is_some() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?;
|
|
||||||
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()
|
||||||
for claim in ["email", "preferred_username", "upn"] {
|
|
||||||
if let Some(val) = claims.get(claim).and_then(|v| v.as_str())
|
|
||||||
&& let Some((_, domain)) = val.rsplit_once('@')
|
|
||||||
&& !domain.is_empty()
|
|
||||||
{
|
|
||||||
return Some(domain.to_ascii_lowercase());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse(payload: &'x [u8]) -> Option<Self> {
|
||||||
|
serde_json::from_slice(payload).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain(&self) -> Option<&str> {
|
||||||
|
[&self.email, &self.preferred_username, &self.upn]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.find_map(|claim| {
|
||||||
|
claim
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, domain)| domain)
|
||||||
|
.filter(|domain| !domain.is_empty())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UsernameParts {
|
impl UsernameParts {
|
||||||
@@ -738,3 +780,76 @@ impl AuthRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn jwt(payload: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"eyJhbGciOiJSUzI1NiJ9.{}.c2lnbmF0dXJl",
|
||||||
|
general_purpose::URL_SAFE_NO_PAD.encode(payload)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hints(token: &str) -> Option<(Option<String>, Option<String>)> {
|
||||||
|
let payload = JwtClaims::decode_payload(token)?;
|
||||||
|
let claims = JwtClaims::parse(&payload)?;
|
||||||
|
|
||||||
|
Some((
|
||||||
|
claims.domain().map(str::to_string),
|
||||||
|
claims.iss.as_deref().map(str::to_string),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jwt_claims_are_extracted() {
|
||||||
|
for (payload, domain, issuer) in [
|
||||||
|
(
|
||||||
|
r#"{"iss":"https://idp.example.org","email":"[email protected]"}"#,
|
||||||
|
Some("Example.ORG"),
|
||||||
|
Some("https://idp.example.org"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"{"preferred_username":"[email protected]","upn":"[email protected]"}"#,
|
||||||
|
Some("example.net"),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"{"email":"broken@","upn":"[email protected]"}"#,
|
||||||
|
Some("example.com"),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"{"iss":"https://idp.example.org","sub":"5db2d1b6","aud":["a","b"],"scope":"openid"}"#,
|
||||||
|
None,
|
||||||
|
Some("https://idp.example.org"),
|
||||||
|
),
|
||||||
|
(r#"{"sub":"5db2d1b6"}"#, None, None),
|
||||||
|
(r#"{"email":"[email protected]"}"#, Some("example.net"), None),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
hints(&jwt(payload)),
|
||||||
|
Some((domain.map(str::to_string), issuer.map(str::to_string))),
|
||||||
|
"Unexpected claims for {payload}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_jwt_tokens_are_ignored() {
|
||||||
|
for token in [
|
||||||
|
"sw1.eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLm9yZyJ9",
|
||||||
|
"sw1.eyJhbGciOiJSUzI1NiJ9",
|
||||||
|
"opaque-token",
|
||||||
|
"one.two",
|
||||||
|
"one.two.three.four",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
JwtClaims::decode_payload(token).is_none(),
|
||||||
|
"Token {token:?} was parsed as a JWT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pub const FAILED_TO_DECODE_TOKEN: &str = concat!(
|
|||||||
"the Authentication object."
|
"the Authentication object."
|
||||||
);
|
);
|
||||||
|
|
||||||
const TOKEN_HEADER: &str = "sw1.";
|
pub(crate) const TOKEN_HEADER: &str = "sw1.";
|
||||||
const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1";
|
const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1";
|
||||||
const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000
|
const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::server::tls::build_self_signed_cert;
|
use super::server::tls::build_self_signed_cert;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ impl Resolvers {
|
|||||||
let config_dnssec = resolver_config.clone();
|
let config_dnssec = resolver_config.clone();
|
||||||
let mut opts_dnssec = opts.clone();
|
let mut opts_dnssec = opts.clone();
|
||||||
opts_dnssec.validate = true;
|
opts_dnssec.validate = true;
|
||||||
|
opts_dnssec.num_concurrent_reqs = 1;
|
||||||
|
|
||||||
let dnssec = DnssecResolver {
|
let dnssec = DnssecResolver {
|
||||||
resolver: TokioResolver::builder_with_config(
|
resolver: TokioResolver::builder_with_config(
|
||||||
@@ -343,6 +344,7 @@ impl Default for Resolvers {
|
|||||||
let config_dnssec = config.clone();
|
let config_dnssec = config.clone();
|
||||||
let mut opts_dnssec = opts.clone();
|
let mut opts_dnssec = opts.clone();
|
||||||
opts_dnssec.validate = true;
|
opts_dnssec.validate = true;
|
||||||
|
opts_dnssec.num_concurrent_reqs = 1;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"),
|
dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"),
|
||||||
|
|||||||
@@ -583,10 +583,10 @@ impl Metrics {
|
|||||||
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
||||||
let metrics = bp.setting_infallible::<structs::Metrics>().await;
|
let metrics = bp.setting_infallible::<structs::Metrics>().await;
|
||||||
let resource = Resource::builder()
|
let resource = Resource::builder()
|
||||||
.with_service_name("stalwart")
|
.with_service_name("inbuxa")
|
||||||
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
|
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
|
||||||
.build();
|
.build();
|
||||||
let instrumentation = InstrumentationScope::builder("stalwart")
|
let instrumentation = InstrumentationScope::builder("inbuxa")
|
||||||
.with_version(types::brand_version_full!())
|
.with_version(types::brand_version_full!())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ pub(crate) fn fn_is_number(v: Vec<Variable>) -> Variable {
|
|||||||
matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into()
|
matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fn_bit_and(v: Vec<Variable>) -> Variable {
|
||||||
|
match (v[0].to_integer(), v[1].to_integer()) {
|
||||||
|
(Some(lhs), Some(rhs)) => Variable::Integer(lhs & rhs),
|
||||||
|
_ => Variable::Integer(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn fn_is_ip_addr(v: Vec<Variable>) -> Variable {
|
pub(crate) fn fn_is_ip_addr(v: Vec<Variable>) -> Variable {
|
||||||
v[0].to_string()
|
v[0].to_string()
|
||||||
.as_str()
|
.as_str()
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub(crate) const FUNCTIONS: &[(&str, fn(Vec<Variable>) -> Variable, u32)] = &[
|
|||||||
("email_part", email::fn_email_part, 2),
|
("email_part", email::fn_email_part, 2),
|
||||||
("is_empty", misc::fn_is_empty, 1),
|
("is_empty", misc::fn_is_empty, 1),
|
||||||
("is_number", misc::fn_is_number, 1),
|
("is_number", misc::fn_is_number, 1),
|
||||||
|
("bit_and", misc::fn_bit_and, 2),
|
||||||
("is_ip_addr", misc::fn_is_ip_addr, 1),
|
("is_ip_addr", misc::fn_is_ip_addr, 1),
|
||||||
("is_ipv4_addr", misc::fn_is_ipv4_addr, 1),
|
("is_ipv4_addr", misc::fn_is_ipv4_addr, 1),
|
||||||
("is_ipv6_addr", misc::fn_is_ipv6_addr, 1),
|
("is_ipv6_addr", misc::fn_is_ipv6_addr, 1),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{Server, manager::fetch_resource};
|
use crate::{Server, manager::fetch_resource};
|
||||||
@@ -11,8 +13,11 @@ use registry::schema::{enums::CompressionAlgo, structs::Application};
|
|||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
io::{self, Cursor, Read},
|
io::{self, Cursor, Read},
|
||||||
path::PathBuf,
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use store::{
|
use store::{
|
||||||
@@ -36,16 +41,18 @@ enum IndexEdit<'x> {
|
|||||||
pub struct WebApplications {
|
pub struct WebApplications {
|
||||||
applications: ArcSwap<Vec<WebApplicationManager>>,
|
applications: ArcSwap<Vec<WebApplicationManager>>,
|
||||||
routes: ArcSwap<AHashMap<String, Arc<AppRoutes>>>,
|
routes: ArcSwap<AHashMap<String, Arc<AppRoutes>>>,
|
||||||
|
generation: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppRoutes {
|
pub struct AppRoutes {
|
||||||
resources: AHashMap<String, Resource<PathBuf>>,
|
resources: AHashMap<String, Resource<PathBuf>>,
|
||||||
oauth_client_id_meta: Option<String>,
|
oauth_client_id_meta: Option<String>,
|
||||||
|
_bundle_dir: TempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WebApplicationManager {
|
pub struct WebApplicationManager {
|
||||||
bundle_path: TempDir,
|
base_path: PathBuf,
|
||||||
prefixes: Vec<String>,
|
prefixes: Vec<String>,
|
||||||
description: String,
|
description: String,
|
||||||
url: String,
|
url: String,
|
||||||
@@ -79,6 +86,7 @@ impl WebApplications {
|
|||||||
Self {
|
Self {
|
||||||
applications: ArcSwap::new(Arc::new(Vec::new())),
|
applications: ArcSwap::new(Arc::new(Vec::new())),
|
||||||
routes: ArcSwap::new(Arc::new(AHashMap::new())),
|
routes: ArcSwap::new(Arc::new(AHashMap::new())),
|
||||||
|
generation: AtomicU64::new(0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,48 +136,55 @@ impl WebApplications {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unpack_all(&self, server: &Server, update: bool) {
|
pub async fn unpack_all(&self, server: &Server, update: bool) {
|
||||||
let mut routes = AHashMap::new();
|
let previous = self.routes.load_full();
|
||||||
|
let sweep_orphans = previous.is_empty();
|
||||||
|
let mut routes = AHashMap::with_capacity(previous.len());
|
||||||
|
|
||||||
for app in self.applications.load().as_ref() {
|
for app in self.applications.load().as_ref() {
|
||||||
if update && let Err(err) = app.delete(server).await {
|
match app
|
||||||
trc::event!(
|
.unpack(server, self.next_generation(), update, sweep_orphans)
|
||||||
Resource(trc::ResourceEvent::Error),
|
.await
|
||||||
Reason = err,
|
{
|
||||||
Url = app.url.clone(),
|
Ok(app_routes) => {
|
||||||
Details = format!(
|
let app_routes = Arc::new(app_routes);
|
||||||
"Failed to delete application bundle for prefixes: {}",
|
|
||||||
app.prefixes.join(", ")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
match app.unpack(server).await {
|
|
||||||
Ok(resources) => {
|
|
||||||
let app_routes = Arc::new(AppRoutes {
|
|
||||||
resources,
|
|
||||||
oauth_client_id_meta: app
|
|
||||||
.oauth_client_id
|
|
||||||
.as_deref()
|
|
||||||
.map(oauth_client_id_meta),
|
|
||||||
});
|
|
||||||
|
|
||||||
for prefix in &app.prefixes {
|
for prefix in &app.prefixes {
|
||||||
routes.insert(prefix.clone(), app_routes.clone());
|
routes.insert(prefix.clone(), app_routes.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
|
let mut is_retained = false;
|
||||||
|
for prefix in &app.prefixes {
|
||||||
|
if let Some(app_routes) = previous.get(prefix) {
|
||||||
|
routes.insert(prefix.clone(), app_routes.clone());
|
||||||
|
is_retained = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Resource(trc::ResourceEvent::Error),
|
Resource(trc::ResourceEvent::Error),
|
||||||
Reason = err,
|
Reason = err,
|
||||||
Url = app.url.clone(),
|
Url = app.url.clone(),
|
||||||
Details = format!(
|
Details = format!(
|
||||||
"Failed to unpack application for prefixes: {}",
|
"Failed to unpack application for prefixes: {}, {}",
|
||||||
app.prefixes.join(", ")
|
app.prefixes.join(", "),
|
||||||
|
if is_retained {
|
||||||
|
"the previously unpacked bundle remains in service"
|
||||||
|
} else {
|
||||||
|
"no bundle is available to serve"
|
||||||
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.routes.store(Arc::new(routes));
|
self.routes.store(Arc::new(routes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn next_generation(&self) -> u64 {
|
||||||
|
self.generation.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebApplicationManager {
|
impl WebApplicationManager {
|
||||||
@@ -182,7 +197,7 @@ impl WebApplicationManager {
|
|||||||
.join(app.id.id().to_string());
|
.join(app.id.id().to_string());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
bundle_path: TempDir::new(base_path),
|
base_path,
|
||||||
blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()),
|
blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()),
|
||||||
url: app.object.resource_url,
|
url: app.object.resource_url,
|
||||||
description: app.object.description,
|
description: app.object.description,
|
||||||
@@ -202,82 +217,43 @@ impl WebApplicationManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unpack(&self, server: &Server) -> trc::Result<AHashMap<String, Resource<PathBuf>>> {
|
async fn unpack(
|
||||||
// Delete any existing bundles
|
&self,
|
||||||
self.bundle_path.clean().await.map_err(unpack_error)?;
|
server: &Server,
|
||||||
|
generation: u64,
|
||||||
// Obtain application bundle
|
force_refresh: bool,
|
||||||
let bundle = if let Some(bundle) = server
|
sweep_orphans: bool,
|
||||||
|
) -> trc::Result<AppRoutes> {
|
||||||
|
let cached = if force_refresh {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
server
|
||||||
.blob_store()
|
.blob_store()
|
||||||
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
||||||
.await?
|
.await?
|
||||||
{
|
};
|
||||||
bundle
|
let is_cached = cached.is_some();
|
||||||
} else {
|
let bundle = match cached {
|
||||||
// Fetch app bundle
|
Some(bundle) => bundle,
|
||||||
let resource = fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
|
None => self.fetch().await?,
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
trc::ResourceEvent::Error
|
|
||||||
.caused_by(trc::location!())
|
|
||||||
.ctx(Key::Url, self.url.clone())
|
|
||||||
.reason(err)
|
|
||||||
.details("Failed to fetch application bundle")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Store in blob store for future use
|
|
||||||
server
|
|
||||||
.blob_store()
|
|
||||||
.put_blob(self.blob_key.as_slice(), &resource, CompressionAlgo::None)
|
|
||||||
.await
|
|
||||||
.caused_by(trc::location!())?;
|
|
||||||
|
|
||||||
// Schedule expiration
|
|
||||||
let mut batch = BatchBuilder::new();
|
|
||||||
batch
|
|
||||||
.set(
|
|
||||||
BlobOp::Link {
|
|
||||||
hash: self.blob_key.clone(),
|
|
||||||
to: BlobLink::Temporary {
|
|
||||||
until: now() + self.expiry,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
.set(
|
|
||||||
BlobOp::Commit {
|
|
||||||
hash: self.blob_key.clone(),
|
|
||||||
},
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
server
|
|
||||||
.store()
|
|
||||||
.write(batch.build_all())
|
|
||||||
.await
|
|
||||||
.caused_by(trc::location!())?;
|
|
||||||
|
|
||||||
trc::event!(
|
|
||||||
Resource(trc::ResourceEvent::ApplicationUpdated),
|
|
||||||
Url = self.url.clone(),
|
|
||||||
Details = self.description.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
resource
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let staging = TempDir::new(self.base_path.join(format!("{:x}-{generation:x}", now())));
|
||||||
|
staging.create().await.map_err(unpack_error)?;
|
||||||
|
|
||||||
let url = self.url.clone();
|
let url = self.url.clone();
|
||||||
let bundle_path = self.bundle_path.path.clone();
|
let bundle_path = staging.path.clone();
|
||||||
let routes = tokio::task::spawn_blocking(move || -> trc::Result<_> {
|
let (resources, bundle) = tokio::task::spawn_blocking(move || -> trc::Result<_> {
|
||||||
let mut bundle = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
|
let mut archive = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
|
||||||
trc::ResourceEvent::Error
|
trc::ResourceEvent::Error
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
.reason(err)
|
.reason(err)
|
||||||
.ctx(Key::Url, url.clone())
|
.ctx(Key::Url, url.clone())
|
||||||
.details("Failed to decompress application bundle")
|
.details("Failed to decompress application bundle")
|
||||||
})?;
|
})?;
|
||||||
let mut routes = AHashMap::new();
|
let mut resources = AHashMap::with_capacity(archive.len());
|
||||||
for i in 0..bundle.len() {
|
for i in 0..archive.len() {
|
||||||
let mut file = bundle.by_index(i).map_err(|err| {
|
let mut file = archive.by_index(i).map_err(|err| {
|
||||||
trc::ResourceEvent::Error
|
trc::ResourceEvent::Error
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
.reason(err)
|
.reason(err)
|
||||||
@@ -315,9 +291,9 @@ impl WebApplicationManager {
|
|||||||
contents: path,
|
contents: path,
|
||||||
};
|
};
|
||||||
|
|
||||||
routes.insert(file_name, resource);
|
resources.insert(file_name, resource);
|
||||||
}
|
}
|
||||||
Ok(routes)
|
Ok((resources, archive.into_inner().into_inner()))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
@@ -327,21 +303,81 @@ impl WebApplicationManager {
|
|||||||
.details("Bundle unpack task panicked")
|
.details("Bundle unpack task panicked")
|
||||||
})??;
|
})??;
|
||||||
|
|
||||||
|
if !is_cached && let Err(err) = self.cache(server, &bundle).await {
|
||||||
|
trc::event!(
|
||||||
|
Resource(trc::ResourceEvent::Error),
|
||||||
|
Reason = err,
|
||||||
|
Url = self.url.clone(),
|
||||||
|
Details = "Failed to cache application bundle, it will be downloaded again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if sweep_orphans {
|
||||||
|
remove_siblings(&self.base_path, &staging.path).await;
|
||||||
|
}
|
||||||
|
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Resource(trc::ResourceEvent::ApplicationUnpacked),
|
Resource(trc::ResourceEvent::ApplicationUnpacked),
|
||||||
Url = self.url.clone(),
|
Url = self.url.clone(),
|
||||||
Path = self.bundle_path.path.to_string_lossy().into_owned(),
|
Path = staging.path.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(routes)
|
Ok(AppRoutes {
|
||||||
|
resources,
|
||||||
|
oauth_client_id_meta: self.oauth_client_id.as_deref().map(oauth_client_id_meta),
|
||||||
|
_bundle_dir: staging,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, server: &Server) -> trc::Result<()> {
|
async fn fetch(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
trc::ResourceEvent::Error
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.ctx(Key::Url, self.url.clone())
|
||||||
|
.reason(err)
|
||||||
|
.details("Failed to fetch application bundle")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cache(&self, server: &Server, bundle: &[u8]) -> trc::Result<()> {
|
||||||
server
|
server
|
||||||
.blob_store()
|
.blob_store()
|
||||||
.delete_blob(self.blob_key.as_slice())
|
.put_blob(self.blob_key.as_slice(), bundle, CompressionAlgo::None)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch
|
||||||
|
.set(
|
||||||
|
BlobOp::Link {
|
||||||
|
hash: self.blob_key.clone(),
|
||||||
|
to: BlobLink::Temporary {
|
||||||
|
until: now() + self.expiry,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.set(
|
||||||
|
BlobOp::Commit {
|
||||||
|
hash: self.blob_key.clone(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
server
|
||||||
|
.store()
|
||||||
|
.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
trc::event!(
|
||||||
|
Resource(trc::ResourceEvent::ApplicationUpdated),
|
||||||
|
Url = self.url.clone(),
|
||||||
|
Details = self.description.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> {
|
pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> {
|
||||||
@@ -361,7 +397,6 @@ impl Resource<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TempDir {
|
pub struct TempDir {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -371,11 +406,36 @@ impl TempDir {
|
|||||||
TempDir { path }
|
TempDir { path }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clean(&self) -> io::Result<()> {
|
pub async fn create(&self) -> io::Result<()> {
|
||||||
if tokio::fs::metadata(&self.path).await.is_ok() {
|
if tokio::fs::metadata(&self.path).await.is_ok() {
|
||||||
let _ = tokio::fs::remove_dir_all(&self.path).await;
|
let _ = tokio::fs::remove_dir_all(&self.path).await;
|
||||||
}
|
}
|
||||||
tokio::fs::create_dir(&self.path).await
|
tokio::fs::create_dir_all(&self.path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_siblings(base_path: &Path, keep: &Path) {
|
||||||
|
let Ok(mut entries) = tokio::fs::read_dir(base_path).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let path = entry.path();
|
||||||
|
if path == keep {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(entry.file_type().await, Ok(file_type) if file_type.is_dir()) {
|
||||||
|
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||||
|
} else {
|
||||||
|
let _ = tokio::fs::remove_file(&path).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,12 +445,6 @@ fn unpack_error(err: std::io::Error) -> trc::Error {
|
|||||||
.details("Failed to unpack application bundle")
|
.details("Failed to unpack application bundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for TempDir {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WebApplications {
|
impl Default for WebApplications {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
@@ -460,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}");
|
||||||
@@ -487,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>");
|
||||||
@@ -521,9 +575,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fixture(name: &str, client_id: Option<&str>) -> (WebApplications, TempDir) {
|
async fn fixture(name: &str, client_id: Option<&str>) -> WebApplications {
|
||||||
let dir = TempDir::new(std::env::temp_dir().join(format!("stalwart-app-{name}")));
|
let dir = TempDir::new(std::env::temp_dir().join(format!("inbuxa-app-{name}")));
|
||||||
dir.clean().await.unwrap();
|
dir.create().await.unwrap();
|
||||||
tokio::fs::write(dir.path.join("index.html"), INDEX)
|
tokio::fs::write(dir.path.join("index.html"), INDEX)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -544,6 +598,7 @@ mod tests {
|
|||||||
let routes = Arc::new(AppRoutes {
|
let routes = Arc::new(AppRoutes {
|
||||||
resources,
|
resources,
|
||||||
oauth_client_id_meta: client_id.map(oauth_client_id_meta),
|
oauth_client_id_meta: client_id.map(oauth_client_id_meta),
|
||||||
|
_bundle_dir: dir,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut map = AHashMap::new();
|
let mut map = AHashMap::new();
|
||||||
@@ -553,7 +608,7 @@ mod tests {
|
|||||||
let apps = WebApplications::new();
|
let apps = WebApplications::new();
|
||||||
apps.routes.store(Arc::new(map));
|
apps.routes.store(Arc::new(map));
|
||||||
|
|
||||||
(apps, dir)
|
apps
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String {
|
async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String {
|
||||||
@@ -565,7 +620,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn serving_index_injects_the_prefix_and_client_id() {
|
async fn serving_index_injects_the_prefix_and_client_id() {
|
||||||
let (apps, _dir) = fixture("serve-configured", Some("pocket-id-client")).await;
|
let apps = fixture("serve-configured", Some("pocket-id-client")).await;
|
||||||
|
|
||||||
let html = serve_html(&apps, "admin", "index.html").await;
|
let html = serve_html(&apps, "admin", "index.html").await;
|
||||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||||
@@ -584,7 +639,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unknown_paths_fall_back_to_a_rewritten_index() {
|
async fn unknown_paths_fall_back_to_a_rewritten_index() {
|
||||||
let (apps, _dir) = fixture("serve-fallback", Some("pocket-id-client")).await;
|
let apps = fixture("serve-fallback", Some("pocket-id-client")).await;
|
||||||
|
|
||||||
let html = serve_html(&apps, "admin", "settings/directory").await;
|
let html = serve_html(&apps, "admin", "settings/directory").await;
|
||||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||||
@@ -596,7 +651,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn assets_and_unknown_prefixes_are_untouched() {
|
async fn assets_and_unknown_prefixes_are_untouched() {
|
||||||
let (apps, _dir) = fixture("serve-assets", Some("pocket-id-client")).await;
|
let apps = fixture("serve-assets", Some("pocket-id-client")).await;
|
||||||
|
|
||||||
let served = apps.serve("admin", "app.js").await.unwrap().unwrap();
|
let served = apps.serve("admin", "app.js").await.unwrap().unwrap();
|
||||||
assert_eq!(served.resource.contents, b"export const x = 1;\n");
|
assert_eq!(served.resource.contents, b"export const x = 1;\n");
|
||||||
@@ -608,7 +663,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn serving_index_without_a_client_id_keeps_the_placeholder() {
|
async fn serving_index_without_a_client_id_keeps_the_placeholder() {
|
||||||
let (apps, _dir) = fixture("serve-unconfigured", None).await;
|
let apps = fixture("serve-unconfigured", None).await;
|
||||||
|
|
||||||
let html = serve_html(&apps, "admin", "index.html").await;
|
let html = serve_html(&apps, "admin", "index.html").await;
|
||||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||||
@@ -624,4 +679,65 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes());
|
assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes());
|
||||||
}
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_parent_directories_are_created() {
|
||||||
|
let base = std::env::temp_dir().join("inbuxa-app-nested");
|
||||||
|
let _ = tokio::fs::remove_dir_all(&base).await;
|
||||||
|
|
||||||
|
let dir = TempDir::new(base.join("webui").join("0"));
|
||||||
|
dir.create().await.unwrap();
|
||||||
|
|
||||||
|
assert!(tokio::fs::metadata(&dir.path).await.is_ok());
|
||||||
|
|
||||||
|
drop(dir);
|
||||||
|
let _ = tokio::fs::remove_dir_all(&base).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dropping_the_routes_removes_the_bundle_directory() {
|
||||||
|
let apps = fixture("drop-guard", None).await;
|
||||||
|
let path = apps
|
||||||
|
.routes
|
||||||
|
.load()
|
||||||
|
.get("admin")
|
||||||
|
.unwrap()
|
||||||
|
._bundle_dir
|
||||||
|
.path
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
assert!(tokio::fs::metadata(&path).await.is_ok());
|
||||||
|
|
||||||
|
apps.routes.store(Arc::new(AHashMap::new()));
|
||||||
|
|
||||||
|
assert!(tokio::fs::metadata(&path).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sweeping_orphans_spares_the_current_generation() {
|
||||||
|
let base = std::env::temp_dir().join("inbuxa-app-sweep");
|
||||||
|
let _ = tokio::fs::remove_dir_all(&base).await;
|
||||||
|
|
||||||
|
let current = TempDir::new(base.join("1"));
|
||||||
|
current.create().await.unwrap();
|
||||||
|
let orphan = base.join("0");
|
||||||
|
tokio::fs::create_dir_all(&orphan).await.unwrap();
|
||||||
|
let stray = base.join("webui.zip");
|
||||||
|
tokio::fs::write(&stray, b"not a bundle").await.unwrap();
|
||||||
|
|
||||||
|
remove_siblings(&base, ¤t.path).await;
|
||||||
|
|
||||||
|
assert!(tokio::fs::metadata(¤t.path).await.is_ok());
|
||||||
|
assert!(tokio::fs::metadata(&orphan).await.is_err());
|
||||||
|
assert!(tokio::fs::metadata(&stray).await.is_err());
|
||||||
|
|
||||||
|
drop(current);
|
||||||
|
let _ = tokio::fs::remove_dir_all(&base).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generations_never_repeat() {
|
||||||
|
let apps = WebApplications::new();
|
||||||
|
|
||||||
|
assert_ne!(apps.next_generation(), apps.next_generation());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "Stalwart Web Interface".to_string(),
|
description: "inbuxa Web Interface".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
|
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -312,7 +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: "Stalwart Web Interface (served by this server)".to_string(),
|
description: "inbuxa Web Interface (served by this server)".to_string(),
|
||||||
redirect_uris: vec![
|
redirect_uris: vec![
|
||||||
"https://mail.example.org/admin/oauth/callback".to_string(),
|
"https://mail.example.org/admin/oauth/callback".to_string(),
|
||||||
"https://mail.example.org/account/oauth/callback".to_string(),
|
"https://mail.example.org/account/oauth/callback".to_string(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,7 +98,38 @@ impl AcmeRequestBuilder {
|
|||||||
reuse_key_pem: Option<String>,
|
reuse_key_pem: Option<String>,
|
||||||
dns_parameters: Option<AcmeDnsParameters>,
|
dns_parameters: Option<AcmeDnsParameters>,
|
||||||
) -> AcmeResult<PemCert> {
|
) -> AcmeResult<PemCert> {
|
||||||
let mut params = CertificateParams::new(domains.clone()).map_err(|err| {
|
let mut published = BTreeSet::new();
|
||||||
|
let result = self
|
||||||
|
.run_order(
|
||||||
|
server,
|
||||||
|
&domains,
|
||||||
|
reuse_key_pem,
|
||||||
|
dns_parameters.as_ref(),
|
||||||
|
&mut published,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Some(dns_parameters) = &dns_parameters {
|
||||||
|
for (zone, challenge_name) in published {
|
||||||
|
let _ = dns_parameters
|
||||||
|
.updater
|
||||||
|
.delete_rrset(&zone, &challenge_name, dns_update::DnsRecordType::TXT)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_order(
|
||||||
|
&self,
|
||||||
|
server: &Server,
|
||||||
|
domains: &[String],
|
||||||
|
reuse_key_pem: Option<String>,
|
||||||
|
dns_parameters: Option<&AcmeDnsParameters>,
|
||||||
|
published: &mut BTreeSet<(String, String)>,
|
||||||
|
) -> AcmeResult<PemCert> {
|
||||||
|
let mut params = CertificateParams::new(domains.to_vec()).map_err(|err| {
|
||||||
AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
|
AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
|
||||||
})?;
|
})?;
|
||||||
params.distinguished_name = DistinguishedName::new();
|
params.distinguished_name = DistinguishedName::new();
|
||||||
@@ -110,7 +141,7 @@ impl AcmeRequestBuilder {
|
|||||||
AcmeError::Crypto(format!("Failed to generate key pair: {}", err))
|
AcmeError::Crypto(format!("Failed to generate key pair: {}", err))
|
||||||
})?,
|
})?,
|
||||||
};
|
};
|
||||||
let response = self.new_order(domains.clone()).await?;
|
let response = self.new_order(domains.to_vec()).await?;
|
||||||
let order_url = response.location;
|
let order_url = response.location;
|
||||||
let mut order = response.body;
|
let mut order = response.body;
|
||||||
let mut retry_after = None;
|
let mut retry_after = None;
|
||||||
@@ -119,7 +150,7 @@ impl AcmeRequestBuilder {
|
|||||||
Acme(AcmeEvent::OrderStart),
|
Acme(AcmeEvent::OrderStart),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Details = order_url.to_string(),
|
Details = order_url.to_string(),
|
||||||
Hostname = domains.as_slice(),
|
Hostname = domains,
|
||||||
Type = self.challenge.as_str(),
|
Type = self.challenge.as_str(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -128,19 +159,20 @@ impl AcmeRequestBuilder {
|
|||||||
OrderStatus::Pending => {
|
OrderStatus::Pending => {
|
||||||
if matches!(self.challenge, ChallengeType::Dns01) {
|
if matches!(self.challenge, ChallengeType::Dns01) {
|
||||||
for url in &order.authorizations {
|
for url in &order.authorizations {
|
||||||
self.authorize(server, url, dns_parameters.as_ref()).await?;
|
self.authorize(server, url, dns_parameters, Some(published))
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let auth_futures = order
|
let auth_futures = order
|
||||||
.authorizations
|
.authorizations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|url| self.authorize(server, url, dns_parameters.as_ref()));
|
.map(|url| self.authorize(server, url, dns_parameters, None));
|
||||||
try_join_all(auth_futures).await?;
|
try_join_all(auth_futures).await?;
|
||||||
}
|
}
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Acme(AcmeEvent::AuthCompleted),
|
Acme(AcmeEvent::AuthCompleted),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Hostname = domains.as_slice(),
|
Hostname = domains,
|
||||||
);
|
);
|
||||||
let response = self.order(&order_url).await?;
|
let response = self.order(&order_url).await?;
|
||||||
order = response.body;
|
order = response.body;
|
||||||
@@ -151,7 +183,7 @@ impl AcmeRequestBuilder {
|
|||||||
trc::event!(
|
trc::event!(
|
||||||
Acme(AcmeEvent::OrderProcessing),
|
Acme(AcmeEvent::OrderProcessing),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Hostname = domains.as_slice(),
|
Hostname = domains,
|
||||||
Total = i,
|
Total = i,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -179,7 +211,7 @@ impl AcmeRequestBuilder {
|
|||||||
trc::event!(
|
trc::event!(
|
||||||
Acme(AcmeEvent::OrderReady),
|
Acme(AcmeEvent::OrderReady),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Hostname = domains.as_slice(),
|
Hostname = domains,
|
||||||
);
|
);
|
||||||
|
|
||||||
let csr = params.serialize_request(&key_pair).map_err(|err| {
|
let csr = params.serialize_request(&key_pair).map_err(|err| {
|
||||||
@@ -192,10 +224,10 @@ impl AcmeRequestBuilder {
|
|||||||
trc::event!(
|
trc::event!(
|
||||||
Acme(AcmeEvent::OrderValid),
|
Acme(AcmeEvent::OrderValid),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Hostname = domains.as_slice(),
|
Hostname = domains,
|
||||||
);
|
);
|
||||||
|
|
||||||
let certificate = self.select_certificate(&domains, certificate).await?;
|
let certificate = self.select_certificate(domains, certificate).await?;
|
||||||
|
|
||||||
return Ok(PemCert {
|
return Ok(PemCert {
|
||||||
certificate,
|
certificate,
|
||||||
@@ -213,7 +245,7 @@ impl AcmeRequestBuilder {
|
|||||||
Acme(AcmeEvent::OrderInvalid),
|
Acme(AcmeEvent::OrderInvalid),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Details = order_url.to_string(),
|
Details = order_url.to_string(),
|
||||||
Hostname = domains.as_slice(),
|
Hostname = domains,
|
||||||
Reason = reason.clone(),
|
Reason = reason.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -228,6 +260,7 @@ impl AcmeRequestBuilder {
|
|||||||
server: &Server,
|
server: &Server,
|
||||||
url: &String,
|
url: &String,
|
||||||
dns_parameters: Option<&AcmeDnsParameters>,
|
dns_parameters: Option<&AcmeDnsParameters>,
|
||||||
|
published: Option<&mut BTreeSet<(String, String)>>,
|
||||||
) -> AcmeResult<()> {
|
) -> AcmeResult<()> {
|
||||||
let response = self
|
let response = self
|
||||||
.auth(url)
|
.auth(url)
|
||||||
@@ -289,7 +322,12 @@ impl AcmeRequestBuilder {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
ChallengeType::Dns01 => {
|
ChallengeType::Dns01 => {
|
||||||
let dns_parameters = dns_parameters.unwrap();
|
let Some(dns_parameters) = dns_parameters else {
|
||||||
|
return Err(AcmeError::Invalid(
|
||||||
|
"DNS-01 challenge requested but a DNS provider was not configured"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
let domain = domain.strip_prefix("*.").unwrap_or(&domain);
|
let domain = domain.strip_prefix("*.").unwrap_or(&domain);
|
||||||
|
|
||||||
let zone = dns_parameters
|
let zone = dns_parameters
|
||||||
@@ -310,6 +348,11 @@ impl AcmeRequestBuilder {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(AcmeError::Dns)?;
|
.map_err(AcmeError::Dns)?;
|
||||||
|
|
||||||
|
if let Some(published) = published {
|
||||||
|
published.insert((zone.to_string(), challenge_name.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
dns_parameters
|
dns_parameters
|
||||||
.updater
|
.updater
|
||||||
.wait_for_txt_propagation(&challenge_name, zone, &proof)
|
.wait_for_txt_propagation(&challenge_name, zone, &proof)
|
||||||
|
|||||||
@@ -1150,6 +1150,36 @@ impl DnsUpdater {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_rrset(
|
||||||
|
&self,
|
||||||
|
origin: &str,
|
||||||
|
name: &str,
|
||||||
|
record_type: DnsRecordType,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if let Err(err) = self
|
||||||
|
.updater
|
||||||
|
.set_rrset(
|
||||||
|
name,
|
||||||
|
record_type,
|
||||||
|
self.ttl.as_secs() as u32,
|
||||||
|
Vec::new(),
|
||||||
|
origin,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
trc::event!(
|
||||||
|
Dns(DnsEvent::RecordDeletionFailed),
|
||||||
|
Hostname = name.to_string(),
|
||||||
|
Details = origin.to_string(),
|
||||||
|
Type = record_type.as_str(),
|
||||||
|
Reason = err.to_string(),
|
||||||
|
);
|
||||||
|
return Err(format!("Failed to delete DNS RRSet: {}", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn add_to_rrset(
|
pub async fn add_to_rrset(
|
||||||
&self,
|
&self,
|
||||||
origin: &str,
|
origin: &str,
|
||||||
|
|||||||
@@ -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 super::{
|
use super::{
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use self::limiter::{ConcurrencyLimiter, InFlight};
|
use self::limiter::{ConcurrencyLimiter, InFlight};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use crate::{
|
|||||||
manager::SPAM_CLASSIFIER_KEY,
|
manager::SPAM_CLASSIFIER_KEY,
|
||||||
network::RcptResolution,
|
network::RcptResolution,
|
||||||
};
|
};
|
||||||
|
use ahash::AHashSet;
|
||||||
use directory::Recipient;
|
use directory::Recipient;
|
||||||
use mail_auth::IpLookupStrategy;
|
use mail_auth::IpLookupStrategy;
|
||||||
use registry::schema::enums::ExpressionVariable;
|
use registry::schema::enums::ExpressionVariable;
|
||||||
@@ -37,6 +38,7 @@ use store::{
|
|||||||
write::{AlignedBytes, Archive, QueueClass, ValueClass},
|
write::{AlignedBytes, Archive, QueueClass, ValueClass},
|
||||||
};
|
};
|
||||||
use trc::{AddContext, SpamEvent};
|
use trc::{AddContext, SpamEvent};
|
||||||
|
use utils::DomainPart;
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
pub async fn rcpt_resolve(
|
pub async fn rcpt_resolve(
|
||||||
@@ -163,7 +165,10 @@ impl Server {
|
|||||||
}
|
}
|
||||||
EmailCache::MailingList(id) => {
|
EmailCache::MailingList(id) => {
|
||||||
if let Some(list) = self.try_list(id).await? {
|
if let Some(list) = self.try_list(id).await? {
|
||||||
return Ok(RcptResolution::Expand(list.recipients.clone()));
|
return Ok(RcptResolution::Expand(
|
||||||
|
self.expand_nested_lists(id, list.recipients.clone())
|
||||||
|
.await?,
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
self.inner
|
self.inner
|
||||||
.cache
|
.cache
|
||||||
@@ -195,6 +200,56 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn expand_nested_lists(
|
||||||
|
&self,
|
||||||
|
list_id: u32,
|
||||||
|
recipients: Arc<[Box<str>]>,
|
||||||
|
) -> trc::Result<Arc<[Box<str>]>> {
|
||||||
|
let mut has_nested = false;
|
||||||
|
for member in recipients.iter() {
|
||||||
|
if let Some(EmailCache::MailingList(_)) = self.rcpt_id_from_email(member).await? {
|
||||||
|
has_nested = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has_nested {
|
||||||
|
return Ok(recipients);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut expanded = Vec::with_capacity(recipients.len());
|
||||||
|
let mut seen: AHashSet<Box<str>> = AHashSet::with_capacity(recipients.len());
|
||||||
|
let mut visited = AHashSet::from_iter([list_id]);
|
||||||
|
let mut pending: Vec<Arc<[Box<str>]>> = Vec::new();
|
||||||
|
let mut members = recipients;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
for member in members.iter() {
|
||||||
|
if let Some(EmailCache::MailingList(nested_id)) =
|
||||||
|
self.rcpt_id_from_email(member).await?
|
||||||
|
{
|
||||||
|
if !visited.insert(nested_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(nested) = self.try_list(nested_id).await? {
|
||||||
|
pending.push(nested.recipients.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if seen.insert(member.to_canonical_address().into()) {
|
||||||
|
expanded.push(member.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(next) = pending.pop() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
members = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(expanded.into())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_dkim_signers(
|
pub async fn get_dkim_signers(
|
||||||
&self,
|
&self,
|
||||||
domain: &str,
|
domain: &str,
|
||||||
|
|||||||
@@ -2,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),
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer
|
|||||||
let (_, mut rx) = builder.register();
|
let (_, mut rx) = builder.register();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let resource = Resource::builder()
|
let resource = Resource::builder()
|
||||||
.with_service_name("stalwart")
|
.with_service_name("inbuxa")
|
||||||
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
|
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let instrumentation = InstrumentationScope::builder("stalwart")
|
let instrumentation = InstrumentationScope::builder("inbuxa")
|
||||||
.with_version(types::brand_version_full!())
|
.with_version(types::brand_version_full!())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coordinator"
|
name = "coordinator"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dav-proto"
|
name = "dav-proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dav"
|
name = "dav"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "directory"
|
name = "directory"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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 = "email"
|
name = "email"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ use std::{borrow::Cow, future::Future};
|
|||||||
use store::ahash::AHashMap;
|
use store::ahash::AHashMap;
|
||||||
use types::blob_hash::BlobHash;
|
use types::blob_hash::BlobHash;
|
||||||
|
|
||||||
|
pub const ORCPT_ADDR_TYPE: &str = "rfc822;";
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct IngestMessage {
|
pub struct IngestMessage {
|
||||||
pub sender_address: String,
|
pub sender_address: String,
|
||||||
@@ -40,6 +42,12 @@ pub struct IngestRecipient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl IngestRecipient {
|
impl IngestRecipient {
|
||||||
|
pub fn orcpt_parameter(&self) -> Option<String> {
|
||||||
|
self.orcpt
|
||||||
|
.as_deref()
|
||||||
|
.map(|orcpt| format!("{ORCPT_ADDR_TYPE}{orcpt}"))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_spam(&self) -> bool {
|
pub fn is_spam(&self) -> bool {
|
||||||
self.spam_percentage
|
self.spam_percentage
|
||||||
.is_some_and(|percentage| percentage >= 50)
|
.is_some_and(|percentage| percentage >= 50)
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ impl SieveScriptIngest for Server {
|
|||||||
.caused_by(trc::location!())?;
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
// Create Sieve instance
|
// Create Sieve instance
|
||||||
|
let orcpt = envelope_to.orcpt_parameter();
|
||||||
let mut instance = self.core.sieve.untrusted_runtime.filter_parsed(message);
|
let mut instance = self.core.sieve.untrusted_runtime.filter_parsed(message);
|
||||||
|
|
||||||
// Set account name and email
|
// Set account name and email
|
||||||
@@ -141,7 +142,7 @@ impl SieveScriptIngest for Server {
|
|||||||
// Set envelope
|
// Set envelope
|
||||||
instance.set_envelope(Envelope::From, envelope_from);
|
instance.set_envelope(Envelope::From, envelope_from);
|
||||||
instance.set_envelope(Envelope::To, envelope_to.address.as_str());
|
instance.set_envelope(Envelope::To, envelope_to.address.as_str());
|
||||||
if let Some(orcpt) = &envelope_to.orcpt {
|
if let Some(orcpt) = &orcpt {
|
||||||
instance.set_envelope(Envelope::Orcpt, orcpt.as_str());
|
instance.set_envelope(Envelope::Orcpt, orcpt.as_str());
|
||||||
}
|
}
|
||||||
instance.set_spam_status(spam_status(envelope_to.spam_percentage));
|
instance.set_spam_status(spam_status(envelope_to.spam_percentage));
|
||||||
|
|||||||
@@ -1,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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "groupware"
|
name = "groupware"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "http_proto"
|
name = "http_proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -2,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::manager::application::Resource;
|
use common::manager::application::Resource;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -230,14 +230,7 @@ async fn delivery_diagnose(
|
|||||||
|
|
||||||
// Lookup MX
|
// Lookup MX
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mxs = match server
|
let mxs = match server.mx_lookup(domain.as_str()).await {
|
||||||
.core
|
|
||||||
.smtp
|
|
||||||
.resolvers
|
|
||||||
.dns
|
|
||||||
.mx_lookup(&domain, Some(&server.inner.cache.dns_mx))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(mxs) => mxs,
|
Ok(mxs) => mxs,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
tx.send(DeliveryStage::MxLookupError {
|
tx.send(DeliveryStage::MxLookupError {
|
||||||
@@ -419,7 +412,7 @@ async fn delivery_diagnose(
|
|||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
None
|
continue 'outer;
|
||||||
}
|
}
|
||||||
Ok(TlsaResult::Missing) => {
|
Ok(TlsaResult::Missing) => {
|
||||||
tx.send(DeliveryStage::TlsaNotFound {
|
tx.send(DeliveryStage::TlsaNotFound {
|
||||||
@@ -440,14 +433,17 @@ async fn delivery_diagnose(
|
|||||||
reason: "No TLSA records found for MX".to_string(),
|
reason: "No TLSA records found for MX".to_string(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
None
|
||||||
} else {
|
} else {
|
||||||
tx.send(DeliveryStage::TlsaLookupError {
|
tx.send(DeliveryStage::TlsaLookupError {
|
||||||
elapsed: now.elapsed_ms(),
|
elapsed: now.elapsed_ms(),
|
||||||
reason: err.to_string(),
|
reason: err.to_string(),
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
continue 'outer;
|
||||||
}
|
}
|
||||||
None
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "imap_proto"
|
name = "imap_proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "imap"
|
name = "imap"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -390,6 +390,16 @@ impl<T: SessionStream> SessionData<T> {
|
|||||||
.await
|
.await
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||||
let mut dest_cache = None;
|
let mut dest_cache = None;
|
||||||
|
let train_spam = if dest_mailbox_id == JUNK_ID {
|
||||||
|
Some(true)
|
||||||
|
} else if src_mailbox.id.mailbox_id == JUNK_ID && dest_mailbox_id != TRASH_ID {
|
||||||
|
Some(false)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut train_batch = BatchBuilder::new();
|
||||||
|
let mut did_train = false;
|
||||||
|
train_batch.with_account_id(src_account_id);
|
||||||
for (id, imap_id) in ids {
|
for (id, imap_id) in ids {
|
||||||
match self
|
match self
|
||||||
.server
|
.server
|
||||||
@@ -515,11 +525,33 @@ impl<T: SessionStream> SessionData<T> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(is_spam) = train_spam {
|
||||||
|
self.server
|
||||||
|
.add_account_spam_sample(
|
||||||
|
&mut train_batch,
|
||||||
|
src_account_id,
|
||||||
|
id,
|
||||||
|
is_spam,
|
||||||
|
self.session_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||||
|
train_batch.commit_point();
|
||||||
|
did_train = true;
|
||||||
|
}
|
||||||
|
|
||||||
if is_move {
|
if is_move {
|
||||||
destroy_ids.insert(id);
|
destroy_ids.insert(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if did_train {
|
||||||
|
self.server
|
||||||
|
.commit_batch(train_batch)
|
||||||
|
.await
|
||||||
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||||
|
}
|
||||||
|
|
||||||
// Untag or delete emails
|
// Untag or delete emails
|
||||||
if !destroy_ids.is_empty() {
|
if !destroy_ids.is_empty() {
|
||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jmap_proto"
|
name = "jmap_proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jmap"
|
name = "jmap"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ use crate::{
|
|||||||
use common::{Server, auth::AccessToken};
|
use common::{Server, auth::AccessToken};
|
||||||
use email::{
|
use email::{
|
||||||
cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess},
|
cache::{MessageCacheFetch, email::MessageCacheAccess, mailbox::MailboxCacheAccess},
|
||||||
message::copy::{CopyMessageError, EmailCopy},
|
mailbox::JUNK_ID,
|
||||||
|
message::{
|
||||||
|
copy::{CopyMessageError, EmailCopy},
|
||||||
|
ingest::EmailIngest,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use http_proto::HttpSessionData;
|
use http_proto::HttpSessionData;
|
||||||
use jmap_proto::{
|
use jmap_proto::{
|
||||||
@@ -29,6 +33,7 @@ use jmap_proto::{
|
|||||||
};
|
};
|
||||||
use jmap_tools::{Key, Value};
|
use jmap_tools::{Key, Value};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
use store::write::BatchBuilder;
|
||||||
use trc::AddContext;
|
use trc::AddContext;
|
||||||
use types::acl::Acl;
|
use types::acl::Acl;
|
||||||
use utils::map::vec_map::VecMap;
|
use utils::map::vec_map::VecMap;
|
||||||
@@ -87,6 +92,9 @@ impl JmapEmailCopy for Server {
|
|||||||
};
|
};
|
||||||
let on_success_delete = request.on_success_destroy_original.unwrap_or(false);
|
let on_success_delete = request.on_success_destroy_original.unwrap_or(false);
|
||||||
let mut destroy_ids = Vec::new();
|
let mut destroy_ids = Vec::new();
|
||||||
|
let mut train_batch = BatchBuilder::new();
|
||||||
|
let mut did_train = false;
|
||||||
|
train_batch.with_account_id(from_account_id);
|
||||||
|
|
||||||
'create: for (id, create) in request.create.into_valid() {
|
'create: for (id, create) in request.create.into_valid() {
|
||||||
let mut from_message_id = None;
|
let mut from_message_id = None;
|
||||||
@@ -208,6 +216,7 @@ impl JmapEmailCopy for Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add response
|
// Add response
|
||||||
|
let train_spam = mailboxes.contains(&JUNK_ID);
|
||||||
match self
|
match self
|
||||||
.copy_message(
|
.copy_message(
|
||||||
from_account_id,
|
from_account_id,
|
||||||
@@ -221,6 +230,20 @@ impl JmapEmailCopy for Server {
|
|||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
Ok(email) => {
|
Ok(email) => {
|
||||||
|
if train_spam {
|
||||||
|
self.add_account_spam_sample(
|
||||||
|
&mut train_batch,
|
||||||
|
from_account_id,
|
||||||
|
from_message_id.document_id(),
|
||||||
|
true,
|
||||||
|
session.session_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
train_batch.commit_point();
|
||||||
|
did_train = true;
|
||||||
|
}
|
||||||
|
|
||||||
response
|
response
|
||||||
.created
|
.created
|
||||||
.append(id, ingested_into_object(email).into());
|
.append(id, ingested_into_object(email).into());
|
||||||
@@ -245,6 +268,12 @@ impl JmapEmailCopy for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if did_train {
|
||||||
|
self.commit_batch(train_batch)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
if !response.created.is_empty() {
|
if !response.created.is_empty() {
|
||||||
response.new_state = self.get_cached_messages(account_id).await?.get_state(false);
|
response.new_state = self.get_cached_messages(account_id).await?.get_state(false);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -657,7 +657,7 @@ fn map_dns_server(dns_server: &DnsServerBootstrap) -> Option<registry::schema::s
|
|||||||
// FreeBSD keeps variable application data under /var/db (hier(7))
|
// FreeBSD keeps variable application data under /var/db (hier(7))
|
||||||
// rather than FHS /var/lib.
|
// rather than FHS /var/lib.
|
||||||
const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") {
|
const DEFAULT_DATA_PATH: &str = if cfg!(target_os = "freebsd") {
|
||||||
"/var/db/stalwart/"
|
"/var/db/inbuxa/"
|
||||||
} else {
|
} else {
|
||||||
"/var/lib/inbuxa/"
|
"/var/lib/inbuxa/"
|
||||||
};
|
};
|
||||||
@@ -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,13 +1,13 @@
|
|||||||
[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.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#![warn(clippy::large_futures)]
|
#![warn(clippy::large_futures)]
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ pub async fn insert_test_data(server: &Server) {
|
|||||||
server.inner.data.queue_id_gen.generate(),
|
server.inner.data.queue_id_gen.generate(),
|
||||||
QueueName::default(),
|
QueueName::default(),
|
||||||
);
|
);
|
||||||
assert!(qm.save_changes(server, None).await);
|
assert!(qm.save_changes(server, None, None).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
for report in sample_tls_internal_reports() {
|
for report in sample_tls_internal_reports() {
|
||||||
@@ -163,7 +163,7 @@ fn sample_queued_messages(blob_hashes: Vec<BlobHash>) -> Vec<Message> {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
flags: RCPT_DSN_SENT,
|
flags: RCPT_DSN_SENT,
|
||||||
orcpt: Some("rfc822;[email protected]".into()),
|
orcpt: Some("[email protected]".into()),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
received_from_ip: std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
|
received_from_ip: std::net::IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10)),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "managesieve"
|
name = "managesieve"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "migration"
|
name = "migration"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#![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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "nlp"
|
name = "nlp"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pop3"
|
name = "pop3"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -64,13 +64,7 @@ impl<T: SessionStream> Session<T> {
|
|||||||
)
|
)
|
||||||
.get_full_range();
|
.get_full_range();
|
||||||
|
|
||||||
self.write_bytes(
|
self.write_bytes(Response::Message::<u32> { bytes, lines }.serialize())
|
||||||
Response::Message::<u32> {
|
|
||||||
bytes,
|
|
||||||
lines: lines.unwrap_or(0),
|
|
||||||
}
|
|
||||||
.serialize(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
Err(trc::Pop3Event::Error
|
Err(trc::Pop3Event::Error
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub enum Response<'x, T> {
|
|||||||
List(Vec<T>),
|
List(Vec<T>),
|
||||||
Message {
|
Message {
|
||||||
bytes: SliceRange<'x>,
|
bytes: SliceRange<'x>,
|
||||||
lines: u32,
|
lines: Option<u32>,
|
||||||
},
|
},
|
||||||
Capability {
|
Capability {
|
||||||
mechanisms: Vec<Mechanism>,
|
mechanisms: Vec<Mechanism>,
|
||||||
@@ -54,40 +54,65 @@ impl<'x, T: Display> Response<'x, T> {
|
|||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
Response::Message { bytes, lines } => {
|
Response::Message { bytes, lines } => {
|
||||||
let mut buf = Vec::with_capacity(bytes.len() + 10);
|
let lines = *lines;
|
||||||
buf.extend_from_slice(b"+OK ");
|
let mut message = Vec::with_capacity(bytes.len() + 16);
|
||||||
buf.extend_from_slice(bytes.len().to_string().as_bytes());
|
let mut octets = 0;
|
||||||
buf.extend_from_slice(b" octets\r\n");
|
let mut last_byte = b'\n';
|
||||||
|
let mut in_headers = lines.is_some();
|
||||||
let mut line_count = 0;
|
let mut is_blank_line = true;
|
||||||
let mut last_byte = 0;
|
let mut body_lines = 0;
|
||||||
|
|
||||||
// Transparency procedure
|
// Transparency procedure
|
||||||
for &byte in bytes.into_iter() {
|
for &byte in bytes.into_iter() {
|
||||||
// POP3 requires that lines end with CRLF, do this check to ensure that
|
// POP3 requires that lines end with CRLF, do this check to ensure that
|
||||||
if byte == b'\n' && last_byte != b'\r' {
|
if byte == b'\n' && last_byte != b'\r' {
|
||||||
buf.push(b'\r');
|
message.push(b'\r');
|
||||||
|
octets += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if byte == b'.' && last_byte == b'\n' {
|
if byte == b'.' && last_byte == b'\n' {
|
||||||
buf.push(b'.');
|
message.push(b'.');
|
||||||
}
|
}
|
||||||
buf.push(byte);
|
message.push(byte);
|
||||||
|
octets += 1;
|
||||||
last_byte = byte;
|
last_byte = byte;
|
||||||
|
|
||||||
if *lines > 0 && byte == b'\n' {
|
match byte {
|
||||||
line_count += 1;
|
b'\n' => {
|
||||||
if line_count == *lines {
|
if in_headers {
|
||||||
|
in_headers = !is_blank_line;
|
||||||
|
} else {
|
||||||
|
body_lines += 1;
|
||||||
|
}
|
||||||
|
if !in_headers && lines.is_some_and(|lines| body_lines >= lines) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
is_blank_line = true;
|
||||||
|
}
|
||||||
|
b'\r' => {}
|
||||||
|
_ => {
|
||||||
|
is_blank_line = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if last_byte != b'\n' {
|
if last_byte != b'\n' {
|
||||||
buf.extend_from_slice(b"\r\n");
|
message.extend_from_slice(b"\r\n");
|
||||||
|
octets += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.extend_from_slice(b".\r\n");
|
if in_headers {
|
||||||
|
message.extend_from_slice(b"\r\n");
|
||||||
|
octets += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.extend_from_slice(b".\r\n");
|
||||||
|
|
||||||
|
let mut buf = Vec::with_capacity(message.len() + 24);
|
||||||
|
buf.extend_from_slice(b"+OK ");
|
||||||
|
buf.extend_from_slice(octets.to_string().as_bytes());
|
||||||
|
buf.extend_from_slice(b" octets\r\n");
|
||||||
|
buf.extend_from_slice(&message);
|
||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
Response::Capability { mechanisms, stls } => {
|
Response::Capability { mechanisms, stls } => {
|
||||||
@@ -208,9 +233,51 @@ mod tests {
|
|||||||
(
|
(
|
||||||
Response::Message {
|
Response::Message {
|
||||||
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
|
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
|
||||||
lines: 0,
|
lines: None,
|
||||||
},
|
},
|
||||||
"+OK 35 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n..test\r\na\r\n.\r\n",
|
"+OK 37 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n..test\r\na\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
|
||||||
|
lines: Some(0),
|
||||||
|
},
|
||||||
|
"+OK 17 octets\r\nSubject: test\r\n\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
|
||||||
|
lines: Some(2),
|
||||||
|
},
|
||||||
|
"+OK 27 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
|
||||||
|
lines: Some(100),
|
||||||
|
},
|
||||||
|
"+OK 37 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n..test\r\na\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Single(b"Subject: test\n\nbody\n"),
|
||||||
|
lines: None,
|
||||||
|
},
|
||||||
|
"+OK 23 octets\r\nSubject: test\r\n\r\nbody\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Single(b"Subject: test\n\n.leading dot\n"),
|
||||||
|
lines: Some(1),
|
||||||
|
},
|
||||||
|
"+OK 31 octets\r\nSubject: test\r\n\r\n..leading dot\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Single(b".dot\r\nSubject: test\r\n"),
|
||||||
|
lines: Some(3),
|
||||||
|
},
|
||||||
|
"+OK 23 octets\r\n..dot\r\nSubject: test\r\n\r\n.\r\n",
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
assert_eq!(expected, String::from_utf8(cmd.serialize()).unwrap());
|
assert_eq!(expected, String::from_utf8(cmd.serialize()).unwrap());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "registry"
|
name = "registry"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 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,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "scim-proto"
|
name = "scim-proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "scim"
|
name = "scim"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "services"
|
name = "services"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
inner
|
||||||
|
.shared_core
|
||||||
|
.load()
|
||||||
|
.storage
|
||||||
|
.data
|
||||||
|
.invalidate_read_snapshot();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match batch.next_event() {
|
match batch.next_event() {
|
||||||
Ok(Some(event)) => {
|
Ok(Some(event)) => {
|
||||||
@@ -174,9 +181,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
BroadcastEvent::QueueRefresh => {
|
BroadcastEvent::QueueRefresh => {
|
||||||
let core = inner.shared_core.load_full();
|
if inner.shared_core.load().network.roles.outbound_mta {
|
||||||
if core.network.roles.outbound_mta {
|
|
||||||
core.storage.data.invalidate_read_snapshot();
|
|
||||||
let _ = inner
|
let _ = inner
|
||||||
.ipc
|
.ipc
|
||||||
.queue_tx
|
.queue_tx
|
||||||
@@ -185,9 +190,7 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
BroadcastEvent::RegistryChange(change) => {
|
BroadcastEvent::RegistryChange(change) => {
|
||||||
let server = inner.build_server();
|
match Box::pin(inner.build_server().reload_registry(change)).await {
|
||||||
server.store().invalidate_read_snapshot();
|
|
||||||
match Box::pin(server.reload_registry(change)).await {
|
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
result.log();
|
result.log();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::task_manager::TaskResult;
|
use crate::task_manager::{TaskResult, deferred_retry_time};
|
||||||
use common::Server;
|
use common::Server;
|
||||||
use email::{message::metadata::MessageMetadata, sieve::SieveScript};
|
use email::{message::metadata::MessageMetadata, sieve::SieveScript};
|
||||||
use groupware::file::FileNode;
|
use groupware::file::FileNode;
|
||||||
@@ -41,7 +41,7 @@ impl DestroyAccountTask for Server {
|
|||||||
match destroy_account(self, task).await {
|
match destroy_account(self, task).await {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let result = TaskResult::temporary(err.to_string());
|
let result = TaskResult::deferred(deferred_retry_time(&err), err.to_string());
|
||||||
trc::error!(
|
trc::error!(
|
||||||
err.account_id(task.account_id.document_id())
|
err.account_id(task.account_id.document_id())
|
||||||
.details("Failed to destroy account")
|
.details("Failed to destroy account")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::task_manager::{Task, TaskDetails, TaskFailureType, TaskResult};
|
use crate::task_manager::{Task, TaskDetails, TaskFailureType, TaskResult, deferred_retry_time};
|
||||||
use common::Server;
|
use common::Server;
|
||||||
use email::{
|
use email::{
|
||||||
cache::MessageCacheFetch,
|
cache::MessageCacheFetch,
|
||||||
@@ -274,7 +274,7 @@ impl SearchIndexTask for Server {
|
|||||||
);
|
);
|
||||||
for r in results.iter_mut() {
|
for r in results.iter_mut() {
|
||||||
if r.task_type == TaskType::Insert && r.result.is_success() {
|
if r.task_type == TaskType::Insert && r.result.is_success() {
|
||||||
r.result = search_store_failure(retry_at, "Failed to index documents");
|
r.result = TaskResult::deferred(retry_at, "Failed to index documents");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@@ -331,7 +331,7 @@ impl SearchIndexTask for Server {
|
|||||||
for r in results.iter_mut() {
|
for r in results.iter_mut() {
|
||||||
if r.task_type == TaskType::Delete && r.result.is_success() {
|
if r.task_type == TaskType::Delete && r.result.is_success() {
|
||||||
r.result =
|
r.result =
|
||||||
search_store_failure(retry_at, "Failed to delete documents from index");
|
TaskResult::deferred(retry_at, "Failed to delete documents from index");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@@ -445,22 +445,6 @@ pub(crate) async fn reindex_account(server: &Server, account_id: u32) -> trc::Re
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deferred_retry_time(err: &trc::Error) -> Option<u64> {
|
|
||||||
err.value(trc::Key::NextRetry)
|
|
||||||
.and_then(|value| value.to_uint())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search_store_failure(retry_at: Option<u64>, message: &'static str) -> TaskResult {
|
|
||||||
match retry_at {
|
|
||||||
Some(retry_at) => TaskResult::Failure {
|
|
||||||
typ: TaskFailureType::Retry(retry_at),
|
|
||||||
message: message.into(),
|
|
||||||
max_attempts: None,
|
|
||||||
},
|
|
||||||
None => TaskResult::temporary(message),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attempt_number(status: &TaskStatus) -> u64 {
|
fn attempt_number(status: &TaskStatus) -> u64 {
|
||||||
match status {
|
match status {
|
||||||
TaskStatus::Pending(_) => 0,
|
TaskStatus::Pending(_) => 0,
|
||||||
|
|||||||
@@ -621,6 +621,7 @@ pub fn perpetual_retry_time(typ: TaskType, attempt: u64) -> Option<u64> {
|
|||||||
| TaskType::DkimManagement
|
| TaskType::DkimManagement
|
||||||
| TaskType::IndexDocument
|
| TaskType::IndexDocument
|
||||||
| TaskType::UnindexDocument
|
| TaskType::UnindexDocument
|
||||||
|
| TaskType::DestroyAccount
|
||||||
)
|
)
|
||||||
.then(|| {
|
.then(|| {
|
||||||
now().saturating_add(
|
now().saturating_add(
|
||||||
|
|||||||
@@ -137,4 +137,20 @@ impl TaskResult {
|
|||||||
max_attempts: None,
|
max_attempts: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn deferred(retry_at: Option<u64>, message: impl Into<String>) -> Self {
|
||||||
|
match retry_at {
|
||||||
|
Some(retry_at) => TaskResult::Failure {
|
||||||
|
typ: TaskFailureType::Retry(retry_at),
|
||||||
|
message: message.into(),
|
||||||
|
max_attempts: None,
|
||||||
|
},
|
||||||
|
None => TaskResult::temporary(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn deferred_retry_time(err: &trc::Error) -> Option<u64> {
|
||||||
|
err.value(trc::Key::NextRetry)
|
||||||
|
.and_then(|value| value.to_uint())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::task_manager::{TaskFailureType, TaskResult};
|
use crate::task_manager::{TaskFailureType, TaskResult};
|
||||||
use common::{
|
use common::{
|
||||||
Server,
|
Server,
|
||||||
ipc::{BroadcastEvent, RegistryChange},
|
ipc::{BroadcastEvent, RegistryChange},
|
||||||
manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource},
|
manager::{SPAM_CLASSIFIER_KEY, SPAM_TRAINER_KEY, fetch_resource, spam_rules},
|
||||||
};
|
};
|
||||||
use registry::{
|
use registry::{
|
||||||
schema::{
|
schema::{
|
||||||
@@ -106,6 +108,7 @@ struct RuleUpdateResult {
|
|||||||
|
|
||||||
async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
|
async fn update_spam_rules(server: &Server) -> trc::Result<TaskResult> {
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
|
let bundled = server.core.spam.spam_rules_url.is_none();
|
||||||
let rules = match fetch_spam_rules(server).await {
|
let rules = match fetch_spam_rules(server).await {
|
||||||
Ok(rules) => rules,
|
Ok(rules) => rules,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -289,24 +292,31 @@ 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,
|
||||||
})
|
}),
|
||||||
.and_then(|bytes| {
|
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 {
|
serde_json::from_slice(&bytes).map_err(|err| RuleUpdateError {
|
||||||
typ: TaskFailureType::Permanent,
|
typ: TaskFailureType::Permanent,
|
||||||
reason: format!("Failed to parse spam rules JSON: {err}"),
|
reason: format!("Failed to parse spam rules JSON: {err}"),
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
[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"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use common::{
|
|||||||
config::smtp::auth::VerifyStrategy,
|
config::smtp::auth::VerifyStrategy,
|
||||||
network::{ServerInstance, asn::AsnGeoLookupResult},
|
network::{ServerInstance, asn::AsnGeoLookupResult},
|
||||||
};
|
};
|
||||||
|
use email::message::delivery::ORCPT_ADDR_TYPE;
|
||||||
use mail_auth::{IprevOutput, SpfOutput};
|
use mail_auth::{IprevOutput, SpfOutput};
|
||||||
use smtp_proto::request::receiver::{
|
use smtp_proto::request::receiver::{
|
||||||
BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver, RequestReceiver,
|
BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver, RequestReceiver,
|
||||||
@@ -306,10 +307,13 @@ impl SessionAddress {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn report_address(&self) -> &str {
|
pub fn orig_address(&self) -> &str {
|
||||||
|
self.dsn_info.as_deref().unwrap_or(&self.address_lcase)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn orcpt_parameter(&self) -> Option<String> {
|
||||||
self.dsn_info
|
self.dsn_info
|
||||||
.as_ref()
|
.as_deref()
|
||||||
.and_then(|v| v.strip_prefix("rfc822;"))
|
.map(|orcpt| format!("{ORCPT_ADDR_TYPE}{}", orcpt.to_lowercase()))
|
||||||
.unwrap_or(&self.address_lcase)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ impl<T: SessionStream> Session<T> {
|
|||||||
if !rc.analysis.forward {
|
if !rc.analysis.forward {
|
||||||
self.data
|
self.data
|
||||||
.rcpt_to
|
.rcpt_to
|
||||||
.retain(|rcpt| !rc.analysis.is_report_address(rcpt.report_address()));
|
.retain(|rcpt| !rc.analysis.is_report_address(rcpt.orig_address()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.data.rcpt_to.is_empty() {
|
if self.data.rcpt_to.is_empty() {
|
||||||
|
|||||||
@@ -202,8 +202,8 @@ impl<T: SessionStream> Session<T> {
|
|||||||
let mut new_addr = SessionAddress::new(address);
|
let mut new_addr = SessionAddress::new(address);
|
||||||
|
|
||||||
if !self.data.rcpt_to.contains(&new_addr) {
|
if !self.data.rcpt_to.contains(&new_addr) {
|
||||||
new_addr.dsn_info = format!("rfc822;{}", orig_addr.address_lcase).into();
|
|
||||||
new_addr.flags = orig_addr.flags;
|
new_addr.flags = orig_addr.flags;
|
||||||
|
new_addr.dsn_info = orig_addr.address_lcase.into();
|
||||||
self.data.rcpt_to.push(new_addr);
|
self.data.rcpt_to.push(new_addr);
|
||||||
} else {
|
} else {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
@@ -353,7 +353,6 @@ impl<T: SessionStream> Session<T> {
|
|||||||
// Expand list
|
// Expand list
|
||||||
if let Some(members) = rcpt_members {
|
if let Some(members) = rcpt_members {
|
||||||
let list_addr = self.data.rcpt_to.pop().unwrap();
|
let list_addr = self.data.rcpt_to.pop().unwrap();
|
||||||
let orcpt = format!("rfc822;{}", list_addr.address_lcase);
|
|
||||||
for member in members.as_ref() {
|
for member in members.as_ref() {
|
||||||
let member_lcase = member.to_lowercase();
|
let member_lcase = member.to_lowercase();
|
||||||
let is_local = match self
|
let is_local = match self
|
||||||
@@ -399,7 +398,7 @@ impl<T: SessionStream> Session<T> {
|
|||||||
if !self.data.rcpt_to.contains(&member_addr)
|
if !self.data.rcpt_to.contains(&member_addr)
|
||||||
&& member_addr.address_lcase != list_addr.address_lcase
|
&& member_addr.address_lcase != list_addr.address_lcase
|
||||||
{
|
{
|
||||||
member_addr.dsn_info = orcpt.clone().into();
|
member_addr.dsn_info = list_addr.address_lcase.clone().into();
|
||||||
member_addr.flags = list_addr.flags;
|
member_addr.flags = list_addr.flags;
|
||||||
self.data.rcpt_to.push(member_addr);
|
self.data.rcpt_to.push(member_addr);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use common::{
|
use common::{
|
||||||
@@ -211,7 +213,7 @@ impl<T: SessionStream> Session<T> {
|
|||||||
Request::Help { .. } => {
|
Request::Help { .. } => {
|
||||||
trc::event!(Smtp(SmtpEvent::Help), SpanId = self.data.session_id,);
|
trc::event!(Smtp(SmtpEvent::Help), SpanId = self.data.session_id,);
|
||||||
|
|
||||||
self.write(b"250 2.0.0 Help can be found at https://stalw.art\r\n")
|
self.write(concat!("250 2.0.0 Help can be found at ", types::brand_url!(), "\r\n").as_bytes())
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Request::Helo { host } => {
|
Request::Helo { host } => {
|
||||||
|
|||||||
@@ -89,17 +89,7 @@ impl<T: SessionStream> Session<T> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|r| r.address_lcase.as_str())
|
.map(|r| r.address_lcase.as_str())
|
||||||
.collect(),
|
.collect(),
|
||||||
env_rcpt_orig_to: self
|
env_rcpt_orig_to: self.data.rcpt_to.iter().map(|r| r.orig_address()).collect(),
|
||||||
.data
|
|
||||||
.rcpt_to
|
|
||||||
.iter()
|
|
||||||
.map(|r| {
|
|
||||||
r.dsn_info
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|info| info.strip_prefix("rfc822;"))
|
|
||||||
.unwrap_or(r.address_lcase.as_str())
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
is_test: false,
|
is_test: false,
|
||||||
is_train: false,
|
is_train: false,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ use crate::outbound::lookup::{DnsLookup, SourceIp};
|
|||||||
use crate::outbound::mta_sts::lookup::MtaStsLookup;
|
use crate::outbound::mta_sts::lookup::MtaStsLookup;
|
||||||
use crate::outbound::mta_sts::verify::VerifyPolicy;
|
use crate::outbound::mta_sts::verify::VerifyPolicy;
|
||||||
use crate::outbound::{client::StartTlsResult, dane::verify::TlsaVerify};
|
use crate::outbound::{client::StartTlsResult, dane::verify::TlsaVerify};
|
||||||
use crate::queue::dsn::SendDsn;
|
use crate::queue::dsn::{DsnStatus, SendDsn};
|
||||||
use crate::queue::spool::SmtpSpool;
|
use crate::queue::spool::{DSN_RETRY, SmtpSpool};
|
||||||
use crate::queue::throttle::IsAllowed;
|
use crate::queue::throttle::IsAllowed;
|
||||||
use crate::queue::{
|
use crate::queue::{
|
||||||
Error, FROM_REPORT, HostResponse, MessageWrapper, Metadata, QueueEnvelope, QueuedMessage,
|
Error, FROM_REPORT, HostResponse, MessageWrapper, Metadata, QueueEnvelope, QueuedMessage,
|
||||||
@@ -155,7 +155,7 @@ impl QueuedMessage {
|
|||||||
let span_id = message.span_id;
|
let span_id = message.span_id;
|
||||||
|
|
||||||
// Send any due Delivery Status Notifications
|
// Send any due Delivery Status Notifications
|
||||||
server.send_dsn(&mut message).await;
|
let dsn_status = server.send_dsn(&mut message).await;
|
||||||
|
|
||||||
match has_pending_delivery {
|
match has_pending_delivery {
|
||||||
PendingDelivery::Yes(true)
|
PendingDelivery::Yes(true)
|
||||||
@@ -163,21 +163,27 @@ impl QueuedMessage {
|
|||||||
.message
|
.message
|
||||||
.next_delivery_event(self.queue_name.into())
|
.next_delivery_event(self.queue_name.into())
|
||||||
.is_some_and(|due| due <= now()) => {}
|
.is_some_and(|due| due <= now()) => {}
|
||||||
PendingDelivery::No => {
|
PendingDelivery::No if dsn_status == DsnStatus::Completed => {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Delivery(DeliveryEvent::Completed),
|
Delivery(DeliveryEvent::Completed),
|
||||||
SpanId = span_id,
|
SpanId = span_id,
|
||||||
Elapsed = trc::Value::Duration((now() - message.message.created) * 1000)
|
Elapsed = trc::Value::Duration((now() - message.message.created) * 1000)
|
||||||
);
|
);
|
||||||
|
|
||||||
// All message recipients expired, do not re-queue. (DSN has been already sent)
|
// All message recipients expired, do not re-queue.
|
||||||
message.remove(&server, self.due.into()).await;
|
message.remove(&server, self.due.into()).await;
|
||||||
|
|
||||||
return QueueEventStatus::Completed;
|
return QueueEventStatus::Completed;
|
||||||
}
|
}
|
||||||
|
PendingDelivery::No => {
|
||||||
|
message
|
||||||
|
.save_changes(&server, self.due.into(), Some(now() + DSN_RETRY))
|
||||||
|
.await;
|
||||||
|
return QueueEventStatus::Deferred;
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Re-queue the message if its not yet due for delivery
|
// Re-queue the message if its not yet due for delivery
|
||||||
message.save_changes(&server, self.due.into()).await;
|
message.save_changes(&server, self.due.into(), None).await;
|
||||||
return QueueEventStatus::Deferred;
|
return QueueEventStatus::Deferred;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,7 +214,7 @@ impl QueuedMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message.save_changes(&server, self.due.into()).await;
|
message.save_changes(&server, self.due.into(), None).await;
|
||||||
|
|
||||||
return QueueEventStatus::Deferred;
|
return QueueEventStatus::Deferred;
|
||||||
}
|
}
|
||||||
@@ -1485,7 +1491,7 @@ impl QueuedMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send Delivery Status Notifications
|
// Send Delivery Status Notifications
|
||||||
server.send_dsn(&mut message).await;
|
let dsn_status = server.send_dsn(&mut message).await;
|
||||||
|
|
||||||
// Notify queue manager
|
// Notify queue manager
|
||||||
if message.message.next_event(None).is_some() {
|
if message.message.next_event(None).is_some() {
|
||||||
@@ -1501,7 +1507,13 @@ impl QueuedMessage {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Save changes to disk
|
// Save changes to disk
|
||||||
message.save_changes(&server, self.due.into()).await;
|
message.save_changes(&server, self.due.into(), None).await;
|
||||||
|
|
||||||
|
QueueEventStatus::Deferred
|
||||||
|
} else if dsn_status == DsnStatus::Deferred {
|
||||||
|
message
|
||||||
|
.save_changes(&server, self.due.into(), Some(now() + DSN_RETRY))
|
||||||
|
.await;
|
||||||
|
|
||||||
QueueEventStatus::Deferred
|
QueueEventStatus::Deferred
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ impl MessageWrapper {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
message
|
let _ = message
|
||||||
.queue(
|
.queue(
|
||||||
QueueParams::new(&autogenerated.message, self.span_id, server)
|
QueueParams::new(&autogenerated.message, self.span_id, server)
|
||||||
.with_dkim_signers(dkim_signers)
|
.with_dkim_signers(dkim_signers)
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ use super::{
|
|||||||
Recipient, Status,
|
Recipient, Status,
|
||||||
};
|
};
|
||||||
use crate::inbound::dkim::DkimSign;
|
use crate::inbound::dkim::DkimSign;
|
||||||
use crate::queue::spool::QueueParams;
|
use crate::queue::spool::{DSN_RETRY, QueueParams};
|
||||||
use crate::queue::{MessageWrapper, UnexpectedResponse};
|
use crate::queue::{MessageWrapper, UnexpectedResponse};
|
||||||
use common::Server;
|
use common::Server;
|
||||||
|
use email::message::delivery::ORCPT_ADDR_TYPE;
|
||||||
use mail_builder::MessageBuilder;
|
use mail_builder::MessageBuilder;
|
||||||
use mail_builder::headers::HeaderType;
|
use mail_builder::headers::HeaderType;
|
||||||
use mail_builder::headers::content_type::ContentType;
|
use mail_builder::headers::content_type::ContentType;
|
||||||
@@ -25,16 +26,24 @@ use std::fmt::Write;
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use store::write::now;
|
use store::write::now;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DsnStatus {
|
||||||
|
Completed,
|
||||||
|
Deferred,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait SendDsn: Sync + Send {
|
pub trait SendDsn: Sync + Send {
|
||||||
fn send_dsn(&self, message: &mut MessageWrapper) -> impl Future<Output = ()> + Send;
|
fn send_dsn(&self, message: &mut MessageWrapper) -> impl Future<Output = DsnStatus> + Send;
|
||||||
fn log_dsn(&self, message: &MessageWrapper) -> impl Future<Output = ()> + Send;
|
fn log_dsn(&self, message: &MessageWrapper) -> impl Future<Output = ()> + Send;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SendDsn for Server {
|
impl SendDsn for Server {
|
||||||
async fn send_dsn(&self, message: &mut MessageWrapper) {
|
async fn send_dsn(&self, message: &mut MessageWrapper) -> DsnStatus {
|
||||||
// Send DSN events
|
// Send DSN events
|
||||||
self.log_dsn(message).await;
|
self.log_dsn(message).await;
|
||||||
|
|
||||||
|
let mut status = DsnStatus::Completed;
|
||||||
|
|
||||||
if !message.message.return_path.is_empty() {
|
if !message.message.return_path.is_empty() {
|
||||||
// Build DSN
|
// Build DSN
|
||||||
if let Some(dsn) = message.build_dsn(self).await {
|
if let Some(dsn) = message.build_dsn(self).await {
|
||||||
@@ -51,12 +60,19 @@ impl SendDsn for Server {
|
|||||||
message.span_id,
|
message.span_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
dsn_message
|
if dsn_message
|
||||||
.queue(
|
.queue(
|
||||||
QueueParams::new(&dsn, message.span_id, self)
|
QueueParams::new(&dsn, message.span_id, self)
|
||||||
.with_dkim_signers(dkim_signers),
|
.with_dkim_signers(dkim_signers),
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
|
message.mark_dsn_sent();
|
||||||
|
} else {
|
||||||
|
status = DsnStatus::Deferred;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.mark_dsn_sent();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle double bounce
|
// Handle double bounce
|
||||||
@@ -64,7 +80,9 @@ impl SendDsn for Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update next DSN notify times
|
// Update next DSN notify times
|
||||||
message.update_next_dsn(self).await;
|
message.update_next_dsn(self, status).await;
|
||||||
|
|
||||||
|
status
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn log_dsn(&self, message: &MessageWrapper) {
|
async fn log_dsn(&self, message: &MessageWrapper) {
|
||||||
@@ -132,7 +150,7 @@ impl SendDsn for Server {
|
|||||||
const MAX_HEADER_SIZE: usize = 4096;
|
const MAX_HEADER_SIZE: usize = 4096;
|
||||||
|
|
||||||
impl MessageWrapper {
|
impl MessageWrapper {
|
||||||
pub async fn build_dsn(&mut self, server: &Server) -> Option<Vec<u8>> {
|
pub async fn build_dsn(&self, server: &Server) -> Option<Vec<u8>> {
|
||||||
let config = &server.core.smtp.queue;
|
let config = &server.core.smtp.queue;
|
||||||
let now = now();
|
let now = now();
|
||||||
|
|
||||||
@@ -141,13 +159,12 @@ impl MessageWrapper {
|
|||||||
let mut txt_failed = String::new();
|
let mut txt_failed = String::new();
|
||||||
let mut dsn = String::new();
|
let mut dsn = String::new();
|
||||||
|
|
||||||
for rcpt in &mut self.message.recipients {
|
for rcpt in &self.message.recipients {
|
||||||
if rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) {
|
if rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match &rcpt.status {
|
match &rcpt.status {
|
||||||
Status::Completed(response) => {
|
Status::Completed(response) => {
|
||||||
rcpt.flags |= RCPT_DSN_SENT;
|
|
||||||
if !rcpt.has_flag(RCPT_NOTIFY_SUCCESS) {
|
if !rcpt.has_flag(RCPT_NOTIFY_SUCCESS) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -164,7 +181,6 @@ impl MessageWrapper {
|
|||||||
response.write_dsn_text(&rcpt.address, &mut txt_delay);
|
response.write_dsn_text(&rcpt.address, &mut txt_delay);
|
||||||
}
|
}
|
||||||
Status::PermanentFailure(response) => {
|
Status::PermanentFailure(response) => {
|
||||||
rcpt.flags |= RCPT_DSN_SENT;
|
|
||||||
if !rcpt.has_flag(RCPT_NOTIFY_FAILURE) {
|
if !rcpt.has_flag(RCPT_NOTIFY_FAILURE) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -357,7 +373,7 @@ impl MessageWrapper {
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_next_dsn(&mut self, server: &Server) {
|
pub async fn update_next_dsn(&mut self, server: &Server, status: DsnStatus) {
|
||||||
let now = now();
|
let now = now();
|
||||||
let mut notify_changes = Vec::new();
|
let mut notify_changes = Vec::new();
|
||||||
for (rcpt_idx, rcpt) in self.message.recipients.iter().enumerate() {
|
for (rcpt_idx, rcpt) in self.message.recipients.iter().enumerate() {
|
||||||
@@ -366,6 +382,11 @@ impl MessageWrapper {
|
|||||||
Status::TemporaryFailure(_) | Status::Scheduled
|
Status::TemporaryFailure(_) | Status::Scheduled
|
||||||
) && rcpt.notify.due <= now
|
) && rcpt.notify.due <= now
|
||||||
{
|
{
|
||||||
|
if status == DsnStatus::Deferred {
|
||||||
|
notify_changes.push((rcpt_idx, 0, now + DSN_RETRY));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let envelope = QueueEnvelope::new(&self.message, rcpt);
|
let envelope = QueueEnvelope::new(&self.message, rcpt);
|
||||||
|
|
||||||
let queue_id = server
|
let queue_id = server
|
||||||
@@ -391,6 +412,19 @@ impl MessageWrapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mark_dsn_sent(&mut self) {
|
||||||
|
for rcpt in &mut self.message.recipients {
|
||||||
|
if !rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER)
|
||||||
|
&& matches!(
|
||||||
|
rcpt.status,
|
||||||
|
Status::Completed(_) | Status::PermanentFailure(_)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
rcpt.flags |= RCPT_DSN_SENT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_double_bounce(&mut self) {
|
fn handle_double_bounce(&mut self) {
|
||||||
let mut is_double_bounce = Vec::with_capacity(0);
|
let mut is_double_bounce = Vec::with_capacity(0);
|
||||||
let now = now();
|
let now = now();
|
||||||
@@ -523,7 +557,7 @@ impl Message {
|
|||||||
impl Recipient {
|
impl Recipient {
|
||||||
fn write_dsn(&self, dsn: &mut String) {
|
fn write_dsn(&self, dsn: &mut String) {
|
||||||
if let Some(orcpt) = &self.orcpt {
|
if let Some(orcpt) = &self.orcpt {
|
||||||
let _ = write!(dsn, "Original-Recipient: rfc822;{orcpt}\r\n");
|
let _ = write!(dsn, "Original-Recipient: {ORCPT_ADDR_TYPE}{orcpt}\r\n");
|
||||||
}
|
}
|
||||||
let _ = write!(dsn, "Final-Recipient: rfc822;{}\r\n", self.address);
|
let _ = write!(dsn, "Final-Recipient: rfc822;{}\r\n", self.address);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ use utils::DomainPart;
|
|||||||
|
|
||||||
pub const LOCK_EXPIRY: u64 = 10 * 60; // 10 minutes
|
pub const LOCK_EXPIRY: u64 = 10 * 60; // 10 minutes
|
||||||
pub const QUEUE_REFRESH: u64 = 5 * 60; // 5 minutes
|
pub const QUEUE_REFRESH: u64 = 5 * 60; // 5 minutes
|
||||||
|
pub const DSN_RETRY: u64 = 5 * 60; // 5 minutes
|
||||||
pub(crate) const INFINITE_LOCK: u64 = 60 * 60 * 24 * 365; // 1 year
|
pub(crate) const INFINITE_LOCK: u64 = 60 * 60 * 24 * 365; // 1 year
|
||||||
const CANDIDATE_OVERSCAN: usize = 4;
|
const CANDIDATE_OVERSCAN: usize = 4;
|
||||||
const MAX_PREALLOCATED_CANDIDATES: usize = 1024;
|
const MAX_PREALLOCATED_CANDIDATES: usize = 1024;
|
||||||
@@ -370,6 +371,7 @@ pub(crate) struct QueueParams<'x, 'y> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MessageWrapper {
|
impl MessageWrapper {
|
||||||
|
#[must_use]
|
||||||
pub(crate) async fn queue<'x, 'y>(mut self, mut params: QueueParams<'x, 'y>) -> bool {
|
pub(crate) async fn queue<'x, 'y>(mut self, mut params: QueueParams<'x, 'y>) -> bool {
|
||||||
// Add DKIM signatures
|
// Add DKIM signatures
|
||||||
let dkim_headers = if params.dkim_signers.is_some() {
|
let dkim_headers = if params.dkim_signers.is_some() {
|
||||||
@@ -669,7 +671,12 @@ impl MessageWrapper {
|
|||||||
recipient.queue = queue.virtual_queue;
|
recipient.queue = queue.virtual_queue;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save_changes(mut self, server: &Server, prev_event: Option<u64>) -> bool {
|
pub async fn save_changes(
|
||||||
|
mut self,
|
||||||
|
server: &Server,
|
||||||
|
prev_event: Option<u64>,
|
||||||
|
retry_at: Option<u64>,
|
||||||
|
) -> bool {
|
||||||
// Release quota for completed deliveries
|
// Release quota for completed deliveries
|
||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
self.release_quota(&mut batch);
|
self.release_quota(&mut batch);
|
||||||
@@ -684,7 +691,12 @@ impl MessageWrapper {
|
|||||||
},
|
},
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
for (queue_name, due) in self.message.next_events() {
|
let mut next_events = self.message.next_events();
|
||||||
|
if let Some(retry_at) = retry_at {
|
||||||
|
let due = next_events.entry(self.queue_name).or_insert(retry_at);
|
||||||
|
*due = std::cmp::min(*due, retry_at);
|
||||||
|
}
|
||||||
|
for (queue_name, due) in next_events {
|
||||||
batch.set(
|
batch.set(
|
||||||
ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent {
|
ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent {
|
||||||
due,
|
due,
|
||||||
|
|||||||
@@ -316,9 +316,6 @@ impl<T: SessionStream> Session<T> {
|
|||||||
if let Some(dkim2_output) = dkim2_output {
|
if let Some(dkim2_output) = dkim2_output {
|
||||||
report_record = report_record.with_dkim2_output(dkim2_output);
|
report_record = report_record.with_dkim2_output(dkim2_output);
|
||||||
}
|
}
|
||||||
if let Some(spf_ehlo) = &self.data.spf_ehlo {
|
|
||||||
report_record = report_record.with_spf_output(spf_ehlo, SPFDomainScope::Helo);
|
|
||||||
}
|
|
||||||
if let Some(spf_mail_from) = &self.data.spf_mail_from {
|
if let Some(spf_mail_from) = &self.data.spf_mail_from {
|
||||||
report_record = report_record.with_spf_output(spf_mail_from, SPFDomainScope::MailFrom);
|
report_record = report_record.with_spf_output(spf_mail_from, SPFDomainScope::MailFrom);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
|
|||||||
self.data
|
self.data
|
||||||
.rcpt_to
|
.rcpt_to
|
||||||
.iter()
|
.iter()
|
||||||
.any(|addr| analysis.is_report_address(addr.report_address()))
|
.any(|addr| analysis.is_report_address(addr.orig_address()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ impl MtaReportSend for Server {
|
|||||||
let dkim_signers = self
|
let dkim_signers = self
|
||||||
.eval_signers(sign_config, &message.message, parent_session_id)
|
.eval_signers(sign_config, &message.message, parent_session_id)
|
||||||
.await;
|
.await;
|
||||||
message
|
let _ = message
|
||||||
.queue(
|
.queue(
|
||||||
QueueParams::new(&report, parent_session_id, self).with_dkim_signers(dkim_signers),
|
QueueParams::new(&report, parent_session_id, self).with_dkim_signers(dkim_signers),
|
||||||
)
|
)
|
||||||
@@ -130,7 +130,7 @@ impl MtaReportSend for Server {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
message
|
let _ = message
|
||||||
.queue(
|
.queue(
|
||||||
QueueParams::new(&raw_message, parent_session_id, self)
|
QueueParams::new(&raw_message, parent_session_id, self)
|
||||||
.with_dkim_signers(dkim_signers),
|
.with_dkim_signers(dkim_signers),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use smtp_proto::{
|
|||||||
use utils::DomainPart;
|
use utils::DomainPart;
|
||||||
|
|
||||||
use crate::core::{SessionAddress, SessionData};
|
use crate::core::{SessionAddress, SessionData};
|
||||||
|
use email::message::delivery::ORCPT_ADDR_TYPE;
|
||||||
|
|
||||||
impl SessionData {
|
impl SessionData {
|
||||||
pub fn apply_envelope_modification(&mut self, envelope: Envelope, value: String) {
|
pub fn apply_envelope_modification(&mut self, envelope: Envelope, value: String) {
|
||||||
@@ -111,7 +112,11 @@ impl SessionData {
|
|||||||
}
|
}
|
||||||
Envelope::Orcpt => {
|
Envelope::Orcpt => {
|
||||||
if let Some(rcpt_to) = self.rcpt_to.last_mut() {
|
if let Some(rcpt_to) = self.rcpt_to.last_mut() {
|
||||||
rcpt_to.dsn_info = value.into();
|
rcpt_to.dsn_info = value
|
||||||
|
.strip_prefix(ORCPT_ADDR_TYPE)
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or(value)
|
||||||
|
.into();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Envelope::Envid => {
|
Envelope::Envid => {
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ impl RunScript for Server {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
message
|
let _ = message
|
||||||
.queue(
|
.queue(
|
||||||
QueueParams::new(raw_message, session_id, self)
|
QueueParams::new(raw_message, session_id, self)
|
||||||
.with_dkim_signers(dkim_signers)
|
.with_dkim_signers(dkim_signers)
|
||||||
|
|||||||
@@ -95,10 +95,8 @@ impl<T: SessionStream> Session<T> {
|
|||||||
params
|
params
|
||||||
.envelope
|
.envelope
|
||||||
.push((Envelope::To, rcpt.address_lcase.to_string().into()));
|
.push((Envelope::To, rcpt.address_lcase.to_string().into()));
|
||||||
if let Some(orcpt) = &rcpt.dsn_info {
|
if let Some(orcpt) = rcpt.orcpt_parameter() {
|
||||||
params
|
params.envelope.push((Envelope::Orcpt, orcpt.into()));
|
||||||
.envelope
|
|
||||||
.push((Envelope::Orcpt, orcpt.as_str().to_lowercase().into()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -109,10 +107,10 @@ impl<T: SessionStream> Session<T> {
|
|||||||
|
|
||||||
for rcpt in &self.data.rcpt_to {
|
for rcpt in &self.data.rcpt_to {
|
||||||
recipients.push(Variable::from(rcpt.address_lcase.to_string()));
|
recipients.push(Variable::from(rcpt.address_lcase.to_string()));
|
||||||
orcpts.push(match &rcpt.dsn_info {
|
orcpts.push(match rcpt.orcpt_parameter() {
|
||||||
Some(orcpt) => {
|
Some(orcpt) => {
|
||||||
has_orcpts = true;
|
has_orcpts = true;
|
||||||
Variable::from(orcpt.as_str().to_lowercase())
|
Variable::from(orcpt)
|
||||||
}
|
}
|
||||||
None => Variable::default(),
|
None => Variable::default(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "spam-filter"
|
name = "spam-filter"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::analysis::domain::SpamFilterAnalyzeDomain;
|
use crate::analysis::domain::SpamFilterAnalyzeDomain;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "store"
|
name = "store"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user