Compare commits
34
Commits
ef068abbb1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5bf67f1bf | ||
|
|
c240946248 | ||
|
|
ee4988e00d | ||
|
|
b2ded0a776 | ||
|
|
3a272096c0 | ||
|
|
b6660554e6 | ||
|
|
7bda874230 | ||
|
|
a4b091578d | ||
|
|
39df888412 | ||
|
|
697f647f8b | ||
|
|
14250cee03 | ||
|
|
cea3d53eb0 | ||
|
|
335281f1de | ||
|
|
7f14992e81 | ||
|
|
1f963a9a1c | ||
|
|
b353f4ad2a | ||
|
|
d7a428a4ce | ||
|
|
367bb2c641 | ||
|
|
10bd747a7b | ||
|
|
ae10c32271 | ||
|
|
c90064f9d8 | ||
|
|
8846a280f1 | ||
|
|
e3717f7990 | ||
|
|
7d2c2d2322 | ||
|
|
6c6fe91d0c | ||
|
|
840215d109 | ||
|
|
d2f41bce26 | ||
|
|
96c7bab032 | ||
|
|
79b6787397 | ||
|
|
3f40b36032 | ||
|
|
cd99037ca4 | ||
|
|
b65afb66f9 | ||
|
|
64cddc9246 | ||
|
|
4b585905d7 |
@@ -20,6 +20,16 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# The upstream name in a new string literal, typically brought in by an
|
||||||
|
# upstream merge. Seconds, and needs no toolchain. tools/fork/name-check.py.
|
||||||
|
name-check:
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
- run: python3 tools/fork/name-check.py
|
||||||
|
|
||||||
build:
|
build:
|
||||||
# Either runner (host1 or host2): the build needs no docker socket.
|
# Either runner (host1 or host2): the build needs no docker socket.
|
||||||
runs-on: light
|
runs-on: light
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Publish the container image, ported from .github/workflows/publish.yml when
|
||||||
|
# the project moved to the self-hosted Gitea (2026-09-22). Starts on a v* tag,
|
||||||
|
# whether a person pushed it or weekly-release.yml created it through the
|
||||||
|
# releases API.
|
||||||
|
#
|
||||||
|
# The image is multi-arch (linux/amd64, linux/arm64) as before, but built in
|
||||||
|
# one buildx run on host1 instead of one native runner per architecture: the
|
||||||
|
# Dockerfile's builder stage runs on the build platform and cross-compiles
|
||||||
|
# with an aarch64 linker, so only the small final stage (apt, setcap) goes
|
||||||
|
# through QEMU for arm64. No digest-joining job is needed.
|
||||||
|
#
|
||||||
|
# Two guards before anything is pushed:
|
||||||
|
# * the tag must be v<brand_version!>. The version is a string in
|
||||||
|
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
|
||||||
|
# with it, so a tag beside an unbumped macro would publish an image that
|
||||||
|
# reports a different version from its tag.
|
||||||
|
# * the tag must be on main, so an image never describes code that was never
|
||||||
|
# reviewed onto the default branch.
|
||||||
|
#
|
||||||
|
# :latest moves with every published tag: tags are cut by the weekly release
|
||||||
|
# (or by hand for a real release); there are no prerelease tags here.
|
||||||
|
#
|
||||||
|
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
|
||||||
|
# own token is refused by the container registry.
|
||||||
|
name: publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version:
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.v.outputs.version }}
|
||||||
|
steps:
|
||||||
|
# Full history: the ancestry check cannot be answered from a shallow
|
||||||
|
# clone. The checkout also fetches every branch as origin/*.
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- id: v
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Scoped to the macro body: branding.rs holds other string literals,
|
||||||
|
# and tagging an image from one of those would be worse than failing.
|
||||||
|
V="$(awk '/macro_rules! brand_version /,/^}/' crates/types/src/branding.rs \
|
||||||
|
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
|
||||||
|
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
|
||||||
|
if [ "$TAG" != "v$V" ]; then
|
||||||
|
echo "Tag $TAG names a commit whose brand_version! says $V." >&2
|
||||||
|
echo "Refusing to publish an image that would report the wrong version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|
||||||
|
|| { echo "$TAG is not on main" >&2; exit 1; }
|
||||||
|
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version $V"
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [version]
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
- run: |
|
||||||
|
test -n "$REGISTRY" && test -n "$VERSION"
|
||||||
|
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
|
||||||
|
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||||
|
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
|
||||||
|
# Attestations off, as before: they add manifests of their own to the
|
||||||
|
# index, and the index should hold the two images and nothing else.
|
||||||
|
- run: |
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--provenance=false --sbom=false \
|
||||||
|
--tag "$IMAGE:$VERSION" \
|
||||||
|
--tag "$IMAGE:latest" \
|
||||||
|
--push .
|
||||||
|
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||||
|
# Gitea keeps a container package on its owner; linking it shows it on
|
||||||
|
# the repository's Packages tab. Idempotent.
|
||||||
|
- run: |
|
||||||
|
apk add --no-cache -q curl
|
||||||
|
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
|
||||||
|
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|
||||||
|
|| echo "package already linked (or link refused); not fatal"
|
||||||
|
- if: always()
|
||||||
|
run: docker logout "$REGISTRY" || true
|
||||||
|
|
||||||
|
# The weekly release creates its Release (and so the tag) first; a tag
|
||||||
|
# pushed by hand has none. Either way the tag ends up with exactly one
|
||||||
|
# Release, created after the image exists so its pull instructions work.
|
||||||
|
release:
|
||||||
|
needs: [version, publish]
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
steps:
|
||||||
|
- shell: bash
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, urllib.request, urllib.error
|
||||||
|
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
|
||||||
|
h = {"Authorization": f"token {os.environ['TOKEN']}", "Content-Type": "application/json"}
|
||||||
|
tag, version = os.environ["TAG"], os.environ["VERSION"]
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(urllib.request.Request(f"{api}/releases/tags/{tag}", headers=h))
|
||||||
|
print(f"{tag} already has a release"); raise SystemExit
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code != 404: raise
|
||||||
|
image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}"
|
||||||
|
body = f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`."
|
||||||
|
data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body}).encode()
|
||||||
|
r = json.load(urllib.request.urlopen(urllib.request.Request(f"{api}/releases", data=data, headers=h)))
|
||||||
|
print(f"created release {r['tag_name']}")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Watch upstream for releases the fork hasn't imported yet, and open an issue
|
||||||
|
# for each one so it waits in the tracker until someone strips it in.
|
||||||
|
#
|
||||||
|
# Reads metadata only -- the releases list from GitHub's API and the head of
|
||||||
|
# this repo's `upstream` branch from Gitea's. Nothing of upstream's is fetched,
|
||||||
|
# so none of its history (which carries the Enterprise code) can land here.
|
||||||
|
# Importing is still by hand: tools/fork/strip.py onto `upstream`, then merge,
|
||||||
|
# as docs/spec/SPEC.md §2.2 and §2.2a describe.
|
||||||
|
#
|
||||||
|
# The imported base is the tag in the `upstream` branch's head commit subject
|
||||||
|
# ("Import upstream v0.16.22, stripped"). Drafts and pre-releases are ignored.
|
||||||
|
# An issue is opened once per release: an existing one with the same title,
|
||||||
|
# open or closed, stops a second.
|
||||||
|
#
|
||||||
|
# Daily 06:17 UTC; run it by hand with workflow_dispatch.
|
||||||
|
name: upstream-watch
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '17 6 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: upstream-watch
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upstream-watch:
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
steps:
|
||||||
|
- shell: bash
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, os, re, sys, urllib.request
|
||||||
|
|
||||||
|
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
|
||||||
|
def call(method, url, body=None, token=os.environ["TOKEN"]):
|
||||||
|
headers = {"Content-Type": "application/json", "User-Agent": "inbuxa-upstream-watch"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"token {token}"
|
||||||
|
req = urllib.request.Request(url, method=method, headers=headers,
|
||||||
|
data=json.dumps(body).encode() if body is not None else None)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.load(r)
|
||||||
|
SEMVER = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
||||||
|
def key(tag):
|
||||||
|
return tuple(int(x) for x in SEMVER.match(tag).groups())
|
||||||
|
|
||||||
|
subject = call("GET", f"{api}/branches/upstream")["commit"]["message"].splitlines()[0]
|
||||||
|
m = re.search(r"\bupstream (v\d+\.\d+\.\d+)\b", subject)
|
||||||
|
if not m:
|
||||||
|
print(f"Can't read the imported base from the upstream branch: {subject!r}", file=sys.stderr); sys.exit(1)
|
||||||
|
base = m.group(1)
|
||||||
|
|
||||||
|
# Unauthenticated: a public repo, once a day, well inside the limit.
|
||||||
|
rels = call("GET", "https://api.github.com/repos/stalwartlabs/stalwart/releases?per_page=30", token=None)
|
||||||
|
newer = sorted((r for r in rels
|
||||||
|
if not r["draft"] and not r["prerelease"] and SEMVER.match(r["tag_name"])
|
||||||
|
and key(r["tag_name"]) > key(base)),
|
||||||
|
key=lambda r: key(r["tag_name"]))
|
||||||
|
if not newer:
|
||||||
|
print(f"Up to date: {base} is the newest upstream release."); sys.exit(0)
|
||||||
|
|
||||||
|
# Titles and bodies stay free of the upstream project's name, as the
|
||||||
|
# rest of the fork's user-visible text does.
|
||||||
|
existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues&q=Import+upstream&limit=50")}
|
||||||
|
for r in newer:
|
||||||
|
tag = r["tag_name"]
|
||||||
|
title = f"Import upstream {tag}"
|
||||||
|
if title in existing:
|
||||||
|
print(f"{tag}: issue already exists."); continue
|
||||||
|
body = (f"Upstream published {tag} on {r['published_at'][:10]}. "
|
||||||
|
f"The fork's imported base is {base}.\n\n"
|
||||||
|
"Import it as tools/fork/README.md describes:\n\n"
|
||||||
|
"```bash\n"
|
||||||
|
"git -C \"$UPSTREAM_CLONE\" fetch --tags\n"
|
||||||
|
f"tools/fork/strip.py --upstream \"$UPSTREAM_CLONE\" --ref {tag} --out /tmp/strip-{tag}\n"
|
||||||
|
"```\n\n"
|
||||||
|
"Commit the stripped tree to `upstream` with the strip report in the message, "
|
||||||
|
"add any new third-party notices to `THIRD-PARTY.md`, then merge `upstream` into `main`.")
|
||||||
|
issue = call("POST", f"{api}/issues", {"title": title, "body": body})
|
||||||
|
print(f"{tag}: opened #{issue['number']}.")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Weekly release, ported from .github/workflows/release.yml when the project
|
||||||
|
# moved to the self-hosted Gitea (2026-09-22): cut a release once a week, but
|
||||||
|
# only if there is something in it. A release with nothing in it moves
|
||||||
|
# :latest to an identical build, spends a version number, and notifies
|
||||||
|
# everybody about nothing.
|
||||||
|
#
|
||||||
|
# The version is the date, YYYY.M.D unpadded, with a .N suffix from 2 for a
|
||||||
|
# second release on one day. It lives in crates/types/src/branding.rs
|
||||||
|
# (brand_version!), deliberately not in Cargo.toml so upstream's version bumps
|
||||||
|
# merge without conflicts. The bump is committed to main and the tag names that
|
||||||
|
# commit, so the tree a tag points at reports the version the tag claims --
|
||||||
|
# publish.yml refuses a tag that doesn't.
|
||||||
|
#
|
||||||
|
# Mondays 10:07 UTC, last of the three INBUXA releases: Admin and the webmail
|
||||||
|
# release ahead of the server they talk to. Run it by hand with
|
||||||
|
# workflow_dispatch; dry_run defaults to true.
|
||||||
|
#
|
||||||
|
# NOT LIVE YET: this only ever dry-runs unless the Actions variable
|
||||||
|
# RELEASE_LIVE is '1' (repo or org). Going live also needs a repo secret
|
||||||
|
# RELEASE_TOKEN (jcoffey-dev, write:repository, allowed to push to main):
|
||||||
|
# * a tag Gitea creates for the job's own token raises no event, and the
|
||||||
|
# tag must start publish.yml;
|
||||||
|
# * the bump is committed through the contents API. Gitea has no "only if
|
||||||
|
# the branch is still at X" guard, so the job checks main's head right
|
||||||
|
# before writing and refuses if it moved since the commit it counted from;
|
||||||
|
# run it again. (The API does refuse if the file itself changed, via its
|
||||||
|
# blob sha.)
|
||||||
|
name: weekly-release
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '7 10 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: Show the decision and stop
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
# One at a time: two overlapping runs would race to write the same version and
|
||||||
|
# create the same tag.
|
||||||
|
concurrency:
|
||||||
|
group: weekly-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
weekly-release:
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
env:
|
||||||
|
READ_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
# Live only with RELEASE_LIVE=1 AND either the schedule or a manual run
|
||||||
|
# with dry_run unticked.
|
||||||
|
DRY_RUN: ${{ (vars.RELEASE_LIVE == '1' && (github.event_name == 'schedule' || inputs.dry_run == false || inputs.dry_run == 'false')) && '0' || '1' }}
|
||||||
|
RELEASE_LIVE: ${{ vars.RELEASE_LIVE }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- shell: bash
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import base64, datetime, json, os, re, subprocess, sys, urllib.request
|
||||||
|
|
||||||
|
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
|
||||||
|
def call(method, path, token, body=None):
|
||||||
|
req = urllib.request.Request(api + path, method=method,
|
||||||
|
data=json.dumps(body).encode() if body is not None else None,
|
||||||
|
headers={"Authorization": f"token {token}", "Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
return json.load(r)
|
||||||
|
def git(*a):
|
||||||
|
return subprocess.run(["git", *a], check=True, capture_output=True, text=True).stdout.strip()
|
||||||
|
def has_tag(t):
|
||||||
|
# show-ref matches an exact ref; rev-parse --verify on this git
|
||||||
|
# can read some tag names as describe output and "find" a tag
|
||||||
|
# that isn't there.
|
||||||
|
return subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/tags/{t}"]).returncode == 0
|
||||||
|
|
||||||
|
sha = git("rev-parse", "HEAD")
|
||||||
|
# The newest published release, or empty on a project that has never
|
||||||
|
# had one -- in which case everything counts as new. A release can
|
||||||
|
# outlive its tag; falling back to the whole history over-counts,
|
||||||
|
# which cuts a release that was due anyway.
|
||||||
|
rels = call("GET", "/releases?draft=false&pre-release=false&limit=1", os.environ["READ_TOKEN"])
|
||||||
|
previous = rels[0]["tag_name"] if rels else ""
|
||||||
|
rng = f"{previous}..HEAD" if previous and has_tag(previous) else "HEAD"
|
||||||
|
count = int(git("rev-list", "--count", rng))
|
||||||
|
if count == 0:
|
||||||
|
print(f"Nothing to release: no commits since {previous}."); sys.exit(0)
|
||||||
|
|
||||||
|
d = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
today = f"{d.year}.{d.month}.{d.day}"
|
||||||
|
version, n = today, 2
|
||||||
|
while has_tag(f"v{version}"):
|
||||||
|
version, n = f"{today}.{n}", n + 1
|
||||||
|
tag = f"v{version}"
|
||||||
|
print(f"Releasing {tag} -- {count} commit(s) since {previous or 'the beginning'}, from {sha}.")
|
||||||
|
if os.environ["DRY_RUN"] == "1":
|
||||||
|
print(f"Dry run (RELEASE_LIVE='{os.environ.get('RELEASE_LIVE', '')}'): stopping here."); sys.exit(0)
|
||||||
|
|
||||||
|
token = os.environ.get("RELEASE_TOKEN", "")
|
||||||
|
if not token:
|
||||||
|
print("RELEASE_TOKEN secret is not set on this repository", file=sys.stderr); sys.exit(1)
|
||||||
|
|
||||||
|
# Scoped to the macro body rather than replacing the first quoted
|
||||||
|
# string in the file, and asserted to have matched exactly once:
|
||||||
|
# branding.rs holds other string literals.
|
||||||
|
path = "crates/types/src/branding.rs"
|
||||||
|
src = open(path, encoding="utf-8").read()
|
||||||
|
out, hits = re.subn(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")',
|
||||||
|
lambda m: m.group(1) + version + m.group(2), src, count=1)
|
||||||
|
assert hits == 1, f"brand_version! not found in {path}"
|
||||||
|
|
||||||
|
head = call("GET", "/branches/main", token)["commit"]["id"]
|
||||||
|
if head != sha:
|
||||||
|
print(f"main moved from {sha} to {head} since this run counted; run it again.", file=sys.stderr); sys.exit(1)
|
||||||
|
blob = call("GET", f"/contents/{path}?ref={sha}", token)["sha"]
|
||||||
|
bump = call("PUT", f"/contents/{path}", token, {
|
||||||
|
"branch": "main", "message": f"Version {version}", "sha": blob,
|
||||||
|
"content": base64.b64encode(out.encode()).decode()})["commit"]["sha"]
|
||||||
|
print(f"committed the bump as {bump}")
|
||||||
|
|
||||||
|
# Notes bounded to what is new: one line per change on main's
|
||||||
|
# first-parent history. Creating the release creates the tag, which
|
||||||
|
# is an ordinary push, so publish.yml builds and pushes the image.
|
||||||
|
notes = git("log", "--first-parent", "--format=- %s", rng)
|
||||||
|
rel = call("POST", "/releases", token, {
|
||||||
|
"tag_name": tag, "target_commitish": bump, "name": f"INBUXA {version}",
|
||||||
|
"body": f"{count} commit(s) since {previous or 'the beginning'}.\n\n{notes}"})
|
||||||
|
print(f"created release {rel['tag_name']}")
|
||||||
|
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
+139
-130
@@ -234,7 +234,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
"synstructure",
|
"synstructure 0.13.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -277,9 +277,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.46"
|
version = "0.4.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f"
|
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-codecs",
|
"compression-codecs",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -310,7 +310,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"regex",
|
"regex",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -369,7 +369,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -874,7 +874,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"num",
|
"num",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -984,7 +984,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1110,9 +1110,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.4.6"
|
version = "1.4.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
|
checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -1160,9 +1160,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg_aliases"
|
name = "cfg_aliases"
|
||||||
@@ -1302,7 +1302,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm-siv",
|
"aes-gcm-siv",
|
||||||
"ahash",
|
"ahash",
|
||||||
@@ -1402,9 +1402,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.41"
|
version = "0.4.43"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
|
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-core",
|
"compression-core",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -1487,7 +1487,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coordinator"
|
name = "coordinator"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -1849,7 +1849,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strsim",
|
"strsim",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1882,7 +1882,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"darling_core 0.24.1",
|
"darling_core 0.24.1",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1899,7 +1899,7 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dav"
|
name = "dav"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"calcard",
|
"calcard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1922,7 +1922,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dav-proto"
|
name = "dav-proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"calcard",
|
"calcard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -2135,7 +2135,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "directory"
|
name = "directory"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"argon2 0.6.0",
|
"argon2 0.6.0",
|
||||||
@@ -2192,7 +2192,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2376,7 +2376,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "email"
|
name = "email"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.3",
|
"aes 0.9.3",
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
@@ -2485,10 +2485,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event_macro"
|
name = "event_macro"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2571,7 +2571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4"
|
checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"web-time",
|
"web-time",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2594,9 +2594,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.12"
|
version = "0.1.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fixed_decimal"
|
name = "fixed_decimal"
|
||||||
@@ -2710,7 +2710,7 @@ dependencies = [
|
|||||||
"foundationdb-sys",
|
"foundationdb-sys",
|
||||||
"foundationdb-tuple",
|
"foundationdb-tuple",
|
||||||
"futures",
|
"futures",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_bytes",
|
"serde_bytes",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2842,7 +2842,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3013,7 +3013,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "groupware"
|
name = "groupware"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"calcard",
|
"calcard",
|
||||||
@@ -3169,7 +3169,7 @@ dependencies = [
|
|||||||
"jni",
|
"jni",
|
||||||
"lru-cache",
|
"lru-cache",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
@@ -3196,7 +3196,7 @@ dependencies = [
|
|||||||
"jni",
|
"jni",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"prefix-trie",
|
"prefix-trie",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
@@ -3223,7 +3223,7 @@ dependencies = [
|
|||||||
"ndk-context",
|
"ndk-context",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"resolv-conf",
|
"resolv-conf",
|
||||||
"rustls",
|
"rustls",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
@@ -3302,7 +3302,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3398,7 +3398,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http_proto"
|
name = "http_proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -3488,9 +3488,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-rustls"
|
name = "hyper-rustls"
|
||||||
version = "0.27.9"
|
version = "0.27.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
"hyper",
|
"hyper",
|
||||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.5",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3884,7 +3884,7 @@ checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "imap"
|
name = "imap"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"common",
|
"common",
|
||||||
@@ -3897,7 +3897,7 @@ dependencies = [
|
|||||||
"md5",
|
"md5",
|
||||||
"nlp",
|
"nlp",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"registry",
|
"registry",
|
||||||
"store",
|
"store",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -3909,7 +3909,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "imap_proto"
|
name = "imap_proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3924,7 +3924,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inbuxa"
|
name = "inbuxa"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"coordinator",
|
"coordinator",
|
||||||
@@ -3932,7 +3932,7 @@ dependencies = [
|
|||||||
"directory",
|
"directory",
|
||||||
"email",
|
"email",
|
||||||
"groupware",
|
"groupware",
|
||||||
"http 0.16.22",
|
"http 0.16.23",
|
||||||
"http_proto",
|
"http_proto",
|
||||||
"imap",
|
"imap",
|
||||||
"jmap",
|
"jmap",
|
||||||
@@ -4134,25 +4134,24 @@ checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jieba-macros"
|
name = "jieba-macros"
|
||||||
version = "0.10.3"
|
version = "0.10.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34904340bc65749a9e9a02fcc7f3368e675427c18447b9bbe02df52c15c9a36a"
|
checksum = "455f837e9d0255b68a712200db247c68fdad4941b72471b76bfa61c3b0c1f79f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_codegen",
|
"phf_codegen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jieba-rs"
|
name = "jieba-rs"
|
||||||
version = "0.10.3"
|
version = "0.10.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d"
|
checksum = "b6a8bbb0f77ee810f0689a30b7cec56b875751ef4ec2e74fd995613dc52b3ae1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytecount",
|
"bytecount",
|
||||||
"cedarwood",
|
"cedarwood",
|
||||||
"include-flate",
|
"include-flate",
|
||||||
"jieba-macros",
|
"jieba-macros",
|
||||||
"phf 0.13.1",
|
"phf 0.13.1",
|
||||||
"regex",
|
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4212,7 +4211,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jmap"
|
name = "jmap"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -4236,7 +4235,7 @@ dependencies = [
|
|||||||
"mail-parser",
|
"mail-parser",
|
||||||
"nlp",
|
"nlp",
|
||||||
"p256",
|
"p256",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"registry",
|
"registry",
|
||||||
"reqwest 0.13.5",
|
"reqwest 0.13.5",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
@@ -4294,7 +4293,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jmap_proto"
|
name = "jmap_proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"calcard",
|
"calcard",
|
||||||
@@ -4699,9 +4698,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lz4-sys"
|
name = "lz4-sys"
|
||||||
@@ -4742,9 +4741,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mail-auth"
|
name = "mail-auth"
|
||||||
version = "0.13.2"
|
version = "0.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e11f19d98aac923fc5b7ee30c3509733a013ef546a226acb959b9202f5ca58f0"
|
checksum = "8505122ba86e1f4adeb664196c1e787c3f29bb6e7c128e4a366d47d209911440"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -4757,7 +4756,7 @@ dependencies = [
|
|||||||
"mail-parser",
|
"mail-parser",
|
||||||
"memchr",
|
"memchr",
|
||||||
"quick-xml 0.42.0",
|
"quick-xml 0.42.0",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"rsa",
|
"rsa",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
@@ -4800,7 +4799,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "managesieve"
|
name = "managesieve"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -4935,7 +4934,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "migration"
|
name = "migration"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"email",
|
"email",
|
||||||
@@ -5066,7 +5065,7 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"rustversion",
|
"rustversion",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5131,7 +5130,7 @@ dependencies = [
|
|||||||
"lru",
|
"lru",
|
||||||
"mysql_common",
|
"mysql_common",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"socket2 0.6.5",
|
"socket2 0.6.5",
|
||||||
@@ -5206,14 +5205,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nlp"
|
name = "nlp"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"hashify",
|
"hashify",
|
||||||
"jieba-rs",
|
"jieba-rs",
|
||||||
"maplit",
|
"maplit",
|
||||||
"psl",
|
"psl",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"rust-stemmers",
|
"rust-stemmers",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -6038,7 +6037,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pop3"
|
name = "pop3"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"directory",
|
"directory",
|
||||||
@@ -6082,7 +6081,7 @@ dependencies = [
|
|||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"md-5 0.11.0",
|
"md-5 0.11.0",
|
||||||
"memchr",
|
"memchr",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
]
|
]
|
||||||
@@ -6119,9 +6118,9 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppmd-rust"
|
name = "ppmd-rust"
|
||||||
version = "1.4.1"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e9219bcb9d7aca6b2f63c83cf100cf78bcd619ac46e6ecbd0dd90869a39345d"
|
checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
@@ -6206,7 +6205,7 @@ dependencies = [
|
|||||||
"proc-macro-error-attr3",
|
"proc-macro-error-attr3",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6260,7 +6259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"itertools 0.13.0",
|
"itertools 0.14.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
@@ -6287,9 +6286,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psl"
|
name = "psl"
|
||||||
version = "2.1.232"
|
version = "2.1.235"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "62834e308cc83aea5e30cd8c80b8aa82cdb104a3240c7f210d4f68d46e29f308"
|
checksum = "8319b56ff38ca0522b4e1e40bfa2b5de7f62dc89fc1e9033eac365551ec58e0e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"psl-types",
|
"psl-types",
|
||||||
]
|
]
|
||||||
@@ -6317,7 +6316,7 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6388,9 +6387,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.11"
|
version = "0.11.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
@@ -6399,7 +6398,7 @@ dependencies = [
|
|||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.5",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6408,16 +6407,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.17"
|
version = "0.11.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"bytes",
|
"bytes",
|
||||||
"fastbloom",
|
"fastbloom",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rand_pcg",
|
"rand_pcg",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
@@ -6440,7 +6439,7 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.5",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -6534,9 +6533,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.10.2"
|
version = "0.10.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chacha20",
|
"chacha20",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -6776,7 +6775,7 @@ dependencies = [
|
|||||||
"num-bigint 0.5.1",
|
"num-bigint 0.5.1",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-native-certs",
|
"rustls-native-certs",
|
||||||
"ryu",
|
"ryu",
|
||||||
@@ -6800,11 +6799,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
@@ -6826,7 +6824,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6860,7 +6858,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "registry"
|
name = "registry"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"hashify",
|
"hashify",
|
||||||
@@ -7044,7 +7042,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7225,9 +7223,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"errno",
|
"errno",
|
||||||
@@ -7412,12 +7410,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"serde_derive_internals",
|
"serde_derive_internals",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scim"
|
name = "scim"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -7443,7 +7441,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scim-proto"
|
name = "scim-proto"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashify",
|
"hashify",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7580,7 +7578,7 @@ dependencies = [
|
|||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"sha3 0.10.9",
|
"sha3 0.10.9",
|
||||||
"slh-dsa",
|
"slh-dsa",
|
||||||
"thiserror 1.0.69",
|
"thiserror 2.0.20",
|
||||||
"twofish",
|
"twofish",
|
||||||
"typenum",
|
"typenum",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
@@ -7624,7 +7622,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7635,7 +7633,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7671,7 +7669,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7716,7 +7714,7 @@ dependencies = [
|
|||||||
"darling 0.24.1",
|
"darling 0.24.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7764,12 +7762,12 @@ checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "services"
|
name = "services"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
@@ -8084,7 +8082,7 @@ checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smtp"
|
name = "smtp"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -8099,7 +8097,7 @@ dependencies = [
|
|||||||
"mail-builder 1.0.0",
|
"mail-builder 1.0.0",
|
||||||
"mail-parser",
|
"mail-parser",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"registry",
|
"registry",
|
||||||
"reqwest 0.13.5",
|
"reqwest 0.13.5",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
@@ -8175,7 +8173,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spam-filter"
|
name = "spam-filter"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -8295,7 +8293,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "store"
|
name = "store"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -8321,7 +8319,7 @@ dependencies = [
|
|||||||
"parking_lot",
|
"parking_lot",
|
||||||
"r2d2",
|
"r2d2",
|
||||||
"radsort",
|
"radsort",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"rayon",
|
"rayon",
|
||||||
"redis",
|
"redis",
|
||||||
"registry",
|
"registry",
|
||||||
@@ -8417,9 +8415,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.5"
|
version = "3.0.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -8446,6 +8444,17 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "synstructure"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sysinfo"
|
name = "sysinfo"
|
||||||
version = "0.37.2"
|
version = "0.37.2"
|
||||||
@@ -8544,7 +8553,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tests"
|
name = "tests"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
@@ -8566,7 +8575,7 @@ dependencies = [
|
|||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures",
|
"futures",
|
||||||
"groupware",
|
"groupware",
|
||||||
"http 0.16.22",
|
"http 0.16.23",
|
||||||
"http_proto",
|
"http_proto",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
@@ -8652,7 +8661,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8790,7 +8799,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8812,7 +8821,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"postgres-protocol",
|
"postgres-protocol",
|
||||||
"postgres-types",
|
"postgres-types",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"socket2 0.6.5",
|
"socket2 0.6.5",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
@@ -8997,7 +9006,7 @@ dependencies = [
|
|||||||
"constant_time_eq",
|
"constant_time_eq",
|
||||||
"hmac 0.13.0",
|
"hmac 0.13.0",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1 0.11.0",
|
"sha1 0.11.0",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
@@ -9136,7 +9145,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trc"
|
name = "trc"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -9195,7 +9204,7 @@ dependencies = [
|
|||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.10.2",
|
"rand 0.10.3",
|
||||||
"sha1 0.11.0",
|
"sha1 0.11.0",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
@@ -9245,7 +9254,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types"
|
name = "types"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -9297,9 +9306,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.26"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-normalization"
|
name = "unicode-normalization"
|
||||||
@@ -9414,7 +9423,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utils"
|
name = "utils"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arcstr",
|
"arcstr",
|
||||||
@@ -9616,7 +9625,7 @@ dependencies = [
|
|||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -10125,14 +10134,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke-derive"
|
name = "yoke-derive"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
"synstructure",
|
"synstructure 0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10655,14 +10664,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom-derive"
|
name = "zerofrom-derive"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.6",
|
||||||
"synstructure",
|
"synstructure 0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10717,7 +10726,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.5",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -10749,9 +10758,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.7"
|
version = "0.6.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM --platform=$BUILDPLATFORM docker.io/lukemathwalker/cargo-chef:latest-rust-slim-trixie AS chef
|
FROM --platform=$BUILDPLATFORM docker.io/lukemathwalker/cargo-chef:latest-rust-slim-trixie@sha256:38dfdbf4fda95c516f873f33032e490baa988b75f7d83c7d12f788f770785b36 AS chef
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM chef AS planner
|
FROM --platform=$BUILDPLATFORM chef AS planner
|
||||||
|
|||||||
+2
-2
@@ -35,8 +35,8 @@ to Stalwart Labs with credit to you, and you'll be told that has happened.
|
|||||||
|
|
||||||
This repository is the mail server. The web front ends have their own:
|
This repository is the mail server. The web front ends have their own:
|
||||||
|
|
||||||
- [inbuxa-admin](https://github.com/inbuxa/inbuxa-admin)
|
- [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
|
||||||
- [ihasmail-inbuxa](https://github.com/inbuxa/ihasmail-inbuxa)
|
- [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa)
|
||||||
|
|
||||||
Upstream's own security documents are kept in `.github-upstream/` for
|
Upstream's own security documents are kept in `.github-upstream/` for
|
||||||
reference. They describe Stalwart Labs' process, not this project's.
|
reference. They describe Stalwart Labs' process, not this project's.
|
||||||
|
|||||||
@@ -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,25 +639,50 @@ pub fn unavailable_directory() -> &'static Arc<Directory> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_jwt_domain(token: &str) -> Option<String> {
|
#[derive(Deserialize)]
|
||||||
let mut parts = token.split('.');
|
struct JwtClaims<'x> {
|
||||||
let _header = parts.next()?;
|
#[serde(borrow, default)]
|
||||||
let payload = parts.next()?;
|
iss: Option<Cow<'x, str>>,
|
||||||
let _signature = parts.next()?;
|
#[serde(borrow, default)]
|
||||||
if parts.next().is_some() {
|
email: Option<Cow<'x, str>>,
|
||||||
return None;
|
#[serde(borrow, default)]
|
||||||
}
|
preferred_username: Option<Cow<'x, str>>,
|
||||||
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?;
|
#[serde(borrow, default)]
|
||||||
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
upn: Option<Cow<'x, str>>,
|
||||||
for claim in ["email", "preferred_username", "upn"] {
|
}
|
||||||
if let Some(val) = claims.get(claim).and_then(|v| v.as_str())
|
|
||||||
&& let Some((_, domain)) = val.rsplit_once('@')
|
impl<'x> JwtClaims<'x> {
|
||||||
&& !domain.is_empty()
|
fn decode_payload(token: &str) -> Option<Vec<u8>> {
|
||||||
{
|
if token.starts_with(TOKEN_HEADER) {
|
||||||
return Some(domain.to_ascii_lowercase());
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut parts = token.split('.');
|
||||||
|
let _header = parts.next()?;
|
||||||
|
let payload = parts.next()?;
|
||||||
|
let _signature = parts.next()?;
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(payload: &'x [u8]) -> Option<Self> {
|
||||||
|
serde_json::from_slice(payload).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain(&self) -> Option<&str> {
|
||||||
|
[&self.email, &self.preferred_username, &self.upn]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.find_map(|claim| {
|
||||||
|
claim
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, domain)| domain)
|
||||||
|
.filter(|domain| !domain.is_empty())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UsernameParts {
|
impl UsernameParts {
|
||||||
@@ -738,3 +780,76 @@ impl AuthRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn jwt(payload: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"eyJhbGciOiJSUzI1NiJ9.{}.c2lnbmF0dXJl",
|
||||||
|
general_purpose::URL_SAFE_NO_PAD.encode(payload)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hints(token: &str) -> Option<(Option<String>, Option<String>)> {
|
||||||
|
let payload = JwtClaims::decode_payload(token)?;
|
||||||
|
let claims = JwtClaims::parse(&payload)?;
|
||||||
|
|
||||||
|
Some((
|
||||||
|
claims.domain().map(str::to_string),
|
||||||
|
claims.iss.as_deref().map(str::to_string),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jwt_claims_are_extracted() {
|
||||||
|
for (payload, domain, issuer) in [
|
||||||
|
(
|
||||||
|
r#"{"iss":"https://idp.example.org","email":"[email protected]"}"#,
|
||||||
|
Some("Example.ORG"),
|
||||||
|
Some("https://idp.example.org"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"{"preferred_username":"[email protected]","upn":"[email protected]"}"#,
|
||||||
|
Some("example.net"),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"{"email":"broken@","upn":"[email protected]"}"#,
|
||||||
|
Some("example.com"),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"{"iss":"https://idp.example.org","sub":"5db2d1b6","aud":["a","b"],"scope":"openid"}"#,
|
||||||
|
None,
|
||||||
|
Some("https://idp.example.org"),
|
||||||
|
),
|
||||||
|
(r#"{"sub":"5db2d1b6"}"#, None, None),
|
||||||
|
(r#"{"email":"[email protected]"}"#, Some("example.net"), None),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
hints(&jwt(payload)),
|
||||||
|
Some((domain.map(str::to_string), issuer.map(str::to_string))),
|
||||||
|
"Unexpected claims for {payload}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_jwt_tokens_are_ignored() {
|
||||||
|
for token in [
|
||||||
|
"sw1.eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLm9yZyJ9",
|
||||||
|
"sw1.eyJhbGciOiJSUzI1NiJ9",
|
||||||
|
"opaque-token",
|
||||||
|
"one.two",
|
||||||
|
"one.two.three.four",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
JwtClaims::decode_payload(token).is_none(),
|
||||||
|
"Token {token:?} was parsed as a JWT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ pub struct Network {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct NetworkInfo {
|
pub struct NetworkInfo {
|
||||||
pub pacc: Pacc,
|
pub pacc: Pacc,
|
||||||
|
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
|
||||||
|
/// served while legacy protocols are off (legacy-protocols LP-7).
|
||||||
|
pub pacc_jmap_only: Pacc,
|
||||||
pub mxs: Vec<MailExchanger>,
|
pub mxs: Vec<MailExchanger>,
|
||||||
pub services: VecMap<ServiceProtocol, Service>,
|
pub services: VecMap<ServiceProtocol, Service>,
|
||||||
}
|
}
|
||||||
@@ -320,11 +323,26 @@ impl Network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let (prefix, suffix) = serde_json::to_string(&pacc)
|
let split = |pacc: &Configuration| {
|
||||||
.unwrap_or_default()
|
serde_json::to_string(pacc)
|
||||||
.rsplit_once(SPLIT_HERE)
|
.unwrap_or_default()
|
||||||
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
|
.rsplit_once(SPLIT_HERE)
|
||||||
.unwrap();
|
.map(|(prefix, suffix)| Pacc {
|
||||||
|
prefix: prefix.to_string(),
|
||||||
|
suffix: suffix.to_string(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
// inbuxa: legacy-protocols LP-7
|
||||||
|
let pacc_jmap_only = {
|
||||||
|
let mut pacc = pacc.clone();
|
||||||
|
pacc.protocols.imap = None;
|
||||||
|
pacc.protocols.pop3 = None;
|
||||||
|
pacc.protocols.smtp = None;
|
||||||
|
pacc.protocols.managesieve = None;
|
||||||
|
split(&pacc)
|
||||||
|
};
|
||||||
|
let pacc = split(&pacc);
|
||||||
let mut network = Network {
|
let mut network = Network {
|
||||||
node_id: bp.node_id() as u64,
|
node_id: bp.node_id() as u64,
|
||||||
server_name: default_hostname.to_string(),
|
server_name: default_hostname.to_string(),
|
||||||
@@ -339,7 +357,8 @@ impl Network {
|
|||||||
info: NetworkInfo {
|
info: NetworkInfo {
|
||||||
mxs: system.mail_exchangers.into_iter().collect(),
|
mxs: system.mail_exchangers.into_iter().collect(),
|
||||||
services: system.services,
|
services: system.services,
|
||||||
pacc: Pacc { prefix, suffix },
|
pacc,
|
||||||
|
pacc_jmap_only,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
.blob_store()
|
) -> trc::Result<AppRoutes> {
|
||||||
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
let cached = if force_refresh {
|
||||||
.await?
|
None
|
||||||
{
|
|
||||||
bundle
|
|
||||||
} else {
|
} else {
|
||||||
// Fetch app bundle
|
|
||||||
let resource = fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
|
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
trc::ResourceEvent::Error
|
|
||||||
.caused_by(trc::location!())
|
|
||||||
.ctx(Key::Url, self.url.clone())
|
|
||||||
.reason(err)
|
|
||||||
.details("Failed to fetch application bundle")
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Store in blob store for future use
|
|
||||||
server
|
server
|
||||||
.blob_store()
|
.blob_store()
|
||||||
.put_blob(self.blob_key.as_slice(), &resource, CompressionAlgo::None)
|
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
||||||
.await
|
.await?
|
||||||
.caused_by(trc::location!())?;
|
};
|
||||||
|
let is_cached = cached.is_some();
|
||||||
// Schedule expiration
|
let bundle = match cached {
|
||||||
let mut batch = BatchBuilder::new();
|
Some(bundle) => bundle,
|
||||||
batch
|
None => self.fetch().await?,
|
||||||
.set(
|
|
||||||
BlobOp::Link {
|
|
||||||
hash: self.blob_key.clone(),
|
|
||||||
to: BlobLink::Temporary {
|
|
||||||
until: now() + self.expiry,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
.set(
|
|
||||||
BlobOp::Commit {
|
|
||||||
hash: self.blob_key.clone(),
|
|
||||||
},
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
server
|
|
||||||
.store()
|
|
||||||
.write(batch.build_all())
|
|
||||||
.await
|
|
||||||
.caused_by(trc::location!())?;
|
|
||||||
|
|
||||||
trc::event!(
|
|
||||||
Resource(trc::ResourceEvent::ApplicationUpdated),
|
|
||||||
Url = self.url.clone(),
|
|
||||||
Details = self.description.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
resource
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let staging = TempDir::new(self.base_path.join(format!("{:x}-{generation:x}", now())));
|
||||||
|
staging.create().await.map_err(unpack_error)?;
|
||||||
|
|
||||||
let url = self.url.clone();
|
let url = self.url.clone();
|
||||||
let bundle_path = self.bundle_path.path.clone();
|
let bundle_path = staging.path.clone();
|
||||||
let routes = tokio::task::spawn_blocking(move || -> trc::Result<_> {
|
let (resources, bundle) = tokio::task::spawn_blocking(move || -> trc::Result<_> {
|
||||||
let mut bundle = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
|
let mut archive = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
|
||||||
trc::ResourceEvent::Error
|
trc::ResourceEvent::Error
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
.reason(err)
|
.reason(err)
|
||||||
.ctx(Key::Url, url.clone())
|
.ctx(Key::Url, url.clone())
|
||||||
.details("Failed to decompress application bundle")
|
.details("Failed to decompress application bundle")
|
||||||
})?;
|
})?;
|
||||||
let mut routes = AHashMap::new();
|
let mut resources = AHashMap::with_capacity(archive.len());
|
||||||
for i in 0..bundle.len() {
|
for i in 0..archive.len() {
|
||||||
let mut file = bundle.by_index(i).map_err(|err| {
|
let mut file = archive.by_index(i).map_err(|err| {
|
||||||
trc::ResourceEvent::Error
|
trc::ResourceEvent::Error
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
.reason(err)
|
.reason(err)
|
||||||
@@ -315,9 +291,9 @@ impl WebApplicationManager {
|
|||||||
contents: path,
|
contents: path,
|
||||||
};
|
};
|
||||||
|
|
||||||
routes.insert(file_name, resource);
|
resources.insert(file_name, resource);
|
||||||
}
|
}
|
||||||
Ok(routes)
|
Ok((resources, archive.into_inner().into_inner()))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
@@ -327,21 +303,81 @@ impl WebApplicationManager {
|
|||||||
.details("Bundle unpack task panicked")
|
.details("Bundle unpack task panicked")
|
||||||
})??;
|
})??;
|
||||||
|
|
||||||
|
if !is_cached && let Err(err) = self.cache(server, &bundle).await {
|
||||||
|
trc::event!(
|
||||||
|
Resource(trc::ResourceEvent::Error),
|
||||||
|
Reason = err,
|
||||||
|
Url = self.url.clone(),
|
||||||
|
Details = "Failed to cache application bundle, it will be downloaded again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if sweep_orphans {
|
||||||
|
remove_siblings(&self.base_path, &staging.path).await;
|
||||||
|
}
|
||||||
|
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Resource(trc::ResourceEvent::ApplicationUnpacked),
|
Resource(trc::ResourceEvent::ApplicationUnpacked),
|
||||||
Url = self.url.clone(),
|
Url = self.url.clone(),
|
||||||
Path = self.bundle_path.path.to_string_lossy().into_owned(),
|
Path = staging.path.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(routes)
|
Ok(AppRoutes {
|
||||||
|
resources,
|
||||||
|
oauth_client_id_meta: self.oauth_client_id.as_deref().map(oauth_client_id_meta),
|
||||||
|
_bundle_dir: staging,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, server: &Server) -> trc::Result<()> {
|
async fn fetch(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
trc::ResourceEvent::Error
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.ctx(Key::Url, self.url.clone())
|
||||||
|
.reason(err)
|
||||||
|
.details("Failed to fetch application bundle")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cache(&self, server: &Server, bundle: &[u8]) -> trc::Result<()> {
|
||||||
server
|
server
|
||||||
.blob_store()
|
.blob_store()
|
||||||
.delete_blob(self.blob_key.as_slice())
|
.put_blob(self.blob_key.as_slice(), bundle, CompressionAlgo::None)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch
|
||||||
|
.set(
|
||||||
|
BlobOp::Link {
|
||||||
|
hash: self.blob_key.clone(),
|
||||||
|
to: BlobLink::Temporary {
|
||||||
|
until: now() + self.expiry,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.set(
|
||||||
|
BlobOp::Commit {
|
||||||
|
hash: self.blob_key.clone(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
server
|
||||||
|
.store()
|
||||||
|
.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
trc::event!(
|
||||||
|
Resource(trc::ResourceEvent::ApplicationUpdated),
|
||||||
|
Url = self.url.clone(),
|
||||||
|
Details = self.description.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> {
|
pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> {
|
||||||
@@ -361,7 +397,6 @@ impl Resource<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TempDir {
|
pub struct TempDir {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -371,11 +406,36 @@ impl TempDir {
|
|||||||
TempDir { path }
|
TempDir { path }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clean(&self) -> io::Result<()> {
|
pub async fn create(&self) -> io::Result<()> {
|
||||||
if tokio::fs::metadata(&self.path).await.is_ok() {
|
if tokio::fs::metadata(&self.path).await.is_ok() {
|
||||||
let _ = tokio::fs::remove_dir_all(&self.path).await;
|
let _ = tokio::fs::remove_dir_all(&self.path).await;
|
||||||
}
|
}
|
||||||
tokio::fs::create_dir(&self.path).await
|
tokio::fs::create_dir_all(&self.path).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_siblings(base_path: &Path, keep: &Path) {
|
||||||
|
let Ok(mut entries) = tokio::fs::read_dir(base_path).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let path = entry.path();
|
||||||
|
if path == keep {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(entry.file_type().await, Ok(file_type) if file_type.is_dir()) {
|
||||||
|
let _ = tokio::fs::remove_dir_all(&path).await;
|
||||||
|
} else {
|
||||||
|
let _ = tokio::fs::remove_file(&path).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,12 +445,6 @@ fn unpack_error(err: std::io::Error) -> trc::Error {
|
|||||||
.details("Failed to unpack application bundle")
|
.details("Failed to unpack application bundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for TempDir {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for WebApplications {
|
impl Default for WebApplications {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
@@ -521,9 +575,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fixture(name: &str, client_id: Option<&str>) -> (WebApplications, TempDir) {
|
async fn fixture(name: &str, client_id: Option<&str>) -> WebApplications {
|
||||||
let dir = TempDir::new(std::env::temp_dir().join(format!("stalwart-app-{name}")));
|
let dir = TempDir::new(std::env::temp_dir().join(format!("inbuxa-app-{name}")));
|
||||||
dir.clean().await.unwrap();
|
dir.create().await.unwrap();
|
||||||
tokio::fs::write(dir.path.join("index.html"), INDEX)
|
tokio::fs::write(dir.path.join("index.html"), INDEX)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -544,6 +598,7 @@ mod tests {
|
|||||||
let routes = Arc::new(AppRoutes {
|
let routes = Arc::new(AppRoutes {
|
||||||
resources,
|
resources,
|
||||||
oauth_client_id_meta: client_id.map(oauth_client_id_meta),
|
oauth_client_id_meta: client_id.map(oauth_client_id_meta),
|
||||||
|
_bundle_dir: dir,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut map = AHashMap::new();
|
let mut map = AHashMap::new();
|
||||||
@@ -553,7 +608,7 @@ mod tests {
|
|||||||
let apps = WebApplications::new();
|
let apps = WebApplications::new();
|
||||||
apps.routes.store(Arc::new(map));
|
apps.routes.store(Arc::new(map));
|
||||||
|
|
||||||
(apps, dir)
|
apps
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String {
|
async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String {
|
||||||
@@ -565,7 +620,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn serving_index_injects_the_prefix_and_client_id() {
|
async fn serving_index_injects_the_prefix_and_client_id() {
|
||||||
let (apps, _dir) = fixture("serve-configured", Some("pocket-id-client")).await;
|
let apps = fixture("serve-configured", Some("pocket-id-client")).await;
|
||||||
|
|
||||||
let html = serve_html(&apps, "admin", "index.html").await;
|
let html = serve_html(&apps, "admin", "index.html").await;
|
||||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||||
@@ -584,7 +639,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unknown_paths_fall_back_to_a_rewritten_index() {
|
async fn unknown_paths_fall_back_to_a_rewritten_index() {
|
||||||
let (apps, _dir) = fixture("serve-fallback", Some("pocket-id-client")).await;
|
let apps = fixture("serve-fallback", Some("pocket-id-client")).await;
|
||||||
|
|
||||||
let html = serve_html(&apps, "admin", "settings/directory").await;
|
let html = serve_html(&apps, "admin", "settings/directory").await;
|
||||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||||
@@ -596,7 +651,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn assets_and_unknown_prefixes_are_untouched() {
|
async fn assets_and_unknown_prefixes_are_untouched() {
|
||||||
let (apps, _dir) = fixture("serve-assets", Some("pocket-id-client")).await;
|
let apps = fixture("serve-assets", Some("pocket-id-client")).await;
|
||||||
|
|
||||||
let served = apps.serve("admin", "app.js").await.unwrap().unwrap();
|
let served = apps.serve("admin", "app.js").await.unwrap().unwrap();
|
||||||
assert_eq!(served.resource.contents, b"export const x = 1;\n");
|
assert_eq!(served.resource.contents, b"export const x = 1;\n");
|
||||||
@@ -608,7 +663,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn serving_index_without_a_client_id_keeps_the_placeholder() {
|
async fn serving_index_without_a_client_id_keeps_the_placeholder() {
|
||||||
let (apps, _dir) = fixture("serve-unconfigured", None).await;
|
let apps = fixture("serve-unconfigured", None).await;
|
||||||
|
|
||||||
let html = serve_html(&apps, "admin", "index.html").await;
|
let html = serve_html(&apps, "admin", "index.html").await;
|
||||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||||
@@ -624,4 +679,65 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes());
|
assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes());
|
||||||
}
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_parent_directories_are_created() {
|
||||||
|
let base = std::env::temp_dir().join("inbuxa-app-nested");
|
||||||
|
let _ = tokio::fs::remove_dir_all(&base).await;
|
||||||
|
|
||||||
|
let dir = TempDir::new(base.join("webui").join("0"));
|
||||||
|
dir.create().await.unwrap();
|
||||||
|
|
||||||
|
assert!(tokio::fs::metadata(&dir.path).await.is_ok());
|
||||||
|
|
||||||
|
drop(dir);
|
||||||
|
let _ = tokio::fs::remove_dir_all(&base).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dropping_the_routes_removes_the_bundle_directory() {
|
||||||
|
let apps = fixture("drop-guard", None).await;
|
||||||
|
let path = apps
|
||||||
|
.routes
|
||||||
|
.load()
|
||||||
|
.get("admin")
|
||||||
|
.unwrap()
|
||||||
|
._bundle_dir
|
||||||
|
.path
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
assert!(tokio::fs::metadata(&path).await.is_ok());
|
||||||
|
|
||||||
|
apps.routes.store(Arc::new(AHashMap::new()));
|
||||||
|
|
||||||
|
assert!(tokio::fs::metadata(&path).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sweeping_orphans_spares_the_current_generation() {
|
||||||
|
let base = std::env::temp_dir().join("inbuxa-app-sweep");
|
||||||
|
let _ = tokio::fs::remove_dir_all(&base).await;
|
||||||
|
|
||||||
|
let current = TempDir::new(base.join("1"));
|
||||||
|
current.create().await.unwrap();
|
||||||
|
let orphan = base.join("0");
|
||||||
|
tokio::fs::create_dir_all(&orphan).await.unwrap();
|
||||||
|
let stray = base.join("webui.zip");
|
||||||
|
tokio::fs::write(&stray, b"not a bundle").await.unwrap();
|
||||||
|
|
||||||
|
remove_siblings(&base, ¤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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ mod tests {
|
|||||||
|
|
||||||
fn web_interface() -> Application {
|
fn web_interface() -> Application {
|
||||||
Application {
|
Application {
|
||||||
description: "Stalwart Web Interface".to_string(),
|
description: "INBUXA Web Interface".to_string(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
|
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -312,7 +312,7 @@ mod tests {
|
|||||||
clients,
|
clients,
|
||||||
vec![FirstPartyClient {
|
vec![FirstPartyClient {
|
||||||
client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
|
client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
|
||||||
description: "Stalwart Web Interface (served by this server)".to_string(),
|
description: "INBUXA Web Interface (served by this server)".to_string(),
|
||||||
redirect_uris: vec![
|
redirect_uris: vec![
|
||||||
"https://mail.example.org/admin/oauth/callback".to_string(),
|
"https://mail.example.org/admin/oauth/callback".to_string(),
|
||||||
"https://mail.example.org/account/oauth/callback".to_string(),
|
"https://mail.example.org/account/oauth/callback".to_string(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
* 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::application::Resource};
|
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||||
use quick_xml::Reader;
|
use quick_xml::Reader;
|
||||||
use quick_xml::XmlVersion;
|
use quick_xml::XmlVersion;
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
@@ -55,7 +57,15 @@ impl Server {
|
|||||||
let _ = writeln!(&mut config, "\t\t<Account>");
|
let _ = writeln!(&mut config, "\t\t<Account>");
|
||||||
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
||||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||||
|
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||||
|
let legacy_off = match emailaddress.rsplit_once('@') {
|
||||||
|
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?,
|
||||||
|
None => self.legacy_protocols_off_for("").await?,
|
||||||
|
};
|
||||||
for (protocol, service) in &self.core.network.info.services {
|
for (protocol, service) in &self.core.network.info.services {
|
||||||
|
if legacy_off && is_legacy_service(protocol) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let (protocol, ports) = match protocol {
|
let (protocol, ports) = match protocol {
|
||||||
ServiceProtocol::Imap => ("IMAP", [143, 993]),
|
ServiceProtocol::Imap => ("IMAP", [143, 993]),
|
||||||
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
|
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
* 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::application::Resource};
|
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||||
use registry::schema::enums::ServiceProtocol;
|
use registry::schema::enums::ServiceProtocol;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use utils::url_params::UrlParams;
|
use utils::url_params::UrlParams;
|
||||||
@@ -28,6 +30,9 @@ impl Server {
|
|||||||
("%EMAILADDRESS%", default_host.as_str())
|
("%EMAILADDRESS%", default_host.as_str())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||||
|
let legacy_off = self.legacy_protocols_off_for(domain).await?;
|
||||||
|
|
||||||
// Build XML response
|
// Build XML response
|
||||||
let mut config = String::with_capacity(1024);
|
let mut config = String::with_capacity(1024);
|
||||||
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||||
@@ -40,6 +45,9 @@ impl Server {
|
|||||||
"\t\t<displayShortName>{domain}</displayShortName>"
|
"\t\t<displayShortName>{domain}</displayShortName>"
|
||||||
);
|
);
|
||||||
for (protocol, service) in &self.core.network.info.services {
|
for (protocol, service) in &self.core.network.info.services {
|
||||||
|
if legacy_off && is_legacy_service(protocol) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let (protocol, tag, ports) = match protocol {
|
let (protocol, tag, ports) = match protocol {
|
||||||
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
|
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
|
||||||
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
|
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
|
use crate::{
|
||||||
|
Server,
|
||||||
|
config::network::Pacc,
|
||||||
|
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
|
||||||
|
};
|
||||||
use ahash::{AHashMap, AHashSet};
|
use ahash::{AHashMap, AHashSet};
|
||||||
use base64::{Engine, engine::general_purpose};
|
use base64::{Engine, engine::general_purpose};
|
||||||
use dns_update::{
|
use dns_update::{
|
||||||
@@ -34,6 +40,8 @@ impl Server {
|
|||||||
let network = &self.core.network;
|
let network = &self.core.network;
|
||||||
let default_host = network.server_name.as_str();
|
let default_host = network.server_name.as_str();
|
||||||
let domain_name = domain.name.as_str();
|
let domain_name = domain.name.as_str();
|
||||||
|
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||||
|
let legacy_off = self.legacy_protocols_off_for(domain_name).await?;
|
||||||
let domain_name_suffix = format!(".{domain_name}");
|
let domain_name_suffix = format!(".{domain_name}");
|
||||||
|
|
||||||
for record_type in record_types {
|
for record_type in record_types {
|
||||||
@@ -193,6 +201,25 @@ impl Server {
|
|||||||
ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)],
|
ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// inbuxa: legacy-protocols LP-7. While they are off, every
|
||||||
|
// name says "not offered" -- target "." (RFC 6186 section
|
||||||
|
// 3.4) -- rather than vanishing, so a client that looks
|
||||||
|
// is told, and an old record left in the zone is replaced.
|
||||||
|
if legacy_off && is_legacy_service(protocol) {
|
||||||
|
for (service_name, _) in services {
|
||||||
|
records.push(NamedDnsRecord {
|
||||||
|
name: format!("_{service_name}._tcp.{domain_name}."),
|
||||||
|
record: DnsRecord::SRV(SRVRecord {
|
||||||
|
target: ".".to_string(),
|
||||||
|
priority: 0,
|
||||||
|
weight: 0,
|
||||||
|
port: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (is_tls, (service_name, port)) in services.into_iter().enumerate() {
|
for (is_tls, (service_name, port)) in services.into_iter().enumerate() {
|
||||||
if is_tls == 1 || service.cleartext {
|
if is_tls == 1 || service.cleartext {
|
||||||
records.push(NamedDnsRecord {
|
records.push(NamedDnsRecord {
|
||||||
@@ -277,6 +304,14 @@ impl Server {
|
|||||||
for (protocol, service) in &network.info.services {
|
for (protocol, service) in &network.info.services {
|
||||||
let hostname = service.hostname.as_deref().unwrap_or(default_host);
|
let hostname = service.hostname.as_deref().unwrap_or(default_host);
|
||||||
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name {
|
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name {
|
||||||
|
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
|
||||||
|
// the switch has closed. Submission's port stays open
|
||||||
|
// (the SMTP lock), so its record stays.
|
||||||
|
if legacy_off
|
||||||
|
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let port = match protocol {
|
let port = match protocol {
|
||||||
ServiceProtocol::Imap => 993,
|
ServiceProtocol::Imap => 993,
|
||||||
ServiceProtocol::Pop3 => 995,
|
ServiceProtocol::Pop3 => 995,
|
||||||
@@ -382,6 +417,12 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
||||||
|
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||||
|
let pacc = if self.legacy_protocols_off_for(domain_name).await? {
|
||||||
|
&self.core.network.info.pacc_jmap_only
|
||||||
|
} else {
|
||||||
|
&self.core.network.info.pacc
|
||||||
|
};
|
||||||
self.get_directory_for_domain(domain_name)
|
self.get_directory_for_domain(domain_name)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
@@ -390,15 +431,9 @@ impl Server {
|
|||||||
.and_then(|directory| {
|
.and_then(|directory| {
|
||||||
directory
|
directory
|
||||||
.oidc_discovery_document()
|
.oidc_discovery_document()
|
||||||
.map(|doc| self.core.network.info.pacc.build(&doc.url))
|
.map(|doc| pacc.build(&doc.url))
|
||||||
})
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
self.core
|
|
||||||
.network
|
|
||||||
.info
|
|
||||||
.pacc
|
|
||||||
.build(&self.core.network.http.url_https)
|
|
||||||
})
|
})
|
||||||
|
.unwrap_or_else(|| pacc.build(&self.core.network.http.url_https))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -21,16 +21,24 @@
|
|||||||
//! a legacy protocol is refused before any password is looked at, so a
|
//! a legacy protocol is refused before any password is looked at, so a
|
||||||
//! listener that exists by mistake still lets nobody in.
|
//! listener that exists by mistake still lets nobody in.
|
||||||
//!
|
//!
|
||||||
|
//! And nothing advertises what is closed (LP-7): client configuration and
|
||||||
|
//! the suggested DNS records leave the legacy services out, or mark them as
|
||||||
|
//! not offered, while the switch is off -- the server's, or for a tenant's
|
||||||
|
//! domains, the tenant's (LP-14a).
|
||||||
|
//!
|
||||||
//! Nothing here touches the host's firewall, NAT port-forwards or any proxy
|
//! Nothing here touches the host's firewall, NAT port-forwards or any proxy
|
||||||
//! (LP-20). The server stops answering; what still routes the port is the
|
//! (LP-20). The server stops answering; what still routes the port is the
|
||||||
//! operator's to reconcile.
|
//! operator's to reconcile.
|
||||||
|
|
||||||
use crate::{Server, config::server::Listeners, network::TcpAcceptor};
|
use crate::{Server, auth::AccessToken, config::server::Listeners, network::TcpAcceptor};
|
||||||
use directory::Credentials;
|
use directory::Credentials;
|
||||||
use inbuxa_features::security::{
|
use inbuxa_features::security::{
|
||||||
|
legacy_use::{self, LegacyUse},
|
||||||
listeners,
|
listeners,
|
||||||
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
||||||
|
tenant_protocol_policy,
|
||||||
};
|
};
|
||||||
|
use registry::schema::enums::ServiceProtocol;
|
||||||
use registry::types::{error::Error, id::ObjectId};
|
use registry::types::{error::Error, id::ObjectId};
|
||||||
use store::registry::bootstrap::Bootstrap;
|
use store::registry::bootstrap::Bootstrap;
|
||||||
|
|
||||||
@@ -102,6 +110,37 @@ impl Server {
|
|||||||
|
|
||||||
protocol_policy::set(&self.core.storage.data, &policy).await?;
|
protocol_policy::set(&self.core.storage.data, &policy).await?;
|
||||||
|
|
||||||
|
// LP-8. Raised here rather than by the JMAP method, so whatever turns
|
||||||
|
// the switch is reported. A /set that changed nothing -- the switch
|
||||||
|
// already where it was asked to be, nothing to close or reopen -- is
|
||||||
|
// not a change.
|
||||||
|
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() {
|
||||||
|
let (moved, direction) = if policy.legacy_protocols.is_disabled() {
|
||||||
|
(&change.closed, "closed")
|
||||||
|
} else {
|
||||||
|
(&change.reopened, "reopened")
|
||||||
|
};
|
||||||
|
trc::event!(
|
||||||
|
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||||
|
Policy = "server",
|
||||||
|
Value = if policy.legacy_protocols.is_disabled() {
|
||||||
|
"disabled"
|
||||||
|
} else {
|
||||||
|
"enabled"
|
||||||
|
},
|
||||||
|
AccountId = policy.changed_by.clone(),
|
||||||
|
Details = direction,
|
||||||
|
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
|
||||||
|
// Only when a listener could not be put back (LP-5).
|
||||||
|
Reason = (!change.failed.is_empty()).then(|| listener_names(
|
||||||
|
change
|
||||||
|
.failed
|
||||||
|
.iter()
|
||||||
|
.map(|(l, why)| format!("{}: {why}", l.id))
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(change)
|
Ok(change)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +254,12 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Names for an event field: the listeners a change closed, reopened or
|
||||||
|
/// failed to reopen (LP-8).
|
||||||
|
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
|
||||||
|
trc::Value::Array(names.map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// A protocol a mail app signs in over, which the switch refuses (LP-6).
|
/// A protocol a mail app signs in over, which the switch refuses (LP-6).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum LegacyProtocol {
|
pub enum LegacyProtocol {
|
||||||
@@ -236,39 +281,92 @@ impl LegacyProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the mail app is told, at server scope (LP-12, LP-6). Each
|
/// The same protocol, as the impact panel's record names it (LP-15).
|
||||||
/// protocol's own framing — IMAP's `[ALERT]`, ManageSieve's quoting —
|
pub fn as_use(&self) -> LegacyUse {
|
||||||
/// is added by its session; POP3 carries `[AUTH]` in the text, since its
|
|
||||||
/// errors have no separate code, and SMTP is the whole reply line.
|
|
||||||
pub fn refusal(&self) -> &'static str {
|
|
||||||
match self {
|
match self {
|
||||||
LegacyProtocol::Imap => {
|
LegacyProtocol::Imap => LegacyUse::Imap,
|
||||||
|
LegacyProtocol::Pop3 => LegacyUse::Pop3,
|
||||||
|
LegacyProtocol::ManageSieve => LegacyUse::ManageSieve,
|
||||||
|
LegacyProtocol::Submission => LegacyUse::Submission,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the mail app is told (LP-12). Each protocol's own framing --
|
||||||
|
/// IMAP's `[ALERT]`, ManageSieve's quoting -- is added by its session;
|
||||||
|
/// POP3 carries `[AUTH]` in the text, since its errors have no separate
|
||||||
|
/// code, and SMTP is the whole reply line. At server scope "Your
|
||||||
|
/// organization" reads "This server" (LP-6).
|
||||||
|
pub fn refusal(&self, scope: RefusalScope) -> &'static str {
|
||||||
|
match (scope, self) {
|
||||||
|
(RefusalScope::Server, LegacyProtocol::Imap) => {
|
||||||
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||||
}
|
}
|
||||||
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."
|
||||||
}
|
}
|
||||||
LegacyProtocol::ManageSieve => "This server allows only INBUXA webmail and JMAP apps.",
|
(RefusalScope::Server, LegacyProtocol::ManageSieve) => {
|
||||||
LegacyProtocol::Submission => {
|
"This server allows only INBUXA webmail and JMAP apps."
|
||||||
|
}
|
||||||
|
(RefusalScope::Server, LegacyProtocol::Submission) => {
|
||||||
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
||||||
}
|
}
|
||||||
|
(RefusalScope::Tenant(_), LegacyProtocol::Imap) => {
|
||||||
|
"Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||||
|
}
|
||||||
|
(RefusalScope::Tenant(_), LegacyProtocol::Pop3) => {
|
||||||
|
"[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||||
|
}
|
||||||
|
(RefusalScope::Tenant(_), LegacyProtocol::ManageSieve) => {
|
||||||
|
"Your organization allows only INBUXA webmail and JMAP apps."
|
||||||
|
}
|
||||||
|
(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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The refusal as an error: `auth.legacy-protocol-refused`, not
|
/// The refusal as an error: `auth.legacy-protocol-refused`, not
|
||||||
/// `auth.failed`, so it never counts against the account or feeds the
|
/// `auth.failed`, so it never counts against the account or feeds the
|
||||||
/// auto-ban (LP-11). It names the protocol and the domain, never the
|
/// auto-ban (LP-11). It names the protocol, the scope and the domain,
|
||||||
/// account; the session it is raised in adds the remote IP.
|
/// never the account; the session adds the remote IP.
|
||||||
pub fn refused(&self, credentials: &Credentials) -> trc::Error {
|
///
|
||||||
|
/// Not the tenant's id: `Id` is what IMAP answers a command's tag from,
|
||||||
|
/// so an error carrying one is sent under the wrong tag and the mail app
|
||||||
|
/// waits for a reply that never comes. The domain names the tenant.
|
||||||
|
pub fn refused(&self, scope: RefusalScope, domain: Option<String>) -> trc::Error {
|
||||||
trc::AuthEvent::LegacyProtocolRefused
|
trc::AuthEvent::LegacyProtocolRefused
|
||||||
.into_err()
|
.into_err()
|
||||||
.details(self.refusal())
|
.details(self.refusal(scope))
|
||||||
.ctx(trc::Key::Source, self.as_str())
|
.ctx(trc::Key::Source, self.as_str())
|
||||||
.ctx(trc::Key::Policy, "server")
|
.ctx(
|
||||||
.ctx_opt(trc::Key::Domain, domain_of(credentials))
|
trc::Key::Policy,
|
||||||
|
match scope {
|
||||||
|
RefusalScope::Server => "server",
|
||||||
|
RefusalScope::Tenant(_) => "tenant",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.ctx_opt(trc::Key::Domain, domain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One account's last sign-in over one legacy protocol, as the impact panel
|
||||||
|
/// shows it (LP-15).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RecentUse {
|
||||||
|
pub account_id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub protocol: &'static str,
|
||||||
|
/// Seconds since the epoch.
|
||||||
|
pub at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whose switch refused a sign-in: the server's (LP-6) or a tenant's (LP-10).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RefusalScope {
|
||||||
|
Server,
|
||||||
|
Tenant(u32),
|
||||||
|
}
|
||||||
|
|
||||||
/// The domain a sign-in is for, from the name it gives, if it gives one.
|
/// The domain a sign-in is for, from the name it gives, if it gives one.
|
||||||
fn domain_of(credentials: &Credentials) -> Option<String> {
|
fn domain_of(credentials: &Credentials) -> Option<String> {
|
||||||
let username = match credentials {
|
let username = match credentials {
|
||||||
@@ -283,21 +381,144 @@ fn domain_of(credentials: &Credentials) -> Option<String> {
|
|||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
/// Refuses a sign-in over a legacy protocol while the server-wide switch
|
/// Refuses a sign-in over a legacy protocol while the server-wide switch
|
||||||
/// is off (LP-6). Called before the credentials are checked, so the
|
/// is off (LP-6), or while the switch of the tenant that owns the named
|
||||||
/// answer is the same for a right password, a wrong one and an account
|
/// domain is (LP-10). Called before the credentials are checked, so the
|
||||||
/// that doesn't exist (LP-11).
|
/// answer is the same for a right password, a wrong one and an address
|
||||||
|
/// that doesn't exist (LP-11): a tenant's domain answers for every address
|
||||||
|
/// on it.
|
||||||
///
|
///
|
||||||
/// Read from the store on each sign-in rather than cached, so every node
|
/// Read from the store on each sign-in rather than cached, so every node
|
||||||
/// of a cluster answers the same the moment the switch turns.
|
/// of a cluster answers the same the moment a switch turns.
|
||||||
pub async fn refuse_legacy_sign_in(
|
pub async fn refuse_legacy_sign_in(
|
||||||
&self,
|
&self,
|
||||||
protocol: LegacyProtocol,
|
protocol: LegacyProtocol,
|
||||||
credentials: &Credentials,
|
credentials: &Credentials,
|
||||||
) -> trc::Result<()> {
|
) -> trc::Result<()> {
|
||||||
|
let domain = domain_of(credentials);
|
||||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||||
Err(protocol.refused(credentials))
|
return Err(protocol.refused(RefusalScope::Server, domain));
|
||||||
} else {
|
}
|
||||||
Ok(())
|
if let Some(name) = &domain
|
||||||
|
&& let Some(domain) = self.domain(name).await?
|
||||||
|
&& let Some(tenant_id) = domain.id_tenant
|
||||||
|
&& self.tenant_legacy_protocols_off(tenant_id).await?
|
||||||
|
{
|
||||||
|
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Once the account is known: refuses it if its tenant has legacy
|
||||||
|
/// protocols off, and otherwise records the sign-in for the impact panel.
|
||||||
|
///
|
||||||
|
/// The refusal is LP-10 again for a bearer token, which needn't name an
|
||||||
|
/// account and so can't be judged by its domain beforehand; for a
|
||||||
|
/// password sign-in it has already been decided. The record is LP-15's:
|
||||||
|
/// one timestamp per account and protocol, at most hourly. A record that
|
||||||
|
/// can't be written is logged and the sign-in goes ahead -- a panel is
|
||||||
|
/// not worth locking anyone out over.
|
||||||
|
pub async fn admit_legacy_session(
|
||||||
|
&self,
|
||||||
|
protocol: LegacyProtocol,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
if let Some(tenant_id) = access_token.tenant_id()
|
||||||
|
&& self.tenant_legacy_protocols_off(tenant_id).await?
|
||||||
|
{
|
||||||
|
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
|
||||||
|
}
|
||||||
|
if let Err(err) = legacy_use::record(
|
||||||
|
&self.core.storage.data,
|
||||||
|
access_token.account_id(),
|
||||||
|
protocol.as_use(),
|
||||||
|
store::write::now(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
trc::error!(err.details("Failed to record a legacy sign-in (LP-15)."));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who signed in over a legacy protocol in the last 30 days, most recent
|
||||||
|
/// first, for the impact panel (LP-15): everyone at server scope, or one
|
||||||
|
/// tenant's accounts. Accounts that no longer exist are left out.
|
||||||
|
pub async fn recent_legacy_use(&self, tenant_id: Option<u32>) -> trc::Result<Vec<RecentUse>> {
|
||||||
|
let mut recent = Vec::new();
|
||||||
|
for entry in legacy_use::recent(&self.core.storage.data, store::write::now()).await? {
|
||||||
|
let Some(account) = self.try_account(entry.account_id).await? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if tenant_id.is_some() && account.id_tenant != tenant_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
recent.push(RecentUse {
|
||||||
|
account_id: entry.account_id,
|
||||||
|
name: account.name.to_string(),
|
||||||
|
protocol: entry.protocol.as_str(),
|
||||||
|
at: entry.at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
recent.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.name.cmp(&b.name)));
|
||||||
|
Ok(recent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether legacy protocols are off for this account: the stricter of the
|
||||||
|
/// server's switch and its tenant's. What the JMAP session tells the
|
||||||
|
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can
|
||||||
|
/// say why a mail app won't connect (LP-19).
|
||||||
|
pub async fn legacy_protocols_off_for_account(
|
||||||
|
&self,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
) -> trc::Result<bool> {
|
||||||
|
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
match access_token.tenant_id() {
|
||||||
|
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a tenant has turned legacy protocols off for itself (LP-10).
|
||||||
|
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> {
|
||||||
|
Ok(
|
||||||
|
tenant_protocol_policy::get(&self.core.storage.data, tenant_id)
|
||||||
|
.await?
|
||||||
|
.legacy_protocols
|
||||||
|
.is_disabled(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The services mail apps sign in to, which the switch turns off: nothing may
|
||||||
|
/// offer them while it is (LP-7). SMTP here is submission -- mail apps
|
||||||
|
/// sending -- since inbound mail is never a configured service.
|
||||||
|
pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
|
||||||
|
matches!(
|
||||||
|
protocol,
|
||||||
|
ServiceProtocol::Imap
|
||||||
|
| ServiceProtocol::Pop3
|
||||||
|
| ServiceProtocol::Smtp
|
||||||
|
| ServiceProtocol::Managesieve
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// Whether legacy services are off for this domain, for the answers that
|
||||||
|
/// must stop offering them: off for the whole server (LP-7), or for the
|
||||||
|
/// tenant the domain belongs to (LP-14a). Read per answer, as sign-in
|
||||||
|
/// reads it. A name that is no domain here answers for the server alone.
|
||||||
|
pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result<bool> {
|
||||||
|
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
match self.domain(domain_name).await? {
|
||||||
|
Some(domain) => match domain.id_tenant {
|
||||||
|
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
||||||
|
None => Ok(false),
|
||||||
|
},
|
||||||
|
None => Ok(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,28 +538,57 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn refusals_read_as_the_spec_writes_them() {
|
fn refusals_read_as_the_spec_writes_them() {
|
||||||
// LP-12, with "Your organization" read as "This server" (LP-6).
|
// LP-12, with "Your organization" read as "This server" (LP-6).
|
||||||
|
let server = RefusalScope::Server;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
LegacyProtocol::Imap.refusal(),
|
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
|
||||||
.refusal()
|
.refusal(server)
|
||||||
.starts_with("[AUTH] This server allows")
|
.starts_with("[AUTH] This server allows")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
LegacyProtocol::ManageSieve.refusal(),
|
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(),
|
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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tenant_refusal_speaks_for_the_organization() {
|
||||||
|
// LP-12, exactly as the spec writes them.
|
||||||
|
let tenant = RefusalScope::Tenant(7);
|
||||||
|
assert_eq!(
|
||||||
|
LegacyProtocol::Imap.refusal(tenant),
|
||||||
|
"Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
LegacyProtocol::Pop3.refusal(tenant),
|
||||||
|
"[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
LegacyProtocol::ManageSieve.refusal(tenant),
|
||||||
|
"Your organization allows only INBUXA webmail and JMAP apps."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
LegacyProtocol::Submission.refusal(tenant),
|
||||||
|
"535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
||||||
|
);
|
||||||
|
let err = LegacyProtocol::Imap.refused(tenant, Some("example.org".into()));
|
||||||
|
assert_eq!(err.value_as_str(trc::Key::Policy), Some("tenant"));
|
||||||
|
// IMAP answers the command's tag from Id; the refusal must leave it be.
|
||||||
|
assert!(err.value(trc::Key::Id).is_none());
|
||||||
|
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_refusal_is_not_a_failed_sign_in() {
|
fn a_refusal_is_not_a_failed_sign_in() {
|
||||||
let err = LegacyProtocol::Imap.refused(&basic("[email protected]"));
|
let err = LegacyProtocol::Imap
|
||||||
|
.refused(RefusalScope::Server, domain_of(&basic("[email protected]")));
|
||||||
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
|
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
|
||||||
assert!(!err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)));
|
assert!(!err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)));
|
||||||
// The session stays open: the mail app is told, not thrown off.
|
// The session stays open: the mail app is told, not thrown off.
|
||||||
@@ -349,6 +599,26 @@ mod tests {
|
|||||||
assert_eq!(err.value_as_str(trc::Key::AccountName), None);
|
assert_eq!(err.value_as_str(trc::Key::AccountName), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_services_mail_apps_sign_in_to_are_legacy() {
|
||||||
|
for protocol in [
|
||||||
|
ServiceProtocol::Imap,
|
||||||
|
ServiceProtocol::Pop3,
|
||||||
|
ServiceProtocol::Smtp,
|
||||||
|
ServiceProtocol::Managesieve,
|
||||||
|
] {
|
||||||
|
assert!(is_legacy_service(&protocol), "{protocol:?}");
|
||||||
|
}
|
||||||
|
for protocol in [
|
||||||
|
ServiceProtocol::Jmap,
|
||||||
|
ServiceProtocol::Caldav,
|
||||||
|
ServiceProtocol::Carddav,
|
||||||
|
ServiceProtocol::Webdav,
|
||||||
|
] {
|
||||||
|
assert!(!is_legacy_service(&protocol), "{protocol:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_domain_comes_from_the_name_given() {
|
fn the_domain_comes_from_the_name_given() {
|
||||||
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
|
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "directory"
|
name = "directory"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,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));
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! When each account last signed in over each legacy protocol, for the
|
||||||
|
//! impact panel (legacy-protocols spec, LP-15, "Last use per protocol").
|
||||||
|
//!
|
||||||
|
//! One timestamp per account per protocol, and nothing else: no address, no
|
||||||
|
//! IP, no client. It is written at most once an hour per account and
|
||||||
|
//! protocol, so a mail app polling every minute costs one read per sign-in
|
||||||
|
//! and one write an hour. Stored under `P` `u`, the account id and a protocol
|
||||||
|
//! byte, in the fork's subspace.
|
||||||
|
|
||||||
|
use store::{
|
||||||
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
/// How long a recorded use stands before the next sign-in rewrites it.
|
||||||
|
pub const WRITE_EVERY_SECS: u64 = 3600;
|
||||||
|
|
||||||
|
/// How far back the impact panel looks (LP-15).
|
||||||
|
pub const RECENT_SECS: u64 = 30 * 24 * 3600;
|
||||||
|
|
||||||
|
/// The protocols the panel names, as they are spelled over JMAP.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum LegacyUse {
|
||||||
|
Imap,
|
||||||
|
Pop3,
|
||||||
|
ManageSieve,
|
||||||
|
Submission,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LegacyUse {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LegacyUse::Imap => "imap",
|
||||||
|
LegacyUse::Pop3 => "pop3",
|
||||||
|
LegacyUse::ManageSieve => "manageSieve",
|
||||||
|
LegacyUse::Submission => "submission",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
LegacyUse::Imap => b'i',
|
||||||
|
LegacyUse::Pop3 => b'p',
|
||||||
|
LegacyUse::ManageSieve => b's',
|
||||||
|
LegacyUse::Submission => b'm',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_byte(byte: u8) -> Option<Self> {
|
||||||
|
match byte {
|
||||||
|
b'i' => Some(LegacyUse::Imap),
|
||||||
|
b'p' => Some(LegacyUse::Pop3),
|
||||||
|
b's' => Some(LegacyUse::ManageSieve),
|
||||||
|
b'm' => Some(LegacyUse::Submission),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One account's last use of one protocol.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Use {
|
||||||
|
pub account_id: u32,
|
||||||
|
pub protocol: LegacyUse,
|
||||||
|
/// Seconds since the epoch.
|
||||||
|
pub at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(account_id: u32, protocol: Option<LegacyUse>) -> ValueKey<ValueClass> {
|
||||||
|
let mut key = Vec::with_capacity(7);
|
||||||
|
key.extend_from_slice(b"Pu");
|
||||||
|
key.extend_from_slice(&account_id.to_be_bytes());
|
||||||
|
key.push(protocol.map_or(0, |p| p.byte()));
|
||||||
|
ValueKey::from(ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a stored key back into who and what, if it is one of ours.
|
||||||
|
fn parse_key(key: &[u8]) -> Option<(u32, LegacyUse)> {
|
||||||
|
// The iterator may or may not hand back the subspace byte; the tail is
|
||||||
|
// what identifies an entry: two bytes of prefix, four of account id and
|
||||||
|
// one of protocol.
|
||||||
|
let tail = key.get(key.len().checked_sub(7)?..)?;
|
||||||
|
(tail[..2] == *b"Pu").then_some(())?;
|
||||||
|
let account_id = u32::from_be_bytes(tail[2..6].try_into().ok()?);
|
||||||
|
Some((account_id, LegacyUse::from_byte(tail[6])?))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct At(u64);
|
||||||
|
|
||||||
|
impl Deserialize for At {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
bytes
|
||||||
|
.try_into()
|
||||||
|
.map(|bytes| At(u64::from_be_bytes(bytes)))
|
||||||
|
.map_err(|_| trc::StoreEvent::DataCorruption.caused_by(trc::location!()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a use at `at` is recent enough for the panel at `now` (LP-15).
|
||||||
|
pub fn is_recent(at: u64, now: u64) -> bool {
|
||||||
|
at >= now.saturating_sub(RECENT_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a use at `now` should be written over one stored at `stored`.
|
||||||
|
fn due(stored: Option<u64>, now: u64) -> bool {
|
||||||
|
stored.is_none_or(|stored| now.saturating_sub(stored) >= WRITE_EVERY_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a successful sign-in, unless one was recorded within the hour.
|
||||||
|
pub async fn record(
|
||||||
|
data: &Store,
|
||||||
|
account_id: u32,
|
||||||
|
protocol: LegacyUse,
|
||||||
|
now: u64,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
let stored = data
|
||||||
|
.get_value::<At>(key(account_id, Some(protocol)))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|At(at)| at);
|
||||||
|
if !due(stored, now) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(
|
||||||
|
key(account_id, Some(protocol)).class,
|
||||||
|
now.to_be_bytes().to_vec(),
|
||||||
|
);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every use recent at `now` (LP-15), across all accounts.
|
||||||
|
pub async fn recent(data: &Store, now: u64) -> trc::Result<Vec<Use>> {
|
||||||
|
let mut uses = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(key(0, None), key(u32::MAX, Some(LegacyUse::Submission))).ascending(),
|
||||||
|
|key, value| {
|
||||||
|
if let Some((account_id, protocol)) = parse_key(key)
|
||||||
|
&& let Ok(At(at)) = At::deserialize(value)
|
||||||
|
&& is_recent(at, now)
|
||||||
|
{
|
||||||
|
uses.push(Use {
|
||||||
|
account_id,
|
||||||
|
protocol,
|
||||||
|
at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(uses)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn written_at_most_once_an_hour() {
|
||||||
|
assert!(due(None, 100));
|
||||||
|
assert!(!due(Some(100), 100 + WRITE_EVERY_SECS - 1));
|
||||||
|
assert!(due(Some(100), 100 + WRITE_EVERY_SECS));
|
||||||
|
// A clock that went backwards doesn't write.
|
||||||
|
assert!(!due(Some(100), 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_panel_looks_back_thirty_days() {
|
||||||
|
// Acceptance test 11: three days ago is listed, forty days ago isn't.
|
||||||
|
let now = 1_800_000_000;
|
||||||
|
let day = 24 * 3600;
|
||||||
|
assert!(is_recent(now - 3 * day, now));
|
||||||
|
assert!(is_recent(now - 30 * day, now));
|
||||||
|
assert!(!is_recent(now - 30 * day - 1, now));
|
||||||
|
assert!(!is_recent(now - 40 * day, now));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_read_back() {
|
||||||
|
for protocol in [
|
||||||
|
LegacyUse::Imap,
|
||||||
|
LegacyUse::Pop3,
|
||||||
|
LegacyUse::ManageSieve,
|
||||||
|
LegacyUse::Submission,
|
||||||
|
] {
|
||||||
|
let ValueClass::Any(any) = key(42, Some(protocol)).class else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(parse_key(&any.key), Some((42, protocol)));
|
||||||
|
// With the subspace byte in front, too.
|
||||||
|
let mut with_subspace = vec![SUBSPACE_INBUXA];
|
||||||
|
with_subspace.extend_from_slice(&any.key);
|
||||||
|
assert_eq!(parse_key(&with_subspace), Some((42, protocol)));
|
||||||
|
}
|
||||||
|
assert_eq!(parse_key(b"Pp"), None);
|
||||||
|
assert_eq!(parse_key(b"Xx\0\0\0\x2ai"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_protocols_are_recorded() {
|
||||||
|
// Nothing but the four legacy protocols has a byte of its own.
|
||||||
|
assert_eq!(LegacyUse::from_byte(0), None);
|
||||||
|
assert_eq!(LegacyUse::from_byte(b'x'), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,5 +10,7 @@
|
|||||||
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
||||||
//! `legacy-protocols.md`.
|
//! `legacy-protocols.md`.
|
||||||
|
|
||||||
|
pub mod legacy_use;
|
||||||
pub mod listeners;
|
pub mod listeners;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
|
pub mod tenant_protocol_policy;
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:TenantProtocolPolicy`, one tenant's legacy mail protocols switch
|
||||||
|
//! (legacy-protocols spec, LP-9 to LP-14a). Stored as JSON under `P` `t` and
|
||||||
|
//! the tenant id in the fork's subspace; a tenant with nothing stored has
|
||||||
|
//! legacy protocols on.
|
||||||
|
//!
|
||||||
|
//! A tenant's switch closes no port -- other tenants share them (LP-13). It
|
||||||
|
//! refuses sign-in on the tenant's domains, and keeps client configuration
|
||||||
|
//! for them from offering what's refused. That is all it is: one fact per
|
||||||
|
//! tenant, easy to turn back, touching no listener, role or permission.
|
||||||
|
|
||||||
|
use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy};
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use store::{
|
||||||
|
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
/// One tenant's switch.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct TenantProtocolPolicy {
|
||||||
|
/// The switch itself.
|
||||||
|
pub legacy_protocols: LegacyProtocols,
|
||||||
|
/// When it last changed, in milliseconds since the epoch.
|
||||||
|
pub changed_at: Option<u64>,
|
||||||
|
/// The account that last changed it.
|
||||||
|
pub changed_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a tenant's switch can't be set this way, if it can't (LP-9).
|
||||||
|
///
|
||||||
|
/// A tenant can always turn legacy protocols off for itself. It can turn
|
||||||
|
/// them back on only while the server has them on: server off means off for
|
||||||
|
/// everyone.
|
||||||
|
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> {
|
||||||
|
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some(
|
||||||
|
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \
|
||||||
|
so they can't be turned back on for one organization.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(tenant_id: u32) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(6);
|
||||||
|
key.extend_from_slice(b"Pt");
|
||||||
|
key.extend_from_slice(&tenant_id.to_be_bytes());
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Json(TenantProtocolPolicy);
|
||||||
|
|
||||||
|
impl Deserialize for Json {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tenant's policy, or the default (on) when it has never been set.
|
||||||
|
pub async fn get(data: &Store, tenant_id: u32) -> trc::Result<TenantProtocolPolicy> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json>(ValueKey::from(key(tenant_id)))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(policy)| policy)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the tenant's policy.
|
||||||
|
pub async fn set(data: &Store, tenant_id: u32, policy: &TenantProtocolPolicy) -> trc::Result<()> {
|
||||||
|
let bytes = serde_json::to_vec(policy).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.reason(err)
|
||||||
|
})?;
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(key(tenant_id), bytes);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets a tenant's switch, when the tenant is deleted. Otherwise a tenant
|
||||||
|
/// that came to have the same id would start with the old one's switch.
|
||||||
|
pub async fn remove(data: &Store, tenant_id: u32) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.clear(key(tenant_id));
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn server(legacy_protocols: LegacyProtocols) -> ProtocolPolicy {
|
||||||
|
ProtocolPolicy {
|
||||||
|
legacy_protocols,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tenant_starts_with_legacy_protocols_on() {
|
||||||
|
assert!(
|
||||||
|
!TenantProtocolPolicy::default()
|
||||||
|
.legacy_protocols
|
||||||
|
.is_disabled()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tenant_can_always_turn_them_off() {
|
||||||
|
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
|
||||||
|
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
|
||||||
|
// LP-9, acceptance test 9.
|
||||||
|
assert_eq!(
|
||||||
|
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
let why =
|
||||||
|
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
|
||||||
|
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_are_per_tenant_and_clear_of_the_server_policy() {
|
||||||
|
let ValueClass::Any(a) = key(1) else { panic!() };
|
||||||
|
let ValueClass::Any(b) = key(2) else { panic!() };
|
||||||
|
assert_ne!(a.key, b.key);
|
||||||
|
assert_eq!(&a.key[..2], b"Pt");
|
||||||
|
assert_ne!(a.key, b"Pp".to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stored_json_reads_back() {
|
||||||
|
let policy = TenantProtocolPolicy {
|
||||||
|
legacy_protocols: LegacyProtocols::Disabled,
|
||||||
|
changed_at: Some(1),
|
||||||
|
changed_by: Some("b".into()),
|
||||||
|
};
|
||||||
|
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
|
||||||
|
assert_eq!(back, policy);
|
||||||
|
// Unknown and missing fields read as defaults.
|
||||||
|
let Json(back) = Json::deserialize(br#"{"futureField":1}"#).unwrap();
|
||||||
|
assert_eq!(back, TenantProtocolPolicy::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use common::manager::application::Resource;
|
use common::manager::application::Resource;
|
||||||
|
|||||||
@@ -1,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]
|
||||||
|
|||||||
@@ -100,6 +100,13 @@ impl<T: SessionStream> Session<T> {
|
|||||||
})
|
})
|
||||||
.and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?;
|
.and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?;
|
||||||
|
|
||||||
|
// inbuxa: legacy-protocols LP-10 for a bearer token that named no
|
||||||
|
// account, and LP-15: the sign-in is recorded for the impact panel
|
||||||
|
self.server
|
||||||
|
.admit_legacy_session(LegacyProtocol::Imap, &access_token)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?;
|
||||||
|
|
||||||
// Enforce concurrency limits
|
// Enforce concurrency limits
|
||||||
let in_flight = match access_token.is_imap_request_allowed() {
|
let in_flight = match access_token.is_imap_request_allowed() {
|
||||||
LimiterResult::Allowed(in_flight) => Some(in_flight),
|
LimiterResult::Allowed(in_flight) => Some(in_flight),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ pub enum ProtocolPolicyProperty {
|
|||||||
/// Server-set: exactly which listeners turning the switch would close,
|
/// Server-set: exactly which listeners turning the switch would close,
|
||||||
/// by name and port, for the confirmation (LP-16).
|
/// by name and port, for the confirmation (LP-16).
|
||||||
WouldClose,
|
WouldClose,
|
||||||
|
/// Server-set: who signed in over a legacy protocol in the last 30
|
||||||
|
/// days, and when, for the impact panel (LP-15).
|
||||||
|
RecentLegacyUse,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
@@ -57,6 +60,7 @@ impl Property for ProtocolPolicyProperty {
|
|||||||
ProtocolPolicyProperty::SavedListeners => "savedListeners",
|
ProtocolPolicyProperty::SavedListeners => "savedListeners",
|
||||||
ProtocolPolicyProperty::ChangedAt => "changedAt",
|
ProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||||
ProtocolPolicyProperty::ChangedBy => "changedBy",
|
ProtocolPolicyProperty::ChangedBy => "changedBy",
|
||||||
|
ProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
|
||||||
ProtocolPolicyProperty::LockedProtocols => "lockedProtocols",
|
ProtocolPolicyProperty::LockedProtocols => "lockedProtocols",
|
||||||
ProtocolPolicyProperty::WouldClose => "wouldClose",
|
ProtocolPolicyProperty::WouldClose => "wouldClose",
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,7 @@ impl ProtocolPolicyProperty {
|
|||||||
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
|
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
|
||||||
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
|
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
|
||||||
b"changedBy" => ProtocolPolicyProperty::ChangedBy,
|
b"changedBy" => ProtocolPolicyProperty::ChangedBy,
|
||||||
|
b"recentLegacyUse" => ProtocolPolicyProperty::RecentLegacyUse,
|
||||||
b"lockedProtocols" => ProtocolPolicyProperty::LockedProtocols,
|
b"lockedProtocols" => ProtocolPolicyProperty::LockedProtocols,
|
||||||
b"wouldClose" => ProtocolPolicyProperty::WouldClose,
|
b"wouldClose" => ProtocolPolicyProperty::WouldClose,
|
||||||
)
|
)
|
||||||
@@ -88,6 +93,7 @@ impl ProtocolPolicyProperty {
|
|||||||
ProtocolPolicyProperty::SavedListeners
|
ProtocolPolicyProperty::SavedListeners
|
||||||
| ProtocolPolicyProperty::ChangedAt
|
| ProtocolPolicyProperty::ChangedAt
|
||||||
| ProtocolPolicyProperty::ChangedBy
|
| ProtocolPolicyProperty::ChangedBy
|
||||||
|
| ProtocolPolicyProperty::RecentLegacyUse
|
||||||
| ProtocolPolicyProperty::LockedProtocols
|
| ProtocolPolicyProperty::LockedProtocols
|
||||||
| ProtocolPolicyProperty::WouldClose
|
| ProtocolPolicyProperty::WouldClose
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:TenantProtocolPolicy/get` and `/set` under `urn:inbuxa:jmap`: one
|
||||||
|
//! tenant's legacy mail protocols switch (legacy-protocols spec, LP-9 to
|
||||||
|
//! LP-14). One per tenant; its id is the tenant's id.
|
||||||
|
//!
|
||||||
|
//! `tenantId`, `changedAt` and `changedBy` are the server's to say. A client
|
||||||
|
//! that sets them is answered with `invalidProperties`.
|
||||||
|
|
||||||
|
use crate::object::{AnyId, JmapObject, JmapObjectId};
|
||||||
|
use jmap_tools::{Element, Key, Property};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct TenantProtocolPolicy;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum TenantProtocolPolicyProperty {
|
||||||
|
Id,
|
||||||
|
/// Server-set: the tenant this is the switch of.
|
||||||
|
TenantId,
|
||||||
|
/// The switch: `enabled` or `disabled`.
|
||||||
|
LegacyProtocols,
|
||||||
|
ChangedAt,
|
||||||
|
ChangedBy,
|
||||||
|
/// Server-set: who signed in over a legacy protocol in the last 30
|
||||||
|
/// days, and when, for the impact panel (LP-15).
|
||||||
|
RecentLegacyUse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum TenantProtocolPolicyValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for TenantProtocolPolicyProperty {
|
||||||
|
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
TenantProtocolPolicyProperty::parse(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
TenantProtocolPolicyProperty::Id => "id",
|
||||||
|
TenantProtocolPolicyProperty::TenantId => "tenantId",
|
||||||
|
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||||
|
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||||
|
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
|
||||||
|
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TenantProtocolPolicyProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => TenantProtocolPolicyProperty::Id,
|
||||||
|
b"tenantId" => TenantProtocolPolicyProperty::TenantId,
|
||||||
|
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
|
||||||
|
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
|
||||||
|
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
|
||||||
|
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TenantProtocolPolicyProperty {
|
||||||
|
/// Whether this property is the server's to say. A client that sets one
|
||||||
|
/// is answered with `invalidProperties`.
|
||||||
|
pub fn is_server_set(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
TenantProtocolPolicyProperty::TenantId
|
||||||
|
| TenantProtocolPolicyProperty::ChangedAt
|
||||||
|
| TenantProtocolPolicyProperty::ChangedBy
|
||||||
|
| TenantProtocolPolicyProperty::RecentLegacyUse
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for TenantProtocolPolicyProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
TenantProtocolPolicyProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for TenantProtocolPolicyValue {
|
||||||
|
type Property = TenantProtocolPolicyProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(TenantProtocolPolicyProperty::Id) => {
|
||||||
|
Id::from_str(value).ok().map(TenantProtocolPolicyValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
TenantProtocolPolicyValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for TenantProtocolPolicy {
|
||||||
|
type Property = TenantProtocolPolicyProperty;
|
||||||
|
|
||||||
|
type Element = TenantProtocolPolicyValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = TenantProtocolPolicyProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for TenantProtocolPolicyValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
TenantProtocolPolicyValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for TenantProtocolPolicyValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
TenantProtocolPolicyValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
TenantProtocolPolicyValue::Id(id) => Some(AnyId::Id(*id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||||
|
if let AnyId::Id(id) = new_id {
|
||||||
|
*self = TenantProtocolPolicyValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for TenantProtocolPolicyProperty {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ pub mod email_submission;
|
|||||||
pub mod fastmail_masked_email; // inbuxa: masked email
|
pub mod fastmail_masked_email; // inbuxa: masked email
|
||||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||||
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
||||||
|
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
||||||
pub mod inbuxa_deleted_account; // inbuxa: undelete
|
pub mod inbuxa_deleted_account; // inbuxa: undelete
|
||||||
pub mod file_node;
|
pub mod file_node;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::ProtocolPolicy(response) => {
|
GetResponseMethod::ProtocolPolicy(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::TenantProtocolPolicy(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::Principal(response) => {
|
GetResponseMethod::Principal(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
|
request.resolve_references(self)?
|
||||||
|
}
|
||||||
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
||||||
@@ -93,6 +96,9 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::ProtocolPolicy(request) => {
|
SetRequestMethod::ProtocolPolicy(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::AddressBook(request) => {
|
SetRequestMethod::AddressBook(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ pub struct InbuxaAccountCapabilities {
|
|||||||
/// The logo that applies to the principal (MT-22): a URL or a data URL.
|
/// The logo that applies to the principal (MT-22): a URL or a data URL.
|
||||||
#[serde(rename(serialize = "logo"))]
|
#[serde(rename(serialize = "logo"))]
|
||||||
pub logo: Option<String>,
|
pub logo: Option<String>,
|
||||||
|
/// Whether legacy mail protocols are `enabled` or `disabled` for the
|
||||||
|
/// principal: the stricter of the server's switch and its tenant's
|
||||||
|
/// (legacy-protocols spec, Interfaces; LP-19).
|
||||||
|
#[serde(rename(serialize = "legacyProtocols"))]
|
||||||
|
pub legacy_protocols: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub enum MethodObject {
|
|||||||
// inbuxa: AI call limits
|
// inbuxa: AI call limits
|
||||||
AiLimits,
|
AiLimits,
|
||||||
ProtocolPolicy,
|
ProtocolPolicy,
|
||||||
|
TenantProtocolPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MethodObject {
|
impl MethodObject {
|
||||||
@@ -77,6 +78,7 @@ impl MethodObject {
|
|||||||
MethodObject::DeletedAccount => Capability::Inbuxa,
|
MethodObject::DeletedAccount => Capability::Inbuxa,
|
||||||
MethodObject::AiLimits => Capability::Inbuxa,
|
MethodObject::AiLimits => Capability::Inbuxa,
|
||||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
|
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,6 +258,12 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
|
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
|
||||||
(MethodFunction::Get, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/get",
|
(MethodFunction::Get, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/get",
|
||||||
(MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set",
|
(MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set",
|
||||||
|
(MethodFunction::Get, MethodObject::TenantProtocolPolicy) => {
|
||||||
|
"inbuxa:TenantProtocolPolicy/get"
|
||||||
|
}
|
||||||
|
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
||||||
|
"inbuxa:TenantProtocolPolicy/set"
|
||||||
|
}
|
||||||
(method, MethodObject::Registry(obj)) => {
|
(method, MethodObject::Registry(obj)) => {
|
||||||
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
|
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
|
||||||
}
|
}
|
||||||
@@ -383,6 +391,8 @@ impl MethodName {
|
|||||||
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
||||||
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
||||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||||
|
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
||||||
|
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
||||||
|
|
||||||
).or_else(|| {
|
).or_else(|| {
|
||||||
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
||||||
@@ -437,6 +447,7 @@ impl Display for MethodObject {
|
|||||||
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
||||||
MethodObject::AiLimits => "inbuxa:AiLimits",
|
MethodObject::AiLimits => "inbuxa:AiLimits",
|
||||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||||
|
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||||
MethodObject::Registry(obj) => {
|
MethodObject::Registry(obj) => {
|
||||||
f.write_str("x:")?;
|
f.write_str("x:")?;
|
||||||
return f.write_str(obj.as_str());
|
return f.write_str(obj.as_str());
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ pub enum GetRequestMethod {
|
|||||||
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
|
TenantProtocolPolicy(
|
||||||
|
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -141,6 +144,9 @@ pub enum SetRequestMethod<'x> {
|
|||||||
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
|
TenantProtocolPolicy(
|
||||||
|
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -176,6 +176,15 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(MethodFunction::Get, MethodObject::TenantProtocolPolicy) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => {
|
||||||
|
RequestMethod::Get(GetRequestMethod::TenantProtocolPolicy(value))
|
||||||
|
}
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
@@ -348,6 +357,15 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => {
|
||||||
|
RequestMethod::Set(SetRequestMethod::TenantProtocolPolicy(value))
|
||||||
|
}
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
|
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ pub enum GetResponseMethod {
|
|||||||
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
||||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||||
|
TenantProtocolPolicy(
|
||||||
|
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -129,6 +132,9 @@ pub enum SetResponseMethod {
|
|||||||
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
|
TenantProtocolPolicy(
|
||||||
|
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -305,6 +311,26 @@ impl<'x> From<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(
|
||||||
|
value: GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||||
|
) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::TenantProtocolPolicy(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(
|
||||||
|
value: SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||||
|
) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::TenantProtocolPolicy(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||||
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||||
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jmap"
|
name = "jmap"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ impl JmapAuthorization for AccessToken {
|
|||||||
// inbuxa: legacy protocols off. It takes listeners away and
|
// inbuxa: legacy protocols off. It takes listeners away and
|
||||||
// puts them back, so it takes the listener's permissions
|
// puts them back, so it takes the listener's permissions
|
||||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||||
|
// inbuxa: legacy protocols off, per tenant. It governs
|
||||||
|
// sign-in on the tenant's domains, so it takes the domain's
|
||||||
|
// permissions, which a tenant administrator already holds.
|
||||||
|
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
||||||
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
||||||
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
||||||
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
||||||
@@ -184,6 +188,14 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysNetworkListenerUpdate,
|
Permission::SysNetworkListenerUpdate,
|
||||||
Permission::SysNetworkListenerUpdate,
|
Permission::SysNetworkListenerUpdate,
|
||||||
),
|
),
|
||||||
|
// inbuxa: legacy protocols off, per tenant, with the domain's
|
||||||
|
SetRequestMethod::TenantProtocolPolicy(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysDomainUpdate,
|
||||||
|
Permission::SysDomainUpdate,
|
||||||
|
Permission::SysDomainUpdate,
|
||||||
|
),
|
||||||
SetRequestMethod::VacationResponse(s) => validate_set(
|
SetRequestMethod::VacationResponse(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
self,
|
self,
|
||||||
@@ -294,7 +306,8 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::MaskedEmail
|
| MethodObject::MaskedEmail
|
||||||
| MethodObject::DeletedAccount
|
| MethodObject::DeletedAccount
|
||||||
| MethodObject::AiLimits
|
| MethodObject::AiLimits
|
||||||
| MethodObject::ProtocolPolicy => Permission::JmapEmailChanges,
|
| MethodObject::ProtocolPolicy
|
||||||
|
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||||
MethodObject::Registry(object_type) => object_type.get_permission(),
|
MethodObject::Registry(object_type) => object_type.get_permission(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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::blob::UploadResponse;
|
use crate::blob::UploadResponse;
|
||||||
@@ -186,7 +188,10 @@ impl ToRequestError for trc::Error {
|
|||||||
trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => {
|
trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => {
|
||||||
RequestError::forbidden()
|
RequestError::forbidden()
|
||||||
}
|
}
|
||||||
trc::SecurityEvent::IpBlockExpired | trc::SecurityEvent::IpAllowExpired => {
|
// inbuxa: legacy-protocols LP-8 is an event, never an error
|
||||||
|
trc::SecurityEvent::IpBlockExpired
|
||||||
|
| trc::SecurityEvent::IpAllowExpired
|
||||||
|
| trc::SecurityEvent::LegacyProtocolsChanged => {
|
||||||
RequestError::internal_server_error()
|
RequestError::internal_server_error()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -224,6 +224,9 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::ProtocolPolicy(set_response) => {
|
SetResponseMethod::ProtocolPolicy(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::AddressBook(set_response) => {
|
SetResponseMethod::AddressBook(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -386,6 +389,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: inbuxa:TenantProtocolPolicy/get (legacy protocols off, per tenant)
|
||||||
|
GetRequestMethod::TenantProtocolPolicy(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::tenant_protocol_policy::get(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
GetRequestMethod::Principal(req) => {
|
GetRequestMethod::Principal(req) => {
|
||||||
self.principal_get(*req, access_token).await?.into()
|
self.principal_get(*req, access_token).await?.into()
|
||||||
}
|
}
|
||||||
@@ -634,6 +644,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: inbuxa:TenantProtocolPolicy/set (legacy protocols off, per tenant)
|
||||||
|
SetRequestMethod::TenantProtocolPolicy(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::tenant_protocol_policy::set(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
SetRequestMethod::AddressBook(mut req) => {
|
SetRequestMethod::AddressBook(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||||
|
|||||||
@@ -66,9 +66,18 @@ impl SessionHandler for Server {
|
|||||||
Capability::Inbuxa,
|
Capability::Inbuxa,
|
||||||
Capabilities::Empty(EmptyCapabilities::default()),
|
Capabilities::Empty(EmptyCapabilities::default()),
|
||||||
);
|
);
|
||||||
|
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter
|
||||||
|
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? {
|
||||||
|
"disabled"
|
||||||
|
} else {
|
||||||
|
"enabled"
|
||||||
|
};
|
||||||
account.account_capabilities.append(
|
account.account_capabilities.append(
|
||||||
Capability::Inbuxa,
|
Capability::Inbuxa,
|
||||||
Capabilities::Inbuxa(InbuxaAccountCapabilities { logo }),
|
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||||
|
logo,
|
||||||
|
legacy_protocols,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
||||||
if access_token.has_permission(Permission::SysMaskedEmailGet) {
|
if access_token.has_permission(Permission::SysMaskedEmailGet) {
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::DeletedAccount
|
| MethodObject::DeletedAccount
|
||||||
| MethodObject::AiLimits
|
| MethodObject::AiLimits
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
|
| MethodObject::TenantProtocolPolicy
|
||||||
| MethodObject::Registry(_) => unreachable!(),
|
| MethodObject::Registry(_) => unreachable!(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
pub mod access;
|
pub mod access;
|
||||||
pub mod ai_limits;
|
pub mod ai_limits;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
|
pub mod tenant_protocol_policy;
|
||||||
pub mod deleted_account;
|
pub mod deleted_account;
|
||||||
pub mod fastmail;
|
pub mod fastmail;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
|
|||||||
@@ -19,7 +19,11 @@
|
|||||||
//! (LP-4).
|
//! (LP-4).
|
||||||
|
|
||||||
use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult};
|
use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult};
|
||||||
use common::{Server, auth::AccessToken, network::legacy::PolicyChange};
|
use common::{
|
||||||
|
Server,
|
||||||
|
auth::AccessToken,
|
||||||
|
network::legacy::{PolicyChange, RecentUse},
|
||||||
|
};
|
||||||
use inbuxa_features::security::{
|
use inbuxa_features::security::{
|
||||||
listeners,
|
listeners,
|
||||||
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
|
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
|
||||||
@@ -50,6 +54,7 @@ const ALL: &[P] = &[
|
|||||||
P::ChangedBy,
|
P::ChangedBy,
|
||||||
P::LockedProtocols,
|
P::LockedProtocols,
|
||||||
P::WouldClose,
|
P::WouldClose,
|
||||||
|
P::RecentLegacyUse,
|
||||||
];
|
];
|
||||||
|
|
||||||
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
|
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
|
||||||
@@ -86,7 +91,12 @@ fn listener_value(listener: &SavedListener) -> PValue {
|
|||||||
Value::Object(out)
|
Value::Object(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_value(policy: &Policy, would_close: &[SavedListener], properties: &[P]) -> PValue {
|
fn to_value(
|
||||||
|
policy: &Policy,
|
||||||
|
would_close: &[SavedListener],
|
||||||
|
recent: &[RecentUse],
|
||||||
|
properties: &[P],
|
||||||
|
) -> PValue {
|
||||||
let mut out = Map::with_capacity(properties.len());
|
let mut out = Map::with_capacity(properties.len());
|
||||||
for property in properties {
|
for property in properties {
|
||||||
let value = match property {
|
let value = match property {
|
||||||
@@ -127,12 +137,45 @@ fn to_value(policy: &Policy, would_close: &[SavedListener], properties: &[P]) ->
|
|||||||
// port, so the confirmation can say so before anything happens
|
// port, so the confirmation can say so before anything happens
|
||||||
// (LP-16).
|
// (LP-16).
|
||||||
P::WouldClose => Value::Array(would_close.iter().map(listener_value).collect()),
|
P::WouldClose => Value::Array(would_close.iter().map(listener_value).collect()),
|
||||||
|
// Who would notice, before anything changes (LP-15).
|
||||||
|
P::RecentLegacyUse => recent_value(recent, |id| ProtocolPolicyValue::Id(Id::from(id))),
|
||||||
};
|
};
|
||||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
}
|
}
|
||||||
Value::Object(out)
|
Value::Object(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The impact panel's list (LP-15): who, over what, and when, in
|
||||||
|
/// milliseconds as `changedAt` is. Shared with the tenant's switch.
|
||||||
|
pub(crate) fn recent_value<Pr, V>(
|
||||||
|
recent: &[RecentUse],
|
||||||
|
id: impl Fn(u32) -> V,
|
||||||
|
) -> Value<'static, Pr, V>
|
||||||
|
where
|
||||||
|
Pr: jmap_tools::Property,
|
||||||
|
V: jmap_tools::Element<Property = Pr>,
|
||||||
|
{
|
||||||
|
Value::Array(
|
||||||
|
recent
|
||||||
|
.iter()
|
||||||
|
.map(|entry| {
|
||||||
|
let mut out = Map::with_capacity(4);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Borrowed("accountId"),
|
||||||
|
Value::Element(id(entry.account_id)),
|
||||||
|
);
|
||||||
|
out.insert_unchecked(Key::Borrowed("name"), Value::Str(entry.name.clone().into()));
|
||||||
|
out.insert_unchecked(Key::Borrowed("protocol"), Value::Str(entry.protocol.into()));
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Borrowed("lastUsedAt"),
|
||||||
|
Value::Number((entry.at * 1000).into()),
|
||||||
|
);
|
||||||
|
Value::Object(out)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The listeners turning the switch on would close, whatever it is now.
|
/// The listeners turning the switch on would close, whatever it is now.
|
||||||
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
|
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
|
||||||
let mut hypothetical = policy.clone();
|
let mut hypothetical = policy.clone();
|
||||||
@@ -164,17 +207,22 @@ pub async fn get(
|
|||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
let recent = if properties.contains(&P::RecentLegacyUse) {
|
||||||
|
server.recent_legacy_use(None).await?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
match ids {
|
match ids {
|
||||||
None => response
|
None => response
|
||||||
.list
|
.list
|
||||||
.push(to_value(&policy, &would_close, &properties)),
|
.push(to_value(&policy, &would_close, &recent, &properties)),
|
||||||
Some(ids) => {
|
Some(ids) => {
|
||||||
for id in ids {
|
for id in ids {
|
||||||
if id.is_singleton() {
|
if id.is_singleton() {
|
||||||
response
|
response
|
||||||
.list
|
.list
|
||||||
.push(to_value(&policy, &would_close, &properties));
|
.push(to_value(&policy, &would_close, &recent, &properties));
|
||||||
} else {
|
} else {
|
||||||
response.push_not_found(id);
|
response.push_not_found(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:TenantProtocolPolicy/get` and `/set`: one tenant's legacy mail
|
||||||
|
//! protocols switch (legacy-protocols spec, LP-9 to LP-14). There is one per
|
||||||
|
//! tenant, and its id is the tenant's.
|
||||||
|
//!
|
||||||
|
//! Inside a tenant, a principal reaches only its own tenant's (MT-1): `/get`
|
||||||
|
//! with no ids answers with it, and any other id is `notFound`. At server
|
||||||
|
//! level, `/get` with no ids answers with every tenant's.
|
||||||
|
//!
|
||||||
|
//! Turning it off never needs the server's leave; turning it back on is
|
||||||
|
//! refused with `forbidden` while the server has legacy protocols off (LP-9).
|
||||||
|
//! A tenant's switch closes no port (LP-13) -- sign-in and client
|
||||||
|
//! configuration read it (LP-10, LP-14a).
|
||||||
|
|
||||||
|
use crate::inbuxa::protocol_policy::recent_value;
|
||||||
|
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
|
||||||
|
use inbuxa_features::{
|
||||||
|
security::{
|
||||||
|
protocol_policy::LegacyProtocols,
|
||||||
|
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
|
||||||
|
},
|
||||||
|
tenancy::quota::all_tenants,
|
||||||
|
};
|
||||||
|
use jmap_proto::{
|
||||||
|
error::set::SetError,
|
||||||
|
method::{
|
||||||
|
get::{GetRequest, GetResponse},
|
||||||
|
set::{SetRequest, SetResponse},
|
||||||
|
},
|
||||||
|
object::inbuxa_tenant_protocol_policy::{
|
||||||
|
TenantProtocolPolicy, TenantProtocolPolicyProperty as P, TenantProtocolPolicyValue,
|
||||||
|
},
|
||||||
|
request::IntoValid,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Key, Map, Value};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
type PValue = Value<'static, P, TenantProtocolPolicyValue>;
|
||||||
|
|
||||||
|
const ALL: &[P] = &[
|
||||||
|
P::Id,
|
||||||
|
P::TenantId,
|
||||||
|
P::LegacyProtocols,
|
||||||
|
P::ChangedAt,
|
||||||
|
P::ChangedBy,
|
||||||
|
P::RecentLegacyUse,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The tenants this principal may reach: its own inside a tenant (MT-1),
|
||||||
|
/// every tenant at server level.
|
||||||
|
async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<Vec<u32>> {
|
||||||
|
match access_token.tenant_id() {
|
||||||
|
Some(tenant_id) => Ok(vec![tenant_id]),
|
||||||
|
None => all_tenants(server.registry()).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
P::Id | P::TenantId => {
|
||||||
|
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
|
||||||
|
}
|
||||||
|
P::LegacyProtocols => Value::Str(
|
||||||
|
match policy.legacy_protocols {
|
||||||
|
LegacyProtocols::Enabled => "enabled",
|
||||||
|
LegacyProtocols::Disabled => "disabled",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
P::ChangedAt => policy
|
||||||
|
.changed_at
|
||||||
|
.map(|at| Value::Number(at.into()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
P::ChangedBy => policy
|
||||||
|
.changed_by
|
||||||
|
.as_ref()
|
||||||
|
.map(|by| Value::Str(by.clone().into()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
P::RecentLegacyUse => {
|
||||||
|
recent_value(recent, |id| TenantProtocolPolicyValue::Id(Id::from(id)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:TenantProtocolPolicy/get`.
|
||||||
|
pub async fn get(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: GetRequest<TenantProtocolPolicy>,
|
||||||
|
) -> trc::Result<GetResponse<TenantProtocolPolicy>> {
|
||||||
|
let properties = request.unwrap_properties(ALL);
|
||||||
|
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||||
|
let mut response = GetResponse {
|
||||||
|
account_id: request.account_id.into(),
|
||||||
|
state: None,
|
||||||
|
list: Vec::new(),
|
||||||
|
not_found,
|
||||||
|
};
|
||||||
|
|
||||||
|
let reachable = reachable(server, access_token).await?;
|
||||||
|
let wanted = match ids {
|
||||||
|
None => reachable.iter().map(|id| Id::from(*id)).collect(),
|
||||||
|
Some(ids) => ids,
|
||||||
|
};
|
||||||
|
for id in wanted {
|
||||||
|
let tenant_id = id.document_id();
|
||||||
|
if reachable.contains(&tenant_id) {
|
||||||
|
let policy = tenant_protocol_policy::get(&server.core.storage.data, tenant_id).await?;
|
||||||
|
// The tenant's own people only (LP-15, MT-1).
|
||||||
|
let recent = if properties.contains(&P::RecentLegacyUse) {
|
||||||
|
server.recent_legacy_use(Some(tenant_id)).await?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
response
|
||||||
|
.list
|
||||||
|
.push(to_value(tenant_id, &policy, &recent, &properties));
|
||||||
|
} else {
|
||||||
|
response.push_not_found(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:TenantProtocolPolicy/set`: turns one tenant's switch. Unset
|
||||||
|
/// (`null`) puts legacy protocols back on, which LP-9 may refuse.
|
||||||
|
pub async fn set(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, TenantProtocolPolicy>,
|
||||||
|
) -> trc::Result<SetResponse<TenantProtocolPolicy>> {
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
// A tenant's switch comes and goes with the tenant; it is only turned.
|
||||||
|
for (client_id, _) in request.unwrap_create() {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
SetError::forbidden().with_description("A tenant's switch exists with the tenant."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for id in request.unwrap_destroy().into_valid() {
|
||||||
|
response.not_destroyed.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("A tenant's switch exists with the tenant."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let reachable = reachable(server, access_token).await?;
|
||||||
|
for (id, value) in request.unwrap_update().into_valid() {
|
||||||
|
let tenant_id = id.document_id();
|
||||||
|
if !reachable.contains(&tenant_id) {
|
||||||
|
response.not_updated.append(id, SetError::not_found());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = &server.core.storage.data;
|
||||||
|
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
|
||||||
|
let mut policy = previous.clone();
|
||||||
|
let mut error = None;
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
let result = match &key {
|
||||||
|
Key::Property(P::LegacyProtocols) => match value {
|
||||||
|
Value::Null => {
|
||||||
|
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
value => match value.as_str().as_deref() {
|
||||||
|
Some("enabled") => {
|
||||||
|
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some("disabled") => {
|
||||||
|
policy.legacy_protocols = LegacyProtocols::Disabled;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(r#"must be "enabled" or "disabled""#),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Key::Property(P::Id) => Err("is immutable"),
|
||||||
|
Key::Property(_) => Err("is set by the server"),
|
||||||
|
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
|
||||||
|
};
|
||||||
|
if let Err(why) = result {
|
||||||
|
error = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(key.into_owned())
|
||||||
|
.with_description(why),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(error) = error {
|
||||||
|
response.not_updated.append(id, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LP-9: server off means off for everyone.
|
||||||
|
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) {
|
||||||
|
response
|
||||||
|
.not_updated
|
||||||
|
.append(id, SetError::forbidden().with_description(why));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if policy.legacy_protocols != previous.legacy_protocols {
|
||||||
|
policy.changed_at = Some(store::write::now() * 1000);
|
||||||
|
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
|
||||||
|
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
|
||||||
|
|
||||||
|
// LP-14. A tenant's switch closes and reopens nothing (LP-13).
|
||||||
|
trc::event!(
|
||||||
|
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||||
|
Policy = "tenant",
|
||||||
|
Id = tenant_id,
|
||||||
|
Value = if policy.legacy_protocols.is_disabled() {
|
||||||
|
"disabled"
|
||||||
|
} else {
|
||||||
|
"enabled"
|
||||||
|
},
|
||||||
|
AccountId = policy.changed_by.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
response.updated.append(id, None);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
@@ -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/"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -851,6 +851,14 @@ impl RegistrySet for Server {
|
|||||||
if let ObjectInner::MaskedEmail(mask) = &object.inner {
|
if let ObjectInner::MaskedEmail(mask) = &object.inner {
|
||||||
crate::inbuxa::masked_email::destroyed(self, id, mask).await?;
|
crate::inbuxa::masked_email::destroyed(self, id, mask).await?;
|
||||||
}
|
}
|
||||||
|
// inbuxa: legacy-protocols, a tenant's switch goes with it
|
||||||
|
if matches!(object.inner, ObjectInner::Tenant(_)) {
|
||||||
|
inbuxa_features::security::tenant_protocol_policy::remove(
|
||||||
|
&self.core.storage.data,
|
||||||
|
id.document_id(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
cache_invalidator.process_delete(id, &object);
|
cache_invalidator.process_delete(id, &object);
|
||||||
set.response.destroyed.push(id);
|
set.response.destroyed.push(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ keywords = ["imap", "jmap", "smtp", "email", "mail", "webdav", "server"]
|
|||||||
categories = ["email"]
|
categories = ["email"]
|
||||||
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; INBUXA takes the AGPL only.
|
# Upstream offers AGPL-3.0-only OR LicenseRef-SEL; INBUXA takes the AGPL only.
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -2,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]
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ impl<T: SessionStream> Session<T> {
|
|||||||
})
|
})
|
||||||
.and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?;
|
.and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?;
|
||||||
|
|
||||||
|
// inbuxa: legacy-protocols LP-10 for a bearer token that named no
|
||||||
|
// account, and LP-15: the sign-in is recorded for the impact panel
|
||||||
|
self.server
|
||||||
|
.admit_legacy_session(LegacyProtocol::ManageSieve, &access_token)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Enforce concurrency limits
|
// Enforce concurrency limits
|
||||||
let in_flight = match access_token.is_imap_request_allowed() {
|
let in_flight = match access_token.is_imap_request_allowed() {
|
||||||
LimiterResult::Allowed(in_flight) => Some(in_flight),
|
LimiterResult::Allowed(in_flight) => Some(in_flight),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "migration"
|
name = "migration"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,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]
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ impl<T: SessionStream> Session<T> {
|
|||||||
})
|
})
|
||||||
.and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?;
|
.and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?;
|
||||||
|
|
||||||
|
// inbuxa: legacy-protocols LP-10 for a bearer token that named no
|
||||||
|
// account, and LP-15: the sign-in is recorded for the impact panel
|
||||||
|
self.server
|
||||||
|
.admit_legacy_session(LegacyProtocol::Pop3, &access_token)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Enforce concurrency limits
|
// Enforce concurrency limits
|
||||||
let in_flight = match access_token.is_imap_request_allowed() {
|
let in_flight = match access_token.is_imap_request_allowed() {
|
||||||
LimiterResult::Allowed(in_flight) => Some(in_flight),
|
LimiterResult::Allowed(in_flight) => Some(in_flight),
|
||||||
|
|||||||
@@ -64,14 +64,8 @@ impl<T: SessionStream> Session<T> {
|
|||||||
)
|
)
|
||||||
.get_full_range();
|
.get_full_range();
|
||||||
|
|
||||||
self.write_bytes(
|
self.write_bytes(Response::Message::<u32> { bytes, lines }.serialize())
|
||||||
Response::Message::<u32> {
|
.await
|
||||||
bytes,
|
|
||||||
lines: lines.unwrap_or(0),
|
|
||||||
}
|
|
||||||
.serialize(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
} else {
|
} else {
|
||||||
Err(trc::Pop3Event::Error
|
Err(trc::Pop3Event::Error
|
||||||
.into_err()
|
.into_err()
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub enum Response<'x, T> {
|
|||||||
List(Vec<T>),
|
List(Vec<T>),
|
||||||
Message {
|
Message {
|
||||||
bytes: SliceRange<'x>,
|
bytes: SliceRange<'x>,
|
||||||
lines: u32,
|
lines: Option<u32>,
|
||||||
},
|
},
|
||||||
Capability {
|
Capability {
|
||||||
mechanisms: Vec<Mechanism>,
|
mechanisms: Vec<Mechanism>,
|
||||||
@@ -54,40 +54,65 @@ impl<'x, T: Display> Response<'x, T> {
|
|||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
Response::Message { bytes, lines } => {
|
Response::Message { bytes, lines } => {
|
||||||
let mut buf = Vec::with_capacity(bytes.len() + 10);
|
let lines = *lines;
|
||||||
buf.extend_from_slice(b"+OK ");
|
let mut message = Vec::with_capacity(bytes.len() + 16);
|
||||||
buf.extend_from_slice(bytes.len().to_string().as_bytes());
|
let mut octets = 0;
|
||||||
buf.extend_from_slice(b" octets\r\n");
|
let mut last_byte = b'\n';
|
||||||
|
let mut in_headers = lines.is_some();
|
||||||
let mut line_count = 0;
|
let mut is_blank_line = true;
|
||||||
let mut last_byte = 0;
|
let mut body_lines = 0;
|
||||||
|
|
||||||
// Transparency procedure
|
// Transparency procedure
|
||||||
for &byte in bytes.into_iter() {
|
for &byte in bytes.into_iter() {
|
||||||
// POP3 requires that lines end with CRLF, do this check to ensure that
|
// POP3 requires that lines end with CRLF, do this check to ensure that
|
||||||
if byte == b'\n' && last_byte != b'\r' {
|
if byte == b'\n' && last_byte != b'\r' {
|
||||||
buf.push(b'\r');
|
message.push(b'\r');
|
||||||
|
octets += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if byte == b'.' && last_byte == b'\n' {
|
if byte == b'.' && last_byte == b'\n' {
|
||||||
buf.push(b'.');
|
message.push(b'.');
|
||||||
}
|
}
|
||||||
buf.push(byte);
|
message.push(byte);
|
||||||
|
octets += 1;
|
||||||
last_byte = byte;
|
last_byte = byte;
|
||||||
|
|
||||||
if *lines > 0 && byte == b'\n' {
|
match byte {
|
||||||
line_count += 1;
|
b'\n' => {
|
||||||
if line_count == *lines {
|
if in_headers {
|
||||||
break;
|
in_headers = !is_blank_line;
|
||||||
|
} else {
|
||||||
|
body_lines += 1;
|
||||||
|
}
|
||||||
|
if !in_headers && lines.is_some_and(|lines| body_lines >= lines) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
is_blank_line = true;
|
||||||
|
}
|
||||||
|
b'\r' => {}
|
||||||
|
_ => {
|
||||||
|
is_blank_line = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if last_byte != b'\n' {
|
if last_byte != b'\n' {
|
||||||
buf.extend_from_slice(b"\r\n");
|
message.extend_from_slice(b"\r\n");
|
||||||
|
octets += 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.extend_from_slice(b".\r\n");
|
if in_headers {
|
||||||
|
message.extend_from_slice(b"\r\n");
|
||||||
|
octets += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.extend_from_slice(b".\r\n");
|
||||||
|
|
||||||
|
let mut buf = Vec::with_capacity(message.len() + 24);
|
||||||
|
buf.extend_from_slice(b"+OK ");
|
||||||
|
buf.extend_from_slice(octets.to_string().as_bytes());
|
||||||
|
buf.extend_from_slice(b" octets\r\n");
|
||||||
|
buf.extend_from_slice(&message);
|
||||||
buf
|
buf
|
||||||
}
|
}
|
||||||
Response::Capability { mechanisms, stls } => {
|
Response::Capability { mechanisms, stls } => {
|
||||||
@@ -208,9 +233,51 @@ mod tests {
|
|||||||
(
|
(
|
||||||
Response::Message {
|
Response::Message {
|
||||||
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
|
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
|
||||||
lines: 0,
|
lines: None,
|
||||||
},
|
},
|
||||||
"+OK 35 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n..test\r\na\r\n.\r\n",
|
"+OK 37 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n..test\r\na\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
|
||||||
|
lines: Some(0),
|
||||||
|
},
|
||||||
|
"+OK 17 octets\r\nSubject: test\r\n\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
|
||||||
|
lines: Some(2),
|
||||||
|
},
|
||||||
|
"+OK 27 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Split(b"Subject: test\r\n\r\n.\r\n", b"test.\r\n.test\r\na"),
|
||||||
|
lines: Some(100),
|
||||||
|
},
|
||||||
|
"+OK 37 octets\r\nSubject: test\r\n\r\n..\r\ntest.\r\n..test\r\na\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Single(b"Subject: test\n\nbody\n"),
|
||||||
|
lines: None,
|
||||||
|
},
|
||||||
|
"+OK 23 octets\r\nSubject: test\r\n\r\nbody\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Single(b"Subject: test\n\n.leading dot\n"),
|
||||||
|
lines: Some(1),
|
||||||
|
},
|
||||||
|
"+OK 31 octets\r\nSubject: test\r\n\r\n..leading dot\r\n.\r\n",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Response::Message {
|
||||||
|
bytes: SliceRange::Single(b".dot\r\nSubject: test\r\n"),
|
||||||
|
lines: Some(3),
|
||||||
|
},
|
||||||
|
"+OK 23 octets\r\n..dot\r\nSubject: test\r\n\r\n.\r\n",
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
assert_eq!(expected, String::from_utf8(cmd.serialize()).unwrap());
|
assert_eq!(expected, String::from_utf8(cmd.serialize()).unwrap());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "registry"
|
name = "registry"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp"
|
|||||||
keywords = ["smtp", "email", "mail", "server"]
|
keywords = ["smtp", "email", "mail", "server"]
|
||||||
categories = ["email"]
|
categories = ["email"]
|
||||||
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
||||||
version = "0.16.22"
|
version = "0.16.23"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,16 +121,7 @@ impl<T: SessionStream> Session<T> {
|
|||||||
.refuse_legacy_sign_in(LegacyProtocol::Submission, &credentials)
|
.refuse_legacy_sign_in(LegacyProtocol::Submission, &credentials)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let refused = err.matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused));
|
return self.legacy_refusal(err).await;
|
||||||
trc::error!(err.span_id(self.data.session_id));
|
|
||||||
if refused {
|
|
||||||
self.write(LegacyProtocol::Submission.refusal().as_bytes())
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
self.write(b"454 4.7.0 Temporary authentication failure\r\n")
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
return Ok(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
@@ -144,6 +135,18 @@ impl<T: SessionStream> Session<T> {
|
|||||||
.await
|
.await
|
||||||
.and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend));
|
.and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend));
|
||||||
|
|
||||||
|
// inbuxa: legacy-protocols LP-10, for a bearer token that named no
|
||||||
|
// account and so couldn't be judged by its domain beforehand; and
|
||||||
|
// LP-15, the sign-in is recorded for the impact panel.
|
||||||
|
if let Ok(access_token) = &result
|
||||||
|
&& let Err(err) = self
|
||||||
|
.server
|
||||||
|
.admit_legacy_session(LegacyProtocol::Submission, access_token)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return self.legacy_refusal(err).await;
|
||||||
|
}
|
||||||
|
|
||||||
let result = match result {
|
let result = match result {
|
||||||
Ok(access_token) => self.server.account_info(access_token.account_id()).await,
|
Ok(access_token) => self.server.account_info(access_token.account_id()).await,
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
@@ -207,6 +210,26 @@ impl<T: SessionStream> Session<T> {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: legacy-protocols LP-6, LP-10. A refusal is written with the
|
||||||
|
/// words the error carries, which know whose switch refused; anything
|
||||||
|
/// else that went wrong deciding is a temporary failure. Neither counts
|
||||||
|
/// as an authentication error (LP-11).
|
||||||
|
async fn legacy_refusal(&mut self, err: trc::Error) -> Result<bool, ()> {
|
||||||
|
let reply = err
|
||||||
|
.matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused))
|
||||||
|
.then(|| err.value_as_str(trc::Key::Details).map(str::to_string))
|
||||||
|
.flatten();
|
||||||
|
trc::error!(err.span_id(self.data.session_id));
|
||||||
|
match reply {
|
||||||
|
Some(reply) => self.write(reply.as_bytes()).await?,
|
||||||
|
None => {
|
||||||
|
self.write(b"454 4.7.0 Temporary authentication failure\r\n")
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn auth_error(&mut self, response: &[u8]) -> Result<bool, ()> {
|
pub async fn auth_error(&mut self, response: &[u8]) -> Result<bool, ()> {
|
||||||
tokio::time::sleep(self.params.auth_errors_wait).await;
|
tokio::time::sleep(self.params.auth_errors_wait).await;
|
||||||
self.data.auth_errors += 1;
|
self.data.auth_errors += 1;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user