Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a23243cc1 | ||
|
|
8a596c44ac | ||
|
|
c50d5eb109 | ||
|
|
098abb102a | ||
|
|
c1702a00bb | ||
|
|
a24ed3b60a | ||
|
|
f791c78d17 | ||
|
|
461f5fab3c | ||
|
|
4f25927d18 | ||
|
|
fb785b8635 | ||
|
|
50a03df30b | ||
|
|
9976d52e29 | ||
|
|
58d2804278 | ||
|
|
5f6548bfdd | ||
|
|
daa484efbc | ||
|
|
1aedc77791 | ||
|
|
a19d9eec89 | ||
|
|
5c1c4c6248 | ||
|
|
2d8728793c | ||
|
|
9429f1de00 | ||
|
|
76c170db9d | ||
|
|
d7bebd454d | ||
|
|
282ad5fc13 | ||
|
|
133d41df36 | ||
|
|
c4a6e4d117 | ||
|
|
f59a9de4dc | ||
|
|
083f22d6fb | ||
|
|
c43abef8ab | ||
|
|
c9f8028502 | ||
|
|
cd7a0f4163 | ||
|
|
30d4cef0e7 | ||
|
|
6c1eeea038 | ||
|
|
ea9a6f0c58 | ||
|
|
1c1838af05 | ||
|
|
78c9490b1e | ||
|
|
ce2742fc80 | ||
|
|
81deaa69c4 | ||
|
|
d9754c46a6 | ||
|
|
4481279f1c | ||
|
|
20abf69d31 | ||
|
|
69ef48239a | ||
|
|
00f00d6d75 | ||
|
|
68d3ad795e | ||
|
|
d486747c11 | ||
|
|
e69df1ae8d | ||
|
|
031d028ba4 | ||
|
|
6d7afc3c06 | ||
|
|
3d5a1692ab | ||
|
|
1de77316f0 | ||
|
|
26c7c6a897 | ||
|
|
4ba1896eb1 | ||
|
|
b0e53ef966 | ||
|
|
dd57709522 | ||
|
|
3450c31345 | ||
|
|
29d3a5f779 | ||
|
|
f1f112fc38 | ||
|
|
96be849976 | ||
|
|
a5c8927dbc | ||
|
|
ad09eeeefb | ||
|
|
7e06a3b1f6 | ||
|
|
e147206e82 | ||
|
|
ca6484c356 | ||
|
|
faf3d1e056 |
No files matched your search
+44
-1
@@ -7,7 +7,12 @@
|
|||||||
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||||
# unreviewed can be pulled in.
|
# unreviewed can be pulled in.
|
||||||
#
|
#
|
||||||
# Not ported, as on GitLab: publish.yml and release.yml still need doing.
|
# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo),
|
||||||
|
# fork-checks and build skip here and the `github` job below waits for the
|
||||||
|
# same work done by .github/workflows/ci.yml on the GitHub mirror, passing or
|
||||||
|
# failing with it -- so this run still carries the answer pull requests and
|
||||||
|
# merges look at. Unset, everything builds here as before. If GitHub is
|
||||||
|
# unavailable, unset BUILD_ON and nothing else has to change.
|
||||||
name: ci
|
name: ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -25,6 +30,7 @@ jobs:
|
|||||||
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
||||||
# check diffs against the upstream snapshot branch, hence the full fetch.
|
# check diffs against the upstream snapshot branch, hence the full fetch.
|
||||||
fork-checks:
|
fork-checks:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
@@ -52,6 +58,7 @@ jobs:
|
|||||||
run: python3 -m unittest discover -s tools/fork/tests
|
run: python3 -m unittest discover -s tools/fork/tests
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
# 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
|
||||||
container:
|
container:
|
||||||
@@ -101,3 +108,39 @@ jobs:
|
|||||||
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
|
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
|
||||||
echo "target dir: ${used:-0} GB"
|
echo "target dir: ${used:-0} GB"
|
||||||
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
|
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
|
||||||
|
|
||||||
|
# BUILD_ON=github: the GitHub mirror builds this commit and posts the result
|
||||||
|
# back as the commit status "github/ci (branch)". This waits for that status
|
||||||
|
# and takes its answer. The mirror pushes on every commit, so a missing
|
||||||
|
# status means GitHub has not got the push or is not running: after the
|
||||||
|
# timeout this fails, which is the cue to unset BUILD_ON.
|
||||||
|
github:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' }}
|
||||||
|
# Its own runner label with plenty of slots: this job only polls, but holds a slot
|
||||||
|
# for as long as the GitHub build takes, and must not starve the build runners.
|
||||||
|
runs-on: wait
|
||||||
|
timeout-minutes: 150
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
CONTEXT: github/ci (branch)
|
||||||
|
run: |
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import json, os, time, urllib.request
|
||||||
|
url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}"
|
||||||
|
f"/commits/{os.environ['SHA']}/statuses?limit=50")
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
|
||||||
|
ctx, last = os.environ["CONTEXT"], None
|
||||||
|
print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True)
|
||||||
|
while True:
|
||||||
|
mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx]
|
||||||
|
state = max(mine, key=lambda s: s["id"]) if mine else None
|
||||||
|
if state and state["status"] != last:
|
||||||
|
last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True)
|
||||||
|
if last == "success": raise SystemExit(0)
|
||||||
|
if last in ("failure", "error"): raise SystemExit(1)
|
||||||
|
time.sleep(20)
|
||||||
|
EOF
|
||||||
@@ -42,6 +42,14 @@
|
|||||||
#
|
#
|
||||||
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
|
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
|
||||||
# own token is refused by the container registry.
|
# own token is refused by the container registry.
|
||||||
|
#
|
||||||
|
# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo), every
|
||||||
|
# job here but the announcement skips, and the tag is published by
|
||||||
|
# .github/workflows/ci.yml on the GitHub mirror instead -- same guards, same
|
||||||
|
# tags, the same Release and binaries, created here through the API. The
|
||||||
|
# `github` job waits for that run's commit status, "github/ci (tag)", and the
|
||||||
|
# announcement follows it as it follows the binaries here. Unset, everything
|
||||||
|
# runs here as before.
|
||||||
name: publish
|
name: publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -50,6 +58,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
version:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
@@ -88,6 +97,7 @@ jobs:
|
|||||||
echo "version $V"
|
echo "version $V"
|
||||||
|
|
||||||
publish-amd64:
|
publish-amd64:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version]
|
needs: [version]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -128,6 +138,7 @@ jobs:
|
|||||||
run: docker logout "$REGISTRY" || true
|
run: docker logout "$REGISTRY" || true
|
||||||
|
|
||||||
publish-arm64:
|
publish-arm64:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version, publish-amd64]
|
needs: [version, publish-amd64]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -162,11 +173,46 @@ jobs:
|
|||||||
- if: always()
|
- if: always()
|
||||||
run: docker logout "$REGISTRY" || true
|
run: docker logout "$REGISTRY" || true
|
||||||
|
|
||||||
|
# BUILD_ON=github: waits for the GitHub mirror's run for this tag, which
|
||||||
|
# posts its result back as the commit status "github/ci (tag)", and takes
|
||||||
|
# its answer. Fails after the timeout if no answer comes.
|
||||||
|
github:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' }}
|
||||||
|
# Its own runner label with plenty of slots: this job only polls, but holds a slot
|
||||||
|
# for as long as the GitHub build takes, and must not starve the build runners.
|
||||||
|
runs-on: wait
|
||||||
|
timeout-minutes: 240
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
CONTEXT: github/ci (tag)
|
||||||
|
run: |
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import json, os, time, urllib.request
|
||||||
|
url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}"
|
||||||
|
f"/commits/{os.environ['SHA']}/statuses?limit=50")
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
|
||||||
|
ctx, last = os.environ["CONTEXT"], None
|
||||||
|
print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True)
|
||||||
|
while True:
|
||||||
|
mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx]
|
||||||
|
state = max(mine, key=lambda s: s["id"]) if mine else None
|
||||||
|
if state and state["status"] != last:
|
||||||
|
last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True)
|
||||||
|
if last == "success": raise SystemExit(0)
|
||||||
|
if last in ("failure", "error"): raise SystemExit(1)
|
||||||
|
time.sleep(20)
|
||||||
|
EOF
|
||||||
|
|
||||||
# The weekly release creates its Release (and so the tag) first; a tag
|
# The weekly release creates its Release (and so the tag) first; a tag
|
||||||
# pushed by hand has none. Either way the tag ends up with exactly one
|
# pushed by hand has none. Either way the tag ends up with exactly one
|
||||||
# Release, created once the amd64 image exists so its pull instructions
|
# Release, created once the amd64 image exists so its pull instructions
|
||||||
# work; arm64 and the binaries follow.
|
# work; arm64 and the binaries follow.
|
||||||
release:
|
release:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version, publish-amd64]
|
needs: [version, publish-amd64]
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
@@ -216,6 +262,7 @@ jobs:
|
|||||||
# `docker create` does not start anything, so pulling an arm64 image on an
|
# `docker create` does not start anything, so pulling an arm64 image on an
|
||||||
# amd64 runner and copying a file out of it needs no emulation.
|
# amd64 runner and copying a file out of it needs no emulation.
|
||||||
binaries:
|
binaries:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version, publish-arm64, release]
|
needs: [version, publish-arm64, release]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -289,8 +336,14 @@ jobs:
|
|||||||
# The release above is made with the job's own token, and Gitea starts no
|
# The release above is made with the job's own token, and Gitea starts no
|
||||||
# workflow for events the Actions bot causes -- announce.yml's
|
# workflow for events the Actions bot causes -- announce.yml's
|
||||||
# 'on: release' never fires for it -- so announce it from here.
|
# 'on: release' never fires for it -- so announce it from here.
|
||||||
|
#
|
||||||
|
# With BUILD_ON=github the release and binaries come from the GitHub run,
|
||||||
|
# so the announcement waits for the `github` job instead. The Release that
|
||||||
|
# run creates for a hand-pushed tag is made with a user token, so
|
||||||
|
# announce.yml fires for it too; discourse-release keeps one topic per tag.
|
||||||
announce:
|
announce:
|
||||||
needs: [release, binaries]
|
needs: [release, binaries, github]
|
||||||
|
if: ${{ always() && ((needs.release.result == 'success' && needs.binaries.result == 'success') || needs.github.result == 'success') }}
|
||||||
runs-on: light
|
runs-on: light
|
||||||
steps:
|
steps:
|
||||||
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
# Cargo. One entry: the workspace has a single lockfile at the root, and
|
|
||||||
# ~30 manifests that upstream bumps on every release -- pointing entries at
|
|
||||||
# individual crates would find manifests with no lockfile beside them.
|
|
||||||
#
|
|
||||||
# Minor and patch arrive as one pull request a week. Majors are left out of
|
|
||||||
# the group on purpose: they are migrations rather than bumps, and each one
|
|
||||||
# deserves its own pull request and its own CI run.
|
|
||||||
- package-ecosystem: cargo
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: tuesday
|
|
||||||
time: "09:00"
|
|
||||||
timezone: Etc/UTC
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
groups:
|
|
||||||
minor-and-patch:
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: tuesday
|
|
||||||
time: "09:00"
|
|
||||||
timezone: Etc/UTC
|
|
||||||
groups:
|
|
||||||
actions:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
# The Dockerfiles pin their base images, so this is what keeps a published
|
|
||||||
# image off a stale base between releases.
|
|
||||||
- package-ecosystem: docker
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: tuesday
|
|
||||||
time: "09:00"
|
|
||||||
timezone: Etc/UTC
|
|
||||||
+469
-38
@@ -1,51 +1,482 @@
|
|||||||
# What CI can check without a mail server's worth of infrastructure.
|
# CI and publishing on GitHub, for the repository Gitea mirrors here.
|
||||||
#
|
#
|
||||||
# The build, and that every test target compiles. It deliberately does not
|
# Gitea (git.coffeylabs.org) is where this project lives: pull requests,
|
||||||
# *run* the test suites: the unit tests only build with the integration crate
|
# issues, releases and the container registry are all there, and it pushes
|
||||||
# in the graph, because that is what switches on the `test_mode` features they
|
# every branch and tag to this GitHub copy as it changes. GitHub's hosted
|
||||||
# rely on (docs/spec/SPEC.md 2.2b), and the integration suites need a `STORE`,
|
# runners are faster than the self-hosted ones -- and have native arm64 -- so
|
||||||
# fixed ports, and in most cases a container apiece (docs/spec/
|
# the building happens here, and the answer goes back to Gitea as a commit
|
||||||
# container-tests.md). Running them here would mean either a green tick that
|
# status that Gitea's own ci.yml / publish.yml wait on.
|
||||||
# skipped everything, or a red one that means "the runner has no Redis".
|
|
||||||
#
|
#
|
||||||
# So this catches what it can honestly catch -- code that does not compile,
|
# One switch decides which side builds: the Actions variable BUILD_ON, set on
|
||||||
# including test code -- and the suites are run by hand, one at a time, as
|
# both forges. BUILD_ON=github runs every job below and turns Gitea's heavy
|
||||||
# that page describes. If that changes, it changes because someone made the
|
# jobs into a wait for this one; anything else leaves Gitea building exactly
|
||||||
# suites runnable unattended, not because CI started ignoring failures.
|
# as before and every job here skips. If GitHub is ever unavailable, unset it
|
||||||
name: CI
|
# on Gitea and nothing else has to change.
|
||||||
|
#
|
||||||
|
# Needs, as organization settings rather than anything in this file:
|
||||||
|
# variables BUILD_ON=github, REGISTRY (the Gitea container registry),
|
||||||
|
# GITEA_URL (the Gitea base URL)
|
||||||
|
# secret GITEA_TOKEN -- jcoffey-dev, write:repository + write:package:
|
||||||
|
# commit statuses, the release and its assets, the registry push
|
||||||
|
#
|
||||||
|
# There is no pull_request trigger: pull requests happen on Gitea, and their
|
||||||
|
# branch arrives here as an ordinary push. Branch pushes get what Gitea's
|
||||||
|
# ci.yml checks; v* tags get what its publish.yml does. Schedules (the weekly
|
||||||
|
# release, the upstream watch) and the release announcement stay on Gitea.
|
||||||
|
#
|
||||||
|
# Every `uses:` is pinned to a full commit SHA with the release in the
|
||||||
|
# trailing comment. A tag is a mutable pointer; do not "simplify" a pin back
|
||||||
|
# to one. Only GitHub's own actions and the three docker/* ones are used.
|
||||||
|
name: ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: ['**']
|
||||||
pull_request:
|
tags: ['**']
|
||||||
# Lets CI be run by hand against any ref, including one that predates a CI
|
|
||||||
# change, without pushing an empty commit to move it.
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# A second push to a branch cancels the run still going for the first: the
|
# A newer push to a branch cancels the run for the older one, whose answer is
|
||||||
# older run's answer is about code nobody is looking at any more.
|
# about code nobody is looking at any more. A tag run is never cancelled: it
|
||||||
|
# publishes.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: ${{ github.ref_type == 'branch' }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL }}
|
||||||
|
# The Gitea status this run answers for. Gitea waits on the one matching
|
||||||
|
# its own event: "(branch)" from ci.yml, "(tag)" from publish.yml.
|
||||||
|
STATUS_CONTEXT: github/ci (${{ github.ref_type }})
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
# Tells Gitea a run has started, so a pull request shows it as pending
|
||||||
|
# rather than missing while the build is still going.
|
||||||
|
start:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
jq -n --arg c "$STATUS_CONTEXT" \
|
||||||
|
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|
||||||
|
'{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' |
|
||||||
|
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' --data @- \
|
||||||
|
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- branches ------
|
||||||
|
# What an upstream merge can bring in or leave behind without a conflict:
|
||||||
|
# the upstream name in a new string literal, and a changed upstream file
|
||||||
|
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
||||||
|
# check diffs against the upstream snapshot in the history, hence the full
|
||||||
|
# fetch.
|
||||||
|
fork-checks:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# Every `uses:` here is pinned to a full commit SHA, with the release it
|
|
||||||
# belongs to in the trailing comment. A tag is a mutable pointer, so
|
|
||||||
# trusting `@v7` is trusting every future version of that action,
|
|
||||||
# including one pushed by whoever compromises the account. Dependabot
|
|
||||||
# updates both halves together -- do not "simplify" a pin back to a tag.
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
with:
|
||||||
- name: System dependencies
|
fetch-depth: 0
|
||||||
# foundationdb and the search backends are off by default, but the
|
- run: python3 tools/fork/name-check.py
|
||||||
# default feature set still links against the system's C libraries.
|
- if: always()
|
||||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang
|
run: python3 tools/fork/notice-check.py
|
||||||
- name: Build the server
|
# Cargo can patch a dependency to a directory in this repository, and
|
||||||
run: cargo build -p inbuxa --locked
|
# the image builds from a context .dockerignore prunes to almost
|
||||||
- name: Compile every test target
|
# nothing. CI never sees the difference; a release does.
|
||||||
# `--no-run` is the point: it builds the unit tests and the integration
|
- if: always()
|
||||||
# crate together, which is the combination that resolves the test
|
run: python3 tools/fork/context-check.py
|
||||||
# features, and stops short of running anything that wants a store.
|
# The personal-data catalog must classify every object and field the
|
||||||
run: cargo test --workspace --locked --no-run
|
# schema has, and name nothing that is gone.
|
||||||
|
- if: always()
|
||||||
|
run: python3 tools/fork/privacy-check.py
|
||||||
|
# The admin reads each expression field's allowed values and variables
|
||||||
|
# from the schema; they're generated from the registry and must match it.
|
||||||
|
- if: always()
|
||||||
|
run: python3 tools/fork/expr-schema.py --check
|
||||||
|
- if: always()
|
||||||
|
run: python3 -m unittest discover -s tools/fork/tests
|
||||||
|
|
||||||
|
# The build, and that every test target compiles. The suites are not run:
|
||||||
|
# they need a store, fixed ports and containers (docs/spec/
|
||||||
|
# container-tests.md), and are run by hand.
|
||||||
|
build:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CARGO_INCREMENTAL: "0"
|
||||||
|
# Debug info is most of a dev target dir, and nothing here runs a
|
||||||
|
# debugger. Without it the dev and test builds fit the runner's disk and
|
||||||
|
# the cache below stays small enough to be worth restoring.
|
||||||
|
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||||
|
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||||
|
steps:
|
||||||
|
# The hosted image carries toolchains this build never touches; a dev,
|
||||||
|
# test and release build of RocksDB and the workspace needs the room.
|
||||||
|
- run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||||
|
df -h /
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
# Current stable, as Gitea's rust:1 image is.
|
||||||
|
- id: rust
|
||||||
|
run: |
|
||||||
|
rustup toolchain install stable --profile minimal
|
||||||
|
rustup default stable
|
||||||
|
echo "version=$(rustc -V | cut -d' ' -f2)" >> "$GITHUB_OUTPUT"
|
||||||
|
- run: sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends clang >/dev/null
|
||||||
|
# Cargo's download cache and the dev/test target dir, keyed on the
|
||||||
|
# lockfile and the compiler. Saved from main only, so the one cache
|
||||||
|
# every branch restores is main's, and branches cannot evict it.
|
||||||
|
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
target/debug
|
||||||
|
key: cargo-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-${{ steps.rust.outputs.version }}-
|
||||||
|
- run: cargo build -p inbuxa --locked
|
||||||
|
# --no-run: compiles every test target without running them, which
|
||||||
|
# catches a test that no longer builds without needing a store.
|
||||||
|
- run: cargo test --workspace --locked --no-run
|
||||||
|
- if: github.ref == 'refs/heads/main'
|
||||||
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
target/debug
|
||||||
|
key: cargo-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
# The release profile, on main only. It is the profile the image is
|
||||||
|
# built with, and it fails in ways the dev profile does not: v2026.9.24
|
||||||
|
# was tagged on a commit whose CI was green and whose release build
|
||||||
|
# could not compile the scim crate at all.
|
||||||
|
- if: github.ref == 'refs/heads/main'
|
||||||
|
run: cargo build -p inbuxa --locked --release
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- tags ------
|
||||||
|
# Two guards before anything is pushed, the same as Gitea's publish.yml:
|
||||||
|
# * 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 or on a release/* branch, so an image never
|
||||||
|
# describes code that was never reviewed onto one of them. A release/*
|
||||||
|
# branch carries a hotfix cut from an earlier release tag.
|
||||||
|
version:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.v.outputs.version }}
|
||||||
|
steps:
|
||||||
|
# Full history, and every branch as origin/*: the ancestry check cannot
|
||||||
|
# be answered from a shallow clone.
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- id: v
|
||||||
|
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
|
||||||
|
commit="$(git rev-parse "${TAG}^{commit}")"
|
||||||
|
on=""
|
||||||
|
for ref in origin/main $(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/*'); do
|
||||||
|
if git merge-base --is-ancestor "$commit" "$ref"; then on="$ref"; break; fi
|
||||||
|
done
|
||||||
|
[ -n "$on" ] || { echo "$TAG is not on main or a release/* branch" >&2; exit 1; }
|
||||||
|
echo "$TAG is on $on"
|
||||||
|
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Each architecture on its own native runner, side by side. The Dockerfile
|
||||||
|
# cross-compiles from the build platform, and on the self-hosted runners one
|
||||||
|
# machine built both one after the other; here two machines build at once,
|
||||||
|
# each natively (the builder stage picks the matching target, and the
|
||||||
|
# aarch64 toolchain it installs exists on arm64 too), and the small final
|
||||||
|
# stage needs no QEMU. amd64 also moves :<version> as soon as it is done, so
|
||||||
|
# a production deploy can start from it; :latest waits for the index below,
|
||||||
|
# so it never names an image without arm64.
|
||||||
|
publish:
|
||||||
|
needs: [version]
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||||
|
echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||||
|
# The release link (fat LTO, one codegen unit) outgrows the runner's
|
||||||
|
# 16 GB: v2026.9.30's arm64 link was killed for memory. Swap gives it
|
||||||
|
# room; buildx's container has no memory limit of its own, so it
|
||||||
|
# reaches the host's swap.
|
||||||
|
- run: |
|
||||||
|
sudo fallocate -l 16G /swap.release
|
||||||
|
sudo chmod 600 /swap.release
|
||||||
|
sudo mkswap /swap.release >/dev/null
|
||||||
|
sudo swapon /swap.release
|
||||||
|
free -g
|
||||||
|
df -h /
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY }}
|
||||||
|
username: jcoffey-dev
|
||||||
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
# Attestations off: they add manifests of their own, and the index
|
||||||
|
# should hold the two images and nothing else. No build cache: GitHub
|
||||||
|
# scopes a tag run's cache to that tag, so the next release could never
|
||||||
|
# read it, and each one would park several GB in the repository's 10 GB
|
||||||
|
# cache and evict main's cargo cache.
|
||||||
|
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/${{ matrix.arch }}
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE }}:${{ env.VERSION }}-${{ matrix.arch }}
|
||||||
|
${{ matrix.arch == 'amd64' && format('{0}:{1}', env.IMAGE, env.VERSION) || '' }}
|
||||||
|
|
||||||
|
# Joins the two per-architecture tags into :<version> and :latest. Built
|
||||||
|
# from the per-architecture tags rather than :<version>, which by now is
|
||||||
|
# the amd64 image and would be read as such.
|
||||||
|
index:
|
||||||
|
needs: [version, publish]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||||
|
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY }}
|
||||||
|
username: jcoffey-dev
|
||||||
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
- run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
--tag "$IMAGE:$VERSION" \
|
||||||
|
--tag "$IMAGE:latest" \
|
||||||
|
"$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64"
|
||||||
|
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.
|
||||||
|
- env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
owner="${GITHUB_REPOSITORY%%/*}"; name="${GITHUB_REPOSITORY#*/}"
|
||||||
|
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$GITEA_URL/api/v1/packages/${owner,,}/container/$name/-/link/$name" \
|
||||||
|
|| echo "package already linked (or link refused); not fatal"
|
||||||
|
|
||||||
|
# The weekly release creates its Release (and so the tag) on Gitea first; a
|
||||||
|
# tag pushed by hand has none. Either way the tag ends up with exactly one
|
||||||
|
# Release there, created once the image exists so its pull instructions
|
||||||
|
# work.
|
||||||
|
release:
|
||||||
|
needs: [version, index]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||||
|
code="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$api/releases/tags/$TAG")"
|
||||||
|
if [ "$code" = 200 ]; then echo "$TAG already has a release"; exit 0; fi
|
||||||
|
[ "$code" = 404 ] || { echo "looking up the release for $TAG answered $code" >&2; exit 1; }
|
||||||
|
image="$REGISTRY/${GITHUB_REPOSITORY,,}:$VERSION"
|
||||||
|
body="Container image: \`$image\` (linux/amd64, linux/arm64); also \`:latest\`.
|
||||||
|
|
||||||
|
Binaries for a host install are attached: \`inbuxa-linux-amd64.tar.gz\` and \`inbuxa-linux-arm64.tar.gz\`, with \`SHA256SUMS\`. Each is the binary out of this release's image for that architecture, so it is the same build. The image grants it \`cap_net_bind_service\`; a host install has to grant that itself (\`setcap\`, or \`AmbientCapabilities\` in the unit) to bind port 25."
|
||||||
|
jq -n --arg tag "$TAG" --arg name "INBUXA $VERSION" --arg body "$body" \
|
||||||
|
'{tag_name:$tag, name:$name, body:$body}' |
|
||||||
|
curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
--data @- "$api/releases" | jq -r '"created release " + .tag_name'
|
||||||
|
|
||||||
|
# The binaries for a host install, taken out of the image that was just
|
||||||
|
# pushed rather than compiled again: the binary in the tarball is the file
|
||||||
|
# the image runs. `docker create` starts nothing, so copying a file out of
|
||||||
|
# the arm64 image on an amd64 runner needs no emulation.
|
||||||
|
binaries:
|
||||||
|
needs: [version, index, release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||||
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY }}
|
||||||
|
username: jcoffey-dev
|
||||||
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
- name: take the binaries out of the image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p out && cd out
|
||||||
|
for arch in amd64 arm64; do
|
||||||
|
docker pull -q --platform "linux/$arch" "$IMAGE:$VERSION"
|
||||||
|
id="$(docker create --platform "linux/$arch" "$IMAGE:$VERSION")"
|
||||||
|
docker cp "$id:/usr/local/bin/inbuxa" inbuxa
|
||||||
|
docker rm -f "$id" >/dev/null
|
||||||
|
chmod 0755 inbuxa
|
||||||
|
tar -czf "inbuxa-linux-$arch.tar.gz" inbuxa
|
||||||
|
rm inbuxa
|
||||||
|
done
|
||||||
|
sha256sum inbuxa-linux-*.tar.gz > SHA256SUMS
|
||||||
|
cat SHA256SUMS
|
||||||
|
# A re-run of a tag replaces its assets rather than leaving two files
|
||||||
|
# with the same name and different contents.
|
||||||
|
#
|
||||||
|
# The uploads cross Cloudflare, which dropped 50 MB HTTP/2 uploads
|
||||||
|
# part-way for v2026.9.30.1 (curl 92, PROTOCOL_ERROR; origin logged
|
||||||
|
# 400), once on each of two runs. Uploads go over HTTP/1.1 and retry.
|
||||||
|
- name: attach them to the release
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||||
|
auth="Authorization: token $GITEA_TOKEN"
|
||||||
|
retry=(--retry 5 --retry-all-errors --retry-delay 15)
|
||||||
|
rel="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/tags/$TAG" | jq -r .id)"
|
||||||
|
assets="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/$rel/assets")"
|
||||||
|
for f in out/inbuxa-linux-amd64.tar.gz out/inbuxa-linux-arm64.tar.gz out/SHA256SUMS; do
|
||||||
|
name="$(basename "$f")"
|
||||||
|
old="$(jq -r --arg n "$name" '.[] | select(.name == $n) | .id' <<<"$assets")"
|
||||||
|
for id in $old; do curl -fsS "${retry[@]}" -o /dev/null -X DELETE -H "$auth" "$api/releases/$rel/assets/$id"; done
|
||||||
|
curl -fsS --http1.1 "${retry[@]}" -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$rel/assets?name=$name"
|
||||||
|
echo "attached $name"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ------------------------------------------------------ ghcr replica ------
|
||||||
|
# Copies the release image from the Gitea registry, which stays the
|
||||||
|
# authoritative one, to ghcr.io under the same version tag and :latest. It is
|
||||||
|
# a copy, not a second build: the digest on GHCR is the digest on the
|
||||||
|
# registry, so `docker pull ghcr.io/...` gets exactly the same image. Left
|
||||||
|
# out of the report to Gitea, like the release copy, so a GHCR problem
|
||||||
|
# cannot fail a release.
|
||||||
|
ghcr:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
||||||
|
needs: [version, index]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG: ${{ needs.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}"
|
||||||
|
dst="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||||
|
tag="$TAG"
|
||||||
|
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
||||||
|
docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$src:$tag"
|
||||||
|
want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')"
|
||||||
|
got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')"
|
||||||
|
echo "registry $src:$tag = $want"
|
||||||
|
echo "ghcr $dst:$tag = $got"
|
||||||
|
[ "$want" = "$got" ] || echo "::warning::GHCR digest differs from the registry's"
|
||||||
|
docker logout ghcr.io
|
||||||
|
|
||||||
|
# ---------------------------------------------------- github release ------
|
||||||
|
# Copies this tag's Gitea release -- notes and files -- to a GitHub release,
|
||||||
|
# so the replica's Releases page, and anyone watching it, keeps up. Gitea's
|
||||||
|
# release is the real one; this is left out of the report to Gitea, so a
|
||||||
|
# failure here cannot fail a release. PR and issue numbers in the notes are
|
||||||
|
# rewritten to Gitea links: on GitHub a bare #16 is some other PR.
|
||||||
|
github-release:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
||||||
|
needs: [binaries]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
echo "GitHub already has a release for $TAG"; exit 0
|
||||||
|
fi
|
||||||
|
# The Gitea release exists by now if this run made it; if the weekly
|
||||||
|
# release job made it, it came before the tag. Allow a few minutes.
|
||||||
|
code=0
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
code="$(curl -sS -o rel.json -w '%{http_code}' "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")"
|
||||||
|
[ "$code" = 200 ] && break
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
if [ "$code" != 200 ]; then echo "No Gitea release for $TAG; nothing to copy"; exit 0; fi
|
||||||
|
if [ "$(jq -r .draft rel.json)" = true ]; then echo "The Gitea release is a draft; not copying"; exit 0; fi
|
||||||
|
export BASE="$(jq -r '.html_url | sub("/releases/tag/.*$"; "")' rel.json)"
|
||||||
|
jq -r '.body // ""' rel.json | perl -pe 's{(?<![\w/&\[])#(\d+)\b}{[#$1]($ENV{BASE}/pulls/$1)}g' > notes.md
|
||||||
|
printf '\n\n_Mirrored from [the Gitea release](%s); report issues on [Gitea](%s/issues)._\n' \
|
||||||
|
"$(jq -r .html_url rel.json)" "$BASE" >> notes.md
|
||||||
|
files=()
|
||||||
|
mkdir -p files
|
||||||
|
while IFS=$'\t' read -r name url; do
|
||||||
|
curl -fsSL -o "files/$name" "$url"; files+=("files/$name")
|
||||||
|
done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json)
|
||||||
|
title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG"
|
||||||
|
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi
|
||||||
|
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \
|
||||||
|
--notes-file notes.md "$kind" "${files[@]}"
|
||||||
|
echo "created the GitHub release for $TAG with ${#files[@]} file(s)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- report ------
|
||||||
|
# One commit status on Gitea for the whole run: what Gitea's ci.yml and
|
||||||
|
# publish.yml wait on. Skipped jobs (the tag jobs on a branch, and the other
|
||||||
|
# way round) count as passing; a failed or cancelled one does not.
|
||||||
|
report:
|
||||||
|
if: ${{ always() && vars.BUILD_ON == 'github' }}
|
||||||
|
needs: [start, fork-checks, build, version, publish, index, release, binaries]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
STATE: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }}
|
||||||
|
run: |
|
||||||
|
# A cancelled run was superseded by a newer run for the same commit (the
|
||||||
|
# mirror can push one commit twice); that run reports. Posting "failure"
|
||||||
|
# here would fail the Gitea check while the real build is still going.
|
||||||
|
if [ "$STATE" = cancelled ]; then echo "cancelled: leaving the result to the newer run"; exit 0; fi
|
||||||
|
jq -n --arg s "$STATE" --arg c "$STATUS_CONTEXT" \
|
||||||
|
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|
||||||
|
'{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' |
|
||||||
|
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' --data @- \
|
||||||
|
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
|
||||||
|
echo "$STATUS_CONTEXT: $STATE"
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Prune old image versions from GHCR.
|
|
||||||
#
|
|
||||||
# Releases are kept forever -- they carry no assets and their generated notes
|
|
||||||
# are this project's only changelog, so deleting one destroys history that
|
|
||||||
# cannot be reconstructed for nothing saved. Images are the opposite: a
|
|
||||||
# multi-arch build a week, and the by-digest push in publish.yml leaves two
|
|
||||||
# untagged per-architecture manifests behind each time on top of the tagged
|
|
||||||
# index. Those accumulate and nobody wants fifty of them.
|
|
||||||
#
|
|
||||||
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
|
|
||||||
# `delete-only-untagged-versions` -- will happily delete the per-architecture
|
|
||||||
# manifests that a multi-arch tag points *at*, because they are untagged by
|
|
||||||
# design. Nothing appears to break: the tag still exists, and pulls simply
|
|
||||||
# start failing for one architecture. This action understands manifest lists
|
|
||||||
# and will not orphan a retained index, and `validate` re-checks every
|
|
||||||
# multi-arch manifest against the registry afterwards.
|
|
||||||
#
|
|
||||||
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
|
|
||||||
# exactly what would be deleted without rebuilding and re-pushing an image to
|
|
||||||
# find out.
|
|
||||||
name: Prune images
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
dry_run:
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
dry_run:
|
|
||||||
description: "List what would be deleted, delete nothing"
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prune:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
# The only third-party action here that is not published by GitHub or
|
|
||||||
# Docker, and the one with the most to lose: it is handed
|
|
||||||
# `packages: write` and its whole job is deletion, so a ref repointed at
|
|
||||||
# something else -- by a compromise or a mistake upstream -- is a bad
|
|
||||||
# day. It was pinned to a commit long before the rest of them were.
|
|
||||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
|
|
||||||
with:
|
|
||||||
owner: inbuxa
|
|
||||||
package: inbuxa-server
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# Ten weekly releases is roughly a quarter of history, which is more
|
|
||||||
# than enough to roll back to and far less than the year's worth that
|
|
||||||
# would otherwise pile up. Older *releases* stay either way; this
|
|
||||||
# only removes the images.
|
|
||||||
keep-n-tagged: 10
|
|
||||||
# Belt and braces on top of the action's own manifest awareness:
|
|
||||||
# `latest` is never a candidate for deletion under any counting.
|
|
||||||
exclude-tags: latest
|
|
||||||
delete-untagged: true
|
|
||||||
# Sweeps the wreckage of a half-failed run: an index whose platform
|
|
||||||
# images did not all land, and referrers whose parent is gone.
|
|
||||||
delete-partial-images: true
|
|
||||||
delete-orphaned-images: true
|
|
||||||
# Checks every remaining multi-architecture manifest still resolves
|
|
||||||
# in the registry. This is the step that would catch the footgun
|
|
||||||
# above rather than leaving a reader to discover it on `docker pull`.
|
|
||||||
validate: true
|
|
||||||
dry-run: ${{ inputs.dry_run }}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
# Publish the container image to GHCR.
|
|
||||||
#
|
|
||||||
# The README and the docs site have told people to run
|
|
||||||
# `ghcr.io/inbuxa/inbuxa-server:latest` for a long time, and nothing ever
|
|
||||||
# pushed it: `docker pull` answered `denied`, because the package did not
|
|
||||||
# exist. This is the workflow that makes those instructions true. It is also
|
|
||||||
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
|
|
||||||
# both install by pulling an image and neither builds from source.
|
|
||||||
#
|
|
||||||
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
|
|
||||||
# a public repository, and an anonymous `docker pull` will still answer
|
|
||||||
# `denied`. Nothing in a workflow can change that -- the visibility is set once
|
|
||||||
# by hand under the package's settings, and until it is, this looks like it
|
|
||||||
# worked while the docs stay just as wrong as before. Check with a logged-out
|
|
||||||
# pull, not with one from a machine that has credentials.
|
|
||||||
#
|
|
||||||
# Two architectures, each built on its own native runner rather than under
|
|
||||||
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
|
|
||||||
# instruction translation, which takes tens of minutes and occasionally runs
|
|
||||||
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
|
|
||||||
# the same work at native speed. The cost is the by-digest dance below: each
|
|
||||||
# runner pushes an untagged image, and a final job joins the two digests into
|
|
||||||
# one multi-arch tag.
|
|
||||||
name: Publish image
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
# Callable, so release.yml can build the release it just cut. This is not a
|
|
||||||
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
|
|
||||||
# `release` event -- GitHub refuses to let a token trigger another workflow,
|
|
||||||
# to stop a workflow looping on its own output. A scheduled job that cut a
|
|
||||||
# release and expected this file to notice would silently never publish. The
|
|
||||||
# alternatives are a personal access token kept as a secret, or calling the
|
|
||||||
# workflow directly. This is the one that needs no credential.
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
ref:
|
|
||||||
description: "Tag, branch or SHA to build"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
tag_latest:
|
|
||||||
description: "Also move :latest to this build"
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
|
|
||||||
# orphans can be neither rerun nor canceled, and this workflow otherwise
|
|
||||||
# only fires on a release -- which is not something to cut twice because a
|
|
||||||
# runner died. `ref` also allows publishing an image for a tag that predates
|
|
||||||
# this workflow, which is how the first one gets built.
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
ref:
|
|
||||||
description: "Tag, branch or SHA to build"
|
|
||||||
required: true
|
|
||||||
default: main
|
|
||||||
tag_latest:
|
|
||||||
description: "Also move :latest to this build"
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Hardcoded rather than derived from github.repository, which would have to
|
|
||||||
# be lowercased to be a legal registry path. This is the string the docs name.
|
|
||||||
IMAGE: ghcr.io/inbuxa/inbuxa-server
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# The version is read once and handed to both builds, so the two
|
|
||||||
# architectures cannot disagree about what they are. It is read from the
|
|
||||||
# macro the binary itself compiles in, which the weekly release commits
|
|
||||||
# before this runs -- so the image is tagged with the version it reports.
|
|
||||||
version:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.v.outputs.version }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
- id: v
|
|
||||||
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; }
|
|
||||||
# A date version carries nothing a Docker tag objects to, so there is
|
|
||||||
# no second, sanitized form of it here.
|
|
||||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "version $V"
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: version
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
|
||||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build and push by digest
|
|
||||||
id: push
|
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
# Attestations are off deliberately: they add manifests of their own
|
|
||||||
# to the index, and `imagetools create` below expects the two entries
|
|
||||||
# it pushed rather than four.
|
|
||||||
provenance: false
|
|
||||||
sbom: false
|
|
||||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
|
||||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
|
||||||
- name: Save the digest
|
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/digests
|
|
||||||
# The prefix is stripped here and put back in the merge job, so the
|
|
||||||
# filename is the bare hash. Leaving it on produces
|
|
||||||
# `image@sha256:sha256:...` when the reference is rebuilt.
|
|
||||||
digest="${{ steps.push.outputs.digest }}"
|
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
|
||||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
# One artifact per platform; the merge job globs them back together.
|
|
||||||
name: digest-${{ strategy.job-index }}
|
|
||||||
path: /tmp/digests/*
|
|
||||||
retention-days: 1
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
# Joins the per-architecture digests into a single tagged manifest, so
|
|
||||||
# `docker pull ghcr.io/inbuxa/inbuxa-server:<tag>` resolves on both.
|
|
||||||
publish:
|
|
||||||
needs: [version, build]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digest-*
|
|
||||||
merge-multiple: true
|
|
||||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
|
||||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Create the manifest
|
|
||||||
run: |
|
|
||||||
# Arrays rather than a string: the tags and the digest references
|
|
||||||
# have to reach docker as separate arguments, and building them by
|
|
||||||
# word-splitting an unquoted variable is the version of this that
|
|
||||||
# breaks the day a value contains a space.
|
|
||||||
tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}")
|
|
||||||
# :latest follows real releases only. A prerelease that moved it
|
|
||||||
# would hand every `:latest` deployment an unfinished build, and a
|
|
||||||
# dispatch run has to ask for it on purpose.
|
|
||||||
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
|
|
||||||
tags+=(-t "${IMAGE}:latest")
|
|
||||||
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
|
|
||||||
tags+=(-t "${IMAGE}:latest")
|
|
||||||
fi
|
|
||||||
refs=()
|
|
||||||
for f in /tmp/digests/*; do
|
|
||||||
refs+=("${IMAGE}@sha256:$(basename "$f")")
|
|
||||||
done
|
|
||||||
echo "tags: ${tags[*]}"
|
|
||||||
echo "refs: ${refs[*]}"
|
|
||||||
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
|
||||||
- name: Show what landed
|
|
||||||
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}"
|
|
||||||
|
|
||||||
# Runs only after a successful publish, because that is the only moment the
|
|
||||||
# package grows. See cleanup.yml for why this is not the obvious one-liner.
|
|
||||||
prune:
|
|
||||||
needs: publish
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
uses: ./.github/workflows/cleanup.yml
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
# Cut a release once a week, but only if there is something in it.
|
|
||||||
#
|
|
||||||
# It does nothing on a quiet week. A release with no commits in it is worse
|
|
||||||
# than no release: it moves `:latest` to an identical build, spends a version
|
|
||||||
# number, and mails everybody watching the repository about nothing.
|
|
||||||
#
|
|
||||||
# INBUXA's version is a string in crates/types/src/branding.rs, deliberately
|
|
||||||
# not in Cargo.toml so that upstream's version bumps merge without conflicts.
|
|
||||||
# So this writes it: the bump is committed to main, and the tag names that
|
|
||||||
# commit. The tree a tag points at therefore reports the version the tag
|
|
||||||
# claims, which a tag placed beside an unbumped macro cannot promise.
|
|
||||||
name: Weekly release
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Mondays, 10:07 UTC, and last of the three: INBUXA Admin and the webmail
|
|
||||||
# release ahead of the server they talk to. Staggered rather than
|
|
||||||
# simultaneous so three releases do not compete for runners, and so a bad
|
|
||||||
# Monday names one repository instead of three. GitHub runs scheduled jobs
|
|
||||||
# best-effort and can delay a run considerably, so the exact minute is not
|
|
||||||
# a promise; the odd minute keeps it off the crowded top of the hour.
|
|
||||||
#
|
|
||||||
# Note also that GitHub disables scheduled workflows in a repository with
|
|
||||||
# no activity for 60 days, which is worth checking for before assuming
|
|
||||||
# this file is broken.
|
|
||||||
- cron: "7 10 * * 1"
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
dry_run:
|
|
||||||
description: "Work out what would be released, then stop"
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
# One at a time. Two overlapping runs would race to write the same version and
|
|
||||||
# create the same tag, and the loser fails noisily for a reason that has
|
|
||||||
# nothing to do with the code.
|
|
||||||
concurrency:
|
|
||||||
group: weekly-release
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
outputs:
|
|
||||||
should_release: ${{ steps.decide.outputs.should_release }}
|
|
||||||
version: ${{ steps.decide.outputs.version }}
|
|
||||||
tag: ${{ steps.decide.outputs.tag }}
|
|
||||||
previous: ${{ steps.decide.outputs.previous }}
|
|
||||||
count: ${{ steps.decide.outputs.count }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
fetch-depth: 0
|
|
||||||
- id: decide
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# The newest published release, or empty on a repository that has
|
|
||||||
# never had one -- in which case everything counts as new. Drafts are
|
|
||||||
# excluded: an unpublished draft is not a release anybody has, so
|
|
||||||
# counting from it would hide commits that have never shipped.
|
|
||||||
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
|
|
||||||
# A tag named by a release is normally present after a full checkout,
|
|
||||||
# but a release can outlive its tag. Falling back to the whole
|
|
||||||
# history is the safe direction to be wrong in: it over-counts, which
|
|
||||||
# cuts a release that was due anyway, where under-counting would skip
|
|
||||||
# one that was.
|
|
||||||
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
|
|
||||||
count="$(git rev-list --count "${previous}..HEAD")"
|
|
||||||
else
|
|
||||||
count="$(git rev-list --count HEAD)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# INBUXA's version is the date: YYYY.M.D, unpadded, as branding.rs
|
|
||||||
# documents. A second release on one day takes a `.N` suffix,
|
|
||||||
# counting from 2, which is why this asks the tags rather than
|
|
||||||
# assuming today is free.
|
|
||||||
today="$(date -u +%Y.%-m.%-d)"
|
|
||||||
version="$today"
|
|
||||||
n=2
|
|
||||||
while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do
|
|
||||||
version="${today}.${n}"
|
|
||||||
n=$((n + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
should_release=true
|
|
||||||
reason=""
|
|
||||||
if [ "$count" -eq 0 ]; then
|
|
||||||
should_release=false
|
|
||||||
reason="no commits since ${previous}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "should_release=$should_release"
|
|
||||||
echo "version=$version"
|
|
||||||
echo "tag=v${version}"
|
|
||||||
echo "previous=$previous"
|
|
||||||
echo "count=$count"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Written to the run summary so a skipped week reads as a decision
|
|
||||||
# rather than as a workflow that quietly did nothing.
|
|
||||||
{
|
|
||||||
echo "### Weekly release"
|
|
||||||
echo
|
|
||||||
if [ "$should_release" = "true" ]; then
|
|
||||||
echo "Releasing **v${version}** — ${count} commit(s) since ${previous:-the beginning}."
|
|
||||||
else
|
|
||||||
echo "Nothing to release: ${reason}."
|
|
||||||
fi
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
cut:
|
|
||||||
needs: check
|
|
||||||
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
outputs:
|
|
||||||
sha: ${{ steps.land.outputs.sha }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
fetch-depth: 0
|
|
||||||
- id: bump
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.check.outputs.version }}
|
|
||||||
BRANCH: release/v${{ needs.check.outputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# 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, and a bump that silently
|
|
||||||
# edited one of those -- or none -- would ship a build whose version
|
|
||||||
# disagrees with its tag.
|
|
||||||
python3 - <<'PY'
|
|
||||||
import os, re
|
|
||||||
path = "crates/types/src/branding.rs"
|
|
||||||
src = open(path, encoding="utf-8").read()
|
|
||||||
pattern = re.compile(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")')
|
|
||||||
out, n = pattern.subn(lambda m: m.group(1) + os.environ["VERSION"] + m.group(2), src, count=1)
|
|
||||||
assert n == 1, f"brand_version! not found in {path}"
|
|
||||||
open(path, "w", encoding="utf-8").write(out)
|
|
||||||
PY
|
|
||||||
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add crates/types/src/branding.rs
|
|
||||||
git commit -m "Version ${VERSION}"
|
|
||||||
git push origin "HEAD:refs/heads/${BRANCH}"
|
|
||||||
|
|
||||||
# main is protected: it takes a pull request with a green build, and
|
|
||||||
# GITHUB_TOKEN is not among the bypass actors. So the bump lands the way
|
|
||||||
# every other change does. The alternative was to hand the release a
|
|
||||||
# credential that outranks the rule, which is a worse thing to own than
|
|
||||||
# a slower Monday.
|
|
||||||
- id: land
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.check.outputs.version }}
|
|
||||||
BRANCH: release/v${{ needs.check.outputs.version }}
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
url="$(gh pr create --base main --head "${BRANCH}" \
|
|
||||||
--title "Version ${VERSION}" \
|
|
||||||
--body "Weekly release. Bumps \`brand_version!\` to ${VERSION} so the tag names a tree that reports the version the tag claims.")"
|
|
||||||
# The number, not the branch: the branch is deleted on merge, and a
|
|
||||||
# deleted branch no longer resolves to its pull request.
|
|
||||||
pr="${url##*/}"
|
|
||||||
echo "Opened #${pr}"
|
|
||||||
|
|
||||||
# The build is what the rule actually requires, and it is also the
|
|
||||||
# thing worth waiting for: a release cut from a tree that does not
|
|
||||||
# compile is the failure this whole arrangement exists to prevent.
|
|
||||||
# A full build of this tree is long, so the deadline is generous.
|
|
||||||
deadline=$(( SECONDS + 3600 ))
|
|
||||||
while :; do
|
|
||||||
state="$(gh pr view "${pr}" --json statusCheckRollup \
|
|
||||||
--jq '[.statusCheckRollup[]? | .conclusion // "PENDING"] | join(",")')"
|
|
||||||
case "${state}" in
|
|
||||||
*FAILURE*|*CANCELLED*|*TIMED_OUT*)
|
|
||||||
echo "::error::CI failed on ${BRANCH} (${state}); no release cut. PR #${pr} is left open."
|
|
||||||
exit 1 ;;
|
|
||||||
*SUCCESS*) break ;;
|
|
||||||
esac
|
|
||||||
if [ "${SECONDS}" -ge "${deadline}" ]; then
|
|
||||||
echo "::error::timed out waiting for CI on ${BRANCH}. PR #${pr} is left open."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 30
|
|
||||||
done
|
|
||||||
|
|
||||||
gh pr merge "${pr}" --rebase --delete-branch
|
|
||||||
|
|
||||||
# A rebase merge rewrites the commit, so the sha to tag is the one
|
|
||||||
# GitHub recorded for the merge, not the tip that was pushed. It can
|
|
||||||
# take a moment to appear.
|
|
||||||
sha=""
|
|
||||||
for _ in $(seq 1 30); do
|
|
||||||
sha="$(gh pr view "${pr}" --json mergeCommit --jq '.mergeCommit.oid // ""')"
|
|
||||||
[ -n "${sha}" ] && break
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
if [ -z "${sha}" ]; then
|
|
||||||
echo "::error::#${pr} merged but GitHub reported no merge commit; nothing safe to tag."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
|
|
||||||
- env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
args=(--target "${{ steps.land.outputs.sha }}"
|
|
||||||
--title "INBUXA ${{ needs.check.outputs.version }}"
|
|
||||||
--generate-notes)
|
|
||||||
# Bound the notes to what is actually new. Without a start tag the
|
|
||||||
# generator reaches back to whatever it decides is previous, which on
|
|
||||||
# a repository carrying upstream's tag shapes is not always the last
|
|
||||||
# release.
|
|
||||||
if [ -n "${{ needs.check.outputs.previous }}" ]; then
|
|
||||||
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
|
|
||||||
fi
|
|
||||||
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
|
|
||||||
|
|
||||||
# Called rather than left to the `release` trigger on purpose: see the note
|
|
||||||
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
|
|
||||||
# event, so without this the tag would exist and no image would follow it.
|
|
||||||
publish:
|
|
||||||
needs: [check, cut]
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
uses: ./.github/workflows/publish.yml
|
|
||||||
with:
|
|
||||||
ref: ${{ needs.cut.outputs.sha }}
|
|
||||||
tag_latest: true
|
|
||||||
Generated
+2
@@ -7762,11 +7762,13 @@ dependencies = [
|
|||||||
"common",
|
"common",
|
||||||
"dns-update",
|
"dns-update",
|
||||||
"email",
|
"email",
|
||||||
|
"futures",
|
||||||
"groupware",
|
"groupware",
|
||||||
"hkdf 0.13.0",
|
"hkdf 0.13.0",
|
||||||
"inbuxa-features",
|
"inbuxa-features",
|
||||||
"jmap-tools",
|
"jmap-tools",
|
||||||
"jmap_proto",
|
"jmap_proto",
|
||||||
|
"mail-auth",
|
||||||
"mail-builder 1.0.0",
|
"mail-builder 1.0.0",
|
||||||
"mail-parser",
|
"mail-parser",
|
||||||
"memory-stats",
|
"memory-stats",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Development happens on [git.coffeylabs.org/inbuxa/inbuxa-server](https://git.coffeylabs.org/inbuxa/inbuxa-server); the copy on GitHub is a read-only mirror.
|
||||||
|
> Report issues at **[git.coffeylabs.org/inbuxa/inbuxa-server/issues](https://git.coffeylabs.org/inbuxa/inbuxa-server/issues)**, and join discussions at **[community.coffeylabs.org](https://community.coffeylabs.org)**.
|
||||||
|
|
||||||
**inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
|
**inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
|
||||||
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
|
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
|
||||||
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
|
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ 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://git.coffeylabs.org/inbuxa/inbuxa-admin)
|
- [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
|
||||||
- [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa)
|
- [inbuxa-webmail](https://git.coffeylabs.org/inbuxa/inbuxa-webmail)
|
||||||
|
|
||||||
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.
|
||||||
@@ -445,6 +445,63 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MA-D0a: a message sent from an address that isn't the sender's own:
|
||||||
|
/// a group's or a shared mailbox's. The message itself only says
|
||||||
|
/// `From:` that address, so the audit log is where the person who sent
|
||||||
|
/// it is named. A locked account's delegate's send is AL-9's record, not
|
||||||
|
/// this one.
|
||||||
|
pub async fn audit_send_as(
|
||||||
|
&self,
|
||||||
|
token: &AccessToken,
|
||||||
|
submission_account_id: u32,
|
||||||
|
submission_id: u32,
|
||||||
|
address: &str,
|
||||||
|
) {
|
||||||
|
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if as_account_id == token.account_id()
|
||||||
|
|| token
|
||||||
|
.delegation(as_account_id)
|
||||||
|
.is_some_and(|delegation| delegation.kind.is_lock())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let actor = self.audit_actor(token).await;
|
||||||
|
let tenant_id = self
|
||||||
|
.account(as_account_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|account| account.id_tenant);
|
||||||
|
let details = if submission_account_id == as_account_id {
|
||||||
|
format!("Sent as {address}")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Sent as {address}, from {}",
|
||||||
|
self.audit_account_name(submission_account_id).await
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.audit_note(Record {
|
||||||
|
at: ms(),
|
||||||
|
actor,
|
||||||
|
via: token.origin().cloned(),
|
||||||
|
remote_ip: None,
|
||||||
|
action: Action::Create,
|
||||||
|
target: Target {
|
||||||
|
kind: "EmailSubmission".into(),
|
||||||
|
id: Some(Id::from(submission_id).to_string()),
|
||||||
|
name: Some(address.to_string()),
|
||||||
|
account_id: Some(as_account_id),
|
||||||
|
tenant_id,
|
||||||
|
},
|
||||||
|
changes: vec![],
|
||||||
|
details: Some(details),
|
||||||
|
reason: None,
|
||||||
|
outcome: Outcome::success(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
/// AU-7: removes entries past the retention period.
|
/// AU-7: removes entries past the retention period.
|
||||||
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
||||||
let settings = log::settings(self.store()).await?;
|
let settings = log::settings(self.store()).await?;
|
||||||
|
|||||||
@@ -36,6 +36,32 @@ use utils::map::bitmap::{Bitmap, BitmapItem};
|
|||||||
use xxhash_rust::xxh3;
|
use xxhash_rust::xxh3;
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
|
/// inbuxa: MA-C: whether people in `owner`'s tenant may share their mail
|
||||||
|
/// (the server's switch, narrowed by the tenant's).
|
||||||
|
pub async fn mail_sharing_allowed(&self, owner: u32) -> trc::Result<bool> {
|
||||||
|
let tenant_id = self.account(owner).await.ok().and_then(|account| account.id_tenant);
|
||||||
|
Ok(
|
||||||
|
inbuxa_features::security::sharing_policy::effective_for(self.store(), tenant_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.mail_sharing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-C: whether `owner`'s mail shares give access now. A locked
|
||||||
|
/// account's or shared mailbox's grants are an administrator's and always
|
||||||
|
/// do; anyone else's only while their tenant allows mail sharing.
|
||||||
|
pub async fn mail_shares_honored(&self, owner: u32) -> trc::Result<bool> {
|
||||||
|
if inbuxa_features::lock::get(self.store(), owner)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
self.mail_sharing_allowed(owner).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn build_access_token(
|
async fn build_access_token(
|
||||||
&self,
|
&self,
|
||||||
account: Account,
|
account: Account,
|
||||||
@@ -46,19 +72,22 @@ impl Server {
|
|||||||
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
||||||
// locked accounts are handed to it. The token is their cache: every
|
// locked accounts are handed to it. The token is their cache: every
|
||||||
// change to a lock invalidates the tokens it touches.
|
// change to a lock invalidates the tokens it touches.
|
||||||
let locked = inbuxa_features::lock::get(self.store(), account_id)
|
let lock_kind = inbuxa_features::lock::get(self.store(), account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
.is_some();
|
.map(|lock| lock.kind);
|
||||||
|
let locked = lock_kind.is_some();
|
||||||
|
let shared_mailbox = lock_kind == Some(inbuxa_features::lock::Kind::SharedMailbox);
|
||||||
let now_secs = now();
|
let now_secs = now();
|
||||||
let delegations: Box<[super::Delegation]> =
|
let delegations: Box<[super::Delegation]> =
|
||||||
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, delegate)| delegate.is_current(now_secs))
|
.filter(|(_, delegate, _)| delegate.is_current(now_secs))
|
||||||
.map(|(locked_id, delegate)| super::Delegation {
|
.map(|(locked_id, delegate, kind)| super::Delegation {
|
||||||
account_id: locked_id,
|
account_id: locked_id,
|
||||||
|
kind,
|
||||||
access: delegate.access,
|
access: delegate.access,
|
||||||
send_as: delegate.send_as,
|
send_as: delegate.send_as,
|
||||||
until: delegate.until,
|
until: delegate.until,
|
||||||
@@ -97,6 +126,9 @@ impl Server {
|
|||||||
.map(|m| m.id() as u32)
|
.map(|m| m.id() as u32)
|
||||||
.collect::<TinyVec<[u32; 3]>>();
|
.collect::<TinyVec<[u32; 3]>>();
|
||||||
let mut access_to: Vec<AccessTo> = Vec::new();
|
let mut access_to: Vec<AccessTo> = Vec::new();
|
||||||
|
// inbuxa: MA-C: whether an owner's mail shares are honored,
|
||||||
|
// looked up once per owner
|
||||||
|
let mut mail_shares_honored: Vec<(u32, bool)> = Vec::new();
|
||||||
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
|
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
|
||||||
for acl_item in self
|
for acl_item in self
|
||||||
.store()
|
.store()
|
||||||
@@ -117,6 +149,27 @@ impl Server {
|
|||||||
.caused_by(trc::location!()));
|
.caused_by(trc::location!()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-C: a mail share from an account whose
|
||||||
|
// tenant (or server) has mail sharing off gives
|
||||||
|
// nothing while it is off. It stays stored, so it
|
||||||
|
// comes back when sharing does. A lock's and a
|
||||||
|
// shared mailbox's grants are an administrator's,
|
||||||
|
// and always count.
|
||||||
|
if collection == Collection::Mailbox {
|
||||||
|
let owner = acl_item.to_account_id;
|
||||||
|
let honored = match mail_shares_honored.iter().find(|(id, _)| *id == owner) {
|
||||||
|
Some((_, honored)) => *honored,
|
||||||
|
None => {
|
||||||
|
let honored = self.mail_shares_honored(owner).await?;
|
||||||
|
mail_shares_honored.push((owner, honored));
|
||||||
|
honored
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !honored {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut collections: Bitmap<Collection> = Bitmap::new();
|
let mut collections: Bitmap<Collection> = Bitmap::new();
|
||||||
if acl.contains(Acl::Read) {
|
if acl.contains(Acl::Read) {
|
||||||
collections.insert(collection);
|
collections.insert(collection);
|
||||||
@@ -247,6 +300,7 @@ impl Server {
|
|||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
locked,
|
locked,
|
||||||
|
shared_mailbox,
|
||||||
delegations: delegations.clone(),
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
@@ -300,6 +354,7 @@ impl Server {
|
|||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
locked,
|
locked,
|
||||||
|
shared_mailbox,
|
||||||
delegations: delegations.clone(),
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
@@ -553,6 +608,16 @@ impl AccessToken {
|
|||||||
|| self.inner.access_to.iter().any(|a| a.account_id == account_id)
|
|| self.inner.access_to.iter().any(|a| a.account_id == account_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-D0: in the account only because it is a group this token
|
||||||
|
/// belongs to. Such a member has the group's mailbox but may not share it
|
||||||
|
/// on: who is in a group is an administrator's decision, and a share
|
||||||
|
/// would let anyone in.
|
||||||
|
pub fn is_group_member_only(&self, account_id: u32) -> bool {
|
||||||
|
self.inner.account_id != account_id
|
||||||
|
&& self.inner.member_of.contains(&account_id)
|
||||||
|
&& !self.has_permission(Permission::Impersonate)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_account_id(&self, account_id: u32) -> bool {
|
pub fn is_account_id(&self, account_id: u32) -> bool {
|
||||||
self.inner.account_id == account_id
|
self.inner.account_id == account_id
|
||||||
}
|
}
|
||||||
@@ -648,6 +713,7 @@ impl AccessToken {
|
|||||||
credential_version: old_inner.credential_version,
|
credential_version: old_inner.credential_version,
|
||||||
obj_size: old_inner.obj_size,
|
obj_size: old_inner.obj_size,
|
||||||
locked: old_inner.locked,
|
locked: old_inner.locked,
|
||||||
|
shared_mailbox: old_inner.shared_mailbox,
|
||||||
delegations: old_inner.delegations.clone(),
|
delegations: old_inner.delegations.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -838,6 +904,18 @@ impl AccessToken {
|
|||||||
self.inner.locked
|
self.inner.locked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-S: the account is a shared mailbox (a lock of that kind).
|
||||||
|
pub fn is_shared_mailbox(&self) -> bool {
|
||||||
|
self.inner.shared_mailbox
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-S: this account's delegation into `account_id` is to a
|
||||||
|
/// shared mailbox, not a locked account.
|
||||||
|
pub fn delegated_shared_mailbox(&self, account_id: u32) -> bool {
|
||||||
|
self.delegation(account_id)
|
||||||
|
.is_some_and(|d| d.kind == inbuxa_features::lock::Kind::SharedMailbox)
|
||||||
|
}
|
||||||
|
|
||||||
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
||||||
/// has one that hasn't ended.
|
/// has one that hasn't ended.
|
||||||
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
||||||
@@ -918,6 +996,7 @@ impl AccessToken {
|
|||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
locked: false,
|
locked: false,
|
||||||
|
shared_mailbox: false,
|
||||||
delegations: Default::default(),
|
delegations: Default::default(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -978,6 +1057,7 @@ impl AccessTokenInner {
|
|||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
locked: false,
|
locked: false,
|
||||||
|
shared_mailbox: false,
|
||||||
delegations: Default::default(),
|
delegations: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ pub struct AccessTokenInner {
|
|||||||
pub(crate) obj_size: u64,
|
pub(crate) obj_size: u64,
|
||||||
// inbuxa: AL-2: the account is locked; it may not authenticate
|
// inbuxa: AL-2: the account is locked; it may not authenticate
|
||||||
pub(crate) locked: bool,
|
pub(crate) locked: bool,
|
||||||
|
// inbuxa: MA-S: the lock is a shared mailbox
|
||||||
|
pub(crate) shared_mailbox: bool,
|
||||||
// inbuxa: AL-5: locked accounts handed to this one
|
// inbuxa: AL-5: locked accounts handed to this one
|
||||||
pub(crate) delegations: Box<[Delegation]>,
|
pub(crate) delegations: Box<[Delegation]>,
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,8 @@ pub struct Delegation {
|
|||||||
pub send_as: bool,
|
pub send_as: bool,
|
||||||
/// Seconds since the epoch.
|
/// Seconds since the epoch.
|
||||||
pub until: Option<u64>,
|
pub until: Option<u64>,
|
||||||
|
/// MA-S: a locked account, or a shared mailbox.
|
||||||
|
pub kind: inbuxa_features::lock::Kind,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Hash, Clone)]
|
#[derive(Debug, Default, Hash, Clone)]
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ impl Server {
|
|||||||
Permission::SysLegalHoldCreate,
|
Permission::SysLegalHoldCreate,
|
||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
Permission::SysLegalHoldExport,
|
Permission::SysLegalHoldExport,
|
||||||
|
// inbuxa: DL-20: the lists and the check are the server's
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
] {
|
] {
|
||||||
permissions.disabled.set(permission as usize);
|
permissions.disabled.set(permission as usize);
|
||||||
}
|
}
|
||||||
@@ -304,6 +307,16 @@ impl Default for DefaultPermissions {
|
|||||||
default.superuser.push(permission);
|
default.superuser.push(permission);
|
||||||
default.tenant.push(permission);
|
default.tenant.push(permission);
|
||||||
}
|
}
|
||||||
|
// inbuxa: deliverability spec, DL-20: a tenant administrator
|
||||||
|
// reads its own domains' findings; the lists and the check
|
||||||
|
// itself are the server's
|
||||||
|
Permission::SysDeliverabilityGet => {
|
||||||
|
default.superuser.push(permission);
|
||||||
|
default.tenant.push(permission);
|
||||||
|
}
|
||||||
|
Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => {
|
||||||
|
default.superuser.push(permission);
|
||||||
|
}
|
||||||
// inbuxa: DLP and mail flow rules, and held mail, are the
|
// inbuxa: DLP and mail flow rules, and held mail, are the
|
||||||
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
|
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
|
||||||
// settled answer 3)
|
// settled answer 3)
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ pub struct Http {
|
|||||||
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
||||||
pub use_forwarded: bool,
|
pub use_forwarded: bool,
|
||||||
pub redirect_root: Option<String>,
|
pub redirect_root: Option<String>,
|
||||||
|
/// inbuxa: HTTP Basic accepted on every endpoint, not only DAV (contract
|
||||||
|
/// C-23). True in bootstrap and recovery mode, or with
|
||||||
|
/// `INBUXA_HTTP_BASIC_AUTH=all`.
|
||||||
|
pub basic_auth_everywhere: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -453,6 +457,35 @@ impl Http {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// inbuxa: outside DAV, HTTP sign-in is a token unless the operator
|
||||||
|
// says otherwise (contract C-23). The integration suites sign in with
|
||||||
|
// passwords over JMAP and the API, so test builds accept Basic
|
||||||
|
// everywhere.
|
||||||
|
#[cfg(feature = "test_mode")]
|
||||||
|
let basic_auth_everywhere = true;
|
||||||
|
|
||||||
|
#[cfg(not(feature = "test_mode"))]
|
||||||
|
let basic_auth_everywhere = bp.registry.is_recovery_mode()
|
||||||
|
|| bp.registry.is_bootstrap_mode()
|
||||||
|
|| match types::branding::env_var("HTTP_BASIC_AUTH") {
|
||||||
|
Ok(value) if value.trim().eq_ignore_ascii_case("all") => true,
|
||||||
|
Ok(value)
|
||||||
|
if value.trim().is_empty() || value.trim().eq_ignore_ascii_case("dav") =>
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Ok(value) => {
|
||||||
|
bp.build_warning(
|
||||||
|
ObjectType::Http.singleton(),
|
||||||
|
format!(
|
||||||
|
"INBUXA_HTTP_BASIC_AUTH is {value:?}; expected \"dav\" or \"all\". Basic authentication stays on DAV only."
|
||||||
|
),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
};
|
||||||
|
|
||||||
if use_permissive_cors {
|
if use_permissive_cors {
|
||||||
http_headers.push((
|
http_headers.push((
|
||||||
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||||
@@ -512,6 +545,7 @@ impl Http {
|
|||||||
cors_origins,
|
cors_origins,
|
||||||
use_forwarded: http.use_x_forwarded,
|
use_forwarded: http.use_x_forwarded,
|
||||||
redirect_root: http.redirect_root,
|
redirect_root: http.redirect_root,
|
||||||
|
basic_auth_everywhere,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ pub enum BroadcastEvent {
|
|||||||
QueueRefresh,
|
QueueRefresh,
|
||||||
// inbuxa: AL-3: end an account's open sessions on every node
|
// inbuxa: AL-3: end an account's open sessions on every node
|
||||||
EndSessions(u32),
|
EndSessions(u32),
|
||||||
|
// inbuxa: deliverability spec, DL-15: every node checks itself now
|
||||||
|
DeliverabilityCheck,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
//! application names another;
|
//! application names another;
|
||||||
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
|
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
|
||||||
//! is set;
|
//! is set;
|
||||||
//! - ihasmail-inbuxa, as the confidential client `ihasmail-inbuxa`, when
|
//! - inbuxa-webmail, as the confidential client `ihasmail-inbuxa`, when
|
||||||
//! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set.
|
//! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set.
|
||||||
//!
|
//!
|
||||||
//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until
|
//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
//! it instead.
|
//! it instead.
|
||||||
//!
|
//!
|
||||||
//! A missing client is created. An existing one gains any redirect URI it
|
//! A missing client is created. An existing one gains any redirect URI it
|
||||||
//! lacks and, for ihasmail-inbuxa, the configured secret; nothing an operator
|
//! lacks and, for inbuxa-webmail, the configured secret; nothing an operator
|
||||||
//! added is removed.
|
//! added is removed.
|
||||||
|
|
||||||
use directory::core::secret::{hash_secret, verify_secret_hash};
|
use directory::core::secret::{hash_secret, verify_secret_hash};
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ use types::id::Id;
|
|||||||
/// Granted to the default administrator roles: "Explain this"
|
/// Granted to the default administrator roles: "Explain this"
|
||||||
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
||||||
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
|
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
|
||||||
/// the data inventory (personal-data catalog spec), and accepting security
|
/// the data inventory (personal-data catalog spec), accepting security
|
||||||
/// to-do items (security to-do list spec).
|
/// to-do items (security to-do list spec), and the deliverability check
|
||||||
|
/// (deliverability spec).
|
||||||
const ADMIN_GRANTS: &[Permission] = &[
|
const ADMIN_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAiExplain,
|
Permission::SysAiExplain,
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
@@ -56,6 +57,9 @@ const ADMIN_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysJournalGet,
|
Permission::SysJournalGet,
|
||||||
Permission::SysJournalUpdate,
|
Permission::SysJournalUpdate,
|
||||||
Permission::SysSecurityAccept,
|
Permission::SysSecurityAccept,
|
||||||
|
Permission::SysDeliverabilityGet,
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Granted to the server-level Compliance Officer role once it exists:
|
/// Granted to the server-level Compliance Officer role once it exists:
|
||||||
@@ -73,7 +77,8 @@ const OFFICER_GRANTS: &[Permission] = &[
|
|||||||
|
|
||||||
/// Granted to the default tenant administrator roles: reading and exporting
|
/// Granted to the default tenant administrator roles: reading and exporting
|
||||||
/// the tenant's audit log (AU-9), locking and delegating its accounts
|
/// the tenant's audit log (AU-9), locking and delegating its accounts
|
||||||
/// (AL-12), and the tenant's slice of the data inventory.
|
/// (AL-12), the tenant's slice of the data inventory, and its own domains'
|
||||||
|
/// deliverability findings (DL-20).
|
||||||
const TENANT_GRANTS: &[Permission] = &[
|
const TENANT_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
Permission::SysAuditExport,
|
Permission::SysAuditExport,
|
||||||
@@ -82,6 +87,7 @@ const TENANT_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysAccountLockUpdate,
|
Permission::SysAccountLockUpdate,
|
||||||
Permission::SysAccountLockDestroy,
|
Permission::SysAccountLockDestroy,
|
||||||
Permission::SysComplianceGet,
|
Permission::SysComplianceGet,
|
||||||
|
Permission::SysDeliverabilityGet,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -104,14 +104,31 @@ impl StoredMetric {
|
|||||||
pub fn timestamp(&self) -> u64 {
|
pub fn timestamp(&self) -> u64 {
|
||||||
SnowflakeIdGenerator::to_timestamp(self.id)
|
SnowflakeIdGenerator::to_timestamp(self.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The node that wrote the sample. Histogram totals are per node, so a
|
||||||
|
/// reader diffs them per node.
|
||||||
|
pub fn node_id(&self) -> u64 {
|
||||||
|
SnowflakeIdGenerator::to_node_id(self.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the node wrote last, so counters and histograms are written as
|
/// What the node wrote last, so counters and histograms are written as
|
||||||
/// changes (MON-4). Per process: a restart counts from the start.
|
/// changes (MON-4). Per process: a restart counts from the start.
|
||||||
static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None);
|
static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None);
|
||||||
|
|
||||||
/// One tick's samples (MON-4 to MON-6).
|
/// Gauges that count the whole cluster's data, not this node's. Only the node
|
||||||
pub fn sample() -> Vec<Metric> {
|
/// that computes them (the metrics-calculation role) has a true reading; on
|
||||||
|
/// the others the queue gauge only moves with local queue events and drifts
|
||||||
|
/// below zero, and the account and domain counts stay at 0.
|
||||||
|
const CLUSTER_GAUGES: [MetricType; 3] = [
|
||||||
|
MetricType::QueueCount,
|
||||||
|
MetricType::UserCount,
|
||||||
|
MetricType::DomainCount,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// One tick's samples (MON-4 to MON-6). `calculates` is whether this node
|
||||||
|
/// computes the cluster-wide gauges; a node that doesn't leaves them out.
|
||||||
|
pub fn sample(calculates: bool) -> Vec<Metric> {
|
||||||
let mut last_guard = LAST.lock().unwrap();
|
let mut last_guard = LAST.lock().unwrap();
|
||||||
let last = last_guard.get_or_insert_with(AHashMap::new);
|
let last = last_guard.get_or_insert_with(AHashMap::new);
|
||||||
let mut samples = Vec::new();
|
let mut samples = Vec::new();
|
||||||
@@ -134,6 +151,9 @@ pub fn sample() -> Vec<Metric> {
|
|||||||
|
|
||||||
// Gauges: the reading, always (MON-5)
|
// Gauges: the reading, always (MON-5)
|
||||||
for gauge in Collector::collect_gauges() {
|
for gauge in Collector::collect_gauges() {
|
||||||
|
if !calculates && CLUSTER_GAUGES.contains(&gauge.id()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
samples.push(Metric::Gauge(MetricCount {
|
samples.push(Metric::Gauge(MetricCount {
|
||||||
count: gauge.get(),
|
count: gauge.get(),
|
||||||
metric: gauge.id(),
|
metric: gauge.id(),
|
||||||
@@ -175,7 +195,7 @@ impl Server {
|
|||||||
if store.is_none() {
|
if store.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let samples = sample();
|
let samples = sample(self.core.network.roles.metrics_calculate);
|
||||||
let count = samples.len();
|
let count = samples.len();
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
match store.write_metrics(samples, now()).await {
|
match store.write_metrics(samples, now()).await {
|
||||||
@@ -265,3 +285,41 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn gauges(samples: &[Metric]) -> Vec<MetricType> {
|
||||||
|
samples
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| match m {
|
||||||
|
Metric::Gauge(g) => Some(g.metric),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_calculating_node_stores_cluster_gauges() {
|
||||||
|
let all = gauges(&sample(true));
|
||||||
|
let local = gauges(&sample(false));
|
||||||
|
for metric in CLUSTER_GAUGES {
|
||||||
|
assert!(
|
||||||
|
all.contains(&metric),
|
||||||
|
"{metric:?} missing on the calculating node"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!local.contains(&metric),
|
||||||
|
"{metric:?} stored by a node that doesn't compute it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Per-node gauges are stored either way
|
||||||
|
for metric in [MetricType::ServerMemory, MetricType::HttpActiveConnections] {
|
||||||
|
assert!(
|
||||||
|
all.contains(&metric) && local.contains(&metric),
|
||||||
|
"{metric:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,10 @@ impl DavAclHandler for Server {
|
|||||||
{
|
{
|
||||||
return Err(DavError::Code(StatusCode::FORBIDDEN));
|
return Err(DavError::Code(StatusCode::FORBIDDEN));
|
||||||
}
|
}
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if access_token.is_group_member_only(account_id) {
|
||||||
|
return Err(DavError::Code(StatusCode::FORBIDDEN));
|
||||||
|
}
|
||||||
|
|
||||||
// Validate ACEs
|
// Validate ACEs
|
||||||
let grants = self
|
let grants = self
|
||||||
@@ -565,7 +569,13 @@ impl Privileges for AccessToken {
|
|||||||
grants: &ArchivedVec<ArchivedAclGrant>,
|
grants: &ArchivedVec<ArchivedAclGrant>,
|
||||||
is_calendar: bool,
|
is_calendar: bool,
|
||||||
) -> Vec<Privilege> {
|
) -> Vec<Privilege> {
|
||||||
if self.is_member(account_id) {
|
if self.is_group_member_only(account_id) {
|
||||||
|
// inbuxa: MA-D0: everything but sharing it on.
|
||||||
|
Privilege::all(is_calendar)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|privilege| !matches!(privilege, Privilege::All | Privilege::WriteAcl))
|
||||||
|
.collect()
|
||||||
|
} else if self.is_member(account_id) {
|
||||||
Privilege::all(is_calendar)
|
Privilege::all(is_calendar)
|
||||||
} else {
|
} else {
|
||||||
current_user_privilege_set(grants.effective_acl(self))
|
current_user_privilege_set(grants.effective_acl(self))
|
||||||
|
|||||||
@@ -290,7 +290,11 @@ impl SieveScriptIngest for Server {
|
|||||||
// inbuxa: AL-4: a locked account answers no sender, so a
|
// inbuxa: AL-4: a locked account answers no sender, so a
|
||||||
// rejection is kept instead; sieve has already cleared
|
// rejection is kept instead; sieve has already cleared
|
||||||
// the implicit keep, so it is filed here
|
// the implicit keep, so it is filed here
|
||||||
Event::Reject { .. } if access_token.is_locked() => {
|
// A shared mailbox (MA-S) is a role address and answers
|
||||||
|
// as one: its Sieve script runs as written
|
||||||
|
Event::Reject { .. }
|
||||||
|
if access_token.is_locked() && !access_token.is_shared_mailbox() =>
|
||||||
|
{
|
||||||
if let Some(message) = messages.get_mut(0)
|
if let Some(message) = messages.get_mut(0)
|
||||||
&& !message.file_into.contains(&INBOX_ID)
|
&& !message.file_into.contains(&INBOX_ID)
|
||||||
{
|
{
|
||||||
@@ -403,7 +407,11 @@ impl SieveScriptIngest for Server {
|
|||||||
// inbuxa: AL-4: a locked account sends nothing on its
|
// inbuxa: AL-4: a locked account sends nothing on its
|
||||||
// own: no redirect, vacation reply or notification. An
|
// own: no redirect, vacation reply or notification. An
|
||||||
// unsent redirect leaves the message to be kept.
|
// unsent redirect leaves the message to be kept.
|
||||||
Event::SendMessage { .. } if access_token.is_locked() => {
|
// A shared mailbox's acknowledgements and redirects go
|
||||||
|
// out (MA-S).
|
||||||
|
Event::SendMessage { .. }
|
||||||
|
if access_token.is_locked() && !access_token.is_shared_mailbox() =>
|
||||||
|
{
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Sieve(SieveEvent::ActionReject),
|
Sieve(SieveEvent::ActionReject),
|
||||||
Details = "Account is locked: nothing is sent",
|
Details = "Account is locked: nothing is sent",
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The blocklists a node asks about itself (deliverability spec, DL-6), and
|
||||||
|
//! how to read each one's answer.
|
||||||
|
//!
|
||||||
|
//! A list answers with an address in 127.0.0.0/8. Each list says which of
|
||||||
|
//! those mean "listed" and which mean "I won't answer you": Spamhaus, for
|
||||||
|
//! one, answers `127.255.255.254` to a query that came through a public
|
||||||
|
//! resolver. A refusal is never read as a listing (DL-4).
|
||||||
|
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Scope {
|
||||||
|
/// Looked up by the reversed address: `2.0.0.127.zen.spamhaus.org`.
|
||||||
|
Ip,
|
||||||
|
/// Looked up by name: `example.org.dbl.spamhaus.org`.
|
||||||
|
Domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct BlockList {
|
||||||
|
/// What the page and the settings call it.
|
||||||
|
pub name: &'static str,
|
||||||
|
pub zone: &'static str,
|
||||||
|
pub scope: Scope,
|
||||||
|
/// Where an administrator looks the address up and asks for removal.
|
||||||
|
pub lookup: &'static str,
|
||||||
|
/// Something the page says beside the list.
|
||||||
|
pub note: Option<&'static str>,
|
||||||
|
read: fn(Ipv4Addr) -> Answer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a list's answer means.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Answer {
|
||||||
|
Listed(&'static str),
|
||||||
|
/// The list won't answer this resolver, or not now.
|
||||||
|
Refused(&'static str),
|
||||||
|
/// A code the list doesn't define: neither listed nor clean.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlockList {
|
||||||
|
pub fn read(&self, answer: Ipv4Addr) -> Answer {
|
||||||
|
(self.read)(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name to look up for `subject`, or None when the subject doesn't
|
||||||
|
/// suit the list (a domain on an IP list, or an IPv6 address: none of
|
||||||
|
/// these lists publish IPv6 zones worth asking).
|
||||||
|
pub fn query(&self, subject: &Subject<'_>) -> Option<String> {
|
||||||
|
match (self.scope, subject) {
|
||||||
|
(Scope::Ip, Subject::Ip(IpAddr::V4(ip))) => {
|
||||||
|
let [a, b, c, d] = ip.octets();
|
||||||
|
Some(format!("{d}.{c}.{b}.{a}.{}.", self.zone))
|
||||||
|
}
|
||||||
|
(Scope::Domain, Subject::Domain(domain)) => {
|
||||||
|
Some(format!("{}.{}.", domain.trim_end_matches('.'), self.zone))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Subject<'x> {
|
||||||
|
Ip(IpAddr),
|
||||||
|
Domain(&'x str),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spamhaus' error codes, the same on every Spamhaus zone.
|
||||||
|
fn spamhaus_refusal(ip: Ipv4Addr) -> Option<Answer> {
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 255, 255, 252] => Some(Answer::Refused("The query was malformed")),
|
||||||
|
[127, 255, 255, 254] => Some(Answer::Refused(
|
||||||
|
"Spamhaus doesn't answer public resolvers; use the server's own",
|
||||||
|
)),
|
||||||
|
[127, 255, 255, 255] => Some(Answer::Refused("Too many queries from this resolver")),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zen(ip: Ipv4Addr) -> Answer {
|
||||||
|
if let Some(refused) = spamhaus_refusal(ip) {
|
||||||
|
return refused;
|
||||||
|
}
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 0, 0, 2] => Answer::Listed("SBL: a known spam source"),
|
||||||
|
[127, 0, 0, 3] => Answer::Listed("CSS: sent spam recently"),
|
||||||
|
[127, 0, 0, 4..=7] => Answer::Listed("XBL: a compromised or infected host"),
|
||||||
|
[127, 0, 0, 9] => Answer::Listed("DROP: a hijacked or criminal network"),
|
||||||
|
[127, 0, 0, 10 | 11] => {
|
||||||
|
Answer::Listed("PBL: an address that isn't meant to send mail directly")
|
||||||
|
}
|
||||||
|
_ => Answer::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dbl(ip: Ipv4Addr) -> Answer {
|
||||||
|
if let Some(refused) = spamhaus_refusal(ip) {
|
||||||
|
return refused;
|
||||||
|
}
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 0, 1, 2] => Answer::Listed("A spam domain"),
|
||||||
|
[127, 0, 1, 4] => Answer::Listed("A phishing domain"),
|
||||||
|
[127, 0, 1, 5] => Answer::Listed("A malware domain"),
|
||||||
|
[127, 0, 1, 6] => Answer::Listed("A botnet controller"),
|
||||||
|
[127, 0, 1, 102..=106] => Answer::Listed("A legitimate domain being abused"),
|
||||||
|
[127, 0, 1, 255] => Answer::Refused("The query was malformed"),
|
||||||
|
_ => Answer::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most lists answer 127.0.0.2 for "listed" and define nothing else.
|
||||||
|
fn just_two(ip: Ipv4Addr) -> Answer {
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 0, 0, 2] => Answer::Listed("Listed"),
|
||||||
|
_ => Answer::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn surbl(ip: Ipv4Addr) -> Answer {
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 0, 0, 1] => Answer::Refused("SURBL doesn't answer this resolver"),
|
||||||
|
[127, 0, 0, bits] if bits & (8 | 16 | 64 | 128) != 0 => {
|
||||||
|
Answer::Listed("Seen in phishing, malware, abuse or cracked sites")
|
||||||
|
}
|
||||||
|
_ => Answer::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uribl(ip: Ipv4Addr) -> Answer {
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 0, 0, 1] => Answer::Refused("URIBL doesn't answer public resolvers"),
|
||||||
|
[127, 0, 0, bits] if bits & (2 | 8) != 0 => Answer::Listed("Seen in spam"),
|
||||||
|
[127, 0, 0, bits] if bits & 4 != 0 => {
|
||||||
|
Answer::Listed("Grey: seen in bulk mail some people don't want")
|
||||||
|
}
|
||||||
|
_ => Answer::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const LISTS: &[BlockList] = &[
|
||||||
|
BlockList {
|
||||||
|
name: "Spamhaus ZEN",
|
||||||
|
zone: "zen.spamhaus.org",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://check.spamhaus.org/",
|
||||||
|
note: None,
|
||||||
|
read: zen,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "SpamCop",
|
||||||
|
zone: "bl.spamcop.net",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://www.spamcop.net/bl.shtml",
|
||||||
|
note: None,
|
||||||
|
read: just_two,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "Barracuda",
|
||||||
|
zone: "b.barracudacentral.org",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://www.barracudacentral.org/lookups",
|
||||||
|
note: Some(
|
||||||
|
"Barracuda answers only resolvers whose address is registered with it (free, at barracudacentral.org/rbl). Until then its lookups can't be checked.",
|
||||||
|
),
|
||||||
|
read: just_two,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "UCEPROTECT level 1",
|
||||||
|
zone: "dnsbl-1.uceprotect.net",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://www.uceprotect.net/en/rblcheck.php",
|
||||||
|
note: None,
|
||||||
|
read: just_two,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "Mailspike",
|
||||||
|
zone: "bl.mailspike.net",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://mailspike.org/iplookup.html",
|
||||||
|
note: None,
|
||||||
|
read: just_two,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "PSBL",
|
||||||
|
zone: "psbl.surriel.com",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://psbl.org/",
|
||||||
|
note: None,
|
||||||
|
read: just_two,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "Spamhaus DBL",
|
||||||
|
zone: "dbl.spamhaus.org",
|
||||||
|
scope: Scope::Domain,
|
||||||
|
lookup: "https://check.spamhaus.org/",
|
||||||
|
note: None,
|
||||||
|
read: dbl,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "SURBL",
|
||||||
|
zone: "multi.surbl.org",
|
||||||
|
scope: Scope::Domain,
|
||||||
|
lookup: "https://surbl.org/surbl-analysis",
|
||||||
|
note: None,
|
||||||
|
read: surbl,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "URIBL",
|
||||||
|
zone: "multi.uribl.com",
|
||||||
|
scope: Scope::Domain,
|
||||||
|
lookup: "https://admin.uribl.com/",
|
||||||
|
note: None,
|
||||||
|
read: uribl,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn by_name(name: &str) -> Option<&'static BlockList> {
|
||||||
|
LISTS.iter().find(|list| list.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ip(s: &str) -> Ipv4Addr {
|
||||||
|
s.parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refusal_is_not_a_listing() {
|
||||||
|
let zen = by_name("Spamhaus ZEN").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
zen.read(ip("127.255.255.254")),
|
||||||
|
Answer::Refused(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(zen.read(ip("127.0.0.2")), Answer::Listed(_)));
|
||||||
|
assert!(matches!(zen.read(ip("127.0.0.10")), Answer::Listed(_)));
|
||||||
|
assert_eq!(zen.read(ip("127.0.0.200")), Answer::Unknown);
|
||||||
|
|
||||||
|
let uribl = by_name("URIBL").unwrap();
|
||||||
|
assert!(matches!(uribl.read(ip("127.0.0.1")), Answer::Refused(_)));
|
||||||
|
assert!(matches!(uribl.read(ip("127.0.0.2")), Answer::Listed(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queries_are_built_per_scope() {
|
||||||
|
let zen = by_name("Spamhaus ZEN").unwrap();
|
||||||
|
let dbl = by_name("Spamhaus DBL").unwrap();
|
||||||
|
let v4 = Subject::Ip("192.0.2.10".parse().unwrap());
|
||||||
|
let v6 = Subject::Ip("2001:db8::1".parse().unwrap());
|
||||||
|
let domain = Subject::Domain("example.org");
|
||||||
|
assert_eq!(
|
||||||
|
zen.query(&v4).as_deref(),
|
||||||
|
Some("10.2.0.192.zen.spamhaus.org.")
|
||||||
|
);
|
||||||
|
assert_eq!(zen.query(&v6), None);
|
||||||
|
assert_eq!(zen.query(&domain), None);
|
||||||
|
assert_eq!(
|
||||||
|
dbl.query(&domain).as_deref(),
|
||||||
|
Some("example.org.dbl.spamhaus.org.")
|
||||||
|
);
|
||||||
|
assert_eq!(dbl.query(&v4), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_are_unique() {
|
||||||
|
for (i, a) in LISTS.iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
LISTS[i + 1..].iter().all(|b| b.name != a.name),
|
||||||
|
"{}",
|
||||||
|
a.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The deliverability check (deliverability spec): what other mail servers
|
||||||
|
//! see when this one sends. Not a rebuild of anything upstream ships.
|
||||||
|
//!
|
||||||
|
//! Every node that sends mail checks itself, because only it knows which
|
||||||
|
//! address it leaves from, and keeps one report. The report holds facts: an
|
||||||
|
//! address's reverse DNS, what each blocklist answered, what SPF said for
|
||||||
|
//! each address, whether a DKIM key in DNS matches the one signing. The
|
||||||
|
//! console grades them, so its wording can change without a server release.
|
||||||
|
//!
|
||||||
|
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
||||||
|
//! with `D`, then one byte for the kind:
|
||||||
|
//!
|
||||||
|
//! - `r` + node id (u64): that node's last report, as JSON.
|
||||||
|
//! - `s`: the settings, as JSON.
|
||||||
|
//!
|
||||||
|
//! Numbers are big-endian.
|
||||||
|
|
||||||
|
pub mod lists;
|
||||||
|
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use store::{
|
||||||
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
const FEATURE: u8 = b'D';
|
||||||
|
const KIND_REPORT: u8 = b'r';
|
||||||
|
const KIND_SETTINGS: u8 = b's';
|
||||||
|
|
||||||
|
/// DL-15: **Check now** runs a node again only this long after its last run.
|
||||||
|
pub const MIN_INTERVAL_SECS: u64 = 600;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Report {
|
||||||
|
/// The node's cluster id, as metric samples carry it.
|
||||||
|
pub node_id: u64,
|
||||||
|
pub hostname: String,
|
||||||
|
/// Seconds since the epoch.
|
||||||
|
pub checked_at: u64,
|
||||||
|
pub addresses: Vec<Address>,
|
||||||
|
pub domains: Vec<DomainReport>,
|
||||||
|
pub certificates: Vec<Certificate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Address {
|
||||||
|
pub ip: String,
|
||||||
|
/// DL-2: how the node came by the address.
|
||||||
|
pub source: AddressSource,
|
||||||
|
/// The connection strategy that sends from it.
|
||||||
|
pub strategy: String,
|
||||||
|
/// The name the node greets with from this address.
|
||||||
|
pub ehlo: String,
|
||||||
|
/// The PTR names, empty when there's none.
|
||||||
|
pub ptr: Vec<String>,
|
||||||
|
/// Some PTR name resolves back to the address.
|
||||||
|
pub forward_confirmed: bool,
|
||||||
|
/// The forward-confirmed name is the EHLO name.
|
||||||
|
pub ehlo_matches: bool,
|
||||||
|
/// Set when the reverse lookup itself failed, rather than found nothing.
|
||||||
|
pub ptr_error: Option<String>,
|
||||||
|
pub listings: Vec<Listing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum AddressSource {
|
||||||
|
/// Set in the connection strategy's source addresses.
|
||||||
|
#[default]
|
||||||
|
Configured,
|
||||||
|
/// What the EHLO name resolves to.
|
||||||
|
Ehlo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Listing {
|
||||||
|
/// The list's name, as in [`lists::LISTS`].
|
||||||
|
pub list: String,
|
||||||
|
pub state: ListingState,
|
||||||
|
/// The address the list answered, when it answered one.
|
||||||
|
pub code: Option<String>,
|
||||||
|
/// What the list says the answer means.
|
||||||
|
pub meaning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ListingState {
|
||||||
|
#[default]
|
||||||
|
Clean,
|
||||||
|
Listed,
|
||||||
|
/// The list wouldn't answer, or the lookup failed: neither listed nor clean.
|
||||||
|
Refused,
|
||||||
|
Error,
|
||||||
|
/// Switched off in the settings, so not asked.
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct DomainReport {
|
||||||
|
pub domain: String,
|
||||||
|
/// DL-20: a tenant administrator sees only their tenant's domains.
|
||||||
|
pub tenant_id: Option<u32>,
|
||||||
|
/// DL-7: what SPF says for each of the node's addresses.
|
||||||
|
pub spf: Vec<SpfResult>,
|
||||||
|
/// DL-8: each DKIM key the domain signs with.
|
||||||
|
pub dkim: Vec<DkimKey>,
|
||||||
|
/// DL-9: the DMARC record, if there's one.
|
||||||
|
pub dmarc: Option<Dmarc>,
|
||||||
|
/// DL-10.
|
||||||
|
pub mta_sts: MtaSts,
|
||||||
|
/// DL-11: there's a `_smtp._tls` record.
|
||||||
|
pub tls_rpt: bool,
|
||||||
|
/// DL-12.
|
||||||
|
pub listings: Vec<Listing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct SpfResult {
|
||||||
|
pub ip: String,
|
||||||
|
/// `pass`, `fail`, `softFail`, `neutral`, `none`, `tempError` or `permError`.
|
||||||
|
pub result: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct DkimKey {
|
||||||
|
pub selector: String,
|
||||||
|
pub state: DkimState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum DkimState {
|
||||||
|
#[default]
|
||||||
|
Matches,
|
||||||
|
/// Nothing published at `<selector>._domainkey.<domain>`.
|
||||||
|
Missing,
|
||||||
|
/// Published, but a different key.
|
||||||
|
Different,
|
||||||
|
/// The lookup failed.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Dmarc {
|
||||||
|
/// `none`, `quarantine` or `reject`.
|
||||||
|
pub policy: String,
|
||||||
|
/// DKIM alignment: `relaxed` or `strict`.
|
||||||
|
pub adkim: String,
|
||||||
|
/// SPF alignment: `relaxed` or `strict`.
|
||||||
|
pub aspf: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct MtaSts {
|
||||||
|
/// The `_mta-sts` record's id; None when there's no record.
|
||||||
|
pub record_id: Option<String>,
|
||||||
|
/// The policy was fetched and parsed. False with a record means the
|
||||||
|
/// fetch or the parse failed, and `error` says why.
|
||||||
|
pub fetched: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// `enforce`, `testing` or `none`.
|
||||||
|
pub mode: Option<String>,
|
||||||
|
pub max_age: Option<u64>,
|
||||||
|
/// The domain's MX names no `mx:` line matches.
|
||||||
|
pub mx_not_covered: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Certificate {
|
||||||
|
/// The EHLO name, or an MX name that points at this node.
|
||||||
|
pub name: String,
|
||||||
|
/// The node holds a certificate for the name.
|
||||||
|
pub covered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// DL-6: lists not to ask, by name.
|
||||||
|
pub disabled_lists: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub fn is_off(&self, list: &str) -> bool {
|
||||||
|
self.disabled_lists.iter().any(|name| name == list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the built-in lists' names, once each.
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
for (i, name) in self.disabled_lists.iter().enumerate() {
|
||||||
|
if lists::by_name(name).is_none() {
|
||||||
|
return Err(format!("There's no list called {name:?}."));
|
||||||
|
}
|
||||||
|
if self.disabled_lists[..i].contains(name) {
|
||||||
|
return Err(format!("{name:?} is named twice."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Report {
|
||||||
|
/// DL-20: what a tenant administrator may see: their tenant's domains
|
||||||
|
/// and nothing about the node's addresses or certificates.
|
||||||
|
pub fn for_tenant(&self, tenant_id: u32) -> Report {
|
||||||
|
Report {
|
||||||
|
node_id: self.node_id,
|
||||||
|
hostname: self.hostname.clone(),
|
||||||
|
checked_at: self.checked_at,
|
||||||
|
addresses: Vec::new(),
|
||||||
|
domains: self
|
||||||
|
.domains
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.tenant_id == Some(tenant_id))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
certificates: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Storage --------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Json<T>(T);
|
||||||
|
|
||||||
|
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||||
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
serde_json::to_vec(&self.0).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to serialize deliverability data")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: for<'de> SerdeDeserialize<'de> + Send + Sync> Deserialize for Json<T> {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.into_err()
|
||||||
|
.details("Invalid deliverability data")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(kind: u8, node_id: Option<u64>) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(10);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(kind);
|
||||||
|
if let Some(node_id) = node_id {
|
||||||
|
key.extend_from_slice(&node_id.to_be_bytes());
|
||||||
|
}
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn report(data: &Store, node_id: u64) -> trc::Result<Option<Report>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Report>>(ValueKey::from(class(KIND_REPORT, Some(node_id))))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(report)| report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every node's report, by node id.
|
||||||
|
pub async fn reports(data: &Store) -> trc::Result<Vec<Report>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(
|
||||||
|
ValueKey::from(class(KIND_REPORT, Some(0))),
|
||||||
|
ValueKey::from(class(KIND_REPORT, Some(u64::MAX))),
|
||||||
|
),
|
||||||
|
|_, value| {
|
||||||
|
if let Ok(Json(report)) = Json::<Report>::deserialize(value) {
|
||||||
|
out.push(report);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
out.sort_by_key(|r| r.node_id);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the node's report.
|
||||||
|
pub async fn put_report(data: &Store, report: &Report) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(
|
||||||
|
class(KIND_REPORT, Some(report.node_id)),
|
||||||
|
Json(report).serialize()?,
|
||||||
|
);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn settings(data: &Store) -> trc::Result<Settings> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Settings>>(ValueKey::from(class(KIND_SETTINGS, None)))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(settings)| settings)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_settings(data: &Store, settings: &Settings) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(class(KIND_SETTINGS, None), Json(settings).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_name_only_built_in_lists_once() {
|
||||||
|
let ok = Settings {
|
||||||
|
disabled_lists: vec!["Barracuda".into(), "URIBL".into()],
|
||||||
|
};
|
||||||
|
assert!(ok.validate().is_ok());
|
||||||
|
assert!(ok.is_off("Barracuda"));
|
||||||
|
assert!(!ok.is_off("SpamCop"));
|
||||||
|
let unknown = Settings {
|
||||||
|
disabled_lists: vec!["My list".into()],
|
||||||
|
};
|
||||||
|
assert!(unknown.validate().is_err());
|
||||||
|
let twice = Settings {
|
||||||
|
disabled_lists: vec!["URIBL".into(), "URIBL".into()],
|
||||||
|
};
|
||||||
|
assert!(twice.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tenant_sees_only_its_domains() {
|
||||||
|
let report = Report {
|
||||||
|
node_id: 2,
|
||||||
|
hostname: "mx2.example.org".into(),
|
||||||
|
checked_at: 1,
|
||||||
|
addresses: vec![Address {
|
||||||
|
ip: "192.0.2.10".into(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
domains: vec![
|
||||||
|
DomainReport {
|
||||||
|
domain: "a.example".into(),
|
||||||
|
tenant_id: Some(7),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
DomainReport {
|
||||||
|
domain: "b.example".into(),
|
||||||
|
tenant_id: Some(8),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
DomainReport {
|
||||||
|
domain: "server.example".into(),
|
||||||
|
tenant_id: None,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
certificates: vec![Certificate {
|
||||||
|
name: "mx2.example.org".into(),
|
||||||
|
covered: true,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let seen = report.for_tenant(7);
|
||||||
|
assert!(seen.addresses.is_empty());
|
||||||
|
assert!(seen.certificates.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
seen.domains
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.domain.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["a.example"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_report_reads_back_with_missing_fields() {
|
||||||
|
let report: Report = serde_json::from_str(r#"{"nodeId": 3}"#).unwrap();
|
||||||
|
assert_eq!(report.node_id, 3);
|
||||||
|
assert!(report.domains.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod branding;
|
pub mod branding;
|
||||||
|
pub mod deliverability; // inbuxa: the deliverability check (not a rebuild)
|
||||||
pub mod hold;
|
pub mod hold;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
|
|||||||
@@ -66,6 +66,53 @@ const KIND_DELEGATE: u8 = b'd';
|
|||||||
/// Most delegates one lock may have (AL-5).
|
/// Most delegates one lock may have (AL-5).
|
||||||
pub const MAX_DELEGATES: usize = 10;
|
pub const MAX_DELEGATES: usize = 10;
|
||||||
|
|
||||||
|
/// Most people one shared mailbox may have (MA-S): a help desk is bigger
|
||||||
|
/// than the handful a departed colleague's mail is handed to.
|
||||||
|
pub const MAX_SHARED_MAILBOX_DELEGATES: usize = 100;
|
||||||
|
|
||||||
|
/// What a lock is for (multi-account spec, MA-S).
|
||||||
|
///
|
||||||
|
/// Both kinds keep receiving mail, can't be signed in to, and are opened by
|
||||||
|
/// delegates through real grants. A shared mailbox is a role address such
|
||||||
|
/// as support@: it needs no reason, holds more people, runs its own Sieve
|
||||||
|
/// replies (an automatic acknowledgement), records only what is sent as it,
|
||||||
|
/// and may only send as its own addresses.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Kind {
|
||||||
|
#[default]
|
||||||
|
Lock,
|
||||||
|
SharedMailbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Kind::Lock => "lock",
|
||||||
|
Kind::SharedMailbox => "sharedMailbox",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"lock" => Some(Kind::Lock),
|
||||||
|
"sharedMailbox" => Some(Kind::SharedMailbox),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_lock(&self) -> bool {
|
||||||
|
matches!(self, Kind::Lock)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_delegates(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Kind::Lock => MAX_DELEGATES,
|
||||||
|
Kind::SharedMailbox => MAX_SHARED_MAILBOX_DELEGATES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What a delegate may do in the locked account (AL-6).
|
/// What a delegate may do in the locked account (AL-6).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -189,6 +236,9 @@ pub struct Replaced {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Lock {
|
pub struct Lock {
|
||||||
pub account_id: u32,
|
pub account_id: u32,
|
||||||
|
/// Absent on locks written before shared mailboxes existed: a lock.
|
||||||
|
#[serde(default, skip_serializing_if = "Kind::is_lock")]
|
||||||
|
pub kind: Kind,
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
/// Seconds since the epoch.
|
/// Seconds since the epoch.
|
||||||
pub locked_at: u64,
|
pub locked_at: u64,
|
||||||
@@ -401,8 +451,9 @@ pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
|
|||||||
Ok(locks)
|
Ok(locks)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The accounts delegated to `delegate`, with its delegation in each.
|
/// The accounts delegated to `delegate`, with its delegation in each and
|
||||||
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> {
|
/// the kind of lock it is in.
|
||||||
|
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate, Kind)>> {
|
||||||
let mut locked = Vec::new();
|
let mut locked = Vec::new();
|
||||||
data.iterate(
|
data.iterate(
|
||||||
IterateParams::new(
|
IterateParams::new(
|
||||||
@@ -425,7 +476,7 @@ pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32,
|
|||||||
if let Some(lock) = get(data, account_id).await?
|
if let Some(lock) = get(data, account_id).await?
|
||||||
&& let Some(delegation) = lock.delegate(delegate)
|
&& let Some(delegation) = lock.delegate(delegate)
|
||||||
{
|
{
|
||||||
delegations.push((account_id, delegation.clone()));
|
delegations.push((account_id, delegation.clone(), lock.kind));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(delegations)
|
Ok(delegations)
|
||||||
@@ -472,6 +523,22 @@ pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kind_reads_back_and_defaults_to_lock() {
|
||||||
|
// MA-S: a lock stored before shared mailboxes existed has no kind
|
||||||
|
let stored = r#"{"accountId":1,"reason":"r","lockedAt":0,"lockedBy":"admin","delegates":[]}"#;
|
||||||
|
let lock: Lock = serde_json::from_str(stored).unwrap();
|
||||||
|
assert_eq!(lock.kind, Kind::Lock);
|
||||||
|
assert!(!serde_json::to_string(&lock).unwrap().contains("kind"), "a lock is written as before");
|
||||||
|
|
||||||
|
let shared = Lock { kind: Kind::SharedMailbox, ..lock };
|
||||||
|
let written = serde_json::to_string(&shared).unwrap();
|
||||||
|
assert!(written.contains(r#""kind":"sharedMailbox""#), "{written}");
|
||||||
|
assert_eq!(serde_json::from_str::<Lock>(&written).unwrap().kind, Kind::SharedMailbox);
|
||||||
|
assert_eq!(Kind::parse("sharedMailbox"), Some(Kind::SharedMailbox));
|
||||||
|
assert_eq!(Kind::SharedMailbox.max_delegates(), MAX_SHARED_MAILBOX_DELEGATES);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keys_read_back() {
|
fn keys_read_back() {
|
||||||
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
|
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
|
||||||
@@ -509,6 +576,7 @@ mod tests {
|
|||||||
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
|
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
|
||||||
Lock {
|
Lock {
|
||||||
account_id: 1,
|
account_id: 1,
|
||||||
|
kind: Kind::Lock,
|
||||||
reason: "r".into(),
|
reason: "r".into(),
|
||||||
locked_at: 0,
|
locked_at: 0,
|
||||||
locked_by: "admin".into(),
|
locked_by: "admin".into(),
|
||||||
@@ -623,6 +691,7 @@ mod tests {
|
|||||||
fn expired_delegations_grant_nothing() {
|
fn expired_delegations_grant_nothing() {
|
||||||
let lock = Lock {
|
let lock = Lock {
|
||||||
account_id: 1,
|
account_id: 1,
|
||||||
|
kind: Kind::Lock,
|
||||||
reason: "Left the company".into(),
|
reason: "Left the company".into(),
|
||||||
locked_at: 100,
|
locked_at: 100,
|
||||||
locked_by: "admin".into(),
|
locked_by: "admin".into(),
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ pub mod legacy_use;
|
|||||||
pub mod log_files;
|
pub mod log_files;
|
||||||
pub mod listeners;
|
pub mod listeners;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
|
pub mod sharing_policy;
|
||||||
pub mod tenant_protocol_policy;
|
pub mod tenant_protocol_policy;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:SharingPolicy`, whether people may share their own mail and add
|
||||||
|
//! other accounts to the webmail (multi-account spec, MA-C, MA-10 to MA-14).
|
||||||
|
//!
|
||||||
|
//! Two levels, as the legacy-protocols switch has: the server's policy, and
|
||||||
|
//! one per tenant that can only be stricter. Stored as JSON in the fork's
|
||||||
|
//! subspace, `W` + `p` for the server and `W` + `t` + tenant for a tenant;
|
||||||
|
//! unset reads as the defaults, which are on, so a server keeps today's
|
||||||
|
//! behavior until someone turns it off.
|
||||||
|
//!
|
||||||
|
//! "Off" refuses new shares and stops honoring the ones already made, which
|
||||||
|
//! stay stored, so turning it back on restores them (John, 2026-10-05).
|
||||||
|
//! Group membership and shared mailboxes aren't users' shares and are never
|
||||||
|
//! affected.
|
||||||
|
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use store::{
|
||||||
|
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
/// One level's switches. `None` on a tenant means "as the server says".
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SharingPolicy {
|
||||||
|
/// People may share their own mail folders (MA-11). Default on.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mail_sharing: Option<bool>,
|
||||||
|
/// People may add their other accounts to the webmail (MA-B). Default on.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub add_accounts: Option<bool>,
|
||||||
|
/// Seconds since the epoch, and who: the console shows them.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub changed_at: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub changed_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What applies to one account: the server's switch, narrowed by its
|
||||||
|
/// tenant's.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Effective {
|
||||||
|
pub mail_sharing: bool,
|
||||||
|
pub add_accounts: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Effective {
|
||||||
|
fn default() -> Self {
|
||||||
|
Effective {
|
||||||
|
mail_sharing: true,
|
||||||
|
add_accounts: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tenant can be stricter than the server, never looser (MA-C).
|
||||||
|
pub fn effective(server: &SharingPolicy, tenant: Option<&SharingPolicy>) -> Effective {
|
||||||
|
let server_mail = server.mail_sharing.unwrap_or(true);
|
||||||
|
let server_add = server.add_accounts.unwrap_or(true);
|
||||||
|
Effective {
|
||||||
|
mail_sharing: server_mail && tenant.and_then(|t| t.mail_sharing).unwrap_or(true),
|
||||||
|
add_accounts: server_add && tenant.and_then(|t| t.add_accounts).unwrap_or(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a tenant can't turn a switch on: the server has it off.
|
||||||
|
pub fn looser_than_server(server: &SharingPolicy, tenant: &SharingPolicy) -> Option<&'static str> {
|
||||||
|
if tenant.mail_sharing == Some(true) && server.mail_sharing == Some(false) {
|
||||||
|
return Some("The server has mail sharing off; a tenant can only be stricter.");
|
||||||
|
}
|
||||||
|
if tenant.add_accounts == Some(true) && server.add_accounts == Some(false) {
|
||||||
|
return Some("The server has adding accounts off; a tenant can only be stricter.");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(tenant_id: Option<u32>) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(6);
|
||||||
|
match tenant_id {
|
||||||
|
None => key.extend_from_slice(b"Wp"),
|
||||||
|
Some(tenant_id) => {
|
||||||
|
key.extend_from_slice(b"Wt");
|
||||||
|
key.extend_from_slice(&tenant_id.to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Json(SharingPolicy);
|
||||||
|
|
||||||
|
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 server's policy (`None`) or a tenant's.
|
||||||
|
pub async fn get(data: &Store, tenant_id: Option<u32>) -> trc::Result<SharingPolicy> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json>(ValueKey::from(key(tenant_id)))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(policy)| policy)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What applies to an account in `tenant_id`.
|
||||||
|
pub async fn effective_for(data: &Store, tenant_id: Option<u32>) -> trc::Result<Effective> {
|
||||||
|
let server = get(data, None).await?;
|
||||||
|
let tenant = match tenant_id {
|
||||||
|
Some(tenant_id) => Some(get(data, Some(tenant_id)).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(effective(&server, tenant.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a policy.
|
||||||
|
pub async fn set(data: &Store, tenant_id: Option<u32>, policy: &SharingPolicy) -> 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(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn on_off(mail: Option<bool>, add: Option<bool>) -> SharingPolicy {
|
||||||
|
SharingPolicy {
|
||||||
|
mail_sharing: mail,
|
||||||
|
add_accounts: add,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unset_is_on() {
|
||||||
|
assert_eq!(effective(&SharingPolicy::default(), None), Effective::default());
|
||||||
|
assert_eq!(
|
||||||
|
effective(&SharingPolicy::default(), Some(&SharingPolicy::default())),
|
||||||
|
Effective::default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tenant_is_only_ever_stricter() {
|
||||||
|
// The server off wins over a tenant on
|
||||||
|
let server = on_off(Some(false), None);
|
||||||
|
let tenant = on_off(Some(true), Some(false));
|
||||||
|
let e = effective(&server, Some(&tenant));
|
||||||
|
assert!(!e.mail_sharing);
|
||||||
|
assert!(!e.add_accounts, "the tenant's own off holds");
|
||||||
|
assert!(looser_than_server(&server, &tenant).is_some());
|
||||||
|
// A tenant off under a server on
|
||||||
|
let e = effective(&on_off(Some(true), Some(true)), Some(&on_off(Some(false), None)));
|
||||||
|
assert!(!e.mail_sharing && e.add_accounts);
|
||||||
|
assert!(looser_than_server(&on_off(None, None), &on_off(Some(true), Some(true))).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_stay_apart() {
|
||||||
|
let ValueClass::Any(server) = key(None) else { panic!() };
|
||||||
|
let ValueClass::Any(tenant) = key(Some(7)) else { panic!() };
|
||||||
|
assert_eq!(server.key, b"Wp");
|
||||||
|
assert_eq!(tenant.key, [b'W', b't', 0, 0, 0, 7]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
pub mod authenticate;
|
pub mod authenticate;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
|
pub mod token_only;
|
||||||
@@ -270,8 +270,12 @@ impl ClientRegistrationHandler for Server {
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if the account is allowed to override client registration
|
// Check if the account is allowed to override client registration.
|
||||||
if self
|
// inbuxa: only while setting up or recovering, when the recovery
|
||||||
|
// administrator signs in before any client is registered (contract C-5)
|
||||||
|
let registry = self.registry();
|
||||||
|
if (registry.is_bootstrap_mode() || registry.is_recovery_mode())
|
||||||
|
&& self
|
||||||
.access_token(account_id)
|
.access_token(account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Where HTTP Basic authentication is refused (contract C-23).
|
||||||
|
//!
|
||||||
|
//! Outside DAV, the HTTP endpoints take a token, never a password: JMAP, the
|
||||||
|
//! management API, and the OAuth endpoints that authenticate a user
|
||||||
|
//! (introspection, userinfo, authenticated client registration). CalDAV and
|
||||||
|
//! CardDAV keep Basic, since that's how calendar and contacts apps sign in.
|
||||||
|
//! The token endpoint's own client authentication isn't user sign-in and
|
||||||
|
//! isn't affected.
|
||||||
|
//!
|
||||||
|
//! Bootstrap and recovery mode accept Basic everywhere, as they keep
|
||||||
|
//! permissive CORS (C-16), and `INBUXA_HTTP_BASIC_AUTH=all` puts it back
|
||||||
|
//! everywhere for an operator who needs it.
|
||||||
|
|
||||||
|
use crate::auth::authenticate::HttpHeaders;
|
||||||
|
use http_proto::HttpRequest;
|
||||||
|
|
||||||
|
/// Whether `path` takes a token only when Basic isn't allowed everywhere.
|
||||||
|
pub fn is_token_only_path(path: &str) -> bool {
|
||||||
|
let mut segments = path.trim_start_matches('/').split('/');
|
||||||
|
match segments.next() {
|
||||||
|
Some("jmap" | "api") => true,
|
||||||
|
Some("auth") => matches!(
|
||||||
|
segments.next(),
|
||||||
|
Some("introspect" | "userinfo" | "register")
|
||||||
|
),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this request signs in with a password where only a token is
|
||||||
|
/// accepted.
|
||||||
|
pub fn is_refused_basic(req: &HttpRequest, basic_auth_everywhere: bool) -> bool {
|
||||||
|
!basic_auth_everywhere
|
||||||
|
&& req.authorization_basic().is_some()
|
||||||
|
&& is_token_only_path(req.uri().path())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::is_token_only_path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_only_paths() {
|
||||||
|
for path in [
|
||||||
|
"/jmap",
|
||||||
|
"/jmap/",
|
||||||
|
"/jmap/session",
|
||||||
|
"/jmap/upload/a/",
|
||||||
|
"/jmap/download/a/b/c",
|
||||||
|
"/jmap/eventsource/",
|
||||||
|
"/jmap/ws",
|
||||||
|
"/api",
|
||||||
|
"/api/account",
|
||||||
|
"/api/schema",
|
||||||
|
"/auth/introspect",
|
||||||
|
"/auth/userinfo",
|
||||||
|
"/auth/register",
|
||||||
|
] {
|
||||||
|
assert!(is_token_only_path(path), "{path} should take a token only");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn basic_stays_where_apps_need_it() {
|
||||||
|
for path in [
|
||||||
|
"/dav/cal/user/",
|
||||||
|
"/dav/card/user/",
|
||||||
|
"/.well-known/caldav",
|
||||||
|
"/.well-known/carddav",
|
||||||
|
"/.well-known/jmap",
|
||||||
|
"/auth/token",
|
||||||
|
"/auth/device",
|
||||||
|
"/scim/v2/Users",
|
||||||
|
"/",
|
||||||
|
"/login",
|
||||||
|
"/jmapx",
|
||||||
|
"/apis",
|
||||||
|
] {
|
||||||
|
assert!(!is_token_only_path(path), "{path} should be left alone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,14 @@
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
HttpSessionManager,
|
HttpSessionManager,
|
||||||
api::{AuthChallenge, ManagementApi, ToManageHttpResponse},
|
api::{AuthChallenge, ManagementApi, ToManageHttpResponse, UnauthorizedResponse},
|
||||||
auth::{
|
auth::{
|
||||||
authenticate::{Authenticator, HttpHeaders},
|
authenticate::{Authenticator, HttpHeaders},
|
||||||
oauth::{
|
oauth::{
|
||||||
FormData, auth::OAuthApiHandler, openid::OpenIdHandler,
|
FormData, auth::OAuthApiHandler, openid::OpenIdHandler,
|
||||||
registration::ClientRegistrationHandler, token::TokenHandler,
|
registration::ClientRegistrationHandler, token::TokenHandler,
|
||||||
},
|
},
|
||||||
|
token_only::{is_refused_basic, is_token_only_path},
|
||||||
},
|
},
|
||||||
form::FormHandler,
|
form::FormHandler,
|
||||||
};
|
};
|
||||||
@@ -92,6 +93,17 @@ impl ParseHttp for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: outside DAV, sign in with a token, never a password (contract C-23)
|
||||||
|
if is_refused_basic(&req, self.core.network.http.basic_auth_everywhere) {
|
||||||
|
trc::event!(
|
||||||
|
Auth(trc::AuthEvent::Failed),
|
||||||
|
SpanId = session.session_id,
|
||||||
|
RemoteIp = session.remote_ip,
|
||||||
|
Reason = "Basic authentication is accepted on DAV only; use a bearer token",
|
||||||
|
);
|
||||||
|
return Ok(HttpResponse::unauthorized(AuthChallenge::Bearer));
|
||||||
|
}
|
||||||
|
|
||||||
match path.next().unwrap_or_default() {
|
match path.next().unwrap_or_default() {
|
||||||
"jmap" => {
|
"jmap" => {
|
||||||
match (path.next().unwrap_or_default(), req.method()) {
|
match (path.next().unwrap_or_default(), req.method()) {
|
||||||
@@ -782,6 +794,15 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
|||||||
// inbuxa: kept for the cross-origin allowlist (contract C-14)
|
// inbuxa: kept for the cross-origin allowlist (contract C-14)
|
||||||
let origin = req.headers().get(hyper::header::ORIGIN).cloned();
|
let origin = req.headers().get(hyper::header::ORIGIN).cloned();
|
||||||
|
|
||||||
|
// inbuxa: offer Basic only where it's accepted (contract C-23)
|
||||||
|
let challenge = if server.core.network.http.basic_auth_everywhere
|
||||||
|
|| !is_token_only_path(req.uri().path())
|
||||||
|
{
|
||||||
|
AuthChallenge::BearerAndBasic
|
||||||
|
} else {
|
||||||
|
AuthChallenge::Bearer
|
||||||
|
};
|
||||||
|
|
||||||
// Parse HTTP request
|
// Parse HTTP request
|
||||||
let response = match Box::pin(server.parse_http_request(
|
let response = match Box::pin(server.parse_http_request(
|
||||||
req,
|
req,
|
||||||
@@ -799,7 +820,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
|||||||
{
|
{
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let response = err.into_http_response(AuthChallenge::BearerAndBasic);
|
let response = err.into_http_response(challenge);
|
||||||
trc::error!(err.span_id(session.session_id));
|
trc::error!(err.span_id(session.session_id));
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ impl<T: SessionStream> Session<T> {
|
|||||||
}
|
}
|
||||||
rights
|
rights
|
||||||
} else {
|
} else {
|
||||||
vec![
|
let mut rights = vec![
|
||||||
Rights::Read,
|
Rights::Read,
|
||||||
Rights::Lookup,
|
Rights::Lookup,
|
||||||
Rights::Insert,
|
Rights::Insert,
|
||||||
@@ -223,8 +223,12 @@ impl<T: SessionStream> Session<T> {
|
|||||||
Rights::CreateMailbox,
|
Rights::CreateMailbox,
|
||||||
Rights::DeleteMailbox,
|
Rights::DeleteMailbox,
|
||||||
Rights::Post,
|
Rights::Post,
|
||||||
Rights::Administer,
|
];
|
||||||
]
|
// inbuxa: MA-D0: a group's members don't share its mailboxes on.
|
||||||
|
if !access_token.is_group_member_only(mailbox_id.account_id) {
|
||||||
|
rights.push(Rights::Administer);
|
||||||
|
}
|
||||||
|
rights
|
||||||
};
|
};
|
||||||
|
|
||||||
trc::event!(
|
trc::event!(
|
||||||
@@ -266,10 +270,20 @@ impl<T: SessionStream> Session<T> {
|
|||||||
|
|
||||||
spawn_op!(data, {
|
spawn_op!(data, {
|
||||||
// Validate mailbox
|
// Validate mailbox
|
||||||
let (mailbox_id, current_mailbox, _) = data
|
let (mailbox_id, current_mailbox, access_token) = data
|
||||||
.get_acl_mailbox(&arguments, true)
|
.get_acl_mailbox(&arguments, true)
|
||||||
.await
|
.await
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||||
|
|
||||||
|
// inbuxa: MA-D0: a group's members don't share its mailboxes on.
|
||||||
|
if access_token.is_group_member_only(mailbox_id.account_id) {
|
||||||
|
return Err(trc::ImapEvent::Error
|
||||||
|
.into_err()
|
||||||
|
.details("This mailbox belongs to a group. Only an administrator can change who has it.")
|
||||||
|
.code(ResponseCode::NoPerm)
|
||||||
|
.id(arguments.tag.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let current_mailbox = current_mailbox
|
let current_mailbox = current_mailbox
|
||||||
.into_deserialized::<email::mailbox::Mailbox>()
|
.into_deserialized::<email::mailbox::Mailbox>()
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||||
@@ -363,6 +377,34 @@ impl<T: SessionStream> Session<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-C: with mail sharing off, nobody here starts or
|
||||||
|
// widens a share (narrowing or ending one is always allowed)
|
||||||
|
let had = current_mailbox
|
||||||
|
.inner
|
||||||
|
.acls
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.account_id == acl_account_id)
|
||||||
|
.map_or(0, |item| item.grants.clone().into_inner());
|
||||||
|
let has = mailbox
|
||||||
|
.acls
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.account_id == acl_account_id)
|
||||||
|
.map_or(0, |item| item.grants.clone().into_inner());
|
||||||
|
if has & !had != 0
|
||||||
|
&& !access_token.has_permission(Permission::Impersonate)
|
||||||
|
&& !data
|
||||||
|
.server
|
||||||
|
.mail_sharing_allowed(mailbox_id.account_id)
|
||||||
|
.await
|
||||||
|
.imap_ctx(&arguments.tag, trc::location!())?
|
||||||
|
{
|
||||||
|
return Err(trc::ImapEvent::Error
|
||||||
|
.into_err()
|
||||||
|
.details("Your organization has turned off sharing mail folders.")
|
||||||
|
.code(ResponseCode::NoPerm)
|
||||||
|
.id(arguments.tag.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item {
|
if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item {
|
||||||
return Err(trc::ImapEvent::Error
|
return Err(trc::ImapEvent::Error
|
||||||
.into_err()
|
.into_err()
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ pub enum AccountLockProperty {
|
|||||||
/// The locked account (on create; afterwards the same as `id`).
|
/// The locked account (on create; afterwards the same as `id`).
|
||||||
AccountId,
|
AccountId,
|
||||||
Name,
|
Name,
|
||||||
|
/// MA-S: `lock` (the default) or `sharedMailbox`; set on create only.
|
||||||
|
Kind,
|
||||||
Reason,
|
Reason,
|
||||||
LockedAt,
|
LockedAt,
|
||||||
LockedBy,
|
LockedBy,
|
||||||
@@ -53,6 +55,7 @@ impl Property for AccountLockProperty {
|
|||||||
AccountLockProperty::Id => "id",
|
AccountLockProperty::Id => "id",
|
||||||
AccountLockProperty::AccountId => "accountId",
|
AccountLockProperty::AccountId => "accountId",
|
||||||
AccountLockProperty::Name => "name",
|
AccountLockProperty::Name => "name",
|
||||||
|
AccountLockProperty::Kind => "kind",
|
||||||
AccountLockProperty::Reason => "reason",
|
AccountLockProperty::Reason => "reason",
|
||||||
AccountLockProperty::LockedAt => "lockedAt",
|
AccountLockProperty::LockedAt => "lockedAt",
|
||||||
AccountLockProperty::LockedBy => "lockedBy",
|
AccountLockProperty::LockedBy => "lockedBy",
|
||||||
@@ -68,6 +71,7 @@ impl AccountLockProperty {
|
|||||||
b"id" => AccountLockProperty::Id,
|
b"id" => AccountLockProperty::Id,
|
||||||
b"accountId" => AccountLockProperty::AccountId,
|
b"accountId" => AccountLockProperty::AccountId,
|
||||||
b"name" => AccountLockProperty::Name,
|
b"name" => AccountLockProperty::Name,
|
||||||
|
b"kind" => AccountLockProperty::Kind,
|
||||||
b"reason" => AccountLockProperty::Reason,
|
b"reason" => AccountLockProperty::Reason,
|
||||||
b"lockedAt" => AccountLockProperty::LockedAt,
|
b"lockedAt" => AccountLockProperty::LockedAt,
|
||||||
b"lockedBy" => AccountLockProperty::LockedBy,
|
b"lockedBy" => AccountLockProperty::LockedBy,
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:DeliverabilityReport/get` and `/set` under `urn:inbuxa:jmap`:
|
||||||
|
//! each sending node's last deliverability check (deliverability spec).
|
||||||
|
//! One per node, written by the server. Creating one asks every node to
|
||||||
|
//! check itself now (DL-15); nothing is updated or destroyed.
|
||||||
|
|
||||||
|
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 DeliverabilityReport;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum DeliverabilityReportProperty {
|
||||||
|
Id,
|
||||||
|
NodeId,
|
||||||
|
Hostname,
|
||||||
|
CheckedAt,
|
||||||
|
Addresses,
|
||||||
|
Domains,
|
||||||
|
Certificates,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum DeliverabilityReportValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for DeliverabilityReportProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside the addresses, domains and certificates stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => DeliverabilityReportProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
DeliverabilityReportProperty::Id => "id",
|
||||||
|
DeliverabilityReportProperty::NodeId => "nodeId",
|
||||||
|
DeliverabilityReportProperty::Hostname => "hostname",
|
||||||
|
DeliverabilityReportProperty::CheckedAt => "checkedAt",
|
||||||
|
DeliverabilityReportProperty::Addresses => "addresses",
|
||||||
|
DeliverabilityReportProperty::Domains => "domains",
|
||||||
|
DeliverabilityReportProperty::Certificates => "certificates",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliverabilityReportProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => DeliverabilityReportProperty::Id,
|
||||||
|
b"nodeId" => DeliverabilityReportProperty::NodeId,
|
||||||
|
b"hostname" => DeliverabilityReportProperty::Hostname,
|
||||||
|
b"checkedAt" => DeliverabilityReportProperty::CheckedAt,
|
||||||
|
b"addresses" => DeliverabilityReportProperty::Addresses,
|
||||||
|
b"domains" => DeliverabilityReportProperty::Domains,
|
||||||
|
b"certificates" => DeliverabilityReportProperty::Certificates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for DeliverabilityReportProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
DeliverabilityReportProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for DeliverabilityReportValue {
|
||||||
|
type Property = DeliverabilityReportProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(DeliverabilityReportProperty::Id) => {
|
||||||
|
Id::from_str(value).ok().map(DeliverabilityReportValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
DeliverabilityReportValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for DeliverabilityReport {
|
||||||
|
type Property = DeliverabilityReportProperty;
|
||||||
|
|
||||||
|
type Element = DeliverabilityReportValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = DeliverabilityReportProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for DeliverabilityReportValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
DeliverabilityReportValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for DeliverabilityReportValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
DeliverabilityReportValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
DeliverabilityReportValue::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 = DeliverabilityReportValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for DeliverabilityReportProperty {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:DeliverabilitySettings/get` and `/set` under `urn:inbuxa:jmap`:
|
||||||
|
//! which of the built-in blocklists the deliverability check leaves out
|
||||||
|
//! (deliverability spec, DL-6), and, read only, what the lists are.
|
||||||
|
|
||||||
|
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 DeliverabilitySettings;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum DeliverabilitySettingsProperty {
|
||||||
|
Id,
|
||||||
|
DisabledLists,
|
||||||
|
Lists,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum DeliverabilitySettingsValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for DeliverabilitySettingsProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside the lists stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => DeliverabilitySettingsProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
DeliverabilitySettingsProperty::Id => "id",
|
||||||
|
DeliverabilitySettingsProperty::DisabledLists => "disabledLists",
|
||||||
|
DeliverabilitySettingsProperty::Lists => "lists",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliverabilitySettingsProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => DeliverabilitySettingsProperty::Id,
|
||||||
|
b"disabledLists" => DeliverabilitySettingsProperty::DisabledLists,
|
||||||
|
b"lists" => DeliverabilitySettingsProperty::Lists,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for DeliverabilitySettingsProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
DeliverabilitySettingsProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for DeliverabilitySettingsValue {
|
||||||
|
type Property = DeliverabilitySettingsProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(DeliverabilitySettingsProperty::Id) => Id::from_str(value)
|
||||||
|
.ok()
|
||||||
|
.map(DeliverabilitySettingsValue::Id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
DeliverabilitySettingsValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for DeliverabilitySettings {
|
||||||
|
type Property = DeliverabilitySettingsProperty;
|
||||||
|
|
||||||
|
type Element = DeliverabilitySettingsValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = DeliverabilitySettingsProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for DeliverabilitySettingsValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
DeliverabilitySettingsValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for DeliverabilitySettingsValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
DeliverabilitySettingsValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
DeliverabilitySettingsValue::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 = DeliverabilitySettingsValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for DeliverabilitySettingsProperty {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:SharingPolicy/get` and `/set` under `urn:inbuxa:jmap`: whether
|
||||||
|
//! people may share their own mail and add other accounts to the webmail
|
||||||
|
//! (multi-account spec, MA-C). The server's policy has the singleton id;
|
||||||
|
//! each tenant's has 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 SharingPolicy;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum SharingPolicyProperty {
|
||||||
|
Id,
|
||||||
|
/// Server-set: the tenant this is the policy of, or null for the server's.
|
||||||
|
TenantId,
|
||||||
|
/// `enabled` or `disabled`: people may share their own mail folders.
|
||||||
|
MailSharing,
|
||||||
|
/// `enabled` or `disabled`: people may add other accounts to the webmail.
|
||||||
|
AddAccounts,
|
||||||
|
ChangedAt,
|
||||||
|
ChangedBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum SharingPolicyValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for SharingPolicyProperty {
|
||||||
|
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
SharingPolicyProperty::parse(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyProperty::Id => "id",
|
||||||
|
SharingPolicyProperty::TenantId => "tenantId",
|
||||||
|
SharingPolicyProperty::MailSharing => "mailSharing",
|
||||||
|
SharingPolicyProperty::AddAccounts => "addAccounts",
|
||||||
|
SharingPolicyProperty::ChangedAt => "changedAt",
|
||||||
|
SharingPolicyProperty::ChangedBy => "changedBy",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharingPolicyProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => SharingPolicyProperty::Id,
|
||||||
|
b"tenantId" => SharingPolicyProperty::TenantId,
|
||||||
|
b"mailSharing" => SharingPolicyProperty::MailSharing,
|
||||||
|
b"addAccounts" => SharingPolicyProperty::AddAccounts,
|
||||||
|
b"changedAt" => SharingPolicyProperty::ChangedAt,
|
||||||
|
b"changedBy" => SharingPolicyProperty::ChangedBy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharingPolicyProperty {
|
||||||
|
/// 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,
|
||||||
|
SharingPolicyProperty::TenantId
|
||||||
|
| SharingPolicyProperty::ChangedAt
|
||||||
|
| SharingPolicyProperty::ChangedBy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for SharingPolicyProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
SharingPolicyProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for SharingPolicyValue {
|
||||||
|
type Property = SharingPolicyProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(SharingPolicyProperty::Id) => {
|
||||||
|
Id::from_str(value).ok().map(SharingPolicyValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for SharingPolicy {
|
||||||
|
type Property = SharingPolicyProperty;
|
||||||
|
|
||||||
|
type Element = SharingPolicyValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = SharingPolicyProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for SharingPolicyValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
SharingPolicyValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for SharingPolicyValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyValue::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 = SharingPolicyValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for SharingPolicyProperty {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ pub mod inbuxa_audit; // inbuxa: the audit log
|
|||||||
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
||||||
pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules
|
pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules
|
||||||
pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items
|
pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items
|
||||||
|
pub mod inbuxa_deliverability_report; // inbuxa: the deliverability check
|
||||||
|
pub mod inbuxa_deliverability_settings; // inbuxa: the deliverability check
|
||||||
pub mod inbuxa_journal; // inbuxa: journaling
|
pub mod inbuxa_journal; // inbuxa: journaling
|
||||||
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
|
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
|
||||||
pub mod inbuxa_held_message; // inbuxa: mail held for review
|
pub mod inbuxa_held_message; // inbuxa: mail held for review
|
||||||
@@ -38,6 +40,7 @@ pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
|||||||
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
||||||
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_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
||||||
|
pub mod inbuxa_sharing_policy; // inbuxa: MA-C, who may share mail
|
||||||
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;
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::SecurityAcceptance(response) => {
|
GetResponseMethod::SecurityAcceptance(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::DeliverabilityReport(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::DeliverabilitySettings(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::Journal(response) => {
|
GetResponseMethod::Journal(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
@@ -109,6 +115,9 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::TenantProtocolPolicy(response) => {
|
GetResponseMethod::TenantProtocolPolicy(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::SharingPolicy(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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::MailRule(request) => request.resolve_references(self)?,
|
GetRequestMethod::MailRule(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?,
|
GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::DeliverabilityReport(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::DeliverabilitySettings(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Journal(request) => request.resolve_references(self)?,
|
GetRequestMethod::Journal(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?,
|
GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
|
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
|
||||||
@@ -64,6 +66,9 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
request.resolve_references(self)?
|
request.resolve_references(self)?
|
||||||
}
|
}
|
||||||
|
GetRequestMethod::SharingPolicy(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)?,
|
||||||
@@ -137,6 +142,12 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::SecurityAcceptance(request) => {
|
SetRequestMethod::SecurityAcceptance(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::DeliverabilityReport(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::DeliverabilitySettings(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::Journal(request) => {
|
SetRequestMethod::Journal(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
@@ -158,6 +169,9 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::TenantProtocolPolicy(request) => {
|
SetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::SharingPolicy(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)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,8 +148,12 @@ pub struct InbuxaDelegatedCapabilities {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct DelegationInfo {
|
pub struct DelegationInfo {
|
||||||
/// Always true: only locked accounts are delegated.
|
/// Always true: only locked accounts are delegated. A shared mailbox is
|
||||||
|
/// a lock too, so a front end that knows no `kind` still treats it as
|
||||||
|
/// one it may only reach as a delegate.
|
||||||
pub locked: bool,
|
pub locked: bool,
|
||||||
|
/// MA-S: `lock` or `sharedMailbox`.
|
||||||
|
pub kind: &'static str,
|
||||||
/// `read`, `organize` or `full`.
|
/// `read`, `organize` or `full`.
|
||||||
pub access: &'static str,
|
pub access: &'static str,
|
||||||
#[serde(rename(serialize = "sendAs"))]
|
#[serde(rename(serialize = "sendAs"))]
|
||||||
@@ -179,6 +183,13 @@ pub struct InbuxaAccountCapabilities {
|
|||||||
/// spec, EX-1 to EX-4).
|
/// spec, EX-1 to EX-4).
|
||||||
#[serde(rename(serialize = "aiExplain"))]
|
#[serde(rename(serialize = "aiExplain"))]
|
||||||
pub ai_explain: bool,
|
pub ai_explain: bool,
|
||||||
|
/// MA-C: whether the principal may share their own mail folders, and
|
||||||
|
/// add other accounts to the webmail: the stricter of the server's
|
||||||
|
/// switch and its tenant's.
|
||||||
|
#[serde(rename(serialize = "mailSharing"))]
|
||||||
|
pub mail_sharing: bool,
|
||||||
|
#[serde(rename(serialize = "addAccounts"))]
|
||||||
|
pub add_accounts: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ pub enum MethodObject {
|
|||||||
MailRule,
|
MailRule,
|
||||||
// inbuxa: accepted security to-do items
|
// inbuxa: accepted security to-do items
|
||||||
SecurityAcceptance,
|
SecurityAcceptance,
|
||||||
|
// inbuxa: the deliverability check
|
||||||
|
DeliverabilityReport,
|
||||||
|
DeliverabilitySettings,
|
||||||
HeldMessage,
|
HeldMessage,
|
||||||
// inbuxa: journaling
|
// inbuxa: journaling
|
||||||
Journal,
|
Journal,
|
||||||
@@ -77,6 +80,7 @@ pub enum MethodObject {
|
|||||||
JournalExport,
|
JournalExport,
|
||||||
JournalVerification,
|
JournalVerification,
|
||||||
TenantProtocolPolicy,
|
TenantProtocolPolicy,
|
||||||
|
SharingPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MethodObject {
|
impl MethodObject {
|
||||||
@@ -118,12 +122,15 @@ impl MethodObject {
|
|||||||
| MethodObject::MailRule
|
| MethodObject::MailRule
|
||||||
| MethodObject::SecurityAcceptance
|
| MethodObject::SecurityAcceptance
|
||||||
| MethodObject::HeldMessage
|
| MethodObject::HeldMessage
|
||||||
|
| MethodObject::DeliverabilityReport
|
||||||
|
| MethodObject::DeliverabilitySettings
|
||||||
| MethodObject::Journal
|
| MethodObject::Journal
|
||||||
| MethodObject::JournalEntry
|
| MethodObject::JournalEntry
|
||||||
| MethodObject::JournalExport
|
| MethodObject::JournalExport
|
||||||
| MethodObject::JournalVerification => Capability::Inbuxa,
|
| MethodObject::JournalVerification => Capability::Inbuxa,
|
||||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||||
|
MethodObject::SharingPolicy => Capability::Inbuxa,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,6 +328,10 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set",
|
(MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set",
|
||||||
(MethodFunction::Get, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/get",
|
(MethodFunction::Get, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/get",
|
||||||
(MethodFunction::Set, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/set",
|
(MethodFunction::Set, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/set",
|
||||||
|
(MethodFunction::Get, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/get",
|
||||||
|
(MethodFunction::Set, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/set",
|
||||||
|
(MethodFunction::Get, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/get",
|
||||||
|
(MethodFunction::Set, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/set",
|
||||||
(MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get",
|
(MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get",
|
||||||
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
|
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
|
||||||
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
|
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
|
||||||
@@ -344,6 +355,12 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
||||||
"inbuxa:TenantProtocolPolicy/set"
|
"inbuxa:TenantProtocolPolicy/set"
|
||||||
}
|
}
|
||||||
|
(MethodFunction::Get, MethodObject::SharingPolicy) => {
|
||||||
|
"inbuxa:SharingPolicy/get"
|
||||||
|
}
|
||||||
|
(MethodFunction::Set, MethodObject::SharingPolicy) => {
|
||||||
|
"inbuxa:SharingPolicy/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()));
|
||||||
}
|
}
|
||||||
@@ -489,6 +506,10 @@ impl MethodName {
|
|||||||
"inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set),
|
"inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set),
|
||||||
"inbuxa:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get),
|
"inbuxa:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get),
|
||||||
"inbuxa:SecurityAcceptance/set" => (MethodObject::SecurityAcceptance, MethodFunction::Set),
|
"inbuxa:SecurityAcceptance/set" => (MethodObject::SecurityAcceptance, MethodFunction::Set),
|
||||||
|
"inbuxa:DeliverabilityReport/get" => (MethodObject::DeliverabilityReport, MethodFunction::Get),
|
||||||
|
"inbuxa:DeliverabilityReport/set" => (MethodObject::DeliverabilityReport, MethodFunction::Set),
|
||||||
|
"inbuxa:DeliverabilitySettings/get" => (MethodObject::DeliverabilitySettings, MethodFunction::Get),
|
||||||
|
"inbuxa:DeliverabilitySettings/set" => (MethodObject::DeliverabilitySettings, MethodFunction::Set),
|
||||||
"inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get),
|
"inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get),
|
||||||
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
|
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
|
||||||
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
|
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
|
||||||
@@ -504,6 +525,8 @@ impl MethodName {
|
|||||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||||
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
||||||
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
||||||
|
"inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get),
|
||||||
|
"inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set),
|
||||||
|
|
||||||
).or_else(|| {
|
).or_else(|| {
|
||||||
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
||||||
@@ -570,6 +593,8 @@ impl Display for MethodObject {
|
|||||||
MethodObject::LegalHold => "inbuxa:LegalHold",
|
MethodObject::LegalHold => "inbuxa:LegalHold",
|
||||||
MethodObject::MailRule => "inbuxa:MailRule",
|
MethodObject::MailRule => "inbuxa:MailRule",
|
||||||
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
|
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
|
||||||
|
MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport",
|
||||||
|
MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings",
|
||||||
MethodObject::Journal => "inbuxa:Journal",
|
MethodObject::Journal => "inbuxa:Journal",
|
||||||
MethodObject::JournalEntry => "inbuxa:JournalEntry",
|
MethodObject::JournalEntry => "inbuxa:JournalEntry",
|
||||||
MethodObject::JournalExport => "inbuxa:JournalExport",
|
MethodObject::JournalExport => "inbuxa:JournalExport",
|
||||||
@@ -578,6 +603,7 @@ impl Display for MethodObject {
|
|||||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||||
|
MethodObject::SharingPolicy => "inbuxa:SharingPolicy",
|
||||||
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());
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ pub enum GetRequestMethod {
|
|||||||
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
|
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
|
||||||
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
|
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
|
||||||
|
DeliverabilityReport(Box<GetRequest<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||||
|
DeliverabilitySettings(Box<GetRequest<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||||
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
|
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
|
||||||
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
|
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
|
||||||
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
|
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
|
||||||
@@ -134,6 +136,9 @@ pub enum GetRequestMethod {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -169,6 +174,8 @@ pub enum SetRequestMethod<'x> {
|
|||||||
SecurityAcceptance(
|
SecurityAcceptance(
|
||||||
Box<SetRequest<'x, crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
Box<SetRequest<'x, crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
||||||
),
|
),
|
||||||
|
DeliverabilityReport(Box<SetRequest<'x, crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||||
|
DeliverabilitySettings(Box<SetRequest<'x, crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||||
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
|
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
|
||||||
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
|
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
|
||||||
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
|
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
|
||||||
@@ -178,6 +185,9 @@ pub enum SetRequestMethod<'x> {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -213,6 +213,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::SharingPolicy) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => {
|
||||||
|
RequestMethod::Get(GetRequestMethod::SharingPolicy(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),
|
||||||
@@ -415,6 +424,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::SharingPolicy) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => {
|
||||||
|
RequestMethod::Set(SetRequestMethod::SharingPolicy(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),
|
||||||
@@ -668,6 +686,35 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// inbuxa: the deliverability check
|
||||||
|
(MethodFunction::Get, MethodObject::DeliverabilityReport) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DeliverabilityReport(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::DeliverabilityReport) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DeliverabilityReport(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Get, MethodObject::DeliverabilitySettings) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DeliverabilitySettings(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::DeliverabilitySettings) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DeliverabilitySettings(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
// inbuxa: journaling
|
// inbuxa: journaling
|
||||||
(MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)),
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ pub enum GetResponseMethod {
|
|||||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||||
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
|
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
|
||||||
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
|
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
|
||||||
|
DeliverabilityReport(GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>),
|
||||||
|
DeliverabilitySettings(GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>),
|
||||||
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
||||||
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
|
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
|
||||||
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
|
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
|
||||||
@@ -121,6 +123,9 @@ pub enum GetResponseMethod {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -156,6 +161,8 @@ pub enum SetResponseMethod {
|
|||||||
SecurityAcceptance(
|
SecurityAcceptance(
|
||||||
Box<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
Box<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
||||||
),
|
),
|
||||||
|
DeliverabilityReport(Box<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||||
|
DeliverabilitySettings(Box<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||||
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
|
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
|
||||||
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
|
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
|
||||||
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
|
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
|
||||||
@@ -166,6 +173,9 @@ pub enum SetResponseMethod {
|
|||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -352,6 +362,16 @@ impl<'x> From<GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(
|
||||||
|
value: GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||||
|
) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::SharingPolicy(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
||||||
for ResponseMethod<'x>
|
for ResponseMethod<'x>
|
||||||
{
|
{
|
||||||
@@ -362,6 +382,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(
|
||||||
|
value: SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||||
|
) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::SharingPolicy(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))
|
||||||
@@ -838,6 +868,31 @@ impl<'x> From<SetResponse<crate::object::inbuxa_held_message::HeldMessage>> for
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: the deliverability check
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::DeliverabilityReport(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::DeliverabilityReport(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::DeliverabilitySettings(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::DeliverabilitySettings(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// inbuxa: accepted security to-do items
|
// inbuxa: accepted security to-do items
|
||||||
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
||||||
for ResponseMethod<'x>
|
for ResponseMethod<'x>
|
||||||
|
|||||||
@@ -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::{api::acl::JmapRights, changes::state::JmapCacheState};
|
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
||||||
@@ -180,7 +182,7 @@ impl AddressBookGet for Server {
|
|||||||
address_book.acls.effective_acl(access_token),
|
address_book.acls.effective_acl(access_token),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
JmapRights::all_rights::<addressbook::AddressBook>()
|
JmapRights::owner_rights::<addressbook::AddressBook>(access_token, account_id)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ impl AddressBookSet for Server {
|
|||||||
continue 'create;
|
continue 'create;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if !address_book.acls.is_empty() && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_created.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
// Validate ACLs
|
// Validate ACLs
|
||||||
if !address_book.acls.is_empty() {
|
if !address_book.acls.is_empty() {
|
||||||
if let Err(err) = self.acl_validate(account_id, &address_book.acls).await {
|
if let Err(err) = self.acl_validate(account_id, &address_book.acls).await {
|
||||||
@@ -203,6 +211,14 @@ impl AddressBookSet for Server {
|
|||||||
continue 'update;
|
continue 'update;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'update;
|
||||||
|
}
|
||||||
if has_acl_changes {
|
if has_acl_changes {
|
||||||
if let Err(err) = self.acl_validate(account_id, &new_address_book.acls).await {
|
if let Err(err) = self.acl_validate(account_id, &new_address_book.acls).await {
|
||||||
response.not_updated.append(id, err.into());
|
response.not_updated.append(id, err.into());
|
||||||
|
|||||||
@@ -187,6 +187,21 @@ impl JmapRights {
|
|||||||
Value::Object(obj)
|
Value::Object(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-D0: an owner's rights, which for a group's member are
|
||||||
|
/// everything but sharing it on.
|
||||||
|
pub fn owner_rights<T: JmapSharedObject>(
|
||||||
|
access_token: &AccessToken,
|
||||||
|
account_id: u32,
|
||||||
|
) -> Value<'static, T::Property, T::Element> {
|
||||||
|
if access_token.is_group_member_only(account_id) {
|
||||||
|
let mut acl = Bitmap::<Acl>::all();
|
||||||
|
acl.remove(Acl::Share);
|
||||||
|
Self::rights::<T>(acl)
|
||||||
|
} else {
|
||||||
|
Self::all_rights::<T>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn rights<T: JmapSharedObject>(
|
pub fn rights<T: JmapSharedObject>(
|
||||||
acls: Bitmap<Acl>,
|
acls: Bitmap<Acl>,
|
||||||
) -> Value<'static, T::Property, T::Element> {
|
) -> Value<'static, T::Property, T::Element> {
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ impl JmapAuthorization for AccessToken {
|
|||||||
// inbuxa: accepted security items are read by whoever may
|
// inbuxa: accepted security items are read by whoever may
|
||||||
// see the server's security settings
|
// see the server's security settings
|
||||||
GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet,
|
GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet,
|
||||||
|
// inbuxa: deliverability spec; the lists are named on the
|
||||||
|
// page that shows the findings, so they read the same way
|
||||||
|
GetRequestMethod::DeliverabilityReport(_)
|
||||||
|
| GetRequestMethod::DeliverabilitySettings(_) => Permission::SysDeliverabilityGet,
|
||||||
// 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,
|
||||||
@@ -130,6 +134,10 @@ impl JmapAuthorization for AccessToken {
|
|||||||
// sign-in on the tenant's domains, so it takes the domain's
|
// sign-in on the tenant's domains, so it takes the domain's
|
||||||
// permissions, which a tenant administrator already holds.
|
// permissions, which a tenant administrator already holds.
|
||||||
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
||||||
|
// inbuxa: MA-C, who may share mail: a tenant administrator
|
||||||
|
// manages their tenant's, so the domain's permissions; the
|
||||||
|
// server's own also needs sysSharingUpdate (see the method)
|
||||||
|
GetRequestMethod::SharingPolicy(_) => 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,
|
||||||
@@ -331,6 +339,23 @@ impl JmapAuthorization for AccessToken {
|
|||||||
.details("You are not authorized to accept security items"))
|
.details("You are not authorized to accept security items"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// inbuxa: DL-15: a create runs the check; the handler
|
||||||
|
// refuses the rest
|
||||||
|
SetRequestMethod::DeliverabilityReport(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
|
),
|
||||||
|
// inbuxa: DL-6, which lists are asked
|
||||||
|
SetRequestMethod::DeliverabilitySettings(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
),
|
||||||
// inbuxa: LH-12, exporting held data
|
// inbuxa: LH-12, exporting held data
|
||||||
SetRequestMethod::HoldExport(s) => validate_set(
|
SetRequestMethod::HoldExport(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
@@ -370,6 +395,14 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysDomainUpdate,
|
Permission::SysDomainUpdate,
|
||||||
Permission::SysDomainUpdate,
|
Permission::SysDomainUpdate,
|
||||||
),
|
),
|
||||||
|
// inbuxa: MA-C, who may share mail, with the domain's
|
||||||
|
SetRequestMethod::SharingPolicy(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,
|
||||||
@@ -494,13 +527,16 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::HoldExport
|
| MethodObject::HoldExport
|
||||||
| MethodObject::MailRule
|
| MethodObject::MailRule
|
||||||
| MethodObject::SecurityAcceptance
|
| MethodObject::SecurityAcceptance
|
||||||
|
| MethodObject::DeliverabilityReport
|
||||||
|
| MethodObject::DeliverabilitySettings
|
||||||
| MethodObject::HeldMessage
|
| MethodObject::HeldMessage
|
||||||
| MethodObject::Journal
|
| MethodObject::Journal
|
||||||
| MethodObject::JournalEntry
|
| MethodObject::JournalEntry
|
||||||
| MethodObject::JournalExport
|
| MethodObject::JournalExport
|
||||||
| MethodObject::JournalVerification
|
| MethodObject::JournalVerification
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
| MethodObject::TenantProtocolPolicy
|
||||||
|
| MethodObject::SharingPolicy => 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(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ impl RequestHandler for Server {
|
|||||||
// inbuxa: AL-9: a delegate's access, and what it
|
// inbuxa: AL-9: a delegate's access, and what it
|
||||||
// changes, are recorded; anyone else here impersonated
|
// changes, are recorded; anyone else here impersonated
|
||||||
if let Some(delegation) = access_token.delegation(account_id) {
|
if let Some(delegation) = access_token.delegation(account_id) {
|
||||||
|
// MA-S: in a shared mailbox only what is sent as it
|
||||||
|
// is recorded (audit_send_as); every read and flag
|
||||||
|
// on a busy desk would bury the log
|
||||||
|
if delegation.kind.is_lock() {
|
||||||
let access = delegation.access.as_str();
|
let access = delegation.access.as_str();
|
||||||
self.audit_delegate(
|
self.audit_delegate(
|
||||||
access_token,
|
access_token,
|
||||||
@@ -213,6 +217,7 @@ impl RequestHandler for Server {
|
|||||||
result.as_ref().err(),
|
result.as_ref().err(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
}
|
||||||
if makes_containers
|
if makes_containers
|
||||||
&& result.is_ok()
|
&& result.is_ok()
|
||||||
&& let Err(err) =
|
&& let Err(err) =
|
||||||
@@ -288,6 +293,12 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::SecurityAcceptance(set_response) => {
|
SetResponseMethod::SecurityAcceptance(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::DeliverabilityReport(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::DeliverabilitySettings(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::Journal(set_response) => {
|
SetResponseMethod::Journal(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -312,6 +323,9 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::SharingPolicy(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);
|
||||||
}
|
}
|
||||||
@@ -531,6 +545,19 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: the deliverability check
|
||||||
|
GetRequestMethod::DeliverabilityReport(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::deliverability::get_reports(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
GetRequestMethod::DeliverabilitySettings(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::deliverability::get_settings(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
// inbuxa: journaling
|
// inbuxa: journaling
|
||||||
GetRequestMethod::Journal(mut req) => {
|
GetRequestMethod::Journal(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)?;
|
||||||
@@ -569,6 +596,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: inbuxa:SharingPolicy/get (MA-C, who may share mail)
|
||||||
|
GetRequestMethod::SharingPolicy(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::sharing_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()
|
||||||
}
|
}
|
||||||
@@ -783,7 +817,7 @@ impl RequestHandler for Server {
|
|||||||
// inbuxa: AL-8: a delegate may send as a locked account
|
// inbuxa: AL-8: a delegate may send as a locked account
|
||||||
access_token.assert_can_send(req.account_id)?;
|
access_token.assert_can_send(req.account_id)?;
|
||||||
|
|
||||||
self.email_submission_set(*req, &session.instance, next_call)
|
self.email_submission_set(*req, access_token, &session.instance, next_call)
|
||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -1045,6 +1079,35 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: DL-15, Check now; nothing it changes needs recording
|
||||||
|
SetRequestMethod::DeliverabilityReport(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::deliverability::set_reports(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
// inbuxa: DL-6; which lists are asked is in the audit log
|
||||||
|
SetRequestMethod::DeliverabilitySettings(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
*req,
|
||||||
|
|req| {
|
||||||
|
Box::pin(crate::inbuxa::deliverability::set_settings(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
req,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
SetRequestMethod::Journal(mut req) => {
|
SetRequestMethod::Journal(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)?;
|
||||||
let reason = req.arguments.reason.clone();
|
let reason = req.arguments.reason.clone();
|
||||||
@@ -1127,6 +1190,23 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: inbuxa:SharingPolicy/set (MA-C, who may share mail)
|
||||||
|
SetRequestMethod::SharingPolicy(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
// inbuxa: AU-1.2, AU-3
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::sharing_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)?;
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ impl SessionHandler for Server {
|
|||||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||||
&& access_token.tenant_id().is_none()
|
&& access_token.tenant_id().is_none()
|
||||||
&& self.ai_explain_model(&self.ai_limits().await).await.is_some();
|
&& self.ai_explain_model(&self.ai_limits().await).await.is_some();
|
||||||
|
// inbuxa: MA-C: what the sharing switches leave this principal
|
||||||
|
let sharing = inbuxa_features::security::sharing_policy::effective_for(
|
||||||
|
self.store(),
|
||||||
|
access_token.tenant_id(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
account.account_capabilities.append(
|
account.account_capabilities.append(
|
||||||
Capability::Inbuxa,
|
Capability::Inbuxa,
|
||||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||||
@@ -87,6 +94,8 @@ impl SessionHandler for Server {
|
|||||||
legacy_protocols,
|
legacy_protocols,
|
||||||
legacy_allowed,
|
legacy_allowed,
|
||||||
ai_explain,
|
ai_explain,
|
||||||
|
mail_sharing: sharing.mail_sharing,
|
||||||
|
add_accounts: sharing.add_accounts,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
||||||
@@ -148,6 +157,7 @@ impl SessionHandler for Server {
|
|||||||
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
|
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
|
||||||
delegation: DelegationInfo {
|
delegation: DelegationInfo {
|
||||||
locked: true,
|
locked: true,
|
||||||
|
kind: delegation.kind.as_str(),
|
||||||
access: delegation.access.as_str(),
|
access: delegation.access.as_str(),
|
||||||
send_as: delegation.send_as,
|
send_as: delegation.send_as,
|
||||||
until: delegation.until.map(|until| {
|
until: delegation.until.map(|until| {
|
||||||
|
|||||||
@@ -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::{api::acl::JmapRights, calendar::Availability, changes::state::JmapCacheState};
|
use crate::{api::acl::JmapRights, calendar::Availability, changes::state::JmapCacheState};
|
||||||
@@ -253,7 +255,7 @@ impl CalendarGet for Server {
|
|||||||
calendar.acls.effective_acl(access_token),
|
calendar.acls.effective_acl(access_token),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
JmapRights::all_rights::<calendar::Calendar>()
|
JmapRights::owner_rights::<calendar::Calendar>(access_token, account_id)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ impl CalendarSet for Server {
|
|||||||
continue 'create;
|
continue 'create;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if !calendar.acls.is_empty() && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_created.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
// Validate ACLs
|
// Validate ACLs
|
||||||
if !calendar.acls.is_empty() {
|
if !calendar.acls.is_empty() {
|
||||||
if let Err(err) = self.acl_validate(account_id, &calendar.acls).await {
|
if let Err(err) = self.acl_validate(account_id, &calendar.acls).await {
|
||||||
@@ -207,6 +215,14 @@ impl CalendarSet for Server {
|
|||||||
continue 'update;
|
continue 'update;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'update;
|
||||||
|
}
|
||||||
if has_acl_changes {
|
if has_acl_changes {
|
||||||
if let Err(err) = self.acl_validate(account_id, &new_calendar.acls).await {
|
if let Err(err) = self.acl_validate(account_id, &new_calendar.acls).await {
|
||||||
response.not_updated.append(id, err.into());
|
response.not_updated.append(id, err.into());
|
||||||
|
|||||||
@@ -432,6 +432,8 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::HoldExport
|
| MethodObject::HoldExport
|
||||||
| MethodObject::MailRule
|
| MethodObject::MailRule
|
||||||
| MethodObject::SecurityAcceptance
|
| MethodObject::SecurityAcceptance
|
||||||
|
| MethodObject::DeliverabilityReport
|
||||||
|
| MethodObject::DeliverabilitySettings
|
||||||
| MethodObject::Journal
|
| MethodObject::Journal
|
||||||
| MethodObject::JournalEntry
|
| MethodObject::JournalEntry
|
||||||
| MethodObject::JournalExport
|
| MethodObject::JournalExport
|
||||||
@@ -439,6 +441,7 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::HeldMessage
|
| MethodObject::HeldMessage
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy
|
| MethodObject::TenantProtocolPolicy
|
||||||
|
| MethodObject::SharingPolicy
|
||||||
| MethodObject::Registry(_) => unreachable!(),
|
| MethodObject::Registry(_) => unreachable!(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::{api::acl::JmapRights, changes::state::JmapCacheState};
|
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
||||||
@@ -172,7 +174,7 @@ impl FileNodeGet for Server {
|
|||||||
file_node.acls.effective_acl(access_token),
|
file_node.acls.effective_acl(access_token),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
JmapRights::all_rights::<file_node::FileNode>()
|
JmapRights::owner_rights::<file_node::FileNode>(access_token, account_id)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,16 @@ impl FileNodeSet for Server {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on,
|
||||||
|
// at the top of its files as anywhere else
|
||||||
|
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_created.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
|
|
||||||
// Inherit ACLs from parent
|
// Inherit ACLs from parent
|
||||||
if file_node.parent_id > 0 {
|
if file_node.parent_id > 0 {
|
||||||
let parent_id = file_node.parent_id - 1;
|
let parent_id = file_node.parent_id - 1;
|
||||||
@@ -509,6 +519,14 @@ impl FileNodeSet for Server {
|
|||||||
continue 'update;
|
continue 'update;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'update;
|
||||||
|
}
|
||||||
if has_acl_changes {
|
if has_acl_changes {
|
||||||
if let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await {
|
if let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await {
|
||||||
response.not_updated.append(id, err.into());
|
response.not_updated.append(id, err.into());
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use common::{
|
|||||||
};
|
};
|
||||||
use email::inbuxa_lock::apply_grants;
|
use email::inbuxa_lock::apply_grants;
|
||||||
use groupware::inbuxa_lock::invalidate;
|
use groupware::inbuxa_lock::invalidate;
|
||||||
use inbuxa_features::lock::{self, Access, Delegate, Lock, MAX_DELEGATES};
|
use inbuxa_features::lock::{self, Access, Delegate, Kind, Lock};
|
||||||
use jmap_proto::{
|
use jmap_proto::{
|
||||||
error::set::SetError,
|
error::set::SetError,
|
||||||
method::{
|
method::{
|
||||||
@@ -39,6 +39,7 @@ const ALL: &[P] = &[
|
|||||||
P::Id,
|
P::Id,
|
||||||
P::AccountId,
|
P::AccountId,
|
||||||
P::Name,
|
P::Name,
|
||||||
|
P::Kind,
|
||||||
P::Reason,
|
P::Reason,
|
||||||
P::LockedAt,
|
P::LockedAt,
|
||||||
P::LockedBy,
|
P::LockedBy,
|
||||||
@@ -75,6 +76,7 @@ async fn parse_delegates(
|
|||||||
server: &Server,
|
server: &Server,
|
||||||
access_token: &AccessToken,
|
access_token: &AccessToken,
|
||||||
locked_id: u32,
|
locked_id: u32,
|
||||||
|
kind: Kind,
|
||||||
value: LValue,
|
value: LValue,
|
||||||
) -> Result<Vec<Delegate>, SetError<P>> {
|
) -> Result<Vec<Delegate>, SetError<P>> {
|
||||||
let invalid = |why: String| {
|
let invalid = |why: String| {
|
||||||
@@ -86,8 +88,10 @@ async fn parse_delegates(
|
|||||||
let Some(items) = json.as_array() else {
|
let Some(items) = json.as_array() else {
|
||||||
return Err(invalid("delegates must be a list.".into()));
|
return Err(invalid("delegates must be a list.".into()));
|
||||||
};
|
};
|
||||||
if items.len() > MAX_DELEGATES {
|
// MA-S: a shared mailbox holds more people than a lock hands over
|
||||||
return Err(invalid(format!("At most {MAX_DELEGATES} delegates.")));
|
let max = kind.max_delegates();
|
||||||
|
if items.len() > max {
|
||||||
|
return Err(invalid(format!("At most {max} delegates.")));
|
||||||
}
|
}
|
||||||
let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
|
let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
|
||||||
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
|
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
|
||||||
@@ -173,6 +177,7 @@ async fn to_value(server: &Server, lock: &Lock, properties: &[P]) -> LValue {
|
|||||||
let value = match property {
|
let value = match property {
|
||||||
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
|
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
|
||||||
P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()),
|
P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()),
|
||||||
|
P::Kind => Value::Str(Cow::Borrowed(lock.kind.as_str())),
|
||||||
P::Reason => Value::Str(lock.reason.clone().into()),
|
P::Reason => Value::Str(lock.reason.clone().into()),
|
||||||
P::LockedAt => date(lock.locked_at),
|
P::LockedAt => date(lock.locked_at),
|
||||||
P::LockedBy => Value::Str(lock.locked_by.clone().into()),
|
P::LockedBy => Value::Str(lock.locked_by.clone().into()),
|
||||||
@@ -283,6 +288,7 @@ pub async fn set(
|
|||||||
|
|
||||||
for (client_id, value) in request.unwrap_create() {
|
for (client_id, value) in request.unwrap_create() {
|
||||||
let mut account_id = None;
|
let mut account_id = None;
|
||||||
|
let mut kind = Kind::Lock;
|
||||||
let mut reason = None;
|
let mut reason = None;
|
||||||
let mut delegates_value = None;
|
let mut delegates_value = None;
|
||||||
let mut invalid = None;
|
let mut invalid = None;
|
||||||
@@ -291,6 +297,17 @@ pub async fn set(
|
|||||||
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
|
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
|
||||||
account_id = Some(id.document_id())
|
account_id = Some(id.document_id())
|
||||||
}
|
}
|
||||||
|
(Key::Property(P::Kind), Value::Str(k)) => match Kind::parse(&k) {
|
||||||
|
Some(k) => kind = k,
|
||||||
|
None => {
|
||||||
|
invalid = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(P::Kind)
|
||||||
|
.with_description("kind must be lock or sharedMailbox."),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
(Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
|
(Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
|
||||||
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
|
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -310,9 +327,14 @@ pub async fn set(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else {
|
// MA-S: a shared mailbox needs no reason; a lock always does
|
||||||
|
let reason = match reason.or_else(|| reason_of(arguments.reason.as_deref())) {
|
||||||
|
Some(reason) => reason,
|
||||||
|
None if kind == Kind::SharedMailbox => String::new(),
|
||||||
|
None => {
|
||||||
response.not_created.append(client_id, reason_required());
|
response.not_created.append(client_id, reason_required());
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
||||||
response.not_created.append(client_id, error);
|
response.not_created.append(client_id, error);
|
||||||
@@ -321,12 +343,13 @@ pub async fn set(
|
|||||||
if lock::get(data, account_id).await?.is_some() {
|
if lock::get(data, account_id).await?.is_some() {
|
||||||
response.not_created.append(
|
response.not_created.append(
|
||||||
client_id,
|
client_id,
|
||||||
SetError::already_exists().with_description("That account is already locked."),
|
SetError::already_exists()
|
||||||
|
.with_description("That account is already locked or a shared mailbox."),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let delegates = match delegates_value {
|
let delegates = match delegates_value {
|
||||||
Some(value) => match parse_delegates(server, access_token, account_id, value).await {
|
Some(value) => match parse_delegates(server, access_token, account_id, kind, value).await {
|
||||||
Ok(delegates) => delegates,
|
Ok(delegates) => delegates,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
response.not_created.append(client_id, error);
|
response.not_created.append(client_id, error);
|
||||||
@@ -337,6 +360,7 @@ pub async fn set(
|
|||||||
};
|
};
|
||||||
let mut created = Lock {
|
let mut created = Lock {
|
||||||
account_id,
|
account_id,
|
||||||
|
kind,
|
||||||
reason,
|
reason,
|
||||||
locked_at: now(),
|
locked_at: now(),
|
||||||
locked_by: actor.name.clone(),
|
locked_by: actor.name.clone(),
|
||||||
@@ -370,7 +394,7 @@ pub async fn set(
|
|||||||
response.not_updated.append(id, SetError::not_found());
|
response.not_updated.append(id, SetError::not_found());
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if reason_of(arguments.reason.as_deref()).is_none() {
|
if current.kind.is_lock() && reason_of(arguments.reason.as_deref()).is_none() {
|
||||||
response.not_updated.append(id, reason_required());
|
response.not_updated.append(id, reason_required());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -379,7 +403,9 @@ pub async fn set(
|
|||||||
for (key, value) in value.into_expanded_object() {
|
for (key, value) in value.into_expanded_object() {
|
||||||
match (&key, value) {
|
match (&key, value) {
|
||||||
(Key::Property(P::Delegates), value) => {
|
(Key::Property(P::Delegates), value) => {
|
||||||
match parse_delegates(server, access_token, account_id, value.into_owned()).await {
|
match parse_delegates(server, access_token, account_id, current.kind, value.into_owned())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(delegates) => updated.delegates = delegates,
|
Ok(delegates) => updated.delegates = delegates,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
invalid = Some(error);
|
invalid = Some(error);
|
||||||
@@ -389,6 +415,7 @@ pub async fn set(
|
|||||||
}
|
}
|
||||||
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
|
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
|
||||||
Some(r) => updated.reason = r,
|
Some(r) => updated.reason = r,
|
||||||
|
None if !current.kind.is_lock() => updated.reason = String::new(),
|
||||||
None => {
|
None => {
|
||||||
invalid = Some(reason_required());
|
invalid = Some(reason_required());
|
||||||
break;
|
break;
|
||||||
@@ -420,7 +447,7 @@ pub async fn set(
|
|||||||
response.not_destroyed.append(id, SetError::not_found());
|
response.not_destroyed.append(id, SetError::not_found());
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if reason_of(arguments.reason.as_deref()).is_none() {
|
if current.kind.is_lock() && reason_of(arguments.reason.as_deref()).is_none() {
|
||||||
response.not_destroyed.append(id, reason_required());
|
response.not_destroyed.append(id, reason_required());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings`
|
||||||
|
//! (deliverability spec).
|
||||||
|
//!
|
||||||
|
//! A report is one sending node's last check, written by that node. Reading
|
||||||
|
//! reports needs `sysDeliverabilityGet`; a tenant administrator gets only
|
||||||
|
//! their tenant's domains and nothing about the nodes (DL-20). Creating a
|
||||||
|
//! report asks every node to check itself now (DL-15): it needs
|
||||||
|
//! `sysDeliverabilityCheck`, returns at once with the node's last check
|
||||||
|
//! time, and the new report replaces the old one when it's done. The
|
||||||
|
//! settings say which built-in lists are left out (DL-6).
|
||||||
|
|
||||||
|
use common::{Server, auth::AccessToken, ipc::BroadcastEvent};
|
||||||
|
use inbuxa_features::deliverability::{
|
||||||
|
self as model, Report, Settings,
|
||||||
|
lists::{self, Scope},
|
||||||
|
};
|
||||||
|
use jmap_proto::{
|
||||||
|
error::set::SetError,
|
||||||
|
method::{
|
||||||
|
get::{GetRequest, GetResponse},
|
||||||
|
set::{SetRequest, SetResponse},
|
||||||
|
},
|
||||||
|
object::{
|
||||||
|
inbuxa_deliverability_report::{
|
||||||
|
DeliverabilityReport, DeliverabilityReportProperty as R, DeliverabilityReportValue,
|
||||||
|
},
|
||||||
|
inbuxa_deliverability_settings::{
|
||||||
|
DeliverabilitySettings, DeliverabilitySettingsProperty as S,
|
||||||
|
DeliverabilitySettingsValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request::IntoValid,
|
||||||
|
types::date::UTCDate,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Element, Key, Map, Property, Value};
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
const REPORT: &[R] = &[
|
||||||
|
R::Id,
|
||||||
|
R::NodeId,
|
||||||
|
R::Hostname,
|
||||||
|
R::CheckedAt,
|
||||||
|
R::Addresses,
|
||||||
|
R::Domains,
|
||||||
|
R::Certificates,
|
||||||
|
];
|
||||||
|
|
||||||
|
const SETTINGS: &[S] = &[S::Id, S::DisabledLists, S::Lists];
|
||||||
|
|
||||||
|
fn server_level(access_token: &AccessToken, what: &'static str) -> trc::Result<()> {
|
||||||
|
if access_token.tenant_id().is_some() {
|
||||||
|
Err(trc::JmapEvent::Forbidden.into_err().details(what))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_value<P: Property, E: Element>(json: serde_json::Value) -> Value<'static, P, E> {
|
||||||
|
match json {
|
||||||
|
serde_json::Value::Null => Value::Null,
|
||||||
|
serde_json::Value::Bool(b) => Value::Bool(b),
|
||||||
|
serde_json::Value::Number(n) => {
|
||||||
|
if let Some(n) = n.as_u64() {
|
||||||
|
Value::Number(n.into())
|
||||||
|
} else if let Some(n) = n.as_i64() {
|
||||||
|
Value::Number(n.into())
|
||||||
|
} else {
|
||||||
|
Value::Number(n.as_f64().unwrap_or_default().into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::String(s) => Value::Str(Cow::Owned(s)),
|
||||||
|
serde_json::Value::Array(items) => {
|
||||||
|
Value::Array(items.into_iter().map(json_to_value).collect())
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
let mut out = Map::with_capacity(map.len());
|
||||||
|
for (key, value) in map {
|
||||||
|
out.insert_unchecked(Key::Owned(key), json_to_value(value));
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date(seconds: u64) -> Value<'static, R, DeliverabilityReportValue> {
|
||||||
|
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_value(report: &Report, properties: &[R]) -> Value<'static, R, DeliverabilityReportValue> {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
R::Id => Value::Element(DeliverabilityReportValue::Id(Id::from(report.node_id))),
|
||||||
|
R::NodeId => Value::Number(report.node_id.into()),
|
||||||
|
R::Hostname => Value::Str(report.hostname.clone().into()),
|
||||||
|
R::CheckedAt => date(report.checked_at),
|
||||||
|
R::Addresses => {
|
||||||
|
json_to_value(serde_json::to_value(&report.addresses).unwrap_or_default())
|
||||||
|
}
|
||||||
|
R::Domains => json_to_value(serde_json::to_value(&report.domains).unwrap_or_default()),
|
||||||
|
R::Certificates => {
|
||||||
|
json_to_value(serde_json::to_value(&report.certificates).unwrap_or_default())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:DeliverabilityReport/get`: every sending node's last report.
|
||||||
|
pub async fn get_reports(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: GetRequest<DeliverabilityReport>,
|
||||||
|
) -> trc::Result<GetResponse<DeliverabilityReport>> {
|
||||||
|
let properties = request.unwrap_properties(REPORT);
|
||||||
|
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 mut reports = model::reports(server.store()).await?;
|
||||||
|
// DL-20
|
||||||
|
if let Some(tenant_id) = access_token.tenant_id() {
|
||||||
|
reports = reports.iter().map(|r| r.for_tenant(tenant_id)).collect();
|
||||||
|
}
|
||||||
|
match ids {
|
||||||
|
None => {
|
||||||
|
response.list = reports
|
||||||
|
.iter()
|
||||||
|
.map(|r| report_value(r, &properties))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
Some(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
match reports.iter().find(|r| r.node_id == id.id()) {
|
||||||
|
Some(report) => response.list.push(report_value(report, &properties)),
|
||||||
|
None => response.push_not_found(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:DeliverabilityReport/set`: a create asks every node to check
|
||||||
|
/// itself now (DL-15). Reports are the server's: nothing else is allowed.
|
||||||
|
pub async fn set_reports(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, DeliverabilityReport>,
|
||||||
|
) -> trc::Result<SetResponse<DeliverabilityReport>> {
|
||||||
|
server_level(access_token, "The deliverability check is the server's.")?;
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
let node_id = server.core.network.node_id;
|
||||||
|
|
||||||
|
let mut asked = false;
|
||||||
|
for (client_id, _) in request.unwrap_create() {
|
||||||
|
if !asked {
|
||||||
|
asked = true;
|
||||||
|
services::inbuxa_deliverability::CHECK_NOW.notify_one();
|
||||||
|
server
|
||||||
|
.cluster_broadcast(BroadcastEvent::DeliverabilityCheck)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
// The node's last check, so the console knows when the new one lands
|
||||||
|
let last = model::report(server.store(), node_id).await?;
|
||||||
|
let mut out = Map::with_capacity(2);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(R::Id),
|
||||||
|
Value::Element(DeliverabilityReportValue::Id(Id::from(node_id))),
|
||||||
|
);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(R::CheckedAt),
|
||||||
|
last.map(|r| date(r.checked_at)).unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
response.created.insert(client_id, Value::Object(out));
|
||||||
|
}
|
||||||
|
for (id, _) in request.unwrap_update().into_valid() {
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("Reports are written by the check."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for id in request.unwrap_destroy().into_valid() {
|
||||||
|
response.not_destroyed.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("Reports are written by the check."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lists_value() -> Value<'static, S, DeliverabilitySettingsValue> {
|
||||||
|
Value::Array(
|
||||||
|
lists::LISTS
|
||||||
|
.iter()
|
||||||
|
.map(|list| {
|
||||||
|
json_to_value(serde_json::json!({
|
||||||
|
"name": list.name,
|
||||||
|
"zone": list.zone,
|
||||||
|
"scope": match list.scope {
|
||||||
|
Scope::Ip => "ip",
|
||||||
|
Scope::Domain => "domain",
|
||||||
|
},
|
||||||
|
"lookup": list.lookup,
|
||||||
|
"note": list.note,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_value(
|
||||||
|
settings: &Settings,
|
||||||
|
properties: &[S],
|
||||||
|
) -> Value<'static, S, DeliverabilitySettingsValue> {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
S::Id => Value::Element(DeliverabilitySettingsValue::Id(Id::singleton())),
|
||||||
|
S::DisabledLists => Value::Array(
|
||||||
|
settings
|
||||||
|
.disabled_lists
|
||||||
|
.iter()
|
||||||
|
.map(|name| Value::Str(name.clone().into()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
S::Lists => lists_value(),
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:DeliverabilitySettings/get`: which lists are left out, and the lists.
|
||||||
|
pub async fn get_settings(
|
||||||
|
server: &Server,
|
||||||
|
_access_token: &AccessToken,
|
||||||
|
mut request: GetRequest<DeliverabilitySettings>,
|
||||||
|
) -> trc::Result<GetResponse<DeliverabilitySettings>> {
|
||||||
|
let properties = request.unwrap_properties(SETTINGS);
|
||||||
|
let (ids, not_found) = request.unwrap_ids(1)?;
|
||||||
|
let mut response = GetResponse {
|
||||||
|
account_id: request.account_id.into(),
|
||||||
|
state: None,
|
||||||
|
list: Vec::new(),
|
||||||
|
not_found,
|
||||||
|
};
|
||||||
|
let settings = model::settings(server.store()).await?;
|
||||||
|
match ids {
|
||||||
|
None => response.list.push(settings_value(&settings, &properties)),
|
||||||
|
Some(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
if id.is_singleton() {
|
||||||
|
response.list.push(settings_value(&settings, &properties));
|
||||||
|
} else {
|
||||||
|
response.push_not_found(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:DeliverabilitySettings/set`: updates the singleton.
|
||||||
|
pub async fn set_settings(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, DeliverabilitySettings>,
|
||||||
|
) -> trc::Result<SetResponse<DeliverabilitySettings>> {
|
||||||
|
server_level(access_token, "The blocklists checked are the server's.")?;
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
for (client_id, _) in request.unwrap_create() {
|
||||||
|
response
|
||||||
|
.not_created
|
||||||
|
.append(client_id, SetError::singleton());
|
||||||
|
}
|
||||||
|
for id in request.unwrap_destroy().into_valid() {
|
||||||
|
response.not_destroyed.append(id, SetError::singleton());
|
||||||
|
}
|
||||||
|
let data = server.store();
|
||||||
|
for (id, value) in request.unwrap_update().into_valid() {
|
||||||
|
if !id.is_singleton() {
|
||||||
|
response.not_updated.append(id, SetError::not_found());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut settings = model::settings(data).await?;
|
||||||
|
let mut error = None;
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
match &key {
|
||||||
|
Key::Property(S::DisabledLists) => {
|
||||||
|
let names = value.as_array().map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.as_str().map(|s| s.to_string()))
|
||||||
|
.collect::<Option<Vec<_>>>()
|
||||||
|
});
|
||||||
|
match names {
|
||||||
|
Some(Some(names)) => settings.disabled_lists = names,
|
||||||
|
_ => {
|
||||||
|
error = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(S::DisabledLists)
|
||||||
|
.with_description("A list of list names."),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key::Property(property) => {
|
||||||
|
error = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(property.clone())
|
||||||
|
.with_description("The server sets this."),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if error.is_none()
|
||||||
|
&& let Err(why) = settings.validate()
|
||||||
|
{
|
||||||
|
error = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(S::DisabledLists)
|
||||||
|
.with_description(why),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match error {
|
||||||
|
Some(error) => response.not_updated.append(id, error),
|
||||||
|
None => {
|
||||||
|
model::put_settings(data, &settings).await?;
|
||||||
|
response.updated.append(id, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ pub mod account_lock;
|
|||||||
pub mod legal_hold;
|
pub mod legal_hold;
|
||||||
pub mod mail_rule;
|
pub mod mail_rule;
|
||||||
pub mod security_acceptance;
|
pub mod security_acceptance;
|
||||||
|
pub mod deliverability; // inbuxa: the deliverability check
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
pub mod journal_entry;
|
pub mod journal_entry;
|
||||||
pub mod held_message;
|
pub mod held_message;
|
||||||
@@ -28,6 +29,7 @@ pub mod webhook_test;
|
|||||||
pub mod explanation;
|
pub mod explanation;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
pub mod tenant_protocol_policy;
|
pub mod tenant_protocol_policy;
|
||||||
|
pub mod sharing_policy;
|
||||||
pub mod deleted_account;
|
pub mod deleted_account;
|
||||||
pub mod fastmail;
|
pub mod fastmail;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:SharingPolicy/get` and `/set`: whether people may share their own
|
||||||
|
//! mail and add other accounts to the webmail (multi-account spec, MA-C).
|
||||||
|
//!
|
||||||
|
//! The server's policy has the singleton id; each tenant's has the tenant's
|
||||||
|
//! id. At server level `/get` with no ids answers with the server's and every
|
||||||
|
//! tenant's; inside a tenant, with the server's (to read) and its own tenant's
|
||||||
|
//! (MT-1). Only a server administrator holding `sysSharingUpdate` changes the
|
||||||
|
//! server's; a tenant's administrator changes their tenant's, and can only be
|
||||||
|
//! stricter than the server.
|
||||||
|
//!
|
||||||
|
//! A change rebuilds every access token, here and on every node: what a share
|
||||||
|
//! still gives is worked out when a token is built.
|
||||||
|
|
||||||
|
use common::{Server, auth::AccessToken, ipc::BroadcastEvent};
|
||||||
|
use inbuxa_features::{
|
||||||
|
security::sharing_policy::{self, SharingPolicy as Policy, looser_than_server},
|
||||||
|
tenancy::quota::all_tenants,
|
||||||
|
};
|
||||||
|
use jmap_proto::{
|
||||||
|
error::set::SetError,
|
||||||
|
method::{
|
||||||
|
get::{GetRequest, GetResponse},
|
||||||
|
set::{SetRequest, SetResponse},
|
||||||
|
},
|
||||||
|
object::inbuxa_sharing_policy::{SharingPolicy, SharingPolicyProperty as P, SharingPolicyValue},
|
||||||
|
request::IntoValid,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Key, Map, Value};
|
||||||
|
use registry::schema::enums::Permission;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
type PValue = Value<'static, P, SharingPolicyValue>;
|
||||||
|
|
||||||
|
const ALL: &[P] = &[P::Id, P::TenantId, P::MailSharing, P::AddAccounts, P::ChangedAt, P::ChangedBy];
|
||||||
|
|
||||||
|
fn switch_str(on: Option<bool>) -> &'static str {
|
||||||
|
if on.unwrap_or(true) { "enabled" } else { "disabled" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_value(tenant_id: Option<u32>, policy: &Policy, properties: &[P]) -> PValue {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
P::Id => Value::Element(SharingPolicyValue::Id(
|
||||||
|
tenant_id.map_or_else(Id::singleton, Id::from),
|
||||||
|
)),
|
||||||
|
P::TenantId => tenant_id
|
||||||
|
.map(|t| Value::Element(SharingPolicyValue::Id(Id::from(t))))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
P::MailSharing => Value::Str(switch_str(policy.mail_sharing).into()),
|
||||||
|
P::AddAccounts => Value::Str(switch_str(policy.add_accounts).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),
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which policy an id names: `None` for the server's.
|
||||||
|
fn target(id: Id) -> Option<u32> {
|
||||||
|
if id.is_singleton() { None } else { Some(id.document_id()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:SharingPolicy/get`.
|
||||||
|
pub async fn get(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: GetRequest<SharingPolicy>,
|
||||||
|
) -> trc::Result<GetResponse<SharingPolicy>> {
|
||||||
|
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: Vec<Id> = match ids {
|
||||||
|
None => std::iter::once(Id::singleton())
|
||||||
|
.chain(reachable.iter().map(|t| Id::from(*t)))
|
||||||
|
.collect(),
|
||||||
|
Some(ids) => ids,
|
||||||
|
};
|
||||||
|
let data = &server.core.storage.data;
|
||||||
|
for id in wanted {
|
||||||
|
match target(id) {
|
||||||
|
None => {
|
||||||
|
let policy = sharing_policy::get(data, None).await?;
|
||||||
|
response.list.push(to_value(None, &policy, &properties));
|
||||||
|
}
|
||||||
|
Some(tenant_id) if reachable.contains(&tenant_id) => {
|
||||||
|
let policy = sharing_policy::get(data, Some(tenant_id)).await?;
|
||||||
|
response.list.push(to_value(Some(tenant_id), &policy, &properties));
|
||||||
|
}
|
||||||
|
Some(_) => response.push_not_found(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:SharingPolicy/set`: turns switches. `null` puts one back to its
|
||||||
|
/// default, on (as far as the server allows).
|
||||||
|
pub async fn set(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, SharingPolicy>,
|
||||||
|
) -> trc::Result<SetResponse<SharingPolicy>> {
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
for (client_id, _) in request.unwrap_create() {
|
||||||
|
response.not_created.append(
|
||||||
|
client_id,
|
||||||
|
SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for id in request.unwrap_destroy().into_valid() {
|
||||||
|
response.not_destroyed.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("A sharing policy exists with the server or the tenant."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let reachable = reachable(server, access_token).await?;
|
||||||
|
let data = &server.core.storage.data;
|
||||||
|
let mut changed = false;
|
||||||
|
for (id, value) in request.unwrap_update().into_valid() {
|
||||||
|
let tenant_id = target(id);
|
||||||
|
match tenant_id {
|
||||||
|
None if access_token.tenant_id().is_some()
|
||||||
|
|| !access_token.has_permission(Permission::SysSharingUpdate) =>
|
||||||
|
{
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden()
|
||||||
|
.with_description("Only a server administrator changes the server's sharing policy."),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Some(tenant_id) if !reachable.contains(&tenant_id) => {
|
||||||
|
response.not_updated.append(id, SetError::not_found());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous = sharing_policy::get(data, tenant_id).await?;
|
||||||
|
let mut policy = previous.clone();
|
||||||
|
let mut error = None;
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
let parsed = match value {
|
||||||
|
Value::Null => Ok(None),
|
||||||
|
Value::Str(s) if s == "enabled" => Ok(Some(true)),
|
||||||
|
Value::Str(s) if s == "disabled" => Ok(Some(false)),
|
||||||
|
_ => Err("must be enabled or disabled".to_string()),
|
||||||
|
};
|
||||||
|
let result = match &key {
|
||||||
|
Key::Property(P::MailSharing) => parsed.map(|v| policy.mail_sharing = v),
|
||||||
|
Key::Property(P::AddAccounts) => parsed.map(|v| policy.add_accounts = v),
|
||||||
|
Key::Property(P::Id) => Err("is immutable".to_string()),
|
||||||
|
Key::Property(_) => Err("is set by the server".to_string()),
|
||||||
|
_ => Err("is not a property of inbuxa:SharingPolicy".to_string()),
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tenant can only be stricter than the server
|
||||||
|
if tenant_id.is_some()
|
||||||
|
&& let Some(why) = looser_than_server(&sharing_policy::get(data, None).await?, &policy)
|
||||||
|
{
|
||||||
|
response
|
||||||
|
.not_updated
|
||||||
|
.append(id, SetError::forbidden().with_description(why));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if policy.mail_sharing != previous.mail_sharing || policy.add_accounts != previous.add_accounts {
|
||||||
|
policy.changed_at = Some(store::write::now() * 1000);
|
||||||
|
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
|
||||||
|
sharing_policy::set(data, tenant_id, &policy).await?;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
response.updated.append(id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
// Shares are honored, or not, as tokens are built
|
||||||
|
server.invalidate_all_local_caches();
|
||||||
|
server.cluster_broadcast(BroadcastEvent::CacheInvalidateAll).await;
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
//! `x:Metric/get` and `/query` over the stored history (monitoring spec,
|
//! `x:Metric/get` and `/query` over the stored history (monitoring spec,
|
||||||
//! "Interfaces"). Samples are server-level (MON-31) and read-only (MON-32).
|
//! "Interfaces"). Samples are server-level (MON-31) and read-only (MON-32).
|
||||||
//! A sample's `timestamp` comes from its id.
|
//! A sample's `timestamp` and `nodeId` come from its id.
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::query::QueryResponseBuilder,
|
api::query::QueryResponseBuilder,
|
||||||
@@ -54,12 +54,16 @@ fn metric_type(metric: &Metric) -> MetricType {
|
|||||||
|
|
||||||
fn to_value(sample: StoredMetric) -> JmapValue<'static> {
|
fn to_value(sample: StoredMetric) -> JmapValue<'static> {
|
||||||
let timestamp = sample.timestamp();
|
let timestamp = sample.timestamp();
|
||||||
|
let node_id = sample.node_id();
|
||||||
let mut value = sample.metric.into_value();
|
let mut value = sample.metric.into_value();
|
||||||
if let JmapValue::Object(obj) = &mut value {
|
if let JmapValue::Object(obj) = &mut value {
|
||||||
obj.insert_unchecked(
|
obj.insert_unchecked(
|
||||||
Property::Timestamp,
|
Property::Timestamp,
|
||||||
JmapValue::Str(UTCDateTime::from_timestamp(timestamp as i64).to_string().into()),
|
JmapValue::Str(UTCDateTime::from_timestamp(timestamp as i64).to_string().into()),
|
||||||
);
|
);
|
||||||
|
// Histograms are running totals per node; without this a reader
|
||||||
|
// diffs one node's total against another's
|
||||||
|
obj.insert_unchecked(Property::NodeId, JmapValue::Number(node_id.into()));
|
||||||
}
|
}
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::{Server, auth::AccessToken, sharing::EffectiveAcl};
|
use common::{Server, auth::AccessToken, sharing::EffectiveAcl};
|
||||||
@@ -14,6 +16,7 @@ use jmap_tools::{Map, Value};
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use store::ahash::AHashSet;
|
use store::ahash::AHashSet;
|
||||||
use types::{acl::Acl, collection::Collection, keyword::Keyword, special_use::SpecialUse};
|
use types::{acl::Acl, collection::Collection, keyword::Keyword, special_use::SpecialUse};
|
||||||
|
use utils::map::bitmap::Bitmap;
|
||||||
|
|
||||||
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
||||||
|
|
||||||
@@ -138,6 +141,11 @@ impl MailboxGet for Server {
|
|||||||
JmapRights::rights::<Mailbox>(
|
JmapRights::rights::<Mailbox>(
|
||||||
cached_mailbox.acls.as_slice().effective_acl(access_token),
|
cached_mailbox.acls.as_slice().effective_acl(access_token),
|
||||||
)
|
)
|
||||||
|
} else if access_token.is_group_member_only(account_id) {
|
||||||
|
// inbuxa: MA-D0: everything but sharing it on.
|
||||||
|
let mut acl = Bitmap::<Acl>::all();
|
||||||
|
acl.remove(Acl::Share);
|
||||||
|
JmapRights::rights::<Mailbox>(acl)
|
||||||
} else {
|
} else {
|
||||||
JmapRights::all_rights::<Mailbox>()
|
JmapRights::all_rights::<Mailbox>()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ use jmap_proto::{
|
|||||||
types::state::State,
|
types::state::State,
|
||||||
};
|
};
|
||||||
use jmap_tools::{JsonPointerItem, Key, Map, Value};
|
use jmap_tools::{JsonPointerItem, Key, Map, Value};
|
||||||
use registry::schema::enums::StorageQuota;
|
use registry::schema::enums::{Permission, StorageQuota};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use store::{
|
use store::{
|
||||||
ValueKey,
|
ValueKey,
|
||||||
@@ -613,6 +613,36 @@ impl MailboxSet for Server {
|
|||||||
// Refresh ACLs
|
// Refresh ACLs
|
||||||
let current = update.map(|(_, current)| current);
|
let current = update.map(|(_, current)| current);
|
||||||
if has_acl_changes {
|
if has_acl_changes {
|
||||||
|
// inbuxa: MA-D0: a group's members don't share its mailboxes on.
|
||||||
|
if ctx.access_token.is_group_member_only(ctx.account_id) {
|
||||||
|
return Ok(Err(SetError::forbidden()
|
||||||
|
.with_property(MailboxProperty::ShareWith)
|
||||||
|
.with_description(
|
||||||
|
"This mailbox belongs to a group. Only an administrator can change who has it.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-C: with mail sharing off, nobody here starts or
|
||||||
|
// widens a share (narrowing or ending one is always allowed)
|
||||||
|
let before = current.as_ref().map(|m| m.inner.acls.as_slice()).unwrap_or_default();
|
||||||
|
let widens = changes.acls.iter().any(|grant| {
|
||||||
|
let had = before
|
||||||
|
.iter()
|
||||||
|
.find(|old| old.account_id == grant.account_id)
|
||||||
|
.map_or(0, |old| old.grants.clone().into_inner());
|
||||||
|
grant.grants.clone().into_inner() & !had != 0
|
||||||
|
});
|
||||||
|
if widens
|
||||||
|
&& !ctx.access_token.has_permission(Permission::Impersonate)
|
||||||
|
&& !self.mail_sharing_allowed(ctx.account_id).await?
|
||||||
|
{
|
||||||
|
return Ok(Err(SetError::forbidden()
|
||||||
|
.with_property(MailboxProperty::ShareWith)
|
||||||
|
.with_description(
|
||||||
|
"Your organization has turned off sharing mail folders. A shared mailbox or a group can be set up by an administrator instead.",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
if !changes.acls.is_empty()
|
if !changes.acls.is_empty()
|
||||||
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
|
&& let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use common::{
|
use common::{
|
||||||
Server,
|
Server,
|
||||||
|
auth::AccessToken,
|
||||||
config::smtp::queue::QueueName,
|
config::smtp::queue::QueueName,
|
||||||
network::{ServerInstance, stream::NullIo},
|
network::{ServerInstance, stream::NullIo},
|
||||||
storage::index::ObjectIndexBuilder,
|
storage::index::ObjectIndexBuilder,
|
||||||
@@ -49,6 +50,7 @@ pub trait EmailSubmissionSet: Sync + Send {
|
|||||||
fn email_submission_set<'x>(
|
fn email_submission_set<'x>(
|
||||||
&self,
|
&self,
|
||||||
request: SetRequest<'x, email_submission::EmailSubmission>,
|
request: SetRequest<'x, email_submission::EmailSubmission>,
|
||||||
|
access_token: &AccessToken,
|
||||||
instance: &Arc<ServerInstance>,
|
instance: &Arc<ServerInstance>,
|
||||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||||
) -> impl Future<Output = trc::Result<SetResponse<email_submission::EmailSubmission>>> + Send;
|
) -> impl Future<Output = trc::Result<SetResponse<email_submission::EmailSubmission>>> + Send;
|
||||||
@@ -56,6 +58,7 @@ pub trait EmailSubmissionSet: Sync + Send {
|
|||||||
fn send_message(
|
fn send_message(
|
||||||
&self,
|
&self,
|
||||||
account_id: u32,
|
account_id: u32,
|
||||||
|
own_addresses_only: bool,
|
||||||
response: &SetResponse<email_submission::EmailSubmission>,
|
response: &SetResponse<email_submission::EmailSubmission>,
|
||||||
instance: &Arc<ServerInstance>,
|
instance: &Arc<ServerInstance>,
|
||||||
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
||||||
@@ -68,6 +71,7 @@ impl EmailSubmissionSet for Server {
|
|||||||
async fn email_submission_set<'x>(
|
async fn email_submission_set<'x>(
|
||||||
&self,
|
&self,
|
||||||
mut request: SetRequest<'x, email_submission::EmailSubmission>,
|
mut request: SetRequest<'x, email_submission::EmailSubmission>,
|
||||||
|
access_token: &AccessToken,
|
||||||
instance: &Arc<ServerInstance>,
|
instance: &Arc<ServerInstance>,
|
||||||
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
next_call: &mut Option<Call<RequestMethod<'x>>>,
|
||||||
) -> trc::Result<SetResponse<email_submission::EmailSubmission>> {
|
) -> trc::Result<SetResponse<email_submission::EmailSubmission>> {
|
||||||
@@ -80,7 +84,14 @@ impl EmailSubmissionSet for Server {
|
|||||||
let mut batch = BatchBuilder::new();
|
let mut batch = BatchBuilder::new();
|
||||||
for (id, object) in request.unwrap_create() {
|
for (id, object) in request.unwrap_create() {
|
||||||
match self
|
match self
|
||||||
.send_message(account_id, &response, instance, object)
|
.send_message(
|
||||||
|
account_id,
|
||||||
|
// inbuxa: MA-S3: a shared mailbox's people send only as it
|
||||||
|
access_token.delegated_shared_mailbox(account_id),
|
||||||
|
&response,
|
||||||
|
instance,
|
||||||
|
object,
|
||||||
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
Ok(submission) => {
|
Ok(submission) => {
|
||||||
@@ -110,6 +121,15 @@ impl EmailSubmissionSet for Server {
|
|||||||
.assign_document_ids(account_id, Collection::EmailSubmission, 1)
|
.assign_document_ids(account_id, Collection::EmailSubmission, 1)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?;
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
// inbuxa: MA-D0a: who sent it, when it went out as someone else
|
||||||
|
self.audit_send_as(
|
||||||
|
access_token,
|
||||||
|
account_id,
|
||||||
|
document_id,
|
||||||
|
&submission.envelope.mail_from.email,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
batch
|
batch
|
||||||
.with_account_id(account_id)
|
.with_account_id(account_id)
|
||||||
.with_collection(Collection::EmailSubmission)
|
.with_collection(Collection::EmailSubmission)
|
||||||
@@ -388,6 +408,7 @@ impl EmailSubmissionSet for Server {
|
|||||||
async fn send_message(
|
async fn send_message(
|
||||||
&self,
|
&self,
|
||||||
account_id: u32,
|
account_id: u32,
|
||||||
|
own_addresses_only: bool,
|
||||||
response: &SetResponse<email_submission::EmailSubmission>,
|
response: &SetResponse<email_submission::EmailSubmission>,
|
||||||
instance: &Arc<ServerInstance>,
|
instance: &Arc<ServerInstance>,
|
||||||
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
object: Value<'_, EmailSubmissionProperty, EmailSubmissionValue>,
|
||||||
@@ -617,6 +638,46 @@ impl EmailSubmissionSet for Server {
|
|||||||
.unarchive::<MessageMetadata>()
|
.unarchive::<MessageMetadata>()
|
||||||
.caused_by(trc::location!())?;
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
// inbuxa: MA-S3: mail that came to a shared mailbox goes out as it,
|
||||||
|
// so the answer comes back to the mailbox and not to whoever sent
|
||||||
|
// it: every From and Reply-To address must be the mailbox's own
|
||||||
|
if own_addresses_only {
|
||||||
|
let mut named = Vec::new();
|
||||||
|
for header in metadata.contents[0].parts[0].headers.iter() {
|
||||||
|
if !matches!(
|
||||||
|
header.name,
|
||||||
|
ArchivedMetadataHeaderName::From | ArchivedMetadataHeaderName::ReplyTo
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match &header.value {
|
||||||
|
ArchivedMetadataHeaderValue::AddressList(addr) => {
|
||||||
|
named.extend(addr.iter().filter_map(|a| a.address.as_ref().map(|v| v.to_string())));
|
||||||
|
}
|
||||||
|
ArchivedMetadataHeaderValue::AddressGroup(groups) => {
|
||||||
|
for group in groups.iter() {
|
||||||
|
named.extend(
|
||||||
|
group
|
||||||
|
.addresses
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| a.address.as_ref().map(|v| v.to_string())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for address in named {
|
||||||
|
if self.account_id_from_email(&address, true).await? != Some(account_id) {
|
||||||
|
return Ok(Err(SetError::new(SetErrorType::ForbiddenFrom).with_description(
|
||||||
|
format!(
|
||||||
|
"A shared mailbox sends only as its own addresses, so replies come back to it; {address} isn't one."
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add recipients to envelope if missing
|
// Add recipients to envelope if missing
|
||||||
let mut bcc_header = None;
|
let mut bcc_header = None;
|
||||||
if rcpt_to.is_empty() {
|
if rcpt_to.is_empty() {
|
||||||
|
|||||||
@@ -1762,6 +1762,10 @@ pub enum Permission {
|
|||||||
SysJournalExport = 683,
|
SysJournalExport = 683,
|
||||||
// inbuxa: the security to-do list, accepting an item
|
// inbuxa: the security to-do list, accepting an item
|
||||||
SysSecurityAccept = 684,
|
SysSecurityAccept = 684,
|
||||||
|
// inbuxa: the deliverability check
|
||||||
|
SysDeliverabilityGet = 685,
|
||||||
|
SysDeliverabilityUpdate = 686,
|
||||||
|
SysDeliverabilityCheck = 687,
|
||||||
SysAccountGet = 219,
|
SysAccountGet = 219,
|
||||||
SysAccountCreate = 220,
|
SysAccountCreate = 220,
|
||||||
SysAccountUpdate = 221,
|
SysAccountUpdate = 221,
|
||||||
|
|||||||
@@ -7102,6 +7102,9 @@ impl EnumImpl for Permission {
|
|||||||
b"sysJournalSearch" => Permission::SysJournalSearch,
|
b"sysJournalSearch" => Permission::SysJournalSearch,
|
||||||
b"sysJournalExport" => Permission::SysJournalExport,
|
b"sysJournalExport" => Permission::SysJournalExport,
|
||||||
b"sysSecurityAccept" => Permission::SysSecurityAccept,
|
b"sysSecurityAccept" => Permission::SysSecurityAccept,
|
||||||
|
b"sysDeliverabilityGet" => Permission::SysDeliverabilityGet,
|
||||||
|
b"sysDeliverabilityUpdate" => Permission::SysDeliverabilityUpdate,
|
||||||
|
b"sysDeliverabilityCheck" => Permission::SysDeliverabilityCheck,
|
||||||
b"sysAccountGet" => Permission::SysAccountGet,
|
b"sysAccountGet" => Permission::SysAccountGet,
|
||||||
b"sysAccountCreate" => Permission::SysAccountCreate,
|
b"sysAccountCreate" => Permission::SysAccountCreate,
|
||||||
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
||||||
@@ -7803,6 +7806,9 @@ impl EnumImpl for Permission {
|
|||||||
Permission::SysJournalSearch => "sysJournalSearch",
|
Permission::SysJournalSearch => "sysJournalSearch",
|
||||||
Permission::SysJournalExport => "sysJournalExport",
|
Permission::SysJournalExport => "sysJournalExport",
|
||||||
Permission::SysSecurityAccept => "sysSecurityAccept",
|
Permission::SysSecurityAccept => "sysSecurityAccept",
|
||||||
|
Permission::SysDeliverabilityGet => "sysDeliverabilityGet",
|
||||||
|
Permission::SysDeliverabilityUpdate => "sysDeliverabilityUpdate",
|
||||||
|
Permission::SysDeliverabilityCheck => "sysDeliverabilityCheck",
|
||||||
Permission::SysAccountGet => "sysAccountGet",
|
Permission::SysAccountGet => "sysAccountGet",
|
||||||
Permission::SysAccountCreate => "sysAccountCreate",
|
Permission::SysAccountCreate => "sysAccountCreate",
|
||||||
Permission::SysAccountUpdate => "sysAccountUpdate",
|
Permission::SysAccountUpdate => "sysAccountUpdate",
|
||||||
@@ -8497,6 +8503,9 @@ impl EnumImpl for Permission {
|
|||||||
682 => Some(Permission::SysJournalSearch),
|
682 => Some(Permission::SysJournalSearch),
|
||||||
683 => Some(Permission::SysJournalExport),
|
683 => Some(Permission::SysJournalExport),
|
||||||
684 => Some(Permission::SysSecurityAccept),
|
684 => Some(Permission::SysSecurityAccept),
|
||||||
|
685 => Some(Permission::SysDeliverabilityGet),
|
||||||
|
686 => Some(Permission::SysDeliverabilityUpdate),
|
||||||
|
687 => Some(Permission::SysDeliverabilityCheck),
|
||||||
219 => Some(Permission::SysAccountGet),
|
219 => Some(Permission::SysAccountGet),
|
||||||
220 => Some(Permission::SysAccountCreate),
|
220 => Some(Permission::SysAccountCreate),
|
||||||
221 => Some(Permission::SysAccountUpdate),
|
221 => Some(Permission::SysAccountUpdate),
|
||||||
@@ -8941,7 +8950,7 @@ impl EnumImpl for Permission {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const COUNT: usize = 685;
|
const COUNT: usize = 688;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl serde::Serialize for Permission {
|
impl serde::Serialize for Permission {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls", "h
|
|||||||
base64 = "0.23"
|
base64 = "0.23"
|
||||||
dns-update = { version = "0.5" }
|
dns-update = { version = "0.5" }
|
||||||
psl = "2"
|
psl = "2"
|
||||||
|
# inbuxa: the deliverability check
|
||||||
|
mail-auth = { version = "0.13" }
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ impl BroadcastBatch<Vec<BroadcastEvent>> {
|
|||||||
serialized.push(13u8);
|
serialized.push(13u8);
|
||||||
let _ = serialized.write_leb128(*account_id);
|
let _ = serialized.write_leb128(*account_id);
|
||||||
}
|
}
|
||||||
|
// inbuxa: DL-15
|
||||||
|
BroadcastEvent::DeliverabilityCheck => {
|
||||||
|
serialized.push(14u8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serialized
|
serialized
|
||||||
@@ -284,6 +288,8 @@ where
|
|||||||
let account_id = self.messages.next_leb128().ok_or(())?;
|
let account_id = self.messages.next_leb128().ok_or(())?;
|
||||||
Ok(Some(BroadcastEvent::EndSessions(account_id)))
|
Ok(Some(BroadcastEvent::EndSessions(account_id)))
|
||||||
}
|
}
|
||||||
|
// inbuxa: DL-15
|
||||||
|
14 => Ok(Some(BroadcastEvent::DeliverabilityCheck)),
|
||||||
_ => Err(()),
|
_ => Err(()),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -189,6 +189,12 @@ pub fn spawn_broadcast_subscriber(inner: Arc<Inner>, mut shutdown_rx: watch::Rec
|
|||||||
.send(PushEvent::Revoke { account_id })
|
.send(PushEvent::Revoke { account_id })
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
// inbuxa: DL-15: this node checks
|
||||||
|
// itself too
|
||||||
|
BroadcastEvent::DeliverabilityCheck => {
|
||||||
|
crate::inbuxa_deliverability::CHECK_NOW
|
||||||
|
.notify_one();
|
||||||
|
}
|
||||||
BroadcastEvent::QueueRefresh => {
|
BroadcastEvent::QueueRefresh => {
|
||||||
if inner.shared_core.load().network.roles.outbound_mta {
|
if inner.shared_core.load().network.roles.outbound_mta {
|
||||||
let _ = inner
|
let _ = inner
|
||||||
@@ -278,6 +284,7 @@ fn log_event(event: &BroadcastEvent) -> trc::Value {
|
|||||||
BroadcastEvent::EndSessions(account_id) => {
|
BroadcastEvent::EndSessions(account_id) => {
|
||||||
trc::Value::Array(vec!["EndSessions".into(), (*account_id).into()])
|
trc::Value::Array(vec!["EndSessions".into(), (*account_id).into()])
|
||||||
}
|
}
|
||||||
|
BroadcastEvent::DeliverabilityCheck => "DeliverabilityCheck".into(),
|
||||||
BroadcastEvent::RegistryChange(change) => match change {
|
BroadcastEvent::RegistryChange(change) => match change {
|
||||||
RegistryChange::Insert(id) => trc::Value::Array(vec![
|
RegistryChange::Insert(id) => trc::Value::Array(vec![
|
||||||
"RegistryInsert".into(),
|
"RegistryInsert".into(),
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The deliverability check (deliverability spec, DL-1 to DL-16): every node
|
||||||
|
//! that sends mail asks what the rest of the internet sees of it, once a day
|
||||||
|
//! and when an administrator asks (**Check now**), and keeps one report.
|
||||||
|
//!
|
||||||
|
//! Each node checks itself, because only it knows which address it sends
|
||||||
|
//! from: a cluster's nodes can each leave from their own (DL-1, DL-2). The
|
||||||
|
//! report holds facts; the console grades them.
|
||||||
|
|
||||||
|
use common::{
|
||||||
|
BuildServer, Inner, Server, config::smtp::auth::Dkim1Signer, expr::functions::EmptyResolver,
|
||||||
|
};
|
||||||
|
use futures::future::join_all;
|
||||||
|
use inbuxa_features::deliverability::{
|
||||||
|
self as model, Address, AddressSource, Certificate, DkimKey, DkimState, Dmarc, DomainReport,
|
||||||
|
Listing, ListingState, MtaSts, Report, Settings, SpfResult,
|
||||||
|
lists::{self, Answer, BlockList, Subject},
|
||||||
|
};
|
||||||
|
use mail_auth::{
|
||||||
|
AuthenticatedMessage, DkimResult, DnsError, Error, SpfResult as Spf,
|
||||||
|
common::headers::HeaderWriter,
|
||||||
|
dmarc::{self, Alignment},
|
||||||
|
mta_sts::{MtaSts as MtaStsRecord, TlsRpt},
|
||||||
|
spf::verify::SpfParameters,
|
||||||
|
};
|
||||||
|
use registry::schema::{prelude::ObjectType, structs::Domain};
|
||||||
|
use smtp::outbound::mta_sts::{lookup::MtaStsLookup, verify::VerifyPolicy};
|
||||||
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
future::Future,
|
||||||
|
net::IpAddr,
|
||||||
|
sync::{Arc, LazyLock},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use store::{registry::RegistryQuery, write::now};
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
/// DL-16: no single lookup holds a run up for longer than this.
|
||||||
|
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
/// DL-16: lookups in flight at once.
|
||||||
|
const PARALLEL: usize = 8;
|
||||||
|
const MTA_STS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
const DAY: u64 = 86_400;
|
||||||
|
/// After a start, wait this long before a run that's overdue.
|
||||||
|
const SETTLE: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// DL-15: wakes this node's check, from **Check now** here or on another node.
|
||||||
|
pub static CHECK_NOW: LazyLock<Notify> = LazyLock::new(Notify::new);
|
||||||
|
|
||||||
|
pub fn spawn_deliverability(inner: Arc<Inner>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut first = true;
|
||||||
|
loop {
|
||||||
|
let server = inner.build_server();
|
||||||
|
let wait = match due_in(&server).await {
|
||||||
|
Ok(wait) => wait,
|
||||||
|
Err(err) => {
|
||||||
|
trc::error!(err.details("Failed to read the deliverability report"));
|
||||||
|
Duration::from_secs(3600)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let wait = if first { wait.max(SETTLE) } else { wait };
|
||||||
|
first = false;
|
||||||
|
let asked = tokio::select! {
|
||||||
|
_ = tokio::time::sleep(wait) => false,
|
||||||
|
_ = CHECK_NOW.notified() => true,
|
||||||
|
};
|
||||||
|
let server = inner.build_server();
|
||||||
|
if !server.core.network.roles.outbound_mta {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// DL-15: asked again within ten minutes, the last report stands
|
||||||
|
if asked
|
||||||
|
&& let Ok(Some(last)) =
|
||||||
|
model::report(server.store(), server.core.network.node_id).await
|
||||||
|
&& now().saturating_sub(last.checked_at) < model::MIN_INTERVAL_SECS
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(err) = run(&server).await {
|
||||||
|
trc::error!(err.details("Failed to run the deliverability check"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DL-14: once a day, at a minute in the first hour of the day (UTC) that's
|
||||||
|
/// the node's own, so nodes and servers don't all ask the lists at once.
|
||||||
|
async fn due_in(server: &Server) -> trc::Result<Duration> {
|
||||||
|
let node_id = server.core.network.node_id;
|
||||||
|
let last = model::report(server.store(), node_id)
|
||||||
|
.await?
|
||||||
|
.map(|r| r.checked_at)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let slot = slot_for(&server.core.network.server_name, node_id);
|
||||||
|
let next = next_slot(last, slot);
|
||||||
|
Ok(Duration::from_secs(next.saturating_sub(now())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot_for(hostname: &str, node_id: u64) -> u64 {
|
||||||
|
let hash = hostname
|
||||||
|
.bytes()
|
||||||
|
.fold(node_id.wrapping_mul(0x9e37_79b9_7f4a_7c15), |h, b| {
|
||||||
|
h.rotate_left(5) ^ b as u64
|
||||||
|
});
|
||||||
|
hash % 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first daily slot after `last`; 0 (never ran) is due now.
|
||||||
|
fn next_slot(last: u64, slot: u64) -> u64 {
|
||||||
|
if last == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut next = last - last % DAY + slot;
|
||||||
|
if next <= last {
|
||||||
|
next += DAY;
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the check on this node and keeps the report.
|
||||||
|
pub async fn run(server: &Server) -> trc::Result<Report> {
|
||||||
|
let settings = model::settings(server.store()).await?;
|
||||||
|
let addresses = addresses(server, &settings).await;
|
||||||
|
let mut domains = Vec::new();
|
||||||
|
let ids = server
|
||||||
|
.registry()
|
||||||
|
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Domain))
|
||||||
|
.await?;
|
||||||
|
for id in ids {
|
||||||
|
if let Some(domain) = server.registry().object::<Domain>(id).await? {
|
||||||
|
domains.push(check_domain(server, &settings, &domain, &addresses).await?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let certificates = certificates(server, &addresses).await;
|
||||||
|
let report = Report {
|
||||||
|
node_id: server.core.network.node_id,
|
||||||
|
hostname: server.core.network.server_name.clone(),
|
||||||
|
checked_at: now(),
|
||||||
|
addresses,
|
||||||
|
domains,
|
||||||
|
certificates,
|
||||||
|
};
|
||||||
|
model::put_report(server.store(), &report).await?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DL-1, DL-2: the addresses ---------------------------------------------
|
||||||
|
|
||||||
|
async fn addresses(server: &Server, settings: &Settings) -> Vec<Address> {
|
||||||
|
let queue = &server.core.smtp.queue;
|
||||||
|
// The strategy the scheduler picks for a message it knows nothing about:
|
||||||
|
// what an expression on the node's own name, as a cluster uses, gives.
|
||||||
|
let strategy = server
|
||||||
|
.eval_if::<String, _>(&queue.connection, &EmptyResolver, 0)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|| "default".to_string());
|
||||||
|
let connection = server.get_connection_or_default(&strategy, 0);
|
||||||
|
let ehlo = connection
|
||||||
|
.ehlo_hostname
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| server.core.network.server_name.clone());
|
||||||
|
|
||||||
|
let mut found: Vec<(IpAddr, AddressSource, String)> = connection
|
||||||
|
.source_ipv4
|
||||||
|
.iter()
|
||||||
|
.chain(connection.source_ipv6.iter())
|
||||||
|
.map(|source| {
|
||||||
|
(
|
||||||
|
source.ip,
|
||||||
|
AddressSource::Configured,
|
||||||
|
source.host.clone().unwrap_or_else(|| ehlo.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if found.is_empty() {
|
||||||
|
for ip in resolve_name(server, &ehlo).await {
|
||||||
|
found.push((ip, AddressSource::Ehlo, ehlo.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(found.len());
|
||||||
|
for (ip, source, ehlo) in found {
|
||||||
|
let mut address = Address {
|
||||||
|
ip: ip.to_string(),
|
||||||
|
source,
|
||||||
|
strategy: strategy.clone(),
|
||||||
|
ehlo: ehlo.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
reverse_dns(server, ip, &ehlo, &mut address).await;
|
||||||
|
address.listings = listings(server, settings, Subject::Ip(ip)).await;
|
||||||
|
out.push(address);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The IPv4 and IPv6 addresses `name` resolves to; none when it doesn't.
|
||||||
|
async fn resolve_name(server: &Server, name: &str) -> Vec<IpAddr> {
|
||||||
|
let dns = &server.core.smtp.resolvers.dns;
|
||||||
|
let cache = &server.inner.cache;
|
||||||
|
let fqdn = fqdn(name);
|
||||||
|
let mut ips = Vec::new();
|
||||||
|
if let Some(Ok(v4)) = timed(dns.ipv4_lookup(fqdn.as_str(), Some(&cache.dns_ipv4))).await {
|
||||||
|
ips.extend(v4.rrset.iter().copied().map(IpAddr::V4));
|
||||||
|
}
|
||||||
|
if let Some(Ok(v6)) = timed(dns.ipv6_lookup(fqdn.as_str(), Some(&cache.dns_ipv6))).await {
|
||||||
|
ips.extend(v6.rrset.iter().copied().map(IpAddr::V6));
|
||||||
|
}
|
||||||
|
ips
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DL-5: the PTR names, whether one resolves back, and whether that one is
|
||||||
|
/// the EHLO name.
|
||||||
|
async fn reverse_dns(server: &Server, ip: IpAddr, ehlo: &str, address: &mut Address) {
|
||||||
|
let dns = &server.core.smtp.resolvers.dns;
|
||||||
|
match timed(dns.ptr_lookup(ip, Some(&server.inner.cache.dns_ptr))).await {
|
||||||
|
Some(Ok(names)) => {
|
||||||
|
address.ptr = names.rrset.iter().map(|n| bare(n)).collect();
|
||||||
|
}
|
||||||
|
Some(Err(Error::Dns(DnsError::RecordNotFound(_)))) => {}
|
||||||
|
Some(Err(err)) => address.ptr_error = Some(err.to_string()),
|
||||||
|
None => address.ptr_error = Some("No answer in 5 seconds".into()),
|
||||||
|
}
|
||||||
|
for name in address.ptr.clone() {
|
||||||
|
if resolve_name(server, &name).await.contains(&ip) {
|
||||||
|
address.forward_confirmed = true;
|
||||||
|
if name.eq_ignore_ascii_case(&bare(ehlo)) {
|
||||||
|
address.ehlo_matches = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DL-4, DL-6, DL-12: blocklists ----------------------------------------
|
||||||
|
|
||||||
|
async fn listings(server: &Server, settings: &Settings, subject: Subject<'_>) -> Vec<Listing> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut asked = Vec::new();
|
||||||
|
for list in lists::LISTS {
|
||||||
|
let Some(name) = list.query(&subject) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if settings.is_off(list.name) {
|
||||||
|
out.push(Listing {
|
||||||
|
list: list.name.into(),
|
||||||
|
state: ListingState::Off,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
asked.push((list, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for chunk in asked.chunks(PARALLEL) {
|
||||||
|
out.extend(join_all(chunk.iter().map(|(list, name)| ask(server, list, name))).await);
|
||||||
|
}
|
||||||
|
// In the lists' own order, whether asked or off
|
||||||
|
out.sort_by_key(|l| lists::LISTS.iter().position(|list| list.name == l.list));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ask(server: &Server, list: &BlockList, name: &str) -> Listing {
|
||||||
|
let dns = &server.core.smtp.resolvers.dns;
|
||||||
|
let mut listing = Listing {
|
||||||
|
list: list.name.into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match timed(dns.ipv4_lookup(name, Some(&server.inner.cache.dns_ipv4))).await {
|
||||||
|
Some(Ok(answer)) => {
|
||||||
|
let Some(code) = answer.rrset.first().copied() else {
|
||||||
|
return listing;
|
||||||
|
};
|
||||||
|
listing.code = Some(code.to_string());
|
||||||
|
match list.read(code) {
|
||||||
|
Answer::Listed(meaning) => {
|
||||||
|
listing.state = ListingState::Listed;
|
||||||
|
listing.meaning = Some(meaning.into());
|
||||||
|
}
|
||||||
|
Answer::Refused(meaning) => {
|
||||||
|
listing.state = ListingState::Refused;
|
||||||
|
listing.meaning = Some(meaning.into());
|
||||||
|
}
|
||||||
|
Answer::Unknown => {
|
||||||
|
listing.state = ListingState::Refused;
|
||||||
|
listing.meaning = Some("An answer this list doesn't define".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not on the list
|
||||||
|
Some(Err(Error::Dns(DnsError::RecordNotFound(_)))) => {}
|
||||||
|
// A list that refuses the resolver often answers REFUSED or SERVFAIL
|
||||||
|
Some(Err(err)) => {
|
||||||
|
listing.state = ListingState::Error;
|
||||||
|
listing.meaning = Some(err.to_string());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
listing.state = ListingState::Error;
|
||||||
|
listing.meaning = Some("No answer in 5 seconds".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listing
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DL-7 to DL-12: per domain --------------------------------------------
|
||||||
|
|
||||||
|
async fn check_domain(
|
||||||
|
server: &Server,
|
||||||
|
settings: &Settings,
|
||||||
|
domain: &Domain,
|
||||||
|
addresses: &[Address],
|
||||||
|
) -> trc::Result<DomainReport> {
|
||||||
|
let name = domain.name.to_lowercase();
|
||||||
|
let mut report = DomainReport {
|
||||||
|
domain: name.clone(),
|
||||||
|
tenant_id: domain.member_tenant_id.map(|id| id.document_id()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let dns = &server.core.smtp.resolvers.dns;
|
||||||
|
let cache = &server.inner.cache;
|
||||||
|
|
||||||
|
// DL-7: SPF for every address the node sends from
|
||||||
|
for address in addresses {
|
||||||
|
let Ok(ip) = address.ip.parse::<IpAddr>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let sender = format!("postmaster@{name}");
|
||||||
|
let output = dns
|
||||||
|
.check_host(cache.build_auth_parameters(SpfParameters::new(
|
||||||
|
ip,
|
||||||
|
&name,
|
||||||
|
&address.ehlo,
|
||||||
|
&server.core.network.server_name,
|
||||||
|
&sender,
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
report.spf.push(SpfResult {
|
||||||
|
ip: address.ip.clone(),
|
||||||
|
result: spf_name(output.result()).into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DL-8: each key the domain signs with is the one published
|
||||||
|
report.dkim = dkim_keys(server, &name).await?;
|
||||||
|
|
||||||
|
// DL-9: what DMARC asks of alignment; the console works it out
|
||||||
|
if let Some(Ok(record)) =
|
||||||
|
timed(dns.txt_lookup::<dmarc::Dmarc>(format!("_dmarc.{name}."), Some(&cache.dns_txt))).await
|
||||||
|
{
|
||||||
|
report.dmarc = Some(Dmarc {
|
||||||
|
policy: match record.p {
|
||||||
|
dmarc::Policy::None | dmarc::Policy::Unspecified => "none",
|
||||||
|
dmarc::Policy::Quarantine => "quarantine",
|
||||||
|
dmarc::Policy::Reject => "reject",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
adkim: alignment(&record.adkim).into(),
|
||||||
|
aspf: alignment(&record.aspf).into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// DL-10
|
||||||
|
report.mta_sts = mta_sts(server, &name).await;
|
||||||
|
|
||||||
|
// DL-11
|
||||||
|
report.tls_rpt = matches!(
|
||||||
|
timed(dns.txt_lookup::<TlsRpt>(format!("_smtp._tls.{name}."), Some(&cache.dns_txt))).await,
|
||||||
|
Some(Ok(_))
|
||||||
|
);
|
||||||
|
|
||||||
|
// DL-12
|
||||||
|
report.listings = listings(server, settings, Subject::Domain(&name)).await;
|
||||||
|
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs a message that's never sent with each of the domain's DKIM keys,
|
||||||
|
/// and verifies it as a receiver would: a key that's missing from DNS, or
|
||||||
|
/// published but different, fails here before it fails anyone's mail.
|
||||||
|
async fn dkim_keys(server: &Server, domain: &str) -> trc::Result<Vec<DkimKey>> {
|
||||||
|
let Some(signers) = server.dkim_signers(domain).await? else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
let message = format!(
|
||||||
|
"From: deliverability-check@{domain}\r\n\
|
||||||
|
To: deliverability-check@{domain}\r\n\
|
||||||
|
Subject: Deliverability check\r\n\
|
||||||
|
Date: Mon, 5 Oct 2026 00:00:00 +0000\r\n\
|
||||||
|
Message-ID: <deliverability-check@{domain}>\r\n\
|
||||||
|
\r\n\
|
||||||
|
This message is signed to check the DKIM keys in DNS. It is never sent.\r\n"
|
||||||
|
);
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for signer in &signers.dkim1 {
|
||||||
|
let signature = match signer {
|
||||||
|
Dkim1Signer::RsaSha256(signer) => signer.sign(message.as_bytes()),
|
||||||
|
Dkim1Signer::Ed25519Sha256(signer) => signer.sign(message.as_bytes()),
|
||||||
|
};
|
||||||
|
let Ok(signature) = signature else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let selector = signature.s.clone();
|
||||||
|
let mut signed = Vec::with_capacity(message.len() + 512);
|
||||||
|
signature.write_header(&mut signed);
|
||||||
|
signed.extend_from_slice(message.as_bytes());
|
||||||
|
let state = match AuthenticatedMessage::parse(&signed) {
|
||||||
|
Some(parsed) => {
|
||||||
|
let outputs = server
|
||||||
|
.core
|
||||||
|
.smtp
|
||||||
|
.resolvers
|
||||||
|
.dns
|
||||||
|
.verify_dkim(server.inner.cache.build_auth_parameters(&parsed))
|
||||||
|
.await;
|
||||||
|
outputs
|
||||||
|
.first()
|
||||||
|
.map(|output| dkim_state(output.result()))
|
||||||
|
.unwrap_or(DkimState::Error)
|
||||||
|
}
|
||||||
|
None => DkimState::Error,
|
||||||
|
};
|
||||||
|
keys.push(DkimKey { selector, state });
|
||||||
|
}
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dkim_state(result: &DkimResult) -> DkimState {
|
||||||
|
match result {
|
||||||
|
DkimResult::Pass => DkimState::Matches,
|
||||||
|
DkimResult::PermError(Error::Dns(DnsError::RecordNotFound(_)))
|
||||||
|
| DkimResult::TempError(Error::Dns(DnsError::RecordNotFound(_))) => DkimState::Missing,
|
||||||
|
DkimResult::TempError(_) => DkimState::Error,
|
||||||
|
_ => DkimState::Different,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mta_sts(server: &Server, domain: &str) -> MtaSts {
|
||||||
|
let dns = &server.core.smtp.resolvers.dns;
|
||||||
|
let cache = &server.inner.cache;
|
||||||
|
let mut out = MtaSts::default();
|
||||||
|
let Some(Ok(record)) =
|
||||||
|
timed(dns.txt_lookup::<MtaStsRecord>(format!("_mta-sts.{domain}."), Some(&cache.dns_txt)))
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
out.record_id = Some(record.id.clone());
|
||||||
|
match server.lookup_mta_sts_policy(domain, MTA_STS_TIMEOUT).await {
|
||||||
|
Ok(policy) => {
|
||||||
|
out.fetched = true;
|
||||||
|
out.mode = Some(
|
||||||
|
match policy.mode {
|
||||||
|
common::config::smtp::resolver::Mode::Enforce => "enforce",
|
||||||
|
common::config::smtp::resolver::Mode::Testing => "testing",
|
||||||
|
common::config::smtp::resolver::Mode::None => "none",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
out.max_age = Some(policy.max_age);
|
||||||
|
if let Some(Ok(mxs)) = timed(dns.mx_lookup(domain, Some(&cache.dns_mx))).await {
|
||||||
|
for mx in mxs.rrset.iter() {
|
||||||
|
for exchange in mx.exchanges.iter() {
|
||||||
|
let host = bare(exchange);
|
||||||
|
if !policy.verify(&host) && !out.mx_not_covered.contains(&host) {
|
||||||
|
out.mx_not_covered.push(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => out.error = Some(err.to_string()),
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- DL-13: certificates ---------------------------------------------------
|
||||||
|
|
||||||
|
/// The EHLO names, and the server's MX names that point at this node, each
|
||||||
|
/// with whether the node holds a certificate for it.
|
||||||
|
async fn certificates(server: &Server, addresses: &[Address]) -> Vec<Certificate> {
|
||||||
|
let mine: Vec<IpAddr> = addresses.iter().filter_map(|a| a.ip.parse().ok()).collect();
|
||||||
|
let mut names: BTreeSet<String> = addresses.iter().map(|a| bare(&a.ehlo)).collect();
|
||||||
|
let default_host = server.core.network.server_name.as_str();
|
||||||
|
for mx in &server.core.network.info.mxs {
|
||||||
|
let name = bare(mx.hostname.as_deref().unwrap_or(default_host));
|
||||||
|
if !names.contains(&name)
|
||||||
|
&& resolve_name(server, &name)
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.any(|ip| mine.contains(ip))
|
||||||
|
{
|
||||||
|
names.insert(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| Certificate {
|
||||||
|
covered: server.resolve_certificate(&name).is_some(),
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn timed<T>(lookup: impl Future<Output = T>) -> Option<T> {
|
||||||
|
tokio::time::timeout(LOOKUP_TIMEOUT, lookup).await.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fqdn(name: &str) -> String {
|
||||||
|
format!("{}.", name.trim_end_matches('.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bare(name: &str) -> String {
|
||||||
|
name.trim_end_matches('.').to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spf_name(result: Spf) -> &'static str {
|
||||||
|
match result {
|
||||||
|
Spf::Pass => "pass",
|
||||||
|
Spf::Fail => "fail",
|
||||||
|
Spf::SoftFail => "softFail",
|
||||||
|
Spf::Neutral => "neutral",
|
||||||
|
Spf::TempError => "tempError",
|
||||||
|
Spf::PermError => "permError",
|
||||||
|
Spf::None => "none",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alignment(alignment: &Alignment) -> &'static str {
|
||||||
|
match alignment {
|
||||||
|
Alignment::Relaxed => "relaxed",
|
||||||
|
Alignment::Strict => "strict",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_daily_slot_follows_the_last_run() {
|
||||||
|
let day = 20_000 * DAY;
|
||||||
|
// Never ran: due now
|
||||||
|
assert_eq!(next_slot(0, 600), 0);
|
||||||
|
// Ran at 14:00: next is tomorrow's slot
|
||||||
|
assert_eq!(next_slot(day + 14 * 3600, 600), day + DAY + 600);
|
||||||
|
// Ran just before today's slot: today's slot
|
||||||
|
assert_eq!(next_slot(day + 300, 600), day + 600);
|
||||||
|
// Ran at the slot: tomorrow's
|
||||||
|
assert_eq!(next_slot(day + 600, 600), day + DAY + 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slots_fall_in_the_first_hour_and_differ_by_node() {
|
||||||
|
let a = slot_for("mx2.example.org", 2);
|
||||||
|
let b = slot_for("mx3.example.org", 3);
|
||||||
|
assert!(a < 3600 && b < 3600);
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ pub mod broadcast;
|
|||||||
// inbuxa: AL-5, delegations end at their date
|
// inbuxa: AL-5, delegations end at their date
|
||||||
pub mod inbuxa_lock_expiry;
|
pub mod inbuxa_lock_expiry;
|
||||||
pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1
|
pub mod inbuxa_log_retention; // inbuxa: personal-data catalog, D1
|
||||||
|
pub mod inbuxa_deliverability; // inbuxa: the deliverability check
|
||||||
pub mod state_manager;
|
pub mod state_manager;
|
||||||
pub mod task_manager;
|
pub mod task_manager;
|
||||||
|
|
||||||
@@ -74,6 +75,9 @@ impl SpawnServices for IpcReceivers {
|
|||||||
// inbuxa: personal-data catalog, D1: old log files go, per node
|
// inbuxa: personal-data catalog, D1: old log files go, per node
|
||||||
inbuxa_log_retention::spawn_log_retention(inner.clone());
|
inbuxa_log_retention::spawn_log_retention(inner.clone());
|
||||||
|
|
||||||
|
// inbuxa: deliverability spec, DL-14: each node checks itself daily
|
||||||
|
inbuxa_deliverability::spawn_deliverability(inner.clone());
|
||||||
|
|
||||||
// Spawn task scheduler
|
// Spawn task scheduler
|
||||||
spawn_task_scheduler(inner);
|
spawn_task_scheduler(inner);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,16 +176,23 @@ impl TlsaLookup for Server {
|
|||||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
let tlsa_lookup = match self
|
// Through `validated_lookup`, like the MX and address lookups: a TLSA
|
||||||
.core
|
// name that is a signed CNAME to a name with no TLSA record (seen at
|
||||||
.smtp
|
// `_25._tcp.mail.usefulinsight.com`, behind Hetzner's resolvers) is
|
||||||
.resolvers
|
// otherwise called bogus, and the message waits on it until it
|
||||||
.dnssec
|
// expires.
|
||||||
.resolver
|
let tlsa_lookup = match validated_lookup(
|
||||||
.tlsa_lookup(Name::from_str_relaxed(key.as_ref())?)
|
&self.core.smtp.resolvers.dnssec.resolver,
|
||||||
|
self.core.smtp.resolvers.dns.resolver(),
|
||||||
|
Name::from_str_relaxed(key.as_ref())?,
|
||||||
|
RecordType::TLSA,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(tlsa_lookup) => tlsa_lookup,
|
// A TLSA record proved to sit in an unsigned zone is no DANE
|
||||||
|
// policy at all.
|
||||||
|
Ok(validated) if validated.insecure => return Ok(TlsaResult::Missing),
|
||||||
|
Ok(validated) => validated.lookup,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Some(denial) = NegativeAnswer::from_error(&err) {
|
if let Some(denial) = NegativeAnswer::from_error(&err) {
|
||||||
return Ok(if denial.dnssec_status == DnssecStatus::Bogus {
|
return Ok(if denial.dnssec_status == DnssecStatus::Bogus {
|
||||||
@@ -436,7 +443,8 @@ impl TlsaLookup for Server {
|
|||||||
// record in the DS reply, and public resolvers often send none.
|
// record in the DS reply, and public resolvers often send none.
|
||||||
// - A signed CNAME to a signed name without the record type queried. Hickory
|
// - A signed CNAME to a signed name without the record type queried. Hickory
|
||||||
// checks the denial of existence against the name first asked for, not the
|
// checks the denial of existence against the name first asked for, not the
|
||||||
// target's, and rejects it.
|
// target's, and rejects it. TLSA lookups hit this too: a TLSA name that is
|
||||||
|
// a CNAME to the zone apex, with no TLSA there, held mail to it for a week.
|
||||||
//
|
//
|
||||||
// When hickory says bogus, check the answer again with lookups it gets right.
|
// When hickory says bogus, check the answer again with lookups it gets right.
|
||||||
// A signed CNAME is followed and the lookup repeated at its target. Otherwise
|
// A signed CNAME is followed and the lookup repeated at its target. Otherwise
|
||||||
@@ -869,4 +877,56 @@ mod tests {
|
|||||||
assert!(validated.insecure);
|
assert!(validated.insecure);
|
||||||
assert!(!validated.lookup.answers().is_empty());
|
assert!(!validated.lookup.answers().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Needs the network: a TLSA name that is a signed CNAME to the zone apex,
|
||||||
|
// which has no TLSA record. Cloudflare's resolver answers with a compact
|
||||||
|
// denial at the name itself; Hetzner's (and others) follow the CNAME, and
|
||||||
|
// hickory then calls the answer bogus. Point the lookup at a resolver that
|
||||||
|
// follows it with INBUXA_TEST_DNS_TCP=<ip:port> (TCP), for instance over
|
||||||
|
// an SSH tunnel to 185.12.64.2:53 from a Hetzner host.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn validated_lookup_follows_signed_cname_for_tlsa() {
|
||||||
|
use mail_auth::hickory_resolver::{
|
||||||
|
config::{CLOUDFLARE, ConnectionConfig, NameServerConfig, ResolverConfig, ResolverOpts},
|
||||||
|
net::runtime::TokioRuntimeProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = match std::env::var("INBUXA_TEST_DNS_TCP") {
|
||||||
|
Ok(addr) => {
|
||||||
|
let addr: std::net::SocketAddr = addr.parse().unwrap();
|
||||||
|
let mut ns = NameServerConfig::new(addr.ip(), true, vec![ConnectionConfig::tcp()]);
|
||||||
|
if let Some(c) = ns.connections.first_mut() {
|
||||||
|
c.port = addr.port();
|
||||||
|
}
|
||||||
|
ResolverConfig::from_parts(None, vec![], vec![ns])
|
||||||
|
}
|
||||||
|
Err(_) => ResolverConfig::udp_and_tcp(&CLOUDFLARE),
|
||||||
|
};
|
||||||
|
let build = |validate: bool| {
|
||||||
|
let mut opts = ResolverOpts::default();
|
||||||
|
opts.validate = validate;
|
||||||
|
opts.num_concurrent_reqs = 1;
|
||||||
|
opts.cache_size = 0;
|
||||||
|
TokioResolver::builder_with_config(config.clone(), TokioRuntimeProvider::default())
|
||||||
|
.with_options(opts)
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let (dnssec, plain) = (build(true), build(false));
|
||||||
|
let query = name("_25._tcp.mail.usefulinsight.com.");
|
||||||
|
|
||||||
|
let direct = dnssec.lookup(query.clone(), RecordType::TLSA).await;
|
||||||
|
eprintln!("hickory alone: {:?}", direct.as_ref().err().map(|e| e.to_string()));
|
||||||
|
|
||||||
|
let err = match validated_lookup(&dnssec, &plain, query, RecordType::TLSA).await {
|
||||||
|
Ok(validated) => panic!("expected no TLSA record, got {:?}", validated.lookup.answers()),
|
||||||
|
Err(err) => err,
|
||||||
|
};
|
||||||
|
let denial = NegativeAnswer::from_error(&err).expect("a denial of existence");
|
||||||
|
assert_eq!(denial.response_code, ResponseCode::NoError);
|
||||||
|
assert_ne!(denial.dnssec_status, DnssecStatus::Bogus);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! brand_version {
|
macro_rules! brand_version {
|
||||||
() => {
|
() => {
|
||||||
"2026.9.29"
|
"2026.10.5.1"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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::DocumentId;
|
use crate::DocumentId;
|
||||||
@@ -36,6 +38,13 @@ impl FromStr for Id {
|
|||||||
type Err = ();
|
type Err = ();
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
// inbuxa: an empty id is not id 0. RFC 8620 §1.2 ids are 1 to 255
|
||||||
|
// characters, and "" would otherwise name each collection's first
|
||||||
|
// document: `mailboxIds: {"": true}` filed a message in the Inbox.
|
||||||
|
if s.is_empty() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
|
||||||
let mut id = 0;
|
let mut id = 0;
|
||||||
|
|
||||||
for &ch in s.as_bytes() {
|
for &ch in s.as_bytes() {
|
||||||
@@ -261,4 +270,10 @@ mod tests {
|
|||||||
|
|
||||||
Id::from_str("p333333333333p333333333333").unwrap();
|
Id::from_str("p333333333333p333333333333").unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_jmap_id_is_refused() {
|
||||||
|
assert!(Id::from_str("").is_err());
|
||||||
|
assert_eq!(Id::from_str("a").unwrap(), Id::from(0u64));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +108,12 @@ impl SnowflakeIdGenerator {
|
|||||||
(id >> (SEQUENCE_LEN + NODE_ID_LEN)) / 1000 + DEFAULT_EPOCH
|
(id >> (SEQUENCE_LEN + NODE_ID_LEN)) / 1000 + DEFAULT_EPOCH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: the node that made the id, so per-node history (metric
|
||||||
|
// totals) can be told apart
|
||||||
|
pub fn to_node_id(id: u64) -> u64 {
|
||||||
|
id & NODE_ID_MASK
|
||||||
|
}
|
||||||
|
|
||||||
#[inline(always)]
|
#[inline(always)]
|
||||||
pub fn past_id(&self, period: Duration) -> Option<u64> {
|
pub fn past_id(&self, period: Duration) -> Option<u64> {
|
||||||
self.epoch.elapsed().ok().map(|elapsed| {
|
self.epoch.elapsed().ok().map(|elapsed| {
|
||||||
|
|||||||
+25
-12
@@ -209,9 +209,9 @@ depends on `store` and can't be called from it (`features/scale-out-storage.md`)
|
|||||||
|
|
||||||
- No "Stalwart" in product names, binaries, images, UI text, packaging or
|
- No "Stalwart" in product names, binaries, images, UI text, packaging or
|
||||||
domains.
|
domains.
|
||||||
- Factual statements are allowed and required: "a fork of Stalwart",
|
- Factual statements are allowed and required: "started as a fork of
|
||||||
"compatible with Stalwart 0.16 data". Upstream copyright notices stay on
|
Stalwart", "compatible with Stalwart 0.16 data". Upstream copyright notices
|
||||||
every file they cover.
|
stay on every file they cover.
|
||||||
- **Identifiers people meet carry the fork's name** (changed 2026-09-22;
|
- **Identifiers people meet carry the fork's name** (changed 2026-09-22;
|
||||||
this bullet used to keep upstream's). Upstream's JMAP capability for the
|
this bullet used to keep upstream's). Upstream's JMAP capability for the
|
||||||
registry (`x:`) objects is `urn:inbuxa:jmap:registry`, beside the fork's
|
registry (`x:`) objects is `urn:inbuxa:jmap:registry`, beside the fork's
|
||||||
@@ -242,14 +242,25 @@ depends on `store` and can't be called from it (`features/scale-out-storage.md`)
|
|||||||
- **Public material names it once, as fact, with the mark attributed** (added
|
- **Public material names it once, as fact, with the mark attributed** (added
|
||||||
2026-09-19). Where the name appears outside the product — the site, release
|
2026-09-19). Where the name appears outside the product — the site, release
|
||||||
announcements, documentation — it carries the attribution: Stalwart is a
|
announcements, documentation — it carries the attribution: Stalwart is a
|
||||||
trademark of Stalwart Labs LLC, and INBUXA is not affiliated with or
|
trademark of Stalwart Labs LLC, and inbuxa is not affiliated with or
|
||||||
endorsed by them. The fork relationship is stated in the provenance or
|
endorsed by them. The lineage is told in the past tense: inbuxa *started as*
|
||||||
license section, and the migration path names the server it migrates from,
|
a fork of Stalwart and its server *descends from* it; never "built on
|
||||||
because an operator searching for it has to find it. The base *version*
|
Stalwart" or "Stalwart with extras". It is told once, in the license or
|
||||||
belongs with the operator-facing material above — not in taglines, page
|
about section, and the clean-room provenance lives on one documentation
|
||||||
titles, hero copy or social previews, where it reads as a source identifier
|
page that everything else links to. The migration path names the server it
|
||||||
rather than a fact. No comparison, favorable or otherwise: what INBUXA
|
migrates from, because an operator searching for it has to find it. The
|
||||||
offers is stated on its own terms.
|
base *version* belongs with the operator-facing material above — not in
|
||||||
|
taglines, page titles, hero copy or social previews, where it reads as a
|
||||||
|
source identifier rather than a fact.
|
||||||
|
- **Comparisons: other products yes, Stalwart no** (changed 2026-10-05; this
|
||||||
|
bullet used to forbid comparison of any kind). Public material may compare
|
||||||
|
inbuxa with the hosted suites organizations choose between and with other
|
||||||
|
self-hosted mail stacks, when the comparison is factual, dated, names no
|
||||||
|
price, and says when the other choice is the better one. Self-hosted peers
|
||||||
|
are treated with respect. Stalwart is never compared: no editions, no
|
||||||
|
pricing, no commentary on Stalwart Labs or other forks. The rebuilt features
|
||||||
|
are described on their own merits, never as the features someone else
|
||||||
|
charges for.
|
||||||
|
|
||||||
### 2.5 Packaging
|
### 2.5 Packaging
|
||||||
|
|
||||||
@@ -364,6 +375,8 @@ is written.
|
|||||||
|
|
||||||
Not a rebuild: the **security to-do list** is INBUXA's own design (inbuxa-drafts `specs/security-score.md`). The console runs its checks; the server's part is `inbuxa:SecurityAcceptance`, the accepted items (`crates/jmap/src/inbuxa/security_acceptance.rs`), and the `sysSecurityAccept` permission.
|
Not a rebuild: the **security to-do list** is INBUXA's own design (inbuxa-drafts `specs/security-score.md`). The console runs its checks; the server's part is `inbuxa:SecurityAcceptance`, the accepted items (`crates/jmap/src/inbuxa/security_acceptance.rs`), and the `sysSecurityAccept` permission.
|
||||||
|
|
||||||
|
Not a rebuild: the **deliverability check** is INBUXA's own design (inbuxa-drafts `specs/deliverability.md`). Each sending node checks what other servers see of it (blocklists, reverse DNS, SPF, DKIM, DMARC, MTA-STS, certificates) and keeps a report: `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings` (`crates/jmap/src/inbuxa/deliverability.rs`, `crates/services/src/inbuxa_deliverability.rs`), and the `sysDeliverabilityGet`, `sysDeliverabilityUpdate` and `sysDeliverabilityCheck` permissions.
|
||||||
|
|
||||||
## 5. The web front ends
|
## 5. The web front ends
|
||||||
|
|
||||||
**Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs,
|
**Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs,
|
||||||
@@ -618,7 +631,7 @@ the tenant administrators' own view is not yet recorded
|
|||||||
|
|
||||||
## 8. Open decisions
|
## 8. Open decisions
|
||||||
|
|
||||||
- The INBUXA fork of ihasmail is **ihasmail-inbuxa** (named 2026-09-18). Open: its repository, and how it tracks
|
- The INBUXA fork of ihasmail is **inbuxa-webmail** (named ihasmail-inbuxa on 2026-09-18, renamed 2026-10-05). Open: how it tracks
|
||||||
public ihasmail (§5).
|
public ihasmail (§5).
|
||||||
- Product name: whether the shipped product is called inbuxa-server or
|
- Product name: whether the shipped product is called inbuxa-server or
|
||||||
something else inside the INBUXA brand.
|
something else inside the INBUXA brand.
|
||||||
|
|||||||
+75
-17
@@ -7,11 +7,11 @@ Status: draft, 2026-09-18. Expands SPEC.md §5.2.
|
|||||||
| Party | What it is | How it reaches the server |
|
| Party | What it is | How it reaches the server |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **inbuxa-server** | The mail server | — |
|
| **inbuxa-server** | The mail server | — |
|
||||||
| **ihasmail-inbuxa** | The INBUXA fork of ihasmail: a Node server and a web app. Public ihasmail stays Stalwart-facing and isn't party to this (SPEC.md §5) | Its **Node server** calls inbuxa-server, server to server. The browser only ever talks to ihasmail-inbuxa |
|
| **inbuxa-webmail** | The INBUXA fork of ihasmail: a Node server and a web app. Public ihasmail stays Stalwart-facing and isn't party to this (SPEC.md §5) | Its **Node server** calls inbuxa-server, server to server. The browser only ever talks to inbuxa-webmail |
|
||||||
| **INBUXA Admin** (`inbuxa-admin`) | A static web app, a fork of Stalwart WebUI | The **browser** calls inbuxa-server directly, cross-origin |
|
| **INBUXA Admin** (`inbuxa-admin`) | A static web app, a fork of Stalwart WebUI | The **browser** calls inbuxa-server directly, cross-origin |
|
||||||
|
|
||||||
That split decides most of what follows. Cross-origin rules matter only for
|
That split decides most of what follows. Cross-origin rules matter only for
|
||||||
INBUXA Admin. Token custody matters most for ihasmail-inbuxa, which holds
|
INBUXA Admin. Token custody matters most for inbuxa-webmail, which holds
|
||||||
tokens on its server for people who aren't there.
|
tokens on its server for people who aren't there.
|
||||||
|
|
||||||
## What upstream does today
|
## What upstream does today
|
||||||
@@ -45,7 +45,7 @@ Observed in the source at `v0.16.22` and against a running inbuxa-server on
|
|||||||
- **Endpoint gating:** `x:Http.allowedEndpoints` is an expression that can
|
- **Endpoint gating:** `x:Http.allowedEndpoints` is an expression that can
|
||||||
refuse endpoints by path and client IP. JMAP administration shares `/jmap`
|
refuse endpoints by path and client IP. JMAP administration shares `/jmap`
|
||||||
with everything else, so it can't separate admin calls on its own.
|
with everything else, so it can't separate admin calls on its own.
|
||||||
- **ihasmail today** (public, and so the starting point for ihasmail-inbuxa)
|
- **ihasmail today** (public, and so the starting point for inbuxa-webmail)
|
||||||
signs in with HTTP Basic auth and keeps the password sealed in its session
|
signs in with HTTP Basic auth and keeps the password sealed in its session
|
||||||
store (`sealedCredentials: {username, password}`), sending it on every
|
store (`sealedCredentials: {username, password}`), sending it on every
|
||||||
upstream call. It registers JMAP push subscriptions to its own URL, and reads
|
upstream call. It registers JMAP push subscriptions to its own URL, and reads
|
||||||
@@ -76,7 +76,7 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
(LP-19). Added 2026-09-21.
|
(LP-19). Added 2026-09-21.
|
||||||
- **C-2.** Each front end states the contract versions it supports and checks
|
- **C-2.** Each front end states the contract versions it supports and checks
|
||||||
`contract` after signing in. Outside its range it stops, with a message
|
`contract` after signing in. Outside its range it stops, with a message
|
||||||
naming both versions. For ihasmail-inbuxa this replaces public ihasmail's
|
naming both versions. For inbuxa-webmail this replaces public ihasmail's
|
||||||
"Stalwart 0.16 or later" check.
|
"Stalwart 0.16 or later" check.
|
||||||
- **C-3.** A breaking change to anything in this document bumps `contract`.
|
- **C-3.** A breaking change to anything in this document bumps `contract`.
|
||||||
Adding optional fields doesn't.
|
Adding optional fields doesn't.
|
||||||
@@ -98,12 +98,26 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
deliberate differences from upstream (see "Security note"). An operator who
|
deliberate differences from upstream (see "Security note"). An operator who
|
||||||
wants open dynamic registration for third-party apps can turn it back on;
|
wants open dynamic registration for third-party apps can turn it back on;
|
||||||
C-9's consent page still names every non-first-party client.
|
C-9's consent page still names every non-first-party client.
|
||||||
|
|
||||||
|
**`oAuthClientOverride` only in bootstrap and recovery mode** (2026-09-29).
|
||||||
|
Upstream lets an account holding it skip the client and redirect URI checks
|
||||||
|
on the sign-in page, at the code exchange and in the device flow.
|
||||||
|
Administrators hold it, so a link naming a made-up client and an attacker's
|
||||||
|
redirect URI handed an administrator's code, and then a token, to the
|
||||||
|
attacker: registration protected everyone except the accounts most worth
|
||||||
|
phishing. Now the permission counts only in bootstrap and recovery mode,
|
||||||
|
where the recovery administrator signs in before any client is registered.
|
||||||
|
An administrator otherwise signs in like anyone else, through a registered
|
||||||
|
client and one of its redirect URIs. INBUXA's production server was checked
|
||||||
|
first: its front ends' clients are registered with the redirect URIs they
|
||||||
|
use (C-6). Released in 2026.9.29.1. Checked by `tests/e2e/client_override.py` against the debug
|
||||||
|
build, with the same script failing against the build before the change.
|
||||||
- **C-6.** Two first-party clients are registered as `x:OAuthClient` whenever
|
- **C-6.** Two first-party clients are registered as `x:OAuthClient` whenever
|
||||||
`x:FrontEnds` is set or changed:
|
`x:FrontEnds` is set or changed:
|
||||||
- **`inbuxa-admin`**: a public client (no secret), authorization code with
|
- **`inbuxa-admin`**: a public client (no secret), authorization code with
|
||||||
PKCE S256, redirect URI `{adminUrl}/oauth/callback`.
|
PKCE S256, redirect URI `{adminUrl}/oauth/callback`.
|
||||||
- **`ihasmail-inbuxa`**: a confidential client with a secret held by the
|
- **`ihasmail-inbuxa`**: a confidential client with a secret held by the
|
||||||
ihasmail-inbuxa server, authorization code with PKCE S256, redirect URI
|
inbuxa-webmail server, authorization code with PKCE S256, redirect URI
|
||||||
`{webmailUrl}/api/auth/callback`.
|
`{webmailUrl}/api/auth/callback`.
|
||||||
INBUXA Admin's `<meta name="oauth-client-id">` is set to `inbuxa-admin`.
|
INBUXA Admin's `<meta name="oauth-client-id">` is set to `inbuxa-admin`.
|
||||||
Served by the server itself it uses the web interface's client,
|
Served by the server itself it uses the web interface's client,
|
||||||
@@ -145,7 +159,7 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
- **C-8.** People sign in on **the server's own sign-in page** (`/login`,
|
- **C-8.** People sign in on **the server's own sign-in page** (`/login`,
|
||||||
already INBUXA-branded), never on a front end's form. Two-factor happens
|
already INBUXA-branded), never on a front end's form. Two-factor happens
|
||||||
there, on the page's existing one-time-code step. Front ends never see a
|
there, on the page's existing one-time-code step. Front ends never see a
|
||||||
password. ihasmail-inbuxa's own sign-in form is retired in favor of a
|
password. inbuxa-webmail's own sign-in form is retired in favor of a
|
||||||
redirect.
|
redirect.
|
||||||
- **C-9.** **Consent for anything that isn't first-party.** When a client other
|
- **C-9.** **Consent for anything that isn't first-party.** When a client other
|
||||||
than the two first-party ones asks to sign someone in, the sign-in page names
|
than the two first-party ones asks to sign someone in, the sign-in page names
|
||||||
@@ -156,10 +170,10 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
|
|
||||||
### Tokens
|
### Tokens
|
||||||
|
|
||||||
- **C-10.** ihasmail-inbuxa holds tokens, never passwords. It keeps the access
|
- **C-10.** inbuxa-webmail holds tokens, never passwords. It keeps the access
|
||||||
and refresh token for each session sealed in its session store, where it now
|
and refresh token for each session sealed in its session store, where it now
|
||||||
keeps sealed credentials, and refreshes the access token before it expires.
|
keeps sealed credentials, and refreshes the access token before it expires.
|
||||||
The browser still holds only ihasmail-inbuxa's own session cookie. Public
|
The browser still holds only inbuxa-webmail's own session cookie. Public
|
||||||
ihasmail's "the browser never holds a credential" property is kept.
|
ihasmail's "the browser never holds a credential" property is kept.
|
||||||
- **C-11.** INBUXA Admin holds its tokens in the browser, as upstream WebUI
|
- **C-11.** INBUXA Admin holds its tokens in the browser, as upstream WebUI
|
||||||
does, since it has no server of its own. So admin tokens are short-lived
|
does, since it has no server of its own. So admin tokens are short-lived
|
||||||
@@ -179,9 +193,9 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
A revoked token stops working on its next use, and never later than one
|
A revoked token stops working on its next use, and never later than one
|
||||||
access-token lifetime.
|
access-token lifetime.
|
||||||
- **C-13.** Grants are listed per account (client, device description, created,
|
- **C-13.** Grants are listed per account (client, device description, created,
|
||||||
last used, IP), so ihasmail-inbuxa's "your sessions" screen shows server-side
|
last used, IP), so inbuxa-webmail's "your sessions" screen shows server-side
|
||||||
truth. Lifetimes, all configurable: access tokens 1 hour and refresh 30 days
|
truth. Lifetimes, all configurable: access tokens 1 hour and refresh 30 days
|
||||||
for ihasmail-inbuxa; access tokens 15 minutes and refresh 8 hours for
|
for inbuxa-webmail; access tokens 15 minutes and refresh 8 hours for
|
||||||
`inbuxa-admin`.
|
`inbuxa-admin`.
|
||||||
|
|
||||||
### Cross-origin
|
### Cross-origin
|
||||||
@@ -223,7 +237,7 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
scope `inbuxa:admin`, which only that client is ever granted. An admin
|
scope `inbuxa:admin`, which only that client is ever granted. An admin
|
||||||
account signing in through a mail client can't administer the server with
|
account signing in through a mail client can't administer the server with
|
||||||
that token, even though the account could.
|
that token, even though the account could.
|
||||||
- **C-19.** ihasmail-inbuxa's own administration (accounts, domains, groups,
|
- **C-19.** inbuxa-webmail's own administration (accounts, domains, groups,
|
||||||
lists, roles, tenants, the dashboard) uses the scope `inbuxa:account-admin`,
|
lists, roles, tenants, the dashboard) uses the scope `inbuxa:account-admin`,
|
||||||
granted only to `ihasmail-inbuxa`, and limited to those object types, plus
|
granted only to `ihasmail-inbuxa`, and limited to those object types, plus
|
||||||
`x:Metric` get and query for the dashboard's message cards (monitoring
|
`x:Metric` get and query for the dashboard's message cards (monitoring
|
||||||
@@ -236,18 +250,62 @@ Each has an ID, and tests name the IDs they check.
|
|||||||
|
|
||||||
### Push
|
### Push
|
||||||
|
|
||||||
- **C-22.** Unchanged from public ihasmail: ihasmail-inbuxa registers JMAP push
|
- **C-22.** Unchanged from public ihasmail: inbuxa-webmail registers JMAP push
|
||||||
subscriptions to its own URL, with VAPID for browser notifications. The only
|
subscriptions to its own URL, with VAPID for browser notifications. The only
|
||||||
difference is that it authenticates with its token rather than the password.
|
difference is that it authenticates with its token rather than the password.
|
||||||
|
|
||||||
|
### Passwords over HTTP
|
||||||
|
|
||||||
|
- **C-23.** **Outside DAV, HTTP sign-in is a token, never a password.** JMAP
|
||||||
|
(`/jmap`, with session, upload, download, event source and WebSocket), the
|
||||||
|
management API (`/api`), and the OAuth endpoints that authenticate a user
|
||||||
|
(`/auth/introspect`, `/auth/userinfo`, authenticated `/auth/register`)
|
||||||
|
refuse an `Authorization: Basic` header with a 401 whose only challenge is
|
||||||
|
`Bearer`, and don't check the password. CalDAV and CardDAV (`/dav`) keep
|
||||||
|
Basic, since that's how calendar and contacts apps sign in, and their 401s
|
||||||
|
still offer it. The sign-in page's own endpoint (`/api/auth`) takes the
|
||||||
|
password in its body, not a header, and isn't affected. Neither is the token
|
||||||
|
endpoint's client authentication. SCIM already takes an API key only.
|
||||||
|
Bootstrap and recovery mode accept Basic everywhere, as they keep
|
||||||
|
permissive CORS (C-16).
|
||||||
|
**Decision**: without this, anyone can put up a copy of a front end on a
|
||||||
|
server of their own that collects a person's password and replays it as
|
||||||
|
Basic. Cross-origin rules (C-14) don't stop that, because a server isn't a
|
||||||
|
browser, and neither does client registration (C-5), because Basic never
|
||||||
|
goes through OAuth. With C-23, the password only goes to the server's own
|
||||||
|
sign-in page (C-8), or to a DAV client or mail app the person set up
|
||||||
|
themselves.
|
||||||
|
An operator who needs Basic on every endpoint sets
|
||||||
|
`INBUXA_HTTP_BASIC_AUTH=all`; `dav`, the default, is this rule. Any other
|
||||||
|
value logs a warning and keeps the default. The setting moves to the
|
||||||
|
registry with `x:FrontEnds` (C-4).
|
||||||
|
inbuxa-webmail confirms a typed password, which it does before creating
|
||||||
|
an app password, on `/api/auth` as its own client, to its registered
|
||||||
|
redirect URI, with a PKCE challenge whose verifier it discards. A
|
||||||
|
"two-factor code needed" answer counts as confirmed, since the server gives
|
||||||
|
it only after the password matched.
|
||||||
|
**Built, 2026-09-29.** `crates/http/src/auth/token_only.rs` names the
|
||||||
|
paths; `request.rs` refuses before routing and picks the 401's challenge by
|
||||||
|
path; `Http.basic_auth_everywhere` holds the setting. Test builds
|
||||||
|
(`test_mode`) accept Basic everywhere, since the integration suites sign in
|
||||||
|
with passwords. Checked by `tests/e2e/http_basic_auth.py` against the debug
|
||||||
|
build, 26 checks: everything above, both front ends' sign-in path, a wrong
|
||||||
|
password answered exactly as the right one, and a redirect URI the webmail
|
||||||
|
didn't register refused.
|
||||||
|
Observed before the change, in INBUXA's production logs from 2026-09-20 to 2026-09-29:
|
||||||
|
every HTTPS password sign-in was the operator's own, apart from
|
||||||
|
inbuxa-webmail's password sign-in on 2026-09-22, before it moved to OAuth.
|
||||||
|
The logs don't say whether a sign-in used a Basic header or the sign-in
|
||||||
|
page.
|
||||||
|
|
||||||
## First boot
|
## First boot
|
||||||
|
|
||||||
1. The installer, or INBUXA Admin's setup wizard, completes bootstrap
|
1. The installer, or INBUXA Admin's setup wizard, completes bootstrap
|
||||||
(SPEC.md §6.2). In bootstrap mode, CORS is permissive (C-16) and the
|
(SPEC.md §6.2). In bootstrap mode, CORS is permissive (C-16) and the
|
||||||
recovery administrator applies.
|
recovery administrator applies.
|
||||||
2. It sets `x:FrontEnds` (webmail and admin URLs, the public URL), which
|
2. It sets `x:FrontEnds` (webmail and admin URLs, the public URL), which
|
||||||
registers both first-party clients (C-6). For ihasmail-inbuxa it returns the
|
registers both first-party clients (C-6). For inbuxa-webmail it returns the
|
||||||
client secret once, for the installer to write into ihasmail-inbuxa's
|
client secret once, for the installer to write into inbuxa-webmail's
|
||||||
environment.
|
environment.
|
||||||
3. After the restart out of bootstrap, CORS follows C-14, registration is
|
3. After the restart out of bootstrap, CORS follows C-14, registration is
|
||||||
required (C-5), and the recovery administrator is ignored (SPEC.md §6.2).
|
required (C-5), and the recovery administrator is ignored (SPEC.md §6.2).
|
||||||
@@ -318,7 +376,7 @@ client and reload settings. This is the state C-5 and C-6 make the default.
|
|||||||
5. The phishing flow in the security note fails at step 1, and a registered
|
5. The phishing flow in the security note fails at step 1, and a registered
|
||||||
third-party client with a non-first-party redirect shows the consent page
|
third-party client with a non-first-party redirect shows the consent page
|
||||||
(C-9).
|
(C-9).
|
||||||
6. ihasmail-inbuxa signs in without ever handling a password. Its session
|
6. inbuxa-webmail signs in without ever handling a password. Its session
|
||||||
store holds tokens only (C-8, C-10).
|
store holds tokens only (C-8, C-10).
|
||||||
7. Revoking one grant stops that session within one access-token lifetime,
|
7. Revoking one grant stops that session within one access-token lifetime,
|
||||||
leaves others working, and "sign out other sessions" keeps the current one
|
leaves others working, and "sign out other sessions" keeps the current one
|
||||||
@@ -330,7 +388,7 @@ client and reload settings. This is the state C-5 and C-6 make the default.
|
|||||||
10. In bootstrap mode, INBUXA Admin reaches the server from any origin (C-16).
|
10. In bootstrap mode, INBUXA Admin reaches the server from any origin (C-16).
|
||||||
11. An admin account's token from a third-party mail client can't read
|
11. An admin account's token from a third-party mail client can't read
|
||||||
`x:NetworkListener`. The same account through `inbuxa-admin` can (C-18).
|
`x:NetworkListener`. The same account through `inbuxa-admin` can (C-18).
|
||||||
12. ihasmail-inbuxa's token can manage accounts and tenants but not listeners
|
12. inbuxa-webmail's token can manage accounts and tenants but not listeners
|
||||||
or certificates (C-19).
|
or certificates (C-19).
|
||||||
13. With `adminNetworks` set, an `inbuxa:admin` request from outside is refused
|
13. With `adminNetworks` set, an `inbuxa:admin` request from outside is refused
|
||||||
(C-20).
|
(C-20).
|
||||||
@@ -344,4 +402,4 @@ client and reload settings. This is the state C-5 and C-6 make the default.
|
|||||||
2. The consent page's wording and whether it remembers a decision per client.
|
2. The consent page's wording and whether it remembers a decision per client.
|
||||||
3. API keys and app passwords with explicit scopes (C-21): what upstream's
|
3. API keys and app passwords with explicit scopes (C-21): what upstream's
|
||||||
`x:ApiKey` already supports, to observe before specifying.
|
`x:ApiKey` already supports, to observe before specifying.
|
||||||
4. Whether ihasmail-inbuxa's secret should rotate, and how.
|
4. Whether inbuxa-webmail's secret should rotate, and how.
|
||||||
@@ -584,7 +584,7 @@ Three consequences:
|
|||||||
name whose DNS points at the mail addresses.
|
name whose DNS points at the mail addresses.
|
||||||
- Whether the front ends need anything at cutover, or follow separately
|
- Whether the front ends need anything at cutover, or follow separately
|
||||||
(SPEC.md §5). The rehearsal does not start them.
|
(SPEC.md §5). The rehearsal does not start them.
|
||||||
- Whether ihasmail-inbuxa and INBUXA Admin behave under real use, rather
|
- Whether inbuxa-webmail and INBUXA Admin behave under real use, rather
|
||||||
than at first sign-in. Both were verified as far as signing in and, for the
|
than at first sign-in. Both were verified as far as signing in and, for the
|
||||||
webmail, mail flowing.
|
webmail, mail flowing.
|
||||||
- The checks only users can make: the second account, the mailbox comparison
|
- The checks only users can make: the second account, the mailbox comparison
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ adds at most 2.
|
|||||||
|
|
||||||
## ihasmail changes
|
## ihasmail changes
|
||||||
|
|
||||||
These go in the INBUXA fork of ihasmail, ihasmail-inbuxa, never in public
|
These go in the INBUXA fork of ihasmail, inbuxa-webmail, never in public
|
||||||
ihasmail, which stays Stalwart-facing (SPEC.md §5).
|
ihasmail, which stays Stalwart-facing (SPEC.md §5).
|
||||||
|
|
||||||
- **Reading:** when a message has an `X-Spam-LLM` header, the message details
|
- **Reading:** when a message has an `X-Spam-LLM` header, the message details
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ Each requirement has an ID, and tests name the IDs they check.
|
|||||||
|
|
||||||
## ihasmail changes
|
## ihasmail changes
|
||||||
|
|
||||||
These go in the INBUXA fork of ihasmail, ihasmail-inbuxa, never in public
|
These go in the INBUXA fork of ihasmail, inbuxa-webmail, never in public
|
||||||
ihasmail, which stays Stalwart-facing (SPEC.md §5).
|
ihasmail, which stays Stalwart-facing (SPEC.md §5).
|
||||||
|
|
||||||
- **Administration, Domains:** a logo field on each domain. Upload a PNG, JPEG
|
- **Administration, Domains:** a logo field on each domain. Upload a PNG, JPEG
|
||||||
@@ -389,7 +389,7 @@ files carry hooks marked `inbuxa:`. Acceptance tests 1 to 17 pass as
|
|||||||
`tests/src/system/branding.rs`.
|
`tests/src/system/branding.rs`.
|
||||||
|
|
||||||
- **BT-1 to BT-26:** built.
|
- **BT-1 to BT-26:** built.
|
||||||
- **ihasmail changes** belong to ihasmail-inbuxa and aren't part of this
|
- **ihasmail changes** belong to inbuxa-webmail and aren't part of this
|
||||||
repository.
|
repository.
|
||||||
- **Test 18 (compat)** is written as `branding_compat`, ignored, and unrun
|
- **Test 18 (compat)** is written as `branding_compat`, ignored, and unrun
|
||||||
until a copy of INBUXA's data is provided. INBUXA holds no logos or
|
until a copy of INBUXA's data is provided. INBUXA holds no logos or
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Written for the record SPEC.md §3 rule 3 asks for. Sources, and nothing else:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| This repository at `0502eb4` (2026-09-28): `crates/smtp/src/inbound/data.rs`, `crates/smtp/src/queue/`, `crates/jmap/src/submission/set.rs`, `crates/common/src/scripts/`, `vendor/sieve-rs`, `resources/schema/schema.json.gz` | AGPL-3.0-only | Where a check can run, what the queue stores, what a sender sees on a refusal |
|
| This repository at `0502eb4` (2026-09-28): `crates/smtp/src/inbound/data.rs`, `crates/smtp/src/queue/`, `crates/jmap/src/submission/set.rs`, `crates/common/src/scripts/`, `vendor/sieve-rs`, `resources/schema/schema.json.gz` | AGPL-3.0-only | Where a check can run, what the queue stores, what a sender sees on a refusal |
|
||||||
| inbuxa-admin at `b82904c` | AGPL-3.0-only | Where the pages go |
|
| inbuxa-admin at `b82904c` | AGPL-3.0-only | Where the pages go |
|
||||||
| ihasmail-inbuxa (the webmail) at `290bc63` | AGPL-3.0-or-later | How a refused send reaches the person sending |
|
| inbuxa-webmail (the webmail) at `290bc63` | AGPL-3.0-or-later | How a refused send reaches the person sending |
|
||||||
| `inbuxa-drafts/queue/dlp.md`, `rule-builder.md` | Own | What John asked for and settled |
|
| `inbuxa-drafts/queue/dlp.md`, `rule-builder.md` | Own | What John asked for and settled |
|
||||||
| The personal-data catalog spec and the audit-hold-lock spec | Own | Roles, the audit log, legal holds, the catalog check |
|
| The personal-data catalog spec and the audit-hold-lock spec | Own | Roles, the audit log, legal holds, the catalog check |
|
||||||
| RFC 5321, RFC 3463 (enhanced status codes), RFC 8620/8621 (JMAP) | Public | Refusal codes and the submission error shape |
|
| RFC 5321, RFC 3463 (enhanced status codes), RFC 8620/8621 (JMAP) | Public | Refusal codes and the submission error shape |
|
||||||
@@ -385,7 +385,7 @@ first). The inspection limit caps the worst case.
|
|||||||
Every form previews the rule in words ("If a recipient is outside and the
|
Every form previews the rule in words ("If a recipient is outside and the
|
||||||
message contains 5 or more card numbers, hold it for review").
|
message contains 5 or more card numbers, hold it for review").
|
||||||
|
|
||||||
## 4. Webmail (ihasmail-inbuxa)
|
## 4. Webmail (inbuxa-webmail)
|
||||||
|
|
||||||
- A warning dialog: the notice, a reason field, **Send anyway** and **Edit
|
- A warning dialog: the notice, a reason field, **Send anyway** and **Edit
|
||||||
message**.
|
message**.
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ upstream files carry hooks marked `inbuxa:`. Acceptance tests 1 to 11 pass as
|
|||||||
`createdBy`. The server-set name is **deferred** until sign-in goes through
|
`createdBy`. The server-set name is **deferred** until sign-in goes through
|
||||||
OAuth (contract C-8): with Basic auth there's no client name, so a mask
|
OAuth (contract C-8): with Basic auth there's no client name, so a mask
|
||||||
created through the Fastmail API has none.
|
created through the Fastmail API has none.
|
||||||
- **ihasmail changes** belong to ihasmail-inbuxa and aren't part of this
|
- **ihasmail changes** belong to inbuxa-webmail and aren't part of this
|
||||||
repository.
|
repository.
|
||||||
- **Test 12 (compat)** is written as `masked_email_compat`, ignored, and unrun
|
- **Test 12 (compat)** is written as `masked_email_compat`, ignored, and unrun
|
||||||
until a copy of INBUXA's data with masks made on it is provided. Its doc
|
until a copy of INBUXA's data with masks made on it is provided. Its doc
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ unchanged.
|
|||||||
|
|
||||||
## ihasmail changes
|
## ihasmail changes
|
||||||
|
|
||||||
These go in ihasmail-inbuxa, not public ihasmail, which stays Stalwart-facing
|
These go in inbuxa-webmail, not public ihasmail, which stays Stalwart-facing
|
||||||
(SPEC.md §5).
|
(SPEC.md §5).
|
||||||
|
|
||||||
- The dashboard already reads `x:Metric` for received, sent and memory. Keep
|
- The dashboard already reads `x:Metric` for received, sent and memory. Keep
|
||||||
|
|||||||
@@ -336,9 +336,9 @@ called from `system_tests` with no gate.
|
|||||||
- **MT-1 to MT-18, MT-20 to MT-23:** built.
|
- **MT-1 to MT-18, MT-20 to MT-23:** built.
|
||||||
- **MT-19, MT-19a:** built, except the submission-time warning, **deferred**
|
- **MT-19, MT-19a:** built, except the submission-time warning, **deferred**
|
||||||
(see MT-19a) until the contract defines a warnings shape.
|
(see MT-19a) until the contract defines a warnings shape.
|
||||||
- **ihasmail changes** (the section above) belong to ihasmail-inbuxa and
|
- **ihasmail changes** (the section above) belong to inbuxa-webmail and
|
||||||
aren't part of this repository. Its branding and quota warnings wait for
|
aren't part of this repository. Its branding and quota warnings wait for
|
||||||
ihasmail-inbuxa.
|
inbuxa-webmail.
|
||||||
- **Test 15 (compat)** is written as `tenant_compat`, ignored, and unrun until
|
- **Test 15 (compat)** is written as `tenant_compat`, ignored, and unrun until
|
||||||
a copy of INBUXA's data is provided. Its doc comment says how to run it.
|
a copy of INBUXA's data is provided. Its doc comment says how to run it.
|
||||||
- **Known limits, not requirements of this spec:**
|
- **Known limits, not requirements of this spec:**
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ check it and the fork keeps it.
|
|||||||
|
|
||||||
## ihasmail changes
|
## ihasmail changes
|
||||||
|
|
||||||
These go in ihasmail-inbuxa, the INBUXA fork of ihasmail, never in public
|
These go in inbuxa-webmail, the INBUXA fork of ihasmail, never in public
|
||||||
ihasmail, which stays Stalwart-facing (SPEC.md §5).
|
ihasmail, which stays Stalwart-facing (SPEC.md §5).
|
||||||
|
|
||||||
- **Domain editor:** a directory picker offering "server default" and the
|
- **Domain editor:** a directory picker offering "server default" and the
|
||||||
|
|||||||
@@ -679,7 +679,7 @@ SCIM error documents (RFC 7644 §3.12): `schemas`
|
|||||||
|
|
||||||
## ihasmail changes
|
## ihasmail changes
|
||||||
|
|
||||||
These go in ihasmail-inbuxa, not public ihasmail, which stays
|
These go in inbuxa-webmail, not public ihasmail, which stays
|
||||||
Stalwart-facing (SPEC.md §5).
|
Stalwart-facing (SPEC.md §5).
|
||||||
|
|
||||||
- **Domains:** an "Allow SCIM provisioning" switch on the domain form. Turning
|
- **Domains:** an "Allow SCIM provisioning" switch on the domain form. Turning
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ marked `inbuxa:`. Acceptance tests 1 to 15 pass as
|
|||||||
`tests/src/system/undelete.rs`, with `/changes` and the `/query` filters.
|
`tests/src/system/undelete.rs`, with `/changes` and the `/query` filters.
|
||||||
|
|
||||||
- **UD-1 to UD-17a:** built.
|
- **UD-1 to UD-17a:** built.
|
||||||
- **ihasmail changes** belong to ihasmail-inbuxa and aren't part of this
|
- **ihasmail changes** belong to inbuxa-webmail and aren't part of this
|
||||||
repository.
|
repository.
|
||||||
- **Test 16 (compat)** is written as `undelete_compat`, ignored, and unrun
|
- **Test 16 (compat)** is written as `undelete_compat`, ignored, and unrun
|
||||||
until a copy of INBUXA's data with archived items made on it is provided.
|
until a copy of INBUXA's data with archived items made on it is provided.
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ conflicts.
|
|||||||
|
|
||||||
1. Every requirement MT-1 to MT-23 is implemented, or deliberately deferred
|
1. Every requirement MT-1 to MT-23 is implemented, or deliberately deferred
|
||||||
with a line in the spec saying so. The branding and quota warnings for
|
with a line in the spec saying so. The branding and quota warnings for
|
||||||
ihasmail can wait for ihasmail-inbuxa.
|
ihasmail can wait for inbuxa-webmail.
|
||||||
2. Acceptance tests 1 to 14 pass as integration tests
|
2. Acceptance tests 1 to 14 pass as integration tests
|
||||||
(`tests/src/system/tenant.rs`), with the `pending-rebuild` gate removed
|
(`tests/src/system/tenant.rs`), with the `pending-rebuild` gate removed
|
||||||
from the tenant call.
|
from the tenant call.
|
||||||
|
|||||||
@@ -166,6 +166,18 @@ reason = ["content"]
|
|||||||
file = "inbuxa_journal_entry.rs"
|
file = "inbuxa_journal_entry.rs"
|
||||||
default = "none"
|
default = "none"
|
||||||
|
|
||||||
|
# The deliverability check (deliverability spec): facts about the server's own
|
||||||
|
# addresses, names and domains. The blocklists it asks see those addresses and
|
||||||
|
# domains, as they would whenever anyone checks mail from the server; nothing
|
||||||
|
# about people is sent or kept.
|
||||||
|
[object."inbuxa:DeliverabilityReport"]
|
||||||
|
file = "inbuxa_deliverability_report.rs"
|
||||||
|
default = "none"
|
||||||
|
|
||||||
|
[object."inbuxa:DeliverabilitySettings"]
|
||||||
|
file = "inbuxa_deliverability_settings.rs"
|
||||||
|
default = "none"
|
||||||
|
|
||||||
[object."inbuxa:LegalHold"]
|
[object."inbuxa:LegalHold"]
|
||||||
file = "inbuxa_legal_hold.rs"
|
file = "inbuxa_legal_hold.rs"
|
||||||
default = "none"
|
default = "none"
|
||||||
@@ -244,6 +256,16 @@ retention = "unbounded"
|
|||||||
changedBy = ["identifier"]
|
changedBy = ["identifier"]
|
||||||
recentLegacyUse = ["identifier", "metadata"]
|
recentLegacyUse = ["identifier", "metadata"]
|
||||||
|
|
||||||
|
[object."inbuxa:SharingPolicy"]
|
||||||
|
file = "inbuxa_sharing_policy.rs"
|
||||||
|
default = "none"
|
||||||
|
whose = ["administrator", "holder"]
|
||||||
|
where = ["data-store"]
|
||||||
|
scope = "tenant"
|
||||||
|
retention = "unbounded"
|
||||||
|
[object."inbuxa:SharingPolicy".properties]
|
||||||
|
changedBy = ["identifier"]
|
||||||
|
|
||||||
[object."inbuxa:AiLimits"]
|
[object."inbuxa:AiLimits"]
|
||||||
file = "inbuxa_ai_limits.rs"
|
file = "inbuxa_ai_limits.rs"
|
||||||
default = "none"
|
default = "none"
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
D8e0s1e4Umau4gRh5MEW24KtsawKGPNvS-6LWrIFbtQ
|
EFFcJvTPjNnvBGmMdfIkunMwqbh6NS_rsmhmhF2OK3U
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local end-to-end check that an administrator gets no OAuth client bypass
|
||||||
|
outside bootstrap and recovery mode (contract C-5).
|
||||||
|
|
||||||
|
Run it with `python3 tests/e2e/client_override.py` after
|
||||||
|
`cargo build -p inbuxa`. Needs Docker. Working state goes under target/e2e.
|
||||||
|
|
||||||
|
Administrators hold OAuthClientOverride. Upstream lets it skip the client and
|
||||||
|
redirect URI checks everywhere, so a link naming a made-up client and an
|
||||||
|
attacker's redirect URI would hand an administrator's code to the attacker.
|
||||||
|
This boots the debug binary and checks that:
|
||||||
|
- in bootstrap mode, the recovery administrator still signs in through an
|
||||||
|
unregistered client, as the setup wizard needs;
|
||||||
|
- after setup, an administrator gets no code for an unregistered client, nor
|
||||||
|
for a registered one with a redirect URI it didn't register, while the
|
||||||
|
registered client and URI still work end to end;
|
||||||
|
- a device code an administrator approves for an unregistered client can't be
|
||||||
|
exchanged for a token;
|
||||||
|
- in recovery mode, the bypass is back for the recovery administrator.
|
||||||
|
|
||||||
|
Passwords are generated into files under target/e2e and never printed.
|
||||||
|
Everything is removed afterwards unless KEEP=1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64, hashlib, json, os, secrets, shutil, subprocess, sys, time, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
DIR = f"{ROOT}/target/e2e"
|
||||||
|
NAME = "inbuxa-client-override"
|
||||||
|
PORT = 18195
|
||||||
|
HTTP = f"http://127.0.0.1:{PORT}"
|
||||||
|
ADMIN_URL = "http://admin.override.test"
|
||||||
|
REDIRECT = f"{ADMIN_URL}/oauth/callback"
|
||||||
|
EVIL = "https://evil.example/cb"
|
||||||
|
# Another build to check, such as one from before the change.
|
||||||
|
BINARY = os.environ.get("INBUXA_BINARY", f"{ROOT}/target/debug/inbuxa")
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, what):
|
||||||
|
print(("ok " if cond else "FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
failures.append(what)
|
||||||
|
|
||||||
|
|
||||||
|
def secret_file(name, value=None):
|
||||||
|
path = f"{DIR}/secrets/{name}"
|
||||||
|
if value is None:
|
||||||
|
value = secrets.token_urlsafe(24)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(value)
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def docker(*args, check_rc=True):
|
||||||
|
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check_rc)
|
||||||
|
|
||||||
|
|
||||||
|
def start(env=None):
|
||||||
|
env_file = f"{DIR}/secrets/override-env"
|
||||||
|
with open(env_file, "w") as f:
|
||||||
|
for key, value in (env or {}).items():
|
||||||
|
f.write(f"{key}={value}\n")
|
||||||
|
os.chmod(env_file, 0o600)
|
||||||
|
docker("run", "-d", "--name", NAME, "--user", f"{os.getuid()}:{os.getgid()}",
|
||||||
|
"--entrypoint", "/usr/local/bin/inbuxa",
|
||||||
|
"-v", f"{BINARY}:/usr/local/bin/inbuxa:ro",
|
||||||
|
"-v", f"{DIR}/etc-override:/etc/inbuxa", "-v", f"{DIR}/data-override:/var/lib/inbuxa",
|
||||||
|
"-p", f"127.0.0.1:{PORT}:8080",
|
||||||
|
# A debug build's workers need more than the default stack.
|
||||||
|
"-e", "RUST_MIN_STACK=16777216",
|
||||||
|
# Registers inbuxa-admin, the one client this server knows (C-6).
|
||||||
|
"-e", f"INBUXA_ADMIN_URL={ADMIN_URL}",
|
||||||
|
"--env-file", env_file,
|
||||||
|
"stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json")
|
||||||
|
for _ in range(120):
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(f"{HTTP}/.well-known/jmap", timeout=2)
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
return
|
||||||
|
sys.exit("server didn't come up: " + docker("logs", "--tail", "40", NAME, check_rc=False).stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def stop():
|
||||||
|
docker("rm", "-f", NAME, check_rc=False)
|
||||||
|
|
||||||
|
|
||||||
|
def restart(env=None):
|
||||||
|
stop()
|
||||||
|
start(env)
|
||||||
|
|
||||||
|
|
||||||
|
def request(path, method="GET", body=None, content_type=None, authorization=None):
|
||||||
|
req = urllib.request.Request(f"{HTTP}{path}", data=body, method=method)
|
||||||
|
if content_type:
|
||||||
|
req.add_header("Content-Type", content_type)
|
||||||
|
if authorization:
|
||||||
|
req.add_header("Authorization", authorization)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return resp.status, resp.read()
|
||||||
|
except urllib.error.HTTPError as err:
|
||||||
|
return err.code, err.read()
|
||||||
|
|
||||||
|
|
||||||
|
def jmap(user, password, calls):
|
||||||
|
body = json.dumps({"using": ["urn:ietf:params:jmap:core", "urn:inbuxa:jmap:registry"],
|
||||||
|
"methodCalls": calls}).encode()
|
||||||
|
auth = "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
status, raw = request("/jmap/", "POST", body, "application/json", auth)
|
||||||
|
if status != 200:
|
||||||
|
sys.exit(f"JMAP call failed: {status}")
|
||||||
|
return json.loads(raw)["methodResponses"]
|
||||||
|
|
||||||
|
|
||||||
|
def pkce():
|
||||||
|
verifier = secrets.token_urlsafe(48)
|
||||||
|
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||||
|
return verifier, challenge
|
||||||
|
|
||||||
|
|
||||||
|
def sign_in(user, password, client_id, redirect_uri, challenge):
|
||||||
|
"""The sign-in page's request: what an authorization link leads to."""
|
||||||
|
status, raw = request("/api/auth", "POST", json.dumps({
|
||||||
|
"type": "authCode", "accountName": user, "accountSecret": password,
|
||||||
|
"clientId": client_id, "redirectUri": redirect_uri,
|
||||||
|
"codeChallenge": challenge, "codeChallengeMethod": "S256"}).encode(), "application/json")
|
||||||
|
return json.loads(raw) if status == 200 else {"type": status}
|
||||||
|
|
||||||
|
|
||||||
|
def exchange(client_id, code, redirect_uri, verifier):
|
||||||
|
status, raw = request("/auth/token", "POST", urllib.parse.urlencode({
|
||||||
|
"grant_type": "authorization_code", "client_id": client_id, "code": code,
|
||||||
|
"redirect_uri": redirect_uri, "code_verifier": verifier}).encode(),
|
||||||
|
"application/x-www-form-urlencoded")
|
||||||
|
return status, json.loads(raw or b"{}")
|
||||||
|
|
||||||
|
|
||||||
|
def phished(user, password, client_id, redirect_uri):
|
||||||
|
"""Whether a link naming this client and redirect URI ends in a token."""
|
||||||
|
verifier, challenge = pkce()
|
||||||
|
answer = sign_in(user, password, client_id, redirect_uri, challenge)
|
||||||
|
if answer.get("type") != "authenticated":
|
||||||
|
return False, answer.get("type")
|
||||||
|
status, body = exchange(client_id, answer["client_code"], redirect_uri, verifier)
|
||||||
|
return status == 200 and "access_token" in body, f"code issued, exchange {status}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
stop()
|
||||||
|
for sub in ("etc-override", "data-override"):
|
||||||
|
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
|
||||||
|
for sub in ("etc-override", "data-override", "secrets"):
|
||||||
|
os.makedirs(f"{DIR}/{sub}", exist_ok=True)
|
||||||
|
os.chmod(f"{DIR}/secrets", 0o700)
|
||||||
|
|
||||||
|
# Bootstrap mode: the recovery administrator keeps the bypass.
|
||||||
|
recovery = secret_file("override-recovery")
|
||||||
|
start({"INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
|
||||||
|
got, how = phished("admin", recovery, "setup-wizard", EVIL)
|
||||||
|
check(got, f"bootstrap mode: the recovery administrator signs in through an unregistered client ({how})")
|
||||||
|
|
||||||
|
got = jmap("admin", recovery, [["x:Bootstrap/get", {"ids": None}, "0"]])
|
||||||
|
singleton = got[0][1]["list"][0]["id"]
|
||||||
|
res = jmap("admin", recovery, [["x:Bootstrap/set", {"update": {singleton: {
|
||||||
|
"serverHostname": "mail.override.test", "defaultDomain": "override.test",
|
||||||
|
"requestTlsCertificate": False}}}, "0"]])
|
||||||
|
updated = res[0][1].get("updated", {}).get(singleton)
|
||||||
|
check(bool(updated), "bootstrap completed")
|
||||||
|
if not updated:
|
||||||
|
sys.exit(json.dumps(res))
|
||||||
|
admin, admin_pw = updated["username"], secret_file("override-admin", updated["secret"])
|
||||||
|
|
||||||
|
# After setup: no bypass for an administrator.
|
||||||
|
restart()
|
||||||
|
got, how = phished(admin, admin_pw, "inbuxa-admin", REDIRECT)
|
||||||
|
check(got, f"the registered client and redirect URI still sign an administrator in ({how})")
|
||||||
|
got, how = phished(admin, admin_pw, "evil-client", EVIL)
|
||||||
|
check(not got, f"an unregistered client gets nothing for an administrator ({how})")
|
||||||
|
got, how = phished(admin, admin_pw, "inbuxa-admin", EVIL)
|
||||||
|
check(not got, f"a registered client with a foreign redirect URI gets nothing ({how})")
|
||||||
|
|
||||||
|
# Device flow: the administrator approves a code a made-up client asked for.
|
||||||
|
status, raw = request("/auth/device", "POST", b"client_id=evil-device", "application/x-www-form-urlencoded")
|
||||||
|
device = json.loads(raw) if status == 200 else {}
|
||||||
|
check("device_code" in device, f"a device code is issued to anyone ({status})")
|
||||||
|
if "device_code" in device:
|
||||||
|
status, raw = request("/api/auth", "POST", json.dumps({
|
||||||
|
"type": "authDevice", "accountName": admin, "accountSecret": admin_pw,
|
||||||
|
"code": device["user_code"]}).encode(), "application/json")
|
||||||
|
print(" approval:", json.loads(raw).get("type") if status == 200 else status)
|
||||||
|
status, raw = request("/auth/token", "POST", urllib.parse.urlencode({
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
|
"client_id": "evil-device", "device_code": device["device_code"]}).encode(),
|
||||||
|
"application/x-www-form-urlencoded")
|
||||||
|
body = json.loads(raw or b"{}")
|
||||||
|
check("access_token" not in body,
|
||||||
|
f"but an administrator's approval can't be exchanged for a token ({status}, {body.get('error')})")
|
||||||
|
|
||||||
|
# Recovery mode: the bypass is back, for the recovery administrator.
|
||||||
|
restart({"INBUXA_RECOVERY_MODE": "1", "INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
|
||||||
|
got, how = phished("admin", recovery, "recovery-tool", EVIL)
|
||||||
|
check(got, f"recovery mode: the recovery administrator signs in through an unregistered client ({how})")
|
||||||
|
|
||||||
|
if os.environ.get("KEEP") != "1":
|
||||||
|
stop()
|
||||||
|
for sub in ("etc-override", "data-override"):
|
||||||
|
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
|
||||||
|
for name in ("override-recovery", "override-admin", "override-env"):
|
||||||
|
try:
|
||||||
|
os.remove(f"{DIR}/secrets/{name}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print()
|
||||||
|
if failures:
|
||||||
|
print(f"{len(failures)} failed")
|
||||||
|
sys.exit(1)
|
||||||
|
print("all passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local end-to-end check of contract C-23: outside DAV, HTTP sign-in is a
|
||||||
|
token, never a password.
|
||||||
|
|
||||||
|
Run it with `python3 tests/e2e/http_basic_auth.py` after
|
||||||
|
`cargo build -p inbuxa`. Needs Docker. Working state goes under target/e2e.
|
||||||
|
|
||||||
|
Boots the debug binary and checks that:
|
||||||
|
- in bootstrap mode, Basic works on JMAP (as permissive CORS does, C-16);
|
||||||
|
- after setup, Basic is refused on JMAP, the API, userinfo and introspection,
|
||||||
|
with a 401 that offers only Bearer, and the password isn't checked;
|
||||||
|
- DAV still takes Basic, and its 401 still offers it;
|
||||||
|
- a token from the sign-in endpoint (`/api/auth`, the password in the body)
|
||||||
|
and the token endpoint works on JMAP: the path the front ends use, and the
|
||||||
|
one inbuxa-webmail's password check relies on;
|
||||||
|
- INBUXA_HTTP_BASIC_AUTH=all puts Basic back everywhere, an unknown value
|
||||||
|
keeps the default with a warning, and recovery mode accepts Basic.
|
||||||
|
|
||||||
|
Passwords are generated into files under target/e2e and never printed.
|
||||||
|
Everything is removed afterwards unless KEEP=1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64, hashlib, json, os, secrets, shutil, subprocess, sys, time, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
DIR = f"{ROOT}/target/e2e"
|
||||||
|
NAME = "inbuxa-basic-auth"
|
||||||
|
PORT = 18180
|
||||||
|
HTTP = f"http://127.0.0.1:{PORT}"
|
||||||
|
ADMIN_URL = "http://admin.basic.test"
|
||||||
|
REDIRECT = f"{ADMIN_URL}/oauth/callback"
|
||||||
|
WEBMAIL_URL = "http://webmail.basic.test"
|
||||||
|
WEBMAIL_REDIRECT = f"{WEBMAIL_URL}/api/auth/callback"
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
WEBMAIL_SECRET = secrets.token_urlsafe(24)
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, what):
|
||||||
|
print(("ok " if cond else "FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
failures.append(what)
|
||||||
|
|
||||||
|
|
||||||
|
def secret_file(name, value=None):
|
||||||
|
path = f"{DIR}/secrets/{name}"
|
||||||
|
if value is None:
|
||||||
|
value = secrets.token_urlsafe(24)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(value)
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def docker(*args, check_rc=True):
|
||||||
|
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check_rc)
|
||||||
|
|
||||||
|
|
||||||
|
def start(env=None):
|
||||||
|
args = ["run", "-d", "--name", NAME, "--user", f"{os.getuid()}:{os.getgid()}",
|
||||||
|
"--entrypoint", "/usr/local/bin/inbuxa",
|
||||||
|
"-v", f"{ROOT}/target/debug/inbuxa:/usr/local/bin/inbuxa:ro",
|
||||||
|
"-v", f"{DIR}/etc-basic:/etc/inbuxa", "-v", f"{DIR}/data-basic:/var/lib/inbuxa",
|
||||||
|
"-p", f"127.0.0.1:{PORT}:8080",
|
||||||
|
# A debug build's workers need more than the default stack.
|
||||||
|
"-e", "RUST_MIN_STACK=16777216",
|
||||||
|
# Registers inbuxa-admin and inbuxa-webmail (C-6).
|
||||||
|
"-e", f"INBUXA_ADMIN_URL={ADMIN_URL}", "-e", f"INBUXA_WEBMAIL_URL={WEBMAIL_URL}"]
|
||||||
|
env_file = f"{DIR}/secrets/basic-env"
|
||||||
|
with open(env_file, "w") as f:
|
||||||
|
f.write(f"INBUXA_WEBMAIL_CLIENT_SECRET={WEBMAIL_SECRET}\n")
|
||||||
|
for key, value in (env or {}).items():
|
||||||
|
f.write(f"{key}={value}\n")
|
||||||
|
os.chmod(env_file, 0o600)
|
||||||
|
args += ["--env-file", env_file, "stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
|
||||||
|
docker(*args)
|
||||||
|
for _ in range(120):
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(f"{HTTP}/.well-known/jmap", timeout=2)
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
return
|
||||||
|
sys.exit("server didn't come up: " + docker("logs", "--tail", "40", NAME, check_rc=False).stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def stop():
|
||||||
|
docker("rm", "-f", NAME, check_rc=False)
|
||||||
|
|
||||||
|
|
||||||
|
def restart(env=None):
|
||||||
|
stop()
|
||||||
|
start(env)
|
||||||
|
|
||||||
|
|
||||||
|
def basic(user, password):
|
||||||
|
return "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def request(path, authorization=None, method="GET", body=None, content_type=None, headers=None):
|
||||||
|
"""(status, headers, body) for a request, whatever the status."""
|
||||||
|
req = urllib.request.Request(f"{HTTP}{path}", data=body, method=method)
|
||||||
|
if authorization:
|
||||||
|
req.add_header("Authorization", authorization)
|
||||||
|
if content_type:
|
||||||
|
req.add_header("Content-Type", content_type)
|
||||||
|
for key, value in (headers or {}).items():
|
||||||
|
req.add_header(key, value)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return resp.status, resp.headers, resp.read()
|
||||||
|
except urllib.error.HTTPError as err:
|
||||||
|
return err.code, err.headers, err.read()
|
||||||
|
|
||||||
|
|
||||||
|
def challenges(headers):
|
||||||
|
return sorted(value.split(" ", 1)[0] for value in headers.get_all("WWW-Authenticate") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def jmap(authorization, calls):
|
||||||
|
body = json.dumps({"using": ["urn:ietf:params:jmap:core", "urn:inbuxa:jmap:registry"],
|
||||||
|
"methodCalls": calls}).encode()
|
||||||
|
status, _, raw = request("/jmap/", authorization, "POST", body, "application/json")
|
||||||
|
if status != 200:
|
||||||
|
sys.exit(f"JMAP call failed: {status}")
|
||||||
|
return json.loads(raw)["methodResponses"]
|
||||||
|
|
||||||
|
|
||||||
|
def sign_in(user, password, client_id, redirect_uri, verifier):
|
||||||
|
"""What the sign-in endpoint answers, the password in the request body."""
|
||||||
|
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
|
||||||
|
status, _, raw = request("/api/auth", method="POST", content_type="application/json", body=json.dumps({
|
||||||
|
"type": "authCode", "accountName": user, "accountSecret": password,
|
||||||
|
"clientId": client_id, "redirectUri": redirect_uri,
|
||||||
|
"codeChallenge": challenge, "codeChallengeMethod": "S256"}).encode())
|
||||||
|
return json.loads(raw) if status == 200 else {"type": status}
|
||||||
|
|
||||||
|
|
||||||
|
def token(user, password):
|
||||||
|
"""An access token the way a front end gets one: the sign-in endpoint, then
|
||||||
|
the token endpoint, with PKCE."""
|
||||||
|
verifier = secrets.token_urlsafe(48)
|
||||||
|
answer = sign_in(user, password, "inbuxa-admin", REDIRECT, verifier)
|
||||||
|
if answer.get("type") != "authenticated":
|
||||||
|
return None, answer.get("type") or status
|
||||||
|
status, _, raw = request("/auth/token", method="POST", content_type="application/x-www-form-urlencoded",
|
||||||
|
body=urllib.parse.urlencode({
|
||||||
|
"grant_type": "authorization_code", "client_id": "inbuxa-admin",
|
||||||
|
"code": answer["client_code"], "redirect_uri": REDIRECT,
|
||||||
|
"code_verifier": verifier}).encode())
|
||||||
|
if status != 200:
|
||||||
|
return None, status
|
||||||
|
return json.loads(raw)["access_token"], "authenticated"
|
||||||
|
|
||||||
|
|
||||||
|
def propfind(path, authorization):
|
||||||
|
return request(path, authorization, "PROPFIND", b'<?xml version="1.0"?><propfind xmlns="DAV:"><prop><resourcetype/></prop></propfind>',
|
||||||
|
"application/xml", {"Depth": "0"})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
stop()
|
||||||
|
for sub in ("etc-basic", "data-basic"):
|
||||||
|
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
|
||||||
|
for sub in ("etc-basic", "data-basic", "secrets"):
|
||||||
|
os.makedirs(f"{DIR}/{sub}", exist_ok=True)
|
||||||
|
os.chmod(f"{DIR}/secrets", 0o700)
|
||||||
|
|
||||||
|
# Bootstrap mode: Basic works on JMAP, as it must for the setup wizard.
|
||||||
|
recovery = secret_file("basic-recovery")
|
||||||
|
start({"INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
|
||||||
|
status, _, _ = request("/jmap/session", basic("admin", recovery))
|
||||||
|
check(status == 200, "bootstrap mode: Basic works on JMAP")
|
||||||
|
got = jmap(basic("admin", recovery), [["x:Bootstrap/get", {"ids": None}, "0"]])
|
||||||
|
singleton = got[0][1]["list"][0]["id"]
|
||||||
|
res = jmap(basic("admin", recovery), [["x:Bootstrap/set", {"update": {singleton: {
|
||||||
|
"serverHostname": "mail.basic.test", "defaultDomain": "basic.test",
|
||||||
|
"requestTlsCertificate": False}}}, "0"]])
|
||||||
|
updated = res[0][1].get("updated", {}).get(singleton)
|
||||||
|
check(bool(updated), "bootstrap completed")
|
||||||
|
if not updated:
|
||||||
|
sys.exit(json.dumps(res))
|
||||||
|
admin, admin_pw = updated["username"], secret_file("basic-admin", updated["secret"])
|
||||||
|
|
||||||
|
# After setup, the default: Basic on DAV only. What follows needs a
|
||||||
|
# tracer to stdout, to read warnings back, and a user account for the
|
||||||
|
# webmail's password check. Both are made with a token, since Basic no
|
||||||
|
# longer reaches JMAP.
|
||||||
|
restart()
|
||||||
|
admin_token, how = token(admin, admin_pw)
|
||||||
|
if not admin_token:
|
||||||
|
sys.exit(f"no token for the administrator: {how}")
|
||||||
|
domain = jmap(f"Bearer {admin_token}", [["x:Domain/get", {"ids": None}, "0"]])[0][1]["list"][0]["id"]
|
||||||
|
user, user_pw = "[email protected]", secret_file("basic-user")
|
||||||
|
res = jmap(f"Bearer {admin_token}", [
|
||||||
|
["x:Tracer/set", {"create": {"t": {"@type": "Stdout", "level": "info", "buffered": False, "ansi": False}}}, "0"],
|
||||||
|
["x:Account/set", {"create": {"a": {"@type": "User", "name": "u", "domainId": domain,
|
||||||
|
"credentials": {"0": {"@type": "Password", "secret": user_pw}}}}}, "1"]])
|
||||||
|
if not (res[0][1].get("created") or {}).get("t") or not (res[1][1].get("created") or {}).get("a"):
|
||||||
|
sys.exit("setup failed: " + json.dumps(res))
|
||||||
|
restart()
|
||||||
|
right, wrong = basic(admin, admin_pw), basic(admin, "not-the-password")
|
||||||
|
status, headers, _ = request("/jmap/session", right)
|
||||||
|
check(status == 401, "Basic with the right password is refused on /jmap/session")
|
||||||
|
check(challenges(headers) == ["Bearer"], f"that 401 offers only Bearer ({challenges(headers)})")
|
||||||
|
status, _, _ = request("/jmap/", right, "POST", b'{"using":[],"methodCalls":[]}', "application/json")
|
||||||
|
check(status == 401, "Basic is refused on a JMAP API call")
|
||||||
|
status, headers, _ = request("/jmap/", None, "POST", b'{"using":[],"methodCalls":[]}', "application/json")
|
||||||
|
check(status == 401 and challenges(headers) == ["Bearer"],
|
||||||
|
f"an unauthenticated JMAP call's 401 offers only Bearer ({challenges(headers)})")
|
||||||
|
for path in ("/api/account", "/auth/userinfo"):
|
||||||
|
status, headers, _ = request(path, right)
|
||||||
|
check(status == 401 and challenges(headers) == ["Bearer"], f"Basic is refused on {path}")
|
||||||
|
status, _, _ = request("/auth/introspect", right, "POST", b"token=x", "application/x-www-form-urlencoded")
|
||||||
|
check(status == 401, "Basic is refused on /auth/introspect")
|
||||||
|
|
||||||
|
# Refused before the password is looked at, so the answer is the same
|
||||||
|
# either way and can't be used to guess one.
|
||||||
|
status_right, headers_right, body_right = request("/jmap/session", right)
|
||||||
|
status_wrong, headers_wrong, body_wrong = request("/jmap/session", wrong)
|
||||||
|
check((status_wrong, challenges(headers_wrong), body_wrong) == (status_right, challenges(headers_right), body_right),
|
||||||
|
"a wrong password over Basic gets exactly the same answer as the right one")
|
||||||
|
|
||||||
|
# DAV keeps Basic.
|
||||||
|
status, _, _ = propfind(f"/dav/card/{admin}/", right)
|
||||||
|
check(status == 207, f"Basic works on CardDAV ({status})")
|
||||||
|
status, _, _ = propfind(f"/dav/cal/{admin}/", right)
|
||||||
|
check(status == 207, f"Basic works on CalDAV ({status})")
|
||||||
|
status, headers, _ = propfind(f"/dav/card/{admin}/", None)
|
||||||
|
check(status == 401 and "Basic" in challenges(headers),
|
||||||
|
f"DAV's 401 still offers Basic ({challenges(headers)})")
|
||||||
|
|
||||||
|
# The front ends' path: the sign-in endpoint and a token.
|
||||||
|
access, how = token(admin, admin_pw)
|
||||||
|
check(access is not None, f"the sign-in endpoint takes the password in its body ({how})")
|
||||||
|
_, how_wrong = token(admin, "not-the-password")
|
||||||
|
check(how_wrong == "failure", f"and says failure for a wrong one ({how_wrong})")
|
||||||
|
if access:
|
||||||
|
status, _, _ = request("/jmap/session", f"Bearer {access}")
|
||||||
|
check(status == 200, "a token works on /jmap/session")
|
||||||
|
status, _, _ = request("/api/account", f"Bearer {access}")
|
||||||
|
check(status == 200, f"a token works on /api/account ({status})")
|
||||||
|
|
||||||
|
# inbuxa-webmail's password check before an app password: its own client,
|
||||||
|
# its registered redirect URI, a verifier it throws away.
|
||||||
|
for password, want in ((user_pw, "authenticated"), ("not-the-password", "failure")):
|
||||||
|
got = sign_in(user, password, "ihasmail-inbuxa", WEBMAIL_REDIRECT, secrets.token_urlsafe(48))
|
||||||
|
check(got.get("type") == want, f"the webmail's password check answers {want} ({got.get('type')})")
|
||||||
|
got = sign_in(user, user_pw, "ihasmail-inbuxa", "https://evil.example/cb", secrets.token_urlsafe(48))
|
||||||
|
check(got.get("type") != "authenticated", f"but not to a redirect URI it didn't register ({got.get('type')})")
|
||||||
|
|
||||||
|
# The operator's switch.
|
||||||
|
restart({"INBUXA_HTTP_BASIC_AUTH": "all"})
|
||||||
|
status, _, _ = request("/jmap/session", right)
|
||||||
|
check(status == 200, "INBUXA_HTTP_BASIC_AUTH=all: Basic works on JMAP again")
|
||||||
|
status, headers, _ = request("/jmap/", None, "POST", b'{"using":[],"methodCalls":[]}', "application/json")
|
||||||
|
check("Basic" in challenges(headers), f"and JMAP's 401 offers it again ({challenges(headers)})")
|
||||||
|
|
||||||
|
restart({"INBUXA_HTTP_BASIC_AUTH": "sometimes"})
|
||||||
|
status, _, _ = request("/jmap/session", right)
|
||||||
|
check(status == 401, "an unknown INBUXA_HTTP_BASIC_AUTH keeps Basic refused")
|
||||||
|
logs = docker("logs", NAME, check_rc=False)
|
||||||
|
check("INBUXA_HTTP_BASIC_AUTH" in logs.stdout + logs.stderr, "and says so in the log")
|
||||||
|
|
||||||
|
restart({"INBUXA_HTTP_BASIC_AUTH": "dav"})
|
||||||
|
status, _, _ = request("/jmap/session", right)
|
||||||
|
check(status == 401, "INBUXA_HTTP_BASIC_AUTH=dav is the default")
|
||||||
|
|
||||||
|
# Recovery mode accepts Basic, for the recovery administrator.
|
||||||
|
restart({"INBUXA_RECOVERY_MODE": "1", "INBUXA_RECOVERY_ADMIN": f"admin:{recovery}"})
|
||||||
|
status, _, _ = request("/jmap/session", basic("admin", recovery))
|
||||||
|
check(status == 200, "recovery mode: Basic works on JMAP")
|
||||||
|
|
||||||
|
if os.environ.get("KEEP") != "1":
|
||||||
|
stop()
|
||||||
|
for sub in ("etc-basic", "data-basic"):
|
||||||
|
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
|
||||||
|
for name in ("basic-recovery", "basic-admin", "basic-user", "basic-env"):
|
||||||
|
try:
|
||||||
|
os.remove(f"{DIR}/secrets/{name}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print()
|
||||||
|
if failures:
|
||||||
|
print(f"{len(failures)} failed")
|
||||||
|
sys.exit(1)
|
||||||
|
print("all passed")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -90,7 +90,9 @@ def start(env_file=None):
|
|||||||
"-p", f"127.0.0.1:{PORTS['submissions']}:465",
|
"-p", f"127.0.0.1:{PORTS['submissions']}:465",
|
||||||
"-p", f"127.0.0.1:{PORTS['imap']}:993",
|
"-p", f"127.0.0.1:{PORTS['imap']}:993",
|
||||||
"-p", f"127.0.0.1:{PORTS['pop3']}:995",
|
"-p", f"127.0.0.1:{PORTS['pop3']}:995",
|
||||||
"-p", f"127.0.0.1:{PORTS['smtp']}:25"]
|
"-p", f"127.0.0.1:{PORTS['smtp']}:25",
|
||||||
|
# This script signs in with passwords over JMAP (contract C-23).
|
||||||
|
"-e", "INBUXA_HTTP_BASIC_AUTH=all"]
|
||||||
if env_file:
|
if env_file:
|
||||||
args += ["--env-file", env_file]
|
args += ["--env-file", env_file]
|
||||||
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
|
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{AssertResult, ImapConnection, Type, append::assert_append_message};
|
use super::{AssertResult, ImapConnection, Type, append::assert_append_message};
|
||||||
@@ -69,6 +71,15 @@ pub async fn test(
|
|||||||
.await;
|
.await;
|
||||||
imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await;
|
imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await;
|
||||||
|
|
||||||
|
// inbuxa: MA-D0: but she can't share the group's mailbox on
|
||||||
|
imap_jane
|
||||||
|
.send("SETACL \"Shared Folders/[email protected]/INBOX\" [email protected] lr")
|
||||||
|
.await;
|
||||||
|
imap_jane
|
||||||
|
.assert_read(Type::Tagged, ResponseType::No)
|
||||||
|
.await
|
||||||
|
.assert_contains("NOPERM");
|
||||||
|
|
||||||
// John should have no shared folders
|
// John should have no shared folders
|
||||||
imap_john.send("LIST \"\" \"*\"").await;
|
imap_john.send("LIST \"\" \"*\"").await;
|
||||||
imap_john
|
imap_john
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! MA-D0 (specs/multi-account.md): a group's members have its calendars,
|
||||||
|
//! address books and files, but can't share them on. Who is in a group is
|
||||||
|
//! an administrator's decision. Mailboxes are checked in `mail::acl`.
|
||||||
|
|
||||||
|
use crate::utils::{jmap::JmapUtils, server::TestServer};
|
||||||
|
use jmap_proto::request::method::MethodObject;
|
||||||
|
use registry::schema::prelude::ObjectType;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
pub async fn test(test: &TestServer) {
|
||||||
|
println!("Running group sharing tests...");
|
||||||
|
let admin = test.account("[email protected]");
|
||||||
|
let sales = test.account("[email protected]");
|
||||||
|
let bill = test.account("[email protected]");
|
||||||
|
let robert = test.account("[email protected]");
|
||||||
|
let robert_id = robert.id_string().to_string();
|
||||||
|
|
||||||
|
// Bill joins the group; Robert stays outside it
|
||||||
|
admin
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Account,
|
||||||
|
bill.id(),
|
||||||
|
json!({"memberGroupIds": {sales.id_string(): true}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Each kind's own name for "may read"
|
||||||
|
for (object, read) in [
|
||||||
|
(MethodObject::Calendar, "mayReadItems"),
|
||||||
|
(MethodObject::AddressBook, "mayRead"),
|
||||||
|
(MethodObject::FileNode, "mayRead"),
|
||||||
|
] {
|
||||||
|
// Made with a share: refused
|
||||||
|
let response = bill
|
||||||
|
.jmap_create_account(
|
||||||
|
sales,
|
||||||
|
object,
|
||||||
|
[json!({
|
||||||
|
"name": "Shared on",
|
||||||
|
"shareWith": {&robert_id: {read: true}}
|
||||||
|
})],
|
||||||
|
Vec::<(&str, &str)>::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response.pointer("/methodResponses/0/1/notCreated/i0/type"),
|
||||||
|
Some(&json!("forbidden")),
|
||||||
|
"MA-D0: {object} created with a share: {:?}",
|
||||||
|
response.pointer("/methodResponses/0")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Made without one: fine, and it says it can't be shared
|
||||||
|
let id = bill
|
||||||
|
.jmap_create_account(
|
||||||
|
sales,
|
||||||
|
object,
|
||||||
|
[json!({"name": "The group's"})],
|
||||||
|
Vec::<(&str, &str)>::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.created(0)
|
||||||
|
.id()
|
||||||
|
.to_string();
|
||||||
|
let rights = bill
|
||||||
|
.jmap_get_account(sales, object, ["myRights"], [id.as_str()])
|
||||||
|
.await
|
||||||
|
.list()[0]["myRights"]
|
||||||
|
.clone();
|
||||||
|
assert_eq!(rights["mayShare"], false, "MA-D0: {object} myRights {rights}");
|
||||||
|
assert_eq!(rights["mayDelete"], true, "MA-D0: {object} myRights {rights}");
|
||||||
|
|
||||||
|
// Shared afterwards: refused
|
||||||
|
let response = bill
|
||||||
|
.jmap_update_account(
|
||||||
|
sales,
|
||||||
|
object,
|
||||||
|
[(
|
||||||
|
&id,
|
||||||
|
json!({format!("shareWith/{robert_id}"): {read: true}}),
|
||||||
|
)],
|
||||||
|
Vec::<(&str, &str)>::new(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
response.pointer(&format!("/methodResponses/0/1/notUpdated/{id}/type")),
|
||||||
|
Some(&json!("forbidden")),
|
||||||
|
"MA-D0: {object} shared on: {:?}",
|
||||||
|
response.pointer("/methodResponses/0")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Robert still has nothing
|
||||||
|
assert_eq!(
|
||||||
|
robert
|
||||||
|
.jmap_get_account(sales, object, Vec::<&str>::new(), [id.as_str()])
|
||||||
|
.await
|
||||||
|
.method_response()
|
||||||
|
.typ(),
|
||||||
|
"forbidden",
|
||||||
|
"MA-D0: {object} reached from outside"
|
||||||
|
);
|
||||||
|
|
||||||
|
bill.jmap_destroy_account(sales, object, [id.as_str()], Vec::<(&str, &str)>::new())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaching the group's calendars and address books made its defaults
|
||||||
|
let sales_id = sales.id_string();
|
||||||
|
bill.jmap_method_calls(json!([
|
||||||
|
["Calendar/get", {"accountId": sales_id, "ids": (), "properties": ["id"]}, "c"],
|
||||||
|
["Calendar/set", {"accountId": sales_id, "onDestroyRemoveEvents": true,
|
||||||
|
"#destroy": {"resultOf": "c", "name": "Calendar/get", "path": "/list/*/id"}}, "cd"],
|
||||||
|
["AddressBook/get", {"accountId": sales_id, "ids": (), "properties": ["id"]}, "a"],
|
||||||
|
["AddressBook/set", {"accountId": sales_id, "onDestroyRemoveContents": true,
|
||||||
|
"#destroy": {"resultOf": "a", "name": "AddressBook/get", "path": "/list/*/id"}}, "ad"]
|
||||||
|
]))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
admin
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Account,
|
||||||
|
bill.id(),
|
||||||
|
json!({"memberGroupIds": {sales.id_string(): false}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::utils::server::TestServer;
|
use crate::utils::server::TestServer;
|
||||||
@@ -713,6 +715,35 @@ pub async fn test(test: &TestServer) {
|
|||||||
.await,
|
.await,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// inbuxa: MA-D0: a member can't share the group's mailbox on, and isn't
|
||||||
|
// told it may. Who is in a group is an administrator's decision.
|
||||||
|
assert_forbidden(
|
||||||
|
john_client
|
||||||
|
.set_default_account_id(sales.id_string())
|
||||||
|
.mailbox_update_acl(&inbox_id, bill.id_string(), [ACL::ReadItems])
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!john_client
|
||||||
|
.set_default_account_id(sales.id_string())
|
||||||
|
.mailbox_get(&inbox_id, [mailbox::Property::MyRights].into())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.my_rights()
|
||||||
|
.unwrap()
|
||||||
|
.acl_list()
|
||||||
|
.contains(&ACL::Administer)
|
||||||
|
);
|
||||||
|
bill_client.refresh_session().await.unwrap();
|
||||||
|
assert!(bill_client.session().account(sales.id_string()).is_none());
|
||||||
|
assert_forbidden(
|
||||||
|
bill_client
|
||||||
|
.set_default_account_id(sales.id_string())
|
||||||
|
.email_get(&email_id, [Property::Subject].into())
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
|
||||||
// Remove John from the sales group
|
// Remove John from the sales group
|
||||||
admin
|
admin
|
||||||
.registry_update_object(
|
.registry_update_object(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::utils::server::TestServerBuilder;
|
use crate::utils::server::TestServerBuilder;
|
||||||
@@ -22,6 +24,7 @@ pub mod compliance;
|
|||||||
pub mod contacts;
|
pub mod contacts;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod group_share;
|
||||||
pub mod mail;
|
pub mod mail;
|
||||||
pub mod principal;
|
pub mod principal;
|
||||||
|
|
||||||
@@ -219,6 +222,7 @@ pub async fn jmap_tests() {
|
|||||||
|
|
||||||
calendar::identity::test(&test).await;
|
calendar::identity::test(&test).await;
|
||||||
calendar::acl::test(&test).await;
|
calendar::acl::test(&test).await;
|
||||||
|
group_share::test(&test).await;
|
||||||
|
|
||||||
principal::get::test(&test).await;
|
principal::get::test(&test).await;
|
||||||
principal::availability::test(&test).await;
|
principal::availability::test(&test).await;
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ pub async fn test(test: &TestServer) {
|
|||||||
// inbuxa: MT-22, the logo that applies to the account, and
|
// inbuxa: MT-22, the logo that applies to the account, and
|
||||||
// LP-19, whether the legacy protocols are open to it, and
|
// LP-19, whether the legacy protocols are open to it, and
|
||||||
// ai-explain EX-1, whether Explain can be offered
|
// ai-explain EX-1, whether Explain can be offered
|
||||||
"urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false },
|
"urn:inbuxa:jmap": { "logo": null, "legacyProtocols": "enabled", "legacyAllowed": ["imap", "pop3", "manageSieve", "submission"], "aiExplain": false, "mailSharing": true, "addAccounts": true },
|
||||||
"https://www.fastmail.com/dev/maskedemail": {}
|
"https://www.fastmail.com/dev/maskedemail": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -447,6 +447,156 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
|
query["ids"].as_array().is_some_and(|ids| ids.len() >= 2),
|
||||||
"AL-9: the delegate's access and changes weren't recorded: {query}"
|
"AL-9: the delegate's access and changes weren't recorded: {query}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
shared_mailbox(admin, &mut smtp_rx, &mut lmtp).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MA-S (specs/multi-account.md): a shared mailbox is a lock of its own
|
||||||
|
/// kind. No reason is needed, more people fit, its Sieve replies go out,
|
||||||
|
/// only what is sent as it is recorded, and it sends only as itself.
|
||||||
|
async fn shared_mailbox(
|
||||||
|
admin: &Account,
|
||||||
|
smtp_rx: &mut tokio::sync::mpsc::Receiver<crate::jmap::mail::submission::MockMessage>,
|
||||||
|
lmtp: &mut SmtpConnection,
|
||||||
|
) {
|
||||||
|
println!("Running shared mailbox tests...");
|
||||||
|
let support = admin
|
||||||
|
.create_user_account("[email protected]", "support-secret-3317", "Support", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let agent = admin
|
||||||
|
.create_user_account("[email protected]", "agent-secret-5520", "Agent", &[], vec![])
|
||||||
|
.await;
|
||||||
|
let support_id = support.id_string().to_string();
|
||||||
|
|
||||||
|
// An automatic acknowledgement, set up while it could still sign in
|
||||||
|
support
|
||||||
|
.jmap_client()
|
||||||
|
.await
|
||||||
|
.vacation_response_enable("Received", "We'll get back to you.".into(), None::<String>)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// More people than a lock may have
|
||||||
|
let mut delegates = vec![json!({"accountId": agent.id_string(), "access": "organize", "sendAs": true})];
|
||||||
|
for n in 0..11 {
|
||||||
|
let name: &'static str = Box::leak(format!("desk{n}@example.com").into_boxed_str());
|
||||||
|
let desk = admin.create_user_account(name, "desk-secret-7781", "Desk", &[], vec![]).await;
|
||||||
|
delegates.push(json!({"accountId": desk.id_string(), "access": "read"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No reason needed
|
||||||
|
let response = admin
|
||||||
|
.lock_set(json!({"create": {"s": {"accountId": support_id, "kind": "sharedMailbox",
|
||||||
|
"delegates": delegates}}}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(response["created"]["s"]["id"], support_id.as_str(), "MA-S1: {response}");
|
||||||
|
let (_, got) = admin
|
||||||
|
.call("inbuxa:AccountLock/get", json!({"accountId": admin.id_string(), "ids": [support_id]}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(got["list"][0]["kind"], "sharedMailbox", "MA-S1: {got}");
|
||||||
|
|
||||||
|
// Nobody signs in to it
|
||||||
|
assert_ne!(support.session_status().await, 200, "MA-S1: a shared mailbox signed in");
|
||||||
|
|
||||||
|
// It says what it is to the people in it
|
||||||
|
let session = agent.jmap_session_object().await.0;
|
||||||
|
let delegation = &session["accounts"][support_id.as_str()]["accountCapabilities"]["urn:inbuxa:jmap"]["delegation"];
|
||||||
|
assert_eq!(delegation["kind"], "sharedMailbox", "MA-S: {session}");
|
||||||
|
assert_eq!(delegation["locked"], true, "MA-S: front ends that know no kind still see a lock");
|
||||||
|
|
||||||
|
// Its Sieve replies go out, where a lock's are held back
|
||||||
|
lmtp.ingest(
|
||||||
|
"[email protected]",
|
||||||
|
&["[email protected]"],
|
||||||
|
// Addressed to it: a vacation reply answers only mail sent to it
|
||||||
|
"From: [email protected]\r\nTo: [email protected]\r\nSubject: My order\r\n\r\nHello.\r\n",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_message_delivery(
|
||||||
|
smtp_rx,
|
||||||
|
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Received"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// The agent answers as support@: sent, and recorded as the agent
|
||||||
|
let (_, mailboxes) = agent
|
||||||
|
.call("Mailbox/get", json!({"accountId": support_id, "ids": null, "properties": ["role"]}))
|
||||||
|
.await;
|
||||||
|
let drafts = mailboxes["list"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|m| m["role"] == "drafts")
|
||||||
|
.unwrap_or_else(|| panic!("no Drafts: {mailboxes}"))["id"]
|
||||||
|
.clone();
|
||||||
|
let (_, identities) = agent
|
||||||
|
.call("Identity/get", json!({"accountId": support_id, "ids": null}))
|
||||||
|
.await;
|
||||||
|
let identity = identities["list"][0]["id"].clone();
|
||||||
|
let send = |reply_to: Option<&str>, subject: &str| {
|
||||||
|
let mut email = json!({
|
||||||
|
"mailboxIds": {drafts.as_str().unwrap(): true},
|
||||||
|
"from": [{"email": "[email protected]"}],
|
||||||
|
"to": [{"email": "[email protected]"}],
|
||||||
|
"subject": subject,
|
||||||
|
"bodyValues": {"t": {"value": "Thanks for writing."}},
|
||||||
|
"textBody": [{"partId": "t", "type": "text/plain"}]
|
||||||
|
});
|
||||||
|
if let Some(reply_to) = reply_to {
|
||||||
|
email["replyTo"] = json!([{"email": reply_to}]);
|
||||||
|
}
|
||||||
|
json!([
|
||||||
|
["Email/set", {"accountId": support_id, "create": {"m": email}}, "e"],
|
||||||
|
["EmailSubmission/set", {"accountId": support_id,
|
||||||
|
"create": {"s": {"identityId": identity, "emailId": "#m"}}}, "s"]
|
||||||
|
])
|
||||||
|
};
|
||||||
|
let response = agent.jmap_request(USING, send(None, "Re: My order")).await.0;
|
||||||
|
assert!(
|
||||||
|
response.pointer("/methodResponses/1/1/created/s").is_some(),
|
||||||
|
"MA-S3: the answer didn't go out: {response}"
|
||||||
|
);
|
||||||
|
assert_message_delivery(
|
||||||
|
smtp_rx,
|
||||||
|
MockMessage::new("<[email protected]>", ["<[email protected]>"], "@Re: My order"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// MA-S3: a reply can't be steered to the agent's own address
|
||||||
|
let response = agent
|
||||||
|
.jmap_request(USING, send(Some("[email protected]"), "Write to me directly"))
|
||||||
|
.await
|
||||||
|
.0;
|
||||||
|
assert_eq!(
|
||||||
|
response.pointer("/methodResponses/1/1/notCreated/s/type"),
|
||||||
|
Some(&json!("forbiddenFrom")),
|
||||||
|
"MA-S3: {response}"
|
||||||
|
);
|
||||||
|
expect_nothing(smtp_rx).await;
|
||||||
|
|
||||||
|
// MA-D0a: the send names the agent; AL-9's per-change records don't
|
||||||
|
// apply in a shared mailbox
|
||||||
|
let (_, query) = admin
|
||||||
|
.call(
|
||||||
|
"inbuxa:AuditEvent/query",
|
||||||
|
json!({"accountId": admin.id_string(),
|
||||||
|
"filter": {"actorId": agent.id_string(), "accountId": support_id}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (_, records) = admin
|
||||||
|
.call("inbuxa:AuditEvent/get", json!({"accountId": admin.id_string(), "ids": query["ids"]}))
|
||||||
|
.await;
|
||||||
|
let kinds = records["list"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| r["target"]["kind"].as_str().unwrap_or_default().to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(kinds, ["EmailSubmission"], "MA-D0a: {records}");
|
||||||
|
|
||||||
|
// Ending it needs no reason either
|
||||||
|
let response = admin.lock_set(json!({"destroy": [support_id]})).await;
|
||||||
|
assert_eq!(response["destroyed"][0], support_id.as_str(), "MA-S: {response}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`.
|
/// Runs these tests alone: `cargo test -p tests account_lock_tests -- --ignored`.
|
||||||
|
|||||||
@@ -540,10 +540,110 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
"AU-7: continued"
|
"AU-7: continued"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// MA-D0a: a group member sending as the group is named in the log; the
|
||||||
|
// same person sending as themselves isn't recorded
|
||||||
|
let group = admin
|
||||||
|
.create_group_account("[email protected]", "Help desk", &[])
|
||||||
|
.await;
|
||||||
|
let agent = admin
|
||||||
|
.create_user_account(
|
||||||
|
"[email protected]",
|
||||||
|
"agent-secret-for-send-as",
|
||||||
|
"Agent",
|
||||||
|
&[],
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
admin
|
||||||
|
.registry_update_object(
|
||||||
|
ObjectType::Account,
|
||||||
|
agent.id(),
|
||||||
|
json!({"memberGroupIds": {group.id_string(): true}}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
agent.send_as("[email protected]").await;
|
||||||
|
agent.send_as("[email protected]").await;
|
||||||
|
let sends = admin
|
||||||
|
.audit(json!({
|
||||||
|
"targetKind": "EmailSubmission",
|
||||||
|
"actorId": agent.id_string(),
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
assert_eq!(sends.len(), 1, "MA-D0a: {sends:?}");
|
||||||
|
assert_eq!(sends[0]["target"]["name"], "[email protected]");
|
||||||
|
assert_eq!(
|
||||||
|
sends[0]["target"]["accountId"],
|
||||||
|
group.id_string(),
|
||||||
|
"MA-D0a: {}",
|
||||||
|
sends[0]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sends[0]["details"],
|
||||||
|
"Sent as [email protected], from [email protected]"
|
||||||
|
);
|
||||||
|
|
||||||
// Clean up what later suites could trip over
|
// Clean up what later suites could trip over
|
||||||
admin.registry_destroy_all(ObjectType::BlockedIp).await;
|
admin.registry_destroy_all(ObjectType::BlockedIp).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
/// Sends one message to itself from its own account, as `from`.
|
||||||
|
async fn send_as(&self, from: &str) {
|
||||||
|
const USING: &[&str] = &[
|
||||||
|
"urn:ietf:params:jmap:core",
|
||||||
|
"urn:ietf:params:jmap:mail",
|
||||||
|
"urn:ietf:params:jmap:submission",
|
||||||
|
];
|
||||||
|
let account_id = self.id_string();
|
||||||
|
let response = self
|
||||||
|
.jmap_request(
|
||||||
|
USING,
|
||||||
|
json!([
|
||||||
|
["Identity/get", {"accountId": account_id}, "i"],
|
||||||
|
["Mailbox/get", {"accountId": account_id, "properties": ["role"]}, "m"]
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let identity = response
|
||||||
|
.0
|
||||||
|
.pointer("/methodResponses/0/1/list")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|list| list.iter().find(|identity| identity["email"] == from))
|
||||||
|
.unwrap_or_else(|| panic!("no identity for {from}: {}", response.0))["id"]
|
||||||
|
.clone();
|
||||||
|
let drafts = response
|
||||||
|
.0
|
||||||
|
.pointer("/methodResponses/1/1/list")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|list| list.iter().find(|mailbox| mailbox["role"] == "drafts"))
|
||||||
|
.unwrap_or_else(|| panic!("no drafts: {}", response.0))["id"]
|
||||||
|
.clone();
|
||||||
|
let response = self
|
||||||
|
.jmap_request(
|
||||||
|
USING,
|
||||||
|
json!([
|
||||||
|
["Email/set", {"accountId": account_id, "create": {"m": {
|
||||||
|
"mailboxIds": {drafts.as_str().unwrap(): true},
|
||||||
|
"from": [{"email": from}],
|
||||||
|
"to": [{"email": self.name()}],
|
||||||
|
"subject": format!("Sent as {from}"),
|
||||||
|
"bodyValues": {"t": {"value": "MA-D0a"}},
|
||||||
|
"textBody": [{"partId": "t", "type": "text/plain"}]
|
||||||
|
}}}, "e"],
|
||||||
|
["EmailSubmission/set", {"accountId": account_id, "create": {"s": {
|
||||||
|
"identityId": identity, "emailId": "#m"
|
||||||
|
}}}, "s"]
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
response.0.pointer("/methodResponses/1/1/created/s").is_some(),
|
||||||
|
"send as {from}: {}",
|
||||||
|
response.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs these tests alone: `cargo test -p tests audit_log_tests -- --ignored`.
|
/// Runs these tests alone: `cargo test -p tests audit_log_tests -- --ignored`.
|
||||||
#[ignore]
|
#[ignore]
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
|||||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user